//! Clarity and texture, end to end on a real device. //! //! `dr-pipeline`'s tests assert what the composer *generates* — the kernel //! width, the uniforms, which lines of WGSL each node emits. None of that can //! tell whether the two passes compose into an unsharp mask, whether the //! original colour really survives the hand-off from the blur pass to the //! combining one, or whether the halo the soft limit is supposed to bound is //! actually bounded in pixels. Those are questions only a GPU answers. //! //! # Why every measurement is in stops //! //! The controls work on log luminance, and their guarantees are stated in //! stops: an overshoot of at most `gain * threshold`, an effect that is //! symmetric about neutral, a strength that does not depend on how bright the //! subject is. Asserting on 8-bit code values would restate all of that in a //! unit where none of it is true, and would need a fresh magic number for //! every brightness tested. So the pixels are decoded back to linear and //! compared as ratios. //! //! # The test image //! //! A vertical step between two **midtones** rather than between black and //! white. Clarity is tapered to nothing at both ends of the range on purpose //! (see `midtone_weight`), so a 0–255 step is the one edge in the world it is //! designed to leave alone, and a test built on it would measure the taper //! working and call it the feature not working. use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext}; use dr_pipeline::descriptor::{OpId, ParamId}; use dr_pipeline::ops::local_contrast::{Clarity, Texture}; use dr_pipeline::{Affects, EditGraph, OutputMode}; use dr_types::ColourSpace; const CLARITY: OpId = OpId("clarity"); const TEXTURE: OpId = OpId("texture"); const AMOUNT: ParamId = ParamId("amount"); /// Large enough that texture's kernel — a tenth of clarity's — is still more /// than one pixel wide. At 1024 its sigma is 1.2 px; at 256 it would round to /// a delta and the control would honestly do nothing, which is the behaviour /// `texture_stops_rather_than_lying_when_the_render_is_too_small` covers and /// not the behaviour under test here. const SIZE: u32 = 1024; /// The two sides of the step, as sRGB code values. /// /// Both well inside the range, and roughly two stops apart — a real edge, of /// the kind that produces the halo this file exists to bound. const DARK: u8 = 90; const BRIGHT: u8 = 175; fn ctx() -> Option { // 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 srgb_decode(v: u8) -> f32 { let e = v as f32 / 255.0; if e <= 0.040_45 { e / 12.92 } else { ((e + 0.055) / 1.055).powf(2.4) } } /// A vertical step from `DARK` to `BRIGHT` at the half-way column. fn step_edge(ctx: &GpuContext, size: u32, tint: [f32; 3]) -> DemosaicedImage { let data: Vec = (0..size * size) .flat_map(|i| { let x = i % size; let v = if x < size / 2 { DARK } else { BRIGHT } as f32; [ (v * tint[0]).round() as u8, (v * tint[1]).round() as u8, (v * tint[2]).round() as u8, 255, ] }) .collect(); DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload") } /// One row of the rendered image, as linear luminance-ish red values. fn row(pixels: &[u8], size: u32, y: u32) -> Vec { (0..size) .map(|x| pixels[((y * size + x) * 4) as usize]) .collect() } /// One row as full RGB triples. fn row_rgb(pixels: &[u8], size: u32, y: u32) -> Vec<[u8; 3]> { (0..size) .map(|x| { let i = ((y * size + x) * 4) as usize; [pixels[i], pixels[i + 1], pixels[i + 2]] }) .collect() } /// Render one graph with its detail stage and read the pixels back. fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec { let shader = graph.compose_for(ColourSpace::Srgb); let scale = graph.render_scale(source.size(), (out, out)); let detail = graph.compose_detail_for(scale, ColourSpace::Srgb); let key = graph.invalidation().through(Affects::Colour); pass.render_detailed(source, &shader, out, out, None, &detail, key) .expect("render"); pass.export_pixels().expect("readback").0 } /// A graph with one of the two controls set and everything else neutral. fn graph_with(op: OpId, amount: f32) -> EditGraph { let mut g = EditGraph::default_chain(); g.set_param(op, AMOUNT, amount); g } /// How far a pixel moved, in stops, against the same pixel unedited. fn stops(edited: u8, plain: u8) -> f32 { (srgb_decode(edited).max(1e-6) / srgb_decode(plain).max(1e-6)).log2() } #[test] fn clarity_lifts_local_contrast_and_leaves_the_flat_regions_alone() { // The definition of a local contrast control, as pixels: it must do // something at the edge and *nothing* a long way from it. An operation // that brightened the whole bright plateau would be an exposure slider // with extra steps, and it is the failure a sign error in the base // produces. let Some(ctx) = ctx() else { return }; let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); let mut plain_pass = AdjustPass::new(&ctx); let plain = row( &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), SIZE, SIZE / 2, ); let mut pass = AdjustPass::new(&ctx); let edited = row( &render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE), SIZE, SIZE / 2, ); let edge = (SIZE / 2) as usize; let reach = Clarity::with_amount(100.0) .kernel(EditGraph::default_chain().render_scale((SIZE, SIZE), (SIZE, SIZE))) as usize; // Far outside the kernel's reach the base equals the pixel, the detail // signal is zero, and the output must be the input to the last code value. for x in [0, reach / 2, SIZE as usize - 1 - reach / 2, SIZE as usize - 1] { assert!( edited[x].abs_diff(plain[x]) <= 1, "column {x} moved by {} away from any edge", edited[x].abs_diff(plain[x]) ); } // And at the edge it must do the thing it is for: the bright side lifts, // the dark side drops, which is what "more local contrast" means. assert!( edited[edge] > plain[edge] + 4, "the bright side of the edge did not lift: {} vs {}", edited[edge], plain[edge] ); assert!( edited[edge - 1] + 4 < plain[edge - 1], "the dark side of the edge did not drop: {} vs {}", edited[edge - 1], plain[edge - 1] ); } #[test] fn the_soft_limit_bounds_the_halo_at_a_hard_edge() { // The single most common way clarity is got wrong, held to a number. // // `t * tanh(d / t)` saturates at `t`, so no pixel may move further than // `gain * threshold` stops however violent the edge — a bound that holds // by construction rather than by tuning, and one this test takes from the // operation itself rather than restating. // // The comparison that gives it meaning is the second assertion: an // unlimited unsharp mask over this edge would move the bright side by // about half the step, which is more than twice as far. That is the // difference between a control and a white glow along the skyline. let Some(ctx) = ctx() else { return }; let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); let mut plain_pass = AdjustPass::new(&ctx); let plain = row( &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), SIZE, SIZE / 2, ); let mut pass = AdjustPass::new(&ctx); let edited = row( &render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE), SIZE, SIZE / 2, ); let worst = (0..SIZE as usize) .map(|x| stops(edited[x], plain[x]).abs()) .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!( worst <= bound + 0.02, "a pixel moved {worst:.3} stops, past the {bound:.3} the soft limit \ promises" ); // Half the step is what an unlimited mask would have produced at the very // edge, since the base there is the mean of the two plateaus. let unlimited = (srgb_decode(BRIGHT) / srgb_decode(DARK)).log2() / 2.0; assert!( worst < unlimited * 0.6, "the limit is not biting: {worst:.3} stops against the {unlimited:.3} \ an unlimited unsharp mask would give" ); // But it is still a real effect, not a control that does nothing. assert!(worst > 0.1, "clarity moved almost nothing: {worst:.3} stops"); } #[test] fn a_proxy_and_an_export_agree_about_the_effect() { // TRACES: FR-DSP-1 — the decision the radius unit rests on, proved in // pixels rather than in kernel widths. // // Clarity's radius is a fraction of the frame because the control is // compositional: "separate the subject from its background" is a statement // about how much of the picture the subject occupies. If that is right, // the *same edit* rendered at two resolutions must produce an effect of // the same strength covering the same proportion of the frame — which is // exactly what a photographer tuning on screen and exporting at full size // is relying on. // // Had the radius been stated in source pixels, the proxy here would show // half the reach and the export would be a different photograph. let Some(ctx) = ctx() else { return }; let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); // Peak excursion in stops, and how far the effect reaches, as a fraction // of the frame. let measure = |out: u32| -> (f32, f32) { let mut plain_pass = AdjustPass::new(&ctx); let plain = row( &render(&mut plain_pass, &EditGraph::default_chain(), &source, out), out, out / 2, ); let mut pass = AdjustPass::new(&ctx); let edited = row( &render(&mut pass, &graph_with(CLARITY, 100.0), &source, out), out, out / 2, ); let moved: Vec = (0..out as usize) .map(|x| stops(edited[x], plain[x]).abs()) .collect(); let peak = moved.iter().cloned().fold(0.0f32, f32::max); // The width of the band that moved by more than a tenth of the peak — // a threshold relative to the effect, so it means the same thing at // both sizes. let touched = moved.iter().filter(|m| **m > peak * 0.1).count(); (peak, touched as f32 / out as f32) }; let (proxy_peak, proxy_reach) = measure(SIZE / 2); let (export_peak, export_reach) = measure(SIZE); assert!( (proxy_peak - export_peak).abs() < 0.03, "the same edit is {proxy_peak:.3} stops on the proxy and \ {export_peak:.3} in the export" ); assert!( (proxy_reach - export_reach).abs() < 0.02, "the effect covers {proxy_reach:.3} of the proxy and {export_reach:.3} \ of the export; a radius tuned on screen must land in the file" ); // And it is a real effect at both sizes, not two flat images agreeing. assert!( proxy_peak > 0.1 && proxy_reach > 0.02, "{proxy_peak:.3} stops over {proxy_reach:.3} of the proxy" ); } #[test] fn texture_acts_at_a_finer_scale_than_clarity() { // The whole reason there are two nodes. If the two controls ever reach the // same distance from an edge, the second slider has become a duplicate of // the first and a photographer setting both is setting one thing twice. let Some(ctx) = ctx() else { return }; let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); let mut plain_pass = AdjustPass::new(&ctx); let plain = row( &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), SIZE, SIZE / 2, ); let reach = |op: OpId| -> usize { let mut pass = AdjustPass::new(&ctx); let edited = row( &render(&mut pass, &graph_with(op, 100.0), &source, SIZE), SIZE, SIZE / 2, ); // How many columns moved by more than a code value — the honest // measure of "how far from the edge does this control reach". (0..SIZE as usize) .filter(|&x| edited[x].abs_diff(plain[x]) > 1) .count() }; let coarse = reach(CLARITY); let fine = reach(TEXTURE); assert!(fine > 0, "texture did nothing at all"); assert!( coarse > fine * 4, "clarity reaches {coarse} columns and texture {fine}; these are not \ separable scales" ); } #[test] fn clarity_moves_luminance_without_moving_hue() { // The third halo decision, in pixels. The gain is applied as a scale on // the whole triple, so chromaticity is untouched; boosting the channels // independently would put a *coloured* fringe along every edge, arriving // from a control the photographer reads as contrast. let Some(ctx) = ctx() else { return }; // A strongly tinted step, so a per-channel mask would show plainly. let source = step_edge(&ctx, SIZE, [1.0, 0.55, 0.25]); let mut plain_pass = AdjustPass::new(&ctx); let plain = row_rgb( &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), SIZE, SIZE / 2, ); let mut pass = AdjustPass::new(&ctx); let edited = row_rgb( &render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE), SIZE, SIZE / 2, ); // Compare in linear light, where a scale is a scale. The two channel // ratios together fix the chromaticity, so holding both fixes the colour. let edge = (SIZE / 2) as usize; for x in [edge, edge + 1, edge + 4, edge - 1, edge - 4] { let ratio = |p: [u8; 3], i: usize| srgb_decode(p[i]) / srgb_decode(p[0]).max(1e-6); for channel in [1, 2] { let before = ratio(plain[x], channel); let after = ratio(edited[x], channel); assert!( (after - before).abs() < 0.02, "column {x} channel {channel}: chromaticity moved from \ {before:.4} to {after:.4} — that is a coloured fringe" ); } } // And the effect was actually applied here, or the assertion above is // vacuous. assert!(edited[edge][0].abs_diff(plain[edge][0]) > 3); } #[test] fn negative_clarity_softens_the_surface_without_dissolving_the_edge() { // The soft limit earns its keep in both directions. An unlimited mask at // −100 subtracts the whole detail signal and turns every edge to mud; // limited, it removes at most the threshold, so modelling softens and real // edges stand. let Some(ctx) = ctx() else { return }; let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); let mut plain_pass = AdjustPass::new(&ctx); let plain = row( &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), SIZE, SIZE / 2, ); let mut pass = AdjustPass::new(&ctx); let softened = row( &render(&mut pass, &graph_with(CLARITY, -100.0), &source, SIZE), SIZE, SIZE / 2, ); let edge = (SIZE / 2) as usize; // The sign is the other way round from the positive case: the bright side // of the edge comes down and the dark side comes up. assert!( softened[edge] + 3 < plain[edge], "negative clarity did not soften: {} vs {}", softened[edge], plain[edge] ); // But the step itself survives. Measured in stops across the edge, so the // claim is about contrast and not about code values. let step_of = |r: &[u8]| (srgb_decode(r[edge]) / srgb_decode(r[edge - 1])).log2(); let before = step_of(&plain); let after = step_of(&softened); assert!( after > before * 0.55, "the edge dissolved: {after:.3} stops left of {before:.3}" ); } #[test] fn neutral_controls_cost_the_edit_nothing() { // Both nodes are in the default chain, and both are the widest kernels in // the pipeline. An unedited photograph must render through the single // fused dispatch it always did — no detail pass, no intermediate texture, // and byte-identical pixels. let Some(ctx) = ctx() else { return }; let source = step_edge(&ctx, 128, [1.0, 1.0, 1.0]); let graph = EditGraph::default_chain(); assert_eq!( graph.compose_for(ColourSpace::Srgb).output_mode, OutputMode::Encoded, "a neutral detail operation must not change how the fused pass ends" ); let mut pass = AdjustPass::new(&ctx); render(&mut pass, &graph, &source, 128); assert_eq!(pass.colour_dispatches(), 1); assert_eq!(pass.detail_dispatches(), 0); assert_eq!(pass.detail_allocations(), 0, "nothing was allocated"); } #[test] fn dragging_the_slider_re_runs_the_detail_stage_and_nothing_else() { // TRACES: FR-DEV-3d. Clarity is `Affects::Detail`, so the fused colour // pass's result is still valid while the slider moves — which for a // hundred-tap kernel is the difference between an interactive control and // a slideshow. Invisible in the output by construction, so a dispatch // counter is the only thing that can see it. let Some(ctx) = ctx() else { return }; let source = step_edge(&ctx, 256, [1.0, 1.0, 1.0]); let mut pass = AdjustPass::new(&ctx); let mut graph = graph_with(CLARITY, 40.0); render(&mut pass, &graph, &source, 256); assert_eq!(pass.colour_dispatches(), 1); assert_eq!(pass.detail_dispatches(), 2, "a separable mask is two passes"); let pipelines = pass.cached_detail_pipelines(); for amount in [50.0, 60.0, 70.0] { graph.set_param(CLARITY, AMOUNT, amount); render(&mut pass, &graph, &source, 256); } assert_eq!( pass.colour_dispatches(), 1, "the fused colour pass re-ran for a change it does not depend on" ); assert_eq!(pass.detail_dispatches(), 8); assert_eq!( pass.cached_detail_pipelines(), pipelines, "an amount is a uniform, not a shader" ); // Turning on the other control adds its own pair, and only its own pair. graph.set_param(TEXTURE, AMOUNT, 40.0); render(&mut pass, &graph, &source, 256); assert_eq!(pass.detail_dispatches(), 12); assert_eq!(pass.colour_dispatches(), 1); } #[test] fn the_two_controls_stack_without_overwriting_each_other() { // Four passes through one ping-pong, with the scratch lane changing hands // half way. If clarity's combining pass left the colour where its blur // pass had put it — or if texture's blur overwrote the colour rather than // the lane — the result would be a blurred image rather than a sharpened // one, which is loud rather than subtle. let Some(ctx) = ctx() else { return }; let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); let mut plain_pass = AdjustPass::new(&ctx); let plain = row( &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), SIZE, SIZE / 2, ); let mut graph = graph_with(CLARITY, 80.0); graph.set_param(TEXTURE, AMOUNT, 80.0); let mut pass = AdjustPass::new(&ctx); let both = row(&render(&mut pass, &graph, &source, SIZE), SIZE, SIZE / 2); assert_eq!(pass.detail_dispatches(), 4); let edge = (SIZE / 2) as usize; // Both sides of the edge move the way local contrast moves them... assert!(both[edge] > plain[edge] + 4); assert!(both[edge - 1] + 4 < plain[edge - 1]); // ...and the plateaus are untouched, which a stray blur would not leave. assert!(both[0].abs_diff(plain[0]) <= 1); assert!(both[SIZE as usize - 1].abs_diff(plain[SIZE as usize - 1]) <= 1); // Stacked, they must reach further than either alone — the coarse control // still working at its own scale rather than being overwritten by the fine // one running after it. let mut clarity_only = AdjustPass::new(&ctx); let coarse = row( &render(&mut clarity_only, &graph_with(CLARITY, 80.0), &source, SIZE), SIZE, SIZE / 2, ); assert!( both[edge] >= coarse[edge], "adding texture undid clarity: {} against {}", both[edge], coarse[edge] ); } #[test] fn texture_contributes_nothing_where_its_scale_does_not_exist() { // Unlike the acutance family this is not an approximation being hidden. A // two-pixel surface structure is not present in a 128-pixel rendering of // the frame, so the honest answer is no pass at all — and clarity, a // hundred times wider, still runs, which is what a thumbnail should show. let Some(ctx) = ctx() else { return }; let source = step_edge(&ctx, 512, [1.0, 1.0, 1.0]); let mut graph = graph_with(TEXTURE, 100.0); // Asserted on the composed chain rather than on a dispatch counter, // because texture *alone* at this size is a configuration the stage as a // whole cannot currently render, and that is a gap in the seam rather than // in this operation. // // `compose_full` decides whether the fused pass should hand on linear // working values from `is_active()`, which has no `RenderScale` to consult; // `compose_detail` decides what to dispatch from the kernel it can actually // draw at this scale. Almost always the two agree. They disagree exactly // when a detail operation is active and its kernel rounds away, and then // `render_detailed` finds an empty chain, falls through to `render_masked`, // and is rejected for handing a linear-working shader to the plain path. // // Pre-existing, and not something clarity and texture introduce: // `DetailStage::passes` documents the empty return as *the honest answer // for an acutance operation on a heavy proxy*, so capture sharpening and // noise reduction reach it by the same road. Fixing it means composing // both halves of a render together, so the fused half can know whether a // detail half survived the scale — a change at the composition boundary, // not in this file. let scale = graph.render_scale(source.size(), (128, 128)); assert!( graph.compose_detail_for(scale, ColourSpace::Srgb).is_empty(), "texture claimed a kernel it cannot draw" ); // With clarity on as well the edit is renderable again, and the dispatch // count says what the assertion above says: two passes, not four. Texture // is active, and contributes nothing. graph.set_param(CLARITY, AMOUNT, 100.0); let mut pass = AdjustPass::new(&ctx); render(&mut pass, &graph, &source, 128); assert_eq!( pass.detail_dispatches(), 2, "clarity survives a thumbnail, and texture added nothing beside it" ); // And texture comes back, exactly, as soon as the view is large enough to // hold it — no separate path, no fade, just the kernel resolving again. let mut zoomed = AdjustPass::new(&ctx); render(&mut zoomed, &graph_with(TEXTURE, 100.0), &source, 1024); assert_eq!(zoomed.detail_dispatches(), 2); }