mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
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:
+53
-77
@@ -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(®ion);
|
||||
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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user