Files
dtourolle c07f81edcb Run the view transform after the detail stage, in a pass of its own
The fused pass stops at "linear working values" when a sharpener, a
blur or a repair follows, and the detail passes convolve what it hands
on. Until now it handed on the rendering: the base curve, and since the
last commit the view transform, ran before the store. So every kernel
worked on display-referred values while its comments promised the
opposite — D19's second finding.

A fused pass composed for a detail stage now stops before the view
transform, and carries a second shader, `ComposedShader::view`, composed
from the same inputs. It runs the same prologue, for the positions a
fragment reads (a film's grain seeds from `source_px`) and the corners
it blacks out, takes its colour from the detail stage's result bound
where the sample cache would be, and runs the view transform, the
output transform and the mask reveal. `render_detailed` dispatches it
after the last detail pass, in the same encoder.

So no detail pass encodes any more. Every pass writes an intermediate,
the last one included, which retires three things that existed only to
make the last pass encode: `writes_output` and the runner's second
layout, the body-less resolve pass for an active kernel with nothing to
draw at this scale, and capture sharpening's pass-through, which now
emits no pass at all. An empty chain is a whole render: the view pass
reads the fused result directly. The detail stage no longer takes an
output space either, so `compose_detail_for` folds into
`compose_detail` and the space is named once, on the fused half.

The cost is one full-render read and write per frame when a detail
stage exists, and a third intermediate for a one-pass chain.
2026-09-27 16:52:54 -04:00

516 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(scale.full_size(), scale.render_size());
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, 3, "the colour result, and the ping-pong pair");
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() {
// 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. Since D19 that is the view pass, whatever the chain holds:
// the chain is empty and the frame is still whole.
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(),
0,
"nothing to sharpen at this scale"
);
assert_eq!(
pass.view_dispatches(),
1,
"and the view pass finishes the frame"
);
// 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");
}