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
+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);