//! TRACES: FR-DEV-2 | FR-DEV-3j //! Scene-referred until the view transform (D19, ARCH §6.14), on a device. //! //! The rule is about every operation between the camera matrix and the view //! transform, so this runs each of them over a ramp that reaches sixteen //! times sensor saturation and asserts the two things a clip or an early //! encode would break: the output still increases with the input, and values //! above 1.0 still differ from one another. //! //! A clip above 1.0 cannot be seen through an 8-bit display encode on its //! own, so each operation is wrapped: a gain of sixteen ahead of it puts the //! ramp into the range the rule is about, a gain of one sixty-fourth after it //! brings the result back under 1.0 — with two stops to spare, for the //! operations that brighten — and an identity in the view transform's //! place stops the sigmoid compressing what is being measured. A fragment //! that clamps, or encodes and decodes through a clamped range, flattens the //! top of the ramp, and the last few steps come out equal. //! //! The view stage and the detail stage are excluded. The view transform and //! film simulation clip into a display range because that is their job, and //! a neighbourhood operation is a pass of its own that a flat frame cannot //! exercise. use std::sync::Arc; use dr_decode::{CfaPattern, CropRect, RawImage}; use dr_gpu::{AdjustPass, Demosaicer, GpuContext}; use dr_pipeline::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind}; use dr_pipeline::operation::{Operation, Stage, Uniform}; const SIZE: u32 = 16; fn ctx() -> Option { pollster::block_on(GpuContext::new_headless()).ok() } /// A gain, as a scene-stage operation, or an identity in the view stage. struct Probe { id: &'static str, gain: f32, stage: Stage, } impl Operation for Probe { fn descriptor(&self) -> Arc { Arc::new(OpDescriptor { id: OpId(self.id), label: LocalizedKey(self.id), params: Vec::new(), attributes: vec![Attribute::Tone], }) } fn set_param(&mut self, _: ParamId, _: f32) {} fn param(&self, _: ParamId) -> f32 { 0.0 } fn is_active(&self) -> bool { true } fn stage(&self) -> Stage { self.stage } /// The identity view claims the view transform's place: while it is /// active the composer emits it rather than the sigmoid. fn renders(&self) -> bool { self.stage == Stage::View } fn wgsl_body(&self) -> String { "c = c * gain;".into() } fn uniforms(&self) -> Vec { vec![Uniform { name: "gain", value: self.gain, }] } } fn probe(id: &'static str, gain: f32, stage: Stage) -> Box { Box::new(Probe { id, gain, stage }) } /// A flat frame at `level` of sensor saturation, identity matrix, neutral /// balance. fn flat(ctx: &GpuContext, level: f32) -> dr_gpu::DemosaicedImage { let raw = RawImage { width: SIZE, height: SIZE, data: vec![(level * f32::from(u16::MAX)).round() as u16; (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, make: String::new(), model: String::new(), crop: CropRect { x: 0, y: 0, width: SIZE, height: SIZE, }, }; Demosaicer::new(ctx) .expect("demosaicer") .run(&raw) .expect("demosaic") } /// Every parameter moved off its default, a third of the way toward its /// maximum — or toward its minimum where the default is the maximum. /// /// The tone curve is the exception, because its neutral is a relationship: /// its parameters are point coordinates, and moving every x and y the same /// fraction leaves the points on the diagonal. It gets a lifted midpoint on /// the master and on the red curve instead — the two helpers that clamped. fn non_neutral(op: &mut dyn Operation) { use dr_pipeline::ops::curve::{coordinate, Axis, Channel}; if op.descriptor().id == dr_pipeline::ops::curve::ID { op.set_param(coordinate(Channel::Master, 2, Axis::Y), 0.65); op.set_param(coordinate(Channel::Red, 2, Axis::Y), 0.6); return; } for p in &op.descriptor().params { if let ParamKind::Scalar { min, max, .. } = p.kind { let toward = if p.default < max { max } else { min }; op.set_param(p.id, p.default + (toward - p.default) / 3.0); } } } /// The ramp, as scene values after the sixteenfold gain: 0.4 to 16. /// /// Kept below 1.0 at the sensor, and away from its last 1.5%, because the /// prologue's highlight desaturation fades a photosite toward neutral there — /// a sensor fact, not an operation's, and flat grey is neutral already. const LEVELS: [f32; 8] = [0.025, 0.05, 0.1, 0.2, 0.4, 0.6, 0.8, 0.95]; #[test] fn scene_referred_until_the_view() { // TRACES: FR-DEV-2 | FR-DEV-3j let Some(ctx) = ctx() else { eprintln!("skipping: no GPU adapter"); return; }; let sources: Vec<_> = LEVELS.iter().map(|&l| flat(&ctx, l)).collect(); let mut adjust = AdjustPass::new(&ctx); let mut checked = 0; for mut op in dr_pipeline::ops::chain() { if op.detail().is_some() || op.stage() == Stage::View { continue; } let id = op.descriptor().id.0; non_neutral(op.as_mut()); assert!(op.is_active(), "{id}: the edit above left it neutral"); let ops = vec![ probe("probe_up", 16.0, Stage::Scene), op, probe("probe_down", 1.0 / 64.0, Stage::Scene), probe("probe_view", 1.0, Stage::View), ]; let shader = dr_pipeline::compose(&ops); assert!( !shader.source.contains("view_sigmoid"), "the identity must take the view transform's place" ); let mut out = Vec::new(); for source in &sources { adjust.render(source, &shader, SIZE, SIZE).expect("render"); let (pixels, _, _) = adjust.export_pixels().expect("readback"); let centre = (((SIZE / 2) * SIZE + SIZE / 2) * 4) as usize; out.push([pixels[centre], pixels[centre + 1], pixels[centre + 2]]); } for channel in 0..3 { let ramp: Vec = out.iter().map(|p| p[channel]).collect(); assert!( ramp.windows(2).all(|w| w[1] >= w[0]), "{id} is not monotone in channel {channel}: {ramp:?}" ); // The top three levels are scene 9.6, 12.8 and 15.2: all far // above 1.0, and a clip anywhere below them makes them equal. let top = &ramp[LEVELS.len() - 3..]; assert!( top[0] < top[1] && top[1] < top[2], "{id} flattens values above 1.0 in channel {channel}: {ramp:?}" ); } checked += 1; } assert!(checked >= 10, "only {checked} operations were checked"); }