//! Local adjustments, end to end on a device. //! //! The unit tests either side of this one check halves: `dr-pipeline` asserts //! the generated WGSL says the right thing, and `dr-gpu`'s mask tests assert //! an array of the right shape comes out. Neither would notice if the two //! agreed with each other and both were wrong — a mask sampled with x and y //! swapped satisfies both. //! //! So this renders a real frame and reads the pixels back: the masked region //! must change, the rest must not, and the boundary must fall where the label //! field says it does. use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, LabelField, MaskPass}; use dr_pipeline::descriptor::ParamId; use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack}; use dr_pipeline::operation::compose_full; use dr_pipeline::{ops, EditGraph, Framing}; use dr_types::ColourSpace; const SIZE: u32 = 32; fn ctx() -> Option { pollster::block_on(GpuContext::new_headless()).ok() } /// A flat mid-grey JPEG-path image, so any change is the adjustment's. fn grey(ctx: &GpuContext) -> DemosaicedImage { let data: Vec = (0..SIZE * SIZE).flat_map(|_| [128, 128, 128, 255]).collect(); DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload") } /// Two regions: 0 is the left half, 1 the right. fn split_field(ctx: &GpuContext) -> LabelField { let labels: Vec = (0..SIZE * SIZE) .map(|i| u32::from(i % SIZE >= SIZE / 2)) .collect(); LabelField::upload(ctx, &labels, SIZE, SIZE, 2).expect("label upload") } /// A layer brightening whatever it covers, by a lot, so it cannot be missed. fn brighten(source: MaskSource) -> MaskLayer { let mut layer = MaskLayer::new("m1", source); layer.set_param("exposure", ParamId("exposure"), 2.0); layer } fn luma_at(pixels: &[u8], x: u32, y: u32) -> u8 { pixels[((y * SIZE + x) * 4) as usize] } /// Render `stack` over flat grey and hand back the RGBA8 result. fn render(ctx: &GpuContext, stack: &MaskStack, field: Option<&LabelField>) -> Vec { let source = grey(ctx); let shader = compose_full( &ops::chain(), &Framing::new(), ColourSpace::Srgb, stack, ); let mut masks = MaskPass::new(ctx).expect("mask pass"); let array = masks.render(stack, field, None, SIZE, SIZE).expect("rasterise"); let mut adjust = AdjustPass::new(ctx); adjust .render_masked(&source, &shader, SIZE, SIZE, Some(array)) .expect("render"); adjust.export_pixels().expect("readback").0 } #[test] fn a_region_mask_changes_only_the_regions_it_names() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let field = split_field(&ctx); let mut stack = MaskStack::new(); stack.push(brighten(MaskSource::Regions { signature: 1, level: 2, ids: vec![0], })); let pixels = render(&ctx, &stack, Some(&field)); // Sampled well inside each half, clear of the feathered boundary. let inside = luma_at(&pixels, 4, SIZE / 2); let outside = luma_at(&pixels, SIZE - 5, SIZE / 2); assert!( inside > outside + 40, "the masked half should be much brighter: {inside} vs {outside}" ); assert!( (120..=136).contains(&outside), "the unmasked half must be untouched mid-grey, got {outside}" ); } /// The failure a swapped axis or an inverted comparison would produce, and /// which the "inside is brighter" assertion alone would not catch. #[test] fn inverting_a_region_mask_swaps_which_half_moves() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let field = split_field(&ctx); let mut layer = brighten(MaskSource::Regions { signature: 1, level: 2, ids: vec![0], }); layer.invert = true; let mut stack = MaskStack::new(); stack.push(layer); let pixels = render(&ctx, &stack, Some(&field)); let left = luma_at(&pixels, 4, SIZE / 2); let right = luma_at(&pixels, SIZE - 5, SIZE / 2); assert!( right > left + 40, "inverted, the *other* half should brighten: left {left}, right {right}" ); } #[test] fn opacity_scales_the_effect() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let field = split_field(&ctx); let source = MaskSource::Regions { signature: 1, level: 2, ids: vec![0], }; let mut full = MaskStack::new(); full.push(brighten(source.clone())); let mut half = MaskStack::new(); let mut layer = brighten(source); layer.opacity = 0.5; half.push(layer); let at_full = luma_at(&render(&ctx, &full, Some(&field)), 4, SIZE / 2); let at_half = luma_at(&render(&ctx, &half, Some(&field)), 4, SIZE / 2); let untouched = 128; assert!( at_half > untouched && at_half < at_full, "half opacity should land between neutral and full: {untouched} < {at_half} < {at_full}" ); } #[test] fn a_linear_gradient_ramps_across_the_frame() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut stack = MaskStack::new(); stack.push(brighten(MaskSource::Linear { centre: (0.5, 0.5), angle: 0.0, width: 1.0, })); let pixels = render(&ctx, &stack, None); let left = luma_at(&pixels, 1, SIZE / 2); let middle = luma_at(&pixels, SIZE / 2, SIZE / 2); let right = luma_at(&pixels, SIZE - 2, SIZE / 2); assert!( left < middle && middle < right, "a horizontal ramp should increase left to right: {left}, {middle}, {right}" ); } #[test] fn a_radial_mask_is_strongest_at_its_centre() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut stack = MaskStack::new(); stack.push(brighten(MaskSource::Radial { centre: (0.5, 0.5), radii: (0.3, 0.3), angle: 0.0, feather: 0.5, })); let pixels = render(&ctx, &stack, None); let centre = luma_at(&pixels, SIZE / 2, SIZE / 2); let corner = luma_at(&pixels, 1, 1); assert!( centre > corner + 40, "the centre should carry the effect: {centre} vs corner {corner}" ); assert!( (120..=136).contains(&corner), "outside the radius must be untouched, got {corner}" ); } /// Two layers must not read each other's slice. #[test] fn stacked_layers_use_their_own_masks() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let field = split_field(&ctx); let mut stack = MaskStack::new(); // Left half up. stack.push(brighten(MaskSource::Regions { signature: 1, level: 2, ids: vec![0], })); // Right half down. let mut darken = MaskLayer::new("m2", MaskSource::Regions { signature: 1, level: 2, ids: vec![1], }); darken.set_param("exposure", ParamId("exposure"), -2.0); stack.push(darken); let pixels = render(&ctx, &stack, Some(&field)); let left = luma_at(&pixels, 4, SIZE / 2); let right = luma_at(&pixels, SIZE - 5, SIZE / 2); assert!(left > 150, "left should have brightened, got {left}"); assert!(right < 100, "right should have darkened, got {right}"); } /// A neutral edit must render identically whether or not masks are bound — /// otherwise merely *having* the feature would alter every unedited image. #[test] fn an_empty_stack_renders_exactly_as_the_unmasked_path() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let plain = { let source = grey(&ctx); let mut adjust = AdjustPass::new(&ctx); let shader = EditGraph::default_chain().compose(); adjust.render(&source, &shader, SIZE, SIZE).expect("render"); adjust.export_pixels().expect("readback").0 }; let masked = render(&ctx, &MaskStack::new(), None); assert_eq!(plain, masked, "an empty mask stack must be a no-op"); }