docs/ had 26 developer documents flat beside the manual, and the two audiences are very differently sized: most readers want the manual and the gesture reference, a few want the register, the designs and the measurements. The manual and gestures.md stay at the top; everything for someone changing the code moves to docs/dev/, and the two documents that name their own successors — the v0.1 milestone and the UI-refinement plan — go to docs/dev/archive/ rather than being deleted, since both are still cited. docs/README.md is the index, users first. Every reference follows: code comments, Cargo manifests, the workflows, the pre-commit hook, the bench and traceability tools (which locate the repo root by docs/dev/requirements.md now), packaging, the Docker READMEs, CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level deeper and is regenerated. Links out of the moved documents into the tree gain a level; a link checker over every Markdown file finds none broken.
292 lines
12 KiB
Rust
292 lines
12 KiB
Rust
//! TRACES: FR-DSP-5 | R5
|
|
//! 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/dev/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<GpuContext> {
|
|
// 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<u8> = (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<u8>, 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<u8> {
|
|
(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::<Vec<_>>(),
|
|
);
|
|
}
|
|
|
|
/// 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}"
|
|
);
|
|
}
|