diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 73383d1d4..80faa5af5 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -281,7 +281,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R // 2 % of the long edge, so previews and exports match) let e = (*edge / 100.0).clamp(-1.0, 1.0) as f32; let gain = if e < 0.0 { 1.0 - 7.0 * e } else { 1.0 }; - sample_seg(seg.as_ref(), detail, gain, frame, w, h, &mut out); + sample_seg(seg.as_ref(), detail, gain, frame, w, h, log_l, &mut out); if e > 0.0 { smooth_plane(&mut out, e * 0.02 * frame_px(frame, w)); } @@ -343,13 +343,40 @@ impl SegGrid { let bot = at(x0, y0 + 1) * (1.0 - tx) + at(x0 + 1, y0 + 1) * tx; top * (1.0 - ty) + bot * ty } + + /// Output pixels per grid cell (the longer side of a cell: the grid is square over a + /// rectangle). + fn cell_px(&self, frame: &Frame, w: usize) -> f64 { + let px = frame.px_per_long(w) / frame.ow.max(frame.oh).max(1e-9); + ((self.r[2] - self.r[0]) * frame.ow).max((self.r[3] - self.r[1]) * frame.oh) * px / self.side.max(1) as f64 + } } +/// [`sample_seg`]'s window, in grid cells (Gaussian sigma): the width of the band bilinear +/// enlargement leaves uncertain, about a cell on either side of the contour. +const SEG_WINDOW: f64 = 1.5; +/// [`sample_seg`]'s guided-filter regularization (EV² of log luminance): an edge of ~0.1 EV +/// contrast counts half. +const SEG_EPS: f32 = 0.01; + /// Stored model segmentations at each output pixel: the zoomed-in `detail` passes inside /// their rectangles (the highest of them where they overlap), the whole-photo `seg` /// elsewhere; the logit (times `gain`: > 1 is a harder edge) through a sigmoid. Damaged /// data selects nothing. -fn sample_seg(seg: Option<&SegMask>, detail: &[SegMask], gain: f32, frame: &Frame, w: usize, h: usize, out: &mut Plane) { +/// +/// Issue #3272: a model's grid is much coarser than an export (a 288² grid has ~20 px cells on +/// 24 MP), and its bilinear contour passes up to a cell from the photo's edge, so the mask spilled +/// onto the background (a bright outline under a raised Exposure). Where the grid is coarser than +/// the output, the logits are guided-filtered by the photo's log luminance ([`guided_cross_fast`]; a +/// window of [`SEG_WINDOW`] cells, so previews and exports draw the same edge): near the contour +/// the logit follows the photo's tones and its edge lands on the photo's. Filtering logits, not +/// coverage, makes the confident side win (a strip at 0.3 between 1 and 0 goes to 0, not 0.1), +/// and leaves a linear ramp of logits, where the photo has no edge, as it was. Pixels whose +/// window is all inside or all outside (|mean logit| ≥ 7, times `gain`) keep their sample exactly, +/// as do those where the photo has no edge to follow. Refine Edges +/// then works on the combined mask ([`evaluate_one`]) and finds these edges already in place. +#[allow(clippy::too_many_arguments)] +fn sample_seg(seg: Option<&SegMask>, detail: &[SegMask], gain: f32, frame: &Frame, w: usize, h: usize, log_l: &Plane, out: &mut Plane) { let coarse = seg.and_then(SegGrid::new); // (capped: a hostile document can hold many) let patches: Vec = detail.iter().take(lightcraft_develop::segmask::MAX_DETAIL).filter_map(SegGrid::new).collect(); @@ -361,10 +388,15 @@ fn sample_seg(seg: Option<&SegMask>, detail: &[SegMask], gain: f32, frame: &Fram for (x, v) in row.iter_mut().enumerate() { let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); let fine = patches.iter().filter(|p| p.contains(n)).map(|p| p.logit(n)).reduce(f32::max); - let l = fine.or_else(|| coarse.as_ref().map(|c| c.logit(n))).unwrap_or(-16.0); - *v = 1.0 / (1.0 + (-l * gain).exp()); + *v = fine.or_else(|| coarse.as_ref().map(|c| c.logit(n))).unwrap_or(-16.0) * gain; } }); + // (the coarsest grid sets the window: it leaves the widest uncertain band) + let cell = coarse.iter().chain(&patches).map(|g| g.cell_px(frame, w)).fold(0.0, f64::max); + if cell >= 1.0 { + guided_cross_fast(log_l, out, (SEG_WINDOW * cell) as f32, SEG_EPS, |mean| 1.0 - smooth(3.0 * gain, 7.0 * gain, mean.abs())); + } + out.map_in_place(|l| 1.0 / (1.0 + (-l).exp())); } fn frame_px(frame: &Frame, w: usize) -> f32 { @@ -665,9 +697,20 @@ pub fn auto_refine(r: f64) -> (f32, f32) { ((0.25 * r).max(1.0) as f32, 0.02) } -/// Guided filter of `p` steered by `guide` (He et al.), clamped to 0..1: `p`'s edges snap to the -/// guide's. -pub fn guided_cross(guide: &Plane, p: &Plane, sigma: f32, eps: f32) -> Plane { +/// A guided filter's linear model at each pixel (`q = a · guide + b`, coefficients averaged over +/// the windows that hold the pixel), the window mean of the filtered plane and, when asked for, +/// how much of an edge the guide has there (`var / (var + eps)`, averaged like the coefficients: +/// 0 where the guide is flat and the model is only a blur). +struct Guided { + a: Plane, + b: Plane, + mean: Plane, + edge: Option, +} + +/// The guided filter (He, Sun and Tang) of `p` steered by `guide`, both the same size: Gaussian +/// windows of `sigma` px, `eps` the regularization (in guide units squared). +fn guided_coeffs(guide: &Plane, p: &Plane, sigma: f32, eps: f32, with_edge: bool) -> Guided { use lightcraft_raster::blur::gaussian; let mi = gaussian(guide, sigma); let mp = gaussian(p, sigma); @@ -675,20 +718,70 @@ pub fn guided_cross(guide: &Plane, p: &Plane, sigma: f32, eps: f32) -> Plane { let cii = gaussian(&guide.map(|a| a * a), sigma); let mut a = Plane::new(p.width, p.height); let mut b = Plane::new(p.width, p.height); + let mut edge = Plane::new(if with_edge { p.width } else { 0 }, if with_edge { p.height } else { 0 }); for i in 0..p.data.len() { let var = (cii.data[i] - mi.data[i] * mi.data[i]).max(0.0); let k = (cip.data[i] - mi.data[i] * mp.data[i]) / (var + eps); a.data[i] = k; b.data[i] = mp.data[i] - k * mi.data[i]; + if let Some(e) = edge.data.get_mut(i) { + *e = var / (var + eps); + } } - let (ma, mb) = (gaussian(&a, sigma), gaussian(&b, sigma)); + Guided { a: gaussian(&a, sigma), b: gaussian(&b, sigma), mean: mp, edge: with_edge.then(|| gaussian(&edge, sigma)) } +} + +/// Guided filter of `p` steered by `guide` (He et al.), clamped to 0..1: `p`'s edges snap to the +/// guide's. +pub fn guided_cross(guide: &Plane, p: &Plane, sigma: f32, eps: f32) -> Plane { + let g = guided_coeffs(guide, p, sigma, eps, false); let mut q = Plane::new(p.width, p.height); for i in 0..q.data.len() { - q.data[i] = (ma.data[i] * guide.data[i] + mb.data[i]).clamp(0.0, 1.0); + q.data[i] = (g.a.data[i] * guide.data[i] + g.b.data[i]).clamp(0.0, 1.0); } q } +/// [`guided_cross`] in its subsampled form (He and Sun, "Fast Guided Filter", 2015, as +/// [`crate::local::guided_fast`] is of the self-guided filter, at its step), in place and +/// unclamped, for a `p` that is smooth at the window's scale (a mask enlarged from a model's grid): +/// the linear model is fitted on both planes box-reduced to about 3 px of window, sampled +/// bilinearly and applied to the full-resolution guide, which is where the edge comes from. A +/// pixel moves to the filtered value by `weight` of its window mean of `p` times how much of an +/// edge the guide has there (0 keeps it exactly: where the photo is flat the filter could only +/// blur `p`). Extra memory: the reduced planes only (the coefficients are sampled per pixel, not +/// enlarged into planes). +fn guided_cross_fast(guide: &Plane, p: &mut Plane, sigma: f32, eps: f32, weight: impl Fn(f32) -> f32 + Sync + Send) { + use lightcraft_raster::resample::{Filter, resize}; + let (w, h) = (p.width, p.height); + if guide.width != w || guide.height != h || w == 0 || h == 0 || !sigma.is_finite() { + return; + } + // (a window past the whole frame fits one model to it all) + let sigma = sigma.clamp(0.5, w.max(h) as f32); + let s = crate::local::guided_fast_step(sigma); + let (lw, lh) = (w.div_ceil(s).max(1), h.div_ceil(s).max(1)); + let g = guided_coeffs(&resize(guide, lw, lh, Filter::Box), &resize(p, lw, lh, Filter::Box), sigma / s as f32, eps, true); + let Some(edge) = &g.edge else { + return; + }; + // (the reduced planes span the same frame: their pixel per full pixel) + let (kx, ky) = (lw as f32 / w as f32, lh as f32 / h as f32); + for_rows(&mut p.data, w, |y, row| { + let fy = (y as f32 + 0.5) * ky; + for (x, (v, i)) in row.iter_mut().zip(guide.row(y)).enumerate() { + let fx = (x as f32 + 0.5) * kx; + let k = weight(bilinear(&g.mean, fx, fy)); + // (no edge in the photo: nothing to follow, keep the sample) + let k = if k > 0.0 { k * smooth(0.05, 1.0, bilinear(edge, fx, fy)) } else { 0.0 }; + if k > 0.0 { + let q = bilinear(&g.a, fx, fy) * i + bilinear(&g.b, fx, fy); + *v += (q - *v) * k; + } + } + }); +} + /// Stamp brush strokes into `out` (max-combine; erase strokes subtract). Auto Mask strokes weight /// each dab by [`auto_similarity`] to the pixel under its centre and are refined with /// [`guided_cross`] on log luminance. diff --git a/crates/pipeline/src/masks/tests.rs b/crates/pipeline/src/masks/tests.rs index a3f776d0d..14cb5dbac 100644 --- a/crates/pipeline/src/masks/tests.rs +++ b/crates/pipeline/src/masks/tests.rs @@ -89,6 +89,115 @@ fn the_edge_setting_hardens_or_feathers_the_transition() { assert!(hard < plain && plain < soft, "transition widths {hard} < {plain} < {soft}"); } +/// A dark disc (radius 0.3 of the frame) on a light background, `n × n`: the photo and its log +/// luminance, and a model's view of it on a `side²` grid (each cell's logit from how much of the +/// cell the disc covers, as a low-resolution segmenter sees it). +fn disc_scene(n: usize, side: usize) -> (Rgb32f, Plane, SegMask) { + const R: f64 = 0.3; + let dark = |x: f64, y: f64| (x - 0.5).hypot(y - 0.5) < R; + let lum = |x: usize, y: usize| if dark((x as f64 + 0.5) / n as f64, (y as f64 + 0.5) / n as f64) { 0.05f32 } else { 0.6 }; + let img = Rgb32f::from_fn(n, n, |x, y| [lum(x, y); 3]); + let log_l = img.map(crate::local::log_lum); + let logits: Vec = (0..side * side) + .map(|i| { + let (cx, cy) = (i % side, i / side); + let sub = 8; + let cover = (0..sub * sub) + .filter(|k| { + dark((cx as f64 + (k % sub) as f64 / sub as f64) / side as f64, (cy as f64 + (k / sub) as f64 / sub as f64) / side as f64) + }) + .count() as f32 + / (sub * sub) as f32; + 12.0 * (2.0 * cover - 1.0) + }) + .collect(); + (img, log_l, SegMask::from_logits(side, &logits)) +} + +/// Alpha along the disc's horizontal radius, `d` px outside (> 0) or inside (< 0) its edge. +fn across_disc_edge(a: &Plane, d: i32) -> f32 { + let n = a.width; + let edge = (0.8 * n as f64).round() as i32; // first background pixel on the right + let x = if d > 0 { edge + d - 1 } else { edge + d }; + a.get(x as usize, n / 2) +} + +/// Issue #3272: a mask from a low-resolution model grid spilled 1–2 px onto the background (a bright +/// outline once its Exposure was raised). Enlarged along the photo's edges, it is ≈ 0 one and two +/// pixels outside the subject, ≈ 1 inside, and exactly what it was away from the edge. +#[test] +fn ai_masks_follow_the_photos_edge_when_enlarged() { + let (n, side) = (512, 32); // 16 px cells, as a 288² grid on a ~4600 px export + let (img, log_l, seg) = disc_scene(n, side); + let shape = MaskShape::Prompt { text: "disc".into(), seg: Some(seg), detail: vec![], edge: 0.0 }; + let f = frame(n, n); + let a = shape_alpha(&shape, &f, n, n, &img, &log_l, 0.0, None); + for d in [1, 2] { + assert!(across_disc_edge(&a, d) < 0.05, "{d} px outside: {}", across_disc_edge(&a, d)); + assert!(across_disc_edge(&a, -d) > 0.95, "{d} px inside: {}", across_disc_edge(&a, -d)); + } + // all around the disc, not only along one row + let r_px = 0.3 * n as f64; + for k in 0..64 { + let t = k as f64 / 64.0 * std::f64::consts::TAU; + let at = |r: f64| a.get((256.0 + r * t.cos()) as usize, (256.0 + r * t.sin()) as usize); + assert!(at(r_px + 2.5) < 0.05 && at(r_px - 2.5) > 0.95, "at {t:.2} rad: {} / {}", at(r_px + 2.5), at(r_px - 2.5)); + } + // away from the edge the selection is the plain sample of the grid, to the bit + let flat = Plane::new(n, n); + let plain = shape_alpha(&shape, &f, n, n, &img, &flat, 0.0, None); + for (x, y) in [(256, 256), (200, 300), (10, 10), (500, 256), (256, 505)] { + assert_eq!(a.get(x, y), plain.get(x, y), "({x}, {y})"); + } +} + +/// Refine Edges on top of an enlarged AI mask: the second filter finds the edge already in place, so +/// there is no second halo and the edge doesn't move inwards. +#[test] +fn refine_edges_on_an_enlarged_ai_mask_neither_halos_nor_tightens() { + let (n, side) = (512, 32); + let (img, log_l, seg) = disc_scene(n, side); + let f = frame(n, n); + let mask = |refine: f64| Mask { + components: vec![MaskComponent { + name: None, + op: MaskOp::Add, + invert: false, + shape: MaskShape::Prompt { text: "disc".into(), seg: Some(seg.clone()), detail: vec![], edge: 0.0 }, + }], + refine, + ..Default::default() + }; + let crossing = |a: &Plane| (0..n).rev().find(|x| a.get(*x, n / 2) > 0.5).unwrap_or(0); + let plain = evaluate_one(&mask(0.0), &f, n, n, &img, &log_l, 0.0, None); + for refine in [50.0, 100.0] { + let a = evaluate_one(&mask(refine), &f, n, n, &img, &log_l, 0.0, None); + for d in [1, 2] { + assert!(across_disc_edge(&a, d) < 0.05, "refine {refine}, {d} px outside: {}", across_disc_edge(&a, d)); + assert!(across_disc_edge(&a, -d) > 0.95, "refine {refine}, {d} px inside: {}", across_disc_edge(&a, -d)); + } + assert!(crossing(&a).abs_diff(crossing(&plain)) <= 1, "refine {refine}: edge at {} vs {}", crossing(&a), crossing(&plain)); + } +} + +/// The window follows the grid, not the output: a preview and an export put the edge in the same +/// place of the photo (and on its edge). +#[test] +fn enlarged_ai_masks_put_the_edge_in_the_same_place_at_any_size() { + let side = 32; + let mut edges = Vec::new(); + for n in [256, 512, 1024] { + let (img, log_l, seg) = disc_scene(n, side); + let shape = MaskShape::Prompt { text: "disc".into(), seg: Some(seg), detail: vec![], edge: 0.0 }; + let a = shape_alpha(&shape, &frame(n, n), n, n, &img, &log_l, 0.0, None); + // the edge's sub-pixel position along the row: the integral of alpha past the centre + let covered: f32 = (n / 2..n).map(|x| a.get(x, n / 2)).sum(); + edges.push((n / 2) as f64 + covered as f64); + let x = edges.last().copied().unwrap_or(0.0) / n as f64; + assert!((x - 0.8).abs() < 1.0 / n as f64, "{n} px: edge at {x:.4} of the width, the disc's at 0.8"); + } +} + #[test] fn ai_shapes_without_a_segmentation_select_nothing() { let f = frame(30, 30); diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index b82a19f45..bf8b8690e 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -157,6 +157,18 @@ fn windows_outside_or_degenerate_are_clamped() { } /// A textured, noisy source: what noise reduction and edge refinement act on. +/// A model's segmentation on a `side²` grid: an ellipse in the middle of the photo. +fn middle_seg(side: usize) -> lightcraft_develop::SegMask { + let logits: Vec = (0..side * side) + .map(|i| { + let (x, y) = (((i % side) as f32 + 0.5) / side as f32, ((i / side) as f32 + 0.5) / side as f32); + let r = (((x - 0.5) / 0.3).powi(2) + ((y - 0.5) / 0.25).powi(2)).sqrt(); + (12.0 * (1.0 - r) * 4.0).clamp(-12.0, 12.0) + }) + .collect(); + lightcraft_develop::SegMask::from_logits(side, &logits) +} + fn noisy_scene() -> Rgb32f { let base = scene(); Rgb32f::from_fn(base.width, base.height, |x, y| { @@ -216,6 +228,8 @@ fn a_mask_overlay_window_matches_the_whole_render() { ("radial", MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.15, angle: 20.0, feather: 50.0, invert: false }, 0.0), ("linear refined", MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.55, 0.5) }, 100.0), ("sky", MaskShape::Sky, 0.0), + // a model's grid, enlarged along the photo's edges (issue #3272) + ("prompt", MaskShape::Prompt { text: "middle".into(), seg: Some(middle_seg(32)), detail: vec![], edge: 0.0 }, 0.0), ]; for (name, shape, refine) in shapes { let mut s = DevelopSettings::default(); diff --git a/docs/ai-masks.md b/docs/ai-masks.md index 845dc0e23..14737ef52 100644 --- a/docs/ai-masks.md +++ b/docs/ai-masks.md @@ -27,6 +27,16 @@ In the Masking panel (M): the people out of a sky mask; − ▸ Object then clicks remove one object. - **Edge** (per selection, −100…100): below 0 a crisper border, above 0 a feathered one (up to 2 % of the long edge, so previews and exports match). +- **Edges follow the photo** (issue #3272): the model's grid (288² over the whole photo) has + ~20 px cells on a 24 MP export, so a plain enlargement put the mask's edge up to a cell off the + subject's and a raised Exposure drew a bright outline around it. Where the grid is coarser than + the output, the mask's logits are guided-filtered by the photo's log luminance (the fast guided + filter, a window of 1.5 grid cells, so a preview and an export put the edge in the same place): + near the edge the selection follows the photo's tones, ≈ 0 a pixel or two outside a subject that + stands out from its background and ≈ 1 inside. Pixels whose surroundings are all in or all out, + and edges the photo has no contrast across, keep the plain enlargement. Refine Edges then works + on the combined mask and finds these edges in place. Cost on an M2 Pro, per AI selection: +22 ms + and +12 MB at 24 MP, +46 ms and +17 MB at 60 MP, +7.5 ms on a 2.7 MP preview. - **Detail:** about a second after the last click (or right after a description), the photo around the selection is analyzed again, zoomed in, from a sharper source (~5 s on an M4 Pro, in the background); the result is kept as a high-resolution patch (at most 4 per selection), diff --git a/docs/gaps.md b/docs/gaps.md index 42d9d437b..98d1abe36 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** minor (gap 5: AI mask edges follow the photo's, no outline under a raised Exposure, #3272; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -84,6 +84,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Gap 5: Object / Describe masks are enlarged along the photo's edges, so a raised Exposure no longer outlines the subject (#3272); the gap (heuristic Subject / Sky / People, no model mirror) and its hours are unchanged | | 2026-10-24 | minor | Gap 28: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata; no key leaves the entry; hours unchanged | | 2026-10-24 | minor | Gap 30 shrinks: the Info panel's Show Metadata For (Target Photo / Selected Photos, `view.infoSelectedPhotos`) shows the selected photos' shared values and marks fields that differ as Mixed; the All Metadata dialog remains; 1–3 h (was 4–8) | | 2026-10-23 | minor | Gap 13: before a Lightroom catalog import the app asks whether it merges into the open library or goes into a new one (opt-out in Settings, #2672); hours unchanged | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 90cfc248f..b85f931e9 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (LR-MASK-OBJECTS: AI mask edges follow the photo's when enlarged, no outline under a raised Exposure, #3272; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -293,7 +293,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model; a photo that carries person mattes (DNG semantic masks: iPhone ProRAW portrait / skin / hair / teeth / glasses mattes) uses them instead (synthetic files only: no corpus file carries person mattes) | | LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`), `crates/pipeline/src/masks.rs` (`Mattes`), `crates/raw/src/semantic.rs` | heuristic; a photo that carries a sky matte (DNG semantic mask, e.g. iPhone ProRAW) uses it instead, through orientation / crop / lens corrections, preview = export (checked on one CC0 iPhone 12 Pro ProRAW and synthetic files) | | LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject mask (heuristic, or the photo's person mattes) | -| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | +| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft); the stored grid is enlarged along the photo's edges (a guided filter of its logits by log luminance, so no outline spills onto the background under a raised Exposure, #3272). The model runs on its own worker thread (the UI never waits; panics become errors; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | | LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic | | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | | LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | @@ -959,3 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | +| 2026-10-29 | minor | LR-MASK-OBJECTS: an Object / Describe selection's stored grid is enlarged along the photo's edges (fast guided filter of its logits by log luminance, a 1.5-cell window), so a raised Exposure no longer draws an outline around the subject (#3272); existing AI masks are 1–2 px tighter at edges; status unchanged (🟡) | diff --git a/docs/process-versions.md b/docs/process-versions.md index 1da9367a9..7e6788f11 100644 --- a/docs/process-versions.md +++ b/docs/process-versions.md @@ -27,6 +27,13 @@ Blacks at 0) is the identity up to white: the SDR shoulder no longer pulls 1.0 d 255 to 254, issue #1945). It landed in place as a bug fix, since the tone map promised identity at neutral settings; edited curves keep the shoulder and their V1 look. +**AI mask edges** (Object and Describe selections stored as a model's grid) follow the photo's edges +when they are enlarged to the output (issue #3272): the plain enlargement spilled 1–2 px onto the +background, which a raised Exposure turned into a bright outline around the subject in previews and +exports. Existing AI masks become 1–2 px tighter at a subject's edges, on every process; their +inside, their outside and edges without contrast in the photo are unchanged. It landed in place as +a bug fix: the outline is output that isn't in the photo nor in the edit, not a look anyone chose. + ## Adding a process 1. Add a variant to `Process` (`V2`), append it to `Process::ALL` (oldest first), give it its number in