From 7db0a64d7e6b5c4e0f75d6f6f739619f63c6e160 Mon Sep 17 00:00:00 2001 From: ubergeneric <104942681+ubergeneric@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:28:12 +1100 Subject: [PATCH 1/2] Detail: Fit is as sharp as the photo (display fit, whole-pixel draw, Lanczos decode) At Fit the loupe was softer than a good downscale of the same file. Four resamples stacked up between the source and the screen: - the render edge is rounded up to a 64 px step, so the texture was drawn up to ~6 % smaller than its size and the GPU shrank it with 2-tap bilinear sampling; - the fitted texture sat on a fractional pixel position, so even at the right size every screen pixel was averaged with its neighbour (#1831 fixed the same for 100 %); - the render box truncated its short side (2560 x 1706 for a 2560 x 1707 source), a slightly anisotropic resample that blurred one axis only; - the codec's decode-time shrink at 2:1 and more, and SourceRef::Fit, used a box filter, which halves the contrast of detail near the output's limit. RenderJob::display_fit shrinks a full-quality render that is larger than its drawn size to exactly that size with Lanczos-3 on the worker (after the caches have the full render; drafts untouched; part of the key). The Detail view sets it for the After and Before renders at rest, and snap_fit_rect draws a texture that is its drawn size on whole physical pixels. The render boxes round their short side, and the 2:1-and-more shrinks use Lanczos-3. Refs #1472, #1042. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/convert.rs | 4 +- crates/engine/src/import.rs | 1 + crates/engine/src/media.rs | 64 ++++++++++++++++++++++++++++- crates/ui-egui/src/panels/detail.rs | 60 +++++++++++++++++++++++++-- 4 files changed, 124 insertions(+), 5 deletions(-) diff --git a/crates/codecs/src/convert.rs b/crates/codecs/src/convert.rs index 02652db5a..403845071 100644 --- a/crates/codecs/src/convert.rs +++ b/crates/codecs/src/convert.rs @@ -129,7 +129,9 @@ pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u3 let (image, alpha) = match opts.max_size { Some((mw, mh)) if (image.width as u32 > mw || image.height as u32 > mh) && mw > 0 && mh > 0 => { let factor = (image.width as f64 / mw as f64).max(image.height as f64 / mh as f64); - let f = if factor >= 2.0 { Filter::Box } else { Filter::Mitchell }; + // (Lanczos at 2:1 and more as well: a box average there halves the contrast of + // detail near the output's resolution limit and aliases what is finer) + let f = if factor >= 2.0 { Filter::Lanczos3 } else { Filter::Mitchell }; let img = fit(&image, mw as usize, mh as usize, f); let alpha = alpha.map(|a| fit(&a, mw as usize, mh as usize, f)); (img, alpha) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 9aa847b01..84af5acb5 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -1566,6 +1566,7 @@ impl Session { display: None, slide: None, preview_cut: None, + display_fit: None, }; let embedded = match (&self.media.preview_loader, c.kind) { (Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)), diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 22d76528d..52c309847 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -277,7 +277,9 @@ impl SourceRef { if from.image.width.max(from.image.height) <= *max_edge { return Ok((**from).clone()); } - let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box); + // Lanczos, not a box average: a box at 2-3:1 halves the contrast of detail near + // the output's resolution limit and aliases what is finer + let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Lanczos3); return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() }); } SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?, @@ -761,6 +763,24 @@ pub struct RenderJob { /// A face tile that can be cut from the raw's embedded preview, tried before `source` /// ([`crate::Session::face_job`]; `None` for every other job). pub preview_cut: Option, + /// Shrink a full-quality result to fit this size (physical pixels) with a Lanczos filter once + /// it is rendered. The loupe renders a little larger than it is drawn (its edge is rounded up + /// to a 64 px step) and the window would otherwise shrink the texture with 2-tap bilinear sampling, which blurs fine detail at + /// small ratios and aliases it at large ones. The caches keep the full render. + pub display_fit: Option<(usize, usize)>, +} + +/// `img` shrunk to fit `max_w × max_h` with a Lanczos filter (see [`RenderJob::display_fit`]). +pub fn fit_for_display(img: &Rgba8, max_w: usize, max_h: usize) -> Rgba8 { + use lightcraft_raster::resample::{Filter, fit}; + let f = lightcraft_raster::Image { + width: img.width, + height: img.height, + data: img.data.iter().map(|p| [f32::from(p[0]), f32::from(p[1]), f32::from(p[2]), f32::from(p[3])]).collect(), + }; + let small = fit(&f, max_w.max(1), max_h.max(1), Filter::Lanczos3); + let c = |v: f32| v.round().clamp(0.0, 255.0) as u8; + Rgba8 { width: small.width, height: small.height, data: small.data.iter().map(|p| [c(p[0]), c(p[1]), c(p[2]), c(p[3])]).collect() } } pub struct RenderResult { @@ -784,6 +804,14 @@ pub struct RenderResult { impl RenderJob { /// Render at draft quality (interactive drags). Gets its own result key. + /// Shrink the full-quality result to fit `w × h` pixels with a Lanczos filter (see + /// [`Self::display_fit`]). Gets its own result key, so a change of drawn size renders again. + pub fn with_display_fit(mut self, w: usize, h: usize) -> Self { + self.display_fit = Some((w, h)); + self.key ^= ((w as u64) << 32 | h as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15); + self + } + pub fn draft(mut self) -> Self { if self.request.quality != Quality::Draft { self.request.quality = Quality::Draft; @@ -994,6 +1022,15 @@ impl RenderJob { { cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone()))); } + // the display fit, after the caches have the full render + if let Some((fw, fh)) = self.display_fit + && self.request.quality == Quality::Full + { + let (w, h) = (rendered.image.width, rendered.image.height); + if w > fw.max(1) || h > fh.max(1) { + rendered.image = fit_for_display(&rendered.image, fw, fh); + } + } RenderResult { request_id: self.request_id, source_key: self.source_key, @@ -1266,6 +1303,7 @@ impl crate::Session { display: None, slide: None, preview_cut: None, + display_fit: None, }) } @@ -1319,6 +1357,7 @@ impl crate::Session { display: None, slide: None, preview_cut: None, + display_fit: None, }) } @@ -1373,6 +1412,7 @@ impl crate::Session { display: None, slide: None, preview_cut, + display_fit: None, }) } @@ -1798,6 +1838,28 @@ mod thumbnail_hash_tests { mod tests { use super::*; + /// A loupe job with a display fit returns its image shrunk to the drawn size, aspect kept; + /// a draft, and a render no larger than the drawn size, are left as rendered. + #[test] + fn a_display_fit_shrinks_the_loupe_render_to_the_drawn_size() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let plain = s.loupe_job(id, 1024, 1024, true).unwrap().run().rendered.unwrap().image; + let long = plain.width.max(plain.height); + assert!(long >= 600, "the demo renders at least 600 px at 1024: {long}"); + let fitted = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400).run().rendered.unwrap().image; + assert_eq!(fitted.width.max(fitted.height), 400, "{}x{}", fitted.width, fitted.height); + let aspect = |i: &Rgba8| i.width as f32 / i.height as f32; + assert!((aspect(&fitted) - aspect(&plain)).abs() < 0.02, "aspect kept"); + let draft = s.loupe_job(id, 1024, 1024, true).unwrap().draft().with_display_fit(400, 400).run().rendered.unwrap().image; + assert_eq!(draft.width.max(draft.height), long, "a draft is not shrunk"); + let same = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(plain.width, plain.height).run().rendered.unwrap().image; + assert_eq!((same.width, same.height), (plain.width, plain.height), "no larger than drawn: left as rendered"); + let a = s.loupe_job(id, 1024, 1024, true).unwrap(); + let b = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400); + assert_ne!(a.key, b.key, "the fit is part of the result key"); + } + #[test] fn renders_know_the_original_size_whichever_level_they_come_from() { // issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 44427565e..ff8b96da5 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -145,6 +145,21 @@ pub(crate) fn fit_texture_rect(area: Rect, size: [usize; 2]) -> Rect { Rect::from_center_size(area.center(), vec2(w * scale, h * scale)) } +/// `rect` moved onto whole physical pixels, at exactly the texture's size, when the texture is +/// already the size it is drawn at (within half a pixel), as a display-fitted render at Fit is: centred +/// on a fractional position, the GPU's bilinear filter would average every screen pixel with its +/// neighbour (the 100 % view does the same in [`fit_rect`]). Textures of other sizes are left to +/// the sampler. +pub(crate) fn snap_fit_rect(rect: Rect, size: [usize; 2], ppp: f32) -> Rect { + let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + let (w, h) = (size[0] as f32, size[1] as f32); + if (rect.width() * ppp - w).abs() > 0.5 || (rect.height() * ppp - h).abs() > 0.5 { + return rect; + } + let snap = |v: f32| (v * ppp).round() / ppp; + Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), vec2(w / ppp, h / ppp)) +} + /// Keep the viewport inside the photo; axes smaller than the viewport stay centred. fn bounded_pan(area: Rect, size: egui::Vec2, pan: (f32, f32)) -> (f32, f32) { let bound = |visible: f32, image: f32, centre: f32| { @@ -564,12 +579,24 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::region::HOLD_SECS)); } let want = plan.main_edge; - let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; + // The short side is rounded, not truncated: a truncated box (2560 x 1706 for a 2560 x 1707 + // source) made the render a slightly anisotropic resample of its source, a 2-tap blur along + // one axis only. A box that matches the source to rounding is rendered at exactly its size. + let short = |e: usize, a: f32| ((e as f32 / a).round() as usize).max(1); + let (rw, rh) = if aspect >= 1.0 { (want, short(want, aspect)) } else { (short(want, 1.0 / aspect), want) }; // hover and before renders are stand-ins: the preview size is plenty and keeps them cheap let se = app.ui.settings.stand_in_edge(want, texture_side); - let (sw, sh) = if aspect >= 1.0 { (se, (se as f32 / aspect) as usize) } else { ((se as f32 * aspect) as usize, se) }; + let (sw, sh) = if aspect >= 1.0 { (se, short(se, aspect)) } else { (short(se, 1.0 / aspect), 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 }; + // at rest the render is shrunk to exactly the size it is drawn at with a Lanczos filter on + // the worker, so the window draws it texel for texel instead of shrinking it with 2-tap + // sampling (the render is up to a 64 px step larger) + let job = if interacting { + job + } else { + job.with_display_fit((target_rect.width() * ppp).round() as usize, (target_rect.height() * ppp).round() as usize) + }; let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); let job = job.with_hidden_grading(app.ui.grading_hidden_for(id)); app.renderer.request(Slot::Main, job, 100); @@ -625,6 +652,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if (split || show_before || split_view) && let Some(job) = app.session.render_job(id, sw.max(8), sh.max(8), true, !crop_tool) { + // (the same display fit as the After view, so both panes are the same texture size) + let job = if interacting { + job + } else { + job.with_display_fit((target_rect.width() * ppp).round() as usize, (target_rect.height() * ppp).round() as usize) + }; app.renderer.request(Slot::Before, job, 90); } // zoomed past what the whole-frame render holds: also render the part on screen, at no more @@ -692,7 +725,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else { return "none"; }; - let image_rect = fit_texture_rect(r, tex.size); + let image_rect = snap_fit_rect(fit_texture_rect(r, tex.size), tex.size, ppp); p.with_clip_rect(clip).image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); what }; @@ -3050,6 +3083,27 @@ mod pen_tests { } } +#[cfg(test)] +mod snap_tests { + use super::*; + + /// A texture drawn at its own size lands on whole physical pixels at exactly that size (no + /// bilinear blur); a texture of another size (zoomed, a stand-in) keeps the rect it was given. + #[test] + fn a_fitted_texture_is_drawn_on_whole_pixels_at_its_own_size() { + let r = Rect::from_min_size(pos2(394.3, 130.5), vec2(1088.4, 720.2)); + let s = snap_fit_rect(r, [1088, 720], 1.0); + assert_eq!((s.min.x, s.min.y, s.width(), s.height()), (394.0, 131.0, 1088.0, 720.0)); + // at 1.5 points per pixel the snap is to physical pixels and the size stays in points + let hi = snap_fit_rect(Rect::from_min_size(pos2(100.3, 50.2), vec2(725.4, 480.1)), [1088, 720], 1.5); + assert!(((hi.min.x * 1.5).fract()).abs() < 1e-3 && (hi.width() - 1088.0 / 1.5).abs() < 1e-3, "{hi:?}"); + let other = snap_fit_rect(r, [2560, 1694], 1.0); + assert_eq!(other, r); + let bad = snap_fit_rect(r, [1088, 720], f32::NAN); + assert_eq!((bad.width(), bad.height()), (1088.0, 720.0)); + } +} + #[cfg(test)] mod filter_pill_tests { use super::*; From 93920397263e64d5042b018fb4aa242903dac4c4 Mon Sep 17 00:00:00 2001 From: ubergeneric <104942681+ubergeneric@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:28:14 +1100 Subject: [PATCH 2/2] Detail: at rest the whole frame renders at the preview level, shrunk once Rendered at the drawn size, the pipeline itself shrinks the 2560 px preview source (Mitchell prefilter, then the bilinear sampling pass) before the display fit shrinks it again. At rest the whole-frame render now asks for no less than the preview level (the photo's own size if smaller, a user's explicit Preview size if lower, the texture limit at most): the source is copied 1:1 and shrunk once, by the Lanczos display fit. Drags still render drafts at the planned size. On a line-grating chart at Fit this takes 1.8 px bars from 0.83 to 1.00 of their contrast (1920 x 1200 window), at the cost of a larger settled render. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/detail.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index ff8b96da5..e2fc146c0 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -578,7 +578,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.size_hold.holding(now) { ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::region::HOLD_SECS)); } - let want = plan.main_edge; + // At rest the whole frame renders at no less than the preview source level: the source is then + // copied 1:1 (no resample in the pipeline, whose prefilter and bilinear pass soften detail near + // the output's limit) and shrunk once, by the Lanczos display fit below. + let want = if interacting { + plan.main_edge + } else { + // (a user's explicit Preview size below the preview level still caps it) + let limit = if app.ui.settings.preview_limit == 0 { usize::MAX } else { app.ui.settings.preview_limit as usize }; + let floor = lightcraft_engine::SourceLevel::Preview.max_edge().min(limit).min(native_long); + plan.main_edge.max(floor).min(texture_side.max(crate::state::MIN_TEXTURE_SIDE)) + }; // The short side is rounded, not truncated: a truncated box (2560 x 1706 for a 2560 x 1707 // source) made the render a slightly anisotropic resample of its source, a 2-tap blur along // one axis only. A box that matches the source to rounding is rendered at exactly its size. @@ -591,7 +601,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let job = if interacting { job.draft() } else { job }; // at rest the render is shrunk to exactly the size it is drawn at with a Lanczos filter on // the worker, so the window draws it texel for texel instead of shrinking it with 2-tap - // sampling (the render is up to a 64 px step larger) + // sampling (the render is up to a 64 px step larger, or the preview level) let job = if interacting { job } else {