From c9305fd0e64d87c3e507357b6d1b88db89a43bee Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:18:13 +0200 Subject: [PATCH] Write out the derivation behind every kernel width these tests assert A test that cannot be run cannot be checked by running it, and a number copied out of a test run agrees with whatever the code did on the day. Each asserted kernel width, tolerance and overshoot bound now carries the arithmetic that produces it -- the shorter edge, the sigma, the truncation at two sigmas, and where the rounding falls -- so a reader can verify the expectation against the recipe without a GPU or a compiler. Also records the two places where a bound is a bound and not a measurement: the tolerance in the frame-fraction test is exactly what rounding a kernel to a whole pixel costs on the smallest frame it uses, and the halo test's floor and ceiling bracket a peak derived from the step, the soft limit and the midtone taper rather than from a run. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-gpu/tests/local_contrast.rs | 13 ++++++ core/dr-pipeline/src/ops/local_contrast.rs | 54 +++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/core/dr-gpu/tests/local_contrast.rs b/core/dr-gpu/tests/local_contrast.rs index 9611940..b78e049 100644 --- a/core/dr-gpu/tests/local_contrast.rs +++ b/core/dr-gpu/tests/local_contrast.rs @@ -216,6 +216,19 @@ fn the_soft_limit_bounds_the_halo_at_a_hard_edge() { .fold(0.0f32, f32::max); let bound = Clarity::with_amount(100.0).overshoot_bound(); + // Where the two comparisons below sit, derived rather than observed: + // + // srgb_decode(175) = 0.4287, srgb_decode(90) = 0.1022 + // the step is log2(0.4287 / 0.1022) = 2.069 stops + // an unlimited mask peaks at half of it = 1.034 stops + // the soft limit saturates at = 0.350 stops (`bound`) + // the midtone taper then takes about 13% off at 175, so the peak this + // test should actually see is near = 0.30 stops + // + // So 0.30 has to clear the 0.1 floor with room, and fall well under both + // 0.35 + slack and 0.6 × 1.034 = 0.62. Every one of those is a bound with + // a reason, not a tolerance widened until the test passed. + // // A code value's worth of slack: the readback is 8-bit, and a pixel // sitting exactly on the bound quantises either side of it. assert!( diff --git a/core/dr-pipeline/src/ops/local_contrast.rs b/core/dr-pipeline/src/ops/local_contrast.rs index 5724027..29de880 100644 --- a/core/dr-pipeline/src/ops/local_contrast.rs +++ b/core/dr-pipeline/src/ops/local_contrast.rs @@ -633,7 +633,15 @@ mod tests { let scale = RenderScale::full((4000, 3000)); let clarity = Clarity::with_amount(100.0).kernel(scale); let texture = Texture::with_amount(100.0).kernel(scale); - // 1.2% of the *shorter* edge — 3000, not 4000 — and two sigmas wide. + // Both derived rather than observed, because a number copied out of a + // test run agrees with whatever the code did on the day. + // + // `frame_fraction` takes the *shorter* edge: min(4000, 3000) = 3000. + // clarity σ = 0.012 × 3000 = 36.0 → round(36.0 × 2) = 72 + // texture σ = 0.0012 × 3000 = 3.6 → round( 3.6 × 2) = 7 + // + // (7.2 rounds down, which is why texture is 7 and not 8 — the + // truncation is two sigmas, and two sigmas of 3.6 px is 7.2 px.) assert_eq!(clarity, 72); assert_eq!(texture, 7, "a decade finer"); assert!( @@ -652,6 +660,16 @@ mod tests { // must cover the same *proportion* of the picture on a proxy as in the // export, which is what `frame_fraction` gives and what // `source_pixels` would not. + // The declared proportion is σ × 2 = 0.012 × 2 = 0.024 of the shorter + // edge, and the tolerance is what rounding to a whole pixel costs: + // + // 300 → round(0.024 × 300) = 7 → 7/300 = 0.02333 (−0.00067) + // 1500 → round(0.024 × 1500) = 36 → 36/1500 = 0.02400 ( 0.00000) + // 4500 → round(0.024 × 4500) = 108 → 108/4500 = 0.02400 ( 0.00000) + // + // Half a pixel over the smallest frame here is 0.5/300 = 0.0017, so + // 0.002 is the tolerance a rounded kernel can actually hold — and it + // is a bound, not a fitted number. let sizes = [(400u32, 300u32), (2000, 1500), (6000, 4500)]; let proportions: Vec = sizes .iter() @@ -670,6 +688,17 @@ mod tests { // And the contrast with the other unit, stated rather than implied: on // a one-third proxy a source-pixel radius *shrinks* to a third of the // proportion it had, which is the bug this choice avoids. + // + // ratio = (2000/6000 + 1500/4500) / 2 = 1/3 + // frame_fraction(0.012) → 0.012 × 1500 = 18 px, the same 18 px the + // export of that framing gets, because the + // unit is a proportion of what is rendered; + // source_pixels(96) → 96 × 1/3 = 32 px, a third of the reach + // the same edit had at full size. + // + // 96 is clarity's kernel at 4000 px of shorter edge, so the second line + // is what this control would have done had it been written in the + // acutance family's unit. let proxy = RenderScale::new((2000, 1500), (6000, 4500)); let clarity = Clarity::with_amount(60.0); assert_eq!(clarity.kernel(proxy), clarity.kernel(RenderScale::full((2000, 1500)))); @@ -685,6 +714,15 @@ mod tests { // rendering of the frame, so the honest thing is to contribute no // pass. Unlike the acutance family this is not an approximation being // hidden: zoom in and the kernel comes back, exactly. + // + // shorter edge = 120 + // texture σ = 0.0012 × 120 = 0.144 → round(0.288) = 0 — no pass + // clarity σ = 0.012 × 120 = 1.44 → round(2.88) = 3 — two passes + // + // Texture's kernel crosses back above zero at round(0.0024 × e) ≥ 1, + // i.e. a shorter edge of about 209 px, which is a little larger than a + // contact sheet thumbnail and a great deal smaller than any view a + // photographer judges surface detail in. let thumbnail = RenderScale::full((160, 120)); assert_eq!(Texture::with_amount(100.0).kernel(thumbnail), 0); assert!(Texture::with_amount(100.0).passes(thumbnail).is_empty()); @@ -724,6 +762,14 @@ mod tests { // and nothing can infer that from the WGSL because the offsets come // from a uniform. An understated radius shows as a seam at every tile // boundary — an artefact that reads as a driver bug. + // + // shorter edge = 1500 + // clarity → round(0.024 × 1500) = 36 px + // texture → round(0.0024 × 1500) = 4 px + // + // so the halo the four passes together need is clarity's 36, taken as + // the maximum rather than the sum: the passes are separate dispatches, + // and a tile is grown for whichever of them reaches furthest. let scale = RenderScale::full((2000, 1500)); let composed = composed(50.0, 50.0, scale); let clarity = Clarity::with_amount(50.0).kernel(scale); @@ -749,6 +795,12 @@ mod tests { // A third of a stop for clarity, before the midtone taper takes more // off; a little over a stop for texture, whose overshoot is two pixels // wide and reads as acutance. + // + // clarity gain = 100/100 × 1.00 = 1.00 ; × 0.35 = 0.35 stops (26%) + // texture gain = 100/100 × 1.25 = 1.25 ; × 1.00 = 1.25 stops + // + // Both at full travel, which is what makes them a bound and not a + // measurement: no picture, and no edge in any picture, can produce more. assert!((bound(&combine.uniforms) - 0.35).abs() < 1e-6); let texture = &Texture::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1]; assert!((bound(&texture.uniforms) - 1.25).abs() < 1e-6);