mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-09 20:04:14 +00:00
refactor(selection): take the feather reach from the blur (#9802)
The feather's ⌈3σ⌉ truncation radius was written out in the blur and again in two engine callers: the windowed marquee and lasso feather (#9794) and the canvas-edge padding of Select › Modify › Feather. selection::feather_reach returns it from the blur's own σ rules, and both callers use it, so a change to the blur's reach can't leave them behind. Output is unchanged; a test pins that the feather leaves everything past its reach at zero.
This commit is contained in:
1 parent
805ed134e4
commit
7ac7b8b106
4 files changed
+47
-13
No files matched your search
@@ -806,6 +806,13 @@ pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
|
||||
crate::selection_blur::feather(m, w, h, radius)
|
||||
}
|
||||
|
||||
/// How far [`feather`] spreads, in pixels (`⌈3σ⌉` with σ = `radius`, 0 when it changes nothing).
|
||||
/// Samples farther than this from every nonzero sample stay 0, so a working window grown by this
|
||||
/// much gives the same result as the whole canvas.
|
||||
pub fn feather_reach(radius: f32) -> usize {
|
||||
crate::selection_blur::feather_reach(radius)
|
||||
}
|
||||
|
||||
/// Anti-aliased polygon (even-odd) coverage over `area`: exact horizontal
|
||||
/// coverage, 4 sub-rows per pixel row (or 1 when not anti-aliased). Bands of
|
||||
/// rows are filled in parallel, and each sub-row tests only the edges that
|
||||
|
||||
@@ -674,3 +674,16 @@ fn polygon_window_keeps_full_canvas_fractional_coverage() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feather_leaves_everything_beyond_its_reach_at_zero() {
|
||||
for radius in [0.05, 0.5, 2.5, 7.0, 40.0] {
|
||||
let reach = feather_reach(radius);
|
||||
let w = 3 * reach + 20;
|
||||
let mut m = vec![0.0; w];
|
||||
m[0] = 1.0;
|
||||
let out = feather(&m, w, 1, radius);
|
||||
assert!(out[0] > 0.0, "radius {radius}");
|
||||
assert!(out[reach + 1..].iter().all(|v| *v == 0.0), "radius {radius}: nonzero past reach {reach}");
|
||||
}
|
||||
}
|
||||
@@ -542,13 +542,9 @@ impl CosKernel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Select › Modify › Feather: Gaussian blur of the mask with `σ = radius`, the kernel truncated
|
||||
/// at `⌈3σ⌉` and normalized, zero beyond the canvas. Photoshop's feather radius is the Gaussian's
|
||||
/// σ (measured on Photoshop 27.11; the same mapping the mask feather uses, fitted on the
|
||||
/// psd-tools corpus in `photocraft_compose::masks::feather_sigma`). It was `radius / 2`: half
|
||||
/// as wide as Photoshop's.
|
||||
pub(crate) fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
|
||||
let n = w.saturating_mul(h);
|
||||
/// The feather's Gaussian σ for `radius`, capped at [`MAX_SIGMA`]. Below 0.1 the feather leaves
|
||||
/// the mask as it is.
|
||||
fn feather_sigma(radius: f32) -> f32 {
|
||||
let sigma = if radius.is_finite() {
|
||||
radius.max(0.0)
|
||||
} else if radius > 0.0 {
|
||||
@@ -556,11 +552,28 @@ pub(crate) fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
sigma.min(MAX_SIGMA)
|
||||
}
|
||||
|
||||
/// How far [`feather`] spreads, in pixels: its kernel's truncation radius `⌈3σ⌉`, or 0 when it
|
||||
/// leaves the mask as it is. Samples farther than this from every nonzero sample stay 0.
|
||||
pub(crate) fn feather_reach(radius: f32) -> usize {
|
||||
let sigma = feather_sigma(radius);
|
||||
if sigma < 0.1 { 0 } else { (sigma * 3.0).ceil() as usize }
|
||||
}
|
||||
|
||||
/// Select › Modify › Feather: Gaussian blur of the mask with `σ = radius`, the kernel truncated
|
||||
/// at `⌈3σ⌉` and normalized, zero beyond the canvas. Photoshop's feather radius is the Gaussian's
|
||||
/// σ (measured on Photoshop 27.11; the same mapping the mask feather uses, fitted on the
|
||||
/// psd-tools corpus in `photocraft_compose::masks::feather_sigma`). It was `radius / 2`: half
|
||||
/// as wide as Photoshop's.
|
||||
pub(crate) fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
|
||||
let n = w.saturating_mul(h);
|
||||
let sigma = feather_sigma(radius);
|
||||
if sigma < 0.1 || m.len() != n || n == 0 {
|
||||
return m.to_vec();
|
||||
}
|
||||
let sigma = sigma.min(MAX_SIGMA);
|
||||
let r = (sigma * 3.0).ceil() as usize;
|
||||
let r = feather_reach(radius);
|
||||
let Some(b) = nonzero_bounds(m, w, h) else {
|
||||
return m.to_vec();
|
||||
};
|
||||
|
||||
@@ -464,11 +464,11 @@ fn modify(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
let area = if matches!(op, "expand" | "contract" | "border") { modify_window::area(doc, r) } else { canvas };
|
||||
let m = sel::mask_from_surface(doc.selection.as_ref(), area);
|
||||
let (w, h) = (area.width() as usize, area.height() as usize);
|
||||
// Feather reaches 3σ = 3 r (σ = r, as in Photoshop); the others reach r. A margin past that
|
||||
// Feather reaches ⌈3σ⌉ (σ = r, as in Photoshop); the others reach r. A margin past that
|
||||
// keeps the canvas edge out of reach. A selection clear of the canvas edge needs none: both
|
||||
// readings agree there.
|
||||
let reach = if op == "feather" { r * 3.0 } else { r };
|
||||
let pad = if touches_edge(&m, w, h) { reach.ceil() as usize + 2 } else { 0 };
|
||||
let reach = if op == "feather" { sel::feather_reach(r) } else { r.ceil() as usize };
|
||||
let pad = if touches_edge(&m, w, h) { reach.saturating_add(2) } else { 0 };
|
||||
let grown = |n: usize| pad.checked_mul(2).and_then(|p| n.checked_add(p));
|
||||
let (Some(pw), Some(ph)) = (grown(w), grown(h)) else {
|
||||
return Err(EngineError::BadParams { cmd: format!("select.modify.{op}"), msg: "the selection is too large to modify".into() });
|
||||
@@ -675,7 +675,8 @@ pub(crate) fn feather_window(bounds: Rect, canvas: Rect, feather: f32) -> Rect {
|
||||
if bounds.is_empty() {
|
||||
return Rect::EMPTY;
|
||||
}
|
||||
bounds.inflate(((feather * 3.0).ceil() as i32).saturating_add(1)).intersect(&canvas)
|
||||
let reach = i32::try_from(sel::feather_reach(feather)).unwrap_or(i32::MAX);
|
||||
bounds.inflate(reach.saturating_add(1)).intersect(&canvas)
|
||||
}
|
||||
|
||||
macro_rules! spec {
|
||||
|
||||
Reference in new issue
Block a user