Files
DarkRoom/core/dr-gpu/tests/capture_sharpen.rs
T
dtourolleandClaude Opus 5 e929e9917e
Build and test / Desktop (Linux) (push) Failing after 9m10s
Build and test / Layer separation (push) Successful in 27s
Traceability / Requirement traces (push) Failing after 24s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 22m15s
Clear the clippy warnings that were failing CI before this branch
Nothing here is film simulation. These are lints that fail master today,
under the -D warnings CI runs with, mostly from a toolchain that learned
new ones rather than from anybody's code -- is_multiple_of and the
derivable Default did not exist as lints when this was written.

They are fixed rather than allowed, and by hand rather than by trusting
cargo clippy --fix wholesale: its automatic pass split a derive in two and
left a stray blank line, which is the sort of thing that is correct and
still wrong to commit.

The four that needed a decision rather than a rewrite:

  - the distance transform's inner loop writes through its iterator now.
     stays, because it is the position the parabola is evaluated at as
    well as the index it is written to -- the lint is about the write.
  -  and  take  by value. Their receiver is
    Copy, so this is the same machine code and the honest signature.
  - the export path's return type is five levels deep and now has a name,
    plus a line saying why the Option wraps the Result: None is
    cancellation, which is not a failure and has no error to report.
  - a test fills a range instead of looping over one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:34:38 +02:00

508 lines
20 KiB
Rust

//! Capture sharpening, end to end on a real device.
//!
//! `dr-pipeline`'s own tests assert what the composer *generates* — the kernel
//! extent, the uniforms, which pass encodes. None of them can tell whether the
//! generated WGSL compiles, whether the second pass is handed what the first
//! one wrote, or whether the result is sharpening rather than a shader that
//! silently produced the input again. Those are questions only a GPU answers.
//!
//! # 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
//! and a black/white step reaches the detail stage as linear 0.0 and 1.0
//! exactly. The last detail pass re-encodes. So a byte read back here is
//! `srgb_encode(whatever the kernel produced in linear light)`, and an
//! overshoot — the bright fringe an unsharp mask puts on the light side of an
//! edge — cannot show above 255 on the white side of a full-scale step, and the
//! undershoot on the dark side of one clips to black long before the halo has
//! been drawn. The tests therefore use a **grey** step, from byte 90 to byte
//! 150, which at 100% amount leaves the whole halo inside the representable
//! range at both ends. Every expected value below is arithmetic on that step,
//! not a number read off a previous run.
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::ops::capture_sharpen::{AMOUNT, ID, RADIUS, THRESHOLD};
use dr_pipeline::{Affects, EditGraph};
use dr_types::ColourSpace;
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 from `low` to `high`, changing at the middle column.
///
/// The one image whose sharpening is worth checking by hand: an unsharp mask
/// must darken the last few columns before the step and brighten the first few
/// after it, and leave everything further away exactly where it was. A gradient
/// would blur to itself and hide a kernel that does nothing at all.
fn step_edge(ctx: &GpuContext, size: u32, low: u8, high: u8) -> DemosaicedImage {
let data: Vec<u8> = (0..size * size)
.flat_map(|i| {
let v = if (i % size) < size / 2 { low } else { high };
[v, v, v, 255]
})
.collect();
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
}
/// A flat field of one value.
fn flat(ctx: &GpuContext, size: u32, value: u8) -> DemosaicedImage {
let data: Vec<u8> = (0..size * size)
.flat_map(|_| [value, value, value, 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], width: u32, y: u32) -> Vec<u8> {
(0..width)
.map(|x| pixels[((y * width + x) * 4) as usize])
.collect()
}
/// The develop chain with capture sharpening set.
fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph {
let mut graph = EditGraph::default_chain();
graph.set_param(ID, AMOUNT, amount);
graph.set_param(ID, RADIUS, radius);
graph.set_param(ID, THRESHOLD, threshold);
graph
}
/// Render one graph, with its detail stage, and read the pixels back.
///
/// The whole calling convention a frontend adopts, 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(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
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 an_unsharp_mask_puts_a_halo_on_the_edge_and_leaves_the_rest_alone() {
// What sharpening *is*, asserted as pixels rather than as "something
// changed": an undershoot immediately before the transition, an overshoot
// immediately after it, the step itself steeper than it was, and the flat
// ground at either end untouched. A shader that ran the blur and forgot to
// add the difference back would pass a "the image changed" test and fail
// every one of these.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 64;
let source = step_edge(&ctx, SIZE, 90, 150);
let plain = render(
&mut AdjustPass::new(&ctx),
&EditGraph::default_chain(),
&source,
SIZE,
);
let sharp = render(
&mut AdjustPass::new(&ctx),
&sharpened(100.0, 2.0, 0.0),
&source,
SIZE,
);
let before = row(&plain, SIZE, SIZE / 2);
let after = row(&sharp, SIZE, SIZE / 2);
let edge = (SIZE / 2) as usize;
// The dark side of the transition is driven darker and the light side
// lighter — the halo. Two pixels in, where a two-pixel-sigma kernel has
// most of its response.
assert!(
after[edge - 2] < before[edge - 2],
"the dark side of the edge should be pushed down: {} -> {}",
before[edge - 2],
after[edge - 2]
);
assert!(
after[edge + 1] > before[edge + 1],
"the light side of the edge should be pushed up: {} -> {}",
before[edge + 1],
after[edge + 1]
);
// And the transition really is steeper across the same two columns.
let slope = |r: &[u8]| r[edge] as i32 - r[edge - 1] as i32;
assert!(
slope(&after) > slope(&before),
"sharpening must steepen the edge: {} -> {}",
slope(&before),
slope(&after)
);
// Far from the edge there is nothing to sharpen, so nothing may move. This
// is the property a kernel that forgot to normalise its weights breaks,
// and it breaks it as a brightness shift over the whole photograph.
for x in [0usize, 4, 8, SIZE as usize - 1] {
assert!(
after[x].abs_diff(before[x]) <= 1,
"column {x} is flat ground and moved: {} -> {}",
before[x],
after[x]
);
}
}
#[test]
fn a_flat_field_survives_any_amount_of_sharpening() {
// The kernel sums to one — `(1 + a)` of the pixel minus `a` of its blur —
// so a sky must come through bit for bit however far the slider is pushed.
// The border is the part that is easy to get wrong: `tap` clamps, and a
// kernel that normalised by an analytic integral instead of by the weights
// it actually summed would draw a band around the whole frame.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 48;
let source = flat(&ctx, SIZE, 128);
let plain = render(
&mut AdjustPass::new(&ctx),
&EditGraph::default_chain(),
&source,
SIZE,
);
let sharp = render(
&mut AdjustPass::new(&ctx),
&sharpened(100.0, 3.0, 0.0),
&source,
SIZE,
);
for (i, (a, b)) in sharp.iter().zip(&plain).enumerate() {
assert!(
a.abs_diff(*b) <= 1,
"pixel {} of a flat field moved: {b} -> {a}",
i / 4
);
}
}
#[test]
fn a_proxy_and_an_export_sharpen_the_same_photograph() {
// TRACES: FR-DSP-1 — the decision this operation is most likely to get
// wrong, and the one that is invisible until an export comes back wrong.
//
// The same edit, rendered at two resolutions of one source. The radius is
// in source pixels, so the halo must cover the same *proportion of the
// picture* at both: a fringe four source pixels wide is four source pixels
// wide whether it was drawn on a half-size proxy or at full size.
//
// Read the radius as render pixels instead and the proxy's halo would be
// twice as wide relative to the frame and roughly twice as strong, so what
// was tuned on screen would not be what landed in the file. That is the
// failure this catches, and it is a large one: the widths would differ by a
// factor of two, not by a rounding.
let Some(ctx) = ctx() else { return };
const SOURCE: u32 = 128;
let source = step_edge(&ctx, SOURCE, 90, 150);
// The widest radius the slider offers, so that even the half-size proxy
// has a 1.5-pixel sigma and resolves it — the honest cut-off is tested in
// `dr-pipeline`, and this test is about the case where both renders draw.
// Asking for more would be asking for a photograph nobody can produce:
// `EditGraph::set_param` clamps to the descriptor on the way in.
let graph = sharpened(100.0, 3.0, 0.0);
// The halo, measured against the same edit with no sharpening at the same
// size: how far from the transition the picture is still disturbed, as a
// fraction of the frame, and how much deviation the halo carries in total.
let measure = |out: u32| -> (f32, f32) {
let plain = render(
&mut AdjustPass::new(&ctx),
&EditGraph::default_chain(),
&source,
out,
);
let sharp = render(&mut AdjustPass::new(&ctx), &graph, &source, out);
let (a, b) = (row(&plain, out, out / 2), row(&sharp, out, out / 2));
let disturbed: Vec<usize> = (0..out as usize)
.filter(|&x| b[x].abs_diff(a[x]) > 3)
.collect();
let first = *disturbed.first().expect("a halo");
let last = *disturbed.last().expect("a halo");
// The halo's strength as an *area* — the sum of the deviations, scaled
// by the width of a render pixel — rather than as its peak. A peak is
// one sample of a smooth curve, and the two renders do not sample it at
// the same place: the pixel next to the transition sits half a render
// pixel from it, which is half a source pixel at export and a whole one
// on the proxy, so their peaks would legitimately differ by more than
// the property under test. An integral over the same curve does not
// care where the samples fell.
let area: f32 = (0..out as usize)
.map(|x| b[x].abs_diff(a[x]) as f32)
.sum::<f32>()
/ out as f32;
((last - first) as f32 / out as f32, area)
};
let (proxy_width, proxy_area) = measure(SOURCE / 2);
let (export_width, export_area) = measure(SOURCE);
assert!(
(proxy_width - export_width).abs() < 0.06,
"the halo covers {proxy_width:.3} of the proxy and {export_width:.3} \
of the export; a radius tuned on screen must land in the file"
);
// The strength has to agree too. A viewport-scaled kernel would not only
// be wider on the proxy, it would push the fringe further, because a wider
// blur takes more away for the high-pass to add back — so the areas would
// differ by considerably more than the sampling slack allowed here.
let ratio = proxy_area / export_area;
assert!(
(0.75..1.35).contains(&ratio),
"the halo carries {proxy_area:.2} on the proxy and {export_area:.2} at \
export, a ratio of {ratio:.2}"
);
// And both are a real halo rather than two flat images agreeing.
assert!(
proxy_width > 0.05 && export_width > 0.05,
"{proxy_width:.3} / {export_width:.3}"
);
assert!(
proxy_area > 1.0 && export_area > 1.0,
"{proxy_area} / {export_area}"
);
}
#[test]
fn the_threshold_leaves_shallow_modulation_where_it_found_it() {
// What the threshold is for: sensor noise is shallow, and sharpening it is
// the fastest way to make a clean frame look worse. Two images, one with a
// strong edge and one with a shallow ripple, through the same gate — the
// edge must still sharpen and the ripple must not.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 64;
// A four-code ripple: about 4% local contrast at this level, which is the
// order of magnitude read noise reaches on a well-exposed frame — and well
// under the 12.5% at which the gate below starts letting detail through.
let ripple: Vec<u8> = (0..SIZE * SIZE)
.flat_map(|i| {
let v = if (i % SIZE).is_multiple_of(2) {
128u8
} else {
132
};
[v, v, v, 255]
})
.collect();
let ripple = DemosaicedImage::from_rgba8(&ctx, &ripple, SIZE, SIZE).expect("upload");
let edge = step_edge(&ctx, SIZE, 90, 150);
let gated = sharpened(100.0, 1.0, 1.0);
let ungated = sharpened(100.0, 1.0, 0.0);
let spread = |graph: &EditGraph, source: &DemosaicedImage| -> u8 {
let pixels = render(&mut AdjustPass::new(&ctx), graph, source, SIZE);
let line = row(&pixels, SIZE, SIZE / 2);
// Peak-to-peak over the middle of the row, away from the border.
let window = &line[8..24];
window.iter().max().unwrap() - window.iter().min().unwrap()
};
let ripple_open = spread(&ungated, &ripple);
let ripple_gated = spread(&gated, &ripple);
assert!(
ripple_gated < ripple_open,
"the gate must hold shallow modulation back: {ripple_open} -> \
{ripple_gated}"
);
// The edge is deep modulation and must come through the same gate
// sharpened — a threshold that flattens everything is not a threshold.
let plain_edge = {
let pixels = render(
&mut AdjustPass::new(&ctx),
&EditGraph::default_chain(),
&edge,
SIZE,
);
row(&pixels, SIZE, SIZE / 2)
};
let gated_edge = {
let pixels = render(&mut AdjustPass::new(&ctx), &gated, &edge, SIZE);
row(&pixels, SIZE, SIZE / 2)
};
let mid = (SIZE / 2) as usize;
assert!(
gated_edge[mid - 1] < plain_edge[mid - 1],
"a real edge must still sharpen through the gate: {} -> {}",
plain_edge[mid - 1],
gated_edge[mid - 1]
);
}
#[test]
fn sharpening_an_edge_does_not_change_its_colour() {
// The reason the high-pass is applied as a gain on the three channels
// rather than as an offset. An offset moves a saturated colour towards
// grey as it brightens it, so a sharpened red roof gets a pink fringe —
// which reads as chromatic aberration and gets blamed on the lens.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 64;
// A step between two saturated reds of different brightness: the ratios
// between the channels are the colour, and they must survive the halo.
let data: Vec<u8> = (0..SIZE * SIZE)
.flat_map(|i| {
if (i % SIZE) < SIZE / 2 {
[80u8, 30, 30, 255]
} else {
[200, 75, 75, 255]
}
})
.collect();
let source = DemosaicedImage::from_rgba8(&ctx, &data, SIZE, SIZE).expect("upload");
let pixels = render(
&mut AdjustPass::new(&ctx),
&sharpened(60.0, 2.0, 0.0),
&source,
SIZE,
);
// Sampled inside the halo, where an additive sharpener would have washed
// the colour out most.
let y = SIZE / 2;
for x in [SIZE / 2 - 2, SIZE / 2 + 1] {
let i = ((y * SIZE + x) * 4) as usize;
let (r, g, b) = (pixels[i] as f32, pixels[i + 1] as f32, pixels[i + 2] as f32);
assert!(r > g && r > b, "the fringe lost its hue at column {x}");
// Green and blue started equal and must stay equal: an offset would
// keep them equal too, but the *ratio* to red is what moves, and this
// is the assertion that it did not.
let saturation = (r - g) / r;
assert!(
saturation > 0.55,
"column {x} washed out: rgb {r} {g} {b}, saturation {saturation:.3}"
);
}
}
#[test]
fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
// The two costs that are ruinous per frame and invisible in the output.
// A sharpening slider is dragged continuously, so this is the difference
// between a control that tracks the mouse and one that stutters.
let Some(ctx) = ctx() else { return };
const SIZE: u32 = 48;
let source = step_edge(&ctx, SIZE, 90, 150);
let mut pass = AdjustPass::new(&ctx);
let mut graph = sharpened(40.0, 1.0, 0.0);
render(&mut pass, &graph, &source, SIZE);
let pipelines = pass.cached_detail_pipelines();
let allocations = pass.detail_allocations();
assert_eq!(pipelines, 2, "one per axis of the separable mask");
assert_eq!(allocations, 2, "the colour result, and one hand-off");
assert_eq!(pass.detail_dispatches(), 2);
assert_eq!(pass.colour_dispatches(), 1);
for amount in [50.0, 60.0, 70.0, 80.0] {
graph.set_param(ID, AMOUNT, amount);
render(&mut pass, &graph, &source, SIZE);
}
assert_eq!(
pass.cached_detail_pipelines(),
pipelines,
"an amount is a uniform, not a shader"
);
assert_eq!(
pass.detail_allocations(),
allocations,
"a steady viewport must allocate nothing"
);
// TRACES: FR-DEV-3d — and the operational point of `Affects::Detail`:
// sharpening is downstream of every fused operation, so dragging it must
// not re-run them.
assert_eq!(
pass.colour_dispatches(),
1,
"the fused colour pass re-ran for a change it does not depend on"
);
// The radius is also only a uniform, even though it changes the kernel
// extent — the loop bound is read from the uniform block rather than
// baked into the source, which is what keeps a drag off the compiler.
graph.set_param(ID, RADIUS, 2.5);
render(&mut pass, &graph, &source, SIZE);
assert_eq!(pass.cached_detail_pipelines(), pipelines);
graph.set_param(ID, THRESHOLD, 0.3);
render(&mut pass, &graph, &source, SIZE);
assert_eq!(pass.cached_detail_pipelines(), pipelines);
}
#[test]
fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() {
// The failure mode that the pass-through exists to prevent, proved on a
// device rather than argued about. With the radius finer than a render
// pixel the operation declines to sharpen — but it is still active, so the
// fused pass has already been composed to hand on unclipped linear values,
// and something must still perform the output transform. An empty chain
// here would not be a soft preview: it would be a hard error out of
// `render_detailed`, on the most ordinary develop view there is.
let Some(ctx) = ctx() else { return };
const SOURCE: u32 = 128;
const RENDER: u32 = 32; // a quarter scale, as a fit view of a large frame
let source = step_edge(&ctx, SOURCE, 90, 150);
let graph = sharpened(100.0, 1.0, 0.0);
let scale = graph.render_scale((SOURCE, SOURCE), (RENDER, RENDER));
assert!(!scale.resolves(1.0), "the premise of this test");
let mut pass = AdjustPass::new(&ctx);
let sharp = render(&mut pass, &graph, &source, RENDER);
assert_eq!(pass.detail_dispatches(), 1, "one pass, and it only encodes");
// And what reaches the screen is the unsharpened picture, not a black
// frame, a linear one, or a guess.
let plain = render(
&mut AdjustPass::new(&ctx),
&EditGraph::default_chain(),
&source,
RENDER,
);
for (i, (a, b)) in sharp.iter().zip(&plain).enumerate() {
assert!(
a.abs_diff(*b) <= 1,
"pixel {} differs from the unsharpened render: {b} -> {a}",
i / 4
);
}
}
#[test]
fn the_operation_is_reachable_by_the_ids_a_frontend_will_use() {
// FR-DEV-3c: adding an operation needs no UI change, which is only true if
// the panel can find it through the capability list. A typo between the
// declaration's `id:` and the descriptor's would place it in the chain
// under one name and address it under another.
let graph = EditGraph::default_chain();
let cap = graph
.capabilities()
.into_iter()
.find(|c| c.id == ID)
.expect("capture sharpening is in the default chain");
let names: Vec<ParamId> = cap.params.iter().map(|p| p.id).collect();
assert_eq!(names, vec![AMOUNT, RADIUS, THRESHOLD]);
assert!(!cap.active, "a fresh chain is not sharpening anything");
}