Merge branch 'worktree-agent-a75c901968abfa183' into integration
# Conflicts: # core/dr-gpu/src/adjust.rs # core/dr-pipeline/ops/README.md # core/dr-pipeline/src/lib.rs # core/dr-pipeline/src/ops/mod.rs # ui/dr-ui/src/develop.rs
This commit is contained in:
+53
-34
@@ -1064,21 +1064,28 @@ mod tests {
|
||||
g.set_param(cap.id, p.id, value);
|
||||
let shader = g.compose();
|
||||
|
||||
// Through the detail stage rather than through `render`,
|
||||
// because a neighbourhood operation contributes no fused
|
||||
// fragment: its WGSL is generated per resolution and lives
|
||||
// in dispatches of its own. Compiling only the fused half
|
||||
// would leave every kernel in the chain untested here —
|
||||
// and worse, `render` refuses a shader composed to hand on
|
||||
// linear working values, so the omission would arrive as
|
||||
// "invalid WGSL" against a shader that is perfectly valid.
|
||||
// A neighbourhood operation compiles as a *chain*, not
|
||||
// as a fragment: it contributes nothing to the fused pass,
|
||||
// and the fused pass in turn stops short of the output
|
||||
// transform so the last detail pass can perform it. Going
|
||||
// through `render_detailed` covers both kinds with one
|
||||
// loop, which is the property that makes this test extend
|
||||
// itself when an operation is added.
|
||||
//
|
||||
// The scale comes from the graph, so the kernel really is
|
||||
// converted the way a render converts it. The image is
|
||||
// 16×16 and so is the target, which puts the ratio at 1.0
|
||||
// and keeps an acutance operation from declining to draw
|
||||
// (`RenderScale::resolves`) and compiling its pass-through
|
||||
// instead of the kernel this test exists to check.
|
||||
// Compiling only the fused half would leave every kernel
|
||||
// untested here — and worse, `render` refuses a shader
|
||||
// composed to hand on linear working values, so the
|
||||
// omission would arrive as "invalid WGSL" against a shader
|
||||
// that is perfectly valid.
|
||||
//
|
||||
// The scale comes from the graph, so the kernel is
|
||||
// converted the way a real render converts it. Mind the
|
||||
// size: a radius stated in source pixels can decide there
|
||||
// is nothing to draw at sixteen pixels
|
||||
// (`RenderScale::resolves`) and compile its pass-through
|
||||
// instead of the kernel under test. The chain still
|
||||
// carries the resolve pass that finishes the render, and
|
||||
// that generated source is worth compiling too.
|
||||
let scale = g.render_scale(img.size(), (16, 16));
|
||||
let detail = g.compose_detail(scale);
|
||||
let key = g.invalidation().through(dr_pipeline::Affects::Colour);
|
||||
@@ -1442,38 +1449,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
let shader = g.compose();
|
||||
|
||||
// Cropped, so the render is against an output size that is not the
|
||||
// source size — the case where a wrong dispatch or a wrong texture
|
||||
// allocation would show up.
|
||||
let (w, h) = g.output_size(32, 32);
|
||||
let shader = g.compose();
|
||||
let scale = g.render_scale(img.size(), (w, h));
|
||||
let detail = g.compose_detail(scale);
|
||||
|
||||
// A neighbourhood operation is active and yet emits no fused block: it
|
||||
// reads pixels it is not writing, so it is a dispatch of its own. The
|
||||
// ones that are come from the detail chain rather than from a list
|
||||
// here, which keeps the count exact as sharpening, noise reduction and
|
||||
// clarity arrive instead of loosening it to an inequality.
|
||||
let neighbourhood: std::collections::BTreeSet<&str> = detail
|
||||
.passes
|
||||
.iter()
|
||||
.map(|p| p.label.split('/').next().expect("<op id>/<pass label>"))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
shader.source.matches("---- ").count(),
|
||||
// Every fusable operation, plus framing — which emits a stage of
|
||||
// its own rather than an operation block, and is not in
|
||||
// `descriptors` — less the ones that run after this shader.
|
||||
g.descriptors().len() + 1 - neighbourhood.len(),
|
||||
"every fusable operation and the framing should be active"
|
||||
// Every operation has to reach the pipeline, but they do not all reach
|
||||
// the same half of it, and which half is not this test's business to
|
||||
// know: a point operation is a block in the fused shader, and a
|
||||
// neighbourhood operation is one or more passes of the detail chain
|
||||
// (`dr_pipeline::detail`) and contributes *no* fused block, because a
|
||||
// fused fragment is handed a colour with no way back to a coordinate.
|
||||
//
|
||||
// Asserted as an exclusive or over the chain rather than as a count,
|
||||
// so that adding either kind of operation extends this test on its own
|
||||
// — and so that an operation which somehow managed both, or neither,
|
||||
// is named rather than showing up as an arithmetic mismatch.
|
||||
let mut fused_blocks = 0;
|
||||
for desc in g.descriptors() {
|
||||
let id = desc.id.0;
|
||||
let point = shader.source.contains(&format!("---- {id} ----"));
|
||||
let neighbourhood = detail
|
||||
.passes
|
||||
.iter()
|
||||
.any(|p| p.label.starts_with(&format!("{id}/")));
|
||||
assert!(
|
||||
point ^ neighbourhood,
|
||||
"{id} reaches {} of the two stages; every active operation \
|
||||
belongs to exactly one",
|
||||
if point { "both" } else { "neither" }
|
||||
);
|
||||
fused_blocks += usize::from(point);
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
shader.source.contains("---- framing ----"),
|
||||
"framing must reach the shader alongside the colour operations"
|
||||
);
|
||||
assert!(
|
||||
!detail.is_empty(),
|
||||
"with every operation active the detail stage must run"
|
||||
);
|
||||
|
||||
// Both halves, from the one graph: with a detail stage present the
|
||||
// fused pass stops at linear working values and the last detail pass
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
//! 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) % 2 == 0;
|
||||
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) % 2 == 0 { 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user