Filters: Photoshop's Blur More kernel, Find Edges strength and Polar Coordinates direction (#4652)

Measured against Photoshop 27.11 on a test chart: Blur More uses the
[1 2 1; 2 2 2; 1 2 1]/14 kernel, Find Edges the full Sobel magnitude, and
Polar Coordinates turns counter-clockwise from 12 o'clock with repeated
edges and Photoshop's sampling grid. All four now match within 2 levels.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MrCloudy2 2026-10-13 18:33:15 +02:00 committed by GitHub
parent cdfec72f71
commit 5f2c23e142
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 53 additions and 11 deletions

View File

@ -160,19 +160,30 @@ pub(crate) fn polar(src: &Image, out: Rect, ctx: &Ctx, mode: PolarMode) -> Vec<f
let (w, h) = (b.width().max(1) as f32, b.height().max(1) as f32);
let (cx, cy) = (b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0);
let rmax = w.min(h) / 2.0;
remap(src, out, ctx, Edge::Transparent, |x, y| match mode {
// Photoshop 27.11: the angle runs counter-clockwise from 12 o'clock, and points past the
// source's edge repeat its edge pixels (the corners outside the circle are filled, not empty).
// Photoshop evaluates the map at pixel corners and reads the source at the resulting index
// (half a pixel from `remap`'s centres): within 2 levels on a whole test chart in both directions.
remap(src, out, ctx, Edge::Repeat, |x, y| polar_map(x - 0.5, y - 0.5, mode, b, (w, h), (cx, cy), rmax))
}
fn polar_map(x: f32, y: f32, mode: PolarMode, b: Rect, (w, h): (f32, f32), (cx, cy): (f32, f32), rmax: f32) -> (f32, f32) {
let (sx, sy) = match mode {
PolarMode::RectangularToPolar => {
// Output is polar: angle from 12 o'clock clockwise → source x,
// radius → source y.
// Output is polar: angle from 12 o'clock counter-clockwise → source x, radius → source y.
let (dx, dy) = (x - cx, y - cy);
let theta = dx.atan2(-dy).rem_euclid(std::f32::consts::TAU);
// The 12 o'clock seam itself reads the source's right edge (360°, not 0°).
let theta = (-dx).atan2(-dy);
let theta = if theta <= 0.0 { theta + std::f32::consts::TAU } else { theta };
let r = (dx * dx + dy * dy).sqrt();
(b.x0 as f32 + theta / std::f32::consts::TAU * w, b.y0 as f32 + r / rmax * h)
}
PolarMode::PolarToRectangular => {
let theta = (x - b.x0 as f32) / w * std::f32::consts::TAU;
// Photoshop's angle is one column ahead of its radius row (fitted exactly).
let theta = (x + 1.0 - b.x0 as f32) / w * std::f32::consts::TAU;
let r = (y - b.y0 as f32) / h * rmax;
(cx + r * theta.sin(), cy - r * theta.cos())
(cx - r * theta.sin(), cy - r * theta.cos())
}
})
};
(sx + 0.5, sy + 0.5)
}

View File

@ -178,8 +178,9 @@ pub(crate) fn emboss(src: &Image, out: Rect, ctx: &Ctx, angle: f32, height: f32,
res
}
/// Find Edges: Sobel magnitude per colour channel, dark edges on white
/// (CMYK samples are ink, so there the edge strength is the ink itself).
/// Find Edges: Sobel magnitude per colour channel, dark edges on white (CMYK samples are ink, so
/// there the edge strength is the ink itself). At full strength, as Photoshop's: a 60-level step
/// darkens by 240 (Photoshop 27.11; a quarter of that was 4× too pale).
pub(crate) fn find_edges(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let n = src.ch;
let cc = if ctx.alpha { n - 1 } else { n };
@ -192,7 +193,7 @@ pub(crate) fn find_edges(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
let g = |dx: i32, dy: i32| src.get(x + dx, y + dy, c);
let gx = (g(1, -1) + 2.0 * g(1, 0) + g(1, 1)) - (g(-1, -1) + 2.0 * g(-1, 0) + g(-1, 1));
let gy = (g(-1, 1) + 2.0 * g(0, 1) + g(1, 1)) - (g(-1, -1) + 2.0 * g(0, -1) + g(1, -1));
let e = ((gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 1.0);
let e = (gx * gx + gy * gy).sqrt().clamp(0.0, 1.0);
*v = if sub { e } else { 1.0 - e };
}
}

View File

@ -488,6 +488,21 @@ fn layer_filters_repeat_the_canvas_edge() {
}
}
/// Photoshop 27.11's Polar Coordinates turn counter-clockwise from 12 o'clock: just left of the
/// top centre, Rectangular to Polar samples the source's left edge (clockwise would take its right).
#[test]
fn polar_coordinates_turn_counter_clockwise() {
let r = Rect::new(0, 0, 64, 64);
let mut s = flat(SampleType::U8, r, [0.0, 0.0, 1.0, 1.0]);
s.fill_rect(Rect::new(0, 0, 32, 64), &[1.0, 0.0, 0.0, 1.0]);
let p = FilterParams::PolarCoordinates { mode: PolarMode::RectangularToPolar };
let out = apply(&s, &p, r, r, None);
let left_of_top = out.rgba(28, 12);
assert!(left_of_top[0] > 0.9 && left_of_top[2] < 0.1, "{left_of_top:?}");
// The corners outside the circle repeat the source's edge instead of going transparent.
assert!(out.rgba(0, 0)[3] > 0.99, "{:?}", out.rgba(0, 0));
}
/// `mix_selection` before #4533, one selection lookup per pixel: the oracle for the per-tile read.
fn mix_selection_reference(data: &mut [f32], t: Rect, sel: &Surface, orig: &crate::image::Image, alpha: bool) {
let w = t.width() as usize;

View File

@ -52,7 +52,22 @@ fn preserve(p: &Value) -> Preserve {
fn preset(id: &str) -> Option<(&'static str, FilterParams)> {
Some(match id {
"filter.blur.blur" => ("Blur", FilterParams::GaussianBlur { radius: 0.6 }),
"filter.blur.blurMore" => ("Blur More", FilterParams::GaussianBlur { radius: 1.4 }),
// Photoshop 27.11's Blur More is the 3×3 kernel [1 2 1; 2 2 2; 1 2 1] / 14 (its response
// to a single white pixel, exactly).
"filter.blur.blurMore" => (
"Blur More",
FilterParams::Custom {
kernel: vec![
0.0, 0.0, 0.0, 0.0, 0.0, //
0.0, 1.0, 2.0, 1.0, 0.0, //
0.0, 2.0, 2.0, 2.0, 0.0, //
0.0, 1.0, 2.0, 1.0, 0.0, //
0.0, 0.0, 0.0, 0.0, 0.0,
],
scale: 14.0,
offset: 0.0,
},
),
"filter.sharpen.sharpen" => ("Sharpen", FilterParams::UnsharpMask { amount: 60.0, radius: 0.5, threshold: 0.0 }),
"filter.sharpen.sharpenMore" => ("Sharpen More", FilterParams::UnsharpMask { amount: 150.0, radius: 0.6, threshold: 0.0 }),
"filter.sharpen.sharpenEdges" => ("Sharpen Edges", FilterParams::UnsharpMask { amount: 100.0, radius: 0.8, threshold: 6.0 }),