mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
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:
co-authored by
Brandon Thomas
parent
414b7b9e4b
commit
897f9f37ad
+2
-1
@@ -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) |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -579,6 +579,7 @@ fn routed(id: &str) -> bool {
|
||||
| "paint.smudge"
|
||||
| "paint.historyBrush"
|
||||
| "paint.redEye"
|
||||
| "paint.remove"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) }))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -2199,6 +2199,7 @@ cameraRaw Color 색상
|
||||
Reduce Color Noise 색상 노이즈 감소
|
||||
Sharpen Details 세부 묘사 선명하게
|
||||
Remove Jpeg Artifact JPEG 가공물 제거
|
||||
Remove Tool 제거 도구
|
||||
Blade Curvature 조리개 날 곡률
|
||||
Depth Map 깊이 맵
|
||||
Focal Distance 초점 거리
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -1317,6 +1317,7 @@
|
||||
Remove All Pins 移除所有控制点
|
||||
Remove Background 移除背景
|
||||
Remove Planes 移除平面
|
||||
Remove Tool 移除工具
|
||||
Remove stop 移除色标
|
||||
Remove variable 移除变量
|
||||
Rename 重命名
|
||||
|
||||
|
@@ -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.
|
@@ -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")),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user