// TRACES: FR-DEV-10 //! A range mask must select by the photograph's values, and by nothing else. //! //! Two properties, and both are silent when they break. A range mask that //! reads the wrong pixels still produces a plausible-looking selection, and one //! that depends on the size it was rasterised at looks right in the develop //! view and wrong only in the export — the one place nobody is watching. //! //! Everything here goes through the real pass. There is no CPU rasterisation of //! a mask to test against and there must not be (ARCH §5.4), so the mask is //! observed the only way it exists: through the adjustment it weights. use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass}; use dr_pipeline::descriptor::ParamId; use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack}; use dr_pipeline::operation::compose_full; use dr_pipeline::spot::SpotSet; use dr_pipeline::{ops, Framing}; use dr_types::ColourSpace; /// The source and the output are the same size, so a difference between two /// runs can only have come from the mask. const SIZE: u32 = 64; fn ctx() -> Option { pollster::block_on(GpuContext::new_headless()).ok() } /// A frame whose left half is one colour and right half another. /// /// Deliberately not a ramp. A range mask's whole job is to divide the picture /// by value, so a source that is already divided by value makes the assertion /// "the mask found the half it was aimed at" rather than "the mask is roughly /// where it should be". fn split(ctx: &GpuContext, left: [u8; 3], right: [u8; 3]) -> DemosaicedImage { let data: Vec = (0..SIZE * SIZE) .flat_map(|i| { let c = if i % SIZE < SIZE / 2 { left } else { right }; [c[0], c[1], c[2], 255] }) .collect(); DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload") } fn brightened(source: MaskSource) -> MaskStack { let mut stack = MaskStack::new(); let mut layer = MaskLayer::new("m1", source); // Two stops, so "selected" and "not selected" are not a judgement call. layer.set_param("exposure", ParamId("exposure"), 2.0); stack.push(layer); stack } /// Render `stack` over `source`, with the mask rasterised at `raster`. /// /// `raster` is a parameter because it is the thing that must not matter: the /// develop view and an export ask for the same mask at different sizes, and a /// range that answered differently at each would be a mask that changes when /// the photograph is exported. fn render(ctx: &GpuContext, source: &DemosaicedImage, stack: &MaskStack, raster: u32) -> Vec { let mut masks = MaskPass::new(ctx).expect("mask pass"); let array = masks .render(stack, None, None, Some(source), raster, raster) .expect("rasterise"); let shader = compose_full( &ops::chain(), &Framing::new(), ColourSpace::Srgb, stack, &SpotSet::new(), &[], ); let mut adjust = AdjustPass::new(ctx); adjust .render_masked(source, &shader, SIZE, SIZE, Some(array)) .expect("render"); adjust.export_pixels().expect("readback").0 } fn red_at(pixels: &[u8], x: u32, y: u32) -> u8 { pixels[((y * SIZE + x) * 4) as usize] } /// The centre of each half, away from the seam the mask's own softness /// straddles. fn halves(pixels: &[u8]) -> (u8, u8) { ( red_at(pixels, SIZE / 4, SIZE / 2), red_at(pixels, SIZE * 3 / 4, SIZE / 2), ) } /// TRACES: FR-DEV-10 #[test] fn a_tone_band_brightens_only_the_half_inside_it() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; // 200 lands near 0.83 on the perceptual scale and 30 near 0.24, so a band // over the upper half contains one and not the other with room to spare. let source = split(&ctx, [200, 200, 200], [30, 30, 30]); let stack = brightened(MaskSource::luminance_range(0.5, 1.0, 0.15)); let pixels = render(&ctx, &source, &stack, SIZE); let (bright, dark) = halves(&pixels); assert!( bright > 230, "the bright half is inside the band and should have been lifted, got {bright}" ); assert!( dark < 45, "the dark half is outside the band and must be untouched, got {dark}" ); } /// TRACES: FR-DEV-10 /// The property the develop view and the export path share. The mask array is /// rasterised at a proxy size in both, but nothing in this pass may *depend* /// on that size — a range is a function of the photograph's values, and those /// do not change when somebody asks for a bigger picture. #[test] fn a_tone_band_is_the_same_mask_at_any_raster_size() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let source = split(&ctx, [200, 200, 200], [30, 30, 30]); let stack = brightened(MaskSource::luminance_range(0.5, 1.0, 0.15)); // A quarter of the source and twice it: one mask texel averaging sixteen // source texels, and one source texel spread over four mask texels. let small = halves(&render(&ctx, &source, &stack, SIZE / 4)); let large = halves(&render(&ctx, &source, &stack, SIZE * 2)); // A tolerance rather than equality: the two rasters land their edges on // different grids, and the assertion is that the *selection* is the same, // not that two resamplings of it are bit-identical. assert!( small.0.abs_diff(large.0) <= 4 && small.1.abs_diff(large.1) <= 4, "the same band selected differently at two raster sizes: {small:?} vs {large:?}" ); } /// TRACES: FR-DEV-10 #[test] fn a_colour_band_follows_hue_rather_than_brightness() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; // Short of saturation on purpose. A fully clipped channel carries no // colour at all, and the pass pulls such a pixel back to neutral before // the band ever sees it — which is correct, and would make this test about // that instead. let source = split(&ctx, [200, 40, 40], [40, 40, 200]); // A narrow arc at red, above the chroma floor that keeps the greys out. let stack = brightened(MaskSource::colour_range(0.0, 0.05, 0.15, 1.0, 0.05)); let pixels = render(&ctx, &source, &stack, SIZE); let (red, blue) = halves(&pixels); assert!( red > 230, "the red half is inside the arc and should have been lifted, got {red}" ); assert!( blue < 60, "the blue half is a third of the circle away and must be untouched, got {blue}" ); } /// TRACES: FR-DEV-10 /// The greys a hue arc would otherwise sweep up. /// /// A nearly-neutral pixel still has a hue — three almost-equal channels round /// to one — so an arc without a chroma floor selects a haze of noise across /// every desaturated part of the picture. It is the failure that looks like the /// mask working badly rather than like a control that is missing. #[test] fn a_colour_band_ignores_the_greys() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; // Both halves neutral, at the two brightnesses the tone test uses, so the // only reason either could be selected is the hue arc reaching them. let source = split(&ctx, [200, 200, 200], [30, 30, 30]); let stack = brightened(MaskSource::colour_range(0.0, 0.5, 0.15, 1.0, 0.05)); let pixels = render(&ctx, &source, &stack, SIZE); let (light, dark) = halves(&pixels); assert!( light < 215 && dark < 45, "a grey frame was selected by a colour mask: {light}, {dark}" ); }