mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 00:11:46 +00:00
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:
1 parent
384ff664d6
commit
a38e544faf
12 files changed
+855
-101
No files matched your search
@@ -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
@@ -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, ¶ms).expect("dense");
|
||||
let sparse = refine_surface(&sampler, &mask, canvas, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, 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, ¶ms, Some(&brush)).unwrap();
|
||||
let sparse = refine_surface(&sampler, &mask, canvas, ¶ms, 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));
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(®ion);
|
||||
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(®ion), 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(®ion), ..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 }),
|
||||
|
||||
@@ -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, ®ion, 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);
|
||||
}
|
||||
}
|
||||
@@ -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, ®ion, area, f32::max),
|
||||
SelectionMode::Subtract => photocraft_algo::selection::combine_sparse(old, ®ion, area, |a, b| (a - b).max(0.0)),
|
||||
SelectionMode::Intersect => photocraft_algo::selection::combine_surfaces(old, ®ion, 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(), ¶ms, 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(), ¶ms, brush.as_ref()))
|
||||
matting::refine_surface(smp, sel, doc.bounds(), ¶ms, 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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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) |
|
||||
|
||||
Reference in new issue
Block a user