mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 19:17:02 -06:00
Merge PR #2012: Detail: Fit is as sharp as the photo
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
Reference in New Issue
Block a user