Files
DarkRoom/core/dr-gpu/tests/capture_sharpen.rs
T
dtourolle 8ea427de3c WIP: capture sharpening
Checkpoint committed by the coordinator, not by the authoring agent: the
session hit its API limit mid-task and left this work uncommitted. Committed
so it survives, NOT because it is finished - expect failing tests and
half-applied changes. The agent resumes from here.
2026-08-22 19:01:18 +02:00

504 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);
// Four source pixels, so that even the half-size proxy has a two-pixel
// sigma and resolves the radius — the honest cut-off is tested in
// `dr-pipeline`, and this test is about the case where both renders draw.
let graph = sharpened(100.0, 4.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) % 2 == 0 { 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");
}