Add the Remove tool with non-local patch completion (#2193)

* Add the Remove tool with non-local patch completion

Tool::Remove (J flyout) paints a hole that paint.remove fills from its
surroundings as a background job, in one undo step. A stroke around an
object also removes what it encloses; the selection limits the hole.

The fill (photocraft_algo::nonlocal) follows Newson, Almansa, Gousseau and
Pérez (IPOL 2017): texture features in the patch distance, onion-peel
initialisation, match fields upsampled between levels and a best-patch
final copy, plus gradient-domain hiding of edge and interior seams.
Sparse holes are filled in tiles. inpaint::complete (Content-Aware Fill,
Spot Healing, Content-Aware Move) is unchanged.

On 20 holes in public-domain photos (remove_quality) the fill keeps 0.89
of the texture it replaces, against 0.54 with the Wexler/PatchMatch
completion. 24 MP (remove_bench): 100 px ring 120 ms, 1000 px ring 2.5 s.

* Translate the Remove tool's label and tighten comments

Adds "Remove Tool" to every UI catalog, following each one's pattern for
tool names and its existing word for Remove. Corrects comments that no
longer matched the code: the benchmark's description, the tile and margin
minimums, and the reasons given for two parameters.

* Put the Remove tool first in the J group

The J flyout's slot now shows Remove until another J tool is picked, and
J from another group selects it; repeated J goes on to Spot Healing Brush,
Healing Brush, Patch, Content-Aware Move and Red Eye.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
JohnnYoru
2026-10-10 05:19:11 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent 414b7b9e4b
commit 897f9f37ad
40 changed files with 2005 additions and 25 deletions
+2 -1
View File
@@ -20,12 +20,13 @@ next to them.
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `painting-symmetry.svg` and `symmetry-*.svg` (110 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg` and `symmetry-*.svg` (110 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/painting-symmetry.svg` | Painting symmetry glyph: Lucide `heart` with a central axis | Lucide Icons and Contributors; adapted by @shorbank | Derived from <https://github.com/lucide-icons/lucide> `icons/heart.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/symmetry-{vertical,horizontal,dual,diagonal}.svg` | Painting symmetry axis glyphs | @shorbank | Original work; supplied SVG line geometry centred on a common square canvas, stroke adjusted to match the painting-symmetry glyph | MIT OR Apache-2.0, [`assets/icons/LICENSE-symmetry-axes.txt`](assets/icons/LICENSE-symmetry-axes.txt) |
+2 -2
View File
@@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<table>
<tr>
<td width="33%" valign="top">
<h4>🧰 52 tools</h4>
Move · Rectangular and Elliptical Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
<h4>🧰 53 tools</h4>
Move · Rectangular and Elliptical Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
</td>
<td width="33%" valign="top">
<h4>🖌️ A real brush engine</h4>
+21
View File
@@ -0,0 +1,21 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 12.01h.01" />
<path d="M10 16.01h.01" />
<path d="M14 12.01h.01" />
<path d="M14 16.01h.01" />
<path d="M18 8v12" />
<path d="M6 8v12" />
<rect x="2" y="8" width="20" height="12" rx="2" />
<path d="M20 1v4" />
<path d="M22 3h-4" />
</svg>

After

Width:  |  Height:  |  Size: 471 B

+4 -4
View File
@@ -29,12 +29,12 @@ const PAR_MIN: usize = 128 * 128;
/// SplitMix64: tiny deterministic RNG.
#[derive(Clone)]
struct Rng(u64);
pub(crate) struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
pub(crate) fn new(seed: u64) -> Self {
Rng(seed ^ 0x9E37_79B9_7F4A_7C15)
}
fn next(&mut self) -> u64 {
pub(crate) fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
@@ -42,7 +42,7 @@ impl Rng {
z ^ (z >> 31)
}
/// Uniform integer in `lo..=hi`.
fn range(&mut self, lo: i32, hi: i32) -> i32 {
pub(crate) fn range(&mut self, lo: i32, hi: i32) -> i32 {
if hi <= lo {
return lo;
}
+2
View File
@@ -42,6 +42,7 @@ pub mod liquify;
pub mod magnetic;
pub mod matting;
mod noise;
pub mod nonlocal;
mod oil;
mod other;
mod other2;
@@ -56,6 +57,7 @@ pub mod pyramid;
pub mod quantize;
pub mod redeye;
mod relight;
pub mod remove;
mod render;
pub mod render2;
pub mod resample;
File diff suppressed because it is too large Load Diff
+310
View File
@@ -0,0 +1,310 @@
//! The Remove Tool: fill what a brush stroke painted over from its surroundings.
//!
//! * [`close_loops`]: a stroke drawn around an object also removes what it encloses (every pixel
//! that can't reach the grid's border without crossing the stroke).
//! * [`dilate`]: the hole grows by a few pixels so an object's soft edge and halo go with it.
//! * [`remove_with`]: the hole is completed by [`crate::nonlocal::complete_with`] (patch-based,
//! with texture features so the fill keeps the grain of its surroundings, thin wires included).
//! * [`hole_radius`]: how thick a hole is, which sets how many scales the completion uses.
//!
//! Buffers are interleaved `w × h × ch` normalised floats (any model/depth) with `w × h` masks;
//! deterministic.
use crate::nonlocal::{Params, complete_with};
use crate::poisson::membrane_fill_with;
use photocraft_raster::{Cancelled, Interrupt};
/// `mask` plus everything it encloses: the cells from which no path of unmasked cells (4-connected)
/// reaches the grid's border. A mask of the wrong length is returned unchanged.
pub fn close_loops(w: usize, h: usize, mask: &[bool]) -> Vec<bool> {
if w == 0 || h == 0 || mask.len() != w.saturating_mul(h) {
return mask.to_vec();
}
let mut outside = vec![false; mask.len()];
let mut stack: Vec<usize> = Vec::new();
let visit = |i: usize, outside: &mut Vec<bool>, stack: &mut Vec<usize>| {
if !mask[i] && !outside[i] {
outside[i] = true;
stack.push(i);
}
};
for x in 0..w {
visit(x, &mut outside, &mut stack);
visit((h - 1) * w + x, &mut outside, &mut stack);
}
for y in 0..h {
visit(y * w, &mut outside, &mut stack);
visit(y * w + w - 1, &mut outside, &mut stack);
}
while let Some(i) = stack.pop() {
let (x, y) = (i % w, i / w);
if x > 0 {
visit(i - 1, &mut outside, &mut stack);
}
if x + 1 < w {
visit(i + 1, &mut outside, &mut stack);
}
if y > 0 {
visit(i - w, &mut outside, &mut stack);
}
if y + 1 < h {
visit(i + w, &mut outside, &mut stack);
}
}
outside.iter().map(|o| !*o).collect()
}
/// `mask` grown by `r` cells (a square structuring element), in two separable passes.
pub fn dilate(w: usize, h: usize, mask: &[bool], r: usize) -> Vec<bool> {
if r == 0 || mask.len() != w.saturating_mul(h) {
return mask.to_vec();
}
// A pass sets each cell when any cell within `r` along one axis is set (a running count).
let pass = |src: &[bool], horizontal: bool| -> Vec<bool> {
let (lines, len) = if horizontal { (h, w) } else { (w, h) };
let at = |line: usize, k: usize| if horizontal { line * w + k } else { k * w + line };
let mut out = vec![false; src.len()];
for line in 0..lines {
let mut count = 0usize;
for k in 0..len.min(r) {
count += usize::from(src[at(line, k)]);
}
for k in 0..len {
if k + r < len {
count += usize::from(src[at(line, k + r)]);
}
if k > r {
count -= usize::from(src[at(line, k - r - 1)]);
}
out[at(line, k)] = count > 0;
}
}
out
};
pass(&pass(mask, true), false)
}
/// The largest chessboard distance from a hole cell to the nearest cell outside the hole (cells
/// beyond the grid don't count as outside: a hole at the canvas edge is not thinner for it).
/// `u32::MAX` when no cell lies outside the hole, 0 for a mask of the wrong size.
pub fn hole_radius(w: usize, h: usize, hole: &[bool]) -> u32 {
if hole.len() != w.saturating_mul(h) {
return 0;
}
let mut d: Vec<u32> = hole.iter().map(|m| if *m { u32::MAX } else { 0 }).collect();
let relax = |d: &mut Vec<u32>, i: usize, j: usize| {
let v = d[j].saturating_add(1);
if v < d[i] {
d[i] = v;
}
};
for y in 0..h {
for x in 0..w {
let i = y * w + x;
if x > 0 {
relax(&mut d, i, i - 1);
}
if y > 0 {
relax(&mut d, i, i - w);
if x > 0 {
relax(&mut d, i, i - w - 1);
}
if x + 1 < w {
relax(&mut d, i, i - w + 1);
}
}
}
}
for y in (0..h).rev() {
for x in (0..w).rev() {
let i = y * w + x;
if x + 1 < w {
relax(&mut d, i, i + 1);
}
if y + 1 < h {
relax(&mut d, i, i + w);
if x + 1 < w {
relax(&mut d, i, i + w + 1);
}
if x > 0 {
relax(&mut d, i, i + w - 1);
}
}
}
}
d.iter().zip(hole).filter(|(_, m)| **m).map(|(v, _)| *v).max().unwrap_or(0)
}
/// Holes covering less than this share of their bounding box (a long stroke, scattered marks) are
/// completed tile by tile, so the work follows the hole rather than its bounding box.
const SPARSE: f64 = 0.25;
/// Smallest tile side, in pixels.
const MIN_TILE: usize = 256;
/// Fill `hole` in `img` from the pixels around it. Returns the full buffer (unchanged outside the
/// hole); a buffer or mask of the wrong size comes back unchanged. When nothing around the hole is
/// fully known (a hole about as big as `img`), it is diffused from its edges instead.
///
/// A sparse hole is filled in tiles eight times its thickness (at least [`MIN_TILE`] pixels), row
/// by row, each with a margin of six times its thickness (at least 48 pixels): each tile sees the
/// ones before it as filled, so the fill runs on across tile edges.
pub fn remove_with(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], seed: u64, ctl: &Interrupt) -> Result<Vec<f32>, Cancelled> {
let n = w.saturating_mul(h);
if ch == 0 || hole.len() != n || img.len() != n.saturating_mul(ch) {
return Ok(img.to_vec());
}
let (mut bx0, mut by0, mut bx1, mut by1, mut count) = (w, h, 0, 0, 0usize);
for (i, _) in hole.iter().enumerate().filter(|(_, m)| **m) {
let (x, y) = (i % w, i / w);
(bx0, by0, bx1, by1, count) = (bx0.min(x), by0.min(y), bx1.max(x + 1), by1.max(y + 1), count + 1);
}
if count == 0 {
return Ok(img.to_vec());
}
let thickness = hole_radius(w, h, hole).min(1 << 20) as usize;
let tile = (8 * thickness).max(MIN_TILE);
let area = (bx1 - bx0) * (by1 - by0);
if count as f64 >= SPARSE * area as f64 || (bx1 - bx0).max(by1 - by0) <= 2 * tile {
return fill(w, h, ch, img, hole, seed, ctl);
}
let margin = (6 * thickness).max(48);
let mut out = img.to_vec();
let mut left = hole.to_vec();
let origins: Vec<(usize, usize)> = (by0..by1).step_by(tile).flat_map(|y| (bx0..bx1).step_by(tile).map(move |x| (x, y))).collect();
for (k, &(tx0, ty0)) in origins.iter().enumerate() {
ctl.check()?;
let (tx1, ty1) = ((tx0 + tile).min(bx1), (ty0 + tile).min(by1));
if !(ty0..ty1).any(|y| left[y * w + tx0..y * w + tx1].iter().any(|m| *m)) {
continue;
}
let (x0, y0, x1, y1) = (tx0.saturating_sub(margin), ty0.saturating_sub(margin), (tx1 + margin).min(w), (ty1 + margin).min(h));
let ww = x1 - x0;
let sub_img: Vec<f32> = (y0..y1).flat_map(|y| out[(y * w + x0) * ch..(y * w + x1) * ch].iter().copied()).collect();
let sub_hole: Vec<bool> = (y0..y1).flat_map(|y| left[y * w + x0..y * w + x1].iter().copied()).collect();
let cancel = || ctl.cancelled();
let progress = |f: f32| ctl.progress((k as f32 + f) / origins.len() as f32);
let filled = fill(ww, y1 - y0, ch, &sub_img, &sub_hole, seed.wrapping_add(k as u64), &Interrupt::new(&cancel, &progress))?;
for y in ty0..ty1 {
for x in tx0..tx1 {
let i = y * w + x;
if left[i] {
let j = (y - y0) * ww + (x - x0);
out[i * ch..(i + 1) * ch].copy_from_slice(&filled[j * ch..(j + 1) * ch]);
left[i] = false;
}
}
}
}
Ok(out)
}
/// Complete one hole, or diffuse it when there is no fully-known patch to copy from.
fn fill(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], seed: u64, ctl: &Interrupt) -> Result<Vec<f32>, Cancelled> {
match complete_with(w, h, ch, img, hole, &Params { seed, ..Params::default() }, ctl)? {
Some(out) => Ok(out),
None => membrane_fill_with(w, h, ch, img, hole, ctl),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn grid(w: usize, h: usize, f: impl Fn(usize, usize) -> bool) -> Vec<bool> {
(0..w * h).map(|i| f(i % w, i / w)).collect()
}
#[test]
fn a_ring_closes_into_a_disc_and_an_open_line_does_not() {
let (w, h) = (40, 40);
let ring = grid(w, h, |x, y| {
let d = (x as f32 - 20.0).hypot(y as f32 - 20.0);
(10.0..12.0).contains(&d)
});
let closed = close_loops(w, h, &ring);
assert!(closed[20 * w + 20], "the centre is enclosed");
assert!(!closed[2 * w + 2], "the outside stays outside");
let line = grid(w, h, |x, y| y == 20 && (5..35).contains(&x));
assert_eq!(close_loops(w, h, &line), line, "an open stroke encloses nothing");
}
#[test]
fn a_loop_open_at_one_pixel_encloses_nothing() {
let (w, h) = (20, 20);
let mut sq = grid(w, h, |x, y| (x == 5 || x == 14 || y == 5 || y == 14) && (5..=14).contains(&x) && (5..=14).contains(&y));
sq[5 * w + 9] = false;
assert!(!close_loops(w, h, &sq)[10 * w + 10]);
}
#[test]
fn dilate_grows_by_the_radius() {
let (w, h) = (11, 11);
let dot = grid(w, h, |x, y| (x, y) == (5, 5));
let d = dilate(w, h, &dot, 2);
assert_eq!(d.iter().filter(|m| **m).count(), 25);
assert!(d[3 * w + 3] && d[7 * w + 7] && !d[2 * w + 5]);
assert_eq!(dilate(w, h, &dot, 0), dot);
}
#[test]
fn radius_measures_the_thickest_part() {
let (w, h) = (30, 30);
let line = grid(w, h, |x, y| y == 10 && (5..25).contains(&x));
assert_eq!(hole_radius(w, h, &line), 1);
let block = grid(w, h, |x, y| (5..16).contains(&x) && (5..16).contains(&y));
assert_eq!(hole_radius(w, h, &block), 6);
assert_eq!(hole_radius(w, h, &vec![true; w * h]), u32::MAX);
assert_eq!(hole_radius(w, h, &[true]), 0, "a mask of the wrong size");
}
/// A hole in a horizontal gradient is filled close to the gradient, both thin and wide.
#[test]
fn removes_thin_and_wide_holes_from_a_gradient() {
let (w, h, ch) = (64, 48, 2);
let truth: Vec<f32> = (0..w * h).flat_map(|i| [(i % w) as f32 / w as f32, 1.0]).collect();
for hole in [grid(w, h, |x, y| y == 24 && (10..54).contains(&x)), grid(w, h, |x, y| (24..40).contains(&x) && (16..32).contains(&y))] {
let mut img = truth.clone();
for (i, m) in hole.iter().enumerate() {
if *m {
img[i * ch] = 1.0;
}
}
let out = remove_with(w, h, ch, &img, &hole, 7, &Interrupt::NONE).unwrap();
for (i, m) in hole.iter().enumerate() {
if *m {
assert!((out[i * ch] - truth[i * ch]).abs() < 0.12, "pixel {i}: {} vs {}", out[i * ch], truth[i * ch]);
} else {
assert_eq!(out[i * ch..(i + 1) * ch], img[i * ch..(i + 1) * ch], "outside the hole is untouched");
}
}
}
}
/// A long, thin diagonal hole goes tile by tile; every cell is filled, the rest is untouched.
#[test]
fn a_long_thin_hole_is_filled_in_tiles() {
let (w, h, ch) = (900, 700, 1);
let img: Vec<f32> = (0..w * h).map(|i| 0.3 + 0.4 * (((i % w) / 7 + (i / w) / 5) % 2) as f32).collect();
let hole = grid(w, h, |x, y| (x as f32 - y as f32 * 1.2 - 20.0).abs() < 3.0 && (40..680).contains(&y));
let mut holed = img.clone();
hole.iter().enumerate().filter(|(_, m)| **m).for_each(|(i, _)| holed[i] = 1.0);
let out = remove_with(w, h, ch, &holed, &hole, 3, &Interrupt::NONE).unwrap();
for (i, m) in hole.iter().enumerate() {
if *m {
assert!((0.29..=0.71).contains(&out[i]), "cell {i} not filled from the stripes: {}", out[i]);
} else {
assert_eq!(out[i], holed[i]);
}
}
}
#[test]
fn hostile_sizes_return_the_input() {
let img = vec![0.5f32; 12];
assert_eq!(remove_with(2, 2, 3, &img, &[true], 1, &Interrupt::NONE).unwrap(), img);
assert_eq!(remove_with(2, 2, 0, &img, &[true; 4], 1, &Interrupt::NONE).unwrap(), img);
assert_eq!(remove_with(3, 2, 2, &img, &[false; 6], 1, &Interrupt::NONE).unwrap(), img);
assert!(close_loops(0, 0, &[]).is_empty());
assert_eq!(dilate(3, 3, &[true], 1), vec![true]);
}
}
+35
View File
@@ -0,0 +1,35 @@
//! Timing: the Remove Tool (`paint.remove`) on a 6000×4000 (24 MP) document, through the full
//! command path (`Session::execute`: window read, hole, fill, write, undo snapshot).
//! `cargo run --release -p photocraft-engine --example remove_bench [depth]`
//!
//! Rings around objects of 100, 300 and 1000 px (one completion each) and a 3000 px thin line
//! (completed tile by tile), each undone before the next.
use std::time::Instant;
use photocraft_engine::Session;
use serde_json::json;
fn ring(cx: f64, cy: f64, r: f64) -> Vec<[f64; 2]> {
(0..=96).map(|i| f64::from(i) / 96.0 * std::f64::consts::TAU).map(|t| [cx + r * t.cos(), cy + r * t.sin()]).collect()
}
fn main() {
let depth: u64 = std::env::args().nth(1).and_then(|v| v.parse().ok()).unwrap_or(8);
let mut s = Session::new();
s.execute("file.new", json!({"width": 6000, "height": 4000, "depth": depth})).unwrap();
s.execute("filter.render.clouds", json!({"seed": 3})).unwrap();
s.execute("filter.noise.addNoise", json!({"amount": 8, "seed": 1})).unwrap();
println!("document 6000x4000, {depth}-bit RGB");
let cases: Vec<(String, serde_json::Value)> = vec![
("ring 100 px".into(), json!({"points": ring(3000.0, 2000.0, 60.0), "size": 16})),
("ring 300 px".into(), json!({"points": ring(3000.0, 2000.0, 160.0), "size": 24})),
("ring 1000 px".into(), json!({"points": ring(3000.0, 2000.0, 510.0), "size": 32})),
("line 3000 px, 4 px wide".into(), json!({"points": [[1500, 1000], [4500, 3000]], "size": 2})),
];
for (name, p) in cases {
let t = Instant::now();
s.execute("paint.remove", p).unwrap();
println!("{name:<26} {:>8.1?}", t.elapsed());
s.execute("edit.undo", json!({})).unwrap();
}
}
+217
View File
@@ -0,0 +1,217 @@
//! Quality of the Remove Tool's fill (`photocraft_algo::remove::remove_with`) on real photos.
//! `cargo run --release -p photocraft-engine --example remove_quality -- <images dir> [out dir]`
//!
//! The images are `01.jpg`, `02.jpg`, `03.jpg`, `04.jpg` and `06.jpg`: public-domain photos from
//! Wikimedia Commons, not committed (the list is in `docs/development.md` › Remove Tool quality).
//! Each gets holes over background (discs and thin lines, placed in fractions of the image size)
//! that are filled through a window like the command's. Measured in the hole against the original:
//!
//! * `psnr`: fidelity in dB (a plausible fill need not match, so read it as a trend);
//! * `texture`: mean gradient magnitude of the fill over the original's (1 is ideal; below 1 the
//! fill is smoother than what it replaces);
//! * `seam`: the same on the band along the hole's edge (above 1 the edge shows).
//!
//! With an out dir, each case is also written as `<image>-<case>.png`: original | hole | fill.
use std::time::Instant;
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType};
use photocraft_raster::Interrupt;
/// A hole: a disc (centre in fractions of the width and height, radius in fractions of the width)
/// or a line (ends in fractions, width in pixels).
#[derive(Clone, Copy)]
enum Shape {
Disc { c: (f64, f64), r: f64 },
Line { a: (f64, f64), b: (f64, f64), width: f64 },
}
fn cases(image: &str) -> Vec<(&'static str, Shape)> {
use Shape::{Disc, Line};
match image {
// The Tetons and the Snake River: forest, river bend, ridge, clouds.
"01" => vec![
("forest", Disc { c: (0.75, 0.62), r: 0.03 }),
("river-bend", Disc { c: (0.45, 0.72), r: 0.045 }),
("ridge", Disc { c: (0.5, 0.28), r: 0.035 }),
("clouds", Disc { c: (0.25, 0.12), r: 0.06 }),
("wire", Line { a: (0.3, 0.55), b: (0.7, 0.85), width: 5.0 }),
],
// A dam in a canyon: rock, water, the dam's wall.
"02" => vec![
("rock", Disc { c: (0.85, 0.45), r: 0.04 }),
("water", Disc { c: (0.6, 0.7), r: 0.035 }),
("dam-wall", Disc { c: (0.35, 0.25), r: 0.025 }),
("wire", Line { a: (0.05, 0.6), b: (0.95, 0.5), width: 4.0 }),
],
// Oak trees by a gravel path: grass, gravel, a trunk, branches.
"03" => vec![
("grass", Disc { c: (0.75, 0.85), r: 0.045 }),
("gravel", Disc { c: (0.25, 0.85), r: 0.035 }),
("trunk", Disc { c: (0.48, 0.45), r: 0.02 }),
("branches", Disc { c: (0.8, 0.2), r: 0.04 }),
],
// A meadow anemone in grass, shallow depth of field.
"04" => vec![
("bokeh", Disc { c: (0.2, 0.35), r: 0.05 }),
("stems", Disc { c: (0.85, 0.6), r: 0.04 }),
("wire", Line { a: (0.1, 0.1), b: (0.9, 0.3), width: 6.0 }),
],
// A coast from above: surf, sand, mountains.
"06" => vec![
("surf", Disc { c: (0.75, 0.85), r: 0.045 }),
("sand", Disc { c: (0.3, 0.75), r: 0.04 }),
("mountains", Disc { c: (0.5, 0.12), r: 0.03 }),
("wire", Line { a: (0.0, 0.55), b: (1.0, 0.65), width: 5.0 }),
],
_ => Vec::new(),
}
}
/// The hole of `shape` on a `w × h` image.
fn mask(shape: Shape, w: usize, h: usize) -> Vec<bool> {
let (wf, hf) = (w as f64, h as f64);
(0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
match shape {
Shape::Disc { c, r } => (x - c.0 * wf).hypot(y - c.1 * hf) < r * wf,
Shape::Line { a, b, width } => {
let (ax, ay, bx, by) = (a.0 * wf, a.1 * hf, b.0 * wf, b.1 * hf);
let (dx, dy) = (bx - ax, by - ay);
let t = (((x - ax) * dx + (y - ay) * dy) / (dx * dx + dy * dy)).clamp(0.0, 1.0);
(x - ax - t * dx).hypot(y - ay - t * dy) < width / 2.0
}
}
})
.collect()
}
/// Bounding box `(x0, y0, x1, y1)` of the set cells.
fn bbox(w: usize, m: &[bool]) -> Option<(usize, usize, usize, usize)> {
let mut b: Option<(usize, usize, usize, usize)> = None;
for (i, _) in m.iter().enumerate().filter(|(_, v)| **v) {
let (x, y) = (i % w, i / w);
b = Some(b.map_or((x, y, x + 1, y + 1), |(x0, y0, x1, y1)| (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1))));
}
b
}
/// A `(x0, y0, x1, y1)` crop of an interleaved buffer.
fn crop(src: &[f32], w: usize, ch: usize, r: (usize, usize, usize, usize)) -> Vec<f32> {
(r.1..r.3).flat_map(|y| src[(y * w + r.0) * ch..(y * w + r.2) * ch].iter().copied()).collect()
}
/// Gradient magnitude (forward differences, summed over channels) at every cell.
fn gradient(img: &[f32], w: usize, h: usize, ch: usize) -> Vec<f32> {
(0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
let (r, d) = ((y * w + (x + 1).min(w - 1)) * ch, ((y + 1).min(h - 1) * w + x) * ch);
(0..ch).map(|c| (img[r + c] - img[i * ch + c]).abs() + (img[d + c] - img[i * ch + c]).abs()).sum()
})
.collect()
}
fn mean_over(v: &[f32], m: &[bool]) -> f64 {
let (s, n) = v.iter().zip(m).filter(|(_, m)| **m).fold((0.0f64, 0usize), |(s, n), (v, _)| (s + f64::from(*v), n + 1));
if n == 0 { 0.0 } else { s / n as f64 }
}
struct Score {
psnr: f64,
texture: f64,
seam: f64,
}
fn score(orig: &[f32], out: &[f32], hole: &[bool], w: usize, h: usize, ch: usize) -> Score {
let (mut se, mut n) = (0.0f64, 0usize);
for (i, _) in hole.iter().enumerate().filter(|(_, m)| **m) {
for c in 0..ch {
se += f64::from(out[i * ch + c] - orig[i * ch + c]).powi(2);
n += 1;
}
}
let psnr = if se == 0.0 { 99.0 } else { 10.0 * (n as f64 / se).log10() };
let (go, gf) = (gradient(orig, w, h, ch), gradient(out, w, h, ch));
// The edge band: cells within 2 px of the hole's boundary, on both sides.
let grown = photocraft_algo::remove::dilate(w, h, hole, 2);
let inner: Vec<bool> = {
let outside: Vec<bool> = hole.iter().map(|m| !*m).collect();
photocraft_algo::remove::dilate(w, h, &outside, 2).iter().map(|o| !*o).collect()
};
let band: Vec<bool> = grown.iter().zip(&inner).map(|(g, i)| *g && !*i).collect();
let ratio = |m: &[bool]| {
let o = mean_over(&go, m);
if o > 0.0 { mean_over(&gf, m) / o } else { 1.0 }
};
Score { psnr, texture: ratio(hole), seam: ratio(&band) }
}
fn write_png(path: &std::path::Path, data: &[f32], w: usize, h: usize, ch: usize) {
let layout = if ch == 1 { ChannelLayout::Gray } else { ChannelLayout::Rgb };
let img = Image::from_normalized(w as u32, h as u32, layout, SampleType::U8, data).expect("image");
std::fs::write(path, photocraft_codecs::encode(&img, Format::Png, &EncodeOptions::default()).expect("encode")).expect("write");
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let dir = std::path::PathBuf::from(args.get(1).expect("usage: remove_quality <images dir> [out dir]"));
let out_dir = args.get(2).map(std::path::PathBuf::from);
if let Some(d) = &out_dir {
std::fs::create_dir_all(d).expect("out dir");
}
println!("{:<4} {:<11} {:>7} {:>8} {:>6} {:>9}", "img", "case", "psnr", "texture", "seam", "ms");
let (mut sum_psnr, mut sum_tex, mut sum_seam, mut sum_ms, mut count) = (0.0, 0.0, 0.0, 0.0, 0);
for name in ["01", "02", "03", "04", "06"] {
let Ok(bytes) = std::fs::read(dir.join(format!("{name}.jpg"))) else {
println!("{name}: missing, skipped");
continue;
};
let img = photocraft_codecs::decode(&bytes).expect("decode");
let (w, h) = (img.width() as usize, img.height() as usize);
let ch = img.layout().channels();
let px = img.to_normalized();
for (case, shape) in cases(name) {
let full = mask(shape, w, h);
let Some(b) = bbox(w, &full) else { continue };
// The command's window: the hole plus three quarters of its extent (32..512 px).
let ext = (b.2 - b.0).max(b.3 - b.1);
let m = (ext * 3 / 4).clamp(32, 512);
let win = (b.0.saturating_sub(m), b.1.saturating_sub(m), (b.2 + m).min(w), (b.3 + m).min(h));
let (ww, wh) = (win.2 - win.0, win.3 - win.1);
let orig = crop(&px, w, ch, win);
let hole: Vec<bool> = (win.1..win.3).flat_map(|y| (win.0..win.2).map(move |x| (x, y))).map(|(x, y)| full[y * w + x]).collect();
let mut holed = orig.clone();
for (i, _) in hole.iter().enumerate().filter(|(_, m)| **m) {
holed[i * ch..(i + 1) * ch].fill(1.0);
}
let t = Instant::now();
let out = photocraft_algo::remove::remove_with(ww, wh, ch, &holed, &hole, 0x0052_e30e, &Interrupt::NONE).expect("never cancelled");
let ms = t.elapsed().as_secs_f64() * 1000.0;
let s = score(&orig, &out, &hole, ww, wh, ch);
println!("{name:<4} {case:<11} {:>7.2} {:>8.3} {:>6.3} {ms:>9.1}", s.psnr, s.texture, s.seam);
(sum_psnr, sum_tex, sum_seam, sum_ms, count) = (sum_psnr + s.psnr, sum_tex + s.texture, sum_seam + s.seam, sum_ms + ms, count + 1);
if let Some(d) = &out_dir {
// Original | hole | fill, cropped to the hole plus half its extent.
let pad = ext / 2 + 8;
let lb = b.0 - win.0;
let tb = b.1 - win.1;
let r = (lb.saturating_sub(pad), tb.saturating_sub(pad), (lb + (b.2 - b.0) + pad).min(ww), (tb + (b.3 - b.1) + pad).min(wh));
let (cw, chh) = (r.2 - r.0, r.3 - r.1);
let parts = [crop(&orig, ww, ch, r), crop(&holed, ww, ch, r), crop(&out, ww, ch, r)];
let mut sheet = vec![0.0f32; cw * 3 * chh * ch];
for (k, p) in parts.iter().enumerate() {
for y in 0..chh {
let dst = (y * cw * 3 + k * cw) * ch;
sheet[dst..dst + cw * ch].copy_from_slice(&p[y * cw * ch..(y + 1) * cw * ch]);
}
}
write_png(&d.join(format!("{name}-{case}.png")), &sheet, cw * 3, chh, ch);
}
}
}
if count > 0 {
let n = f64::from(count);
println!("{:<16} {:>7.2} {:>8.3} {:>6.3} {:>9.1}", "mean", sum_psnr / n, sum_tex / n, sum_seam / n, sum_ms / n);
}
}
+1
View File
@@ -579,6 +579,7 @@ fn routed(id: &str) -> bool {
| "paint.smudge"
| "paint.historyBrush"
| "paint.redEye"
| "paint.remove"
)
}
+21
View File
@@ -364,6 +364,27 @@ fn content_aware_move_runs_as_a_job() {
assert_eq!(pixels(&s), before.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().read_region(before.bounds()));
}
#[test]
fn remove_runs_as_a_job() {
let mut inline = session(160, 120);
let mut s = session(160, 120);
let p = json!({"points": [[60, 50], [100, 70]], "size": 24});
inline.execute("paint.remove", p.clone()).unwrap();
let before = pixels(&s);
let id = job(s.start("paint.remove", p.clone()).unwrap());
let e = wait_event(&mut s, id);
let JobOutcome::Done(v) = &e.outcome else { panic!("{e:?}") };
assert!(v["damage"].is_array(), "{v}");
assert_eq!(pixels(&s), pixels(&inline), "same result as the synchronous command");
// Cancelled at once: the document stays as it was.
s.undo();
let id = job(s.start("paint.remove", p).unwrap());
assert!(s.cancel_job(id));
s.join_cancelled_jobs();
assert_eq!(wait_event(&mut s, id).outcome, JobOutcome::Cancelled);
assert_eq!(pixels(&s), before);
}
#[test]
fn open_runs_as_a_job_and_cancel_adds_nothing() {
let mut src = session(40, 30);
+14 -1
View File
@@ -1,5 +1,5 @@
//! Retouching tools as commands: Clone Stamp, Pattern Stamp, Healing Brush, Spot Healing Brush,
//! Patch, Content-Aware Move, Dodge, Burn, Sponge, Blur, Sharpen, Smudge and History Brush.
//! Patch, Content-Aware Move, Remove, Dodge, Burn, Sponge, Blur, Sharpen, Smudge and History Brush.
//!
//! Every command takes a Photoshop-style brush (`points`, `size`, `hardness`, `opacity`, `flow`,
//! `spacing`, `layer`), respects the active selection as a mask and the layer's transparency lock, and
@@ -26,6 +26,7 @@ use crate::{EngineError, Result, Session};
mod content_aware_move;
mod patch;
mod pattern_stamp;
mod remove;
pub use patch::preview as patch_preview;
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
@@ -1099,6 +1100,18 @@ pub fn specs() -> Vec<CommandSpec> {
run: content_aware_move::content_aware_move,
journal: true,
},
CommandSpec {
id: "paint.remove",
label: "Remove",
menu: &[],
shortcut: None,
params: brush_params!(
r#","closeLoops":bool=true (a stroke around an object removes what it encloses),"sampleAllLayers":bool=false → {"damage"} (a background job)"#
),
enabled: has_pixel_layer,
run: remove::remove,
journal: true,
},
CommandSpec {
id: "paint.dodge",
label: "Dodge",
@@ -166,16 +166,16 @@ fn read(doc: &mut Document, plan: &Plan, p: &Value, rect: Rect) -> Result<Region
/// Write `out` over `mask` into the target, replacing the pixels (the result already holds what
/// should show there). With the transparency lock, alpha is kept and transparent pixels stay as
/// they are. Returns the written rectangle.
fn replace(doc: &mut Document, plan: &Plan, p: &Value, out: &Region, mask: &[bool]) -> Result<Rect> {
let (surf, lock) = crate::channel_cmds::target_surface(doc, plan.id, p)?;
/// they are. Returns the written rectangle. Shared with the Remove Tool.
pub(super) fn replace(doc: &mut Document, id: Option<LayerId>, p: &Value, out: &Region, mask: &[bool]) -> Result<Rect> {
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
let fmt = surf.format();
let a = alpha_index(&fmt);
let rect = out.rect;
let mut px = surf.read_region(rect);
let n = out.ch;
if px.len() != out.data.len() || mask.len() * n != px.len() {
return Err(EngineError::Other("internal error: content-aware move buffers disagree in size".into()));
return Err(EngineError::Other("internal error: fill buffers disagree in size".into()));
}
for ((dst, src), m) in px.chunks_exact_mut(n).zip(out.data.chunks_exact(n)).zip(mask) {
if !*m {
@@ -256,7 +256,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra
let allowed: Vec<bool> = old_place.iter().map(|o| !*o).collect();
buf.data = ctx.stage(0.0, split, label, |ctl| fill_with(w, h, n, &buf.data, &band, &allowed, &opts, ctl)).map_err(cancelled)?;
}
let mut damage = replace(doc, plan, p, &buf, &moved)?;
let mut damage = replace(doc, plan.id, p, &buf, &moved)?;
// 2. Move: fill the place the content left (where the content didn't land on it).
if plan.mode == Mode::Move {
@@ -269,7 +269,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra
if hole.iter().any(|h| *h) {
let allowed: Vec<bool> = landed.iter().map(|l| !*l).collect();
let data = ctx.stage(split, 1.0, label, |ctl| fill_with(w, h, img.ch, &img.data, &hole, &allowed, &opts, ctl)).map_err(cancelled)?;
damage = damage.union(&replace(doc, plan, p, &Region { rect: win, ch: img.ch, data }, &hole)?);
damage = damage.union(&replace(doc, plan.id, p, &Region { rect: win, ch: img.ch, data }, &hole)?);
}
}
let surf = crate::channel_cmds::target_surface(doc, plan.id, p)?.0.clone();
+153
View File
@@ -0,0 +1,153 @@
//! The Remove Tool as a command: paint over something and it is replaced by its surroundings.
//!
//! The stroke's footprint is the hole. A stroke that closes around an object also removes what it
//! encloses (`closeLoops`), and the hole grows by [`GROW`] pixels so the object's soft edge and
//! halo go with it. The hole is completed from a window around the stroke by patch-based
//! completion with texture features (`photocraft_algo::remove`, `photocraft_algo::nonlocal`). The
//! selection, when there is one, limits what changes. Large holes take seconds, so the command
//! runs as a background job (#210): with progress, cancellable, and the document unchanged until
//! it applies, in one undo step.
use super::content_aware_move::replace;
use super::patch::coverage;
use super::*;
const CMD: &str = "paint.remove";
/// Largest stroke, in pixels of its bounding box (as the Patch Tool's and Content-Aware Move's).
/// Checked before the stroke's coverage is rendered, which allocates its whole bounding box, also
/// where it leaves the canvas.
const MAX_PIXELS: u64 = 16 << 20;
/// Cap on the sampling margin around the stroke (the window grows with the stroke).
const MAX_MARGIN: i32 = 512;
/// How far the hole grows beyond the stroke, in pixels.
const GROW: usize = 2;
/// Seed of the completion, so a removal is reproducible.
const SEED: u64 = 0x0052_e30e;
/// What a removal does, checked before any work starts.
struct Plan {
id: Option<LayerId>,
stroke: Stroke,
/// Where the fill samples from: the stroke plus a margin, on the canvas.
window: Rect,
close_loops: bool,
all_layers: bool,
}
/// The stroke's bounding box (points ± the brush radius), before any coverage is rendered.
fn stroke_bounds(stroke: &Stroke) -> Rect {
let r = f64::from(stroke.brush.size) / 2.0 + 2.0;
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for q in &stroke.points {
(x0, y0, x1, y1) = (x0.min(q.x), y0.min(q.y), x1.max(q.x), y1.max(q.y));
}
// `check_coords` keeps points within ±MAX_COORD and sizes are capped, so these fit an i32.
Rect::new((x0 - r).floor() as i32, (y0 - r).floor() as i32, (x1 + r).ceil() as i32, (y1 + r).ceil() as i32)
}
fn plan(s: &Session, p: &Value) -> Result<Plan> {
let (stroke, id) = parse_brush(s, p, CMD)?;
let d = s.active().ok_or(EngineError::NoDocument)?;
let canvas = d.doc.bounds();
let bounds = stroke_bounds(&stroke);
let pixels = u64::from(bounds.width()) * u64::from(bounds.height());
if pixels > MAX_PIXELS {
return Err(bad(CMD, format!("the stroke is too large to remove ({pixels} pixels, at most {MAX_PIXELS})")));
}
let area = bounds.intersect(&canvas);
if area.is_empty() {
return Err(bad(CMD, "the stroke is outside the canvas"));
}
let window = area.inflate(crate::fill_cmds::sampling_margin(area).min(MAX_MARGIN)).intersect(&canvas);
let close_loops = flag(p, "closeLoops", true);
let all_layers = flag(p, "sampleAllLayers", false) && targets_pixels(p);
Ok(Plan { id, stroke, window, close_loops, all_layers })
}
/// The hole over the plan's window: the stroke's footprint, its enclosed areas and a margin,
/// limited to the selection. Empty when nothing would change.
fn hole(plan: &Plan, sel: Option<&Surface>) -> Vec<bool> {
let win = plan.window;
let (w, h) = (win.width() as usize, win.height() as usize);
let (bounds, cov) = stroke_coverage(&plan.stroke);
let mut m = vec![false; w * h];
let bw = bounds.width() as usize;
for (i, c) in cov.iter().enumerate() {
let (x, y) = (bounds.x0 + (i % bw.max(1)) as i32, bounds.y0 + (i / bw.max(1)) as i32);
if *c > 0.0 && win.contains(x, y) {
let j = (y - win.y0) as usize * w + (x - win.x0) as usize;
if let Some(v) = m.get_mut(j) {
*v = true;
}
}
}
if plan.close_loops {
m = photocraft_algo::remove::close_loops(w, h, &m);
}
m = photocraft_algo::remove::dilate(w, h, &m, GROW);
if let Some(sel) = sel {
for (v, c) in m.iter_mut().zip(coverage(sel, win)) {
*v &= c > 0.0;
}
}
m
}
/// The filled window and the hole it fills, computed on `doc` (a snapshot).
fn run_remove(doc: &mut Document, plan: &Plan, p: &Value, ctx: &crate::jobs::JobCtx, label: &str) -> Result<(Region, Vec<bool>)> {
let hole = hole(plan, doc.selection.as_ref());
if !hole.iter().any(|m| *m) {
return Err(bad(CMD, "the stroke doesn't cover anything that can change (check the selection)"));
}
if hole.iter().all(|m| *m) {
return Err(bad(CMD, "nothing is left around the stroke to fill it from"));
}
ctx.check()?;
let win = plan.window;
let (img, fmt) = {
let (surf, _) = crate::channel_cmds::target_surface(doc, plan.id, p)?;
let fmt = surf.format();
let img = if plan.all_layers { composite_region(doc, None, SampleLayers::All, win, fmt) } else { Region::read(surf, win) };
(img, fmt)
};
let (w, h) = (win.width() as usize, win.height() as usize);
let mut data = ctx
.stage(0.0, 1.0, label, |ctl| photocraft_algo::remove::remove_with(w, h, img.ch, &img.data, &hole, SEED, ctl))
.map_err(|_| EngineError::Cancelled)?;
// The fill's seam blending can step slightly past the storable range.
clamp_samples(&fmt, &mut data);
Ok((Region { rect: win, ch: img.ch, data }, hole))
}
pub(super) fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let plan = plan(s, p)?;
let label = "Remove";
let d = s.active().ok_or(EngineError::NoDocument)?;
let doc = d.doc.clone();
let p = p.clone();
let id = plan.id;
crate::jobs::run(
s,
label,
true,
{
let p = p.clone();
move |ctx| {
ctx.progress(0.0, label);
let mut work = (*doc).clone();
run_remove(&mut work, &plan, &p, ctx, label)
}
},
move |s, (out, hole)| {
let damage = s.edit(label, |doc, _| replace(doc, id, &p, &out, &hole))?;
if let Some(st) = s.active_mut() {
st.last_damage = Some(damage);
}
Ok(json!({ "damage": damage_json(damage) }))
},
)
}
+96 -4
View File
@@ -400,6 +400,7 @@ fn retouch_commands_are_registered_and_need_a_pixel_layer() {
"paint.sharpen",
"paint.smudge",
"paint.historyBrush",
"paint.remove",
];
for id in ids {
let spec = crate::commands::find(id).unwrap_or_else(|| panic!("{id} missing"));
@@ -416,7 +417,7 @@ fn retouch_commands_are_registered_and_need_a_pixel_layer() {
// Paint target (#207): mask, alpha channel, Quick Mask
// ---------------------------------------------------------------------------------------------
const RETOUCH_IDS: [&str; 11] = [
const RETOUCH_IDS: [&str; 12] = [
"paint.cloneStamp",
"paint.patternStamp",
"paint.healingBrush",
@@ -428,6 +429,7 @@ const RETOUCH_IDS: [&str; 11] = [
"paint.sharpen",
"paint.smudge",
"paint.historyBrush",
"paint.remove",
];
/// Grey noise in 0.3..0.7 (no period along the clone offset used below).
@@ -484,10 +486,11 @@ fn retouch_params(id: &str, history_state: usize, target: Value) -> Value {
p
}
/// Tools whose stroke must visibly change grey noise (Sponge has no colour to change on grey, and
/// healing a noise texture with itself may land on the same values).
/// Tools whose stroke must visibly change grey noise (Sponge has no colour to change on grey,
/// healing a noise texture with itself may land on the same values, and Remove completes this
/// periodic noise exactly).
fn changes_gray(id: &str) -> bool {
!matches!(id, "paint.sponge" | "paint.healingBrush" | "paint.spotHealing")
!matches!(id, "paint.sponge" | "paint.healingBrush" | "paint.spotHealing" | "paint.remove")
}
#[test]
@@ -1301,6 +1304,7 @@ fn retouch_strokes_refuse_absurd_point_coordinates() {
"paint.spotHealing",
"paint.historyBrush",
"paint.patternStamp",
"paint.remove",
];
// A single far point first: cheap even without the bound, so a regression fails fast.
let far = [json!([[1_000_001, 0]]), json!([[0, 0], [0, -2_000_000]]), json!([[0, 0], [1e300, 0]]), json!([[1e300, 1e300]])];
@@ -1321,3 +1325,91 @@ fn retouch_strokes_refuse_absurd_point_coordinates() {
assert_ne!(rgba(&s, 16, 16), before, "{depth}");
}
}
// ---------------------------------------------------------------------------------------------
// Remove Tool
// ---------------------------------------------------------------------------------------------
/// A flat grey layer with a dark square at 40..52 × 24..36.
fn square_session(depth: u64, mode: &str) -> Session {
let mut s = session(96, 64, depth, mode);
paint_layer(&mut s, |x, y| if (40..52).contains(&x) && (24..36).contains(&y) { [0.05, 0.05, 0.05, 1.0] } else { [0.5, 0.5, 0.5, 1.0] });
s
}
/// A ring of points around the square.
fn ring() -> Vec<[f64; 2]> {
(0..=64).map(|i| f64::from(i) / 64.0 * std::f64::consts::TAU).map(|t| [46.0 + 13.0 * t.cos(), 30.0 + 13.0 * t.sin()]).collect()
}
fn near(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
}
#[test]
fn remove_a_ring_around_an_object_removes_it_in_one_undo_step() {
for (depth, mode) in [(8, "rgb"), (16, "gray"), (32, "rgb"), (8, "cmyk"), (16, "lab")] {
let mut s = square_session(depth, mode);
let bg = rgba(&s, 10, 10);
assert!(!near(rgba(&s, 46, 30), bg, 0.05), "{mode}/{depth}: the square is there");
let past = s.active().unwrap().history.past_len();
let r = s.execute("paint.remove", json!({"points": ring(), "size": 4})).unwrap();
assert!(r["damage"].is_array(), "{r}");
for (x, y) in [(46, 30), (40, 24), (51, 35)] {
assert!(near(rgba(&s, x, y), bg, 0.03), "{mode}/{depth} ({x}, {y}): {:?} vs {bg:?}", rgba(&s, x, y));
}
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "{mode}/{depth}: one undo step");
s.execute("edit.undo", json!({})).unwrap();
assert!(!near(rgba(&s, 46, 30), bg, 0.05), "{mode}/{depth}: undo brings the square back");
}
}
#[test]
fn remove_open_strokes_and_the_selection_limit_what_changes() {
let mut s = square_session(8, "rgb");
let bg = rgba(&s, 10, 10);
// Without closeLoops a ring leaves its inside alone.
s.execute("paint.remove", json!({"points": ring(), "size": 4, "closeLoops": false})).unwrap();
assert!(!near(rgba(&s, 46, 30), bg, 0.05));
// A stroke straight over the square removes it.
s.execute("paint.remove", json!({"points": [[38, 30], [54, 30]], "size": 16})).unwrap();
assert!(near(rgba(&s, 46, 30), bg, 0.03), "{:?}", rgba(&s, 46, 30));
// Selected elsewhere, a stroke over the square changes nothing.
let mut s = square_session(8, "rgb");
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
let err = s.execute("paint.remove", json!({"points": [[46, 30]], "size": 20})).unwrap_err().to_string();
assert!(err.contains("check the selection"), "{err}");
assert!(!near(rgba(&s, 46, 30), bg, 0.05));
}
#[test]
fn remove_thin_scratches_take_their_surroundings() {
let mut s = session(80, 40, 16, "rgb");
paint_layer(&mut s, |x, y| if y == 20 { [1.0, 1.0, 1.0, 1.0] } else { [x as f32 / 80.0, 0.4, 0.6, 1.0] });
s.execute("paint.remove", json!({"points": [[5, 20], [75, 20]], "size": 2})).unwrap();
for x in [10, 40, 70] {
assert!(near(rgba(&s, x, 20), [x as f32 / 80.0, 0.4, 0.6, 1.0], 0.02), "x {x}: {:?}", rgba(&s, x, 20));
}
}
#[test]
fn remove_bad_params_are_errors() {
assert!(Session::new().execute("paint.remove", json!({"points": [[1, 1]]})).is_err(), "no document");
let mut s = square_session(8, "rgb");
for p in [
json!({}),
json!({"points": []}),
json!({"points": "x"}),
json!({"points": [[1, 1]], "size": "big", "closeLoops": "yes", "sampleAllLayers": 3}),
json!({"points": [[1, 1]], "layer": 999_999}),
] {
let _ = s.execute("paint.remove", p);
}
let err = s.execute("paint.remove", json!({"points": [[-500, -500]], "size": 10})).unwrap_err().to_string();
assert!(err.contains("outside the canvas"), "{err}");
// A stroke far off the canvas is refused before its coverage (its whole bounding box) is rendered.
let err = s.execute("paint.remove", json!({"points": [[0, 0], [99_999, 99_999]], "size": 5000})).unwrap_err().to_string();
assert!(err.contains("too large"), "{err}");
let err = s.execute("paint.remove", json!({"points": [[48, 32]], "size": 3000})).unwrap_err().to_string();
assert!(err.contains("nothing is left"), "{err}");
}
+7 -1
View File
@@ -533,6 +533,7 @@ pub(crate) fn freehand_tool(tool: Tool) -> bool {
| Tool::Eraser
| Tool::BackgroundEraser
| Tool::HistoryBrush
| Tool::Remove
| Tool::SpotHealing
| Tool::Healing
| Tool::CloneStamp
@@ -3444,7 +3445,12 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View
t if t.is_brushlike() || t == Tool::QuickSelection => {
// Retouching strokes preview as a translucent trail of the brush footprint: a mask,
// not a brush-wide egui polyline (which zoomed in tessellates into wedges, #189).
let col = Color32::from_white_alpha(if t == Tool::QuickSelection { 40 } else { 60 });
let col = match t {
Tool::QuickSelection => Color32::from_white_alpha(40),
// The Remove Tool tints what it will remove, like Photoshop's.
Tool::Remove => crate::theme::Tokens::get(painter.ctx()).danger.gamma_multiply(0.45),
_ => Color32::from_white_alpha(60),
};
let Some(st) = app.session.active() else { return };
let size = [st.doc.size.width, st.doc.size.height];
let doc_rect = xf.doc_rect(st.doc.bounds());
+1
View File
@@ -1323,6 +1323,7 @@
Remove All Pins Odstranit všechny špendlíky
Remove Background Odstranit pozadí
Remove Planes Odstranit roviny
Remove Tool Nástroj odstranění
Remove stop Odstranit zarážku
Remove variable Odstranit proměnnou
Rename Přejmenovat
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1307,6 +1307,7 @@
Remove All Pins Alle Pins entfernen
Remove Background Hintergrund entfernen
Remove Planes Flächen entfernen
Remove Tool Entfernen-Werkzeug
Remove stop Farbstopp entfernen
Remove variable Variable entfernen
Rename Umbenennen
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2796,6 +2796,7 @@
Remove All Pins Αφαίρεση όλων των καρφιτσών
Remove Background Αφαίρεση φόντου
Remove Planes Αφαίρεση επιπέδων προοπτικής
Remove Tool Αφαίρεση (Remove Tool)
Remove Warp Split Αφαίρεση διαχωρισμού παραμόρφωσης
Remove stop Αφαίρεση σημείου διαβάθμισης
Remove variable Αφαίρεση μεταβλητής
Can't render this file because it contains an unexpected character in line 966 and column 18.
+1
View File
@@ -1308,6 +1308,7 @@
Remove All Pins Quitar todos los pines
Remove Background Quitar el fondo
Remove Planes Quitar los planos
Remove Tool Herramienta Quitar
Remove stop Quitar punto
Remove variable Quitar variable
Rename Cambiar nombre
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -1292,6 +1292,7 @@
Remove All Pins Retirer toutes les épingles
Remove Background Supprimer l'arrière-plan
Remove Planes Supprimer les plans
Remove Tool Outil Retirer
Remove stop Retirer le point
Remove variable Retirer la variable
Rename Renommer
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1286,6 +1286,7 @@
Remove All Pins Hapus Semua Pin
Remove Background Hapus Background
Remove Planes Hapus Bidang
Remove Tool Alat Hapus
Remove stop Hapus stop
Remove variable Hapus variabel
Rename Ganti Nama
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -1307,6 +1307,7 @@
Remove All Pins Rimuovi tutti i pin
Remove Background Rimuovi sfondo
Remove Planes Rimuovi piani
Remove Tool Strumento Rimuovi
Remove stop Rimuovi punto
Remove variable Rimuovi variabile
Rename Rinomina
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
1307
1308
1309
1310
1311
1312
1313
+1
View File
@@ -2548,6 +2548,7 @@ fontWeight Italic 斜体
Reduce Noise ノイズを軽減
Relief レリーフ
Remove Jpeg Artifact JPEG アーティファクトを除去
Remove Tool 削除ツール
Reorder Layer レイヤーを並べ替え
Repeat 繰り返し
Reticulation 粒状化
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2199,6 +2199,7 @@ cameraRaw Color 색상
Reduce Color Noise 색상 노이즈 감소
Sharpen Details 세부 묘사 선명하게
Remove Jpeg Artifact JPEG 가공물 제거
Remove Tool 제거 도구
Blade Curvature 조리개 날 곡률
Depth Map 깊이 맵
Focal Distance 초점 거리
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
2199
2200
2201
2202
2203
2204
2205
+1
View File
@@ -2828,6 +2828,7 @@ cameraRaw Color Kleur
Reduce Color Noise Kleurruis verminderen
Sharpen Details Details verscherpen
Remove Jpeg Artifact JPEG-artefacten verwijderen
Remove Tool Verwijdergereedschap
Blade Curvature Kromming lamellen
Depth Map Dieptekaart
Focal Distance Scherpstelafstand
Can't render this file because it contains an unexpected character in line 8 and column 34.
+1
View File
@@ -2690,6 +2690,7 @@ cameraRaw Roughness Chropowatość
Reduce Color Noise Redukuj szum kolorów
Sharpen Details Wyostrz szczegóły
Remove Jpeg Artifact Usuń artefakty JPEG
Remove Tool Narzędzie Usuń
Blade Curvature Krzywizna listków
Depth Map Mapa głębi
Focal Distance Odległość ogniskowania
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1291,6 +1291,7 @@
Remove All Pins Remover todos os alfinetes
Remove Background Remover plano de fundo
Remove Planes Remover planos
Remove Tool Ferramenta Remover
Remove stop Remover parada
Remove variable Remover variável
Rename Renomear
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1660,6 +1660,7 @@
Add Pins Добавить булавки
Delete Pins Удалить булавки
Remove All Pins Удалить все булавки
Remove Tool Инструмент «Удаление»
Pin Depth: Глубина булавки:
Bring the selected pin forward Переместить выбранную булавку вперёд
Send the selected pin backward Переместить выбранную булавку назад
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -2853,6 +2853,7 @@ cameraRaw Masking Маскування
Reduce Color Noise Зменшити колірний шум
Sharpen Details Підвищити різкість деталей
Remove Jpeg Artifact Прибрати артефакти JPEG
Remove Tool Інструмент «Вилучення»
Blade Curvature Кривина пелюсток діафрагми
Depth Map Карта глибини
Focal Distance Фокусна відстань
Can't render this file because it contains an unexpected character in line 1312 and column 18.
+1
View File
@@ -1317,6 +1317,7 @@
Remove All Pins 移除所有控制点
Remove Background 移除背景
Remove Planes 移除平面
Remove Tool 移除工具
Remove stop 移除色标
Remove variable 移除变量
Rename 重命名
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
1317
1318
1319
1320
1321
1322
1323
+1
View File
@@ -1314,6 +1314,7 @@
Remove All Pins 移除所有圖釘
Remove Background 移除背景
Remove Planes 移除平面
Remove Tool 移除工具
Remove stop 移除色標
Remove variable 移除變數
Rename 重新命名
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -10,6 +10,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("arrow-left-right", include_bytes!("../../../assets/icons/arrow-left-right.svg")),
("ban", include_bytes!("../../../assets/icons/ban.svg")),
("bandage", include_bytes!("../../../assets/icons/bandage.svg")),
("bandage-sparkle", include_bytes!("../../../assets/icons/bandage-sparkle.svg")),
("blend", include_bytes!("../../../assets/icons/blend.svg")),
("brush", include_bytes!("../../../assets/icons/brush.svg")),
("brush-cleaning", include_bytes!("../../../assets/icons/brush-cleaning.svg")),
+1
View File
@@ -119,6 +119,7 @@ pub fn tool_icon(t: Tool) -> &'static str {
Tool::RotateView => "compass",
Tool::Zoom => "zoom-in",
Tool::SpotHealing | Tool::Healing => "bandage",
Tool::Remove => "bandage-sparkle",
Tool::Patch => "lasso-select",
Tool::ContentAwareMove => "arrow-left-right",
Tool::RedEye => "eye",
+3 -1
View File
@@ -24,7 +24,7 @@ const TOOL_SECTIONS: &[&[&[Tool]]] = &[
&[Tool::Eyedropper, Tool::Ruler, Tool::Note, Tool::Count],
],
&[
&[Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye],
&[Tool::Remove, Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye],
&[Tool::Brush, Tool::Pencil, Tool::MixerBrush],
&[Tool::CloneStamp, Tool::PatternStamp],
&[Tool::HistoryBrush],
@@ -3710,6 +3710,8 @@ mod type_flyout_tests {
let j = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).find(|slot| slot.contains(&Tool::SpotHealing)).expect("J group");
assert!(j.contains(&Tool::RedEye), "{j:?}");
assert_eq!(j.last(), Some(&Tool::RedEye));
// Remove leads the flyout, so the slot shows it until another J tool is picked.
assert_eq!(j.first(), Some(&Tool::Remove));
}
#[test]
+30 -2
View File
@@ -66,6 +66,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m
let pts = json!(points);
let (cmd, mut p): (&str, Value) = match tool {
Tool::SpotHealing => ("paint.spotHealing", json!({"type": o.spot_type, "sampleAllLayers": o.sample_all_layers})),
Tool::Remove => ("paint.remove", json!({"sampleAllLayers": o.sample_all_layers})),
Tool::MixerBrush => ("paint.mixerBrush", json!({})),
Tool::PatternStamp => {
let mut p = json!({
@@ -308,6 +309,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
crate::widgets::vline(ui, 22.0);
crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers"));
}
Tool::Remove => {
crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers"));
}
Tool::Patch => {
opt(ui, tl!("Patch:"));
for (k, l) in [("source", tl!("Source")), ("destination", tl!("Destination"))] {
@@ -541,7 +545,7 @@ mod tests {
#[test]
fn sample_all_layers_reaches_the_command() {
// #207, #731: the options-bar checkbox reaches the command.
for tool in [Tool::SpotHealing, Tool::Blur, Tool::Sharpen, Tool::Smudge] {
for tool in [Tool::SpotHealing, Tool::Remove, Tool::Blur, Tool::Sharpen, Tool::Smudge] {
for all in [false, true] {
let mut app = app();
stripes(&mut app, 4, "pixels");
@@ -761,13 +765,37 @@ mod tests {
assert!(app.session.active().is_some_and(|st| st.doc.selection.is_some()), "the selection stays");
}
#[test]
fn remove_tool_stroke_removes_what_it_covers() {
let mut app = app();
app.run("paint.pencil", json!({"points": [[0, 30], [100, 30]], "size": 200, "color": "#808080"})).unwrap();
app.run("paint.pencil", json!({"points": [[30, 20], [30, 40]], "size": 4, "color": "#000000"})).unwrap();
let bg = active(&app).surface().unwrap().rgba(70, 30);
app.ui.tool = Tool::Remove;
let m = egui::Modifiers::NONE;
tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 18.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Move { x: 30.0, y: 42.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Up { x: 30.0, y: 42.0 }, m);
assert!(!app.ui.status_error, "{}", app.ui.status);
assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("paint.remove"));
let px = active(&app).surface().unwrap().rgba(30, 30);
assert!(px.iter().zip(bg).all(|(a, b)| (a - b).abs() < 0.02), "{px:?} vs {bg:?}");
}
#[test]
fn red_eye_is_in_the_j_flyout() {
let j = [Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye];
let j = [Tool::Remove, Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye];
assert!(j.contains(&Tool::RedEye));
assert!(j.iter().all(|t| t.key() == 'J'));
}
#[test]
fn j_cycles_from_remove() {
// J from another group picks the first J tool, and repeated J goes on in this order.
let group: Vec<Tool> = Tool::ALL.iter().copied().filter(|t| t.key() == 'J').collect();
assert_eq!(group, [Tool::Remove, Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye]);
}
#[test]
fn s_group_is_clone_stamp_then_pattern_stamp() {
assert_eq!(Tool::CloneStamp.key(), 'S');
+10 -3
View File
@@ -87,6 +87,7 @@ pub enum Tool {
Hand,
RotateView,
Zoom,
Remove,
SpotHealing,
Healing,
Patch,
@@ -117,7 +118,7 @@ pub enum Tool {
}
impl Tool {
pub const ALL: [Tool; 52] = [
pub const ALL: [Tool; 53] = [
Tool::Move,
Tool::RectMarquee,
Tool::EllipseMarquee,
@@ -143,6 +144,7 @@ impl Tool {
Tool::Hand,
Tool::RotateView,
Tool::Zoom,
Tool::Remove,
Tool::SpotHealing,
Tool::Healing,
Tool::Patch,
@@ -201,6 +203,7 @@ impl Tool {
Tool::Hand => "Hand Tool",
Tool::RotateView => "Rotate View Tool",
Tool::Zoom => "Zoom Tool",
Tool::Remove => "Remove Tool",
Tool::SpotHealing => "Spot Healing Brush Tool",
Tool::Healing => "Healing Brush Tool",
Tool::Patch => "Patch Tool",
@@ -241,6 +244,7 @@ impl Tool {
| Tool::MixerBrush
| Tool::Eraser
| Tool::BackgroundEraser
| Tool::Remove
| Tool::SpotHealing
| Tool::Healing
| Tool::CloneStamp
@@ -270,7 +274,7 @@ impl Tool {
Tool::Hand => 'H',
Tool::RotateView => 'R',
Tool::Zoom => 'Z',
Tool::SpotHealing | Tool::Healing | Tool::Patch | Tool::ContentAwareMove | Tool::RedEye => 'J',
Tool::Remove | Tool::SpotHealing | Tool::Healing | Tool::Patch | Tool::ContentAwareMove | Tool::RedEye => 'J',
Tool::CloneStamp | Tool::PatternStamp => 'S',
Tool::HistoryBrush => 'Y',
Tool::Blur | Tool::Sharpen | Tool::Smudge => '\0',
@@ -1106,7 +1110,10 @@ mod tests {
assert!(!Tool::RedEye.is_brushlike());
assert_eq!(Tool::from_name("patternStamp"), Some(Tool::PatternStamp));
assert_eq!(Tool::from_name("Pattern Stamp Tool"), Some(Tool::PatternStamp));
assert_eq!(Tool::ALL.len(), 52);
assert_eq!(Tool::ALL.len(), 53);
assert_eq!(Tool::from_name("Remove Tool"), Some(Tool::Remove));
assert_eq!(Tool::Remove.key(), 'J');
assert!(Tool::Remove.is_brushlike());
assert_eq!(Tool::from_name("RotateView"), Some(Tool::RotateView));
assert_eq!(Tool::from_name("Rotate View Tool"), Some(Tool::RotateView));
assert_eq!(Tool::RotateView.key(), 'R');
+21
View File
@@ -395,6 +395,27 @@ Input calls (`ui.key`, `ui.type`, `ui.click`) now reply only after the app has p
so a following `ui.inspect` observes their effect.
## Remove Tool quality
`cargo run --release -p photocraft-engine --example remove_quality -- <dir> [out dir]` fills
holes (discs and thin lines over background) in five photos with `photocraft_algo::remove` and
prints, per hole, the PSNR against the original, `texture` (the fill's mean gradient over the
original's: below 1 it is smoother than what it replaces) and `seam` (the same on the band along the
hole's edge: above 1 the edge shows). With an out dir it writes original | hole | fill crops. The
photos are public domain, not committed; download them at 1920 px wide into `<dir>` as:
| File | Photo |
|---|---|
| `01.jpg` | [The Tetons and the Snake River](https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg), Ansel Adams, 1942 (U.S. National Archives) |
| `02.jpg` | [Ansel Adams, National Archives 79-AAB-01](https://commons.wikimedia.org/wiki/File:Ansel_Adams_-_National_Archives_79-AAB-01.jpg) (U.S. National Archives) |
| `03.jpg` | [Hiking at Crane Meadows National Wildlife Refuge](https://commons.wikimedia.org/wiki/File:Hiking_at_Crane_Meadows_National_Wildlife_Refuge_(49018614166).jpg) (USFWS) |
| `04.jpg` | [Meadow Anemone, Lake Andes National Wildlife Refuge](https://commons.wikimedia.org/wiki/File:Meadow_Anemone_Lake_Andes_National_Wildlife_Refuge_South_Dakota_(53923253226).jpg) (USFWS) |
| `06.jpg` | [Line5070, NOAA Photo Library](https://commons.wikimedia.org/wiki/File:Line5070_-_Flickr_-_NOAA_Photo_Library.jpg) (NOAA) |
`https://commons.wikimedia.org/wiki/Special:FilePath/<file name>?width=1600` serves each at the
size the cases were placed for (Commons rounds it to 1920). Timing on a 24 MP document through the
command: `cargo run --release -p photocraft-engine --example remove_bench`.
## Test corpora
Real files are our best oracles, but they are large binaries, so they never go into this
+10
View File
@@ -132,6 +132,16 @@ Bounding the mask table to the hole and pruning losing SSD candidates reduced ta
by about 90–93 percent there. Full 24–36 MP kernel inputs improved 4.60–6.24x, but normal
engine strokes already use a cropped region. See [method and limits](proximity-match-performance.md).
2026-10-09: the Remove tool is in the J flyout (`paint.remove`, a background job). A stroke
around an object also removes what it encloses. The fill is non-local patch completion with
texture features (A. Newson et al., IPOL 2017: `crates/algo/src/nonlocal.rs`), finished by a
best-patch copy and gradient-domain seam hiding. On 20 holes in public-domain photos
(`remove_quality`, `docs/development.md` › Remove Tool quality) the fill keeps 0.89 of the
original's texture (Wexler/PatchMatch completion as Content-Aware Fill uses it: 0.54). On a 24 MP
document a 100 px ring takes 120 ms and a 1000 px ring 2.5 s (`remove_bench`). It is not
generative: large objects over complex structure fill less convincingly than Photoshop's AI mode
(#41).
2026-10-08: the tools update above is checked against the current toolbar groups in
[`panels.rs`](../crates/ui-egui/src/panels.rs), the Pencil and tool-cycle tests in
[`pencil_tests.rs`](../crates/ui-egui/src/pencil_tests.rs), Patch's live-preview test in
+7
View File
@@ -277,3 +277,10 @@ target = "Crop gear menu: Show Cropped Area (H), Enable Crop Shield with Match C
status = "done"
issue = 1919
note = "crop_shield.rs: fill() resolves the shield (canvas.rs paints it around upright and turned frames), gear popup in the options bar, H ahead of the Hand key in shortcuts.rs, Custom… opens the Color Picker; ToolOptions crop_shield, ui.set cropShield. No Classic Mode / Auto Center Preview (PhotoCraft is always Classic Mode)"
[[item]]
id = "TOOL-213-19"
target = "Remove tool: paint over an object (or circle it) and it is filled from its surroundings"
status = "partial"
issue = 213
note = "paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Missing: generative AI mode, Remove after each stroke off, live preview of the result"