Filter › Other › Minimum / Maximum: Preserve Roundness (#27)

Photoshop's Minimum and Maximum have Preserve: Squareness | Roundness;
only the square element existed. Roundness takes the extreme over a
disc (dx² + dy² ≤ r²), one van Herk / Gil-Werman running min/max per
disc row, so cost stays O(r) per pixel. filter.other.minimum/maximum
take "preserve" (default squareness; older params deserialize as
Squareness).

Co-authored-by: storybored379 <storybored379@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
storybored379 2026-10-04 07:13:23 -07:00 committed by GitHub
parent 6bdee62e87
commit 7ec0b4aa62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 337 additions and 16 deletions

View File

@ -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<f
FilterParams::AddNoise { amount, distribution, monochromatic, seed } => 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),

View File

@ -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<f32> {
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<f32> {
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<f32>) {
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<f32> {

View File

@ -45,8 +45,10 @@ fn all_filters() -> Vec<FilterParams> {
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<f32> {
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<f32> = 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);

View File

@ -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<FilterParams> {
@ -71,8 +80,8 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
},
"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<CommandSpec> {
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};