//! TRACES: FR-DEV-3j | FR-DEV-2 //! The view transform, end to end on a device. //! //! `dr-pipeline` checks the curve on the CPU and that the composer emits it in //! the right place. Neither would notice a shader that disagreed with the CPU //! reference, or a clamp somewhere upstream that made two highlights the same //! number before the curve ever saw them — which is exactly what the retired //! base curve did, and why D19 exists. So this renders real pixels. use dr_decode::{CfaPattern, CropRect, RawImage}; use dr_gpu::{AdjustPass, Demosaicer, GpuContext}; use dr_pipeline::view::Sigmoid; use dr_pipeline::EditGraph; const SIZE: u32 = 16; fn ctx() -> Option { pollster::block_on(GpuContext::new_headless()).ok() } /// A flat RGGB frame at `level` out of 65535, with an identity matrix and a /// neutral balance, so the only things that move a pixel are the edit and the /// view transform. fn flat_raw(level: u16) -> RawImage { RawImage { width: SIZE, height: SIZE, data: vec![level; (SIZE * SIZE) as usize], cfa_pattern: CfaPattern::Rggb, black_level: [0; 4], white_level: u16::MAX, wb_coeffs: [1.0, 1.0, 1.0, 1.0], color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]), samples_per_pixel: 1, profile: None, profile_tables: None, baseline_exposure: 0.0, make: String::new(), model: String::new(), crop: CropRect { x: 0, y: 0, width: SIZE, height: SIZE, }, } } /// The default chain with D19's sigmoid chosen: Camera Raw's tone is the /// default since D21, and these tests are about the sigmoid. fn sigmoid_chain() -> EditGraph { let mut g = EditGraph::default_chain(); g.set_param( dr_pipeline::ops::view_transform::ID, dr_pipeline::ops::view_transform::CURVE, dr_pipeline::ops::view_transform::SIGMOID, ); g } #[test] fn camera_raw_tone_is_the_default_and_agrees_with_the_acr3_curve() { // TRACES: FR-DEV-3j // D21: a raw with no profile renders a grey through Camera Raw's default // curve, which the profile buffer's placeholder carries. let Some(ctx) = ctx() else { eprintln!("skipping: no GPU adapter"); return; }; let graph = EditGraph::default_chain(); for level in [500u16, 4_000, 8_520, 20_000, 40_000] { let scene = f32::from(level) / f32::from(u16::MAX); let display = dr_types::tone::evaluate(&dr_types::tone::ACR3_DEFAULT, scene); let expected = (dr_types::Transfer::Srgb.encode(display) * 255.0).round() as i32; let got = i32::from(rendered(&ctx, level, &graph)); assert!( (got - expected).abs() <= 2, "raw {level} rendered as {got}, the ACR3 curve says {expected}" ); } } /// Render `graph` over a flat frame and return the centre pixel's red. /// /// The centre rather than a corner: a demosaic has to invent its edges. fn rendered(ctx: &GpuContext, level: u16, graph: &EditGraph) -> u8 { let source = Demosaicer::new(ctx) .expect("demosaicer") .run(&flat_raw(level)) .expect("demosaic"); let shader = graph.compose(); let mut adjust = AdjustPass::new(ctx); adjust.render(&source, &shader, SIZE, SIZE).expect("render"); let (pixels, _, _) = adjust.export_pixels().expect("readback"); let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4; pixels[centre as usize] } #[test] fn the_shader_agrees_with_the_cpu_reference() { // TRACES: FR-DEV-3j let Some(ctx) = ctx() else { eprintln!("skipping: no GPU adapter"); return; }; let curve = Sigmoid::default_curve(); let graph = sigmoid_chain(); for level in [0u16, 500, 4_000, 8_520, 32_768, 60_000, u16::MAX] { let scene = f32::from(level) / f32::from(u16::MAX); let display = curve.channel(scene).min(1.0); let expected = (dr_types::Transfer::Srgb.encode(display) * 255.0).round() as i32; let got = i32::from(rendered(&ctx, level, &graph)); // Two 8-bit steps, for the `Rgba16Float` intermediate and the // rounding either side of the encode. assert!( (got - expected).abs() <= 2, "raw {level} rendered as {got}, expected about {expected}" ); } } #[test] fn highlights_above_one_stay_distinct() { // TRACES: FR-DEV-2 | FR-DEV-3j // The failure D19 names first. Two stops of exposure put these two // frames at 1.0 and 1.5 of sensor saturation. The base curve was flat // past 1.0, so both rendered as the same white; the view transform's // shoulder still separates them. let Some(ctx) = ctx() else { eprintln!("skipping: no GPU adapter"); return; }; let mut graph = sigmoid_chain(); graph.set_param( dr_pipeline::ops::exposure::ID, dr_pipeline::ops::exposure::EXPOSURE, 2.0, ); let lower = rendered(&ctx, u16::MAX / 4, &graph); let upper = rendered(&ctx, (u16::MAX / 8) * 3, &graph); assert!( upper > lower, "scene 1.0 rendered {lower} and scene 1.5 rendered {upper}" ); assert!(upper < 255, "scene 1.5 is below the default white point"); } #[test] fn the_rendering_is_monotone_through_the_whole_range() { // TRACES: FR-DEV-3j // A dip anywhere puts a dark band across a smooth gradient — a sky, most // visibly. let Some(ctx) = ctx() else { eprintln!("skipping: no GPU adapter"); return; }; let graph = EditGraph::default_chain(); let mut last = 0u8; for step in 0..=32u32 { let level = (step * u32::from(u16::MAX) / 32) as u16; let got = rendered(&ctx, level, &graph); assert!(got >= last, "raw {level} rendered {got}, below {last}"); last = got; } } /// Render `graph` over a flat frame through `render_detailed`, the path every /// frontend takes, and return the centre pixel's red. fn rendered_detailed(ctx: &GpuContext, level: u16, graph: &EditGraph) -> (u8, AdjustPass) { let source = Demosaicer::new(ctx) .expect("demosaicer") .run(&flat_raw(level)) .expect("demosaic"); let shader = graph.compose_for(dr_types::ColourSpace::Srgb); let detail = graph.compose_detail(source.size(), (SIZE, SIZE)); let key = graph.invalidation().through(dr_pipeline::Affects::Colour); let mut adjust = AdjustPass::new(ctx); adjust .render_detailed(&source, &shader, SIZE, SIZE, None, &detail, key) .expect("render"); let (pixels, _, _) = adjust.export_pixels().expect("readback"); let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4; (pixels[centre as usize], adjust) } #[test] fn a_detail_stage_renders_through_the_view_pass_unchanged() { // TRACES: FR-DEV-3j | FR-DEV-2 // With a detail stage the view transform is a dispatch of its own after // it (D19). Sharpening a flat field changes nothing, so the same frame // with and without it must render the same: the view pass read the detail // stage's result, applied the view transform once, and encoded once. let Some(ctx) = ctx() else { eprintln!("skipping: no GPU adapter"); return; }; let plain = rendered(&ctx, 8_520, &EditGraph::default_chain()); let mut sharpened = EditGraph::default_chain(); let id = dr_pipeline::ops::capture_sharpen::ID; sharpened.set_param(id, dr_pipeline::ops::capture_sharpen::AMOUNT, 100.0); sharpened.set_param(id, dr_pipeline::ops::capture_sharpen::RADIUS, 1.0); let (detailed, pass) = rendered_detailed(&ctx, 8_520, &sharpened); assert!( pass.detail_dispatches() > 0, "the premise: a detail stage ran" ); assert_eq!(pass.view_dispatches(), 1); assert!( detailed.abs_diff(plain) <= 1, "with a detail stage {detailed}, without {plain}" ); }