Find Edges and Solarize: treat CMYK values as ink (#4222) (#4223)

CMYK samples are ink (1 = full ink), but both filters assumed light
polarity. Find Edges wrote 1 - edge, so flat CMYK areas became 100% ink
(black) instead of white; Solarize inverted channels above 50%, i.e. the
shadows. Both now use fxutil::subtractive(ctx), like Trace Contour: Find
Edges writes the edge strength as ink, Solarize inverts channels below
50% ink. Alpha stays untouched. RGB, Grayscale, Lab and other modes are
unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
David MannandClaude Opus 5.5 authored and GitHub committed 2026-10-12 21:17:54 -04:00
1 parent 468d1c622a
commit 95663451c0
3 files changed
+41 -3

No files matched your search

+1 -1
View File
@@ -740,7 +740,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
FilterParams::Mosaic { cell_size } => stylize::mosaic(src, out, ctx, *cell_size),
FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount),
FilterParams::FindEdges => stylize::find_edges(src, out, ctx),
FilterParams::Solarize => stylize::per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v }),
FilterParams::Solarize => stylize::solarize(src, out, ctx),
FilterParams::Invert => stylize::per_pixel(src, out, ctx, |v| 1.0 - v),
FilterParams::Desaturate => stylize::desaturate(src, out, ctx),
FilterParams::Twirl { angle } => distort::twirl(src, out, ctx, *angle),
+16 -2
View File
@@ -5,6 +5,7 @@ use photocraft_geom::Rect;
use photocraft_raster::{from_rgba, to_rgba};
use crate::Ctx;
use crate::fxutil::subtractive;
use crate::image::Image;
fn fmt(ctx: &Ctx) -> PixelFormat {
@@ -23,6 +24,16 @@ fn set_gray(ctx: &Ctx, px: &mut [f32], g: f32) {
px.copy_from_slice(&v[..px.len()]);
}
/// Solarize: inverts the highlights. CMYK samples are ink, so there the
/// channels below 50% ink (the light ones) invert.
pub(crate) fn solarize(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
if subtractive(ctx) {
per_pixel(src, out, ctx, |v| if v < 0.5 { 1.0 - v } else { v })
} else {
per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v })
}
}
/// Colour channels through `f` (alpha untouched).
pub(crate) fn per_pixel(src: &Image, out: Rect, ctx: &Ctx, f: impl Fn(f32) -> f32) -> Vec<f32> {
let n = src.ch;
@@ -167,10 +178,12 @@ 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.
/// Find Edges: Sobel magnitude per colour channel, dark edges on white
/// (CMYK samples are ink, so there the edge strength is the ink itself).
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 };
let sub = subtractive(ctx);
let mut res = src.crop(out);
let w = out.width() as usize;
for (i, px) in res.chunks_exact_mut(n).enumerate() {
@@ -179,7 +192,8 @@ 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));
*v = (1.0 - (gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 1.0);
let e = ((gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 1.0);
*v = if sub { e } else { 1.0 - e };
}
}
res
+24
View File
@@ -367,6 +367,30 @@ fn stylize_basics() {
assert!((d[0] - d[1]).abs() < 1e-6 && (d[1] - d[2]).abs() < 1e-6);
}
/// CMYK samples are ink (1 = full ink): Find Edges leaves flat areas white (no ink, not 100% ink)
/// and Solarize inverts the channels below 50% ink, as Photoshop does (#4222). Alpha is untouched.
#[test]
fn find_edges_and_solarize_treat_cmyk_as_ink() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let f = PixelFormat::new(ColorMode::Cmyk, depth, true);
let mut s = Surface::new(f);
s.fill_rect(R, &[0.1, 0.1, 0.1, 0.1, 1.0]);
let e = run(&s, &FilterParams::FindEdges).pixel(20, 15);
assert!(e[..4].iter().all(|v| v.abs() < 1e-6) && (e[4] - 1.0).abs() < 1e-6, "{depth:?} flat CMYK -> white: {e:?}");
let mut s = Surface::new(f);
s.fill_rect(R, &[0.1, 0.2, 0.8, 0.9, 0.6]);
let so = run(&s, &FilterParams::Solarize).pixel(3, 3);
let want = [0.9, 0.8, 0.8, 0.9, 0.6];
assert!(so.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{depth:?} solarize CMYK: {so:?}");
}
// RGB keeps the light-polarity behaviour: flat -> white, channels above 50% invert.
let s = flat(SampleType::U8, R, [0.1, 0.2, 0.8, 1.0]);
let e = run(&s, &FilterParams::FindEdges).pixel(20, 15);
assert!(e[..3].iter().all(|v| (v - 1.0).abs() < 1e-6), "{e:?}");
let so = run(&s, &FilterParams::Solarize).pixel(3, 3);
assert!(so.iter().zip([0.1, 0.2, 0.2, 1.0]).all(|(a, b)| (a - b).abs() < 0.01), "{so:?}");
}
#[test]
fn distortions_are_identity_at_zero() {
let s = pattern(SampleType::U8, R);