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:
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user