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"
);
}
+202 -1
View File
@@ -30,6 +30,83 @@ pub(super) fn fit(sw: u32, sh: u32, max_w: u32, max_h: u32) -> (u32, u32) {
)
}
/// How far short of exactly 1:1 a view may fall and still count as 1:1.
///
/// Relative, and half a percent rather than a float epsilon, because the error
/// it absorbs is not only float error. [`DevelopSession::one_to_one_zoom`]
/// lands the view on 1:1 measured along the edge [`fit`] rounded, and the
/// other edge is then out by up to half a pixel — 0.17% of a 300px phone
/// canvas. A threshold that missed that would show the 1:1 inspection
/// smoothed on one photograph and in pixels on the next.
///
/// Nothing is lost by the margin: just under 1:1 the render is drawn at very
/// nearly one texel per screen pixel, and at that scale neither filter has
/// anything to do.
const ONE_TO_ONE_TOLERANCE: f64 = 0.005;
/// TRACES: FR-UI-4 | FR-DSP-8
/// Screen pixels per source pixel, for the part of the frame being looked at.
///
/// `framed` is the framed image at source resolution, `view` the fraction of
/// it on screen (the framing's view rect, width and height), and `viewport`
/// the canvas in **physical** pixels — what `display_ui::physical` hands the
/// renderer, and the same unit [`DevelopSession::one_to_one_zoom`] defines
/// 1:1 in. A logical viewport here would call a 2× display's 1:1 a 50% view.
///
/// Measured against the viewport rather than against what was rendered,
/// because the render never exceeds the source (see [`fit`]) and the canvas
/// stretches it to the box: a small JPEG fitted to a large window is on
/// screen magnified whatever size its texture is.
pub(super) fn magnification(framed: (u32, u32), view: (f32, f32), viewport: (u32, u32)) -> f64 {
let behind_w = f64::from(framed.0.max(1)) * f64::from(view.0.max(f32::EPSILON));
let behind_h = f64::from(framed.1.max(1)) * f64::from(view.1.max(f32::EPSILON));
(f64::from(viewport.0.max(1)) / behind_w).min(f64::from(viewport.1.max(1)) / behind_h)
}
/// TRACES: FR-UI-4
/// Whether a view at `magnification` shows the file's own pixels, and so is
/// drawn nearest-neighbour rather than smoothed.
///
/// **At 1:1 and past it**, not only past it. From 1:1 on there is no detail
/// left to reconstruct, so smoothing only invents values between real ones,
/// and inspecting focus or noise is the whole reason to look that closely.
/// Below it several source pixels share each screen pixel and filtering is
/// what keeps the image from aliasing.
pub(super) fn shows_source_pixels(magnification: f64) -> bool {
magnification >= 1.0 - ONE_TO_ONE_TOLERANCE
}
/// TRACES: FR-UI-4 | FR-DSP-1
/// The size to render the viewed region at: fitted to the viewport, and never
/// more pixels than the source has behind it.
///
/// **The second half is what makes a magnified view show pixels.** The render
/// used to be viewport-sized at any zoom, so past 1:1 the pipeline itself was
/// the upsampler — bilinearly wherever a straightening angle or a lens
/// correction was in the chain — and the neighbourhood operations then
/// sharpened and denoised those invented pixels with radii scaled up to
/// match. Whatever filter the canvas chose, it was handed a texture already
/// blurred. Rendering the region at its own resolution gives the canvas the
/// pixels an export would have, and leaves the enlargement to it, which draws
/// them nearest-neighbour; it is also a fraction of the shading.
///
/// Below 1:1 this is [`fit`] of the whole frame, as it always was: the view
/// rect shrinking while the target keeps its size is how a zoom short of 1:1
/// gains detail. The two branches meet at 1:1, where both are the viewport.
pub(super) fn render_size(
framed: (u32, u32),
view: (f32, f32),
viewport: (u32, u32),
) -> (u32, u32) {
if framed.0 == 0 || framed.1 == 0 || magnification(framed, view, viewport) < 1.0 {
return fit(framed.0, framed.1, viewport.0.max(1), viewport.1.max(1));
}
let behind = |edge: u32, fraction: f32| {
((f64::from(edge) * f64::from(fraction.clamp(f32::EPSILON, 1.0))).round() as u32).max(1)
};
(behind(framed.0, view.0), behind(framed.1, view.1))
}
impl DevelopSession {
/// Rasterise the current mask stack, if there is one.
///
@@ -163,9 +240,14 @@ impl DevelopSession {
// Fitted against the *framed* size, not the sensor's: a crop changes
// the aspect ratio, and fitting the uncropped shape would letterbox
// to the wrong box and render the crop squashed.
//
// And, past 1:1, only as many pixels as the source has behind the
// view — see `render_size` for why the canvas and not the pipeline
// has to be the one that enlarges.
let (sw, sh) = self.demosaiced.size();
let (fw, fh) = self.graph.output_size(sw, sh);
let (w, h) = fit(fw, fh, width.max(1), height.max(1));
let view = self.graph.framing().view();
let (w, h) = render_size((fw, fh), (view.width, view.height), (width, height));
// TRACES: FR-DSP-8 | FR-DSP-6
// **Composed for the display that is showing this canvas**, not for
@@ -1022,6 +1104,125 @@ mod tests {
assert_eq!((w, h), (400, 300));
}
/// TRACES: FR-UI-4
/// From 1:1 on the canvas is drawn as pixels; below it, smoothed.
#[test]
fn the_canvas_shows_pixels_from_one_to_one_on() {
// A 6000px-wide frame in a 1500px viewport: 1:1 is a quarter of it.
let framed = (6000, 4000);
let viewport = (1500, 1000);
let at =
|extent: f32| shows_source_pixels(magnification(framed, (extent, extent), viewport));
assert!(!at(1.0), "fitted is a quarter of 1:1");
assert!(!at(0.5), "and 2x is half of it");
assert!(at(0.25), "4x is 1:1");
assert!(at(0.0625), "and 16x is past it");
// Exactly at the threshold, and a float-error short of it.
assert!(shows_source_pixels(1.0));
assert!(shows_source_pixels(0.99999));
assert!(
at(0.250_002),
"a view a rounding error wider than 1:1 is still 1:1"
);
assert!(!shows_source_pixels(0.9), "90% is below 1:1, and smoothed");
}
/// TRACES: FR-UI-4
/// The 1:1 the inspection toggle lands on is a 1:1 this counts as one,
/// even where [`fit`] rounded the edge it was measured along.
#[test]
fn the_inspection_zoom_is_always_drawn_as_pixels() {
// Frames and canvases whose fitted edges do not divide evenly.
for (framed, viewport) in [
((6001, 4000), (1600, 900)),
((5999, 4001), (1600, 900)),
((4000, 6001), (333, 517)),
((7952, 5304), (301, 211)),
] {
let (rw, _) = fit(framed.0, framed.1, viewport.0, viewport.1);
// What `DevelopSession::one_to_one_zoom` and `inspect_at` compute.
let extent = 1.0 / (framed.0 as f32 / rw as f32);
let m = magnification(framed, (extent, extent), viewport);
assert!(
shows_source_pixels(m),
"{framed:?} in {viewport:?} at the inspection zoom is {m}, not 1:1"
);
}
}
/// TRACES: FR-UI-4 | FR-DSP-8
/// 1:1 is one source pixel per *physical* screen pixel, on a scaled
/// display as on any other.
#[test]
fn one_to_one_is_measured_in_physical_pixels() {
// A 1000-logical-pixel canvas at 2× is 2000 device pixels.
let viewport = crate::display_ui::physical((1000, 800), 2.0);
assert_eq!(viewport, (2000, 1600));
// 2000 source pixels across it are 1:1 — though they cover only 1000
// logical pixels, which a logical measure would call 50%.
assert!(shows_source_pixels(magnification(
(2000, 1000),
(1.0, 1.0),
viewport
)));
// And 3000 are not, though a logical measure would call them 1:3 of
// the same thing.
assert!(!shows_source_pixels(magnification(
(3000, 1500),
(1.0, 1.0),
viewport
)));
// Fractional scaling: 1.25 over 1203 logical rounds to 1504 device
// pixels, and 1504 source pixels across them are exactly 1:1.
let viewport = crate::display_ui::physical((1203, 900), 1.25);
assert!(shows_source_pixels(magnification(
(1504, 1000),
(1.0, 1.0),
viewport
)));
assert!(!shows_source_pixels(magnification(
(1600, 1000),
(1.0, 1.0),
viewport
)));
}
/// TRACES: FR-UI-4 | FR-DSP-1
/// Below 1:1 the render is fitted to the viewport; from 1:1 on it is the
/// region's own pixels, which the canvas then enlarges.
#[test]
fn a_magnified_region_renders_at_source_resolution() {
let framed = (6000, 4000);
let viewport = (1500, 1000);
assert_eq!(
render_size(framed, (1.0, 1.0), viewport),
fit(6000, 4000, 1500, 1000),
"a full view is exactly the fit it always was"
);
assert_eq!(render_size(framed, (0.5, 0.5), viewport), (1500, 1000));
assert_eq!(render_size(framed, (0.25, 0.25), viewport), (1500, 1000));
assert_eq!(
render_size(framed, (0.125, 0.125), viewport),
(750, 500),
"at 2x the viewport has only half its width of source behind it"
);
// A frame smaller than the viewport is its own size, fitted or not.
assert_eq!(
render_size((400, 300), (1.0, 1.0), (3840, 2160)),
(400, 300)
);
assert_eq!(
render_size((400, 300), (0.5, 0.5), (3840, 2160)),
(200, 150)
);
}
#[test]
fn fitting_handles_a_degenerate_source() {
let (w, h) = fit(0, 0, 800, 600);