Select and Mask: Shift Edge is a curve on coverage, so hard edges stay put

#5176 made ±100% move the edge by max(radius, feather, 2σ smooth), at
least 1 px. Measured in the reference application (a 100 px square,
Radius/Feather/Smooth each varied, Shift Edge -100/-50/+100):

- A hard selection does not move at all, at any Shift Edge.
- On a feathered edge the result is a fixed per-pixel curve, the same at
  2, 5 and 10 px feather: the pixel's odds a/(1-a) are multiplied by
  49^(shift/100). At -100% a 50% pixel becomes 2%, at -50% 1/8, at
  +100% 98%. The fit holds within ±0.05 in log-odds on every sample.

So Shift Edge is now that curve, applied last as before. It needs no
neighbours, so the morphology callback and the halo/spread it added are
gone, with the ignored benchmark that timed it (matting::morph itself
stays for Select › Modify and the mask view).

Tests: a hard edge is unchanged at ±50/±100%; seven measured
before→after coverage pairs; a feathered edge is pulled in; the engine
refine tests now check the hard edge stays and a feathered one moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Mann
2026-10-30 14:58:18 +01:00
parent b1cfff9d30
commit bf80e2d2fa
4 changed files with 72 additions and 142 deletions
+53 -77
View File
@@ -35,10 +35,11 @@ pub struct RefineParams {
pub feather: f32,
/// 0–100.
pub contrast: f32,
/// −100–100. 100% moves the edge by its soft width: `max(radius, feather, 2 × smooth σ)`,
/// at least 1 px, so a hard edge moves a pixel (Photoshop's Shift Edge moves soft borders;
/// a band of several pixels around hard line art fills it in solid). Values between whole
/// pixels blend the two neighbouring shifts, so the slider does not jump.
/// −100–100. A curve on the refined coverage, applied last: each pixel's odds (`a / (1 − a)`)
/// are multiplied by `49^(shift / 100)`, so at −100% a half-covered pixel drops to 2% and at
/// +100% it rises to 98%. Fully selected and unselected pixels never change, so Shift Edge
/// moves only soft borders and a hard edge stays put (measured against the reference
/// application: the fit holds within ±0.05 in log-odds at feathers of 2, 5 and 10 px).
pub shift_edge: f32,
}
@@ -62,20 +63,10 @@ impl RefineParams {
fn feather_sigma(&self) -> f32 {
self.feather.max(0.0) / 2.0
}
/// Signed shift in pixels, fractional. 100% is the edge's soft width (at least 1 px),
/// clamped to ±64.
fn shift_amount(&self) -> f32 {
let soft = self.radius.max(self.feather).max(2.0 * self.smooth_sigma()).max(1.0);
let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * soft;
if px.is_finite() { px.clamp(-64.0, 64.0) } else { 0.0 }
}
/// How many pixels the mask can move: the farther of the two integer radii a fractional
/// shift blends. Whole-pixel amounts stay exact (`ceil(4) == 4`).
fn shift_px(&self) -> i32 {
let px = self.shift_amount();
let reach = px.abs().ceil();
let signed = if px.is_sign_negative() { -reach } else { reach };
if signed.is_finite() { signed as i32 } else { 0 }
/// The factor Shift Edge multiplies each pixel's odds by: `49^(shift / 100)`, 1 at 0%.
fn shift_odds(&self) -> f32 {
let s = self.shift_edge.clamp(-100.0, 100.0) / 100.0;
if s.is_finite() { 49f32.powf(s) } else { 1.0 }
}
fn uses_image(&self) -> bool {
self.radius > 0.0
@@ -88,14 +79,13 @@ impl RefineParams {
} else {
0
};
edge + (3.0 * self.smooth_sigma()).ceil() as i32 + (3.0 * self.feather_sigma()).ceil() as i32 + self.shift_px().abs() + 2
edge + (3.0 * self.smooth_sigma()).ceil() as i32 + (3.0 * self.feather_sigma()).ceil() as i32 + 2
}
/// How far the refined mask can extend beyond the original.
pub fn spread(&self) -> i32 {
(if self.uses_image() { self.radius.ceil() as i32 } else { 0 })
+ (3.0 * self.smooth_sigma()).ceil() as i32
+ (3.0 * self.feather_sigma()).ceil() as i32
+ self.shift_px().max(0)
+ 1
}
}
@@ -311,7 +301,7 @@ pub fn edge_width(img: &RgbImage, r: usize) -> Vec<f32> {
/// Refines a soft mask buffer `m` (`w × h`) with `img` as guide (required when `radius > 0`).
pub fn refine_buffer(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams) -> Vec<f32> {
refine_buffer_with(img, m, w, h, p, None, morph)
refine_buffer_with(img, m, w, h, p, None)
}
/// The Refine Edge Brush over one buffer: signed coverage (`w × h`, see [`EdgeBrush`]) and the
@@ -322,10 +312,7 @@ struct BrushBuf<'a> {
window: usize,
}
fn refine_buffer_with<F>(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams, brush: Option<BrushBuf>, morphology: F) -> Vec<f32>
where
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy,
{
fn refine_buffer_with(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams, brush: Option<BrushBuf>) -> Vec<f32> {
let mut a = m.to_vec();
if let (true, Some(img)) = (p.uses_image(), img) {
let bin: Vec<bool> = m.iter().map(|v| *v >= 0.5).collect();
@@ -385,36 +372,23 @@ where
*v = ((*v - 0.5) * gain + 0.5).clamp(0.0, 1.0);
}
}
let amount = p.shift_amount();
if amount.abs() >= 1.0e-4 {
a = shift_mask(&a, w, h, amount, morphology);
let k = p.shift_odds();
if (k - 1.0).abs() >= 1.0e-6 {
for v in &mut a {
*v = shift_coverage(*v, k);
}
}
a
}
/// Grow or shrink `src` by a fractional pixel distance. Whole pixels use one morphology;
/// the remainder blends that result with the next pixel, so 0.3 px is not the same step as 1 px.
fn shift_mask<F>(src: &[f32], w: usize, h: usize, amount: f32, morphology: F) -> Vec<f32>
where
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy,
{
let mag = amount.abs();
if !mag.is_finite() || mag < 1.0e-4 {
return src.to_vec();
/// Shift Edge on one coverage value: its odds times `k`. 0 and 1 stay exact.
fn shift_coverage(v: f32, k: f32) -> f32 {
let v = v.clamp(0.0, 1.0);
if v <= 0.0 || v >= 1.0 {
return v;
}
let grow = amount > 0.0;
let whole = mag.floor() as usize;
let frac = mag - whole as f32;
if frac < 1.0e-4 {
return morphology(src, w, h, whole, grow);
}
let lo = if whole == 0 { src.to_vec() } else { morphology(src, w, h, whole, grow) };
let hi = morphology(src, w, h, whole + 1, grow);
let n = w.saturating_mul(h);
if lo.len() < n || hi.len() < n || lo.len() != hi.len() {
return hi;
}
lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect()
let shifted = k * v / (k * v + (1.0 - v));
if shifted.is_finite() { shifted.clamp(0.0, 1.0) } else { v }
}
/// Most pixels one refine should cover. Its result is a dense [`Region`] over [`refine_area`]
@@ -437,7 +411,7 @@ pub fn refine_area(content: Rect, canvas: Rect, p: &RefineParams, brush: Option<
/// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image
/// (only read when `radius > 0`). Returns `None` when nothing remains selected.
pub fn refine_mask(sampler: &dyn Sampler, mask: &(dyn Fn(Rect) -> Vec<f32> + Sync), content: Rect, canvas: Rect, p: &RefineParams) -> Option<Region> {
refine_mask_with(sampler, mask, content, canvas, p, None, morph)
refine_mask_with(sampler, mask, content, canvas, p, None)
}
/// [`refine_mask`] with the Refine Edge Brush: where `brush` adds, coverage comes from the image
@@ -451,21 +425,17 @@ pub fn refine_mask_brushed(
p: &RefineParams,
brush: Option<&EdgeBrush>,
) -> Option<Region> {
refine_mask_with(sampler, mask, content, canvas, p, brush, morph)
refine_mask_with(sampler, mask, content, canvas, p, brush)
}
fn refine_mask_with<F>(
fn refine_mask_with(
sampler: &dyn Sampler,
mask: &(dyn Fn(Rect) -> Vec<f32> + Sync),
content: Rect,
canvas: Rect,
p: &RefineParams,
brush: Option<&EdgeBrush>,
morphology: F,
) -> Option<Region>
where
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy + Sync + Send,
{
) -> Option<Region> {
let bbox = refine_area(content, canvas, p, brush);
if bbox.is_empty() {
return None;
@@ -505,7 +475,7 @@ where
(Some(v), Some(b)) => Some(BrushBuf { v, window: b.window() }),
_ => None,
};
let out = refine_buffer_with(img.as_ref(), &m, rw, rh, p, bb, morphology);
let out = refine_buffer_with(img.as_ref(), &m, rw, rh, p, bb);
(*t, core(&out))
};
#[cfg(not(target_arch = "wasm32"))]
@@ -769,24 +739,30 @@ mod tests {
assert!(f[5 * w + 29] > 0.5 && f[5 * w + 29] < 0.8 && f[5 * w + 31] > 0.1);
let c = refine_buffer(None, &f, w, h, &RefineParams { contrast: 100.0, ..Default::default() });
assert!(c[5 * w + 29] == 1.0 && c[5 * w + 31] == 0.0);
// A hard edge (no radius, feather or smooth) moves 1 px at 100%, so 50% is half a
// pixel. The pixel just outside fades in instead of snapping on.
let grown = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 100.0, ..Default::default() });
assert_eq!(m[5 * w + 30], 0.0);
assert_eq!(grown[5 * w + 30], 1.0);
assert_eq!(grown[5 * w + 31], 0.0);
let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -100.0, ..Default::default() });
assert_eq!(shrunk[5 * w + 28], 1.0);
assert_eq!(shrunk[5 * w + 29], 0.0);
let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 50.0, ..Default::default() });
assert!((half[5 * w + 30] - 0.5).abs() < 0.02, "half pixel {}", half[5 * w + 30]);
assert_eq!(half[5 * w + 31], 0.0, "the blend must not jump a second pixel");
let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() });
assert!((pulled[5 * w + 29] - 0.5).abs() < 0.02, "half-pixel contract {}", pulled[5 * w + 29]);
// A softer edge moves farther: 100% is the feather, here 4 px.
let wide = refine_buffer(None, &m, w, h, &RefineParams { feather: 4.0, contrast: 100.0, shift_edge: 100.0, ..Default::default() });
assert_eq!(wide[5 * w + 33], 1.0);
assert_eq!(wide[5 * w + 34], 0.0);
// Shift Edge leaves a hard edge where it is, at any amount: only soft coverage moves.
for shift_edge in [-100.0, -50.0, 50.0, 100.0] {
let s = refine_buffer(None, &m, w, h, &RefineParams { shift_edge, ..Default::default() });
assert_eq!(s, m, "Shift Edge {shift_edge}% moved a hard edge");
}
// Coverage before → after, read off the reference application's feathered edges.
for (shift_edge, before, after) in [
(-100.0, 0.898, 0.153),
(-100.0, 0.612, 0.031),
(-100.0, 0.965, 0.357),
(-50.0, 0.537, 0.141),
(-50.0, 0.898, 0.557),
(100.0, 0.102, 0.847),
(100.0, 0.388, 0.969),
] {
let flat = vec![before; w * h];
let s = refine_buffer(None, &flat, w, h, &RefineParams { shift_edge, ..Default::default() });
assert!((s[5 * w + 30] - after).abs() < 0.01, "{shift_edge}%: {before} became {}, expected {after}", s[5 * w + 30]);
}
// So a feathered edge moves: at −100% its half-covered point lands where it was 98%.
let soft = refine_buffer(None, &m, w, h, &RefineParams { feather: 6.0, ..Default::default() });
let pulled = refine_buffer(None, &m, w, h, &RefineParams { feather: 6.0, shift_edge: -100.0, ..Default::default() });
let half = |v: &[f32]| (0..w).find(|x| v[5 * w + x] < 0.5).unwrap_or(w);
assert!(half(&pulled) < half(&soft), "−100% did not pull the soft edge in");
let sm = refine_buffer(None, &m, w, h, &RefineParams { smooth: 50.0, ..Default::default() });
assert!(sm[5 * w + 10] == 1.0 && sm[5 * w + 50] == 0.0);
}
@@ -230,65 +230,6 @@ fn octagonal_morphology_release_comparison() {
}
}
#[test]
#[ignore = "release comparison of the full tiled Select and Mask CPU path"]
#[allow(clippy::assertions_on_constants)]
fn tiled_refinement_release_comparison() {
use crate::{
matting::{RefineParams, refine_mask_with, region_reader},
segment::{ImageSampler, RgbImage, trim_region},
selection::Region,
};
use photocraft_geom::Rect;
use std::{hint::black_box, time::Instant};
assert!(!cfg!(debug_assertions), "run with --release");
for (w, h) in [(6000usize, 4000usize), (7200, 5000)] {
let image = RgbImage::from_fn(w, h, |x, y| {
let t = x as f32 / w as f32;
let u = y as f32 / h as f32;
[0.2 + 0.5 * t, 0.15 + 0.55 * u, 0.3 + 0.15 * ((x / 17 + y / 23) % 3) as f32]
});
let sampler = ImageSampler { img: &image, origin: (0, 0) };
let canvas = Rect::new(0, 0, w as i32, h as i32);
let mask: Vec<_> = (0..w * h)
.map(|i| {
let dx = (i % w) as f64 - w as f64 * 0.51;
let dy = (i / w) as f64 - h as f64 * 0.48;
let d = (dx * dx + dy * dy).sqrt();
((0.5 + (w.min(h) as f64 * 0.27 - d) / 7.0).clamp(0.0, 1.0) * 255.0).round() as u8
})
.collect();
let region = trim_region(Region { bbox: canvas, mask }).unwrap();
let reader = region_reader(&region);
let p = RefineParams { radius: 64.0, shift_edge: 100.0, ..Default::default() };
let old = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, None, repeated).unwrap();
let new = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, None, morph).unwrap();
assert_eq!(new.bbox, old.bbox);
assert_eq!(new.mask, old.mask);
let (mut old_ms, mut new_ms) = (Vec::new(), Vec::new());
for run in 0..3 {
let mut measure = |fast: bool| {
let start = Instant::now();
let out = if fast {
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, None, morph)
} else {
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, None, repeated)
}
.unwrap();
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
assert_eq!(out.bbox, old.bbox);
assert_eq!(out.mask, old.mask);
if fast { new_ms.push(elapsed) } else { old_ms.push(elapsed) }
};
measure(run % 2 == 0);
measure(run % 2 != 0);
}
old_ms.sort_by(f64::total_cmp);
new_ms.sort_by(f64::total_cmp);
println!("REFINE {w}x{h} radius=64 shift=100 old_ms={old_ms:?} new_ms={new_ms:?} speedup={:.2}", old_ms[1] / new_ms[1]);
}
}
#[test]
#[ignore = "release comparison on a dense fractional 24 MP matte"]
#[allow(clippy::assertions_on_constants)]
@@ -451,9 +451,15 @@ fn refine_edge_outputs_to_a_new_document() {
#[test]
fn refine_edge_shift_and_contrast() {
let mut s = blurred_edge_selected();
// A hard selection moves 1 px at 100%: no band of several pixels.
s.execute("select.refineEdge", json!({"shiftEdge": 100})).unwrap();
assert!(cov(&s, 40, 20) > 0.99 && cov(&s, 41, 20) < 0.01);
// Shift Edge leaves a hard selection where it is (selected x < 40), at either end.
for shift in [100, -100] {
s.execute("select.refineEdge", json!({"shiftEdge": shift})).unwrap();
assert!(cov(&s, 39, 20) > 0.99 && cov(&s, 40, 20) < 0.01, "Shift Edge {shift}% moved a hard edge");
s.execute("edit.undo", json!({})).unwrap();
}
// A feathered one moves: -100% pulls its half-covered point inside the old edge.
s.execute("select.refineEdge", json!({"feather": 4, "shiftEdge": -100})).unwrap();
assert!(cov(&s, 37, 20) < 0.5 && cov(&s, 30, 20) > 0.99);
s.execute("edit.undo", json!({})).unwrap();
s.execute("select.refineEdge", json!({"feather": 8, "contrast": 100})).unwrap();
assert!(cov(&s, 38, 20) == 1.0 && cov(&s, 42, 20) == 0.0);
@@ -661,8 +667,9 @@ fn refine_edge_edits_match_the_tools_and_reach_the_outputs() {
// Refinement runs on the edited selection: Shift Edge contracts the brushed bar too.
let mut s = two_regions(8);
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
s.execute("select.refineEdge", json!({"shiftEdge": -100, "edits": [{"tool": "brush", "points": [[30, 80], [100, 80]], "size": 20}]})).unwrap();
assert!(cov(&s, 60, 80) >= 0.5 && cov(&s, 60, 89) < 0.5 && cov(&s, 60, 70) < 0.5, "the hard 20 px bar shrank by one pixel at -100% Shift Edge");
s.execute("select.refineEdge", json!({"feather": 4, "shiftEdge": -100, "edits": [{"tool": "brush", "points": [[30, 80], [100, 80]], "size": 20}]}))
.unwrap();
assert!(cov(&s, 60, 80) >= 0.5 && cov(&s, 60, 87) < 0.5 && cov(&s, 60, 72) < 0.5, "the feathered 20 px bar did not shrink at -100% Shift Edge");
// Layer mask and new-layer outputs take the edited selection.
let mut s = blurred_edge_selected();
let r = s.execute("select.refineEdge", json!({"output": "layerMask", "edits": [{"tool": "invert"}]})).unwrap();
+7 -1
View File
@@ -1,6 +1,12 @@
# Exact octagonal mask shifts
Select and Mask applies Shift Edge as flat grayscale dilation or erosion. Previously,
> **2026-10-30:** Shift Edge no longer uses this morphology. Measured against the reference
> application, it is a curve on each pixel's coverage (odds times `49^(shift / 100)`), so it
> leaves hard edges in place; see `RefineParams::shift_edge`. `matting::morph` remains for the
> Select and Mask view and Select › Modify, and the kernel numbers below still hold. The full
> refinement comparison further down timed the old Shift Edge and is kept as history.
Select and Mask applied Shift Edge as flat grayscale dilation or erosion. Previously,
`matting::morph` ran one full 3x3 pass for each radius step, alternating a square and a
cross. A 64 px shift needed 64 image passes. The filter supports fractional coverage.