From 57f54868debcdd3a7efebc0041872bd8220d422a Mon Sep 17 00:00:00 2001 From: David Campos <132998405+David-T-Campos@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:03:09 +0400 Subject: [PATCH 1/5] Update documentation for render state and windows --- crates/ui-egui/src/region.rs | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 8fe47f97..8fe29e13 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -25,7 +25,7 @@ pub struct RegionView { pub key: u64, pub full: (usize, usize), pub window: PixelWindow, - /// Hash of the develop settings it was rendered with. + /// Hash of the render state it was rendered with (develop settings plus any window-safe overlay). pub settings: u64, } @@ -72,9 +72,9 @@ pub struct ViewSizes { pub texture_side: usize, /// 1.0, or less while a slider is dragged (a draft of the whole-frame render). pub draft_scale: f32, - /// Whether a window can be drawn over the whole-frame render (not with an overlay, soft - /// proofing, or a read too far for one window): if not, the whole frame is rendered at the - /// size it is drawn. + /// Whether a window can be drawn over the whole-frame render (not with a diagnostic overlay + /// that needs whole-frame context, soft proofing, or a read too far for one window). Mask + /// overlays are window-safe. pub windows: bool, } @@ -97,9 +97,9 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { let drawn_long = if v.drawn_long.is_finite() { v.drawn_long.max(8.0) } else { canvas_long }; let scale = if v.draft_scale.is_finite() { v.draft_scale.clamp(0.1, 1.0) } else { 1.0 }; if !v.windows { - // the whole frame at the size it is drawn (an overlay or soft proof has no window); while a - // slider drags, a canvas-sized draft as with windows: a drag at 1:1 must not render the - // photo's every pixel each frame + // the whole frame at the size it is drawn (soft proof or a whole-frame diagnostic has no + // window); while a slider drags, a canvas-sized draft as with windows: a drag at 1:1 must + // not render the photo's every pixel each frame let long = if scale < 1.0 { drawn_long.min(canvas_long) } else { drawn_long }; let main_edge = settings.loupe_edge(long * scale, v.native_long, v.texture_side); return LoupePlan { main_edge, window_edge: None }; @@ -281,8 +281,9 @@ mod tests { assert_eq!(drag.window_edge, still.window_edge); } - // Given no window (an overlay, soft proofing), the view at rest renders the whole frame at the - // size it is drawn, but a slider drag drafts at the canvas size like the windowed path + // Given no window (a whole-frame diagnostic or soft proofing), the view at rest renders the + // whole frame at the size it is drawn, but a slider drag drafts at the canvas size like the + // windowed path #[test] fn without_a_window_a_drag_drafts_at_the_canvas_size() { let no_window = |draft_scale: f32| ViewSizes { windows: false, draft_scale, ..sizes(24000.0, 2800.0, 6000) }; @@ -331,8 +332,8 @@ mod tests { } } - // Given a mode whose picture can't be a window (a mask overlay, soft proofing, a spot read too - // far for one render), the whole-frame render is as big as the view, as it was before windows + // Given a mode whose picture can't be a window (soft proofing, a whole-frame diagnostic, or a + // spot read too far for one render), the whole-frame render is as big as the view #[test] fn without_windows_the_whole_frame_is_rendered_at_the_drawn_size() { let p = plan(&auto(), ViewSizes { windows: false, ..sizes(6000.0, 2800.0, 6000) }); From 330f824c316aac0703074ec6461849094600316f Mon Sep 17 00:00:00 2001 From: David Campos <132998405+David-T-Campos@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:03:42 +0400 Subject: [PATCH 2/5] masking: keep zoom and pan across mask interactions (detail) Added a function to check if an overlay supports window rendering. Updated overlay handling in various contexts to ensure compatibility with new overlay support logic. --- crates/ui-egui/src/panels/detail.rs | 41 ++++++++++++++++++----------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 2e708da6..b2f760f2 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -214,9 +214,17 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { r } +/// Whether an overlay can be rendered on a region tile without changing what the tile means. +/// Mask overlays are evaluated from the same mask alpha as the full render; the other diagnostic +/// overlays either need whole-frame context or deliberately replace the loupe. +pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool { + matches!(overlay, lightcraft_pipeline::Overlay::None | lightcraft_pipeline::Overlay::Mask { .. }) +} + /// What a window render of the loupe needs to know about the view it belongs to. struct WindowCtx { id: PhotoId, + overlay: lightcraft_pipeline::Overlay, /// Long edge of the frame the windows are cut from (`None`: no windows). frame_edge: Option, drawn_long: f32, @@ -282,9 +290,14 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt app.window_refused = Some((c.id, c.look, frame_edge)); return None; }; - // a drag renders drafts of the window, as it does of the whole frame + // a drag renders drafts of the window, as it does of the whole frame. The Before tile stays + // plain; the After tile carries the same mask overlay as the whole-frame render. let job = if c.interacting { job.draft() } else { job }; - let look = job.settings.hash64(); + let overlay = if v.before { lightcraft_pipeline::Overlay::None } else { c.overlay }; + let job = job.with_overlay(overlay); + // The overlay is part of the tile's identity too, so changing mask view/colour never leaves a + // stale region tile on screen while its replacement renders. + let look = job.settings.hash64() ^ overlay.key().rotate_left(17); let view = crate::region::RegionView { photo: c.id, before: v.before, key: job.key, full: (fw, fh), window: win, settings: look }; // windows of other photos, looks and zooms of this side can't be drawn any more app.region_tiles.retain(|(before, _), t| *before != v.before || t.is_current(c.id, (fw, fh), look, c.interacting)); @@ -393,15 +406,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let drawn_long = target_rect.width().max(target_rect.height()) * ppp; // the whole frame at about canvas size; the zoomed part of it is a window render (below) let look = d.hash64(); - // (modes that draw another picture over the loupe, or reads too far for one window, have the - // whole frame rendered at the size it is drawn, as they did before windows) + let overlay = view_overlay(app, &d); + // Diagnostic views that need whole-frame context, soft proofing, or reads too far for one + // window still render the whole frame at the size it is drawn. A mask overlay is window-safe. let sizes = crate::region::ViewSizes { drawn_long, canvas_long: canvas.width().max(canvas.height()) * ppp, native_long, texture_side, draft_scale: scale, - windows: !app.ui.soft_proof && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None, + windows: !app.ui.soft_proof && overlay_supports_window(overlay), }; let mut plan = crate::region::plan(&app.ui.settings, sizes); if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) { @@ -420,7 +434,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (sw, sh) = if aspect >= 1.0 { (se, (se as f32 / aspect) as usize) } else { ((se as f32 * aspect) as usize, se) }; if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) { let job = if interacting { job.draft() } else { job }; - let job = job.with_overlay(view_overlay(app, &d)).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); + let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); app.renderer.request(Slot::Main, job, 100); } // hovering a preset or profile: the photo with that look, shown instead of the loupe render @@ -468,17 +482,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.renderer.request(Slot::Before, job, 90); } // zoomed past what the whole-frame render holds: also render the part on screen, at no more - // than 100 % (the GPU magnifies beyond that), for each side that is shown. Not while hovering a - // look or showing an overlay (those draw another image), nor where the shown picture isn't the - // frame's own (an unsupported raw's embedded JPEG with another crop: window coordinates are - // the frame's). - let plain_view = hover_key.is_none() - && !app.ui.soft_proof - && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None - && crate::region::same_aspect(display_aspect, aspect); + // than 100 % (the GPU magnifies beyond that), for each side that is shown. Mask overlays use + // the same window; diagnostic overlays that need whole-frame context do not. + let windowable_view = + hover_key.is_none() && !app.ui.soft_proof && overlay_supports_window(overlay) && crate::region::same_aspect(display_aspect, aspect); let window_ctx = WindowCtx { id, - frame_edge: plan.window_edge.filter(|_| plain_view), + overlay, + frame_edge: plan.window_edge.filter(|_| windowable_view), drawn_long, aspect, ppp, From 128539a93985f2addabf4979e6ec7aeb90307e9f Mon Sep 17 00:00:00 2001 From: David Campos <132998405+David-T-Campos@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:04:11 +0400 Subject: [PATCH 3/5] masks: improve sky mask edges and horizon handling Refactor sky masking logic to use rasterize_sky function for improved clarity and maintainability. --- crates/pipeline/src/masks.rs | 104 +++++++++++++++++++++++++++++------ 1 file changed, 88 insertions(+), 16 deletions(-) diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 4777c878..0ea77c30 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -228,21 +228,7 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R *v = 1.0 - smooth(tol * 0.5, tol, d); } } - MaskShape::Sky => { - // Classical sky heuristic until the segmenter lands (M12): bright, smooth, blue-ish or - // unsaturated, and connected to the top of the frame. - let m = frame.out_to_norm(w, h); - for (i, v) in out.data.iter_mut().enumerate() { - let (x, y) = (i % w, i / w); - let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); - let c = img.data[i].map(|v| v * gain); - let l = log_l.data[i] + ev; - let blue = (c[2] - c[0]).max(0.0) / (c[2] + 1e-4); - let top = 1.0 - smooth(0.25, 0.7, n.y as f32); - *v = top * smooth(-3.5, -1.0, l) * (0.4 + 0.6 * smooth(0.0, 0.3, blue).max(smooth(0.0, 1.5, l))); - } - smooth_plane(&mut out, 0.01 * frame_px(frame, w)); - } + MaskShape::Sky => rasterize_sky(frame, w, h, img, log_l, ev, &mut out), MaskShape::Object { seg, detail, edge, .. } | MaskShape::Prompt { seg, detail, edge, .. } if seg.is_some() || !detail.is_empty() => { // Edge: below 0 a steeper transition (up to 8× the logits), above 0 feathered (up to // 2 % of the long edge, so previews and exports match) @@ -342,6 +328,68 @@ fn smooth_plane(p: &mut Plane, sigma: f32) { *p = lightcraft_raster::blur::gaussian(p, sigma.max(0.5)); } +/// Classical sky fallback for photos without a semantic sky matte. +/// +/// The old score had a 0.4 floor for every bright pixel and faded every candidate from 25 % to +/// 70 % down the frame, which both admitted bright non-sky near the horizon and removed real sky +/// before it reached the horizon. It then used a broad Gaussian (1 % of the long edge), smearing +/// the selection across branches, wires and the horizon. Keep this heuristic local/window-stable: +/// a true flood fill to the top cannot be reproduced by a zoom-window render without evaluating +/// the missing part of the frame. +fn rasterize_sky(frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32, out: &mut Plane) { + if w == 0 || h == 0 { + return; + } + let m = frame.out_to_norm(w, h); + let gain = ev.exp2(); + for (i, v) in out.data.iter_mut().enumerate() { + let (x, y) = (i % w, i / w); + let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); + let c = img.data[i].map(|v| (v * gain).max(0.0)); + let l = log_l.data[i] + ev; + let hi = c[0].max(c[1]).max(c[2]); + let lo = c[0].min(c[1]).min(c[2]); + let sat = (hi - lo) / (hi + 1e-4); + let blue = (c[2] - c[0].max(c[1])).max(0.0) / (c[2] + 1e-4); + let py = n.y as f32; + + let bright = smooth(-3.5, -0.7, l); + let blue_sky = smooth(0.01, 0.18, blue); + // Bright, low-chroma cloud/overcast remains possible, but unlike the old brightness floor + // it gets a stronger vertical prior so neutral ground does not qualify as readily. + let neutral_sky = (1.0 - smooth(0.08, 0.24, sat)) * smooth(-2.8, -0.5, l) * (1.0 - smooth(0.50, 0.80, py)); + // Blue sky is allowed much closer to the horizon than before; only the bottom of the frame + // is strongly discouraged. + let height = 0.15 + 0.85 * (1.0 - smooth(0.66, 0.94, py)); + let score = bright * blue_sky.max(0.85 * neutral_sky) * height; + *v = smooth(0.16, 0.55, score); + } + + // Feather at a quarter of the old width, but only across locally smooth luminance and where + // the unblurred classifier has support. A dark wire/branch therefore stays out instead of + // being filled by the blur; the horizon does not bleed into a strong luminance edge. + let sigma = 0.0025 * frame.output_long(w, h) as f32; + let blurred = lightcraft_raster::blur::gaussian(out, sigma.max(0.5)); + let original = std::mem::replace(out, blurred); + for y in 0..h { + for x in 0..w { + let i = y * w + x; + let l = log_l.data[i]; + let edge = (l - log_l.get_clamped(x as isize - 1, y as isize)) + .abs() + .max((l - log_l.get_clamped(x as isize + 1, y as isize)).abs()) + .max((l - log_l.get_clamped(x as isize, y as isize - 1)).abs()) + .max((l - log_l.get_clamped(x as isize, y as isize + 1)).abs()); + let flat = 1.0 - smooth(0.08, 0.35, edge); + let a = original.data[i]; + let support = smooth(0.02, 0.20, a); + let mix = flat * (0.15 + 0.85 * support); + let b = out.data[i]; + out.data[i] = (a + (b - a) * mix).clamp(0.0, 1.0); + } + } +} + /// The union (max) of `mattes` (0..255, over the EXIF-oriented source) at output resolution, /// sampled like the image: user orientation, lens / perspective warp, crop and flips; area-averaged /// first when the output is much smaller than the matte, bilinear otherwise. @@ -809,6 +857,30 @@ mod tests { assert!(l > 0.95 && r < 0.3, "refine 100: {l} vs {r}"); } + #[test] + fn sky_heuristic_reaches_the_horizon_without_filling_thin_objects() { + let (w, h, horizon) = (200, 120, 72); + let img = Rgb32f::from_fn(w, h, |x, y| { + let wire = y == 34 && x > 20 && x < w - 20; + let branch = (96..=98).contains(&x) && y < horizon; + if wire || branch { + [0.04; 3] + } else if y < horizon { + [0.35, 0.60, 1.10] + } else { + [0.55, 0.38, 0.20] + } + }); + let l = img.map(crate::local::log_lum); + let a = shape_alpha(&MaskShape::Sky, &frame(w, h), w, h, &img, &l, 0.0, None); + + assert!(a.get(30, horizon - 3) > 0.8, "sky reaches the horizon: {}", a.get(30, horizon - 3)); + assert!(a.get(30, horizon + 3) < 0.1, "warm ground stays out: {}", a.get(30, horizon + 3)); + assert!(a.get(80, 34) < 0.2, "thin wire stays out: {}", a.get(80, 34)); + assert!(a.get(97, 50) < 0.2, "thin branch stays out: {}", a.get(97, 50)); + assert!(a.get(90, 50) > 0.8 && a.get(104, 50) > 0.8, "sky beside the branch remains selected"); + } + /// A half-resolution matte selecting the left `frac` of a `w × h` source. fn left_matte(w: usize, h: usize, frac: f64) -> Image { Image::from_fn(w / 2, h / 2, |x, _| if (x as f64 + 0.5) < frac * (w / 2) as f64 { 255 } else { 0 }) @@ -867,4 +939,4 @@ mod tests { let lips = MaskShape::People { person: 0, parts: vec!["Lips".into()] }; assert_eq!(eval(&lips), shape_alpha(&lips, &f, w, h, &img, &l, 0.0, None)); } -} + } From 760819eab3b33e35fb663166bfeb6b43b888a50b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 03:10:35 -0400 Subject: [PATCH 4/5] LR-MASK-SKY: drop the sky heuristic rewrite Rendered the old and new heuristic over the 24 procedural demo scenes: the rewrite sharpens edges on blue skies (beach, dunes, canyon), but drops warm sunset and dark night skies to nothing, punches a hole around the sun, and selects lavender rows, a flower and lit mountains as sky. It also changes the render of every existing Sky mask without a process version. Back to main's heuristic; the masking-view part of this PR stays. Co-Authored-By: Claude Opus 5.5 --- crates/pipeline/src/masks.rs | 104 ++++++----------------------------- 1 file changed, 16 insertions(+), 88 deletions(-) diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 0ea77c30..4777c878 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -228,7 +228,21 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R *v = 1.0 - smooth(tol * 0.5, tol, d); } } - MaskShape::Sky => rasterize_sky(frame, w, h, img, log_l, ev, &mut out), + MaskShape::Sky => { + // Classical sky heuristic until the segmenter lands (M12): bright, smooth, blue-ish or + // unsaturated, and connected to the top of the frame. + let m = frame.out_to_norm(w, h); + for (i, v) in out.data.iter_mut().enumerate() { + let (x, y) = (i % w, i / w); + let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); + let c = img.data[i].map(|v| v * gain); + let l = log_l.data[i] + ev; + let blue = (c[2] - c[0]).max(0.0) / (c[2] + 1e-4); + let top = 1.0 - smooth(0.25, 0.7, n.y as f32); + *v = top * smooth(-3.5, -1.0, l) * (0.4 + 0.6 * smooth(0.0, 0.3, blue).max(smooth(0.0, 1.5, l))); + } + smooth_plane(&mut out, 0.01 * frame_px(frame, w)); + } MaskShape::Object { seg, detail, edge, .. } | MaskShape::Prompt { seg, detail, edge, .. } if seg.is_some() || !detail.is_empty() => { // Edge: below 0 a steeper transition (up to 8× the logits), above 0 feathered (up to // 2 % of the long edge, so previews and exports match) @@ -328,68 +342,6 @@ fn smooth_plane(p: &mut Plane, sigma: f32) { *p = lightcraft_raster::blur::gaussian(p, sigma.max(0.5)); } -/// Classical sky fallback for photos without a semantic sky matte. -/// -/// The old score had a 0.4 floor for every bright pixel and faded every candidate from 25 % to -/// 70 % down the frame, which both admitted bright non-sky near the horizon and removed real sky -/// before it reached the horizon. It then used a broad Gaussian (1 % of the long edge), smearing -/// the selection across branches, wires and the horizon. Keep this heuristic local/window-stable: -/// a true flood fill to the top cannot be reproduced by a zoom-window render without evaluating -/// the missing part of the frame. -fn rasterize_sky(frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32, out: &mut Plane) { - if w == 0 || h == 0 { - return; - } - let m = frame.out_to_norm(w, h); - let gain = ev.exp2(); - for (i, v) in out.data.iter_mut().enumerate() { - let (x, y) = (i % w, i / w); - let n = m.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)); - let c = img.data[i].map(|v| (v * gain).max(0.0)); - let l = log_l.data[i] + ev; - let hi = c[0].max(c[1]).max(c[2]); - let lo = c[0].min(c[1]).min(c[2]); - let sat = (hi - lo) / (hi + 1e-4); - let blue = (c[2] - c[0].max(c[1])).max(0.0) / (c[2] + 1e-4); - let py = n.y as f32; - - let bright = smooth(-3.5, -0.7, l); - let blue_sky = smooth(0.01, 0.18, blue); - // Bright, low-chroma cloud/overcast remains possible, but unlike the old brightness floor - // it gets a stronger vertical prior so neutral ground does not qualify as readily. - let neutral_sky = (1.0 - smooth(0.08, 0.24, sat)) * smooth(-2.8, -0.5, l) * (1.0 - smooth(0.50, 0.80, py)); - // Blue sky is allowed much closer to the horizon than before; only the bottom of the frame - // is strongly discouraged. - let height = 0.15 + 0.85 * (1.0 - smooth(0.66, 0.94, py)); - let score = bright * blue_sky.max(0.85 * neutral_sky) * height; - *v = smooth(0.16, 0.55, score); - } - - // Feather at a quarter of the old width, but only across locally smooth luminance and where - // the unblurred classifier has support. A dark wire/branch therefore stays out instead of - // being filled by the blur; the horizon does not bleed into a strong luminance edge. - let sigma = 0.0025 * frame.output_long(w, h) as f32; - let blurred = lightcraft_raster::blur::gaussian(out, sigma.max(0.5)); - let original = std::mem::replace(out, blurred); - for y in 0..h { - for x in 0..w { - let i = y * w + x; - let l = log_l.data[i]; - let edge = (l - log_l.get_clamped(x as isize - 1, y as isize)) - .abs() - .max((l - log_l.get_clamped(x as isize + 1, y as isize)).abs()) - .max((l - log_l.get_clamped(x as isize, y as isize - 1)).abs()) - .max((l - log_l.get_clamped(x as isize, y as isize + 1)).abs()); - let flat = 1.0 - smooth(0.08, 0.35, edge); - let a = original.data[i]; - let support = smooth(0.02, 0.20, a); - let mix = flat * (0.15 + 0.85 * support); - let b = out.data[i]; - out.data[i] = (a + (b - a) * mix).clamp(0.0, 1.0); - } - } -} - /// The union (max) of `mattes` (0..255, over the EXIF-oriented source) at output resolution, /// sampled like the image: user orientation, lens / perspective warp, crop and flips; area-averaged /// first when the output is much smaller than the matte, bilinear otherwise. @@ -857,30 +809,6 @@ mod tests { assert!(l > 0.95 && r < 0.3, "refine 100: {l} vs {r}"); } - #[test] - fn sky_heuristic_reaches_the_horizon_without_filling_thin_objects() { - let (w, h, horizon) = (200, 120, 72); - let img = Rgb32f::from_fn(w, h, |x, y| { - let wire = y == 34 && x > 20 && x < w - 20; - let branch = (96..=98).contains(&x) && y < horizon; - if wire || branch { - [0.04; 3] - } else if y < horizon { - [0.35, 0.60, 1.10] - } else { - [0.55, 0.38, 0.20] - } - }); - let l = img.map(crate::local::log_lum); - let a = shape_alpha(&MaskShape::Sky, &frame(w, h), w, h, &img, &l, 0.0, None); - - assert!(a.get(30, horizon - 3) > 0.8, "sky reaches the horizon: {}", a.get(30, horizon - 3)); - assert!(a.get(30, horizon + 3) < 0.1, "warm ground stays out: {}", a.get(30, horizon + 3)); - assert!(a.get(80, 34) < 0.2, "thin wire stays out: {}", a.get(80, 34)); - assert!(a.get(97, 50) < 0.2, "thin branch stays out: {}", a.get(97, 50)); - assert!(a.get(90, 50) > 0.8 && a.get(104, 50) > 0.8, "sky beside the branch remains selected"); - } - /// A half-resolution matte selecting the left `frac` of a `w × h` source. fn left_matte(w: usize, h: usize, frac: f64) -> Image { Image::from_fn(w / 2, h / 2, |x, _| if (x as f64 + 0.5) < frac * (w / 2) as f64 { 255 } else { 0 }) @@ -939,4 +867,4 @@ mod tests { let lips = MaskShape::People { person: 0, parts: vec!["Lips".into()] }; assert_eq!(eval(&lips), shape_alpha(&lips, &f, w, h, &img, &l, 0.0, None)); } - } +} From 0b1d80f08d8d53baf9e9812d4a6f5c6dc8f5e4c1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 04:04:41 -0400 Subject: [PATCH 5/5] LR-MASK-OVERLAY: test the mask overlay as a zoomed window There was no zoom/pan reset on main to fix: what this PR changes is that a zoomed view with the mask overlay shown is now a canvas-sized frame plus a sharp window carrying the overlay, as without it, instead of a whole-frame render at the zoomed size. Tests: the overlay on a window matches the same pixels of the whole render for radial, refined linear and sky masks in three views (pipeline tests_window.rs), and at 1:1 the loupe uses a window with the overlay, keeps zoom and pan, and swaps tiles when the overlay is hidden or shown (ui-egui tests_preview_limit.rs). Parity row updated. No render output change (no cache bump). Co-Authored-By: Claude Opus 5.5 --- crates/pipeline/src/tests_window.rs | 34 ++++++++++++++++++++ crates/ui-egui/src/tests_preview_limit.rs | 38 +++++++++++++++++++++++ docs/parity.md | 2 +- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index 0ed5e81e..1d5b15c6 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -204,6 +204,40 @@ fn mask_edge_refinement_does_not_depend_on_the_window() { check_noisy("refine", &s, MID, 3, 0.2); } +// Given a mask overlay (the Masking panel's view of the selected mask), a window of it matches the +// same pixels of the whole render with that overlay, for every view and for drawn, AI and +// refined shapes: the zoomed loupe can show the overlay as a window (issue #547) +#[test] +fn a_mask_overlay_window_matches_the_whole_render() { + use crate::{MaskView, Overlay}; + use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let shapes = [ + ("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), + ]; + for (name, shape, refine) in shapes { + let mut s = DevelopSettings::default(); + s.masks.push(Mask { + id: 3, + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], + adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, + refine, + ..Default::default() + }); + for view in [MaskView::Color, MaskView::WhiteOnBlack, MaskView::ImageOnBlack] { + let overlay = Overlay::Mask { id: 3, view, color: [255, 0, 0], opacity: 70 }; + let f = render(&noisy_scene(), &SourceInfo::default(), &s, &RenderRequest { overlay, ..RenderRequest::fit(W, H) }).image; + let w = + render(&noisy_scene(), &SourceInfo::default(), &s, &RenderRequest { overlay, window: Some(MID), ..RenderRequest::fit(W, H) }).image; + let (max, mean) = compare(&f, &w, MID); + // (refine edges differs by a rounding step, as in `mask_edge_refinement_does_not_depend_on_the_window`) + assert!(max <= 3 && mean <= 0.25, "{name} {view:?}: max {max} (≤ 3), mean {mean:.3} (≤ 0.25)"); + } + } +} + // ---- Deep zoom: the wide stages' kernels are far wider than any margin ------------------------- /// Hard-edged blocks of very different brightness at several scales: the wide stages' edge-aware diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index f48a2e1c..ae57c570 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -189,6 +189,44 @@ mod in_the_loupe { assert!(w >= 1300 && hh >= 700, "it covers the canvas: {w}×{hh}"); } + // Given a mask selected with its overlay shown at 1:1 (issue #547), the zoomed view keeps its + // zoom and pan and stays a canvas-sized frame plus a native window carrying the overlay, as + // without it, instead of a whole-frame render at the zoomed size; hiding or showing the + // overlay swaps the window for one with the other look + #[test] + fn a_mask_overlay_at_one_to_one_is_a_window_and_keeps_zoom_and_pan() { + let (mut h, native) = detail(); + for (command, params) in [("panel.masking", json!({})), ("mask.add", json!({"kind": "radial", "center": [0.4, 0.5], "rx": 0.2, "ry": 0.2}))] { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + } + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.app.ui.pan = (0.3, 0.4); + h.app.ui.mask_overlay = false; + h.settle(SETTLE); + let (zoom, pan) = (h.app.ui.zoom, h.app.ui.pan); + assert_eq!(zoom, crate::state::Zoom::Percent(100.0)); + let plain = h.app.region_view.expect("a window render without the overlay"); + + h.app.ui.mask_overlay = true; + h.settle(SETTLE); + let id = h.app.session.active().expect("a photo"); + let d = h.app.session.develop_of(id).expect("settings"); + assert!(matches!(crate::panels::detail::view_overlay(&h.app, &d), lightcraft_pipeline::Overlay::Mask { .. }), "the mask overlay is shown"); + assert_eq!((h.app.ui.zoom, h.app.ui.pan), (zoom, pan), "showing the overlay keeps zoom and pan"); + let main = rendered_long_edge(&h); + assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}"); + let shown = h.app.region_view.expect("a window render with the overlay"); + assert_eq!(shown.full.0.max(shown.full.1), native, "cut from the photo's own pixels"); + assert_eq!(shown.window, plain.window, "the same part of the photo"); + assert_ne!(shown.settings, plain.settings, "the overlay is part of the window's look"); + + h.app.ui.mask_overlay = false; + h.settle(SETTLE); + assert_eq!((h.app.ui.zoom, h.app.ui.pan), (zoom, pan), "hiding it keeps them too"); + assert_eq!(h.app.region_view.map(|r| r.settings), Some(plain.settings), "back to the plain window"); + } + // Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that #[test] fn an_explicit_limit_still_caps_one_to_one() { diff --git a/docs/parity.md b/docs/parity.md index ad82e3d2..4ae97c10 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -365,7 +365,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | | | LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | `cmd:mask.refine`, `crates/pipeline/src/masks.rs` (`evaluate_one`) | Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) | | LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets | -| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; no auto-show on hover | +| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests_preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | | LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | | LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | |