Merge PR #2012: Detail: Fit is as sharp as the photo

This commit is contained in:
aiongg
2026-10-21 14:48:42 +00:00
4 changed files with 135 additions and 6 deletions
+3 -1
View File
@@ -141,7 +141,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)
+1
View File
@@ -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)),
+63 -1
View File
@@ -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<crate::face_tiles::PreviewCut>,
/// 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
+68 -4
View File
@@ -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| {
@@ -563,13 +578,35 @@ 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;
let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) };
// 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.
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, or the preview level)
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 +662,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 +735,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
};
@@ -3073,6 +3116,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::*;