Show the file's own pixels at 1:1 and beyond

Zoomed to 1:1 or past it, the develop canvas showed a smoothed blur
rather than the photograph's pixels, so focus and noise could not be
judged at the magnification meant for judging them.

Two things caused it. The canvas only switched to nearest-neighbour
strictly past 1:1, with a margin, so the 1:1 inspection itself stayed
smooth. And the switch mostly had nothing to act on: the pipeline
rendered a viewport-sized frame at every zoom, so past 1:1 it was the
pipeline doing the enlarging - bilinearly whenever a straightening angle
or lens correction was in the chain - and the detail stage then sharpened
and denoised those invented pixels at radii scaled up to match. The
texture reached the canvas already blurred and was presented 1:1.

Now, from 1:1 on, the visible region is rendered at the source's own
resolution (render::render_size) and the canvas enlarges it with
nearest-neighbour, so the blocks on screen are the pixels an export would
have; it is also less shading. The decision lives in two small
functions, render::magnification and render::shows_source_pixels,
measured in physical pixels like one_to_one_zoom, with a half-percent
tolerance so the inspection zoom counts as 1:1 even where fit() rounded
the other edge. Below 1:1 the render and the smooth filter are unchanged.
This commit is contained in:
2026-09-24 21:24:57 -04:00
parent 5569a066ff
commit 229def0afc
5 changed files with 353 additions and 98 deletions
+72 -31
View File
@@ -4,7 +4,7 @@ use dr_pipeline::{CropRect, Edit};
use crate::labels;
use super::render::fit;
use super::render::{fit, magnification, shows_source_pixels};
use super::session::DevelopSession;
/// TRACES: FR-DEV-3
@@ -139,35 +139,36 @@ impl DevelopSession {
self.demosaiced.size()
}
/// Whether one source pixel now covers more than one screen pixel.
/// TRACES: FR-UI-4
/// Whether the canvas is showing the file's own pixels: at 1:1 or closer,
/// one source pixel to one screen pixel or more.
///
/// The question the interface asks to decide how the canvas is *filtered*,
/// not how it is rendered. Below 1:1 there are more source pixels than
/// screen pixels and smoothing is what stops the image aliasing; past it
/// there is no more detail to show, and smoothing only invents values
/// between real ones — at which point a photographer inspecting focus or
/// noise wants to see the pixels, not a blur of them.
/// The question the interface asks to decide how the canvas is *filtered*.
/// Below 1:1 there are more source pixels than screen pixels and smoothing
/// is what stops the image aliasing; from 1:1 on there is no more detail
/// to show, and smoothing only invents values between real ones — at
/// which point a photographer inspecting focus or noise wants to see the
/// pixels, not a blur of them. [`Self::render`] draws such a view at the
/// source's own resolution for the same reason, so the canvas is the one
/// enlarging it.
///
/// Measured against the visible region rather than the zoom factor alone,
/// because the two differ: a 24 MP file in a 1200px viewport is still
/// showing five sensor pixels per screen pixel at 4×, while a small JPEG is
/// already magnified at 1×.
///
/// `viewport_w`/`viewport_h` are **physical** pixels, as for
/// [`Self::one_to_one_zoom`]; the arithmetic and its tolerance live in
/// `render::magnification` and `render::shows_source_pixels`.
pub fn magnifies_source(&self, viewport_w: u32, viewport_h: u32) -> bool {
let (sw, sh) = self.demosaiced.size();
let (fw, fh) = self.graph.output_size(sw, sh);
let (rw, rh) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1));
// How many source pixels lie behind the render target: the framed
// image narrowed to the region the view selects. The target keeps its
// size while that region shrinks, which is what raises the ratio.
let framed = self.graph.output_size(sw, sh);
let view = self.graph.framing().view();
let behind_w = f64::from(fw) * f64::from(view.width.max(f32::EPSILON));
let behind_h = f64::from(fh) * f64::from(view.height.max(f32::EPSILON));
// Strictly greater, with a margin: at exactly 1:1 either filter gives
// the same answer, and flipping mode on a rounding error would make the
// canvas visibly change character mid-scroll.
f64::from(rw) > behind_w * 1.001 && f64::from(rh) > behind_h * 1.001
shows_source_pixels(magnification(
framed,
(view.width, view.height),
(viewport_w, viewport_h),
))
}
/// Set the crop rectangle, in fractions of the source.
@@ -824,21 +825,61 @@ mod tests {
"16x on a 4x-downscaled source magnifies and must not be filtered"
);
// Smaller than the viewport: `fit` refuses to upscale, so the render is
// 1:1 and unzoomed is exactly the boundary — not past it.
// Smaller than the viewport: `fit` refuses to upscale, and the canvas
// stretches the render to the box — so fitted, it is already on screen
// magnified, and is drawn as pixels like any other view past 1:1.
let small = vec![128u8; (100 * 100 * 4) as usize];
let mut session =
let session =
DevelopSession::open_rgb(&ctx, &small, 100, 100, dr_types::Orientation::NORMAL)
.expect("session");
assert!(
!session.magnifies_source(800, 800),
"1:1 is the boundary, not past it — filtering must not flip on a \
rounding error"
);
session.zoom_about(2.0, 0.5, 0.5);
assert!(
session.magnifies_source(800, 800),
"any zoom past a 1:1 render magnifies"
"a 100px image filling an 800px canvas is 8x, fitted or not"
);
assert!(
session.magnifies_source(100, 100),
"exactly 1:1 shows the file's own pixels too"
);
assert!(!session.magnifies_source(50, 50), "and half size does not");
}
/// TRACES: FR-UI-4
/// Past 1:1 the pipeline renders the region at the source's resolution
/// and leaves the enlargement to the canvas.
///
/// The failure this guards: a viewport-sized render at 4× is the pipeline
/// upsampling — bilinearly under any straightening angle or lens
/// correction, and then sharpened at a radius scaled to match — so the
/// canvas's nearest-neighbour filter was handed pixels already smoothed,
/// and a photographer at 1:1 or beyond saw a blur rather than the file.
#[test]
fn a_magnified_view_is_rendered_at_the_sources_own_resolution() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
// Sixty-four source pixels in a thirty-two pixel viewport: fitted,
// the render is the viewport.
let fitted = session.render(32, 32).expect("fitted render");
assert_eq!((fitted.size().width, fitted.size().height), (32, 32));
// At 1:1 the region behind the viewport is thirty-two source pixels.
session.toggle_inspection(0.5, 0.5, 32, 32);
let one_to_one = session.render(32, 32).expect("1:1 render");
assert_eq!(
(one_to_one.size().width, one_to_one.size().height),
(32, 32)
);
assert!(session.magnifies_source(32, 32), "1:1 is drawn as pixels");
// At 4× only sixteen are behind it, and sixteen are what is rendered.
session.reset_zoom();
session.zoom_about(4.0, 0.5, 0.5);
let magnified = session.render(32, 32).expect("magnified render");
assert_eq!(
(magnified.size().width, magnified.size().height),
(16, 16),
"a 4x view of a 64px frame has 16 source pixels behind a 32px \
viewport; rendering more is the pipeline inventing them"
);
}