The decoding half of camera-profiles.md §11-§12. RawImage gains baseline_exposure: the file's BaselineExposure plus the chosen profile's BaselineExposureOffset, as the DNG SDK sums them (+0.25 for the library's 6D DNGs). A profile copied out of a DNG carries that DNG's baseline as its offset, so the body's CR2s, which have none, land at the same total. ProfileTables gains the profile's ProfileToneCurve, resampled at decode onto 1025 points with a natural cubic spline; an identity curve counts as none. dr-types now holds Camera Raw's ACR3 default curve, RawTherapee's adobe_camera_raw_default_curve copied value for value, for every raw whose profile has no curve. Nothing renders through either yet.
199 lines
7.2 KiB
Rust
199 lines
7.2 KiB
Rust
//! 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<GpuContext> {
|
|
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<OpDescriptor> {
|
|
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<Uniform> {
|
|
vec![Uniform {
|
|
name: "gain",
|
|
value: self.gain,
|
|
}]
|
|
}
|
|
}
|
|
|
|
fn probe(id: &'static str, gain: f32, stage: Stage) -> Box<dyn Operation> {
|
|
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,
|
|
profile_tables: None,
|
|
baseline_exposure: 0.0,
|
|
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<u8> = 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");
|
|
}
|