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.
592 lines
25 KiB
Rust
592 lines
25 KiB
Rust
//! Noise reduction, end to end on a real device.
|
||
//!
|
||
//! `dr-pipeline`'s own tests assert what the operation *composes* — how many
|
||
//! passes, what radius, in what unit. None of them can tell whether the WGSL
|
||
//! compiles, whether the filter actually preserves an edge, or whether the
|
||
//! luminance and chroma halves stay out of each other's way once real floats
|
||
//! run through them. Those are questions only a GPU answers.
|
||
//!
|
||
//! # Reading the expected values
|
||
//!
|
||
//! Sources are uploaded through `DemosaicedImage::from_rgba8`, which flags
|
||
//! them non-linear, so the generated shader decodes sRGB before any operation
|
||
//! runs and the detail stage sees linear values. The last detail pass
|
||
//! re-encodes. So every assertion here decodes the readback back to linear
|
||
//! before comparing — comparing 8-bit code values directly would fold the
|
||
//! transfer function's varying slope into every tolerance.
|
||
//!
|
||
//! Almost everything is measured **against a baseline render of the same
|
||
//! image with the amount at zero**, rather than against an absolute
|
||
//! expectation. That is deliberate: it isolates what noise reduction did from
|
||
//! everything else the pipeline does to a pixel, and it stays correct if a
|
||
//! later change to the chain moves the values this stage is handed.
|
||
|
||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||
use dr_pipeline::detail::RenderScale;
|
||
use dr_pipeline::ops::noise_reduction::{CHROMA, ID, LUMINANCE};
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
fn graph_with(luminance: f32, chroma: f32) -> EditGraph {
|
||
let mut graph = EditGraph::default_chain();
|
||
graph.set_param(ID, LUMINANCE, luminance);
|
||
graph.set_param(ID, CHROMA, chroma);
|
||
graph
|
||
}
|
||
|
||
/// Render one graph and read the pixels back, at the scale the graph itself
|
||
/// works out — which is what a frontend does.
|
||
fn render(
|
||
ctx: &GpuContext,
|
||
pass: &mut AdjustPass,
|
||
graph: &EditGraph,
|
||
source: &DemosaicedImage,
|
||
out: (u32, u32),
|
||
) -> Vec<u8> {
|
||
let scale = graph.render_scale(source.size(), out);
|
||
render_at(ctx, pass, graph, source, out, scale)
|
||
}
|
||
|
||
/// Render with an explicitly chosen [`RenderScale`].
|
||
///
|
||
/// Split out for one test only — the one that needs to compose the detail
|
||
/// stage at the *wrong* scale on purpose, to show that the conversion from
|
||
/// source pixels to render pixels is load-bearing rather than decorative.
|
||
fn render_at(
|
||
_ctx: &GpuContext,
|
||
pass: &mut AdjustPass,
|
||
graph: &EditGraph,
|
||
source: &DemosaicedImage,
|
||
out: (u32, u32),
|
||
scale: RenderScale,
|
||
) -> Vec<u8> {
|
||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||
let key = graph.invalidation().through(Affects::Colour);
|
||
pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key)
|
||
.expect("render");
|
||
pass.export_pixels().expect("readback").0
|
||
}
|
||
|
||
fn srgb_decode(byte: u8) -> f32 {
|
||
let e = byte as f32 / 255.0;
|
||
if e <= 0.040_45 {
|
||
e / 12.92
|
||
} else {
|
||
((e + 0.055) / 1.055).powf(2.4)
|
||
}
|
||
}
|
||
|
||
fn luminance(c: [f32; 3]) -> f32 {
|
||
0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
|
||
}
|
||
|
||
/// One pixel of a readback, as linear RGB.
|
||
fn linear(pixels: &[u8], width: u32, x: u32, y: u32) -> [f32; 3] {
|
||
let i = ((y * width + x) * 4) as usize;
|
||
[
|
||
srgb_decode(pixels[i]),
|
||
srgb_decode(pixels[i + 1]),
|
||
srgb_decode(pixels[i + 2]),
|
||
]
|
||
}
|
||
|
||
/// A pixel split the way the operation itself splits it: a luminance, and a
|
||
/// colour difference whose own luminance is zero.
|
||
fn split(pixels: &[u8], width: u32, x: u32, y: u32) -> (f32, [f32; 3]) {
|
||
let c = linear(pixels, width, x, y);
|
||
let y0 = luminance(c);
|
||
(y0, [c[0] - y0, c[1] - y0, c[2] - y0])
|
||
}
|
||
|
||
/// Upload an image built from a per-pixel closure.
|
||
fn upload(ctx: &GpuContext, size: u32, f: impl Fn(u32, u32) -> [u8; 3]) -> DemosaicedImage {
|
||
let data: Vec<u8> = (0..size * size)
|
||
.flat_map(|i| {
|
||
let (x, y) = (i % size, i / size);
|
||
let [r, g, b] = f(x, y);
|
||
[r, g, b, 255]
|
||
})
|
||
.collect();
|
||
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
|
||
}
|
||
|
||
/// A vertical step edge of a chosen height, centred on the frame.
|
||
fn step_edge(ctx: &GpuContext, size: u32, low: u8, high: u8) -> DemosaicedImage {
|
||
upload(ctx, size, move |x, _| {
|
||
let v = if x < size / 2 { low } else { high };
|
||
[v, v, v]
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn a_difference_below_the_threshold_is_averaged_and_one_above_it_is_not() {
|
||
// The defining property, and the reason this is a bilateral rather than a
|
||
// Gaussian. Both images are step edges and the filter is identical; the
|
||
// only thing that differs is how tall the step is relative to the noise
|
||
// threshold. A Gaussian would soften both by exactly the same amount, and
|
||
// that indiscriminate softening is what "denoised" pictures look like.
|
||
let Some(ctx) = ctx() else { return };
|
||
const SIZE: u32 = 64;
|
||
let mid = SIZE / 2;
|
||
let row = SIZE / 2;
|
||
|
||
let measure = |source: &DemosaicedImage| -> f32 {
|
||
let mut off = AdjustPass::new(&ctx);
|
||
let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), source, (SIZE, SIZE));
|
||
let mut on = AdjustPass::new(&ctx);
|
||
let denoised = render(&ctx, &mut on, &graph_with(100.0, 0.0), source, (SIZE, SIZE));
|
||
// How far the pixel just inside the bright side moved, in linear
|
||
// luminance. An averaging filter pulls it down towards the dark half;
|
||
// an edge-preserving one leaves it where it was.
|
||
let (before, _) = split(&plain, SIZE, mid, row);
|
||
let (after, _) = split(&denoised, SIZE, mid, row);
|
||
before - after
|
||
};
|
||
|
||
// A step of eight code values around mid-grey is about 0.028 in linear
|
||
// luminance, against a threshold of roughly 0.035 at full amount: within
|
||
// the range where the filter is meant to treat a difference as noise.
|
||
//
|
||
// Worked out on paper, since it cannot be run here: at amount 100 the
|
||
// kernel is 3 render pixels and sigma_k is 0.075, so at y = 0.2159 the
|
||
// range sigma is 0.075·sqrt(0.2159 + 0.0025) = 0.0350 and a neighbour
|
||
// 0.0280 away is weighted exp(-0.320) = 0.726. Summing the 7×7 kernel's
|
||
// spatial weights over the four bright columns and the three dark ones
|
||
// gives a filtered luminance of 0.2076 against 0.2159 — a move of 0.0082,
|
||
// which survives the 8-bit readback as about **0.0072**. The threshold
|
||
// below is set well under that rather than at it: what would be a bug is
|
||
// the filter declining to average at all.
|
||
let quiet = measure(&step_edge(&ctx, SIZE, 120, 128));
|
||
assert!(
|
||
quiet > 0.004,
|
||
"a difference below the threshold was left alone: moved {quiet}"
|
||
);
|
||
|
||
// Black to white is thirteen times the threshold — 1.0 against a sigma of
|
||
// 0.075·sqrt(1.0025) = 0.0751 — so a neighbour across it is weighted
|
||
// exp(-88), which is zero in any arithmetic. The edge has to survive
|
||
// intact; one that softens here is a halo in every high-contrast picture.
|
||
let loud = measure(&step_edge(&ctx, SIZE, 0, 255));
|
||
assert!(
|
||
loud.abs() < 0.004,
|
||
"an edge far above the threshold was smoothed: moved {loud}"
|
||
);
|
||
assert!(
|
||
quiet > loud.abs() * 3.0,
|
||
"the filter did not distinguish noise from an edge: {quiet} vs {loud}"
|
||
);
|
||
}
|
||
|
||
/// A fine chroma pattern: red and blue swung in opposite directions by the
|
||
/// same number of code values, green held.
|
||
///
|
||
/// This is what chroma noise looks like to the filter — a colour difference
|
||
/// alternating over a few pixels — and it is the one pattern that can tell the
|
||
/// two halves of this operation apart.
|
||
///
|
||
/// It is not a *pure* colour pattern, and the tests must not assume it is.
|
||
/// Rec. 709 weights red at 0.2126 and blue at 0.0722, so swinging one up and
|
||
/// the other down by equal amounts moves lightness by about a seventh of the
|
||
/// swing. That residue is real, it is not the chroma filter's to remove, and
|
||
/// [`chroma_r`] is what keeps it out of the measurements.
|
||
fn chroma_pattern(ctx: &GpuContext, size: u32, half_period: u32, swing: i32) -> DemosaicedImage {
|
||
upload(ctx, size, move |x, _| {
|
||
let on = (x / half_period).is_multiple_of(2);
|
||
let d = if on { swing } else { -swing };
|
||
[(128 + d) as u8, 128, (128 - d) as u8]
|
||
})
|
||
}
|
||
|
||
/// How much of a known alternating pattern survived, as the correlation of a
|
||
/// chosen measurement of the middle row against the pattern's own sign.
|
||
///
|
||
/// A matched filter rather than a peak-to-peak reading. The readback is eight
|
||
/// bits, and the modulation these tests work with is only a handful of code
|
||
/// values — deliberately, because a larger one would read as a real colour
|
||
/// boundary and the filter would refuse to touch it. Correlating over a whole
|
||
/// number of periods averages the quantisation down instead of letting it set
|
||
/// the noise floor of the measurement.
|
||
///
|
||
/// `margin` covers a whole number of periods too, so the window is unbiased by
|
||
/// the row's mean, and it keeps the measurement clear of the borders where a
|
||
/// clamped kernel legitimately behaves differently.
|
||
///
|
||
/// `sample` is what to measure. Passing [`chroma_r`] rather than the raw red
|
||
/// channel matters more than it looks: a colour square wave built from 8-bit
|
||
/// code values carries a *luminance* square wave under it — Rec. 709 does not
|
||
/// weight red and blue equally, so swinging one up and the other down moves
|
||
/// lightness too — and that component is not the chroma filter's to remove.
|
||
/// Left in the measurement it is a constant floor under every reading, which
|
||
/// compresses every ratio this file asserts towards one and would leave the
|
||
/// tests unable to tell a correct kernel from one twice the size.
|
||
fn modulation(
|
||
pixels: &[u8],
|
||
width: u32,
|
||
half_period: u32,
|
||
sample: impl Fn([f32; 3]) -> f32,
|
||
) -> f32 {
|
||
let row = width / 2;
|
||
let margin = half_period * 4;
|
||
let mut total = 0.0;
|
||
let mut count = 0.0;
|
||
for x in margin..(width - margin) {
|
||
let value = sample(linear(pixels, width, x, row));
|
||
let sign = if (x / half_period).is_multiple_of(2) {
|
||
1.0
|
||
} else {
|
||
-1.0
|
||
};
|
||
total += value * sign;
|
||
count += 1.0;
|
||
}
|
||
total / count
|
||
}
|
||
|
||
/// The red component of the colour difference — red with its lightness taken
|
||
/// out, which is the quantity the chroma passes actually filter.
|
||
fn chroma_r(c: [f32; 3]) -> f32 {
|
||
c[0] - luminance(c)
|
||
}
|
||
|
||
#[test]
|
||
fn chroma_noise_reduction_never_moves_lightness() {
|
||
// Half of the claim the two-slider design rests on. The split is into a
|
||
// luminance and a colour difference whose own luminance is zero, so the
|
||
// chroma passes reconstruct with the lightness this pixel arrived with —
|
||
// exactly, not approximately.
|
||
let Some(ctx) = ctx() else { return };
|
||
const SIZE: u32 = 64;
|
||
|
||
// The strongest available form of the assertion, on an image that has no
|
||
// colour to filter: every colour difference is zero, so the filter is the
|
||
// identity and the output must be the *same bytes*. A reconstruction that
|
||
// used a filtered luminance instead of this pixel's own would soften the
|
||
// step and show up here immediately.
|
||
let grey = step_edge(&ctx, SIZE, 90, 110);
|
||
let mut off = AdjustPass::new(&ctx);
|
||
let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), &grey, (SIZE, SIZE));
|
||
let mut on = AdjustPass::new(&ctx);
|
||
let denoised = render(&ctx, &mut on, &graph_with(0.0, 100.0), &grey, (SIZE, SIZE));
|
||
assert_eq!(
|
||
plain, denoised,
|
||
"chroma noise reduction altered an image with no colour in it"
|
||
);
|
||
|
||
// And on an image that does have colour to filter, where the two halves
|
||
// could actually interfere: the colour modulation must fall while the
|
||
// luminance modulation under it stays where it was.
|
||
//
|
||
// On paper, at amount 100 over a half-period of 4: the kernel is 12 render
|
||
// pixels, both chroma thresholds are 0.16·sqrt(y + floor) ≈ 0.076 and
|
||
// 0.20·… ≈ 0.095, so an opposite-coloured neighbour is weighted 0.410, and
|
||
// summing the separable kernel over the eight phases leaves about **0.42**
|
||
// of the chroma amplitude — 0.0158 of 0.0377. The luminance amplitude must
|
||
// not move at all: every colour difference the pass averages has zero
|
||
// luminance by construction, so their weighted mean does too.
|
||
//
|
||
// Both tolerances are wide of those numbers because both readings pass
|
||
// through an eight-bit readback twice over; the failure they guard against
|
||
// is not a drift of a few percent but a collapse, which is what a leak
|
||
// between the two components would be.
|
||
let source = chroma_pattern(&ctx, SIZE, 4, 12);
|
||
let mut off = AdjustPass::new(&ctx);
|
||
let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), &source, (SIZE, SIZE));
|
||
let mut on = AdjustPass::new(&ctx);
|
||
let chroma = render(
|
||
&ctx,
|
||
&mut on,
|
||
&graph_with(0.0, 100.0),
|
||
&source,
|
||
(SIZE, SIZE),
|
||
);
|
||
|
||
let colour_before = modulation(&plain, SIZE, 4, chroma_r);
|
||
let colour_after = modulation(&chroma, SIZE, 4, chroma_r);
|
||
assert!(
|
||
colour_after < colour_before * 0.7,
|
||
"chroma noise reduction did not reduce the colour swing: \
|
||
{colour_after} of {colour_before}"
|
||
);
|
||
|
||
let light_before = modulation(&plain, SIZE, 4, luminance);
|
||
let light_after = modulation(&chroma, SIZE, 4, luminance);
|
||
assert!(
|
||
(light_after - light_before).abs() < light_before.abs() * 0.3,
|
||
"chroma noise reduction moved lightness: {light_after} was {light_before}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn luminance_noise_reduction_never_moves_colour() {
|
||
// The other half. The luminance pass adds the *change* in lightness back
|
||
// to the colour it was given, so the colour difference passes through
|
||
// untouched however hard the luminance is filtered. Written the obvious
|
||
// way instead — filtering the three channels and calling it a luminance
|
||
// filter — the colour would desaturate as the amount rose, and the chroma
|
||
// slider would stop meaning anything on its own.
|
||
let Some(ctx) = ctx() else { return };
|
||
const SIZE: u32 = 64;
|
||
let source = chroma_pattern(&ctx, SIZE, 4, 12);
|
||
|
||
let mut off = AdjustPass::new(&ctx);
|
||
let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), &source, (SIZE, SIZE));
|
||
let mut on = AdjustPass::new(&ctx);
|
||
let luma = render(
|
||
&ctx,
|
||
&mut on,
|
||
&graph_with(100.0, 0.0),
|
||
&source,
|
||
(SIZE, SIZE),
|
||
);
|
||
|
||
// The colour difference — not the raw channel, which follows lightness.
|
||
let row = SIZE / 2;
|
||
let interior = 16..(SIZE - 16);
|
||
let mut worst = 0.0f32;
|
||
for x in interior {
|
||
let (_, before) = split(&plain, SIZE, x, row);
|
||
let (_, after) = split(&luma, SIZE, x, row);
|
||
worst = worst.max((after[0] - before[0]).abs());
|
||
}
|
||
// A code value at this brightness, doubled for the two readbacks the
|
||
// comparison passes through. The leak this guards against would be a
|
||
// sizeable fraction of the pattern's own 0.037 swing, not a rounding.
|
||
let quantum = srgb_decode(129) - srgb_decode(128);
|
||
assert!(
|
||
worst < quantum * 3.0,
|
||
"luminance noise reduction moved colour by {worst} \
|
||
(one code value is {quantum})"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_same_edit_denoises_the_same_at_two_resolutions() {
|
||
// TRACES: FR-DSP-1 — the thing this operation is most likely to get wrong.
|
||
//
|
||
// A radius is stored in *source* pixels and converted to render pixels at
|
||
// every render, because noise is made by photosites. Get that conversion
|
||
// wrong and the develop view and the exported file are different
|
||
// photographs: tune the slider on a half-size proxy and the export is
|
||
// denoised at half the strength, or twice it.
|
||
//
|
||
// The subject is a chroma square wave with a period that is a power of two
|
||
// and aligned to the frame, so halving the render resolution decimates it
|
||
// exactly. The fused pass loads the nearest source pixel when the framing
|
||
// is unrotated, so output column `x` reads source column `2x` — always the
|
||
// same half of an eight-wide block as `2x + 1` — and the proxy sees the
|
||
// same two colours at half the period, with no resampling of its own to
|
||
// confuse the measurement.
|
||
//
|
||
// # The expected numbers
|
||
//
|
||
// Worked out on paper, because the tolerances below are meaningless
|
||
// without knowing what they are tolerances *around*.
|
||
//
|
||
// The two colours are (134, 128, 122) and (122, 128, 134), which decode to
|
||
// linear (0.2384, 0.2159, 0.1946) and its mirror. Their colour differences
|
||
// are ±(0.0193, -0.0033, -0.0245), so the pattern's chroma amplitude —
|
||
// what `modulation` with `chroma_r` reads — is 0.0188 before filtering.
|
||
//
|
||
// At amount 60 both chroma thresholds are 0.12·sqrt(y + floor) ≈ 0.0565,
|
||
// so a neighbour of the opposite colour is weighted
|
||
// `exp(-(dl²/2σ_g² + |dc|²/2σ_c²)) ≈ exp(-0.624) ≈ 0.536`: attenuated, but
|
||
// far from rejected, which is the regime where the kernel's *width* is
|
||
// what decides the answer. That is the point — a test where the range
|
||
// weights dominated would pass whatever the radius conversion did.
|
||
//
|
||
// Summing the separable kernel's spatial weights over the eight phases of
|
||
// the pattern gives a mean surviving fraction of **0.521** at export (a
|
||
// radius of 8 render pixels over a half-period of 8) and **0.521** on the
|
||
// proxy (4 over 4) — the two arrangements are the same filter sampled at
|
||
// two rates, and they agree to three decimal places. So both amplitudes
|
||
// land at about 0.0098.
|
||
//
|
||
// The control lands at **0.305**, about 0.0057: a kernel of 8 render
|
||
// pixels over a half-period of 4 is twice as wide in the terms that
|
||
// matter. The tolerances are set wide of those numbers rather than tight
|
||
// to them, because the readback is eight bits and each of the handful of
|
||
// distinct output values carries up to half a code value of rounding — a
|
||
// floor of a few percent on any of these readings that no amount of
|
||
// averaging over a larger frame removes, since the error is periodic
|
||
// rather than random.
|
||
let Some(ctx) = ctx() else { return };
|
||
const SOURCE: u32 = 256;
|
||
const HALF_PERIOD: u32 = 8; // in source pixels
|
||
// Six code values of swing. Small on purpose: the colour difference has to
|
||
// land near the filter's threshold, because a larger one is a colour
|
||
// boundary and the whole point of a bilateral is that it refuses to cross
|
||
// those. There would be nothing to measure at either resolution.
|
||
let source = chroma_pattern(&ctx, SOURCE, HALF_PERIOD, 6);
|
||
|
||
// Sixty percent is an eight-source-pixel radius, which halves to exactly
|
||
// four render pixels on a half-size proxy — so the rounding to an integer
|
||
// kernel is not what this test is measuring.
|
||
let graph = graph_with(0.0, 60.0);
|
||
|
||
let mut export_pass = AdjustPass::new(&ctx);
|
||
let export = render(&ctx, &mut export_pass, &graph, &source, (SOURCE, SOURCE));
|
||
let export_amp = modulation(&export, SOURCE, HALF_PERIOD, chroma_r);
|
||
|
||
let proxy_size = SOURCE / 2;
|
||
let mut proxy_pass = AdjustPass::new(&ctx);
|
||
let proxy = render(
|
||
&ctx,
|
||
&mut proxy_pass,
|
||
&graph,
|
||
&source,
|
||
(proxy_size, proxy_size),
|
||
);
|
||
let proxy_amp = modulation(&proxy, proxy_size, HALF_PERIOD / 2, chroma_r);
|
||
|
||
// Both must be doing something: two flat images would agree perfectly and
|
||
// prove nothing. About 0.0098 of 0.0188, by the derivation above.
|
||
let untouched = {
|
||
let mut pass = AdjustPass::new(&ctx);
|
||
let plain = render(
|
||
&ctx,
|
||
&mut pass,
|
||
&graph_with(0.0, 0.0),
|
||
&source,
|
||
(SOURCE, SOURCE),
|
||
);
|
||
modulation(&plain, SOURCE, HALF_PERIOD, chroma_r)
|
||
};
|
||
assert!(
|
||
export_amp < untouched * 0.8,
|
||
"the denoiser did nothing: {export_amp} of {untouched}"
|
||
);
|
||
|
||
assert!(
|
||
(proxy_amp - export_amp).abs() < export_amp * 0.25,
|
||
"the same edit left {proxy_amp} of the pattern on the proxy and \
|
||
{export_amp} on the export"
|
||
);
|
||
|
||
// The control, and the reason the tolerance above means something. Compose
|
||
// the detail stage as though the proxy were a full-resolution render —
|
||
// which is exactly the bug of storing a radius in render pixels — and the
|
||
// kernel is twice as wide in source terms. If the conversion were not
|
||
// load-bearing, this would land in the same place as the other two.
|
||
let mut wrong_pass = AdjustPass::new(&ctx);
|
||
let wrong = render_at(
|
||
&ctx,
|
||
&mut wrong_pass,
|
||
&graph,
|
||
&source,
|
||
(proxy_size, proxy_size),
|
||
RenderScale::full((proxy_size, proxy_size)),
|
||
);
|
||
let wrong_amp = modulation(&wrong, proxy_size, HALF_PERIOD / 2, chroma_r);
|
||
assert!(
|
||
wrong_amp < export_amp * 0.8,
|
||
"an unconverted radius was indistinguishable from a converted one: \
|
||
{wrong_amp} against {export_amp}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn each_amount_costs_only_the_dispatches_it_needs() {
|
||
// The cost story, which is invisible in the picture and therefore has to
|
||
// be asserted on a counter. Luminance is one exact two-dimensional pass;
|
||
// chroma is two, because at its radius the exact form is quadratic and
|
||
// unaffordable. An edit using neither must pay for neither — and must
|
||
// produce pixels identical to a chain that has no denoiser in it at all.
|
||
let Some(ctx) = ctx() else { return };
|
||
const SIZE: u32 = 48;
|
||
let source = step_edge(&ctx, SIZE, 40, 200);
|
||
|
||
for (luminance, chroma, expected) in [(60.0, 0.0, 1), (0.0, 60.0, 2), (60.0, 60.0, 3)] {
|
||
let mut pass = AdjustPass::new(&ctx);
|
||
render(
|
||
&ctx,
|
||
&mut pass,
|
||
&graph_with(luminance, chroma),
|
||
&source,
|
||
(SIZE, SIZE),
|
||
);
|
||
assert_eq!(
|
||
pass.detail_dispatches(),
|
||
expected,
|
||
"luminance {luminance}, chroma {chroma}"
|
||
);
|
||
assert_eq!(pass.colour_dispatches(), 1);
|
||
}
|
||
|
||
let mut neutral = AdjustPass::new(&ctx);
|
||
let a = render(
|
||
&ctx,
|
||
&mut neutral,
|
||
&graph_with(0.0, 0.0),
|
||
&source,
|
||
(SIZE, SIZE),
|
||
);
|
||
assert_eq!(neutral.detail_dispatches(), 0);
|
||
assert_eq!(neutral.detail_allocations(), 0, "nothing was allocated");
|
||
|
||
// Byte-identical, not merely close: an operation at its defaults must not
|
||
// touch the image, and a stage that ran and wrote back the same values
|
||
// would still have quantised twice.
|
||
let mut absent = AdjustPass::new(&ctx);
|
||
let b = render(
|
||
&ctx,
|
||
&mut absent,
|
||
&EditGraph::default_chain(),
|
||
&source,
|
||
(SIZE, SIZE),
|
||
);
|
||
assert_eq!(a, b, "a neutral denoiser changed the picture");
|
||
}
|
||
|
||
#[test]
|
||
fn dragging_either_slider_recompiles_nothing_and_reallocates_nothing() {
|
||
// TRACES: FR-DEV-3d. Both of these are ruinous per frame and invisible in
|
||
// the output, which is why they need a counter rather than an eye. A
|
||
// radius rides in a uniform buffer, so moving a slider re-runs the detail
|
||
// dispatches against the pipelines already compiled — and does not re-run
|
||
// the colour pass at all, since nothing it depends on moved.
|
||
let Some(ctx) = ctx() else { return };
|
||
const SIZE: u32 = 48;
|
||
let source = step_edge(&ctx, SIZE, 40, 200);
|
||
let mut pass = AdjustPass::new(&ctx);
|
||
|
||
let mut graph = graph_with(50.0, 50.0);
|
||
render(&ctx, &mut pass, &graph, &source, (SIZE, SIZE));
|
||
let pipelines = pass.cached_detail_pipelines();
|
||
let allocations = pass.detail_allocations();
|
||
assert_eq!(pipelines, 3, "one per pass: luminance, then two for chroma");
|
||
|
||
for amount in [55.0, 60.0, 65.0, 70.0] {
|
||
graph.set_param(ID, LUMINANCE, amount);
|
||
graph.set_param(ID, CHROMA, amount);
|
||
render(&ctx, &mut pass, &graph, &source, (SIZE, 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"
|
||
);
|
||
assert_eq!(
|
||
pass.colour_dispatches(),
|
||
1,
|
||
"the fused colour pass re-ran for a change it does not depend on"
|
||
);
|
||
}
|