# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	xtask/src/layers.rs
This commit is contained in:
Claude committed 2026-10-11 11:57:45 +00:00
commit 17dfa9709e
24 files changed
+1009 -14

No files matched your search

Generated
+8
View File
@@ -3302,6 +3302,13 @@ dependencies = [
"rayon",
]
[[package]]
name = "lightcraft-inpaint"
version = "0.6.0"
dependencies = [
"rayon",
]
[[package]]
name = "lightcraft-lensdb"
version = "0.6.0"
@@ -3358,6 +3365,7 @@ dependencies = [
"lightcraft-color",
"lightcraft-develop",
"lightcraft-geom",
"lightcraft-inpaint",
"lightcraft-raster",
"lightcraft-raw",
"lightcraft-scenes",
+1
View File
@@ -25,6 +25,7 @@ lightcraft-codecs = { path = "crates/codecs" }
lightcraft-meta = { path = "crates/meta" }
lightcraft-develop = { path = "crates/develop" }
lightcraft-lensdb = { path = "crates/lensdb" }
lightcraft-inpaint = { path = "crates/inpaint" }
lightcraft-scenes = { path = "crates/scenes" }
lightcraft-pipeline = { path = "crates/pipeline" }
lightcraft-gpu = { path = "crates/gpu" }
+6
View File
@@ -22,6 +22,12 @@ Foundation project licensed under the Apache License 2.0, and from the CIE 1931
functions it ships (CIE, CC-BY-4.0). The matrices were fitted by the LightCraft contributors
(2026) with tools/spectral_camera_table.py. See assets/ATTRIBUTION.md.
crates/inpaint (lightcraft-inpaint) ports the inpainting (PatchMatch completion) and Poisson membrane
fill code of PhotoCraft (https://github.com/storytold/photocraft, crates/algo/src/inpaint.rs and
poisson.rs), Copyright the PhotoCraft contributors, licensed under the MIT License or the Apache
License 2.0, at your option. The port was modified by the LightCraft contributors (2026); each
ported file says so.
crates/segment (lightcraft-segment) ports the SAM 3 model code of Hugging Face Transformers
(https://github.com/huggingface/transformers, src/transformers/models/sam3 and sam3_tracker),
Copyright The HuggingFace Team and Meta Platforms, Inc., licensed under the Apache License 2.0.
+2
View File
@@ -963,6 +963,8 @@ pub enum SpotMode {
Remove,
Heal,
Clone,
/// Content-aware: the spot is rebuilt from patches of its surroundings (no single source).
Fill,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+2 -2
View File
@@ -752,7 +752,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Remove Spot",
[],
None,
"{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}",
"{mode?: remove|heal|clone|fill (content-aware), points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}",
has_active,
|s, p| {
let mode: SpotMode =
@@ -776,7 +776,7 @@ pub fn specs() -> Vec<CommandSpec> {
};
let id = s.active().ok_or_else(|| bad("spot.add", "no active photo"))?;
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
if spot.source_offset.is_none() {
if spot.source_offset.is_none() && spot.mode != SpotMode::Fill {
// resolve the automatic source now: it gets a pin and stays put at every size
spot.source_offset = pick_source(s, id, &dd, &spot, None);
}
+10
View File
@@ -55,3 +55,13 @@ fn spots_select_move_edit_refresh_and_delete() {
s.execute("library.next", &json!({})).unwrap();
assert_eq!(s.active_spot, None);
}
#[test]
fn a_content_aware_spot_has_no_source() {
let mut s = Session::with_demo();
s.execute("spot.add", &json!({"mode": "fill", "points": [[0.4, 0.5]], "size": 0.03})).unwrap();
let sp = &spots(&s)[0];
assert_eq!((sp.mode, sp.source_offset), (lightcraft_develop::SpotMode::Fill, None));
assert_eq!(serde_json::to_value(sp).unwrap()["mode"], "fill");
assert!(s.execute("spot.update", &json!({"mode": "nonsense"})).is_err());
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "lightcraft-inpaint"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Content-aware hole filling: PatchMatch-based patch completion and membrane fill (L1). Ported from PhotoCraft"
[lints]
workspace = true
[dependencies]
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon = { workspace = true }
+61
View File
@@ -0,0 +1,61 @@
//! Content-aware hole filling (inpainting) for the Remove tool.
//!
//! * [`complete`]: multi-scale patch-based completion (Wexler, Shechtman, Irani 2007) with the
//! nearest-neighbour field found by PatchMatch (Barnes et al. 2009). See [`patchmatch`].
//! * [`membrane_fill`]: smooth harmonic fill, used to seed the coarsest level and as a fallback.
//!
//! Buffers are interleaved `w × h × ch` floats in any colour model plus a `w × h` hole mask.
//! Everything is deterministic (fixed-seed RNG, results independent of the thread count) and never
//! panics on mismatched sizes: such calls return `None` or the input unchanged.
//!
//! Sources: C. Barnes, E. Shechtman, A. Finkelstein, D. B. Goldman, *PatchMatch: A Randomized
//! Correspondence Algorithm for Structural Image Editing*, SIGGRAPH 2009; Y. Wexler, E. Shechtman,
//! M. Irani, *Space-Time Completion of Video*, PAMI 2007.
//!
//! Ported from PhotoCraft (`crates/algo/src/inpaint.rs`, `poisson.rs`, MIT OR Apache-2.0, same
//! organisation; see `NOTICE`).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod membrane;
pub mod patchmatch;
pub use membrane::membrane_fill;
pub use patchmatch::{CompleteParams, complete, complete_with};
/// A cancelled [`complete_with`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Cancelled;
impl std::fmt::Display for Cancelled {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("cancelled")
}
}
impl std::error::Error for Cancelled {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostile_sizes_do_not_panic() {
assert_eq!(complete(4, 4, 3, &[0.0; 5], &[true; 16], &CompleteParams::default()), None);
assert_eq!(complete(4, 4, 0, &[], &[true; 16], &CompleteParams::default()), None);
assert_eq!(complete(usize::MAX, 2, 3, &[0.0; 6], &[true; 2], &CompleteParams::default()), None);
assert_eq!(membrane_fill(4, 4, 1, &[0.5; 3], &[true; 16]), vec![0.5; 3]);
// A hole covering everything: nothing to copy from.
assert_eq!(complete(16, 16, 1, &[0.5; 256], &[true; 256], &CompleteParams::default()), None);
// A tiny image smaller than a patch.
let _ = complete(2, 2, 1, &[0.5; 4], &[true, false, false, false], &CompleteParams::default());
}
#[test]
fn cancellation_stops() {
let img = vec![0.5f32; 64 * 64];
let hole: Vec<bool> = (0..64 * 64usize).map(|i| (i % 64).abs_diff(32) < 4 && (i / 64).abs_diff(32) < 4).collect();
assert_eq!(complete_with(64, 64, 1, &img, &hole, &CompleteParams::default(), &|| true), Err(Cancelled));
}
}
+197
View File
@@ -0,0 +1,197 @@
//! Membrane (harmonic) fill: `Δv = 0` inside a hole with the known pixels as Dirichlet boundary.
//! Used to initialise the coarsest level of [`crate::complete`] and as its fallback.
//!
//! Sources: the membrane interpolant of P. Pérez, M. Gangnet, A. Blake, *Poisson Image Editing*,
//! ACM SIGGRAPH 2003 (§3), solved by successive over-relaxation on the 4-connected grid, accelerated
//! cascadically (solve on a half-resolution grid, prolong bilinearly, a few SOR sweeps per level).
//! Ported from PhotoCraft's `algo::poisson` (MIT OR Apache-2.0).
/// Convergence threshold (max per-sweep update) for the SOR sweeps.
const TOL: f32 = 2e-5;
/// SOR sweeps (cap) and relaxation factor on every level above the coarsest: tuned so a 96×72 disc
/// is within ~1e-3 of a fully converged Gauss–Seidel solve (below one 8-bit step).
const FINE_ITERS: usize = 30;
const FINE_OMEGA: f32 = 1.7;
/// Solve `Δv = 0` on `unknown` pixels with Dirichlet values from the known pixels of `v`
/// (out-of-grid neighbours are ignored, i.e. a Neumann boundary). `v` holds the known values on
/// entry and the solution on exit. Cascadic multigrid + SOR.
pub(crate) fn solve_membrane(w: usize, h: usize, unknown: &[bool], v: &mut [f32]) {
if unknown.len() != w * h || v.len() != w * h {
return;
}
solve_rec(w, h, unknown, v, 0, &|| false);
}
fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize, stop: &(dyn Fn() -> bool + Sync)) {
if stop() {
return;
}
let n_unknown = unknown.iter().filter(|u| **u).count();
if n_unknown == 0 {
return;
}
let known: Vec<f32> = v.iter().zip(unknown).filter(|(_, u)| !**u).map(|(x, _)| *x).collect();
if known.is_empty() {
return; // nothing to interpolate from
}
if w >= 8 && h >= 8 && n_unknown > 64 && depth < 16 {
// Restrict: a coarse pixel is known if any of its children is (value = mean of known children).
let (cw, chh) = (w.div_ceil(2), h.div_ceil(2));
let mut cu = vec![true; cw * chh];
let mut cv = vec![0.0f32; cw * chh];
for cy in 0..chh {
for cx in 0..cw {
let (mut s, mut n) = (0.0f32, 0u32);
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
let (x, y) = (cx * 2 + dx, cy * 2 + dy);
if x < w && y < h && !unknown[y * w + x] {
s += v[y * w + x];
n += 1;
}
}
if n > 0 {
cu[cy * cw + cx] = false;
cv[cy * cw + cx] = s / n as f32;
}
}
}
solve_rec(cw, chh, &cu, &mut cv, depth + 1, stop);
// Prolong: bilinear sample of the coarse solution as the initial guess.
for y in 0..h {
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (chh - 1) as f32);
let y0 = fy.floor() as usize;
let y1 = (y0 + 1).min(chh - 1);
let ty = fy - y0 as f32;
for x in 0..w {
if !unknown[y * w + x] {
continue;
}
let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (cw - 1) as f32);
let x0 = fx.floor() as usize;
let x1 = (x0 + 1).min(cw - 1);
let tx = fx - x0 as f32;
let a = cv[y0 * cw + x0] + (cv[y0 * cw + x1] - cv[y0 * cw + x0]) * tx;
let b = cv[y1 * cw + x0] + (cv[y1 * cw + x1] - cv[y1 * cw + x0]) * tx;
v[y * w + x] = a + (b - a) * ty;
}
}
sor(w, h, unknown, v, FINE_ITERS, FINE_OMEGA, stop);
} else {
let mean = known.iter().sum::<f32>() / known.len() as f32;
for (x, u) in v.iter_mut().zip(unknown) {
if *u {
*x = mean;
}
}
sor(w, h, unknown, v, 2000, 1.85, stop);
}
}
/// SOR sweeps over the unknown pixels until the largest update drops below [`TOL`].
fn sor(w: usize, h: usize, unknown: &[bool], v: &mut [f32], max_iter: usize, omega: f32, stop: &(dyn Fn() -> bool + Sync)) {
// Precompute the unknown pixels and which neighbours exist.
let cells: Vec<(u32, u8)> = (0..w * h)
.filter(|&i| unknown[i])
.map(|i| {
let (x, y) = (i % w, i / w);
let m = u8::from(x > 0) | (u8::from(x + 1 < w) << 1) | (u8::from(y > 0) << 2) | (u8::from(y + 1 < h) << 3);
(i as u32, m)
})
.collect();
for _ in 0..max_iter {
if stop() {
return;
}
let mut max_d = 0.0f32;
for &(i, m) in &cells {
let i = i as usize;
let (s, n) = if m == 0b1111 {
(v[i - 1] + v[i + 1] + v[i - w] + v[i + w], 4.0)
} else {
let mut s = 0.0;
let mut n = 0.0;
if m & 1 != 0 {
s += v[i - 1];
n += 1.0;
}
if m & 2 != 0 {
s += v[i + 1];
n += 1.0;
}
if m & 4 != 0 {
s += v[i - w];
n += 1.0;
}
if m & 8 != 0 {
s += v[i + w];
n += 1.0;
}
if n == 0.0 {
continue;
}
(s, n)
};
let d = omega * (s / n - v[i]);
v[i] += d;
max_d = max_d.max(d.abs());
}
if max_d < TOL {
break;
}
}
}
/// Map `f` over the channels of an interleaved buffer as planar slices (parallel on native).
fn per_channel(w: usize, h: usize, ch: usize, f: impl Fn(usize) -> Vec<f32> + Sync + Send) -> Vec<f32> {
#[cfg(not(target_arch = "wasm32"))]
let planes: Vec<Vec<f32>> = if w * h >= 64 * 64 {
use rayon::prelude::*;
(0..ch).into_par_iter().map(&f).collect()
} else {
(0..ch).map(&f).collect()
};
#[cfg(target_arch = "wasm32")]
let planes: Vec<Vec<f32>> = (0..ch).map(&f).collect();
let mut out = vec![0.0f32; w * h * ch];
for (c, p) in planes.iter().enumerate() {
for (i, v) in p.iter().enumerate() {
out[i * ch + c] = *v;
}
}
out
}
/// Membrane fill (no source texture): harmonic interpolation of the known pixels into `hole`.
/// `img` is interleaved `w × h × ch`, `hole` has `w × h` entries; on a size mismatch `img` comes back
/// unchanged.
pub fn membrane_fill(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool]) -> Vec<f32> {
if Some(hole.len()) != w.checked_mul(h) || Some(img.len()) != hole.len().checked_mul(ch) {
return img.to_vec();
}
per_channel(w, h, ch, |c| {
let mut v: Vec<f32> = (0..w * h).map(|i| if hole[i] { 0.0 } else { img[i * ch + c] }).collect();
solve_membrane(w, h, hole, &mut v);
v
})
}
#[cfg(test)]
mod tests {
use super::*;
fn disc(w: usize, h: usize, cx: f32, cy: f32, r: f32) -> Vec<bool> {
(0..w * h).map(|i| ((i % w) as f32 - cx).hypot((i / w) as f32 - cy) < r).collect()
}
#[test]
fn membrane_reproduces_linear_boundary() {
// A linear function is harmonic: the solve must reproduce it exactly inside the hole.
let (w, h) = (80, 60);
let unknown = disc(w, h, 40.0, 30.0, 20.0);
let truth: Vec<f32> = (0..w * h).map(|i| 0.2 + 0.005 * (i % w) as f32 + 0.003 * (i / w) as f32).collect();
let mut v: Vec<f32> = truth.iter().zip(&unknown).map(|(t, u)| if *u { 0.0 } else { *t }).collect();
solve_membrane(w, h, &unknown, &mut v);
let err = v.iter().zip(&truth).map(|(a, b)| (a - b).abs()).fold(0.0, f32::max);
assert!(err < 2e-3, "{err}");
}
}
+587
View File
@@ -0,0 +1,587 @@
//! Hole filling for Spot Healing: patch-based completion, proximity match and texture synthesis.
//!
//! * [`complete`], content-aware fill: the multi-scale "space-time completion" EM scheme of
//! Y. Wexler, E. Shechtman, M. Irani, *Space-Time Completion of Video*, IEEE TPAMI 2007, with the
//! nearest-neighbour field computed by C. Barnes, E. Shechtman, A. Finkelstein, D. B. Goldman,
//! *PatchMatch: A Randomized Correspondence Algorithm for Structural Image Editing*, SIGGRAPH 2009
//! (random init, propagation, exponentially shrinking random search). Each EM iteration finds, for
//! every patch overlapping the hole, its most similar fully-known patch, then re-estimates hole
//! pixels by a similarity-weighted vote of the overlapping matches. Coarse-to-fine over an image
//! pyramid (the coarsest level is initialised by membrane interpolation), so large holes stay cheap.
//! * [`best_offset`], proximity match: the single displacement whose surrounding ring best matches the
//! ring around the hole (sum of squared differences), i.e. an automatic Healing Brush source.
//! * [`synthesize`], create texture: patch-based texture synthesis in the style of A. Efros,
//! W. Freeman, *Image Quilting for Texture Synthesis and Transfer*, SIGGRAPH 2001 (blocks picked from
//! random candidates by overlap error, feathered overlap instead of a min-cut seam).
//!
//! Ported from PhotoCraft's `algo::inpaint` (MIT OR Apache-2.0); its proximity match and texture
//! synthesis are left out.
//!
//! All functions take interleaved `w × h × ch` normalised floats (any colour model/depth) and a
//! `w × h` hole mask, and return a full buffer equal to the input outside the hole. They are
//! deterministic (fixed-seed hash RNG, results independent of thread count).
use crate::Cancelled;
use crate::membrane::membrane_fill;
/// Below this many grid cells a level runs single-threaded (thread hand-off costs more than it saves).
#[cfg(not(target_arch = "wasm32"))]
const PAR_MIN: usize = 128 * 128;
/// SplitMix64: tiny deterministic RNG.
#[derive(Clone)]
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
Rng(seed ^ 0x9E37_79B9_7F4A_7C15)
}
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);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Uniform integer in `lo..=hi`.
fn range(&mut self, lo: i32, hi: i32) -> i32 {
if hi <= lo {
return lo;
}
lo + (self.next() % (hi - lo + 1) as u64) as i32
}
}
/// Parameters for [`complete`].
#[derive(Clone, Debug, PartialEq)]
pub struct CompleteParams {
/// Patch radius (patches are `2r+1` square).
pub patch_radius: usize,
/// EM iterations at the coarsest level (finer levels use fewer, down to `min_em_iters`).
pub em_iters: usize,
pub min_em_iters: usize,
/// PatchMatch passes per EM iteration.
pub pm_iters: usize,
/// Levels where the hole is wider than this (in that level's pixels) only *refine* the upsampled
/// match field (one local PatchMatch pass and one vote), which keeps big holes tractable: the
/// structure is decided at coarse scale, fine levels just add detail.
pub refine_extent: usize,
pub seed: u64,
}
impl Default for CompleteParams {
fn default() -> Self {
Self { patch_radius: 3, em_iters: 8, min_em_iters: 2, pm_iters: 2, refine_extent: 96, seed: 1 }
}
}
/// One pyramid level.
#[derive(Clone)]
struct Level {
w: usize,
h: usize,
img: Vec<f32>,
hole: Vec<bool>,
}
fn downsample(l: &Level, ch: usize) -> Level {
let (w, h) = (l.w.div_ceil(2), l.h.div_ceil(2));
let mut img = vec![0.0f32; w * h * ch];
let mut hole = vec![false; w * h];
for y in 0..h {
for x in 0..w {
let mut n = 0.0f32;
let o = (y * w + x) * ch;
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
let (sx, sy) = (x * 2 + dx, y * 2 + dy);
if sx >= l.w || sy >= l.h {
continue;
}
let si = sy * l.w + sx;
if l.hole[si] {
hole[y * w + x] = true;
} else {
for c in 0..ch {
img[o + c] += l.img[si * ch + c];
}
n += 1.0;
}
}
if n > 0.0 {
for c in 0..ch {
img[o + c] /= n;
}
}
}
}
Level { w, h, img, hole }
}
/// Summed-area table of a boolean grid (`(w+1) × (h+1)`).
fn integral(w: usize, h: usize, m: &[bool]) -> Vec<u32> {
let mut s = vec![0u32; (w + 1) * (h + 1)];
for y in 0..h {
let mut row = 0u32;
for x in 0..w {
row += u32::from(m[y * w + x]);
s[(y + 1) * (w + 1) + x + 1] = s[y * (w + 1) + x + 1] + row;
}
}
s
}
/// Count of set cells in the clipped window `[x0, x1) × [y0, y1)`.
fn window_count(s: &[u32], gw: usize, gh: usize, x0: i32, y0: i32, x1: i32, y1: i32) -> u32 {
let (x0, y0) = (x0.max(0) as usize, y0.max(0) as usize);
let (x1, y1) = ((x1.max(0) as usize).min(gw), (y1.max(0) as usize).min(gh));
if x1 <= x0 || y1 <= y0 {
return 0;
}
let w1 = gw + 1;
s[y1 * w1 + x1] + s[y0 * w1 + x0] - s[y0 * w1 + x1] - s[y1 * w1 + x0]
}
fn hole_bbox(w: usize, h: usize, hole: &[bool]) -> Option<(usize, usize, usize, usize)> {
let mut b: Option<(usize, usize, usize, usize)> = None;
for y in 0..h {
for x in 0..w {
if hole[y * w + x] {
b = Some(match b {
None => (x, y, x + 1, y + 1),
Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1)),
});
}
}
}
b
}
/// Per-level PatchMatch/EM state.
struct Solver<'a> {
w: usize,
h: usize,
ch: usize,
r: i32,
img: &'a mut Vec<f32>,
hole: &'a [bool],
/// Valid source centres: the whole patch is inside the grid and contains no hole pixel.
valid: Vec<bool>,
valid_list: Vec<(i32, i32)>,
/// Target centres (patch overlaps the hole), row-major.
targets: Vec<bool>,
/// Cancellation: checked per row band; a cancelled solve's output is discarded.
ctl: &'a (dyn Fn() -> bool + Sync),
}
impl Solver<'_> {
#[inline]
fn is_valid(&self, x: i32, y: i32) -> bool {
x >= 0 && y >= 0 && (x as usize) < self.w && (y as usize) < self.h && self.valid[y as usize * self.w + x as usize]
}
/// SSD between the (clipped) target patch at `t` and the source patch at `s`, per sample.
fn dist(&self, t: (i32, i32), s: (i32, i32), cutoff: f32) -> f32 {
let (w, h, ch, r) = (self.w as i32, self.h as i32, self.ch, self.r);
let full = ((2 * r + 1) * (2 * r + 1)) as f32 * ch as f32;
let mut sum = 0.0f32;
let mut n = 0usize;
for dy in -r..=r {
let ty = t.1 + dy;
if ty < 0 || ty >= h {
continue;
}
let sy = s.1 + dy;
for dx in -r..=r {
let tx = t.0 + dx;
if tx < 0 || tx >= w {
continue;
}
let ti = (ty as usize * self.w + tx as usize) * ch;
let si = (sy as usize * self.w + (s.0 + dx) as usize) * ch;
for c in 0..ch {
let d = self.img[ti + c] - self.img[si + c];
sum += d * d;
}
n += ch;
}
// Early out: the mean can only be ≥ sum / (full patch size).
if sum > cutoff * full {
return f32::INFINITY;
}
}
if n == 0 { f32::INFINITY } else { sum / n as f32 }
}
/// One PatchMatch pass over all target rows, in parallel bands (each band propagates within itself).
fn patchmatch(&self, nnf: &mut [(i32, i32)], cost: &mut [f32], pass: usize, seed: u64, search_radius: i32) {
const BAND: usize = 16;
let w = self.w;
let reverse = pass % 2 == 1;
let band_fn = |bi: usize, nn: &mut [(i32, i32)], co: &mut [f32]| {
if (self.ctl)() {
return;
}
let mut rng = Rng::new(seed ^ (bi as u64).wrapping_mul(0x2545_F491_4F6C_DD1D) ^ ((pass as u64) << 40));
let y0 = bi * BAND;
let rows = nn.len() / w;
let step: i32 = if reverse { -1 } else { 1 };
for ry in 0..rows {
let ly = if reverse { rows - 1 - ry } else { ry };
let y = y0 + ly;
for rx in 0..w {
let x = if reverse { w - 1 - rx } else { rx };
let li = ly * w + x;
if !self.targets[y * w + x] {
continue;
}
let t = (x as i32, y as i32);
let (mut best, mut bc) = (nn[li], co[li]);
// Propagation from the already-visited neighbours (within this band).
let nx = x as i32 - step;
if nx >= 0 && (nx as usize) < w {
let c = nn[ly * w + nx as usize];
let cand = (c.0 + step, c.1);
if self.is_valid(cand.0, cand.1) && cand != best {
let d = self.dist(t, cand, bc);
if d < bc {
(best, bc) = (cand, d);
}
}
}
let nly = ly as i32 - step;
if nly >= 0 && (nly as usize) < rows {
let c = nn[nly as usize * w + x];
let cand = (c.0, c.1 + step);
if self.is_valid(cand.0, cand.1) && cand != best {
let d = self.dist(t, cand, bc);
if d < bc {
(best, bc) = (cand, d);
}
}
}
// Random search in exponentially shrinking windows.
let mut rad = search_radius;
while rad >= 1 {
let cand = (best.0 + rng.range(-rad, rad), best.1 + rng.range(-rad, rad));
if self.is_valid(cand.0, cand.1) && cand != best {
let d = self.dist(t, cand, bc);
if d < bc {
(best, bc) = (cand, d);
}
}
rad /= 2;
}
nn[li] = best;
co[li] = bc;
}
}
};
#[cfg(not(target_arch = "wasm32"))]
if w * self.h >= PAR_MIN {
use rayon::prelude::*;
nnf.par_chunks_mut(BAND * w).zip(cost.par_chunks_mut(BAND * w)).enumerate().for_each(|(bi, (nn, co))| band_fn(bi, nn, co));
return;
}
for (bi, (nn, co)) in nnf.chunks_mut(BAND * w).zip(cost.chunks_mut(BAND * w)).enumerate() {
band_fn(bi, nn, co);
}
}
/// Re-estimate hole pixels by a similarity-weighted vote of all overlapping patch matches.
fn vote(&mut self, nnf: &[(i32, i32)], cost: &[f32]) {
let (w, h, ch, r) = (self.w, self.h, self.ch, self.r);
// σ² from the 75th percentile of match costs (Wexler et al. use a robust scale like this).
let mut cs: Vec<f32> = cost.iter().zip(&self.targets).filter(|(c, t)| **t && c.is_finite()).map(|(c, _)| *c).collect();
let sigma2 = if cs.is_empty() {
1.0
} else {
let k = (cs.len() * 3 / 4).min(cs.len() - 1);
let (_, v, _) = cs.select_nth_unstable_by(k, |a, b| a.total_cmp(b));
v.max(1e-6)
};
let img = &*self.img;
let row = |y: usize| -> Vec<(usize, Vec<f32>)> {
let mut out = Vec::new();
if (self.ctl)() {
return out;
}
for x in 0..w {
if !self.hole[y * w + x] {
continue;
}
let mut acc = vec![0.0f32; ch];
let mut wsum = 0.0f32;
for dy in -r..=r {
let ty = y as i32 + dy;
if ty < 0 || ty as usize >= h {
continue;
}
for dx in -r..=r {
let tx = x as i32 + dx;
if tx < 0 || tx as usize >= w {
continue;
}
let ti = ty as usize * w + tx as usize;
if !self.targets[ti] || !cost[ti].is_finite() {
continue;
}
let s = nnf[ti];
let (sx, sy) = (s.0 - dx, s.1 - dy);
let wt = (-cost[ti] / (2.0 * sigma2)).exp().max(1e-8);
let si = (sy as usize * w + sx as usize) * ch;
for c in 0..ch {
acc[c] += img[si + c] * wt;
}
wsum += wt;
}
}
if wsum > 0.0 {
acc.iter_mut().for_each(|v| *v /= wsum);
out.push((x, acc));
}
}
out
};
#[cfg(not(target_arch = "wasm32"))]
let rows: Vec<Vec<(usize, Vec<f32>)>> = if w * h >= PAR_MIN {
use rayon::prelude::*;
(0..h).into_par_iter().map(row).collect()
} else {
(0..h).map(row).collect()
};
#[cfg(target_arch = "wasm32")]
let rows: Vec<Vec<(usize, Vec<f32>)>> = (0..h).map(row).collect();
for (y, r) in rows.into_iter().enumerate() {
for (x, v) in r {
self.img[(y * w + x) * ch..(y * w + x + 1) * ch].copy_from_slice(&v);
}
}
}
}
/// Content-aware completion of `hole` (see module docs). Returns `None` when there is no fully-known
/// patch to copy from (the caller can fall back to [`membrane_fill`]).
pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &CompleteParams) -> Option<Vec<f32>> {
// Never cancelled, so never `Err`.
complete_with(w, h, ch, img, hole, p, &|| false).unwrap_or(None)
}
/// [`complete`] that can be cancelled (checked per EM iteration, PatchMatch pass and row band)
/// (`cancelled` is polled per EM iteration, PatchMatch pass and row band): `Err(Cancelled)` once it
/// returned `true`. `Ok(None)` also for mismatched buffer sizes or `ch == 0`.
pub fn complete_with(
w: usize,
h: usize,
ch: usize,
img: &[f32],
hole: &[bool],
p: &CompleteParams,
cancelled: &(dyn Fn() -> bool + Sync),
) -> Result<Option<Vec<f32>>, Cancelled> {
let check = || if cancelled() { Err(Cancelled) } else { Ok(()) };
if ch == 0 || Some(hole.len()) != w.checked_mul(h) || Some(img.len()) != hole.len().checked_mul(ch) {
return Ok(None);
}
let Some((bx0, by0, bx1, by1)) = hole_bbox(w, h, hole) else { return Ok(Some(img.to_vec())) };
let r = p.patch_radius.max(1);
let psz = 2 * r + 1;
// Pyramid: shrink until the hole is a few patches across.
let mut levels = vec![Level { w, h, img: img.to_vec(), hole: hole.to_vec() }];
let mut ext = (bx1 - bx0).max(by1 - by0);
let mut exts = vec![ext];
while let Some(l) = levels.last() {
check()?;
if ext <= 2 * psz || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
break;
}
let d = downsample(l, ch);
levels.push(d);
ext = ext.div_ceil(2);
exts.push(ext);
}
let nlev = levels.len();
let mut nnf: Vec<(i32, i32)> = Vec::new();
let mut prev: Option<(usize, usize, Vec<f32>)> = None;
for li in (0..nlev).rev() {
check()?;
let Level { w: lw, h: lh, img: mut limg, hole: lhole } = levels[li].clone();
// Initialise the hole: membrane at the coarsest level, upsampled result elsewhere.
match &prev {
None => limg = membrane_fill(lw, lh, ch, &limg, &lhole),
Some((pw, ph, pimg)) => {
for y in 0..lh {
for x in 0..lw {
if lhole[y * lw + x] {
let (sx, sy) = ((x / 2).min(pw - 1), (y / 2).min(ph - 1));
let (d, s) = ((y * lw + x) * ch, (sy * pw + sx) * ch);
limg[d..d + ch].copy_from_slice(&pimg[s..s + ch]);
}
}
}
}
}
let ri = r as i32;
let hs = integral(lw, lh, &lhole);
let mut valid = vec![false; lw * lh];
let mut valid_list = Vec::new();
let mut targets = vec![false; lw * lh];
for y in 0..lh as i32 {
if y % 32 == 0 {
check()?;
}
for x in 0..lw as i32 {
let cnt = window_count(&hs, lw, lh, x - ri, y - ri, x + ri + 1, y + ri + 1);
let inside = x >= ri && y >= ri && x + ri < lw as i32 && y + ri < lh as i32;
let i = y as usize * lw + x as usize;
if inside && cnt == 0 {
valid[i] = true;
valid_list.push((x, y));
}
targets[i] = cnt > 0;
}
}
if valid_list.is_empty() {
if li == 0 {
return Ok(None);
}
prev = Some((lw, lh, limg));
nnf.clear();
continue;
}
let mut solver = Solver { w: lw, h: lh, ch, r: ri, img: &mut limg, hole: &lhole, valid, valid_list, targets, ctl: cancelled };
// NNF init: upsampled from the coarser level where possible, random otherwise.
let mut rng = Rng::new(p.seed ^ (li as u64) << 20);
let pw_prev = prev.as_ref().map_or(0, |p| p.0);
let old = std::mem::take(&mut nnf);
let mut new_nnf = vec![(0i32, 0i32); lw * lh];
for y in 0..lh {
if y % 32 == 0 {
check()?;
}
for x in 0..lw {
if !solver.targets[y * lw + x] {
continue;
}
let mut cand = None;
if !old.is_empty() && pw_prev > 0 {
let pi = (y / 2) * pw_prev + (x / 2);
if let Some(&(sx, sy)) = old.get(pi) {
let c = (sx * 2 + (x % 2) as i32, sy * 2 + (y % 2) as i32);
if solver.is_valid(c.0, c.1) {
cand = Some(c);
}
}
}
new_nnf[y * lw + x] = cand.unwrap_or_else(|| solver.valid_list[(rng.next() % solver.valid_list.len() as u64) as usize]);
}
}
nnf = new_nnf;
let refine_only = li + 1 < nlev && exts[li] > p.refine_extent;
let em = if li + 1 == nlev {
p.em_iters
} else if refine_only {
1
} else {
(p.em_iters >> (nlev - 1 - li)).max(p.min_em_iters)
};
let pm_iters = if refine_only { 1 } else { p.pm_iters };
let full_search = (lw.max(lh)) as i32;
let mut cost = vec![f32::INFINITY; lw * lh];
for it in 0..em {
// Costs change as hole pixels are re-estimated: refresh before each PatchMatch.
for (i, c) in cost.iter_mut().enumerate() {
if i % (lw * 32).max(1) == 0 {
check()?;
}
if solver.targets[i] {
*c = solver.dist(((i % lw) as i32, (i / lw) as i32), nnf[i], f32::INFINITY);
}
}
// Wide random search on coarse levels / first iteration, local refinement afterwards.
let radius = if refine_only {
2
} else if li + 1 == nlev || it == 0 {
full_search
} else {
(4 * psz as i32).min(full_search)
};
for pass in 0..pm_iters {
check()?;
solver.patchmatch(&mut nnf, &mut cost, pass + it * pm_iters, p.seed.wrapping_add((li * 1000 + it) as u64), radius);
}
check()?;
solver.vote(&nnf, &cost);
check()?;
}
prev = Some((lw, lh, limg));
}
Ok(prev.map(|(_, _, img)| img))
}
/// Proximity match: the displacement `(dx, dy)` (within `max_radius`) whose surroundings best match the
/// ring of `ring` pixels around the hole, with the displaced hole fully inside the grid and outside the
#[cfg(test)]
mod tests {
use super::*;
/// Smooth gradient plus deterministic fine noise.
fn texture(w: usize, h: usize) -> Vec<f32> {
let mut rng = Rng::new(7);
(0..w * h)
.flat_map(|i| {
let (x, y) = ((i % w) as f32, (i / w) as f32);
let n = (rng.next() % 1000) as f32 / 1000.0 * 0.04 - 0.02;
[0.3 + 0.004 * x + n, 0.5 + 0.002 * y + n, 0.4 + n]
})
.collect()
}
fn disc(w: usize, h: usize, cx: f32, cy: f32, r: f32) -> Vec<bool> {
(0..w * h).map(|i| ((i % w) as f32 - cx).hypot((i / w) as f32 - cy) < r).collect()
}
fn hole_rmse(a: &[f32], b: &[f32], hole: &[bool], ch: usize) -> f32 {
let (mut e, mut n) = (0.0f32, 0);
for (i, hh) in hole.iter().enumerate() {
if *hh {
for c in 0..ch {
e += (a[i * ch + c] - b[i * ch + c]).powi(2);
n += 1;
}
}
}
(e / n as f32).sqrt()
}
#[test]
fn completion_fills_blemish_from_texture() {
let (w, h, ch) = (96, 96, 3);
let clean = texture(w, h);
let hole = disc(w, h, 48.0, 48.0, 9.0);
let mut dirty = clean.clone();
for (i, hh) in hole.iter().enumerate() {
if *hh {
dirty[i * ch..i * ch + ch].copy_from_slice(&[0.02, 0.02, 0.02]);
}
}
let out = complete(w, h, ch, &dirty, &hole, &CompleteParams::default()).unwrap();
let before = hole_rmse(&dirty, &clean, &hole, ch);
let after = hole_rmse(&out, &clean, &hole, ch);
assert!(after < 0.06 && after < before * 0.15, "rmse before {before} after {after}");
// Outside the hole untouched.
assert!(out.iter().zip(&dirty).zip(hole.iter().flat_map(|h| [*h; 3])).all(|((a, b), h)| h || a == b));
// Deterministic.
assert_eq!(out, complete(w, h, ch, &dirty, &hole, &CompleteParams::default()).unwrap());
}
#[test]
fn completion_continues_structure() {
// Vertical stripes: the filled hole should keep the stripes (not a blur).
let (w, h, ch) = (80, 80, 1);
let clean: Vec<f32> = (0..w * h).map(|i| if (i % w / 4) % 2 == 0 { 0.2 } else { 0.8 }).collect();
let hole = disc(w, h, 40.0, 40.0, 7.0);
let dirty: Vec<f32> = clean.iter().zip(&hole).map(|(c, h)| if *h { 0.5 } else { *c }).collect();
let out = complete(w, h, ch, &dirty, &hole, &CompleteParams::default()).unwrap();
let err = hole_rmse(&out, &clean, &hole, ch);
assert!(err < 0.15, "{err}");
}
}
+1
View File
@@ -14,6 +14,7 @@ lightcraft-color = { workspace = true }
lightcraft-raster = { workspace = true }
lightcraft-develop = { workspace = true }
lightcraft-raw = { workspace = true }
lightcraft-inpaint = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }
+98 -2
View File
@@ -5,6 +5,10 @@
//! (frequency separation — a fast, stable approximation of gradient-domain healing).
//! - **Remove** is Heal with an automatically chosen source: candidate offsets on rings around the
//! spot are scored by how well the source's surrounding annulus matches the target's.
//! - **Fill** (content-aware) rebuilds the spot from patches of its surroundings instead of one
//! source: PatchMatch-based completion ([`lightcraft_inpaint::complete`]), coarse to fine, so the
//! structure is chosen at a scale set by the spot's size (the same at preview and export size).
//! Deterministic; the source offset is ignored.
use lightcraft_develop::{Spot, SpotMode};
use lightcraft_geom::Point;
@@ -235,6 +239,19 @@ pub fn window_for_reads_checked(
)
}
/// Content-aware fill of the box: `src` becomes `tgt` with every pixel under the stroke (`alpha > 0`)
/// rebuilt from the rest of the box. Falls back to a smooth membrane fill when the box holds no whole
/// patch to copy from.
fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh: usize) {
let hole: Vec<bool> = alpha.iter().map(|a| *a > 0.0).collect();
let flat: Vec<f32> = tgt.iter().flatten().copied().collect();
let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default())
.unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole));
for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) {
*d = [v[0], v[1], v[2]];
}
}
/// Apply all spots to `img` (output resolution). `ppl` = output pixels per long-edge unit.
pub fn apply(img: &mut Rgb32f, spots: &[Spot], frame: &Frame, ppl: f64) {
if spots.is_empty() {
@@ -253,7 +270,9 @@ pub fn apply(img: &mut Rgb32f, spots: &[Spot], frame: &Frame, ppl: f64) {
})
.collect();
let Some(&first) = pts.first() else { continue };
let fill = spot.mode == SpotMode::Fill;
let offset = match spot.source_offset {
_ if fill => (0.0, 0.0),
Some(o) => {
let a = to_out.apply(Point::new(0.0, 0.0));
let b = to_out.apply(o);
@@ -269,7 +288,8 @@ pub fn apply(img: &mut Rgb32f, spots: &[Spot], frame: &Frame, ppl: f64) {
x1 = x1.max(p.0 + r);
y1 = y1.max(p.1 + r);
}
let pad = r;
// content-aware fill reads the spot's surroundings (the window holds 2 radii around it)
let pad = if fill { 2.0 * r } else { r };
let (bx0, by0) = (((x0 - pad).floor().max(0.0)) as usize, ((y0 - pad).floor().max(0.0)) as usize);
let (bx1, by1) = (((x1 + pad).ceil() as usize).min(w), ((y1 + pad).ceil() as usize).min(h));
if bx1 <= bx0 || by1 <= by0 {
@@ -292,7 +312,10 @@ pub fn apply(img: &mut Rgb32f, spots: &[Spot], frame: &Frame, ppl: f64) {
tgt[y * bw + x] = img.get(bx0 + x, by0 + y);
}
}
let heal = spot.mode != SpotMode::Clone;
if fill {
fill_box(&mut src, &tgt, &alpha, bw, bh);
}
let heal = matches!(spot.mode, SpotMode::Remove | SpotMode::Heal);
let low = if heal {
// Tone correction from the spot's *surroundings* only: normalized convolution of
// (target − source) over pixels outside the stroke (a membrane-like interpolation).
@@ -361,6 +384,79 @@ mod tests {
assert!((c[0] - 0.26).abs() < 0.03, "{c:?}");
}
/// Vertical stripes (period `period` px) with a black blot over the middle.
fn striped_with_blot(w: usize, h: usize, period: usize) -> Rgb32f {
let mut img = Rgb32f::from_fn(w, h, |x, _| if (x / period).is_multiple_of(2) { [0.2; 3] } else { [0.7; 3] });
let (cx, cy, r) = (w as f32 / 2.0, h as f32 / 2.0, w as f32 * 0.04);
for y in 0..h {
for x in 0..w {
if (x as f32 + 0.5 - cx).hypot(y as f32 + 0.5 - cy) < r {
img.set(x, y, [0.0; 3]);
}
}
}
img
}
fn fill_spot() -> Spot {
// a source offset is ignored by Fill
Spot {
mode: SpotMode::Fill,
points: vec![Point::new(0.5, 0.5)],
size: 0.06,
feather: 0.0,
source_offset: Some(Point::new(0.3, 0.0)),
..Default::default()
}
}
#[test]
fn fill_rebuilds_structure_deterministically() {
let (w, h) = (200, 160);
let mut img = striped_with_blot(w, h, 8);
let frame = Frame::new(w, h, &DevelopSettings::default(), true);
apply(&mut img, &[fill_spot()], &frame, frame.px_per_long(w));
// the blot is gone and the stripes continue through it
let mut err = 0.0f32;
for x in 90..110 {
let want = if (x / 8) % 2 == 0 { 0.2 } else { 0.7 };
err += (img.get(x, 80)[0] - want).abs();
}
assert!(err / 20.0 < 0.12, "mean error {}", err / 20.0);
let mut again = striped_with_blot(w, h, 8);
apply(&mut again, &[fill_spot()], &frame, frame.px_per_long(w));
assert_eq!(img.data, again.data);
}
#[test]
fn fill_matches_between_preview_and_export_size() {
// the same photo at two sizes: the half-size fill matches the full-size one, downsampled
let (mut big, mut small) = (striped_with_blot(320, 240, 16), striped_with_blot(160, 120, 8));
let (fb, fs) = (Frame::new(320, 240, &DevelopSettings::default(), true), Frame::new(160, 120, &DevelopSettings::default(), true));
apply(&mut big, &[fill_spot()], &fb, fb.px_per_long(320));
apply(&mut small, &[fill_spot()], &fs, fs.px_per_long(160));
let (mut err, mut n) = (0.0f32, 0);
for y in 50..70 {
for x in 70..90 {
let b: f32 = [(0, 0), (1, 0), (0, 1), (1, 1)].iter().map(|(dx, dy)| big.get(2 * x + dx, 2 * y + dy)[0]).sum::<f32>() / 4.0;
err += (b - small.get(x, y)[0]).abs();
n += 1;
}
}
assert!(err / n as f32 <= 0.08, "mean difference {}", err / n as f32);
}
#[test]
fn fill_survives_hostile_spots() {
let frame = Frame::new(40, 30, &DevelopSettings::default(), true);
for (p, size) in [((0.0, 0.0), 2.0), ((-5.0, 9.0), 0.1), ((0.5, 0.5), 0.0), ((f64::NAN, 0.5), 0.1), ((0.5, 0.5), f64::INFINITY)] {
let mut img = Rgb32f::from_fn(40, 30, |x, _| [x as f32 / 40.0; 3]);
let spot = Spot { mode: SpotMode::Fill, points: vec![Point::new(p.0, p.1)], size, ..Default::default() };
apply(&mut img, &[spot], &frame, frame.px_per_long(40));
assert!(img.data.iter().flatten().all(|v| v.is_finite()));
}
}
#[test]
fn clone_copies_source() {
let mut img = Rgb32f::from_fn(100, 100, |x, _| if x < 50 { [0.1; 3] } else { [0.9; 3] });
+2 -1
View File
@@ -2062,5 +2062,6 @@
"Intent:": "Priorität:",
"Perceptual": "Perzeptiv",
"Relative": "Relativ",
"Simulate Paper & Ink": "Papier und Druckfarbe simulieren"
"Simulate Paper & Ink": "Papier und Druckfarbe simulieren",
"Content-Aware": "Inhaltsbasiert"
}
+2 -1
View File
@@ -1907,5 +1907,6 @@
"Intent:": "Propósito:",
"Perceptual": "Perceptual",
"Relative": "Relativo",
"Simulate Paper & Ink": "Simular papel y tinta"
"Simulate Paper & Ink": "Simular papel y tinta",
"Content-Aware": "Según el contenido"
}
+2 -1
View File
@@ -1907,5 +1907,6 @@
"Intent:": "Mode de rendu :",
"Perceptual": "Perceptif",
"Relative": "Relatif",
"Simulate Paper & Ink": "Simuler le papier et l'encre"
"Simulate Paper & Ink": "Simuler le papier et l'encre",
"Content-Aware": "Contenu pris en compte"
}
+2 -1
View File
@@ -1909,5 +1909,6 @@
"Intent:": "マッチング方法:",
"Perceptual": "知覚的",
"Relative": "相対的",
"Simulate Paper & Ink": "紙とインキをシミュレート"
"Simulate Paper & Ink": "紙とインキをシミュレート",
"Content-Aware": "コンテンツに応じる"
}
+2 -1
View File
@@ -1907,5 +1907,6 @@
"Intent:": "Tipo de conversão:",
"Perceptual": "Perceptual",
"Relative": "Relativo",
"Simulate Paper & Ink": "Simular papel e tinta"
"Simulate Paper & Ink": "Simular papel e tinta",
"Content-Aware": "Sensível ao conteúdo"
}
+2 -1
View File
@@ -1621,5 +1621,6 @@
"Intent:": "Метод:",
"Perceptual": "Перцепционный",
"Relative": "Относительный",
"Simulate Paper & Ink": "Имитировать бумагу и краску"
"Simulate Paper & Ink": "Имитировать бумагу и краску",
"Content-Aware": "С учётом содержимого"
}
+2 -1
View File
@@ -2101,5 +2101,6 @@
"Intent:": "Метод:",
"Perceptual": "Перцепційний",
"Relative": "Відносний",
"Simulate Paper & Ink": "Імітувати папір і фарбу"
"Simulate Paper & Ink": "Імітувати папір і фарбу",
"Content-Aware": "З урахуванням вмісту"
}
+2 -1
View File
@@ -1907,5 +1907,6 @@
"Intent:": "方法:",
"Perceptual": "可感知",
"Relative": "相对",
"Simulate Paper & Ink": "模拟纸张和油墨"
"Simulate Paper & Ink": "模拟纸张和油墨",
"Content-Aware": "内容识别"
}
+2 -1
View File
@@ -1907,5 +1907,6 @@
"Intent:": "方式:",
"Perceptual": "感知",
"Relative": "相對",
"Simulate Paper & Ink": "模擬紙張和油墨"
"Simulate Paper & Ink": "模擬紙張和油墨",
"Content-Aware": "內容感知"
}
+1
View File
@@ -2032,6 +2032,7 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo
let mode = match app.ui.tool.as_str() {
"heal" => "heal",
"clone" => "clone",
"fill" => "fill",
_ => "remove",
};
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
+2 -1
View File
@@ -343,7 +343,7 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
header(ui, "Remove");
padded(ui, |ui| {
ui.horizontal_wrapped(|ui| {
for (label, tool) in [("Remove", "remove"), ("Heal", "heal"), ("Clone", "clone")] {
for (label, tool) in [("Remove", "remove"), ("Content-Aware", "fill"), ("Heal", "heal"), ("Clone", "clone")] {
if text_button(ui, &format!("removeMode-{tool}"), label, app.ui.tool == tool).clicked() {
app.ui.tool = tool.into();
}
@@ -383,6 +383,7 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
lightcraft_develop::SpotMode::Heal => "Heal",
lightcraft_develop::SpotMode::Clone => "Clone",
lightcraft_develop::SpotMode::Remove => "Remove",
lightcraft_develop::SpotMode::Fill => "Content-Aware",
};
super::edit::sub_title(ui, &crate::i18n::tr_format!("{mode} spot {} of {}", i + 1, d.spots.len(), mode = mode));
}
+1
View File
@@ -47,6 +47,7 @@ pub const TABLE: &[(&str, Class)] = &[
("meta", Class::Layer(1)),
("develop", Class::Layer(1)),
("lensdb", Class::Layer(2)),
("inpaint", Class::Layer(1)),
("faces", Class::Layer(1)),
("geocode", Class::Layer(1)),
("scenes", Class::Layer(1)),