diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 5d0ec6fa..bb3a428c 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -117,6 +117,17 @@ pub enum UndefinedAreas { Transparent, } +/// Minimum / Maximum › Preserve: the shape the filter grows or shrinks by. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum Preserve { + /// A square of `2·radius + 1` pixels (keeps corners square). + #[default] + Squareness, + /// A disc of `radius` pixels (rounds corners off evenly). + Roundness, +} + /// Spherize mode. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -179,8 +190,17 @@ pub enum FilterParams { AddNoise { amount: f32, distribution: Distribution, monochromatic: bool, seed: u32 }, Median { radius: f32 }, DustAndScratches { radius: f32, threshold: f32 }, - Minimum { radius: f32 }, - Maximum { radius: f32 }, + /// Radius px; `preserve` is the dialog's Preserve (older params default to Squareness). + Minimum { + radius: f32, + #[serde(default)] + preserve: Preserve, + }, + Maximum { + radius: f32, + #[serde(default)] + preserve: Preserve, + }, Offset { horizontal: i32, vertical: i32, undefined: UndefinedAreas }, Mosaic { cell_size: f32 }, /// Angle degrees, height px, amount %. @@ -319,8 +339,8 @@ impl FilterParams { | FilterParams::SurfaceBlur { radius, .. } | FilterParams::Median { radius } | FilterParams::DustAndScratches { radius, .. } - | FilterParams::Minimum { radius } - | FilterParams::Maximum { radius } => Halo::Radius(radius.max(0.0).ceil() as i32 + 1), + | FilterParams::Minimum { radius, .. } + | FilterParams::Maximum { radius, .. } => Halo::Radius(radius.max(0.0).ceil() as i32 + 1), FilterParams::MotionBlur { distance, .. } => Halo::Radius((distance.abs() / 2.0).ceil() as i32 + 2), FilterParams::Mosaic { cell_size } => Halo::Radius(cell_size.max(1.0).ceil() as i32 + 1), FilterParams::Emboss { height, .. } => Halo::Radius(height.abs().ceil() as i32 + 2), @@ -474,8 +494,8 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec noise::add(src, out, ctx, *amount, *distribution, *monochromatic, *seed), FilterParams::Median { radius } => noise::median(src, out, *radius, None), FilterParams::DustAndScratches { radius, threshold } => noise::median(src, out, *radius, Some(*threshold)), - FilterParams::Minimum { radius } => other::min_max(src, out, *radius, false), - FilterParams::Maximum { radius } => other::min_max(src, out, *radius, true), + FilterParams::Minimum { radius, preserve } => other::min_max_preserve(src, out, *radius, false, *preserve), + FilterParams::Maximum { radius, preserve } => other::min_max_preserve(src, out, *radius, true, *preserve), FilterParams::Offset { horizontal, vertical, undefined } => other::offset(src, out, ctx, *horizontal, *vertical, *undefined), FilterParams::Mosaic { cell_size } => stylize::mosaic(src, out, ctx, *cell_size), FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount), diff --git a/crates/algo/src/other.rs b/crates/algo/src/other.rs index aff546cc..dd34e17a 100644 --- a/crates/algo/src/other.rs +++ b/crates/algo/src/other.rs @@ -3,7 +3,84 @@ use photocraft_geom::Rect; use crate::image::{Edge, Image}; -use crate::{Ctx, UndefinedAreas}; +use crate::{Ctx, Preserve, UndefinedAreas}; + +/// Minimum or Maximum with the dialog's Preserve option: a square (`min_max`) or a disc +/// (`min_max_round`). +pub(crate) fn min_max_preserve( + src: &Image, + out: Rect, + radius: f32, + max: bool, + preserve: Preserve, +) -> Vec { + match preserve { + Preserve::Squareness => min_max(src, out, radius, max), + Preserve::Roundness => min_max_round(src, out, radius, max), + } +} + +/// Minimum or Maximum over a disc (Preserve: Roundness): every offset with +/// `dx² + dy² ≤ radius²`, so corners and straight edges move by the same distance. Each row of +/// the disc is a horizontal window, taken with the van Herk / Gil-Werman running extreme +/// (constant work per pixel whatever the window), so the cost is O(radius) per pixel. +pub(crate) fn min_max_round(src: &Image, out: Rect, radius: f32, max: bool) -> Vec { + let n = src.ch; + let r = radius.max(0.0); + let ri = r.floor() as i32; + if ri == 0 { + return src.crop(out); + } + let w = out.width() as usize; + let pick = |a: f32, b: f32| if max { a.max(b) } else { a.min(b) }; + let mut res = vec![if max { f32::MIN } else { f32::MAX }; w * out.height() as usize * n]; + let (mut row, mut ext) = (Vec::new(), Vec::new()); + for dy in -ri..=ri { + // Half-width of the disc on this row (the epsilon keeps exact squares, e.g. r 5, dy 3 → 4). + let hw = ((r * r - (dy * dy) as f32).max(0.0).sqrt() + 1e-4).floor() as i32; + for y in out.y0..out.y1 { + let o = (y - out.y0) as usize * w * n; + for c in 0..n { + row.clear(); + row.extend((out.x0 - hw..out.x1 + hw).map(|x| src.get(x, y + dy, c))); + running_extreme(&row, (2 * hw + 1) as usize, max, &mut ext); + for (x, v) in ext.iter().enumerate() { + let i = o + x * n + c; + res[i] = pick(res[i], *v); + } + } + } + } + res +} + +/// `out[i]` = extreme of `v[i..i + k]` for every full window (van Herk / Gil-Werman: prefix +/// extremes within blocks of `k` plus suffix extremes within blocks, one lookup of each). +pub(crate) fn running_extreme(v: &[f32], k: usize, max: bool, out: &mut Vec) { + out.clear(); + let n = v.len(); + if k <= 1 { + out.extend_from_slice(v); + return; + } + if n < k { + return; + } + let pick = |a: f32, b: f32| if max { a.max(b) } else { a.min(b) }; + let mut pre = v.to_vec(); + let mut suf = v.to_vec(); + for i in 1..n { + if i % k != 0 { + pre[i] = pick(pre[i - 1], v[i]); + } + } + for i in (0..n - 1).rev() { + if (i + 1) % k != 0 { + suf[i] = pick(suf[i + 1], v[i]); + } + } + out.extend((0..=n - k).map(|i| pick(suf[i], pre[i + k - 1]))); +} /// Minimum (spreads dark/transparent areas) or Maximum over a square. pub(crate) fn min_max(src: &Image, out: Rect, radius: f32, max: bool) -> Vec { diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 7a887157..af4ff1c7 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -45,8 +45,10 @@ fn all_filters() -> Vec { FilterParams::AddNoise { amount: 25.0, distribution: Distribution::Gaussian, monochromatic: false, seed: 3 }, FilterParams::Median { radius: 2.0 }, FilterParams::DustAndScratches { radius: 2.0, threshold: 10.0 }, - FilterParams::Minimum { radius: 1.0 }, - FilterParams::Maximum { radius: 2.0 }, + FilterParams::Minimum { radius: 1.0, preserve: Preserve::Squareness }, + FilterParams::Maximum { radius: 2.0, preserve: Preserve::Squareness }, + FilterParams::Minimum { radius: 2.5, preserve: Preserve::Roundness }, + FilterParams::Maximum { radius: 3.0, preserve: Preserve::Roundness }, FilterParams::Offset { horizontal: 7, vertical: -3, undefined: UndefinedAreas::Wrap }, FilterParams::Mosaic { cell_size: 6.0 }, FilterParams::Emboss { angle: 135.0, height: 2.0, amount: 100.0 }, @@ -224,13 +226,158 @@ fn median_and_dust_remove_a_speck() { fn minimum_maximum_spread() { let mut s = flat(SampleType::F32, R, [0.5, 0.5, 0.5, 1.0]); s.write_pixel(10, 10, &[1.0, 1.0, 1.0, 1.0]); - let mx = run(&s, &FilterParams::Maximum { radius: 1.0 }); + let mx = run(&s, &FilterParams::Maximum { radius: 1.0, preserve: Preserve::Squareness }); assert!((mx.pixel(11, 11)[0] - 1.0).abs() < 1e-6); assert!((mx.pixel(12, 10)[0] - 0.5).abs() < 1e-6); - let mn = run(&s, &FilterParams::Minimum { radius: 1.0 }); + let mn = run(&s, &FilterParams::Minimum { radius: 1.0, preserve: Preserve::Squareness }); assert!((mn.pixel(10, 10)[0] - 0.5).abs() < 1e-6); } +#[test] +fn maximum_roundness_grows_a_point_into_a_disc() { + // One white pixel on black: Maximum spreads it over the structuring element itself. + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut s = flat(depth, R, [0.0, 0.0, 0.0, 1.0]); + s.write_pixel(20, 15, &[1.0, 1.0, 1.0, 1.0]); + for radius in [1.0f32, 2.0, 3.5, 6.0] { + let round = run( + &s, + &FilterParams::Maximum { + radius, + preserve: Preserve::Roundness, + }, + ); + let square = run( + &s, + &FilterParams::Maximum { + radius, + preserve: Preserve::Squareness, + }, + ); + let rs = radius.round() as i32; + for y in R.y0..R.y1 { + for x in R.x0..R.x1 { + let (dx, dy) = (x - 20, y - 15); + let in_disc = (dx * dx + dy * dy) as f32 <= radius * radius; + assert_eq!( + round.pixel(x, y)[0] > 0.5, + in_disc, + "{depth:?} r {radius} at {dx},{dy}" + ); + assert_eq!( + square.pixel(x, y)[0] > 0.5, + dx.abs() <= rs && dy.abs() <= rs, + "{depth:?} r {radius} at {dx},{dy}" + ); + } + } + } + } + // Radius 1: the four neighbours, not the diagonals (a 3×3 square would take them). + let mut s = flat(SampleType::U8, R, [0.0, 0.0, 0.0, 1.0]); + s.write_pixel(20, 15, &[1.0, 1.0, 1.0, 1.0]); + let r1 = run( + &s, + &FilterParams::Maximum { + radius: 1.0, + preserve: Preserve::Roundness, + }, + ); + assert_eq!(r1.pixel(21, 15)[0], 1.0); + assert_eq!(r1.pixel(21, 16)[0], 0.0); +} + +#[test] +fn minimum_maximum_roundness_match_brute_force() { + // Exhaustive disc search on a textured image, both operators, a few radii (incl. fractional). + let s = pattern(SampleType::F32, R); + let img = s.read_region(R); + let at = |x: i32, y: i32, c: usize| -> Option { + R.contains(x, y) + .then(|| img[((y * 40 + x) * 4) as usize + c]) + }; + for radius in [1.0f32, 1.5, 2.9, 4.0] { + let ri = radius.floor() as i32; + for max in [false, true] { + let p = if max { + FilterParams::Maximum { + radius, + preserve: Preserve::Roundness, + } + } else { + FilterParams::Minimum { + radius, + preserve: Preserve::Roundness, + } + }; + let got = run(&s, &p); + for y in R.y0 + ri..R.y1 - ri { + for x in R.x0 + ri..R.x1 - ri { + for c in 0..3 { + let mut want = if max { f32::MIN } else { f32::MAX }; + for dy in -ri..=ri { + for dx in -ri..=ri { + if (dx * dx + dy * dy) as f32 <= radius * radius + && let Some(v) = at(x + dx, y + dy, c) + { + want = if max { want.max(v) } else { want.min(v) }; + } + } + } + assert!( + (got.pixel(x, y)[c] - want).abs() < 1e-6, + "r {radius} max {max} at {x},{y} c{c}" + ); + } + } + } + } + } +} + +#[test] +fn running_extreme_windows() { + let v = [3.0, 1.0, 4.0, 1.0, 5.0, 9.0, 2.0, 6.0]; + let mut out = Vec::new(); + for k in 1..=v.len() { + for max in [false, true] { + crate::other::running_extreme(&v, k, max, &mut out); + let want: Vec = v + .windows(k) + .map(|w| { + w.iter() + .copied() + .fold(if max { f32::MIN } else { f32::MAX }, |a, b| { + if max { a.max(b) } else { a.min(b) } + }) + }) + .collect(); + assert_eq!(out, want, "k {k} max {max}"); + } + } +} + +#[test] +fn minimum_maximum_old_params_default_to_squareness() { + let p: FilterParams = serde_json::from_str(r#"{"filter":"minimum","radius":3}"#).unwrap(); + assert_eq!( + p, + FilterParams::Minimum { + radius: 3.0, + preserve: Preserve::Squareness + } + ); + let p: FilterParams = + serde_json::from_str(r#"{"filter":"maximum","radius":2,"preserve":"roundness"}"#).unwrap(); + assert_eq!( + p, + FilterParams::Maximum { + radius: 2.0, + preserve: Preserve::Roundness + } + ); +} + #[test] fn offset_wrap_repeat_transparent() { let s = pattern(SampleType::U8, R); diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index 34f2c981..f2f3b06d 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -4,7 +4,7 @@ //! smart object, recording a smart filter), respects the selection, and is //! undoable. `filter.lastFilter` re-runs the most recent filter command. -use photocraft_algo::{self as algo, Distribution, FilterParams, PolarMode, RadialMethod, RippleSize, SpherizeMode, UndefinedAreas, WaveType}; +use photocraft_algo::{self as algo, Distribution, FilterParams, PolarMode, Preserve, RadialMethod, RippleSize, SpherizeMode, UndefinedAreas, WaveType}; use photocraft_doc::{LayerContent, SmartFilter}; use serde_json::{Value, json}; @@ -31,6 +31,15 @@ fn undefined(p: &Value) -> UndefinedAreas { } } +/// Minimum / Maximum › Preserve (Photoshop's default is Squareness). +fn preserve(p: &Value) -> Preserve { + if s(p, "preserve", "squareness") == "roundness" { + Preserve::Roundness + } else { + Preserve::Squareness + } +} + /// Builds the algorithm parameters for a filter command id from JSON params /// (Photoshop dialog units). pub fn params_for(id: &str, p: &Value) -> Option { @@ -71,8 +80,8 @@ pub fn params_for(id: &str, p: &Value) -> Option { }, "filter.noise.median" => FilterParams::Median { radius: f(p, "radius", 1.0).clamp(1.0, 500.0) }, "filter.noise.dustAndScratches" => FilterParams::DustAndScratches { radius: f(p, "radius", 1.0).clamp(1.0, 500.0), threshold: f(p, "threshold", 0.0).clamp(0.0, 255.0) }, - "filter.other.minimum" => FilterParams::Minimum { radius: f(p, "radius", 1.0).clamp(0.2, 500.0) }, - "filter.other.maximum" => FilterParams::Maximum { radius: f(p, "radius", 1.0).clamp(0.2, 500.0) }, + "filter.other.minimum" => FilterParams::Minimum { radius: f(p, "radius", 1.0).clamp(0.2, 500.0), preserve: preserve(p) }, + "filter.other.maximum" => FilterParams::Maximum { radius: f(p, "radius", 1.0).clamp(0.2, 500.0), preserve: preserve(p) }, "filter.other.offset" => FilterParams::Offset { horizontal: i(p, "horizontal", 0), vertical: i(p, "vertical", 0), undefined: undefined(p) }, "filter.pixelate.mosaic" => FilterParams::Mosaic { cell_size: f(p, "cellSize", 10.0).clamp(2.0, 200.0) }, "filter.stylize.emboss" => FilterParams::Emboss { angle: f(p, "angle", 135.0), height: f(p, "height", 3.0).clamp(1.0, 100.0), amount: f(p, "amount", 100.0).clamp(1.0, 500.0) }, @@ -253,8 +262,8 @@ pub fn specs() -> Vec { filter_cmd!("filter.distort.ripple", "Ripple…", ["Filter", "Distort"], r##"{"amount":-999..999=100,"size":"small|medium|large"}"##), filter_cmd!("filter.distort.polarCoordinates", "Polar Coordinates…", ["Filter", "Distort"], r##"{"mode":"rectangularToPolar|polarToRectangular"}"##), filter_cmd!("filter.other.highPass", "High Pass…", ["Filter", "Other"], r##"{"radius":0.1..1000=10}"##), - filter_cmd!("filter.other.minimum", "Minimum…", ["Filter", "Other"], r##"{"radius":0.2..500=1}"##), - filter_cmd!("filter.other.maximum", "Maximum…", ["Filter", "Other"], r##"{"radius":0.2..500=1}"##), + filter_cmd!("filter.other.minimum", "Minimum…", ["Filter", "Other"], r##"{"radius":0.2..500=1,"preserve":"squareness|roundness"}"##), + filter_cmd!("filter.other.maximum", "Maximum…", ["Filter", "Other"], r##"{"radius":0.2..500=1,"preserve":"squareness|roundness"}"##), filter_cmd!("filter.other.offset", "Offset…", ["Filter", "Other"], r##"{"horizontal":px=0,"vertical":px=0,"undefinedAreas":"wrap|repeat|transparent"}"##), CommandSpec { id: "filter.lastFilter", @@ -426,6 +435,74 @@ mod tests { assert!(params_for("filter.nope", &json!({})).is_none()); } + #[test] + fn minimum_maximum_preserve_roundness() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute( + "file.new", + json!({"width": 48, "height": 32, "depth": depth}), + ) + .unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + // A white dot on black. + s.edit("dot", |doc, active| { + let surf = doc + .layer_mut(active.unwrap()) + .unwrap() + .surface_mut() + .unwrap(); + surf.fill_rect( + photocraft_geom::Rect::new(0, 0, 48, 32), + &[0.0, 0.0, 0.0, 1.0], + ); + surf.write_pixel(24, 16, &[1.0, 1.0, 1.0, 1.0]); + Ok(()) + }) + .unwrap(); + let before = active_pixels(&s); + let lit = |s: &Session, x: usize, y: usize| active_pixels(s)[(y * 48 + x) * 4] > 0.5; + s.execute( + "filter.other.maximum", + json!({"radius": 4, "preserve": "roundness"}), + ) + .unwrap(); + // Grown into a disc of radius 4: (4, 0) and (2, 3) are in, the square's corner isn't. + assert!(lit(&s, 28, 16) && lit(&s, 26, 19), "depth {depth}"); + assert!(!lit(&s, 28, 20) && !lit(&s, 27, 19), "depth {depth}"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(active_pixels(&s), before); + // The default is Squareness: the corner is taken. + s.execute("filter.other.maximum", json!({"radius": 4})) + .unwrap(); + assert!(lit(&s, 28, 20), "depth {depth}"); + // Minimum with roundness: only the centre of that 9×9 square holds a whole disc. + s.execute( + "filter.other.minimum", + json!({"radius": 4, "preserve": "roundness"}), + ) + .unwrap(); + assert!(!lit(&s, 20, 12) && lit(&s, 24, 16), "depth {depth}"); + } + assert_eq!( + params_for( + "filter.other.minimum", + &json!({"radius": 2, "preserve": "roundness"}) + ), + Some(FilterParams::Minimum { + radius: 2.0, + preserve: Preserve::Roundness + }) + ); + assert_eq!( + params_for("filter.other.maximum", &json!({})), + Some(FilterParams::Maximum { + radius: 1.0, + preserve: Preserve::Squareness + }) + ); + } + #[test] fn smart_object_records_smart_filter() { use photocraft_doc::{Layer, SmartObject, SmartSource};