Make Select and Mask refinement and previews tile-sparse (#10732)

* Make Select and Mask refinement and previews tile-sparse

* Preserve finite mask extent when copying sparse pixels

* Keep solid preview coverage shared during conversion

* Preserve alpha sharing and document sparse refinement limits

* Refine mixed tiles in bounded parallel batches

* Skip guide sampling for constant refinement halos

* Balance sparse refinement batches using measured queue size

* Preserve default alpha and cancel sparse refinement cooperatively

* Address review follow-ups: cancellation after completed flush, stroke assertions, and docstrings

* Fix merge: bind tile bytes to avoid temporary borrow in matting

---------

Co-authored-by: FredApps <fredapps@users.noreply.github.com>
This commit is contained in:
Khawar Zamman WaniandFredApps authored and GitHub committed 2026-11-05 14:22:03 +01:00
1 parent 384ff664d6
commit a38e544faf
12 files changed
+855 -101

No files matched your search

+3 -4
View File
@@ -81,16 +81,15 @@ fn main() {
// Refine edge on a 20 px band (selection = the object result as a surface).
let sel = combine_region(None, Some(&obj), SelectionMode::Replace).expect("selection");
let content = sel.content_bounds();
let p = RefineParams { radius: 20.0, smooth: 10.0, feather: 1.0, contrast: 10.0, ..Default::default() };
let t = Instant::now();
let refined = matting::refine_mask(&sampler, &matting::surface_reader(&sel), content, canvas, &p).expect("refined");
let refined = matting::refine_surface(&sampler, &sel, canvas, &p, None).expect("allocation").expect("refined");
println!("refine edge (radius 20): {:.2?}", t.elapsed());
let t = Instant::now();
let _ = matting::refine_mask(&sampler, &matting::surface_reader(&sel), content, canvas, &RefineParams { smart_radius: true, ..p }).expect("refined");
let _ = matting::refine_surface(&sampler, &sel, canvas, &RefineParams { smart_radius: true, ..p }, None).expect("allocation").expect("refined");
println!("refine edge (radius 20, smart): {:.2?}", t.elapsed());
let t = Instant::now();
let dec = matting::decontaminate(&s, &refined, 20.0, 100.0);
let dec = matting::decontaminate_surface(&s, &refined, 20.0, 100.0);
println!("decontaminate: {:.2?} ({} tiles)", t.elapsed(), dec.tile_count());
// Focus area.
+494 -8
View File
@@ -391,14 +391,7 @@ fn shift_coverage(v: f32, k: f32) -> f32 {
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`]
/// (about two bytes a pixel while it is assembled), so callers refuse a larger refine up front
/// rather than allocate it: 2³⁰ pixels, a 32k × 32k selection.
pub const MAX_REFINE_AREA: u64 = 1 << 30;
/// The area [`refine_mask`] and [`refine_mask_brushed`] compute and allocate their result over:
/// `content` grown by how far `p` can spread it, plus where `brush` adds, within `canvas`. Empty
/// when `content` misses the canvas.
/// Bounds reachable by refinement and brush additions, clipped to the canvas.
pub fn refine_area(content: Rect, canvas: Rect, p: &RefineParams, brush: Option<&EdgeBrush>) -> Rect {
let content = content.intersect(&canvas);
if content.is_empty() {
@@ -410,6 +403,7 @@ pub fn refine_area(content: Rect, canvas: Rect, p: &RefineParams, brush: Option<
/// Refines a selection mask. `mask(r)` returns coverage over `r` (row-major); `content` bounds
/// 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.
#[cfg(test)]
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)
}
@@ -417,6 +411,7 @@ pub fn refine_mask(sampler: &dyn Sampler, mask: &(dyn Fn(Rect) -> Vec<f32> + Syn
/// [`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.
#[cfg(test)]
pub fn refine_mask_brushed(
sampler: &dyn Sampler,
mask: &(dyn Fn(Rect) -> Vec<f32> + Sync),
@@ -428,6 +423,8 @@ pub fn refine_mask_brushed(
refine_mask_with(sampler, mask, content, canvas, p, brush)
}
/// Internal helper for dense refinement test oracles.
#[cfg(test)]
fn refine_mask_with(
sampler: &dyn Sampler,
mask: &(dyn Fn(Rect) -> Vec<f32> + Sync),
@@ -497,7 +494,245 @@ fn refine_mask_with(
crate::segment::trim_region(Region { bbox, mask: out })
}
/// Refines sparse coverage without materializing its bounding rectangle.
/// Missing input tiles use the mask's default; output is zero outside the canvas.
pub fn refine_surface(
sampler: &dyn Sampler,
mask: &Surface,
canvas: Rect,
p: &RefineParams,
brush: Option<&EdgeBrush>,
) -> Result<Option<Surface>, photocraft_raster::AllocationError> {
refine_surface_with_interrupt(sampler, mask, canvas, p, brush, &photocraft_raster::Interrupt::NONE)
}
/// Interruptible refinement. Cancellation discards the partial result and returns `Ok(None)`.
/// Callers distinguish cancellation from an empty mask through `interrupt.cancelled()`.
pub fn refine_surface_with_interrupt(
sampler: &dyn Sampler,
mask: &Surface,
canvas: Rect,
p: &RefineParams,
brush: Option<&EdgeBrush>,
interrupt: &photocraft_raster::Interrupt<'_>,
) -> Result<Option<Surface>, photocraft_raster::AllocationError> {
if interrupt.cancelled() {
return Ok(None);
}
use std::collections::{BTreeSet, HashMap};
use std::sync::Arc;
let default = mask.default_pixel().first().copied().unwrap_or(0.0);
let content = if default > 0.0 { canvas } else { mask.content_bounds().intersect(&canvas) };
let bbox = refine_area(content, canvas, p, brush);
if bbox.is_empty() {
return Ok(None);
}
let halo = p.halo().saturating_add(brush.map_or(0, |b| (b.window() as i32).saturating_mul(4)));
// Preserve the dense implementation's block origin, including non-aligned selections.
let mut blocks = BTreeSet::new();
let mut include = |area: Rect| -> Result<(), photocraft_raster::AllocationError> {
let area = area.intersect(&bbox);
if area.is_empty() {
return Ok(());
}
let x0 = (i64::from(area.x0) - i64::from(bbox.x0)) / i64::from(TILE);
let y0 = (i64::from(area.y0) - i64::from(bbox.y0)) / i64::from(TILE);
let x1 = (i64::from(area.x1) - 1 - i64::from(bbox.x0)) / i64::from(TILE);
let y1 = (i64::from(area.y1) - 1 - i64::from(bbox.y0)) / i64::from(TILE);
for y in y0..=y1 {
for x in x0..=x1 {
if interrupt.cancelled() {
return Ok(());
}
blocks.insert((y, x));
if blocks.len() > 1_048_576 {
return Err(photocraft_raster::AllocationError::NotEnoughMemory { bytes: blocks.len().saturating_mul(64) });
}
}
}
Ok(())
};
if default > 0.0 {
include(bbox)?;
} else {
for (tc, _) in mask.tiles() {
if interrupt.cancelled() {
return Ok(None);
}
include(tc.rect().inflate(p.spread()))?;
}
}
if let Some(b) = brush {
for tc in b.tile_coords() {
if interrupt.cancelled() {
return Ok(None);
}
include(tc.rect().inflate(p.spread()))?;
}
}
if interrupt.cancelled() {
return Ok(None);
}
let mut uniform: HashMap<usize, Option<f32>> = HashMap::new();
let mut output = Surface::new(PixelFormat::GRAY8);
let solid = output.solid_tile(&[1.0]);
// Only mixed blocks need expensive filtering. Keep their concurrency and aggregate
// scratch estimate bounded; uniform interiors still take the cheap serial path.
struct Work {
core: Rect,
input: Rect,
painted: Option<Vec<f32>>,
}
let run = |work: &Work| {
let (t, r) = (work.core, work.input);
if interrupt.cancelled() {
return (t, Vec::new());
}
let m = crate::selection::mask_from_surface(Some(mask), r);
let (rw, rh) = (r.width() as usize, r.height() as usize);
let adds = work.painted.as_ref().is_some_and(|v| v.iter().any(|v| *v > 0.0));
// An allocated tile can be mixed while this block's clipped halo is constant.
// Preserve the dense path's local check before sampling the guide image.
let first = m.first().copied().unwrap_or(0.0);
if !adds && (first == 0.0 || first == 1.0) && m.iter().all(|v| *v == first) {
return (t, vec![(first * 255.0).round() as u8; t.width() as usize * t.height() as usize]);
}
let img = (p.uses_image() || adds).then(|| sampler.rgb(r));
let bb = match (&work.painted, brush) {
(Some(v), Some(b)) => Some(BrushBuf { v, window: b.window() }),
_ => None,
};
let refined = refine_buffer_with(img.as_ref(), &m, rw, rh, p, bb);
let mut data = Vec::with_capacity(t.width() as usize * t.height() as usize);
for yy in t.y0..t.y1 {
let start = (yy - r.y0) as usize * rw + (t.x0 - r.x0) as usize;
let end = start + t.width() as usize;
if let Some(row) = refined.get(start..end) {
data.extend(row.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8));
}
}
(t, data)
};
let flush = |pending: &mut Vec<Work>, output: &mut Surface| -> Result<(), photocraft_raster::AllocationError> {
if interrupt.cancelled() {
pending.clear();
return Ok(());
}
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<_> = {
use rayon::prelude::*;
pending.par_iter().map(&run).collect()
};
#[cfg(target_arch = "wasm32")]
let results: Vec<_> = pending.iter().map(&run).collect();
if interrupt.cancelled() {
pending.clear();
return Ok(());
}
for (t, data) in results {
if data.iter().any(|v| *v != 0) {
output.try_write_interleaved(t, &data)?;
for tc in t.tiles() {
if output.tile(tc).is_some_and(|tile| tile.bytes().iter().all(|v| *v == 255)) {
output.put_tiles([(tc, solid.clone())]);
}
}
}
}
pending.clear();
Ok(())
};
let mut pending = Vec::new();
let mut pending_bytes = 0usize;
const MAX_SCRATCH_BYTES: usize = 512 * 1024 * 1024;
// A larger queue lets Rayon balance cheap and expensive diagonal blocks.
// The scratch budget still limits how many blocks can enter a batch.
const MAX_FILTER_BATCH: usize = 24;
for (by, bx) in blocks {
if interrupt.cancelled() {
return Ok(None);
}
let x = (i64::from(bbox.x0) + bx * i64::from(TILE)) as i32;
let y = (i64::from(bbox.y0) + by * i64::from(TILE)) as i32;
let t = Rect::new(x, y, x.saturating_add(TILE).min(bbox.x1), y.saturating_add(TILE).min(bbox.y1));
let r = t.inflate(halo).intersect(&canvas);
let scratch_pixels = (r.width() as usize).checked_mul(r.height() as usize).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
let scratch_bytes = scratch_pixels.checked_mul(256).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
let painted = brush
.filter(|b| b.tile_coords().any(|tc| !tc.rect().intersect(&r).is_empty()))
.map(|b| {
if scratch_bytes > MAX_SCRATCH_BYTES {
return Err(photocraft_raster::AllocationError::NotEnoughMemory { bytes: scratch_bytes });
}
Ok(b.read(r))
})
.transpose()?;
let adds = painted.as_ref().is_some_and(|v| v.iter().any(|v| *v > 0.0));
let mut constant = None;
let mut all_uniform = true;
for tc in r.tiles() {
let value = if let Some(tile) = mask.tile(tc) {
let key = Arc::as_ptr(tile) as usize;
*uniform.entry(key).or_insert_with(|| {
let first = mask.sample_channel(tc.rect().x0, tc.rect().y0, 0);
let fmt = mask.format();
let bytes = fmt.bytes_per_pixel();
let binding = tile.bytes();
let first_pixel = binding.get(..bytes)?;
binding.chunks_exact(bytes).all(|p| p == first_pixel).then_some(first)
})
} else {
Some(default)
};
let Some(value) = value else {
all_uniform = false;
break;
};
if constant.is_some_and(|v| v != value) {
all_uniform = false;
break;
}
constant = Some(value);
}
if !adds && all_uniform && matches!(constant, Some(0.0)) {
continue;
}
if !adds && all_uniform && matches!(constant, Some(1.0)) {
let tc = photocraft_geom::TileCoord::containing(t.x0, t.y0);
if tc.rect() == t {
output.put_tiles([(tc, solid.clone())]);
continue;
}
output.fill_rect(t, &[1.0]);
} else {
if scratch_bytes > MAX_SCRATCH_BYTES {
return Err(photocraft_raster::AllocationError::NotEnoughMemory { bytes: scratch_bytes });
}
if !pending.is_empty() && (pending.len() >= MAX_FILTER_BATCH || pending_bytes.saturating_add(scratch_bytes) > MAX_SCRATCH_BYTES) {
flush(&mut pending, &mut output)?;
pending_bytes = 0;
}
pending_bytes = pending_bytes.saturating_add(scratch_bytes);
pending.push(Work { core: t, input: r, painted });
continue;
}
// Canonical tiles can straddle two legacy blocks. Share them as soon as complete,
// keeping solid interiors compact even when the processing origin is unaligned.
for tc in t.tiles() {
if output.tile(tc).is_some_and(|tile| tile.bytes().iter().all(|v| *v == 255)) {
output.put_tiles([(tc, solid.clone())]);
}
}
}
flush(&mut pending, &mut output)?;
if interrupt.cancelled() {
return Ok(None);
}
Ok((output.tile_count() > 0).then_some(output))
}
/// A mask reader over a [`Region`] (zero outside its box).
#[cfg(test)]
pub fn region_reader(reg: &Region) -> impl Fn(Rect) -> Vec<f32> + Sync + '_ {
move |r: Rect| {
let mut v = Vec::with_capacity(r.width() as usize * r.height() as usize);
@@ -511,6 +746,7 @@ pub fn region_reader(reg: &Region) -> impl Fn(Rect) -> Vec<f32> + Sync + '_ {
}
/// A mask reader over a selection surface (channel 0).
#[cfg(test)]
pub fn surface_reader(s: &Surface) -> impl Fn(Rect) -> Vec<f32> + Sync + '_ {
move |r: Rect| crate::selection::mask_from_surface(Some(s), r)
}
@@ -522,6 +758,8 @@ pub fn region_surface(reg: &Region) -> Surface {
s
}
/// Partitions an area into fixed-size tile rectangles.
#[cfg(test)]
fn tiles_of(area: Rect) -> Vec<Rect> {
let mut tiles = Vec::new();
let mut y = area.y0;
@@ -536,6 +774,8 @@ fn tiles_of(area: Rect) -> Vec<Rect> {
tiles
}
/// Maps a function over tile rectangles in parallel on native targets.
#[cfg(test)]
fn par_tiles<T: Send>(tiles: &[Rect], f: impl Fn(&Rect) -> Option<T> + Sync + Send) -> Vec<T> {
#[cfg(not(target_arch = "wasm32"))]
{
@@ -552,6 +792,7 @@ fn par_tiles<T: Send>(tiles: &[Rect], f: impl Fn(&Rect) -> Option<T> + Sync + Se
/// average colour of nearby fully selected pixels (normalised convolution with a box of radius
/// `max(radius, 3)`, widened ×4 where no such pixel is near), by `amount`% scaled by its
/// transparency. Works in the surface's own colour channels (any model / depth).
#[cfg(test)]
pub fn decontaminate(src: &Surface, alpha: &Region, radius: f32, amount: f32) -> Surface {
let fmt = src.format();
let n = fmt.channels();
@@ -610,8 +851,76 @@ pub fn decontaminate(src: &Surface, alpha: &Region, radius: f32, amount: f32) ->
out
}
/// Decontaminates sparse GRAY8 alpha, processing one fringe tile at a time.
pub fn decontaminate_surface(src: &Surface, alpha: &Surface, radius: f32, amount: f32) -> Surface {
let fmt = src.format();
let n = fmt.channels();
let nc = n - fmt.alpha as usize;
let rad = radius.max(3.0).ceil() as usize;
let halo = (rad * 4) as i32;
let amount = amount.clamp(0.0, 100.0) / 100.0;
let soft = |a: f32| a > 0.02 && a < 0.98;
let run = |t: &Rect| {
if !(t.y0..t.y1).any(|y| (t.x0..t.x1).any(|x| soft(alpha.sample_channel(x, y, 0)))) {
return None;
}
let r = t.inflate(halo);
let (rw, rh) = (r.width() as usize, r.height() as usize);
let mut px = Vec::new();
src.read_region_into(r, &mut px);
let al: Vec<f32> = (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| alpha.sample_channel(x, y, 0))).collect();
let wts: Vec<f32> = (0..rw * rh).map(|i| if al[i] >= 0.98 { if fmt.alpha { px[i * n + n - 1] } else { 1.0 } } else { 0.0 }).collect();
let den = box_mean(&wts, rw, rh, rad);
let den2 = box_mean(&wts, rw, rh, rad * 4);
let mut nums = Vec::with_capacity(nc);
for c in 0..nc {
let v: Vec<f32> = (0..rw * rh).map(|i| wts[i] * px[i * n + c]).collect();
nums.push((box_mean(&v, rw, rh, rad), box_mean(&v, rw, rh, rad * 4)));
}
let (cx, cy, cw, ch) = ((t.x0 - r.x0) as usize, (t.y0 - r.y0) as usize, t.width() as usize, t.height() as usize);
let mut out = Vec::with_capacity(cw * ch * n);
for yy in 0..ch {
for xx in 0..cw {
let i = (cy + yy) * rw + cx + xx;
let a = al[i];
let k = if soft(a) { amount * ((1.0 - a) * 4.0).clamp(0.0, 1.0) } else { 0.0 };
for c in 0..n {
let v = px[i * n + c];
if c >= nc || k == 0.0 {
out.push(v);
continue;
}
let f = if den[i] > 1e-4 {
nums[c].0[i] / den[i]
} else if den2[i] > 1e-4 {
nums[c].1[i] / den2[i]
} else {
v
};
out.push(v + (f - v) * k);
}
}
}
Some((*t, out))
};
let mut out = src.clone();
let mut hard = std::collections::HashMap::new();
for (tc, tile) in alpha.tiles() {
let key = std::sync::Arc::as_ptr(tile) as usize;
let uniform_hard = *hard.entry(key).or_insert_with(|| tile.bytes().iter().all(|v| *v == 0 || *v == 255));
if uniform_hard {
continue;
}
if let Some((t, data)) = run(&tc.rect()) {
out.write_region(t, &data);
}
}
out
}
/// The pixels of `src` with their alpha multiplied by `alpha` (zero outside its box); the result
/// has an alpha channel even if `src` does not.
#[cfg(test)]
pub fn masked_copy(src: &Surface, alpha: &Region) -> Surface {
let fmt = src.format();
let n = fmt.channels();
@@ -644,12 +953,182 @@ pub fn masked_copy(src: &Surface, alpha: &Region) -> Surface {
out
}
/// Copies selected pixels without collecting buffers across the alpha bounds.
/// `alpha` is a zero-default GRAY8 surface, as returned by [`refine_surface`].
/// Explicit `bounds` preserve source colour under zero alpha inside the finite mask extent.
pub fn masked_copy_surface(src: &Surface, alpha: &Surface, bounds: Rect) -> Surface {
let fmt = src.format();
let n = fmt.channels();
let output_fmt = PixelFormat { alpha: true, ..fmt };
let mut out = Surface::new(output_fmt);
let mut default = src.default_pixel();
if !fmt.alpha {
default.push(1.0);
}
let default_tile = out.solid_tile(&default);
let mut full = std::collections::HashMap::new();
for (tc, tile) in alpha.tiles() {
let key = std::sync::Arc::as_ptr(tile) as usize;
let opaque = bounds.contains_rect(&tc.rect()) && *full.entry(key).or_insert_with(|| tile.bytes().iter().all(|v| *v == 255));
if opaque && fmt.alpha {
if let Some(t) = src.tile(*tc) {
out.put_tiles([(*tc, t.clone())]);
} else if default.last().is_some_and(|a| *a > 0.0) {
out.put_tiles([(*tc, default_tile.clone())]);
}
continue;
}
if opaque && src.tile(*tc).is_none() {
out.put_tiles([(*tc, default_tile.clone())]);
continue;
}
let r = tc.rect().intersect(&bounds);
let px = src.read_region(r);
let al = alpha.read_region(r);
let mut data = Vec::with_capacity(al.len().saturating_mul(output_fmt.channels()));
let mut any = false;
for (p, a) in px.chunks_exact(n).zip(al) {
let colors = n - usize::from(fmt.alpha);
data.extend_from_slice(p.get(..colors).unwrap_or(&[]));
let sa = if fmt.alpha { p.last().copied().unwrap_or(0.0) } else { 1.0 };
data.push(sa * a);
any |= sa * a > 0.0;
}
if any {
out.write_region(r, &data);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::segment::ImageSampler;
use photocraft_color::SampleType;
#[test]
fn sparse_refine_matches_dense_at_unaligned_origins() {
let canvas = Rect::new(-15, -9, 540, 280);
let source = Surface::new(PixelFormat::RGBA8);
let sampler = crate::segment::SurfaceSampler(&source);
let mut mask = Surface::new(PixelFormat::GRAY8);
mask.fill_rect(Rect::new(-3, 4, 310, 210), &[1.0]);
mask.fill_rect(Rect::new(64, 50, 130, 90), &[0.25]);
for params in [RefineParams::default(), RefineParams { radius: 4.0, smart_radius: true, smooth: 10.0, feather: 2.0, contrast: 20.0, shift_edge: -15.0 }]
{
let dense = refine_mask(&sampler, &surface_reader(&mask), mask.content_bounds(), canvas, &params).expect("dense");
let sparse = refine_surface(&sampler, &mask, canvas, &params, None).expect("allocation").expect("sparse");
assert_eq!(sparse.to_interleaved(canvas), region_surface(&dense).to_interleaved(canvas));
}
}
#[test]
fn refinement_discards_partial_output_after_cancellation() {
use std::sync::atomic::{AtomicBool, Ordering};
struct CancelGuide<'a>(&'a AtomicBool);
impl Sampler for CancelGuide<'_> {
fn rgba(&self, r: Rect) -> Vec<[f32; 4]> {
self.0.store(true, Ordering::Relaxed);
vec![[0.2, 0.4, 0.7, 1.0]; r.width() as usize * r.height() as usize]
}
}
let cancelled = AtomicBool::new(false);
let cancel = || cancelled.load(Ordering::Relaxed);
let interrupt = photocraft_raster::Interrupt::cancel_only(&cancel);
let canvas = Rect::new(0, 0, 1024, 1024);
let mut mask = Surface::new(PixelFormat::GRAY8);
mask.fill_rect(Rect::new(100, 100, 800, 800), &[1.0]);
let before = mask.clone();
let params = RefineParams { radius: 4.0, ..Default::default() };
let result = refine_surface_with_interrupt(&CancelGuide(&cancelled), &mask, canvas, &params, None, &interrupt).unwrap();
assert!(cancelled.load(Ordering::Relaxed), "guide must trigger cancellation");
assert!(result.is_none(), "partial refinement must be discarded");
assert_eq!(mask, before);
// Multi-batch cancellation: an earlier batch flushes and writes output, and a later
// guide sample triggers cancellation. The partial output must be discarded.
let multi_cancelled = AtomicBool::new(false);
let multi_cancel = || multi_cancelled.load(Ordering::Relaxed);
let multi_interrupt = photocraft_raster::Interrupt::cancel_only(&multi_cancel);
struct MultiBatchGuide<'a> {
cancelled: &'a AtomicBool,
samples: std::sync::atomic::AtomicUsize,
cancel_after: usize,
}
impl Sampler for MultiBatchGuide<'_> {
fn rgba(&self, r: Rect) -> Vec<[f32; 4]> {
let prev = self.samples.fetch_add(1, Ordering::SeqCst);
if prev >= self.cancel_after {
self.cancelled.store(true, Ordering::Relaxed);
}
vec![[0.2, 0.4, 0.7, 1.0]; r.width() as usize * r.height() as usize]
}
}
// 32 mixed blocks along x exceed MAX_FILTER_BATCH (24), forcing at least one completed flush.
let multi_canvas = Rect::new(0, 0, 32 * 256, 256);
let mut multi_mask = Surface::new(PixelFormat::GRAY8);
multi_mask.fill_rect(Rect::new(0, 0, 32 * 256, 128), &[1.0]);
let multi_before = multi_mask.clone();
let multi_guide = MultiBatchGuide { cancelled: &multi_cancelled, samples: std::sync::atomic::AtomicUsize::new(0), cancel_after: 24 };
let multi_result = refine_surface_with_interrupt(&multi_guide, &multi_mask, multi_canvas, &params, None, &multi_interrupt).unwrap();
assert!(multi_cancelled.load(Ordering::Relaxed), "later guide call must trigger cancellation");
assert!(multi_guide.samples.load(Ordering::SeqCst) > 24, "cancellation must occur after the initial batch");
assert!(multi_result.is_none(), "partial output from completed flush must be discarded upon cancellation");
assert_eq!(multi_mask, multi_before);
}
#[test]
fn constant_clipped_halo_does_not_sample_a_mixed_tile_guide() {
struct NoGuide;
impl Sampler for NoGuide {
fn rgba(&self, _: Rect) -> Vec<[f32; 4]> {
panic!("constant halo must not read the guide");
}
}
let canvas = Rect::new(10, 10, 30, 30);
let mut mask = Surface::new(PixelFormat::GRAY8);
mask.fill_rect(canvas, &[1.0]);
let params = RefineParams { radius: 4.0, feather: 1.0, ..Default::default() };
let output = refine_surface(&NoGuide, &mask, canvas, &params, None).unwrap().unwrap();
assert!(output.to_interleaved(canvas).iter().all(|v| *v == 255));
}
#[test]
fn sparse_brushed_refine_matches_dense_for_add_and_restore() {
let canvas = Rect::new(-10, -5, 530, 270);
let source = Surface::with_default(PixelFormat::RGBA8, &[0.2, 0.4, 0.7, 1.0]);
let sampler = crate::segment::SurfaceSampler(&source);
let mut mask = Surface::new(PixelFormat::GRAY8);
mask.fill_rect(Rect::new(12, 17, 300, 230), &[1.0]);
mask.fill_rect(Rect::new(80, 70, 140, 110), &[0.0]);
let strokes = [
EdgeStroke { points: vec![(295.0, 40.0), (320.0, 200.0)], size: 20.0, add: true },
EdgeStroke { points: vec![(310.0, 100.0), (300.0, 160.0)], size: 12.0, add: false },
];
let brush = EdgeBrush::new(&strokes, canvas).unwrap();
let params = RefineParams { radius: 4.0, smart_radius: true, feather: 2.0, smooth: 5.0, ..Default::default() };
let dense = refine_mask_brushed(&sampler, &surface_reader(&mask), mask.content_bounds(), canvas, &params, Some(&brush)).unwrap();
let sparse = refine_surface(&sampler, &mask, canvas, &params, Some(&brush)).unwrap().unwrap();
assert_eq!(sparse.to_interleaved(canvas), region_surface(&dense).to_interleaved(canvas));
}
#[test]
fn sparse_refine_handles_reveal_all_without_input_tiles() {
let canvas = Rect::new(0, 0, 100_000, 100_000);
let source = Surface::new(PixelFormat::RGBA8);
let sampler = crate::segment::SurfaceSampler(&source);
let mask = Surface::with_default(PixelFormat::GRAY8, &[1.0]);
let output = refine_surface(&sampler, &mask, canvas, &RefineParams::default(), None).expect("allocation").expect("selected");
assert_eq!(output.sample_channel(0, 0, 0), 1.0);
assert_eq!(output.sample_channel(99_999, 99_999, 0), 1.0);
assert_eq!(output.sample_channel(100_000, 100_000, 0), 0.0);
assert!(std::sync::Arc::ptr_eq(
output.tile(photocraft_geom::TileCoord::new(10, 10)).unwrap(),
output.tile(photocraft_geom::TileCoord::new(20, 20)).unwrap()
));
}
/// Horizontal blend from `a` (left) to `b` (right) across a Gaussian-blurred edge at x = 40.
fn blurred_edge(w: usize, h: usize, sigma: f32) -> (RgbImage, Vec<f32>) {
let (a, b) = ([0.9f32, 0.8, 0.2], [0.1f32, 0.2, 0.6]);
@@ -813,6 +1292,13 @@ mod tests {
.collect();
let a = Region { bbox: Rect::new(0, 0, 20, 8), mask };
let d = decontaminate(&s, &a, 3.0, 100.0);
let sparse_alpha = region_surface(&a);
let sparse_d = decontaminate_surface(&s, &sparse_alpha, 3.0, 100.0);
for (expected, actual) in d.read_region(a.bbox).iter().zip(sparse_d.read_region(a.bbox)) {
assert!((expected - actual).abs() < 1e-5, "{sample:?}: decontamination changed");
}
let sparse_c = masked_copy_surface(&d, &sparse_alpha, a.bbox);
assert_eq!(sparse_c.to_interleaved(a.bbox), masked_copy(&d, &a).to_interleaved(a.bbox));
let p = d.pixel(10, 4);
assert!(p[0] > 0.9 && p[2] < 0.1, "{sample:?}: {p:?}");
assert_eq!(d.pixel(15, 4), s.pixel(15, 4));
+6
View File
@@ -216,6 +216,12 @@ fn segments(points: &[(f32, f32)], r: f32, clip: Rect) -> impl Iterator<Item = (
}
impl EdgeBrush {
/// Coordinates actually painted by these strokes, including restore coverage.
/// Iterating these avoids visiting the empty space between distant strokes.
pub fn tile_coords(&self) -> impl Iterator<Item = TileCoord> + '_ {
self.tiles.keys().copied()
}
/// Rasterises `strokes` (clipped to `canvas`), tile by tile. `None` when they paint nothing,
/// sweep more than [`MAX_SWEPT_AREA`] or paint more than [`MAX_PAINTED_TILES`] tiles.
pub fn new(strokes: &[EdgeStroke], canvas: Rect) -> Option<EdgeBrush> {
+7 -7
View File
@@ -63,25 +63,25 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
let found = ctx
.stage(0.1, 0.6, "Finding subject", |ctl| with_doc_sampler(doc, Some(id), sample_all, |smp, _| subject::select_subject_with(smp, area, ctl)))
.map_err(|_| EngineError::Cancelled)?;
let mut region = found.ok_or_else(|| EngineError::Other("no subject found".into()))?;
let region = found.ok_or_else(|| EngineError::Other("no subject found".into()))?;
let mut surface = matting::region_surface(&region);
if ctx.cancelled() {
return Err(EngineError::Cancelled);
}
if refine {
ctx.progress(0.6, "Refining edge");
let refined = with_doc_sampler(doc, Some(id), sample_all, |smp, _| {
matting::refine_mask(smp, &matting::region_reader(&region), region.bbox, area, &REFINE)
let refined = ctx.stage(0.6, 0.95, "Refining edge", |interrupt| {
with_doc_sampler(doc, Some(id), sample_all, |smp, _| matting::refine_surface_with_interrupt(smp, &surface, area, &REFINE, None, interrupt))
});
region = refined.unwrap_or(region);
surface = refined.map_err(|e| EngineError::Other(e.to_string()))?.unwrap_or(surface);
if ctx.cancelled() {
return Err(EngineError::Cancelled);
}
}
crate::extra_cmds::background_to_layer_for_mask(doc, id);
let mask = crate::commands::mask_at_depth(LayerMask { surface: matting::region_surface(&region), ..LayerMask::reveal_all() }, doc.depth)?;
let mask = crate::commands::mask_at_depth(LayerMask { surface: surface.clone(), ..LayerMask::reveal_all() }, doc.depth)?;
doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(mask);
doc.selection = None;
let b = region.bbox;
let b = surface.content_bounds();
Ok([b.x0, b.y0, b.width() as i32, b.height() as i32])
},
move |bounds| json!({ "layer": id.0, "bounds": bounds }),
+45 -3
View File
@@ -316,6 +316,49 @@ fn remove_matte(s: &mut Session, white: bool) -> Result<Value> {
})
}
/// Extracts the alpha channel of `surf` within bounds `r` into a GRAY8 surface,
/// preserving default alpha for unallocated tiles intersecting `r`.
fn decontamination_alpha(surf: &Surface, r: Rect) -> Surface {
let mut alpha = Surface::new(photocraft_color::PixelFormat::GRAY8);
let default_alpha = to_rgba(&surf.format(), &surf.default_pixel())[3].clamp(0.0, 1.0);
if default_alpha > 0.0 {
let shared = alpha.solid_tile(&[default_alpha]);
for tc in r.tiles() {
if surf.tile(tc).is_some() {
continue;
}
let area = tc.rect().intersect(&r);
if area == tc.rect() {
alpha.put_tiles([(tc, shared.clone())]);
} else {
alpha.fill_rect(area, &[default_alpha]);
}
}
}
let mut converted = std::collections::HashMap::new();
for (tc, tile) in surf.tiles() {
let area = tc.rect().intersect(&r);
if area.is_empty() {
continue;
}
let key = std::sync::Arc::as_ptr(tile) as usize;
if area == tc.rect()
&& let Some(shared) = converted.get(&key)
{
alpha.put_tiles([(*tc, std::sync::Arc::clone(shared))]);
continue;
}
let values: Vec<u8> = read_rgba(surf, area).iter().map(|q| (q[3].clamp(0.0, 1.0) * 255.0).round() as u8).collect();
alpha.write_interleaved(area, &values);
if area == tc.rect()
&& let Some(shared) = alpha.tile(*tc)
{
converted.insert(key, shared.clone());
}
}
alpha
}
/// Layer › Matting › Color Decontaminate: fringe colours move toward nearby opaque colours
/// (the Select and Mask decontamination, driven by the layer's own alpha).
fn color_decontaminate(s: &mut Session, p: &Value) -> Result<Value> {
@@ -328,9 +371,8 @@ fn color_decontaminate(s: &mut Session, p: &Value) -> Result<Value> {
if r.is_empty() || !surf.format().alpha {
return Ok(());
}
let alpha: Vec<u8> = read_rgba(surf, r).iter().map(|q| (q[3].clamp(0.0, 1.0) * 255.0).round() as u8).collect();
let region = Region { bbox: r, mask: alpha };
*surf = photocraft_algo::matting::decontaminate(surf, &region, radius, amount);
let alpha = decontamination_alpha(surf, r);
*surf = photocraft_algo::matting::decontaminate_surface(surf, &alpha, radius, amount);
Ok(())
})?;
Ok(Value::Null)
@@ -821,3 +821,29 @@ fn applying_a_feathered_mask_keeps_off_canvas_pixels_it_reveals() {
s.execute("layer.layerMask.apply", json!({})).unwrap();
assert_eq!(active(&s).surface().unwrap().rgba(52, 15)[3], 1.0, "off-canvas pixel stays visible");
}
#[test]
fn decontamination_alpha_shares_solid_source_tiles() {
let mut source = Surface::new(photocraft_color::PixelFormat::RGBA8);
let bounds = Rect::new(0, 0, 2048, 1024);
source.fill_rect(bounds, &[0.4, 0.2, 0.7, 0.5]);
let alpha = decontamination_alpha(&source, bounds);
let a = photocraft_geom::TileCoord::new(0, 0);
let b = photocraft_geom::TileCoord::new(3, 2);
assert!(std::sync::Arc::ptr_eq(alpha.tile(a).unwrap(), alpha.tile(b).unwrap()));
assert_eq!(alpha.sample_channel(20, 20, 0), source.sample_channel(20, 20, 3));
}
#[test]
fn decontamination_alpha_keeps_missing_source_alpha_only_inside_bounds() {
let mut source = Surface::with_default(photocraft_color::PixelFormat::RGBA8, &[1.0, 0.0, 0.0, 1.0]);
source.write_pixel(255, 128, &[0.5, 0.0, 0.5, 0.5]);
let bounds = Rect::new(10, 10, 700, 240);
let alpha = decontamination_alpha(&source, bounds);
assert_eq!(alpha.sample_channel(300, 128, 0), 1.0);
assert_eq!(alpha.sample_channel(699, 128, 0), 1.0);
assert_eq!(alpha.sample_channel(255, 128, 0), source.sample_channel(255, 128, 3));
for (x, y) in [(9, 128), (700, 128), (300, 9), (300, 240)] {
assert_eq!(alpha.sample_channel(x, y, 0), 0.0);
}
}
+27 -21
View File
@@ -99,6 +99,7 @@ fn points(p: &Value) -> Vec<(f32, f32)> {
/// edges and removes blockiness.
const ENHANCE: RefineParams = RefineParams { radius: 4.0, smart_radius: true, smooth: 15.0, feather: 0.0, contrast: 25.0, shift_edge: 0.0 };
/// Expands a selection around sampled points using image gradients, optionally enhancing edges.
fn quick_selection(s: &mut Session, p: &Value) -> Result<Value> {
let pts = points(p);
if pts.is_empty() {
@@ -113,10 +114,26 @@ fn quick_selection(s: &mut Session, p: &Value) -> Result<Value> {
let enhance = b(p, "enhanceEdge", false);
let region = with_sampler(s, b(p, "sampleAllLayers", false), |smp, doc| {
let canvas = doc.bounds();
let r = quick::quick_select(smp, canvas, &pts, size)?;
if enhance { matting::refine_mask(smp, &matting::region_reader(&r), r.bbox, canvas, &ENHANCE) } else { Some(r) }
let Some(r) = quick::quick_select(smp, canvas, &pts, size) else { return Ok(None) };
let surface = matting::region_surface(&r);
if enhance { matting::refine_surface(smp, &surface, canvas, &ENHANCE, None).map_err(|e| EngineError::Other(e.to_string())) } else { Ok(Some(surface)) }
})??;
let Some(region) = region else { return apply(s, "Quick Selection", None, m) };
let bounds = region.content_bounds();
let selected = s.edit("Quick Selection", |doc, _| {
let empty = photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8);
let old = doc.selection.as_ref().unwrap_or(&empty);
let area = old.content_bounds().union(&bounds).intersect(&doc.bounds());
let out = match m {
SelectionMode::Replace => region.clone(),
SelectionMode::Add => photocraft_algo::selection::combine_sparse(old, &region, area, f32::max),
SelectionMode::Subtract => photocraft_algo::selection::combine_sparse(old, &region, area, |a, b| (a - b).max(0.0)),
SelectionMode::Intersect => photocraft_algo::selection::combine_surfaces(old, &region, area, f32::min),
};
doc.selection = (!out.content_bounds().is_empty()).then_some(out);
Ok(doc.selection.is_some())
})?;
apply(s, "Quick Selection", region.as_ref(), m)
Ok(json!({"selected": selected, "changed": true, "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()]}))
}
fn rect_param(p: &Value) -> Option<Rect> {
@@ -434,24 +451,14 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result<Value> {
let edited = if edits.is_empty() { None } else { select_mask_edits::apply(doc, active, start.cloned(), &edits) };
let Some(sel) = (if edits.is_empty() { start } else { edited.as_ref() }) else { return Ok(None) };
let brush = matting::EdgeBrush::new(&strokes, doc.bounds());
// The refined result is dense over the selection's bounds: refuse what would not fit
// rather than allocate it (a thin edit across a huge canvas; the #8783 review).
let content = sel.content_bounds();
let area = matting::refine_area(content, doc.bounds(), &params, brush.as_ref());
if u64::from(area.width()) * u64::from(area.height()) > matting::MAX_REFINE_AREA {
return Err(bad(
"select.refineEdge",
&format!("the selection is too large to refine ({} × {} px; at most {} px in all)", area.width(), area.height(), matting::MAX_REFINE_AREA),
));
}
Ok(matting::refine_mask_brushed(smp, &matting::surface_reader(sel), content, doc.bounds(), &params, brush.as_ref()))
matting::refine_surface(smp, sel, doc.bounds(), &params, brush.as_ref()).map_err(|e| EngineError::Other(e.to_string()))
})??;
let d = s.active().ok_or(EngineError::NoDocument)?;
let active = d.active_layer.filter(|id| d.doc.layer(*id).is_some());
match output.as_str() {
"selection" => {
let selected = s.edit("Select and Mask", |doc, _| {
doc.selection = region.as_ref().map(matting::region_surface);
doc.selection = region.clone();
Ok(doc.selection.is_some())
})?;
Ok(json!({ "output": output, "selected": selected }))
@@ -459,8 +466,7 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result<Value> {
"layerMask" => {
let id = active.ok_or_else(|| EngineError::Other("no active layer".into()))?;
s.edit("Select and Mask", |doc, _| {
let surface =
region.as_ref().map(matting::region_surface).unwrap_or_else(|| photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8));
let surface = region.clone().unwrap_or_else(|| photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8));
crate::extra_cmds::background_to_layer_for_mask(doc, id);
let existing = doc.layer(id).and_then(|l| l.mask.as_ref()).cloned().unwrap_or_else(LayerMask::reveal_all);
doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(LayerMask { surface, ..existing });
@@ -476,14 +482,14 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result<Value> {
EngineError::Other(format!("the active layer is {} {} layer without pixels", src_layer.content.article(), src_layer.content.kind_name()))
})?;
let name = d.doc.copy_name(&src_layer.name);
let empty = Region { bbox: Rect::EMPTY, mask: Vec::new() };
let empty = photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8);
let reg = region.as_ref().unwrap_or(&empty);
let pixels = if decontaminate { matting::decontaminate(src, reg, params.radius, amount) } else { src.clone() };
let pixels = if decontaminate { matting::decontaminate_surface(src, reg, params.radius, amount) } else { src.clone() };
let with_mask = matches!(output.as_str(), "newLayerWithMask" | "newDocumentWithMask");
let (content, mask) = if with_mask {
(pixels, Some(LayerMask { surface: matting::region_surface(reg), ..LayerMask::reveal_all() }))
(pixels, Some(LayerMask { surface: reg.clone(), ..LayerMask::reveal_all() }))
} else {
(matting::masked_copy(&pixels, reg), None)
(matting::masked_copy_surface(&pixels, reg, reg.content_bounds()), None)
};
if output.starts_with("newDocument") {
// Same canvas, colour mode, depth and profile, so the pixels need no conversion.
+26 -18
View File
@@ -543,29 +543,26 @@ fn sel_bytes(s: &Session, w: i32, h: i32) -> Vec<u8> {
(0..h).flat_map(|y| (0..w).map(move |x| (cov(s, x, y) * 255.0).round() as u8)).collect()
}
/// The #8783 review: a refine whose result would be dense over more than
/// [`matting::MAX_REFINE_AREA`] pixels is refused, not allocated: a thin Brush edit across a
/// huge canvas (its edited selection spans the canvas) and a selection of the whole canvas.
/// The selection stays as it was.
/// Huge sparse selections refine without a dense bounding-box result.
#[test]
fn refine_edge_refuses_a_selection_too_large_to_refine() {
fn refine_edge_accepts_huge_sparse_and_solid_selections() {
let mut s = Session::new();
s.add_document(Document::new("huge", photocraft_doc::Size::new(100_000, 100_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8), None);
s.execute("select.rect", json!({"x": 10, "y": 60, "width": 10, "height": 10})).unwrap();
let small = s.active().and_then(|d| d.doc.selection.clone()).expect("selected");
let diagonal = json!({"edits": [{"tool": "brush", "points": [[0, 0], [99_999, 99_999]], "size": 1}]});
let t = std::time::Instant::now();
let e = s.execute("select.refineEdge", diagonal).expect_err("too large to refine");
assert!(e.to_string().contains("too large to refine"), "{e}");
assert!(s.active().and_then(|d| d.doc.selection.as_ref()) == Some(&small), "the selection is unchanged");
// The small selection itself still refines.
s.execute("select.refineEdge", json!({"radius": 4})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
s.execute("select.refineEdge", json!({"edits": [{"tool": "brush", "points": [[0, 0], [99_999, 99_999]], "size": 1}]})).expect("sparse refine");
let diagonal = s.active().and_then(|d| d.doc.selection.as_ref()).expect("selected");
assert!(diagonal.tile_count() < 4000);
for coordinate in [100, 50_000, 99_990] {
assert!(diagonal.sample_channel(coordinate, coordinate, 0) > 0.5, "diagonal stroke missing at {coordinate}");
}
assert!(diagonal.sample_channel(100, 150, 0) < 0.01, "off-stroke pixel inside a touched tile was filled");
s.execute("select.all", json!({})).unwrap();
let all = s.active().and_then(|d| d.doc.selection.clone()).expect("selected");
assert!(s.execute("select.refineEdge", json!({"radius": 4})).is_err());
assert!(s.active().and_then(|d| d.doc.selection.as_ref()) == Some(&all));
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
s.execute("select.refineEdge", json!({"radius": 4})).expect("solid refine");
let all = s.active().and_then(|d| d.doc.selection.as_ref()).expect("selected");
assert_eq!(all.sample_channel(50_000, 50_000, 0), 1.0);
let a = all.tile(photocraft_geom::TileCoord::new(10, 10)).expect("interior");
let b = all.tile(photocraft_geom::TileCoord::new(20, 20)).expect("interior");
assert!(std::sync::Arc::ptr_eq(a, b), "solid output buffers are shared");
}
/// The #8783 review: Brush and Lasso edits and Refine Edge Brush strokes that would paint
@@ -700,3 +697,14 @@ fn refine_edge_rejects_bad_edits_and_keeps_the_selection() {
assert_eq!(sel_bytes(&s, 80, 40), before, "{bad}");
}
}
#[test]
fn refine_edge_rejects_excessive_scratch_before_changing_selection() {
let mut s = Session::new();
s.add_document(Document::new("scratch", photocraft_doc::Size::new(10_000, 10_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8), None);
s.execute("select.rect", json!({"x": 5000, "y": 5000, "width": 1, "height": 1})).unwrap();
let before = s.active().unwrap().doc.selection.clone();
let error = s.execute("select.refineEdge", json!({"feather": 1000})).expect_err("scratch limit");
assert!(error.to_string().contains("not enough memory"), "{error}");
assert_eq!(s.active().unwrap().doc.selection, before);
}
+152 -37
View File
@@ -360,73 +360,126 @@ pub(crate) fn ants(app: &PhotocraftApp, idx: usize) -> Option<Option<(Arc<Docume
Some(Some((p.result.clone()?, p.key.k)))
}
/// A one-channel coverage over the canvas: `values` inside `area`, `outside` elsewhere.
/// Sparse float coverage; quantize only when building a display mask.
struct Coverage {
area: Rect,
values: Vec<f32>,
values: Surface,
outside: f32,
}
impl Coverage {
/// Creates an empty coverage buffer with zero outside default.
fn empty() -> Coverage {
Coverage { area: Rect::EMPTY, values: Vec::new(), outside: 0.0 }
Coverage { values: Surface::new(PixelFormat::GRAY8.with_sample(photocraft_color::SampleType::F32)), outside: 0.0 }
}
/// `s` (a selection or a layer mask) read over the part of `canvas` that isn't its default.
/// Reads sparse mask coverage from `s` within `canvas`, preserving shared tile buffers.
fn read(s: Option<&Surface>, canvas: Rect) -> Coverage {
let Some(s) = s else { return Coverage::empty() };
let outside = s.default_pixel().first().copied().unwrap_or(0.0).clamp(0.0, 1.0);
let area = if outside == 0.0 { s.content_bounds().intersect(&canvas) } else { canvas };
Coverage { area, values: photocraft_algo::selection::mask_from_surface(Some(s), area), outside }
let mut out = Coverage { values: Surface::with_default(PixelFormat::GRAY8.with_sample(photocraft_color::SampleType::F32), &[outside]), outside };
let mut converted = std::collections::HashMap::new();
for (tc, tile) in s.tiles() {
let area = tc.rect().intersect(&canvas);
if area.is_empty() {
continue;
}
let key = Arc::as_ptr(tile) as usize;
if area == tc.rect()
&& let Some(shared) = converted.get(&key)
{
out.values.put_tiles([(*tc, Arc::clone(shared))]);
continue;
}
let data = photocraft_algo::selection::mask_from_surface(Some(s), area);
out.values.write_region(area, &data);
if area == tc.rect()
&& let Some(shared) = out.values.tile(*tc)
{
converted.insert(key, shared.clone());
}
}
out
}
/// The band `r` px either side of the edge of `s`: where grown and shrunk coverage differ.
/// Computes the morphological edge band of `s` within `canvas` with radius `r`.
fn edge_band(s: Option<&Surface>, canvas: Rect, r: usize) -> Coverage {
let Some(s) = s else { return Coverage::empty() };
let reach = i32::try_from(r).unwrap_or(i32::MAX).saturating_add(1);
let outside = s.default_pixel().first().copied().unwrap_or(0.0);
let area = if outside == 0.0 { s.content_bounds().inflate(reach).intersect(&canvas) } else { canvas };
let (w, h) = (area.width() as usize, area.height() as usize);
let values = photocraft_algo::selection::mask_from_surface(Some(s), area);
let grown = photocraft_algo::matting::morph(&values, w, h, r, true);
let shrunk = photocraft_algo::matting::morph(&values, w, h, r, false);
if grown.len() != values.len() || shrunk.len() != values.len() {
return Coverage::empty();
let mut candidates = std::collections::BTreeSet::new();
for (tc, _) in s.tiles() {
candidates.extend(tc.rect().inflate(reach).intersect(&area).tiles());
}
Coverage { area, values: grown.iter().zip(&shrunk).map(|(g, s)| (g - s).clamp(0.0, 1.0)).collect(), outside: 0.0 }
let mut out = Coverage::empty();
for tc in candidates {
let core = tc.rect().intersect(&area);
let input = core.inflate(reach).intersect(&area);
let (w, h) = (input.width() as usize, input.height() as usize);
let values = photocraft_algo::selection::mask_from_surface(Some(s), input);
let grown = photocraft_algo::matting::morph(&values, w, h, r, true);
let shrunk = photocraft_algo::matting::morph(&values, w, h, r, false);
for y in core.y0..core.y1 {
for x in core.x0..core.x1 {
let i = (y - input.y0) as usize * w + (x - input.x0) as usize;
if let (Some(g), Some(s)) = (grown.get(i), shrunk.get(i)) {
let v = (g - s).clamp(0.0, 1.0);
if v != 0.0 {
out.values.write_pixel(x, y, &[v]);
}
}
}
}
}
out
}
/// Coverage at canvas pixel `(x, y)`.
/// Returns the coverage value at canvas coordinate `(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)
self.values.sample_channel(x, y, 0)
}
/// 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 {
/// Blends painted brush additions and restorations into the coverage buffer.
fn with_brush(mut 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 }
for tc in brush.tile_coords() {
let area = tc.rect().intersect(&canvas);
for y in area.y0..area.y1 {
for x in area.x0..area.x1 {
let b = brush.at(x, y);
if b == 0.0 {
continue;
}
let v = self.at(x, y);
let value = if b >= 0.0 { v.max(b) } else { v * (1.0 + b) };
self.values.write_pixel(x, y, &[value]);
}
}
}
self
}
/// A GRAY8 mask surface holding `f(coverage)`.
/// Converts float coverage to an 8-bit GRAY8 surface using mapping function `f`.
fn surface(&self, f: impl Fn(f32) -> f32) -> Surface {
let g = |v: f32| f(v).clamp(0.0, 1.0);
let mut s = Surface::with_default(PixelFormat::GRAY8, &[g(self.outside)]);
if !self.area.is_empty() && self.values.len() == self.area.width() as usize * self.area.height() as usize {
let bytes: Vec<u8> = self.values.iter().map(|v| (g(*v) * 255.0).round() as u8).collect();
s.write_interleaved(self.area, &bytes);
let default = s.default_bytes().first().copied().unwrap_or(0);
let mut converted: std::collections::HashMap<usize, Option<Arc<photocraft_raster::Tile>>> = std::collections::HashMap::new();
for (tc, tile) in self.values.tiles() {
let key = Arc::as_ptr(tile) as usize;
if let Some(shared) = converted.get(&key) {
if let Some(shared) = shared {
s.put_tiles([(*tc, shared.clone())]);
}
continue;
}
let bytes: Vec<u8> = self.values.read_region(tc.rect()).iter().map(|v| (g(*v) * 255.0).round() as u8).collect();
if bytes.iter().all(|v| *v == default) {
converted.insert(key, None);
} else {
s.write_interleaved(tc.rect(), &bytes);
converted.insert(key, s.tile(*tc).cloned());
}
}
s
}
@@ -769,3 +822,65 @@ mod tests {
assert!(pixel(&shown, 19, 10)[0] > 0.99 && pixel(&shown, 21, 10)[0] < 0.01, "the selection as it was");
}
}
#[test]
fn sparse_edge_band_matches_dense_reference_at_canvas_edges_and_default_holes() {
let canvas = Rect::new(0, 0, 540, 300);
let mut mask = Surface::with_default(PixelFormat::GRAY8, &[1.0]);
mask.fill_rect(Rect::new(0, 0, 7, 18), &[0.0]);
mask.fill_rect(Rect::new(240, 100, 270, 280), &[0.0]);
mask.fill_rect(Rect::new(20, 0, 24, 5), &[0.0]);
let sparse = Coverage::edge_band(Some(&mask), canvas, 2);
let input = photocraft_algo::selection::mask_from_surface(Some(&mask), canvas);
let w = canvas.width() as usize;
let h = canvas.height() as usize;
let grown = photocraft_algo::matting::morph(&input, w, h, 2, true);
let shrunk = photocraft_algo::matting::morph(&input, w, h, 2, false);
for y in canvas.y0..canvas.y1 {
for x in canvas.x0..canvas.x1 {
let i = y as usize * w + x as usize;
let want = (grown[i] - shrunk[i]).clamp(0.0, 1.0);
assert!((sparse.at(x, y) - want).abs() < 1e-6, "({x},{y})");
}
}
assert_eq!(sparse.values.sample_channel(0, 0, 0), sparse.at(0, 0));
let display = sparse.surface(|v| v);
assert_eq!(display.sample_channel(0, 0, 0), (sparse.at(0, 0) * 255.0).round() / 255.0);
assert_eq!(sparse.values.tile_count(), display.tile_count());
}
#[test]
fn sparse_brush_dabs_bound_tiles_and_apply_add_restore_formula() {
let canvas = Rect::new(0, 0, 100_000, 32);
let mut base = Surface::with_default(PixelFormat::GRAY8.with_sample(photocraft_color::SampleType::F32), &[0.25]);
base.fill_rect(Rect::new(100, 10, 104, 14), &[0.6]);
let coverage = Coverage::read(Some(&base), canvas);
let strokes = [
photocraft_algo::matting::EdgeStroke { points: vec![(101.5, 11.5)], size: 4.0, add: true },
photocraft_algo::matting::EdgeStroke { points: vec![(99_001.5, 11.5)], size: 4.0, add: false },
];
let brush = photocraft_algo::matting::EdgeBrush::new(&strokes, canvas).expect("dabs paint");
let brush_tiles = brush.tile_coords().count();
assert!(brush_tiles <= 4, "distant dabs allocated {brush_tiles} tiles");
let original = Coverage::read(Some(&base), canvas);
let result = coverage.with_brush(Some(&brush), canvas);
assert!(result.values.tile_count() <= original.values.tile_count() + brush_tiles);
for (x, b) in [(101, brush.at(101, 11)), (99_001, brush.at(99_001, 11))] {
let v = original.at(x, 11);
let want = if b >= 0.0 { v.max(b) } else { v * (1.0 + b) };
assert!((result.at(x, 11) - want).abs() < 1e-6, "x={x}, brush={b}");
}
}
#[test]
fn preview_conversion_preserves_shared_solid_tiles() {
let canvas = Rect::new(0, 0, 2048, 1024);
let mut selection = Surface::new(PixelFormat::GRAY8);
selection.fill_rect(canvas, &[1.0]);
let coverage = Coverage::read(Some(&selection), canvas);
let display = coverage.surface(|v| v);
let a = photocraft_geom::TileCoord::new(0, 0);
let b = photocraft_geom::TileCoord::new(3, 2);
assert!(Arc::ptr_eq(coverage.values.tile(a).unwrap(), coverage.values.tile(b).unwrap()));
assert!(Arc::ptr_eq(display.tile(a).unwrap(), display.tile(b).unwrap()));
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Where PhotoCraft falls short of Photoshop
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black inLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; G8: Type › OpenType › Stylistic Sets submenu, Set 1–20, #10285; G8: PhotoCraft-native PSDs keep stylistic sets and other Txt2-only OpenType flags through save/reopen, #10308; G10: TIFF Options dialog on Save As and Save a Copy, compression, Save Image Pyramid, Save Transparency, #8827; G11: Batch Esc Continue/Stop and read-only input previews; full live tabs remain open, #4579; G11: action recording folds Move-tool nudges, records relative keyboard document switches and scales point text placement; paragraph boxes remain open, #4514; G11: Batch measured output naming and UTF-16 log corrections; remaining progress and cancel prompt parity tracked, #4579; G11: Render ATN events mapped; Stylize/Pixelate ATN events mapped, current counts 128 supported/89 unmapped; G9: 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; G11: `.atn` document offsets preserve opening order after tab reordering, #7103; embedded AI/PDF/EPS save-back retains native format with a source-bound SVG display preview; Smooth and Feather command masks cropped to selection bounds plus blur reach; G1: Halftone Pattern Filter Gallery settings survive PSD/PSB saves (#8940); cropped selection morphology and command mask conversion performance evidence added; G6: Art History Brush landed as partial, TOOL-213-20, #1610, so no toolbar tool is missing; G4: five hard-round built-in presets use 5% spacing to remove stacked-tip outlines and reduce pressure-opacity bands, #8842; G9: Crop tool canvas menu, #6561; G2: Content-Aware Scale seam carving 1.65–3.6× faster with unchanged output, P28 row 3.3 → 1.6 s on a 4-core VM, #5236; G9: UI scale presets done, 105–120% steps, #3222; G14: lossy DNG, raw sidecars, Camera Raw Defaults live, Calibration and Auto, CR3 assessed as not clean-room feasible; G8: PSD type on a path opens on its path and saves back into the file's `Txt2`, #7962; G11: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; adjustment clipping stays visible in compact Properties, #5231; G9: Color panel modes, ramp and copy commands, the same in every theme, UI-217-17, #6000; G17: Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; G10: `.asl` style libraries landed, #1051; G8: area type (TYPE-218-10); G1: smart objects keep Photoshop's Cylinder warp, #4112; G8: paragraph direction buttons in the Paragraph panel and options bar, TYPE-218-2 partial; G1: 32-bit Hue/Saturation and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; G11: compound layer properties export as separate Photoshop Set events, rename last, #8304; G11: Batch and droplets take the result at the action's Save step, so actions ending in Save As and Close work; G11: `.atn` steps that switch to another open document import, play and save back, #7103; G14: the Nikon D4 white level follows the sample depth, so 12-bit D4 NEFs no longer open 2.6 stops dark, #5978; G14: a Nikon NEF's Adobe RGB preview is read as Adobe RGB (maker-note ColorSpace 0x001E), #5987; Apply Layer Comp action mapping, #7101; G10: JPEG Options dialog landed, FILE-215-19 partial (no Matte, progressive Scans or size preview); G10: Cineon write; G10: DPX read and write, #1049, Cineon and Amiga IFF read; Classical Object Finder improvement; learned segmentation remains open; G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon Line truncated
Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact
on the way to beta. This is the work list agents pick from: take the highest-ranked entry that
+66
View File
@@ -0,0 +1,66 @@
# Sparse Select and Mask refinement (#8822)
Measured 2026-11-01. Refinement writes a zero-default GRAY8 Surface, skips empty tiles,
shares solid interiors, and filters mixed blocks in bounded native parallel batches.
The queue holds at most 24 blocks and its conservative combined scratch estimate is
limited to 512 MiB. Wasm processes the same bounded work sequentially.
## Release measurements
[Benchmark run](https://github.com/wanikhawar/photocraft/actions/runs/41329979528),
Ubuntu 24.04 hosted runner, `RAYON_NUM_THREADS=4`, five repetitions per configuration,
alternating queue-size order. All rows below come from that same run. Values are medians.
The benchmark compares the previous dense tiled implementation (retained under `cfg(test)`)
with the sparse implementation and varies only its queue size. Production uses a fixed
queue limit; the benchmark's environment-variable tuning exists only on the scratch branch.
| Fixture | Dense time (ms) | Sparse queue 4 (ms) | Sparse queue 24 (ms) | Dense peak RSS (KiB) | Sparse queue 24 peak RSS (KiB) |
| --- | ---: | ---: | ---: | ---: | ---: |
| Small selection on 6000 × 4000 canvas | 6.819 | 6.792 | 6.968 | 9,352 | 9,332 |
| Select All, 6000 × 4000 | 54.782 | 12.863 | 12.309 | 56,220 | 12,892 |
| Thin diagonal, 6000 × 4000 | 434.603 | 483.662 | 348.148 | 70,868 | 54,208 |
| Select All, 100,000 × 100,000 | Not run | 501.262 | 455.635 | Not run | 113,988 |
| Thin diagonal, 100,000 × 100,000 | Not run | 12,305.541 | 8,475.229 | Not run | 198,912 |
Queue 24 reduces the 24 MP diagonal time by 28% against queue 4 and by 20% against
the dense reference. The 100k² diagonal improves by 31% against queue 4. The small
selection is essentially unchanged; the measurements do not establish a universal speedup.
Queue 24 uses more memory than queue 4 on diagonals (54,208 vs 43,016 KiB for 24 MP;
198,912 vs 186,944 KiB for 100k²), while keeping work bounded.
Each measurement runs in a fresh process. Time covers refinement and, for the dense
reference, conversion to the output Surface. RSS is the whole process high-water mark,
including fixture construction and output checksum calculation; it is not the scratch
estimate or a measurement of the desktop app's total memory. Image sampling uses a
constant synthetic RGBA8 source, radius 4 and feather 1; the diagonal is a 3 px brush
stroke. These fixtures do not measure a textured guide, every radius, or every colour mode.
The dense reference is never executed on 100k² fixtures.
Output checksums cover tile coordinates and tile bytes, reusing hashes for shared buffers.
Every implementation and queue size produced the same checksum for each fixture.
Small-image regression tests separately compare dense and sparse bytes directly,
including non-aligned origins, holes, soft coverage, smart radius, and add/restore strokes.
Reproduce using the measurement script and fixture at
[scratch commit 3f41ac0b](https://github.com/wanikhawar/photocraft/tree/3f41ac0b853d7eeb50815665fc0dc2b626709529/evidence-8822).
The scratch workflow and runtime queue selector are excluded from the feature PR.
## Preview checks
[Preview evidence run](https://github.com/wanikhawar/photocraft/actions/runs/41322345403)
compares the previous dense Coverage implementation with sparse float-tile Coverage.
All six decoded RGBA pairs match exactly: Overlay, Black & White, and On Layers, each
with Show Edge off/on, using overlapping add/restore brush strokes. The overlay and
edge-band captures were also inspected visually. These are composed preview images,
not full-window screenshots; unchanged workspace controls are not assessed by them.
## Remaining limits
Candidate work is capped at 1,048,576 blocks; scratch estimates, checked offsets and
fallible output writes reject oversized requests before changing the document.
Remove Background passes a cooperative interrupt; cancellation is checked while building
candidates, between blocks, before batch execution and on workers before filtering.
A cancelled refinement discards its partial output.
The sparse representation does not make an inherently dense, detailed output small.
Refine Hair, Object Aware and Presets remain open, so TOOL-214-1 stays partial and
readiness percentages and performance-budget enforcement are unchanged.
+2 -2
View File
@@ -1,6 +1,6 @@
# PhotoCraft vs Adobe Photoshop: target-app parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-11-02 · **Change:** minor (tile-sparse Select and Mask outputs and preview coverage, #8822; classical Select › Sky can seed substantial high-confidence components beneath an obstructed top band; learned segmentation remains open; Art History Brush Opacity on the options bar, #10263; no estimate changed; Automation Batch Esc Continue/Stop and read-only input previews now follow the measured cancellation flow; full live tabs remain partial, AUTO-219-5, #4579; Automation recording folds Move-tool nudge runs, records keyboard document switches by opening-order offset and scales point text placement; paragraph boxes remain open, #4514; Automation Batch output naming and UTF-16 logs now follow measured Photoshop behavior; progress and cancel prompt remain partial, AUTO-219-5, #4579; 11 of 183 preferences do nothing: startup reads Graphics Adapter through the typed Performance section, so the scorecard audit sees it; selected colour sets edit drawing, tone and supported filters; geometry remains open; Smooth and Feather command masks cropped to selection bounds plus blur reach; cropped selection morphology and command mask conversion performance evidence added; the Camera Raw row names per-camera colour for non-DNG cameras as missing, not the Calibration panel, which landed 2026-10-13; Content-Aware Scale measurements added, #5236; Help › Check for Updates and an opt-out check once per launch, DIST-220-1 partial, #5485; area type, TYPE-218-10 partial, Type scorecard 5 / 5 / 0; compound layer properties export as separate Photoshop Set events, #8304; JPEG Options dialog landed, FILE-215-19 partial; Print › Functions › Bleed through `file.print`; Content-Aware Move live preview for every selection size, TOOL-213-4 done, #6680; retouching evidence: Pattern Stamp and Background Eraser draw live, only the Mixer Brush is left of TOOL-213-7; fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged; PSD spot plates in Photoshop's polarity, #7415 split; TIFF spot plates, FILE-215-17's TIFF part, #7415) · **Target:** Adobe Photoshop 2026
The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The
one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md);
@@ -167,7 +167,7 @@ parity for the area and are already contained in the dimension rows above.
| Area | % | Hours | Notes |
|---|---:|---:|---|
| Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: a single pixel layer copies its pixels without its mask and styles (UI-217-23: smart object, type, shape, group, fill and adjustment layers and multi-layer selections copy and paste whole), smart-object perspective (FILE-215-6), a user contour library (FILE-215-15). Multi-layer Smart Object conversion (#2052, #2651), Stamp Visible (UI-217-22) and mask targeting by command (#2458) are done |
| Selections, Select and Mask, channels | 55% | 120–200 | [Selected colour sets](multi-channel-editing.md) edit raster drawing, tone and supported filters; partial-set geometry remains open. All selection tools, single row/column marquee included (#2837). Select and Mask is Photoshop's workspace with its own tools (Quick Selection, Refine Edge Brush, Brush, Object Selection, Lasso, Polygonal Lasso), view modes and six outputs (TOOL-214-1, #7069, #8148), no Refine Hair (#2671), Object Aware or Presets; Select Subject/Sky classical; Sky has an upper-half component fallback for obstructed top bands (2026-10-26), still without semantic recognition. Select Subject: 559 ms p50 on 24 MP vs a 100 ms budget |
| Selections, Select and Mask, channels | 55% | 120–200 | [Selected colour sets](multi-channel-editing.md) edit raster drawing, tone and supported filters; partial-set geometry remains open. All selection tools, single row/column marquee included (#2837). Select and Mask is Photoshop's workspace with its own tools (Quick Selection, Refine Edge Brush, Brush, Object Selection, Lasso, Polygonal Lasso), view modes and six outputs (TOOL-214-1, #7069, #8148), [tile-sparse outputs and float preview coverage](select-mask-sparse-refine.md) (#8822, 2026-11-01; 100k× diagonal/Select All regressions), no Refine Hair (#2671), Object Aware or Presets; Select Subject/Sky classical; Sky has an upper-half component fallback for obstructed top bands (2026-10-26), still without semantic recognition. Select Subject: 559 ms p50 on 24 MP vs a 100 ms budget |
| Painting and brush engine | 55% | 150–250 | Every Brush Settings section exists; feel is the gap: lag on big brushes, pressure jumps, smoothing and edge quality reports ([brush-parity.md](brush-parity.md)) |
| Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | Remove tool non-generative; Content-Aware Fill has no workspace (TOOL-214-15); no live stroke preview for the Mixer Brush yet (TOOL-213-7) |
| Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right, with a right-click transform menu (TOOL-214-5) and H/V skew fields; Puppet Warp mesh follows the object outline (TOOL-214-6); Liquify called "useless" by a user (#2606) |