Select and Mask: Refine Edge Brush (#2972) (#2989)

* Select and Mask: live preview in Photoshop's view modes (#1754)

The Select and Mask dialog had no preview of the refined selection. It now
previews live in Photoshop's view modes: Onion Skin (default), Marching
Ants, Overlay, On Black, On White, Black & White and On Layers, with a
per-view Opacity, Show Edge (J) and Show Original (P). F cycles the views,
O/M/V/A/T/K/Y pick one and holding X shows the image without a view.

The view state lives in the dialog's fields, so ui.dialog.set drives it.
The preview runs select.refineEdge on the proxy with output=selection and
builds the view from it (veil fill layers, a layer mask, or the refined
selection, whose marching ants the canvas draws scaled back up), through
the existing filter-preview worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Select and Mask: Refine Edge Brush (#2972)

Select and Mask had no Refine Edge Brush. select.refineEdge now takes
refineStrokes: [{"points":[[x,y],...],"size":px,"mode":"add|subtract"}],
applied in painting order. Added areas become unknown and take a colour
estimate: each pixel's colour projected on the line between the mean known
foreground and background colours around it (normalised convolution over
a box of the brush size, four times as far where none are near), so hair
or fur beyond the edge-detection band joins the selection. Subtracted
areas keep the original edge. Malformed or oversized strokes are rejected
(stroke / point / swept-area limits), never a panic.

The dialog gets the brush: Refine Edge Brush (R), Mode (Expand Detection
Area / Restore Original Edge, Alt swaps), Size ([ and ]) and Clear Brush
Strokes. With it on, dragging on the canvas paints strokes into the
dialog's refineStrokes field, so the live preview, Show Edge and OK use
them, and ui.dialog.set / agents drive them like any other param.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-11 18:00:25 +02:00 committed by GitHub
parent 3360f6e442
commit c1bd296dcf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 815 additions and 22 deletions

View File

@ -224,8 +224,11 @@ pub fn morph(src: &[f32], w: usize, h: usize, r: usize, grow: bool) -> Vec<f32>
morphology::morph(src, w, h, r, grow)
}
mod brush;
mod morphology;
pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, swept_area};
/// Running max (or min) over a window of radius `r` (edge-clipped), van Herk / Gil–Werman:
/// three comparisons per sample whatever the radius.
fn running_extreme(src: &[f32], out: &mut [f32], r: usize, max: bool, g: &mut Vec<f32>, hb: &mut Vec<f32>) {
@ -293,10 +296,18 @@ 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, morph)
refine_buffer_with(img, m, w, h, p, None, morph)
}
fn refine_buffer_with<F>(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams, morphology: F) -> Vec<f32>
/// The Refine Edge Brush over one buffer: signed coverage (`w × h`, see [`EdgeBrush`]) and the
/// box radius of its colour estimate.
#[derive(Clone, Copy)]
struct BrushBuf<'a> {
v: &'a [f32],
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,
{
@ -324,13 +335,26 @@ where
Some(wd) => wd[i].clamp(1.5, p.radius.max(1.5)),
None => p.radius,
};
let wgt = (reff + 1.0 - dist[i]).clamp(0.0, 1.0);
let mut wgt = (reff + 1.0 - dist[i]).clamp(0.0, 1.0);
// Restored by the brush: the original edge.
if let Some(b) = brush.and_then(|b| b.v.get(i)).filter(|b| **b < 0.0) {
wgt *= 1.0 + b;
}
if wgt > 0.0 {
a[i] = m[i] + (q[i].clamp(0.0, 1.0) - m[i]) * wgt;
}
}
}
}
// Added by the brush: coverage estimated from the image.
if let (Some(b), Some(img)) = (brush.filter(|b| b.v.iter().any(|v| *v > 0.0)), img) {
let est = brush::estimate(img, m, b.v, w, h, b.window);
for ((ai, ei), bi) in a.iter_mut().zip(&est).zip(b.v) {
if *bi > 0.0 {
*ai += (ei - *ai) * bi;
}
}
}
let ss = p.smooth_sigma();
if ss >= 0.3 {
a = gaussian_blur(&a, w, h, ss);
@ -357,7 +381,21 @@ where
/// 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, morph)
refine_mask_with(sampler, mask, content, canvas, p, None, morph)
}
/// [`refine_mask`] with the Refine Edge Brush: where `brush` adds, coverage comes from the image
/// (the area can reach beyond the edge-detection band); where it restores, the original edge
/// stays. The sampler is read wherever the brush adds, whatever the radius.
pub fn refine_mask_brushed(
sampler: &dyn Sampler,
mask: &(dyn Fn(Rect) -> Vec<f32> + Sync),
content: Rect,
canvas: Rect,
p: &RefineParams,
brush: Option<&EdgeBrush>,
) -> Option<Region> {
refine_mask_with(sampler, mask, content, canvas, p, brush, morph)
}
fn refine_mask_with<F>(
@ -366,6 +404,7 @@ fn refine_mask_with<F>(
content: Rect,
canvas: Rect,
p: &RefineParams,
brush: Option<&EdgeBrush>,
morphology: F,
) -> Option<Region>
where
@ -375,8 +414,8 @@ where
if content.is_empty() {
return None;
}
let bbox = content.inflate(p.spread()).intersect(&canvas);
let halo = p.halo();
let bbox = content.inflate(p.spread()).union(&brush.map_or(Rect::EMPTY, EdgeBrush::added_bounds)).intersect(&canvas);
let halo = p.halo() + brush.map_or(0, |b| i32::try_from(b.window() * 4).unwrap_or(i32::MAX));
let mut tiles = Vec::new();
let mut y = bbox.y0;
while y < bbox.y1 {
@ -401,11 +440,17 @@ where
}
out
};
if (first == 0.0 || first == 1.0) && m.iter().all(|v| *v == first) {
let painted = brush.filter(|b| !b.bounds().intersect(&r).is_empty()).map(|b| b.read(r));
let adds = painted.as_ref().is_some_and(|v| v.iter().any(|v| *v > 0.0));
if !adds && (first == 0.0 || first == 1.0) && m.iter().all(|v| *v == first) {
return (*t, vec![q(first); cw * ch]);
}
let img = p.uses_image().then(|| sampler.rgb(r));
let out = refine_buffer_with(img.as_ref(), &m, rw, rh, p, morphology);
let img = (p.uses_image() || adds).then(|| sampler.rgb(r));
let bb = match (&painted, brush) {
(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);
(*t, core(&out))
};
#[cfg(not(target_arch = "wasm32"))]

View File

@ -0,0 +1,333 @@
//! Select and Mask's Refine Edge Brush: strokes that set where edge refinement happens.
//!
//! Painting (`add`) marks an area as unknown: its coverage is estimated from the image, so soft
//! detail such as hair beyond the edge-detection band is picked up. Painting with `add = false`
//! (Photoshop's Alt) removes the refinement there and keeps the original edge. Strokes apply in
//! order; a later one paints over an earlier one.
//!
//! The estimate projects each painted pixel's colour on the line between the mean colours of the
//! known foreground and background around it (normalised convolution over a box twice the brush
//! size), the colour-line model of A. Levin, D. Lischinski and Y. Weiss, "A Closed-Form Solution
//! to Natural Image Matting" (CVPR 2006), with one foreground and one background colour per window.
use photocraft_geom::Rect;
use super::box_mean;
use crate::segment::RgbImage;
/// One Refine Edge Brush stroke, in document pixels.
#[derive(Clone, Debug, PartialEq)]
pub struct EdgeStroke {
/// The stroke's path (a single point paints a dab).
pub points: Vec<(f32, f32)>,
/// Brush diameter in pixels.
pub size: f32,
/// Add to the refinement area (true) or restore the original edge (false).
pub add: bool,
}
/// Brush diameters the strokes may use.
pub const SIZE_RANGE: std::ops::RangeInclusive<f32> = 1.0..=5000.0;
/// The painted area: signed coverage (`+1` refined, `−1` restored, `0` untouched) over `bbox`.
#[derive(Clone, Debug, PartialEq)]
pub struct EdgeBrush {
bbox: Rect,
/// Coverage × 127, row-major over `bbox`.
v: Vec<i8>,
/// Bounds of the added (positive) coverage.
added: Rect,
/// Box radius of the colour estimate (pixels).
window: usize,
}
/// Coverage (0–1) of a round brush of radius `r` swept from `a` to `b`, at pixel centre `p`.
fn capsule(p: (f32, f32), a: (f32, f32), b: (f32, f32), r: f32) -> f32 {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let len2 = dx * dx + dy * dy;
let t = if len2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
let (cx, cy) = (a.0 + t * dx, a.1 + t * dy);
let d = ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt();
(r + 0.5 - d).clamp(0.0, 1.0)
}
/// The pixels a disc of radius `r` at `p` can touch, as a (possibly empty) rectangle.
fn dab_bounds(p: (f32, f32), r: f32) -> Rect {
let lo = |v: f32| (v - r - 1.0).floor().clamp(i32::MIN as f32 / 2.0, i32::MAX as f32 / 2.0) as i32;
let hi = |v: f32| (v + r + 1.0).ceil().clamp(i32::MIN as f32 / 2.0, i32::MAX as f32 / 2.0) as i32;
Rect::new(lo(p.0), lo(p.1), hi(p.0), hi(p.1))
}
/// Most pixels the strokes of one [`EdgeBrush`] may sweep (Σ length × size + size² per stroke):
/// about eleven times a 24 MP canvas.
pub const MAX_SWEPT_AREA: f64 = (1u64 << 28) as f64;
/// The area `strokes` sweep (see [`MAX_SWEPT_AREA`]); non-finite strokes count as infinite.
pub fn swept_area(strokes: &[EdgeStroke]) -> f64 {
strokes
.iter()
.map(|s| {
let size = f64::from(s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()));
let len: f64 = s.points.windows(2).filter_map(|w| Some(f64::from((w.get(1)?.0 - w.first()?.0).hypot(w.get(1)?.1 - w.first()?.1)))).sum();
if len.is_finite() { len * size + size * size } else { f64::INFINITY }
})
.sum()
}
/// The stroke's path as short segments: points closer than a quarter of the radius to the last
/// kept one are dropped, and long segments are split at most `r` long, so each segment's box
/// stays close to the area it paints.
fn segments(points: &[(f32, f32)], r: f32) -> Vec<((f32, f32), (f32, f32))> {
let Some(&first) = points.first() else { return Vec::new() };
let min_step = (r / 4.0).max(0.5);
let max_step = r.max(2.0);
let mut kept = vec![first];
for p in points.iter().skip(1) {
let last = kept.last().copied().unwrap_or(first);
if (p.0 - last.0).hypot(p.1 - last.1) >= min_step {
kept.push(*p);
}
}
if let (Some(last), Some(end)) = (kept.last().copied(), points.last().copied())
&& last != end
{
kept.push(end);
}
if kept.len() == 1 {
return vec![(first, first)];
}
let mut out = Vec::new();
for w in kept.windows(2) {
let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue };
let pieces = ((b.0 - a.0).hypot(b.1 - a.1) / max_step).ceil().clamp(1.0, 1e6) as usize;
let at = |k: usize| {
let t = k as f32 / pieces as f32;
(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)
};
out.extend((0..pieces).map(|k| (at(k), at(k + 1))));
}
out
}
impl EdgeBrush {
/// Rasterises `strokes` (clipped to `canvas`). `None` when they paint nothing or sweep more
/// than [`MAX_SWEPT_AREA`].
pub fn new(strokes: &[EdgeStroke], canvas: Rect) -> Option<EdgeBrush> {
let usable = |s: &&EdgeStroke| s.size.is_finite() && !s.points.is_empty() && s.points.iter().all(|p| p.0.is_finite() && p.1.is_finite());
if swept_area(strokes) > MAX_SWEPT_AREA {
return None;
}
let strokes: Vec<&EdgeStroke> = strokes.iter().filter(usable).collect();
let stroke_bounds = |s: &EdgeStroke| -> Rect {
let r = s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
s.points.iter().fold(Rect::EMPTY, |acc, p| acc.union(&dab_bounds(*p, r))).intersect(&canvas)
};
let bbox = strokes.iter().fold(Rect::EMPTY, |acc, s| acc.union(&stroke_bounds(s)));
if bbox.is_empty() {
return None;
}
let bw = bbox.width() as usize;
let mut v = vec![0i8; bw.checked_mul(bbox.height() as usize)?];
let mut cov: Vec<f32> = Vec::new();
let mut max_add = 0.0f32;
for s in strokes {
let sb = stroke_bounds(s);
if sb.is_empty() {
continue;
}
let r = s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
if s.add {
max_add = max_add.max(2.0 * r);
}
let sw = sb.width() as usize;
cov.clear();
cov.resize(sw * sb.height() as usize, 0.0);
// The stroke's coverage: the most any of its segments gives (a single dab for one point).
for (a, b) in segments(&s.points, r) {
let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&sb);
for y in seg.y0..seg.y1 {
for x in seg.x0..seg.x1 {
let c = capsule((x as f32 + 0.5, y as f32 + 0.5), a, b, r);
if let Some(slot) = cov.get_mut((y - sb.y0) as usize * sw + (x - sb.x0) as usize)
&& c > *slot
{
*slot = c;
}
}
}
}
let target = if s.add { 1.0 } else { -1.0 };
for y in sb.y0..sb.y1 {
for x in sb.x0..sb.x1 {
let c = cov.get((y - sb.y0) as usize * sw + (x - sb.x0) as usize).copied().unwrap_or(0.0);
if c <= 0.0 {
continue;
}
if let Some(slot) = v.get_mut((y - bbox.y0) as usize * bw + (x - bbox.x0) as usize) {
let cur = f32::from(*slot) / 127.0;
*slot = ((cur + (target - cur) * c) * 127.0).round().clamp(-127.0, 127.0) as i8;
}
}
}
}
if v.iter().all(|c| *c == 0) {
return None;
}
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
for (row, line) in v.chunks_exact(bw).enumerate() {
let (Some(first), Some(last)) = (line.iter().position(|c| *c > 0), line.iter().rposition(|c| *c > 0)) else { continue };
let y = bbox.y0 + row as i32;
(x0, x1, y0, y1) = (x0.min(bbox.x0 + first as i32), x1.max(bbox.x0 + last as i32 + 1), y0.min(y), y1.max(y + 1));
}
let added = if x0 < x1 { Rect::new(x0, y0, x1, y1) } else { Rect::EMPTY };
Some(EdgeBrush { bbox, v, added, window: max_add.ceil().clamp(4.0, 64.0) as usize })
}
/// Where the strokes paint (added or restored).
pub fn bounds(&self) -> Rect {
self.bbox
}
/// Where the strokes add to the refinement area (empty when they only restore).
pub fn added_bounds(&self) -> Rect {
self.added
}
/// Box radius of the colour estimate (it reads up to four times as far).
pub fn window(&self) -> usize {
self.window
}
/// Signed coverage at document pixel `(x, y)` (zero outside the painted area).
pub fn at(&self, x: i32, y: i32) -> f32 {
if !self.bbox.contains(x, y) {
return 0.0;
}
let i = (y - self.bbox.y0) as usize * self.bbox.width() as usize + (x - self.bbox.x0) as usize;
self.v.get(i).map_or(0.0, |c| f32::from(*c) / 127.0)
}
/// Signed coverage over `r` (row-major).
pub fn read(&self, r: Rect) -> Vec<f32> {
(r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| self.at(x, y))).collect()
}
}
/// Coverage estimated from the image where `brush` (signed, `w × h`) adds: each pixel's colour
/// projected on the line between the mean known foreground and background colours within
/// `window` px (or `4 × window` where there are none that close). Known means outside the painted area and (nearly) fully in or out of `m`. Pixels
/// with no foreground or background nearby, or no contrast between them, keep `m`.
pub(super) fn estimate(img: &RgbImage, m: &[f32], brush: &[f32], w: usize, h: usize, window: usize) -> Vec<f32> {
let n = w * h;
let known = |want_fg: bool| -> Vec<f32> {
(0..n)
.map(|i| {
let (mv, bv) = (m.get(i).copied().unwrap_or(0.0), brush.get(i).copied().unwrap_or(0.0));
let ok = bv <= 0.0 && if want_fg { mv >= 0.98 } else { mv <= 0.02 };
if ok { 1.0 } else { 0.0 }
})
.collect()
};
let (wf, wb) = (known(true), known(false));
// Normalised convolution of the known colours: weight sums and weighted colour sums, over the
// window and (where the window finds none) four times as far.
let means = |wt: &[f32], r: usize| -> (Vec<f32>, Vec<Vec<f32>>) {
let sums = (0..3)
.map(|c| {
let v: Vec<f32> = img.px.iter().zip(wt).map(|(p, k)| p.get(c).copied().unwrap_or(0.0) * k).collect();
box_mean(&v, w, h, r)
})
.collect();
(box_mean(wt, w, h, r), sums)
};
let near = [means(&wf, window), means(&wb, window)];
let far = [means(&wf, window * 4), means(&wb, window * 4)];
// The mean known colour at `i` (0 = foreground, 1 = background).
let mean = |k: usize, i: usize| -> Option<[f32; 3]> {
let pick = |(d, s): &(Vec<f32>, Vec<Vec<f32>>)| -> Option<[f32; 3]> {
let d = d.get(i).copied().filter(|d| *d >= 1e-4)?;
Some([0, 1, 2].map(|c| s.get(c).and_then(|v| v.get(i)).copied().unwrap_or(0.0) / d))
};
near.get(k).and_then(pick).or_else(|| far.get(k).and_then(pick))
};
let mut out = m.to_vec();
for (i, o) in out.iter_mut().enumerate() {
if brush.get(i).copied().unwrap_or(0.0) <= 0.0 {
continue;
}
let (Some(fm), Some(bm), Some(px)) = (mean(0, i), mean(1, i), img.px.get(i)) else { continue };
let mut num = 0.0f32;
let mut den = 0.0f32;
for ((f, b), p) in fm.iter().zip(&bm).zip(px) {
num += (p - b) * (f - b);
den += (f - b) * (f - b);
}
if den > 1e-4 {
*o = (num / den).clamp(0.0, 1.0);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::matting::{RefineParams, refine_mask_brushed, region_reader};
use crate::segment::ImageSampler;
use crate::selection::Region;
/// Strands of the subject's colour (every 4th column from x = 60) beyond the selection's
/// edge at x = 40: brushing over them adds them, Alt-brushing the edge keeps it as it was.
#[test]
fn brush_picks_up_strands_beyond_the_band_and_restores_the_edge() {
let (w, h) = (120usize, 60usize);
let (fg, bg) = ([0.9f32, 0.7, 0.2], [0.1f32, 0.3, 0.8]);
let strand = |x: usize| x < 40 || ((60..84).contains(&x) && x.is_multiple_of(4));
let img = RgbImage::from_fn(w, h, |x, _| if strand(x) { fg } else { bg });
let s = ImageSampler { img: &img, origin: (0, 0) };
let canvas = Rect::new(0, 0, w as i32, h as i32);
let reg = Region { bbox: canvas, mask: (0..w * h).map(|i| if i % w < 40 { 255 } else { 0 }).collect() };
let p = RefineParams { radius: 4.0, ..Default::default() };
let refine = |strokes: &[EdgeStroke]| {
let brush = EdgeBrush::new(strokes, canvas);
refine_mask_brushed(&s, &region_reader(&reg), reg.bbox, canvas, &p, brush.as_ref()).expect("refined")
};
let plain = refine(&[]);
assert!(plain.at(64, 30) < 0.01, "the band alone stops short of the strands");
let over_strands = EdgeStroke { points: vec![(70.0, 10.0), (70.0, 50.0)], size: 30.0, add: true };
let brushed = refine(std::slice::from_ref(&over_strands));
assert!(brushed.at(64, 30) > 0.9 && brushed.at(72, 30) > 0.9, "strands added: {}", brushed.at(64, 30));
assert!(brushed.at(66, 30) < 0.1, "the background between them stays out: {}", brushed.at(66, 30));
assert!(brushed.at(20, 30) > 0.99 && brushed.at(100, 30) < 0.01, "unpainted areas keep the refined selection");
// Restoring paints the refinement out: with Shift Edge 0 the edge is the original one.
let soft = RefineParams { radius: 4.0, feather: 0.0, ..Default::default() };
let blurred = RgbImage::from_fn(w, h, |x, _| {
let t = ((x as f32 - 36.0) / 8.0).clamp(0.0, 1.0);
[0, 1, 2].map(|c| fg[c] * (1.0 - t) + bg[c] * t)
});
let sb = ImageSampler { img: &blurred, origin: (0, 0) };
let refined = refine_mask_brushed(&sb, &region_reader(&reg), reg.bbox, canvas, &soft, None).expect("refined");
assert!(refined.at(41, 30) > 0.1, "the band softens the blurred edge");
let restore = [EdgeStroke { points: vec![(40.0, 0.0), (40.0, 60.0)], size: 20.0, add: false }];
let restored = refine_mask_brushed(&sb, &region_reader(&reg), reg.bbox, canvas, &soft, EdgeBrush::new(&restore, canvas).as_ref()).expect("refined");
assert!(restored.at(41, 30) < 0.01 && restored.at(38, 30) > 0.99, "the original edge is back");
}
#[test]
fn strokes_paint_in_order_and_clip_to_the_canvas() {
let canvas = Rect::new(0, 0, 100, 50);
let add = EdgeStroke { points: vec![(10.0, 25.0), (90.0, 25.0)], size: 10.0, add: true };
let b = EdgeBrush::new(std::slice::from_ref(&add), canvas).expect("painted");
assert!(b.at(50, 25) > 0.99 && b.at(50, 40) == 0.0);
assert_eq!(b.window(), 10);
// A later subtract stroke paints over the add.
let sub = EdgeStroke { points: vec![(50.0, 25.0)], size: 20.0, add: false };
let b = EdgeBrush::new(&[add.clone(), sub], canvas).expect("painted");
assert!(b.at(50, 25) < -0.99 && b.at(20, 25) > 0.99);
// Off the canvas, non-finite or empty strokes paint nothing.
let off = EdgeStroke { points: vec![(-500.0, -500.0)], size: 10.0, add: true };
let nan = EdgeStroke { points: vec![(f32::NAN, 3.0)], size: 10.0, add: true };
let empty = EdgeStroke { points: vec![], size: 10.0, add: true };
assert!(EdgeBrush::new(&[off, nan, empty], canvas).is_none());
}
}

View File

@ -261,8 +261,8 @@ fn tiled_refinement_release_comparison() {
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, repeated).unwrap();
let new = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, morph).unwrap();
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());
@ -270,9 +270,9 @@ fn tiled_refinement_release_comparison() {
let mut measure = |fast: bool| {
let start = Instant::now();
let out = if fast {
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, morph)
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, repeated)
refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, None, repeated)
}
.unwrap();
let elapsed = start.elapsed().as_secs_f64() * 1000.0;

View File

@ -205,12 +205,66 @@ fn refine_params(p: &Value) -> RefineParams {
}
}
/// Most Refine Edge Brush strokes, and points across them, one Select and Mask takes.
pub const MAX_STROKES: usize = 4096;
pub const MAX_STROKE_POINTS: usize = 100_000;
/// The Refine Edge Brush strokes of `refineStrokes`:
/// `[{"points":[[x,y],…],"size":px,"mode":"add|subtract"}]`, in painting order.
fn refine_strokes(p: &Value) -> Result<Vec<matting::EdgeStroke>> {
const CMD: &str = "select.refineEdge";
let Some(v) = p.get("refineStrokes").filter(|v| !v.is_null()) else { return Ok(Vec::new()) };
let list = v.as_array().ok_or_else(|| bad(CMD, "refineStrokes must be an array of strokes"))?;
if list.len() > MAX_STROKES {
return Err(bad(CMD, &format!("refineStrokes holds more than {MAX_STROKES} strokes")));
}
let mut total = 0usize;
let mut strokes = Vec::with_capacity(list.len());
for stroke in list {
let o = stroke.as_object().ok_or_else(|| bad(CMD, "each refineStrokes entry must be {\"points\":[[x,y],…],\"size\":px,\"mode\":\"add|subtract\"}"))?;
let pts = o.get("points").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(|| bad(CMD, "each stroke needs a non-empty points array"))?;
total = total.saturating_add(pts.len());
if total > MAX_STROKE_POINTS {
return Err(bad(CMD, &format!("refineStrokes holds more than {MAX_STROKE_POINTS} points")));
}
let points = pts
.iter()
.map(|pt| {
let xy = pt.as_array().filter(|a| a.len() == 2)?;
let (x, y) = (xy.first()?.as_f64()?, xy.get(1)?.as_f64()?);
let ok = |v: f64| v.is_finite() && v.abs() <= 1e7;
(ok(x) && ok(y)).then_some((x as f32, y as f32))
})
.collect::<Option<Vec<_>>>()
.ok_or_else(|| bad(CMD, "stroke points must be [x,y] pairs of finite numbers"))?;
let range = matting::BRUSH_SIZE_RANGE;
let size = match o.get("size") {
None => 30.0,
Some(v) => v
.as_f64()
.filter(|s| s.is_finite() && (f64::from(*range.start())..=f64::from(*range.end())).contains(s))
.ok_or_else(|| bad(CMD, &format!("stroke size must be {}..{} px", range.start(), range.end())))? as f32,
};
let add = match o.get("mode").map(|m| m.as_str()) {
None | Some(Some("add")) => true,
Some(Some("subtract")) => false,
Some(_) => return Err(bad(CMD, "stroke mode must be add|subtract")),
};
strokes.push(matting::EdgeStroke { points, size, add });
}
if matting::swept_area(&strokes) > matting::MAX_SWEPT_AREA {
return Err(bad(CMD, "refineStrokes paint too large an area"));
}
Ok(strokes)
}
/// Select and Mask: refines the selection edge, then outputs to the selection, a layer mask on
/// the active layer, or a new layer (with or without a mask). Decontaminating colours needs a
/// new layer, so it upgrades `selection` / `layerMask` output to `newLayerWithMask` (as
/// Photoshop does). New-layer outputs hide the source layer.
fn refine_edge(s: &mut Session, p: &Value) -> Result<Value> {
let params = refine_params(p);
let strokes = refine_strokes(p)?;
let decontaminate = b(p, "decontaminate", false);
let amount = f(p, "amount", 100.0).clamp(0.0, 100.0);
let d = s.active().ok_or(EngineError::NoDocument)?;
@ -227,7 +281,8 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result<Value> {
let region = with_sampler(s, b(p, "sampleAllLayers", false), |smp, doc| {
let mask_surf = active.and_then(|id| doc.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| &m.surface);
let sel = doc.selection.as_ref().or(mask_surf)?;
matting::refine_mask(smp, &matting::surface_reader(sel), sel.content_bounds(), doc.bounds(), &params)
let brush = matting::EdgeBrush::new(&strokes, doc.bounds());
matting::refine_mask_brushed(smp, &matting::surface_reader(sel), sel.content_bounds(), doc.bounds(), &params, brush.as_ref())
})?;
let d = s.active().ok_or(EngineError::NoDocument)?;
let active = d.active_layer.filter(|id| d.doc.layer(*id).is_some());
@ -319,7 +374,7 @@ pub fn specs() -> Vec<CommandSpec> {
"select.refineEdge",
"Refine Edge",
[],
r##"{"radius":px=0,"smartRadius":bool=false,"smooth":0..100=0,"feather":px=0,"contrast":0..100=0,"shiftEdge":-100..100=0,"decontaminate":bool=false,"amount":0..100=100,"output":"selection|layerMask|newLayer|newLayerWithMask"="selection","sampleAllLayers":bool=false}"##,
r##"{"radius":px=0,"smartRadius":bool=false,"smooth":0..100=0,"feather":px=0,"contrast":0..100=0,"shiftEdge":-100..100=0,"decontaminate":bool=false,"amount":0..100=100,"output":"selection|layerMask|newLayer|newLayerWithMask"="selection","sampleAllLayers":bool=false,"refineStrokes":[{"points":[[x,y],…],"size":px=30,"mode":"add|subtract"="add"},…]=[]} (refineStrokes: the Refine Edge Brush, in painting order: add refines the painted area from the image, subtract keeps the original edge there)"##,
has_selection_or_mask,
refine_edge
),
@ -620,6 +675,41 @@ mod tests {
assert!(cov(&s, 38, 20) == 1.0 && cov(&s, 42, 20) == 0.0);
}
/// The Refine Edge Brush adds strands beyond the radius band, at every depth, and rejects
/// malformed strokes.
#[test]
fn refine_edge_brush_strokes() {
for depth in [8, 16, 32] {
// Warm subject (x < 40) with warm strands every 4th column from x = 60, on blue.
let strand = |x: u32| x < 40 || ((60..84).contains(&x) && x.is_multiple_of(4));
let mut s = session_with(120, 40, depth, |x, _| if strand(x) { [0.9, 0.7, 0.2] } else { [0.1, 0.3, 0.8] });
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap();
let stroke = json!([{"points": [[70, 5], [70, 35]], "size": 30}]);
s.execute("select.refineEdge", json!({"radius": 4, "refineStrokes": stroke})).unwrap();
assert!(cov(&s, 64, 20) > 0.9 && cov(&s, 72, 20) > 0.9, "depth {depth}: strands {}", cov(&s, 64, 20));
assert!(cov(&s, 66, 20) < 0.1 && cov(&s, 100, 20) < 0.01, "depth {depth}: background");
s.execute("edit.undo", json!({})).unwrap();
// Subtract over the same place paints the refinement out again.
let both = json!([{"points": [[70, 5], [70, 35]], "size": 30}, {"points": [[70, 5], [70, 35]], "size": 40, "mode": "subtract"}]);
s.execute("select.refineEdge", json!({"radius": 4, "refineStrokes": both})).unwrap();
assert!(cov(&s, 64, 20) < 0.01, "depth {depth}");
s.execute("edit.undo", json!({})).unwrap();
for bad in [
json!(7),
json!([5]),
json!([{"points": []}]),
json!([{"points": [[1, "x"]]}]),
json!([{"points": [[1, 2, 3]]}]),
json!([{"points": [[1, 2]], "size": 0}]),
json!([{"points": [[1, 2]], "size": 1e9}]),
json!([{"points": [[1, 2]], "mode": "xor"}]),
json!([{"points": [[0, 0], [1e6, 1e6], [0, 0], [1e6, 1e6]], "size": 5000}]),
] {
assert!(s.execute("select.refineEdge", json!({"refineStrokes": bad})).is_err(), "{bad}");
}
}
}
#[test]
fn refine_edge_works_on_layer_mask_without_selection() {
let mut s = blurred_edge_selected();

View File

@ -2810,7 +2810,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
let picking = primary && (crate::color_picker_ui::top(app).is_some() || curves_picking);
let range_picking =
primary && app.ui.dialogs.last().is_some_and(|d| crate::color_range_ui::owns(&d.fields) && crate::color_range_ui::controls(&d.fields).sampling);
let hand = app.ui.tool == Tool::Hand && !picking && !range_picking;
// Select and Mask's Refine Edge Brush paints on the image.
let refine_brushing =
primary && app.ui.dialogs.last().is_some_and(|d| crate::select_mask_view::owns(&d.fields) && crate::select_mask_view::brush_on(&d.fields));
let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing;
if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) {
let d = xf.unmap_vec(d) / (view.zoom / ppp);
view.center[0] -= d.x;
@ -2832,6 +2835,12 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
crate::color_picker_ui::sample_at(app, d[0], d[1]);
}
}
} else if refine_brushing
&& !middle
&& let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect)
{
let press = crate::dialogs::free_press(&ctx, rect).map(|q| xf.to_doc(q));
crate::select_mask_view::canvas_brush(app, &painter, p, press, xf.zoom_x());
} else if primary
&& !middle
&& let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect).filter(|p| img_rect.contains(*p))

View File

@ -2572,6 +2572,12 @@ cameraData Lens Objektiv
Show Edge Zobrazit okraj
Show Original Zobrazit originál
F cycles the views; hold X to see the image F přepíná zobrazení; podržením X uvidíte obraz
# Select and Mask Refine Edge Brush (#2972)
Refine Edge Brush (R) Štětec pro upřesnění okraje (R)
Expand Detection Area Rozšířit oblast detekce
Restore Original Edge Obnovit původní okraj
Paint over hair or fur on the image; Alt paints the other mode Malujte přes vlasy nebo srst v obraze; Alt maluje v opačném režimu
Clear Brush Strokes Vymazat tahy štětce
Show rulers Zobrazit pravítka
# Color Sampler (added with the i18n coverage repair)

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2603,6 +2603,12 @@ cameraData Lens Objektiv
Show Edge Kante anzeigen
Show Original Original anzeigen
F cycles the views; hold X to see the image F wechselt die Ansicht; X gedrückt halten zeigt das Bild
# Select and Mask Refine Edge Brush (#2972)
Refine Edge Brush (R) Kante-verbessern-Pinsel (R)
Expand Detection Area Erkennungsbereich erweitern
Restore Original Edge Ursprüngliche Kante wiederherstellen
Paint over hair or fur on the image; Alt paints the other mode Über Haare oder Fell im Bild malen; Alt malt im anderen Modus
Clear Brush Strokes Pinselstriche löschen
Show rulers Lineale anzeigen
# Color Sampler (added with the i18n coverage repair)

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3664,6 +3664,12 @@ cameraData Lens Φακός
Show Edge Εμφάνιση άκρης
Show Original Εμφάνιση αρχικού
F cycles the views; hold X to see the image Το F εναλλάσσει τις προβολές· κρατήστε το X για να δείτε την εικόνα
# Select and Mask Refine Edge Brush (#2972)
Refine Edge Brush (R) Πινέλο βελτίωσης ακμής (R)
Expand Detection Area Επέκταση περιοχής ανίχνευσης
Restore Original Edge Επαναφορά αρχικής ακμής
Paint over hair or fur on the image; Alt paints the other mode Βάψτε πάνω σε μαλλιά ή γούνα στην εικόνα· το Alt βάφει με την άλλη λειτουργία
Clear Brush Strokes Απαλοιφή πινελιών
Show rulers Εμφάνιση χαράκων
# Color Sampler (added with the i18n coverage repair)

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -2644,6 +2644,12 @@ cameraData Lens Objetivo
Show Edge Mostrar borde
Show Original Mostrar original
F cycles the views; hold X to see the image F alterna las vistas; mantén X para ver la imagen
# Select and Mask Refine Edge Brush (#2972)
Refine Edge Brush (R) Pincel para perfeccionar bordes (R)
Expand Detection Area Ampliar área de detección
Restore Original Edge Restaurar borde original
Paint over hair or fur on the image; Alt paints the other mode Pinte sobre el pelo o el pelaje de la imagen; Alt pinta en el otro modo
Clear Brush Strokes Borrar trazos de pincel
Show rulers Mostrar reglas
# Color Sampler (added with the i18n coverage repair)

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -2981,6 +2981,12 @@ cameraData Lens Objectif
Show Edge Afficher le bord
Show Original Afficher l'original
F cycles the views; hold X to see the image F fait défiler les vues ; maintenez X pour voir l'image
# Select and Mask Refine Edge Brush (#2972)
Refine Edge Brush (R) Pinceau d'amélioration du contour (R)
Expand Detection Area Étendre la zone de détection
Restore Original Edge Restaurer le contour d'origine
Paint over hair or fur on the image; Alt paints the other mode Peignez sur les cheveux ou la fourrure dans l'image ; Alt peint dans l'autre mode
Clear Brush Strokes Effacer les traits de pinceau
Show rulers Afficher les règles
# Color Sampler (added with the i18n coverage repair)

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2968,6 +2968,12 @@ cameraData Lens Lensa
Show Edge Tampilkan Tepi
Show Original Tampilkan Asli
F cycles the views; hold X to see the image F berganti tampilan; tahan X untuk melihat gambar
# Select and Mask Refine Edge Brush (#2972)
Refine Edge Brush (R) Kuas Perhalus Tepi (R)
Expand Detection Area Perluas Area Deteksi
Restore Original Edge Pulihkan Tepi Asli
Paint over hair or fur on the image; Alt paints the other mode Lukis di atas rambut atau bulu pada gambar; Alt melukis dalam mode lainnya
Clear Brush Strokes Hapus Goresan Kuas
Show rulers Tampilkan Penggaris
# Color Sampler (added with the i18n coverage repair)

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -2635,6 +2635,12 @@ cameraData Lens Obiettivo
Show Edge Mostra bordo
Show Original Mostra originale
F cycles the views; hold X to see the image F scorre le viste; tieni premuto X per vedere l'immagine
# Select and Mask Refine Edge Brush (#2972)
Refine Edge Brush (R) Pennello per migliorare il bordo (R)
Expand Detection Area Espandi area di rilevamento
Restore Original Edge Ripristina bordo originale
Paint over hair or fur on the image; Alt paints the other mode Dipingi su capelli o pelo nell'immagine; Alt dipinge nell'altra modalità
Clear Brush Strokes Cancella tratti del pennello
Show rulers Mostra righelli
# Color Sampler (added with the i18n coverage repair)

Can't render this file because it has a wrong number of fields in line 16.

View File

@ -3578,6 +3578,12 @@ cameraData Lens レンズ
Show Edge エッジを表示
Show Original 元の状態を表示
F cycles the views; hold X to see the image F で表示を切り替え、X を押している間は画像を表示
# Select and Mask Refine Edge Brush (#2972)
Refine Edge Brush (R) 境界線調整ブラシ (R)
Expand Detection Area 検出領域を拡張
Restore Original Edge 元のエッジを復元
Paint over hair or fur on the image; Alt paints the other mode 画像の髪や毛の上をペイントします。Alt で反対のモードでペイント
Clear Brush Strokes ブラシストロークを消去
Show rulers 定規を表示
# Color Sampler (added with the i18n coverage repair)

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3147,6 +3147,12 @@ cameraData Lens 렌즈
Show Edge 가장자리 표시
Show Original 원본 표시
F cycles the views; hold X to see the image F로 보기 전환, X를 누르고 있으면 이미지 표시
# Select and Mask Refine Edge Brush (#2972)
Refine Edge Brush (R) 가장자리 다듬기 브러시 (R)
Expand Detection Area 감지 영역 확장
Restore Original Edge 원래 가장자리 복원
Paint over hair or fur on the image; Alt paints the other mode 이미지의 머리카락이나 털 위를 칠합니다. Alt는 다른 모드로 칠합니다
Clear Brush Strokes 브러시 획 지우기
Show rulers 눈금자 표시
# Color Sampler (added with the i18n coverage repair)

Can't render this file because it has a wrong number of fields in line 11.

View File

@ -3724,6 +3724,12 @@ cameraData Lens Lens
Show Edge Rand tonen
Show Original Origineel tonen
F cycles the views; hold X to see the image F wisselt de weergaven; houd X ingedrukt om de afbeelding te zien
# Select and Mask Refine Edge Brush (#2972)
Refine Edge Brush (R) Penseel Rand verfijnen (R)
Expand Detection Area Detectiegebied uitbreiden
Restore Original Edge Oorspronkelijke rand herstellen
Paint over hair or fur on the image; Alt paints the other mode Schilder over haar of vacht in de afbeelding; Alt schildert in de andere modus
Clear Brush Strokes Penseelstreken wissen
Show rulers Linialen tonen
Frame Tool Kadergereedschap
Create a new rectangular frame Nieuw rechthoekig kader maken

Can't render this file because it contains an unexpected character in line 8 and column 34.

View File

@ -3579,6 +3579,12 @@ cameraData Lens Obiektyw
Show Edge Pokaż krawędź
Show Original Pokaż oryginał
F cycles the views; hold X to see the image F przełącza widoki; przytrzymaj X, aby zobaczyć obraz
# Select and Mask Refine Edge Brush (#2972)
Refine Edge Brush (R) Pędzel dopracowania krawędzi (R)
Expand Detection Area Rozszerz obszar wykrywania
Restore Original Edge Przywróć oryginalną krawędź
Paint over hair or fur on the image; Alt paints the other mode Maluj po włosach lub sierści na obrazie; Alt maluje w drugim trybie
Clear Brush Strokes Wyczyść pociągnięcia pędzla
Show rulers Pokaż linijki
# Color Sampler (added with the i18n coverage repair)

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2988,6 +2988,12 @@ cameraData Lens Lente
Show Edge Mostrar borda
Show Original Mostrar original
F cycles the views; hold X to see the image F alterna as visualizações; segure X para ver a imagem
# Select and Mask Refine Edge Brush (#2972)
Refine Edge Brush (R) Pincel de refinamento de borda (R)
Expand Detection Area Expandir área de detecção
Restore Original Edge Restaurar borda original
Paint over hair or fur on the image; Alt paints the other mode Pinte sobre cabelo ou pelo na imagem; Alt pinta no outro modo
Clear Brush Strokes Limpar traços do pincel
Show rulers Mostrar réguas
# Color Sampler (added with the i18n coverage repair)

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2904,6 +2904,12 @@ cameraData Lens Объектив
Show Edge Показать край
Show Original Показать исходное
F cycles the views; hold X to see the image F переключает виды; удерживайте X, чтобы увидеть изображение
# Select and Mask Refine Edge Brush (#2972)
Refine Edge Brush (R) Кисть уточнения края (R)
Expand Detection Area Расширить область обнаружения
Restore Original Edge Восстановить исходный край
Paint over hair or fur on the image; Alt paints the other mode Рисуйте по волосам или шерсти на изображении; Alt рисует в другом режиме
Clear Brush Strokes Очистить мазки кисти
Show rulers Показать линейки
New Crop Preset… Новый набор параметров кадрирования…
Delete Crop Preset… Удалить набор параметров кадрирования…

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3654,6 +3654,12 @@ cameraData Lens Об'єктив
Show Edge Показати край
Show Original Показати початкове
F cycles the views; hold X to see the image F перемикає види; утримуйте X, щоб побачити зображення
# Select and Mask Refine Edge Brush (#2972)
Refine Edge Brush (R) Пензель уточнення краю (R)
Expand Detection Area Розширити область виявлення
Restore Original Edge Відновити початковий край
Paint over hair or fur on the image; Alt paints the other mode Малюйте по волоссю чи хутру на зображенні; Alt малює в іншому режимі
Clear Brush Strokes Очистити мазки пензля
Show rulers Показати лінійки
Frame Tool Інструмент «Рамка»
Create a new rectangular frame Створити прямокутну рамку

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -2573,6 +2573,12 @@ cameraData Lens 镜头
Show Edge 显示边缘
Show Original 显示原始
F cycles the views; hold X to see the image 按 F 切换视图;按住 X 查看图像
# Select and Mask Refine Edge Brush (#2972)
Refine Edge Brush (R) 调整边缘画笔 (R)
Expand Detection Area 扩展检测区域
Restore Original Edge 恢复原始边缘
Paint over hair or fur on the image; Alt paints the other mode 在图像中的头发或毛发上涂抹;按住 Alt 以另一种模式涂抹
Clear Brush Strokes 清除画笔描边
Show rulers 显示标尺
New Crop Preset… 新建裁剪预设…
Delete Crop Preset… 删除裁剪预设…

Can't render this file because it has a wrong number of fields in line 15.

View File

@ -2543,6 +2543,12 @@ cameraData Lens 鏡頭
Show Edge 顯示邊緣
Show Original 顯示原始
F cycles the views; hold X to see the image 按 F 切換檢視;按住 X 檢視影像
# Select and Mask Refine Edge Brush (#2972)
Refine Edge Brush (R) 調整邊緣筆刷 (R)
Expand Detection Area 擴展偵測區域
Restore Original Edge 恢復原始邊緣
Paint over hair or fur on the image; Alt paints the other mode 在影像中的頭髮或毛髮上塗抹;按住 Alt 以另一種模式塗抹
Clear Brush Strokes 清除筆刷筆觸
Show rulers 顯示尺標
New Crop Preset… 新增裁切預設集…
Delete Crop Preset… 刪除裁切預設集…

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -129,12 +129,19 @@ pub fn owns(fields: &Map<String, Value>) -> bool {
}
/// A view key pressed in the dialog: a view letter picks that view, F the next one, J toggles
/// Show Edge and P Show Original. Returns whether the key was one of them.
/// Show Edge and P Show Original, R the Refine Edge Brush, and [ / ] change its size. Returns
/// whether the key was one of them.
pub fn apply_key(fields: &mut Map<String, Value>, key: egui::Key) -> bool {
if key == egui::Key::F {
fields.insert("__view".into(), json!(mode(fields).next().id()));
} else if let Some(m) = Mode::ALL.into_iter().find(|m| m.key() == key) {
fields.insert("__view".into(), json!(m.id()));
} else if key == egui::Key::R {
fields.insert("__brush".into(), json!(!brush_on(fields)));
} else if key == egui::Key::OpenBracket || key == egui::Key::CloseBracket {
let size = brush_size(fields);
let next = if key == egui::Key::OpenBracket { (size * 0.9).min(size - 1.0) } else { (size * 1.1).max(size + 1.0) };
fields.insert("__brushSize".into(), json!(next.round().clamp(BRUSH_SIZES.0, BRUSH_SIZES.1)));
} else if key == egui::Key::J || key == egui::Key::P {
let field = if key == egui::Key::J { "__showEdge" } else { "__showOriginal" };
fields.insert(field.into(), json!(!flag(fields, field)));
@ -193,6 +200,114 @@ pub fn controls(ui: &mut egui::Ui, fields: &mut Map<String, Value>) {
ui.add_space(4.0);
crate::widgets::hairline(ui);
ui.add_space(6.0);
brush_controls(ui, fields);
}
/// The Refine Edge Brush (R): painting on the canvas sets where edge refinement happens.
fn brush_controls(ui: &mut egui::Ui, fields: &mut Map<String, Value>) {
let t = Tokens::get(ui.ctx());
let mut on = brush_on(fields);
crate::widgets::checkbox(ui, &mut on, tl!("Refine Edge Brush (R)"));
fields.insert("__brush".into(), json!(on));
if on {
let mut subtract = brush_subtract(fields);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Mode")).color(t.text_dim));
let options = [(false, tl!("Expand Detection Area")), (true, tl!("Restore Original Edge"))];
crate::widgets::dropdown(ui, "select-mask-brush-mode", &mut subtract, &options, 170.0);
});
fields.insert("__brushMode".into(), json!(if subtract { "subtract" } else { "add" }));
let mut size = brush_size(fields);
crate::widgets::slider_row(ui, tl!("Size"), &mut size, BRUSH_SIZES.0..=BRUSH_SIZES.1, "px", None);
fields.insert("__brushSize".into(), json!(size.round().clamp(BRUSH_SIZES.0, BRUSH_SIZES.1)));
ui.label(egui::RichText::new(tl!("Paint over hair or fur on the image; Alt paints the other mode")).color(t.text_dim).small());
}
let painted = fields.get("refineStrokes").and_then(Value::as_array).is_some_and(|a| !a.is_empty());
if painted && crate::widgets::secondary_button(ui, tl!("Clear Brush Strokes"), 0.0).clicked() {
fields.remove("refineStrokes");
}
ui.add_space(4.0);
crate::widgets::hairline(ui);
ui.add_space(6.0);
}
/// The Refine Edge Brush's diameters (px) in the dialog.
const BRUSH_SIZES: (f32, f32) = (1.0, 1000.0);
/// Whether the Refine Edge Brush is the dialog's tool.
pub fn brush_on(fields: &Map<String, Value>) -> bool {
flag(fields, "__brush")
}
fn brush_size(fields: &Map<String, Value>) -> f32 {
let size = fields.get("__brushSize").and_then(Value::as_f64).filter(|v| v.is_finite()).map_or(30.0, |v| v as f32);
size.clamp(BRUSH_SIZES.0, BRUSH_SIZES.1)
}
/// The brush restores the original edge (Photoshop's − mode) instead of expanding the area.
fn brush_subtract(fields: &Map<String, Value>) -> bool {
fields.get("__brushMode").and_then(Value::as_str) == Some("subtract")
}
/// Paints the Refine Edge Brush at document point `at` into the dialog's `refineStrokes`:
/// `start` begins a new stroke (in `subtract` mode or not), otherwise the point extends the last
/// one. Points closer than an eighth of the brush to the previous one are skipped, and painting
/// stops at the command's stroke and point limits.
pub fn paint(fields: &mut Map<String, Value>, at: [f64; 2], start: bool, subtract: bool) {
if !at[0].is_finite() || !at[1].is_finite() {
return;
}
let size = f64::from(brush_size(fields));
let mut strokes = fields.get("refineStrokes").and_then(Value::as_array).cloned().unwrap_or_default();
let points = |s: &Value| s.get("points").and_then(Value::as_array).map_or(0, Vec::len);
if strokes.iter().map(points).sum::<usize>() >= photocraft_engine::smartselect_cmds::MAX_STROKE_POINTS {
return;
}
let round = |v: f64| (v * 10.0).round() / 10.0;
let pt = json!([round(at[0]), round(at[1])]);
let room = strokes.len() < photocraft_engine::smartselect_cmds::MAX_STROKES;
let last = strokes.last_mut().and_then(|s| s.get_mut("points")).and_then(Value::as_array_mut);
match last {
Some(pts) if !start => {
let prev = pts.last().and_then(|p| Some((p.get(0)?.as_f64()?, p.get(1)?.as_f64()?)));
if prev.is_none_or(|(x, y)| (x - at[0]).hypot(y - at[1]) >= (size / 8.0).max(1.0)) {
pts.push(pt);
}
}
_ if room => {
strokes.push(json!({"points": [pt], "size": size, "mode": if subtract { "subtract" } else { "add" }}));
}
_ => return,
}
fields.insert("refineStrokes".into(), Value::Array(strokes));
}
/// The canvas under a Select and Mask dialog with the Refine Edge Brush on: shows the brush at
/// `over` (zoom = screen points per document pixel) and paints at document point `press`. False
/// when the brush isn't the top dialog's tool.
pub fn canvas_brush(app: &mut PhotocraftApp, painter: &egui::Painter, over: egui::Pos2, press: Option<[f64; 2]>, zoom: f32) -> bool {
let ctx = painter.ctx().clone();
let Some(d) = app.ui.dialogs.last_mut().filter(|d| owns(&d.fields) && brush_on(&d.fields)) else { return false };
let (alt, start) = ctx.input(|i| (i.modifiers.alt, i.pointer.primary_pressed()));
// Alt paints in the other mode, as in Photoshop.
let subtract = brush_subtract(&d.fields) != alt;
let r = (brush_size(&d.fields) / 2.0 * zoom).max(1.5);
let (dark, light) = (egui::Stroke::new(1.0, egui::Color32::from_black_alpha(140)), egui::Stroke::new(1.0, egui::Color32::from_white_alpha(220)));
painter.circle_stroke(over, r + 0.5, dark);
painter.circle_stroke(over, r, light);
let arm = 3.0;
for stroke in [egui::Stroke::new(2.5, egui::Color32::from_black_alpha(140)), egui::Stroke::new(1.0, egui::Color32::from_white_alpha(220))] {
painter.line_segment([over - egui::vec2(arm, 0.0), over + egui::vec2(arm, 0.0)], stroke);
if !subtract {
painter.line_segment([over - egui::vec2(0.0, arm), over + egui::vec2(0.0, arm)], stroke);
}
}
ctx.set_cursor_icon(egui::CursorIcon::None);
if let Some(at) = press {
paint(&mut d.fields, at, start, subtract);
ctx.request_repaint();
}
true
}
/// The preview request: the command's params plus the view (`__view`), so a view change
@ -268,6 +383,30 @@ impl Coverage {
Coverage { area, values: grown.iter().zip(&shrunk).map(|(g, s)| (g - s).clamp(0.0, 1.0)).collect(), outside: 0.0 }
}
/// Coverage at canvas pixel `(x, y)`.
fn at(&self, x: i32, y: i32) -> f32 {
if !self.area.contains(x, y) {
return self.outside;
}
let i = (y - self.area.y0) as usize * self.area.width() as usize + (x - self.area.x0) as usize;
self.values.get(i).copied().unwrap_or(self.outside)
}
/// The edge band as the Refine Edge Brush changes it: painted areas join it, restored ones
/// leave it.
fn with_brush(self, brush: Option<&photocraft_algo::matting::EdgeBrush>, canvas: Rect) -> Coverage {
let Some(brush) = brush else { return self };
let area = self.area.union(&brush.bounds()).intersect(&canvas);
let values = (area.y0..area.y1)
.flat_map(|y| (area.x0..area.x1).map(move |x| (x, y)))
.map(|(x, y)| {
let (v, b) = (self.at(x, y), brush.at(x, y));
if b >= 0.0 { v.max(b) } else { v * (1.0 + b) }
})
.collect();
Coverage { area, values, outside: self.outside }
}
/// A GRAY8 mask surface holding `f(coverage)`.
fn surface(&self, f: impl Fn(f32) -> f32) -> Surface {
let g = |v: f32| f(v).clamp(0.0, 1.0);
@ -317,7 +456,8 @@ pub fn preview_document(doc: &Document, active: Option<LayerId>, params: &Value,
let original = proxy.selection.clone().or_else(|| active.and_then(|id| proxy.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| m.surface.clone()));
let canvas = Rect::new(0, 0, i32::try_from(proxy.size.width).unwrap_or(i32::MAX), i32::try_from(proxy.size.height).unwrap_or(i32::MAX));
let coverage = if view.show_edge {
Coverage::edge_band(original.as_ref(), canvas, length(params, "radius", k).round().clamp(1.0, 250.0) as usize)
let band = Coverage::edge_band(original.as_ref(), canvas, length(params, "radius", k).round().clamp(1.0, 250.0) as usize);
band.with_brush(scaled_strokes(params, k).as_ref().and_then(|s| photocraft_algo::matting::EdgeBrush::new(s, canvas)).as_ref(), canvas)
} else if view.show_original {
Coverage::read(original.as_ref(), canvas)
} else {
@ -344,10 +484,54 @@ fn refine(proxy: &Document, active: Option<LayerId>, params: &Value, k: u32, can
p.insert(key.into(), json!(length(params, key, k)));
}
}
if let Some(strokes) = p.get("refineStrokes").and_then(Value::as_array).cloned() {
let k = f64::from(k.max(1));
let scale = |v: &Value| v.as_f64().map_or(v.clone(), |n| json!(n / k));
let scaled: Vec<Value> = strokes
.into_iter()
.map(|mut s| {
if let Some(o) = s.as_object_mut() {
if let Some(size) = o.get("size").and_then(Value::as_f64) {
o.insert("size".into(), json!((size / k).max(1.0)));
}
if let Some(pts) = o.get_mut("points").and_then(Value::as_array_mut) {
for pt in pts.iter_mut() {
if let Some(xy) = pt.as_array_mut() {
xy.iter_mut().for_each(|v| *v = scale(v));
}
}
}
}
s
})
.collect();
p.insert("refineStrokes".into(), Value::Array(scaled));
}
s.execute(COMMAND, Value::Object(p)).ok()?;
Some(s.active()?.doc.selection.clone())
}
/// The Refine Edge Brush strokes of `params`, reduced by `k`, for Show Edge; `None` when there
/// are none (or they are malformed: the refinement itself reports those).
fn scaled_strokes(params: &Value, k: u32) -> Option<Vec<photocraft_algo::matting::EdgeStroke>> {
let k = k.max(1) as f32;
params
.get("refineStrokes")?
.as_array()?
.iter()
.map(|s| {
let points = s
.get("points")?
.as_array()?
.iter()
.map(|p| Some((p.get(0)?.as_f64()? as f32 / k, p.get(1)?.as_f64()? as f32 / k)))
.collect::<Option<Vec<_>>>()?;
let size = (s.get("size").and_then(Value::as_f64).unwrap_or(30.0) as f32 / k).max(1.0);
Some(photocraft_algo::matting::EdgeStroke { points, size, add: s.get("mode").and_then(Value::as_str) != Some("subtract") })
})
.collect()
}
/// A solid fill layer named for the view.
fn veil(name: &str, color: Color, opacity: f32, mask: Option<Surface>) -> Layer {
let mut l = Layer::new(name, LayerContent::Fill(Fill::Solid(color)));
@ -472,6 +656,42 @@ mod tests {
assert!(preview_document(&doc, None, &bad, 3, None).is_some());
}
/// Refine Edge Brush strokes painted in the dialog reach the (reduced) preview: strands
/// beyond the band join the selection, and Show Edge includes the painted area.
#[test]
fn brush_strokes_reach_the_preview() {
// Warm subject (x < 40) with warm strands every 4th column from x = 60, on blue.
let strand = |x: u32| x < 40 || ((60..84).contains(&x) && x.is_multiple_of(4));
let mut doc = Document::with_background("d", Size::new(120, 40), ColorMode::Rgb, SampleType::U8, Color::WHITE);
if let Some(bg) = doc.layers.first_mut().and_then(|l| l.surface_mut()) {
let n = bg.channels();
let data: Vec<f32> = (0..40u32)
.flat_map(|_| (0..120u32).flat_map(move |x| if strand(x) { [0.9, 0.7, 0.2, 1.0] } else { [0.1, 0.3, 0.8, 1.0] }.into_iter().take(n)))
.collect();
bg.write_region(Rect::new(0, 0, 120, 40), &data);
}
let mut sel = Surface::new(PixelFormat::GRAY8);
sel.fill_rect(Rect::new(0, 0, 40, 40), &[1.0]);
doc.selection = Some(sel);
let active = doc.layers.first().map(|l| l.id);
let mut f = Map::new();
apply_key(&mut f, egui::Key::R);
assert!(brush_on(&f), "R picks the brush");
f.insert("__brushSize".into(), json!(30));
paint(&mut f, [70.0, 2.0], true, false);
paint(&mut f, [70.0, 2.5], false, false);
paint(&mut f, [70.0, 38.0], false, false);
let strokes = f.get("refineStrokes").cloned().unwrap_or_default();
assert_eq!(strokes, json!([{"points": [[70.0, 2.0], [70.0, 38.0]], "size": 30.0, "mode": "add"}]), "near points are skipped");
let mut params = json!({"radius": 4, "refineStrokes": strokes, "__view": {"mode": "blackWhite"}});
let shown = preview_document(&doc, active, &params, 2, None).expect("a preview");
assert!(pixel(&shown, 32, 10)[0] > 0.8, "a strand at full size x = 64 joins the selection");
assert!(pixel(&shown, 50, 10)[0] < 0.01, "the background past the strands stays out");
params["__view"]["showEdge"] = json!(true);
let edge = preview_document(&doc, active, &params, 2, None).expect("a preview");
assert!(pixel(&edge, 35, 10)[0] > 0.99 && pixel(&edge, 55, 10)[0] < 0.01, "Show Edge includes the painted area");
}
/// Show Edge shows the band around the selection's edge; Show Original the unrefined selection.
#[test]
fn show_edge_and_show_original() {

View File

@ -65,8 +65,8 @@ what an agent can't finish alone.
- **Estimate:** 60–100 h (2–9 h each, per the calibration).
### G7. Workspaces shown as parameter dialogs
- **Missing:** Select and Mask workspace layout, Refine Edge brush and outputs
(TOOL-214-1, Refine Hair #2671; its view modes preview live since #1754); Content-Aware Fill workspace with a paintable sampling area
- **Missing:** Select and Mask workspace layout and New Document outputs
(TOOL-214-1, Refine Hair #2671; its view modes preview live since #1754, Refine Edge Brush #2972); Content-Aware Fill workspace with a paintable sampling area
(TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Scale through the
transform box (TOOL-214-7); Puppet Warp mesh following the outline (TOOL-214-6); Content-Aware
Move's Transform On Drop (TOOL-213-4).

View File

@ -145,7 +145,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| TOOL-213-13 | Brush regression tests: no seams, hard edge, zero-scatter defaults, ABR never black | done | [#213](https://github.com/storytold/photocraft/issues/213) | Seam, hard-edge and zero-scatter tests in brush_tests.rs; ABR tip colour test in abr_map/tests.rs |
| TOOL-213-14 | Tablet: no OS arrow cursor over the canvas on macOS, Windows and Linux | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Brush tools set CursorIcon::None (canvas.rs:1259-1275); not verified per OS |
| TOOL-213-18 | Pattern Stamp tool: sibling of Clone Stamp in the S flyout | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::PatternStamp in the S group; paint.patternStamp stamps the Patterns-panel tile with Aligned and Impressionist (an approximation: it jitters each dab's tile phase, not Photoshop's smeared impressionist look); engine tests cover depths, seams, hostile input |
| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); no workspace layout or Refine Edge brush |
| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask; no workspace layout or New Document outputs |
| TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs |
| TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Fuzziness-0 tests cover midtones/shadows only (selection_cmds.rs:444-447) |
| TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs, layer.selectInRect, #2844) done; double-click fit and drag to a tab open |

View File

@ -115,7 +115,7 @@ id = "TOOL-214-1"
target = "Select and Mask workspace: view modes, Refine Edge brush, 6 outputs"
status = "partial"
issue = 214
note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); no workspace layout or Refine Edge brush"
note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask; no workspace layout or New Document outputs"
[[item]]
id = "TOOL-214-2"