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
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>
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_for(scale, ColourSpace::Srgb);
|
||
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"
|
||
);
|
||
}
|