//! Capture sharpening, end to end on a real device. //! //! `dr-pipeline`'s own tests assert what the composer *generates* — the kernel //! extent, the uniforms, which pass encodes. None of them can tell whether the //! generated WGSL compiles, whether the second pass is handed what the first //! one wrote, or whether the result is sharpening rather than a shader that //! silently produced the input again. Those are questions only a GPU answers. //! //! # Reading the expected values //! //! The source is uploaded through `DemosaicedImage::from_rgba8`, which flags it //! non-linear, so the generated shader decodes sRGB before any operation runs //! and a black/white step reaches the detail stage as linear 0.0 and 1.0 //! exactly. The last detail pass re-encodes. So a byte read back here is //! `srgb_encode(whatever the kernel produced in linear light)`, and an //! overshoot — the bright fringe an unsharp mask puts on the light side of an //! edge — cannot show above 255 on the white side of a full-scale step, and the //! undershoot on the dark side of one clips to black long before the halo has //! been drawn. The tests therefore use a **grey** step, from byte 90 to byte //! 150, which at 100% amount leaves the whole halo inside the representable //! range at both ends. Every expected value below is arithmetic on that step, //! not a number read off a previous run. use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext}; use dr_pipeline::descriptor::ParamId; use dr_pipeline::ops::capture_sharpen::{AMOUNT, ID, RADIUS, THRESHOLD}; use dr_pipeline::{Affects, EditGraph}; use dr_types::ColourSpace; 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 } } } /// A vertical step from `low` to `high`, changing at the middle column. /// /// The one image whose sharpening is worth checking by hand: an unsharp mask /// must darken the last few columns before the step and brighten the first few /// after it, and leave everything further away exactly where it was. A gradient /// would blur to itself and hide a kernel that does nothing at all. fn step_edge(ctx: &GpuContext, size: u32, low: u8, high: u8) -> DemosaicedImage { let data: Vec = (0..size * size) .flat_map(|i| { let v = if (i % size) < size / 2 { low } else { high }; [v, v, v, 255] }) .collect(); DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload") } /// A flat field of one value. fn flat(ctx: &GpuContext, size: u32, value: u8) -> DemosaicedImage { let data: Vec = (0..size * size) .flat_map(|_| [value, value, value, 255]) .collect(); DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload") } /// One row of the rendered image, red channel, as bytes. fn row(pixels: &[u8], width: u32, y: u32) -> Vec { (0..width) .map(|x| pixels[((y * width + x) * 4) as usize]) .collect() } /// The develop chain with capture sharpening set. fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph { let mut graph = EditGraph::default_chain(); graph.set_param(ID, AMOUNT, amount); graph.set_param(ID, RADIUS, radius); graph.set_param(ID, THRESHOLD, threshold); graph } /// Render one graph, with its detail stage, and read the pixels back. /// /// The whole calling convention a frontend adopts, in five lines: compose both /// halves from one graph at one output space, ask the graph for the scale, and /// pass the invalidation key through. 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(scale.full_size(), scale.render_size()); 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 } #[test] fn an_unsharp_mask_puts_a_halo_on_the_edge_and_leaves_the_rest_alone() { // What sharpening *is*, asserted as pixels rather than as "something // changed": an undershoot immediately before the transition, an overshoot // immediately after it, the step itself steeper than it was, and the flat // ground at either end untouched. A shader that ran the blur and forgot to // add the difference back would pass a "the image changed" test and fail // every one of these. let Some(ctx) = ctx() else { return }; const SIZE: u32 = 64; let source = step_edge(&ctx, SIZE, 90, 150); let plain = render( &mut AdjustPass::new(&ctx), &EditGraph::default_chain(), &source, SIZE, ); let sharp = render( &mut AdjustPass::new(&ctx), &sharpened(100.0, 2.0, 0.0), &source, SIZE, ); let before = row(&plain, SIZE, SIZE / 2); let after = row(&sharp, SIZE, SIZE / 2); let edge = (SIZE / 2) as usize; // The dark side of the transition is driven darker and the light side // lighter — the halo. Two pixels in, where a two-pixel-sigma kernel has // most of its response. assert!( after[edge - 2] < before[edge - 2], "the dark side of the edge should be pushed down: {} -> {}", before[edge - 2], after[edge - 2] ); assert!( after[edge + 1] > before[edge + 1], "the light side of the edge should be pushed up: {} -> {}", before[edge + 1], after[edge + 1] ); // And the transition really is steeper across the same two columns. let slope = |r: &[u8]| r[edge] as i32 - r[edge - 1] as i32; assert!( slope(&after) > slope(&before), "sharpening must steepen the edge: {} -> {}", slope(&before), slope(&after) ); // Far from the edge there is nothing to sharpen, so nothing may move. This // is the property a kernel that forgot to normalise its weights breaks, // and it breaks it as a brightness shift over the whole photograph. for x in [0usize, 4, 8, SIZE as usize - 1] { assert!( after[x].abs_diff(before[x]) <= 1, "column {x} is flat ground and moved: {} -> {}", before[x], after[x] ); } } #[test] fn a_flat_field_survives_any_amount_of_sharpening() { // The kernel sums to one — `(1 + a)` of the pixel minus `a` of its blur — // so a sky must come through bit for bit however far the slider is pushed. // The border is the part that is easy to get wrong: `tap` clamps, and a // kernel that normalised by an analytic integral instead of by the weights // it actually summed would draw a band around the whole frame. let Some(ctx) = ctx() else { return }; const SIZE: u32 = 48; let source = flat(&ctx, SIZE, 128); let plain = render( &mut AdjustPass::new(&ctx), &EditGraph::default_chain(), &source, SIZE, ); let sharp = render( &mut AdjustPass::new(&ctx), &sharpened(100.0, 3.0, 0.0), &source, SIZE, ); for (i, (a, b)) in sharp.iter().zip(&plain).enumerate() { assert!( a.abs_diff(*b) <= 1, "pixel {} of a flat field moved: {b} -> {a}", i / 4 ); } } #[test] fn a_proxy_and_an_export_sharpen_the_same_photograph() { // TRACES: FR-DSP-1 — the decision this operation is most likely to get // wrong, and the one that is invisible until an export comes back wrong. // // The same edit, rendered at two resolutions of one source. The radius is // in source pixels, so the halo must cover the same *proportion of the // picture* at both: a fringe four source pixels wide is four source pixels // wide whether it was drawn on a half-size proxy or at full size. // // Read the radius as render pixels instead and the proxy's halo would be // twice as wide relative to the frame and roughly twice as strong, so what // was tuned on screen would not be what landed in the file. That is the // failure this catches, and it is a large one: the widths would differ by a // factor of two, not by a rounding. let Some(ctx) = ctx() else { return }; const SOURCE: u32 = 128; let source = step_edge(&ctx, SOURCE, 90, 150); // The widest radius the slider offers, so that even the half-size proxy // has a 1.5-pixel sigma and resolves it — the honest cut-off is tested in // `dr-pipeline`, and this test is about the case where both renders draw. // Asking for more would be asking for a photograph nobody can produce: // `EditGraph::set_param` clamps to the descriptor on the way in. let graph = sharpened(100.0, 3.0, 0.0); // The halo, measured against the same edit with no sharpening at the same // size: how far from the transition the picture is still disturbed, as a // fraction of the frame, and how much deviation the halo carries in total. let measure = |out: u32| -> (f32, f32) { let plain = render( &mut AdjustPass::new(&ctx), &EditGraph::default_chain(), &source, out, ); let sharp = render(&mut AdjustPass::new(&ctx), &graph, &source, out); let (a, b) = (row(&plain, out, out / 2), row(&sharp, out, out / 2)); let disturbed: Vec = (0..out as usize) .filter(|&x| b[x].abs_diff(a[x]) > 3) .collect(); let first = *disturbed.first().expect("a halo"); let last = *disturbed.last().expect("a halo"); // The halo's strength as an *area* — the sum of the deviations, scaled // by the width of a render pixel — rather than as its peak. A peak is // one sample of a smooth curve, and the two renders do not sample it at // the same place: the pixel next to the transition sits half a render // pixel from it, which is half a source pixel at export and a whole one // on the proxy, so their peaks would legitimately differ by more than // the property under test. An integral over the same curve does not // care where the samples fell. let area: f32 = (0..out as usize) .map(|x| b[x].abs_diff(a[x]) as f32) .sum::() / out as f32; ((last - first) as f32 / out as f32, area) }; let (proxy_width, proxy_area) = measure(SOURCE / 2); let (export_width, export_area) = measure(SOURCE); assert!( (proxy_width - export_width).abs() < 0.06, "the halo covers {proxy_width:.3} of the proxy and {export_width:.3} \ of the export; a radius tuned on screen must land in the file" ); // The strength has to agree too. A viewport-scaled kernel would not only // be wider on the proxy, it would push the fringe further, because a wider // blur takes more away for the high-pass to add back — so the areas would // differ by considerably more than the sampling slack allowed here. let ratio = proxy_area / export_area; assert!( (0.75..1.35).contains(&ratio), "the halo carries {proxy_area:.2} on the proxy and {export_area:.2} at \ export, a ratio of {ratio:.2}" ); // And both are a real halo rather than two flat images agreeing. assert!( proxy_width > 0.05 && export_width > 0.05, "{proxy_width:.3} / {export_width:.3}" ); assert!( proxy_area > 1.0 && export_area > 1.0, "{proxy_area} / {export_area}" ); } #[test] fn the_threshold_leaves_shallow_modulation_where_it_found_it() { // What the threshold is for: sensor noise is shallow, and sharpening it is // the fastest way to make a clean frame look worse. Two images, one with a // strong edge and one with a shallow ripple, through the same gate — the // edge must still sharpen and the ripple must not. let Some(ctx) = ctx() else { return }; const SIZE: u32 = 64; // A four-code ripple: about 4% local contrast at this level, which is the // order of magnitude read noise reaches on a well-exposed frame — and well // under the 12.5% at which the gate below starts letting detail through. let ripple: Vec = (0..SIZE * SIZE) .flat_map(|i| { let v = if (i % SIZE).is_multiple_of(2) { 128u8 } else { 132 }; [v, v, v, 255] }) .collect(); let ripple = DemosaicedImage::from_rgba8(&ctx, &ripple, SIZE, SIZE).expect("upload"); let edge = step_edge(&ctx, SIZE, 90, 150); let gated = sharpened(100.0, 1.0, 1.0); let ungated = sharpened(100.0, 1.0, 0.0); let spread = |graph: &EditGraph, source: &DemosaicedImage| -> u8 { let pixels = render(&mut AdjustPass::new(&ctx), graph, source, SIZE); let line = row(&pixels, SIZE, SIZE / 2); // Peak-to-peak over the middle of the row, away from the border. let window = &line[8..24]; window.iter().max().unwrap() - window.iter().min().unwrap() }; let ripple_open = spread(&ungated, &ripple); let ripple_gated = spread(&gated, &ripple); assert!( ripple_gated < ripple_open, "the gate must hold shallow modulation back: {ripple_open} -> \ {ripple_gated}" ); // The edge is deep modulation and must come through the same gate // sharpened — a threshold that flattens everything is not a threshold. let plain_edge = { let pixels = render( &mut AdjustPass::new(&ctx), &EditGraph::default_chain(), &edge, SIZE, ); row(&pixels, SIZE, SIZE / 2) }; let gated_edge = { let pixels = render(&mut AdjustPass::new(&ctx), &gated, &edge, SIZE); row(&pixels, SIZE, SIZE / 2) }; let mid = (SIZE / 2) as usize; assert!( gated_edge[mid - 1] < plain_edge[mid - 1], "a real edge must still sharpen through the gate: {} -> {}", plain_edge[mid - 1], gated_edge[mid - 1] ); } #[test] fn sharpening_an_edge_does_not_change_its_colour() { // The reason the high-pass is applied as a gain on the three channels // rather than as an offset. An offset moves a saturated colour towards // grey as it brightens it, so a sharpened red roof gets a pink fringe — // which reads as chromatic aberration and gets blamed on the lens. let Some(ctx) = ctx() else { return }; const SIZE: u32 = 64; // A step between two saturated reds of different brightness: the ratios // between the channels are the colour, and they must survive the halo. let data: Vec = (0..SIZE * SIZE) .flat_map(|i| { if (i % SIZE) < SIZE / 2 { [80u8, 30, 30, 255] } else { [200, 75, 75, 255] } }) .collect(); let source = DemosaicedImage::from_rgba8(&ctx, &data, SIZE, SIZE).expect("upload"); let pixels = render( &mut AdjustPass::new(&ctx), &sharpened(60.0, 2.0, 0.0), &source, SIZE, ); // Sampled inside the halo, where an additive sharpener would have washed // the colour out most. let y = SIZE / 2; for x in [SIZE / 2 - 2, SIZE / 2 + 1] { let i = ((y * SIZE + x) * 4) as usize; let (r, g, b) = (pixels[i] as f32, pixels[i + 1] as f32, pixels[i + 2] as f32); assert!(r > g && r > b, "the fringe lost its hue at column {x}"); // Green and blue started equal and must stay equal: an offset would // keep them equal too, but the *ratio* to red is what moves, and this // is the assertion that it did not. let saturation = (r - g) / r; assert!( saturation > 0.55, "column {x} washed out: rgb {r} {g} {b}, saturation {saturation:.3}" ); } } #[test] fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() { // The two costs that are ruinous per frame and invisible in the output. // A sharpening slider is dragged continuously, so this is the difference // between a control that tracks the mouse and one that stutters. let Some(ctx) = ctx() else { return }; const SIZE: u32 = 48; let source = step_edge(&ctx, SIZE, 90, 150); let mut pass = AdjustPass::new(&ctx); let mut graph = sharpened(40.0, 1.0, 0.0); render(&mut pass, &graph, &source, SIZE); let pipelines = pass.cached_detail_pipelines(); let allocations = pass.detail_allocations(); assert_eq!(pipelines, 2, "one per axis of the separable mask"); assert_eq!(allocations, 3, "the colour result, and the ping-pong pair"); assert_eq!(pass.detail_dispatches(), 2); assert_eq!(pass.colour_dispatches(), 1); for amount in [50.0, 60.0, 70.0, 80.0] { graph.set_param(ID, AMOUNT, amount); render(&mut pass, &graph, &source, 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" ); // TRACES: FR-DEV-3d — and the operational point of `Affects::Detail`: // sharpening is downstream of every fused operation, so dragging it must // not re-run them. assert_eq!( pass.colour_dispatches(), 1, "the fused colour pass re-ran for a change it does not depend on" ); // The radius is also only a uniform, even though it changes the kernel // extent — the loop bound is read from the uniform block rather than // baked into the source, which is what keeps a drag off the compiler. graph.set_param(ID, RADIUS, 2.5); render(&mut pass, &graph, &source, SIZE); assert_eq!(pass.cached_detail_pipelines(), pipelines); graph.set_param(ID, THRESHOLD, 0.3); render(&mut pass, &graph, &source, SIZE); assert_eq!(pass.cached_detail_pipelines(), pipelines); } #[test] fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() { // Proved on a device rather than argued about. With the radius finer than // a render pixel the operation declines to sharpen — but it is still // active, so the fused pass has already been composed to hand on // unclipped linear values, and something must still perform the output // transform. Since D19 that is the view pass, whatever the chain holds: // the chain is empty and the frame is still whole. let Some(ctx) = ctx() else { return }; const SOURCE: u32 = 128; const RENDER: u32 = 32; // a quarter scale, as a fit view of a large frame let source = step_edge(&ctx, SOURCE, 90, 150); let graph = sharpened(100.0, 1.0, 0.0); let scale = graph.render_scale((SOURCE, SOURCE), (RENDER, RENDER)); assert!(!scale.resolves(1.0), "the premise of this test"); let mut pass = AdjustPass::new(&ctx); let sharp = render(&mut pass, &graph, &source, RENDER); assert_eq!( pass.detail_dispatches(), 0, "nothing to sharpen at this scale" ); assert_eq!( pass.view_dispatches(), 1, "and the view pass finishes the frame" ); // And what reaches the screen is the unsharpened picture, not a black // frame, a linear one, or a guess. let plain = render( &mut AdjustPass::new(&ctx), &EditGraph::default_chain(), &source, RENDER, ); for (i, (a, b)) in sharp.iter().zip(&plain).enumerate() { assert!( a.abs_diff(*b) <= 1, "pixel {} differs from the unsharpened render: {b} -> {a}", i / 4 ); } } #[test] fn the_operation_is_reachable_by_the_ids_a_frontend_will_use() { // FR-DEV-3c: adding an operation needs no UI change, which is only true if // the panel can find it through the capability list. A typo between the // declaration's `id:` and the descriptor's would place it in the chain // under one name and address it under another. let graph = EditGraph::default_chain(); let cap = graph .capabilities() .into_iter() .find(|c| c.id == ID) .expect("capture sharpening is in the default chain"); let names: Vec = cap.params.iter().map(|p| p.id).collect(); assert_eq!(names, vec![AMOUNT, RADIUS, THRESHOLD]); assert!(!cap.active, "a fresh chain is not sharpening anything"); }