diff --git a/core/dr-gpu/tests/zoom_resolution.rs b/core/dr-gpu/tests/zoom_resolution.rs new file mode 100644 index 0000000..2a95022 --- /dev/null +++ b/core/dr-gpu/tests/zoom_resolution.rs @@ -0,0 +1,287 @@ +//! TRACES: FR-DSP-5 +//! Zooming to 1:1 samples the source, pixel for pixel. +//! +//! FR-DSP-5: *"Fit, 1:1, and arbitrary zoom levels. At 1:1 and above, the +//! pipeline operates on the visible crop at full source resolution."* +//! +//! `Framing::view` shrinks the sampled region while the render target keeps its +//! size, so zooming *raises* the resolution the pipeline works at rather than +//! magnifying pixels it has already drawn. There is no second full-resolution +//! code path — the zoom is the full-resolution path — which is why the +//! requirement has been satisfied for some time without anyone tagging it. +//! +//! `docs/display-and-extension.md` §7 is the reason this file exists rather +//! than a tag on `framing.rs`: a requirement counts as covered when a `TRACES` +//! comment names it, and nothing checks that the code under the tag does the +//! thing. `FR-DEV-8` is tagged against plumbing a future operation would use. +//! So the rule that document sets is that a requirement is closed by **a test +//! that would fail if the behaviour were removed**, and these are written to +//! fail in exactly that case: delete the view from `Framing::visible_rect` and +//! the 1:1 render collapses into the fit render, which +//! [`a_proxy_cannot_resolve_the_finest_detail_in_the_source`] establishes +//! carries none of the information the 1:1 render reproduces. +//! +//! # Why the fixture is alternating columns +//! +//! Because it makes "sampled at source resolution" a *pixel* assertion rather +//! than a "something got sharper" one. +//! +//! The source is one pixel black, one pixel white, all the way across. That is +//! the highest spatial frequency the image can hold, and it is exactly what a +//! proxy render throws away: a 1024-wide source in a 128-wide viewport maps +//! output column `x` to source column `8x + 4`, every one of which has the same +//! parity, so the whole proxy comes out flat. No amount of resampling that flat +//! image recovers the stripes. If the 1:1 render shows them — and shows them in +//! the right phase, from the right place in the source — then it read the +//! source and did not magnify the proxy. There is no third explanation. +//! +//! The source is uploaded through `DemosaicedImage::from_rgba8`, which flags it +//! non-linear, so the fused shader decodes sRGB before the chain and re-encodes +//! after it. Bytes 0 and 255 are fixed points of that round trip, which is why +//! the pattern is black and white and why the comparison can be for equality +//! rather than within a tolerance. + +use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext}; +use dr_pipeline::{CropRect, EditGraph}; + +/// A power of two, so every view rect below is exact in binary32 and the +/// mapping from output column to source column is exact arithmetic rather than +/// something that happens to round the right way. +const SOURCE: u32 = 1024; + +/// The viewport. `SOURCE / RENDER` is 8, so a fit render steps eight source +/// columns per output column — four full periods of the pattern. +const RENDER: u32 = 128; + +/// Where the 1:1 window sits in the source. **Odd on both axes on purpose**: a +/// view that honoured `width` but dropped `x` would land on the opposite phase +/// of the stripes and produce an exactly inverted image, which is the most +/// likely way for this to be subtly wrong and the one an assertion about +/// "contrast" or "variance" would sail straight past. +const WINDOW: (u32, u32) = (301, 157); + +fn ctx() -> Option { + // CI runners and headless machines may have no usable adapter. Skip rather + // than fail, exactly as the rest of this crate's device tests do. + match pollster::block_on(GpuContext::new_headless()) { + Ok(c) => Some(c), + Err(e) => { + eprintln!("skipping: no GPU adapter ({e})"); + None + } + } +} + +/// Black and white alternating every column: the finest detail an image can +/// carry. +fn stripes(ctx: &GpuContext) -> DemosaicedImage { + let data: Vec = (0..SOURCE * SOURCE) + .flat_map(|i| { + let v = if (i % SOURCE).is_multiple_of(2) { 0u8 } else { 255 }; + [v, v, v, 255] + }) + .collect(); + DemosaicedImage::from_rgba8(ctx, &data, SOURCE, SOURCE).expect("upload") +} + +/// What the source holds at `(x, y)` — the same rule [`stripes`] wrote. +fn source_byte(x: u32, _y: u32) -> u8 { + if x.is_multiple_of(2) { + 0 + } else { + 255 + } +} + +/// Render a neutral edit through `view` and hand back the bytes and the size. +/// +/// Neutral because this is a test about *which pixel* is read, and any active +/// operation would put a colour transform between the source byte and the +/// rendered one for no gain. +fn render(ctx: &GpuContext, source: &DemosaicedImage, view: CropRect) -> (Vec, u32, u32) { + let mut graph = EditGraph::default_chain(); + graph.framing_mut().set_view(view); + let shader = graph.compose(); + + let mut adjust = AdjustPass::new(ctx); + adjust + .render(source, &shader, RENDER, RENDER) + .expect("render"); + adjust.export_pixels().expect("readback") +} + +/// The view rect that puts one render pixel on one source pixel, with its +/// top-left corner at `WINDOW`. +fn one_to_one() -> CropRect { + CropRect { + x: WINDOW.0 as f32 / SOURCE as f32, + y: WINDOW.1 as f32 / SOURCE as f32, + width: RENDER as f32 / SOURCE as f32, + height: RENDER as f32 / SOURCE as f32, + } +} + +/// The red channel of one row of a rendered frame. +fn row(pixels: &[u8], width: u32, y: u32) -> Vec { + (0..width) + .map(|x| pixels[((y * width + x) * 4) as usize]) + .collect() +} + +/// TRACES: FR-DSP-5 +/// The proxy render carries none of the source's finest detail. +/// +/// Half of the argument, and the half that makes the other half mean something: +/// if the fit render already showed the stripes, a 1:1 render showing them would +/// prove nothing at all. It comes out uniform, so whatever the 1:1 render +/// contains cannot have come from resampling it. +#[test] +fn a_proxy_cannot_resolve_the_finest_detail_in_the_source() { + let Some(ctx) = ctx() else { return }; + let source = stripes(&ctx); + + let (pixels, w, h) = render(&ctx, &source, CropRect::default()); + assert_eq!((w, h), (RENDER, RENDER)); + + let first = pixels[0]; + let uniform = pixels + .chunks_exact(4) + .all(|px| px[0] == first && px[1] == first && px[2] == first); + assert!( + uniform, + "the fit render of a one-pixel stripe pattern should be flat — a 1024 px \ + source in a {RENDER} px viewport steps 8 source columns per output \ + column, so every sample lands on the same phase. It was not: row 0 is \ + {:?}. Either the sampling changed or the fixture no longer says what it \ + is meant to, and the 1:1 test below is worthless until this is true \ + again.", + &row(&pixels, w, 0)[..16.min(w as usize)] + ); +} + +/// TRACES: FR-DSP-5 +/// At 1:1 the render *is* the source region, byte for byte. +/// +/// The requirement's actual content — "at 1:1 and above, the pipeline operates +/// on the visible crop at full source resolution" — stated as the strongest +/// thing that could be true of it: not that the result is sharper, but that +/// output pixel `(x, y)` is source pixel `(WINDOW.0 + x, WINDOW.1 + y)` and +/// nothing has been interpolated, averaged or magnified on the way. +#[test] +fn a_one_to_one_view_reproduces_the_source_pixel_for_pixel() { + let Some(ctx) = ctx() else { return }; + let source = stripes(&ctx); + + let (pixels, w, h) = render(&ctx, &source, one_to_one()); + + // The render target keeps its size while the sampled region shrinks. That + // is the whole mechanism, and a zoom that resized the target would be + // magnification rather than resolution. + assert_eq!( + (w, h), + (RENDER, RENDER), + "zooming must not change the size of the render target" + ); + + let mut mismatches = Vec::new(); + for y in 0..h { + for x in 0..w { + let got = pixels[((y * w + x) * 4) as usize]; + let want = source_byte(WINDOW.0 + x, WINDOW.1 + y); + if got != want && mismatches.len() < 8 { + mismatches.push((x, y, got, want)); + } + } + } + assert!( + mismatches.is_empty(), + "a 1:1 view starting at {WINDOW:?} must reproduce the source exactly. \ + First mismatches (x, y, got, want): {mismatches:?}\n\ + rendered row 0: {:?}\n\ + source row 0: {:?}\n\ + An exactly inverted row means the view's *offset* was dropped while its \ + width was honoured; a flat row means the view was ignored altogether \ + and the pipeline is still rendering the proxy.", + &row(&pixels, w, 0)[..12], + (0..12) + .map(|x| source_byte(WINDOW.0 + x, WINDOW.1)) + .collect::>(), + ); +} + +/// TRACES: FR-DSP-5 +/// An arbitrary zoom between fit and 1:1 samples at the ratio it asks for. +/// +/// FR-DSP-5 says "fit, 1:1, **and arbitrary zoom levels**", and the two tests +/// above only pin the ends. This one takes the middle: a view a quarter of the +/// frame wide, which puts four source pixels behind each output pixel, and +/// checks that the pipeline reports and samples at that ratio rather than +/// snapping to one of the two cases anybody would have special-cased. +/// +/// `render_scale` is asserted alongside the pixels because it is what the +/// neighbourhood stage converts kernel radii through: a zoom that moved the +/// pixels but not the scale would silently sharpen at the wrong radius, which +/// is invisible until somebody compares a preview against an export. +#[test] +fn an_arbitrary_zoom_samples_at_the_ratio_it_asks_for() { + let Some(ctx) = ctx() else { return }; + let source = stripes(&ctx); + + // A quarter of the frame: 256 source columns across 128 output columns. + let view = CropRect { + x: 0.25, + y: 0.25, + width: 0.25, + height: 0.25, + }; + + let mut graph = EditGraph::default_chain(); + graph.framing_mut().set_view(view); + + // The region on screen is 256 source pixels wide, rendered into 128, so the + // ratio is one render pixel per two source pixels. + let scale = graph.render_scale((SOURCE, SOURCE), (RENDER, RENDER)); + assert_eq!(scale.full_size(), (256, 256)); + assert!( + (scale.ratio() - 0.5).abs() < 1e-6, + "ratio {}", + scale.ratio() + ); + + let (pixels, w, _) = render(&ctx, &source, view); + + // Where output column `x` reads from, worked through rather than asserted + // from a previous run — a test that recomputed this with the shader's own + // expression would agree with a bug in it. + // + // uv = 0.25 + (x + 0.5) / 128 * 0.25 = (128 + x + 0.5) / 512 + // col = floor(uv * 1024) = floor(256 + 2x + 1.0) = 257 + 2x + // + // Odd for every `x`, so this zoom lands flat — as the fit render does, and + // for the same reason. **The 1.0 is the interesting part.** At a two-to-one + // downsample an output pixel's centre falls exactly on the boundary between + // the two source pixels it covers, and truncation takes the right-hand one. + // That is nearest-neighbour behaving correctly and not an off-by-one; a + // reader checking this file by hand will get 256 on the first attempt, so + // it is written out. + const SAMPLED: u32 = 257; + + let first = pixels[0]; + assert!( + pixels.chunks_exact(4).all(|px| px[0] == first), + "a two-to-one zoom steps two source columns per output column, so every \ + sample has the same parity and the frame should be flat. row 0: {:?}", + &row(&pixels, w, 0)[..12] + ); + // And it is flat on the *right* phase. A pipeline that had ignored the view + // entirely would also be flat — but its `ratio` would not be 0.5, which the + // assertion above already rules out — and one that had snapped to 1:1 would + // show the stripes instead. Between them, only sampling the window the view + // actually asked for produces this. + assert_eq!( + first, + source_byte(SAMPLED, SAMPLED), + "a view starting a quarter of the way across a {SOURCE} px source should \ + sample from source column {SAMPLED}" + ); +}