M9.1: add Find Edges raster effect

This commit is contained in:
Vikram Bhaskaran
2026-10-10 09:50:05 +07:00
committed by bflatastic
parent d28cbacbf7
commit 2d371849df
5 changed files with 54 additions and 5 deletions
+1
View File
@@ -290,6 +290,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{amount: % 1..500 (50), radius: pt 0.1..250 (1; σ of the blur edges are found against), threshold: levels 0..255 (0; smaller differences are left alone)}",
json!({"amount": 50.0, "radius": 1.0, "threshold": 0.0}),
),
r("stylize.findEdges", "Find Edges", STYLIZE, "{} replaces colour with a Sobel edge map while preserving transparency", json!({})),
];
v.extend([
g(
+30
View File
@@ -0,0 +1,30 @@
//! Stylize › Find Edges.
use super::Px16;
/// Convert premultiplied RGBA pixels to a Sobel edge map in place.
pub(super) fn find(px: &mut [[u8; 4]], w: usize, h: usize) {
if w < 3 || h < 3 || w.checked_mul(h) != Some(px.len()) {
return;
}
let src: Vec<Px16> = px.iter().map(|p| p.map(u16::from)).collect();
let luminance = |x: usize, y: usize| -> f32 {
src.get(y * w + x).map_or(0.0, |p| {
(0.2126 * f32::from(p[0]) + 0.7152 * f32::from(p[1]) + 0.0722 * f32::from(p[2])) / 257.0
})
};
for y in 1..h - 1 {
for x in 1..w - 1 {
let gx = -luminance(x - 1, y - 1) + luminance(x + 1, y - 1)
- 2.0 * luminance(x - 1, y) + 2.0 * luminance(x + 1, y)
- luminance(x - 1, y + 1) + luminance(x + 1, y + 1);
let gy = -luminance(x - 1, y - 1) - 2.0 * luminance(x, y - 1) - luminance(x + 1, y - 1)
+ luminance(x - 1, y + 1) + 2.0 * luminance(x, y + 1) + luminance(x + 1, y + 1);
let edge = gx.hypot(gy).clamp(0.0, 255.0) as u8;
if let Some(out) = px.get_mut(y * w + x) {
let alpha = out[3];
*out = [edge.min(alpha), edge.min(alpha), edge.min(alpha), alpha];
}
}
}
}
+9 -2
View File
@@ -1,5 +1,5 @@
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Sharpen › Unsharp
//! Mask): filters over the pixels of an object rendered alone, premultiplied RGBA8.
//! Mask, and Stylize › Find Edges): filters over premultiplied RGBA8 pixels.
//!
//! - Every distance is in document units (points) and becomes pixels through the raster's
//! [`PixelSpace::px`], so an effect looks the same at any zoom and at any Document Raster Effects
@@ -9,6 +9,7 @@
//! - Beyond the raster's edges is transparency.
mod blur;
mod edges;
mod sharpen;
use serde_json::Value;
@@ -17,7 +18,7 @@ use vectorcraft_geom::{Affine, Point, Rect};
use crate::util::{num, text};
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
pub const PIXEL_EFFECTS: [&str; 3] = ["blur.radial", "blur.smart", "sharpen.unsharpMask"];
pub const PIXEL_EFFECTS: [&str; 4] = ["blur.radial", "blur.smart", "sharpen.unsharpMask", "stylize.findEdges"];
/// A Photoshop-style raster effect with its parameters read and clamped to their ranges. Lengths
/// are in document units.
@@ -33,6 +34,8 @@ pub enum PixelFx {
/// Sharpen › Unsharp Mask: colours pushed away from a Gaussian blur of σ = `radius` by `amount`
/// (1 = 100 %), where they differ from it by at least `threshold` levels.
UnsharpMask { amount: f64, radius: f64, threshold: f64 },
/// Stylize › Find Edges: replace colour with a Sobel edge map while preserving opacity.
FindEdges,
}
/// Where a raster's pixels lie in the document.
@@ -69,6 +72,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
radius: num(p, "radius", 1.0).clamp(0.1, 250.0),
threshold: num(p, "threshold", 0.0).clamp(0.0, 255.0),
},
"stylize.findEdges" => PixelFx::FindEdges,
_ => return None,
})
}
@@ -85,6 +89,7 @@ impl PixelFx {
PixelFx::RadialBlur { amount, zoom: true, .. } => far * (blur::zoom_extent(amount).exp() - 1.0),
PixelFx::SmartBlur { radius, .. } => radius,
PixelFx::UnsharpMask { .. } => 0.0,
PixelFx::FindEdges => 0.0,
}
}
@@ -95,6 +100,7 @@ impl PixelFx {
PixelFx::RadialBlur { .. } => None,
PixelFx::SmartBlur { radius, .. } => Some(radius),
PixelFx::UnsharpMask { radius, .. } => Some(3.0 * radius),
PixelFx::FindEdges => Some(1.0),
}
}
@@ -107,6 +113,7 @@ impl PixelFx {
PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes),
PixelFx::SmartBlur { radius, threshold, samples } => blur::smart(px, w, h, to_px(radius), threshold, samples),
PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold),
PixelFx::FindEdges => edges::find(px, w, h),
}
}
}
+11
View File
@@ -55,6 +55,7 @@ fn params_take_defaults_and_stay_in_range() {
fx("sharpen.unsharpMask", json!({"amount": 1e9, "radius": 0, "threshold": 1e9})),
PixelFx::UnsharpMask { amount: 5.0, radius: 0.1, threshold: 255.0 }
);
assert_eq!(fx("stylize.findEdges", json!({})), PixelFx::FindEdges);
for id in PIXEL_EFFECTS {
assert!(crate::is_raster(id) && crate::effect_info(id).is_some_and(|e| e.raster), "{id}");
}
@@ -120,6 +121,16 @@ fn every_effect_is_deterministic() {
}
}
#[test]
fn find_edges_detects_a_boundary_and_preserves_alpha() {
let (w, h) = (9, 9);
let src = image(w, h, |x, _| if x < 4 { [0, 0, 0, 200] } else { [200, 200, 200, 200] });
let mut out = src.clone();
fx("stylize.findEdges", json!({})).apply(&mut out, w, h, &space(w, h));
assert!(at(&out, w, 3, 4)[0] > 0 || at(&out, w, 4, 4)[0] > 0);
assert!(out.chunks_exact(4).all(|p| p[3] == 200 && p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]));
}
#[test]
fn spin_blur_smears_along_arcs_and_keeps_the_centre() {
let (w, h) = (41, 41);