Merge branch 'worktree-agent-a75dc051d9bf691de' into integration
# Conflicts: # docs/traceability.md
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
//! The neighbourhood stage, end to end on a real device.
|
||||
//!
|
||||
//! `dr-pipeline`'s own tests assert what the composer *generates*; nothing
|
||||
//! there can tell whether the WGSL compiles, whether pass two is handed what
|
||||
//! pass one wrote, or whether the output transform happens exactly once. Those
|
||||
//! are questions only a GPU answers, and they are the ones that decide whether
|
||||
//! a future sharpening operation works or draws nonsense.
|
||||
//!
|
||||
//! The consumer is `detail_probe`, a separable box blur that is not a develop
|
||||
//! operation (see `dr_pipeline::detail::probe`). A box blur is used because its
|
||||
//! answer is known in closed form: over a step edge it produces a ramp exactly
|
||||
//! `2r + 1` pixels wide with a computable value at every step, so these tests
|
||||
//! assert **pixels** rather than "something changed".
|
||||
//!
|
||||
//! # Reading the expected values
|
||||
//!
|
||||
//! The source is uploaded through `DemosaicedImage::from_rgba8`, which flags it
|
||||
//! non-linear, so the generated shader decodes sRGB before any operation runs.
|
||||
//! A black/white step therefore reaches the detail stage as linear 0.0 and 1.0
|
||||
//! exactly. The blur averages those, and the last detail pass re-encodes. So
|
||||
//! the expected byte at a column is `srgb_encode(white_taps / (2r + 1))`, with
|
||||
//! taps clamped at the border — which is exactly what `expected_profile`
|
||||
//! computes.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::descriptor::{OpId, ParamId};
|
||||
use dr_pipeline::detail::probe::BoxBlur;
|
||||
use dr_pipeline::{Affects, EditGraph, OutputMode};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
const PROBE: OpId = OpId("detail_probe");
|
||||
const RADIUS: ParamId = ParamId("radius");
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A vertical step edge: black to the left of `size / 2`, white to the right.
|
||||
///
|
||||
/// The one image whose blur is worth checking by hand. A gradient would
|
||||
/// average to itself and hide a kernel that is off by one; a step does not.
|
||||
fn step_edge(ctx: &GpuContext, size: u32) -> DemosaicedImage {
|
||||
let data: Vec<u8> = (0..size * size)
|
||||
.flat_map(|i| {
|
||||
let x = i % size;
|
||||
let v = if x < size / 2 { 0u8 } else { 255 };
|
||||
[v, v, v, 255]
|
||||
})
|
||||
.collect();
|
||||
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
|
||||
}
|
||||
|
||||
/// One row of the rendered image, red channel, as bytes.
|
||||
fn row(pixels: &[u8], size: u32, y: u32) -> Vec<u8> {
|
||||
(0..size)
|
||||
.map(|x| pixels[((y * size + x) * 4) as usize])
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn srgb_encode(v: f32) -> u8 {
|
||||
let e = if v <= 0.003_130_8 {
|
||||
v * 12.92
|
||||
} else {
|
||||
1.055 * v.powf(1.0 / 2.4) - 0.055
|
||||
};
|
||||
(e.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
/// What a separable box blur of radius `r` must produce over the step edge.
|
||||
fn expected_profile(size: u32, r: i32) -> Vec<u8> {
|
||||
let last = size as i32 - 1;
|
||||
let edge = (size / 2) as i32;
|
||||
(0..size as i32)
|
||||
.map(|x| {
|
||||
let white = (-r..=r)
|
||||
.filter(|i| (x + i).clamp(0, last) >= edge)
|
||||
.count();
|
||||
srgb_encode(white as f32 / (2 * r + 1) as f32)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render one graph, with its detail stage, and read the pixels back.
|
||||
///
|
||||
/// This is the whole calling convention a frontend has to adopt, in five
|
||||
/// lines: compose both halves from one graph at one output space, ask the
|
||||
/// graph for the scale, and pass the invalidation key through.
|
||||
fn render(
|
||||
ctx: &GpuContext,
|
||||
pass: &mut AdjustPass,
|
||||
graph: &EditGraph,
|
||||
source: &DemosaicedImage,
|
||||
out: u32,
|
||||
) -> Vec<u8> {
|
||||
let _ = ctx;
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale(source.size(), (out, out));
|
||||
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||
.expect("render");
|
||||
pass.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_neighbourhood_pass_produces_the_pixels_it_should() {
|
||||
// The whole seam, proved once: an operation that reads its neighbours runs
|
||||
// on the GPU, and the values it writes are the ones a box blur is defined
|
||||
// to write. Not "the edge got softer" — every byte of the ramp.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 64;
|
||||
|
||||
let mut graph = EditGraph::with_detail_probe();
|
||||
graph.set_param(PROBE, RADIUS, 0.0625); // 4 px on a 64 px edge
|
||||
let source = step_edge(&ctx, SIZE);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let pixels = render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||
let got = row(&pixels, SIZE, SIZE / 2);
|
||||
|
||||
let r = BoxBlur::with_radius(0.0625).kernel(graph.render_scale((SIZE, SIZE), (SIZE, SIZE)));
|
||||
assert_eq!(r, 4, "5/64 of the shorter edge, rounded");
|
||||
let want = expected_profile(SIZE, r as i32);
|
||||
|
||||
for (x, (a, b)) in got.iter().zip(&want).enumerate() {
|
||||
assert!(
|
||||
a.abs_diff(*b) <= 2,
|
||||
"column {x}: got {a}, expected {b}\ngot: {got:?}\nwant: {want:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_second_pass_reads_what_the_first_one_wrote() {
|
||||
// The ping-pong, stated as a property of the picture rather than of the
|
||||
// plumbing. A separable blur is symmetric: applied to a *horizontal* step
|
||||
// it must also soften a horizontal edge in the other direction. Wire the
|
||||
// second pass to read the original again and the vertical smear vanishes,
|
||||
// which is exactly what this sees.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 64;
|
||||
|
||||
// A quadrant image: the vertical pass has something to do only if it is
|
||||
// reading the horizontal pass's output rather than the source.
|
||||
let data: Vec<u8> = (0..SIZE * SIZE)
|
||||
.flat_map(|i| {
|
||||
let (x, y) = (i % SIZE, i / SIZE);
|
||||
let v = if (x < SIZE / 2) == (y < SIZE / 2) {
|
||||
0u8
|
||||
} else {
|
||||
255
|
||||
};
|
||||
[v, v, v, 255]
|
||||
})
|
||||
.collect();
|
||||
let source = DemosaicedImage::from_rgba8(&ctx, &data, SIZE, SIZE).expect("upload");
|
||||
|
||||
let mut graph = EditGraph::with_detail_probe();
|
||||
graph.set_param(PROBE, RADIUS, 0.0625);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let pixels = render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||
|
||||
// Two separable passes compose into a true two-dimensional box average —
|
||||
// but only if the second reads the first's output. Computed in closed form
|
||||
// over the same window the shader uses, so this is an assertion about
|
||||
// values rather than about direction.
|
||||
let r = 4i32;
|
||||
let last = SIZE as i32 - 1;
|
||||
let half = (SIZE / 2) as i32;
|
||||
let quadrant_is_black = |x: i32, y: i32| (x < half) == (y < half);
|
||||
let want: Vec<u8> = (0..SIZE as i32)
|
||||
.map(|x| {
|
||||
let y = half;
|
||||
let mut white = 0usize;
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let (sx, sy) = ((x + dx).clamp(0, last), (y + dy).clamp(0, last));
|
||||
if !quadrant_is_black(sx, sy) {
|
||||
white += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
srgb_encode(white as f32 / ((2 * r + 1) * (2 * r + 1)) as f32)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let got = row(&pixels, SIZE, SIZE / 2);
|
||||
for (x, (a, b)) in got.iter().zip(&want).enumerate() {
|
||||
// A second pass reading the *source* instead would leave column 20 at
|
||||
// 255 where a real 2D average puts it near 196 — so the failure this
|
||||
// catches is loud, not marginal.
|
||||
assert!(
|
||||
a.abs_diff(*b) <= 2,
|
||||
"column {x}: got {a}, expected {b}\ngot: {got:?}\nwant: {want:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inactive_detail_operation_costs_exactly_nothing() {
|
||||
// The rule the whole pipeline rests on, carried into this stage. A
|
||||
// photograph with no sharpening must render through the single fused
|
||||
// dispatch it always did, allocate no intermediate, and — the part worth
|
||||
// checking — produce byte-identical pixels to a graph that has no
|
||||
// neighbourhood operation in it at all.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 32;
|
||||
let source = step_edge(&ctx, SIZE);
|
||||
|
||||
let probe = EditGraph::with_detail_probe();
|
||||
assert_eq!(
|
||||
probe.compose_for(ColourSpace::Srgb).output_mode,
|
||||
OutputMode::Encoded,
|
||||
"a neutral detail operation must not change how the fused pass ends"
|
||||
);
|
||||
|
||||
let mut with_probe = AdjustPass::new(&ctx);
|
||||
let a = render(&ctx, &mut with_probe, &probe, &source, SIZE);
|
||||
assert_eq!(with_probe.colour_dispatches(), 1);
|
||||
assert_eq!(with_probe.detail_dispatches(), 0);
|
||||
assert_eq!(with_probe.detail_allocations(), 0, "nothing was allocated");
|
||||
|
||||
let plain = EditGraph::default_chain();
|
||||
let mut without = AdjustPass::new(&ctx);
|
||||
let b = render(&ctx, &mut without, &plain, &source, SIZE);
|
||||
|
||||
assert_eq!(a, b, "an operation at its defaults must not touch the image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_detail_parameter_does_not_re_run_the_colour_pass() {
|
||||
// TRACES: FR-DEV-3d, and the operational point of `Affects::Detail`.
|
||||
//
|
||||
// Invisible in the output by construction — the picture is meant to be
|
||||
// whatever the sharpening says whichever way it was computed — so a
|
||||
// dispatch counter is the only thing that can see it. Without this, the
|
||||
// whole invalidation story is a comment.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 64;
|
||||
let source = step_edge(&ctx, SIZE);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let mut graph = EditGraph::with_detail_probe();
|
||||
graph.set_param(PROBE, RADIUS, 0.0625);
|
||||
render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||
assert_eq!(pass.colour_dispatches(), 1);
|
||||
assert_eq!(pass.detail_dispatches(), 2, "a separable blur is two passes");
|
||||
|
||||
// Drag the sharpening slider. The colour chain is untouched, so the linear
|
||||
// intermediate it wrote is still exactly right.
|
||||
graph.set_param(PROBE, RADIUS, 0.09);
|
||||
render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||
assert_eq!(
|
||||
pass.colour_dispatches(),
|
||||
1,
|
||||
"the fused colour pass re-ran for a change it does not depend on"
|
||||
);
|
||||
assert_eq!(pass.detail_dispatches(), 4);
|
||||
|
||||
// Now move exposure. The detail stage reads what the colour pass wrote, so
|
||||
// this one genuinely does have to re-run both — anything else would show a
|
||||
// sharpened version of the previous exposure.
|
||||
graph.set_param(
|
||||
dr_pipeline::ops::exposure::ID,
|
||||
dr_pipeline::ops::exposure::EXPOSURE,
|
||||
1.0,
|
||||
);
|
||||
render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||
assert_eq!(pass.colour_dispatches(), 2);
|
||||
assert_eq!(pass.detail_dispatches(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_slider_recompiles_nothing_and_reallocates_nothing() {
|
||||
// The two costs that are ruinous per frame and invisible in the output.
|
||||
// Both are the same rule the rest of the crate follows: values ride in a
|
||||
// uniform buffer, and textures are reallocated on resize rather than on
|
||||
// change.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 48;
|
||||
let source = step_edge(&ctx, SIZE);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let mut graph = EditGraph::with_detail_probe();
|
||||
|
||||
graph.set_param(PROBE, RADIUS, 0.05);
|
||||
render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||
let pipelines = pass.cached_detail_pipelines();
|
||||
let allocations = pass.detail_allocations();
|
||||
assert_eq!(pipelines, 2, "one per pass of the separable blur");
|
||||
assert_eq!(allocations, 2, "the colour result, and one hand-off");
|
||||
|
||||
for radius in [0.06, 0.07, 0.08, 0.09] {
|
||||
graph.set_param(PROBE, RADIUS, radius);
|
||||
render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||
}
|
||||
assert_eq!(
|
||||
pass.cached_detail_pipelines(),
|
||||
pipelines,
|
||||
"a radius is a uniform, not a shader"
|
||||
);
|
||||
assert_eq!(
|
||||
pass.detail_allocations(),
|
||||
allocations,
|
||||
"a steady viewport must allocate nothing"
|
||||
);
|
||||
|
||||
// A resize is the one thing that legitimately reallocates.
|
||||
render(&ctx, &mut pass, &graph, &source, SIZE / 2);
|
||||
assert!(pass.detail_allocations() > allocations);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proxy_and_an_export_agree_about_where_the_effect_lands() {
|
||||
// TRACES: FR-DSP-1 — the subtle one, and the reason `RenderScale` exists.
|
||||
//
|
||||
// The same edit, rendered at two resolutions. A radius stored as a
|
||||
// fraction of the shorter edge must produce a transition covering the same
|
||||
// *proportion* of the frame at both, or a sharpening tuned on screen is a
|
||||
// different sharpening in the exported file.
|
||||
//
|
||||
// The tolerance is a pixel's worth at the smaller size, because the kernel
|
||||
// is an integer count and 6.25% of 64 pixels is not 6.25% of 128. That
|
||||
// rounding is the whole of the error, and it is bounded by half a render
|
||||
// pixel by construction.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SOURCE: u32 = 128;
|
||||
let source = step_edge(&ctx, SOURCE);
|
||||
|
||||
let mut graph = EditGraph::with_detail_probe();
|
||||
graph.set_param(PROBE, RADIUS, 0.0625);
|
||||
|
||||
let spread = |out: u32| -> f32 {
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let pixels = render(&ctx, &mut pass, &graph, &source, out);
|
||||
let line = row(&pixels, out, out / 2);
|
||||
// Where the ramp starts and ends, in fractions of the frame.
|
||||
let first = line.iter().position(|&v| v > 4).expect("a ramp") as f32;
|
||||
let last = line.iter().rposition(|&v| v < 251).expect("a ramp") as f32;
|
||||
(last - first) / out as f32
|
||||
};
|
||||
|
||||
let proxy = spread(SOURCE / 2);
|
||||
let export = spread(SOURCE);
|
||||
assert!(
|
||||
(proxy - export).abs() < 0.03,
|
||||
"the effect covers {proxy:.3} of the proxy and {export:.3} of the \
|
||||
export; a radius tuned on screen must land in the file"
|
||||
);
|
||||
// And it is a real transition in both, not two flat images agreeing.
|
||||
assert!(proxy > 0.08 && export > 0.08, "{proxy:.3} / {export:.3}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_halves_of_one_composition_must_be_dispatched_together() {
|
||||
// The failure this guards is a bad one to debug: a shader composed to hand
|
||||
// on linear working values, bound to an rgba8 storage texture. wgpu
|
||||
// rejects it, but the message is about a bind group, a long way from the
|
||||
// caller that composed one half of an edit and rendered the other.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 32;
|
||||
let source = step_edge(&ctx, SIZE);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let mut graph = EditGraph::with_detail_probe();
|
||||
graph.set_param(PROBE, RADIUS, 0.0625);
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
assert_eq!(shader.output_mode, OutputMode::LinearWorking);
|
||||
|
||||
let err = pass
|
||||
.render_masked(&source, &shader, SIZE, SIZE, None)
|
||||
.expect_err("a linear-working shader has no business in the plain path");
|
||||
assert!(
|
||||
format!("{err}").contains("render_detailed"),
|
||||
"the error should name the way out: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_chain_falls_through_to_the_ordinary_render() {
|
||||
// A caller that always goes through `render_detailed` — which is what a
|
||||
// frontend will do, since it does not want to branch on whether the user
|
||||
// has sharpening on — must pay exactly nothing for the edits that have
|
||||
// none.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SIZE: u32 = 32;
|
||||
let source = step_edge(&ctx, SIZE);
|
||||
let graph = EditGraph::default_chain();
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE));
|
||||
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
||||
assert!(detail.is_empty());
|
||||
|
||||
pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0)
|
||||
.expect("render");
|
||||
assert_eq!(pass.colour_dispatches(), 1);
|
||||
assert_eq!(pass.detail_dispatches(), 0);
|
||||
assert_eq!(pass.detail_allocations(), 0);
|
||||
}
|
||||
Reference in New Issue
Block a user