The Camera Raw default rested on comparing against Lightroom previews of photographs that carry the user's Lightroom edits — HSL saturation Blue +58, Aqua +50 and more, Highlights -40, Blacks -20, in every DNG's XMP — so it measured the house look, not Camera Raw's base rendering. Under the ACR3 curve _MG_9080 renders brighter than its Lightroom preview (mean 0.39 against 0.31). So the curve choice's first variant, the default, is the sigmoid again and every raw renders as in 0.20.0 apart from baseline exposure. D21 and camera-profiles.md §12 now say the default is open, to be decided by measuring against Lightroom exports of unedited photographs. Tests that are about Camera Raw's tone choose it explicitly.
292 lines
9.9 KiB
Rust
292 lines
9.9 KiB
Rust
//! TRACES: FR-DEV-3e
|
||
//! The camera profile's tables, end to end on a device (D20).
|
||
//!
|
||
//! `dr-pipeline` holds the lookup to the DNG SDK's algorithm on the CPU
|
||
//! (`ops::camera_profile::apply_reference`). Nothing there would notice a
|
||
//! shader that disagreed with it — a transposed constant matrix, an index
|
||
//! off by one column, a buffer bound in the wrong order — so this renders a
|
||
//! frame of 256 different colours through tables that move every one of them
|
||
//! a long way, and holds each pixel to the reference.
|
||
//!
|
||
//! The source is a linear three-sample frame, so the colours arrive exactly
|
||
//! as written with no demosaic between, and an identity stands in the view
|
||
//! transform's place so the readback is the scene colour, display-encoded.
|
||
|
||
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};
|
||
use dr_pipeline::operation::{Operation, Stage, Uniform};
|
||
use dr_pipeline::ops::camera_profile::{apply_reference, CameraProfile, APPLY, LOOK};
|
||
use dr_types::{HueSatTable, ProfileOrigin, ProfileTables, Transfer};
|
||
|
||
const SIZE: u32 = 16;
|
||
|
||
fn ctx() -> Option<GpuContext> {
|
||
pollster::block_on(GpuContext::new_headless()).ok()
|
||
}
|
||
|
||
/// An identity in the view transform's place.
|
||
struct IdentityView;
|
||
|
||
impl Operation for IdentityView {
|
||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||
Arc::new(OpDescriptor {
|
||
id: OpId("identity_view"),
|
||
label: LocalizedKey("identity_view"),
|
||
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 {
|
||
Stage::View
|
||
}
|
||
fn renders(&self) -> bool {
|
||
true
|
||
}
|
||
fn wgsl_body(&self) -> String {
|
||
String::new()
|
||
}
|
||
fn uniforms(&self) -> Vec<Uniform> {
|
||
Vec::new()
|
||
}
|
||
}
|
||
|
||
/// 256 colours across hue, saturation and value, kept under the prologue's
|
||
/// highlight desaturation and above black.
|
||
fn colours() -> Vec<[f32; 3]> {
|
||
(0..SIZE * SIZE)
|
||
.map(|i| {
|
||
let f = |k: u32| {
|
||
let x = (i.wrapping_mul(2_654_435_761).rotate_left(k * 7) >> 8) % 1000;
|
||
0.04 + 0.86 * x as f32 / 1000.0
|
||
};
|
||
[f(1), f(2), f(3)]
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn frame(tables: Option<ProfileTables>) -> RawImage {
|
||
let data = colours()
|
||
.iter()
|
||
.flat_map(|c| c.map(|v| (v * 65535.0).round() as u16))
|
||
.collect();
|
||
RawImage {
|
||
width: SIZE,
|
||
height: SIZE,
|
||
data,
|
||
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: 3,
|
||
profile: None,
|
||
profile_tables: tables.map(Arc::new),
|
||
baseline_exposure: 0.0,
|
||
make: String::new(),
|
||
model: String::new(),
|
||
crop: CropRect {
|
||
x: 0,
|
||
y: 0,
|
||
width: SIZE,
|
||
height: SIZE,
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Tables that move every colour by a different amount: hue shifts of tens
|
||
/// of degrees, saturation scales either side of one, and a 3-D, sRGB-indexed
|
||
/// look whose value scale varies down the value axis.
|
||
fn strong_tables() -> ProfileTables {
|
||
let (hd, sd) = (12u32, 5u32);
|
||
let hue_sat = (0..hd * sd)
|
||
.map(|i| {
|
||
let (h, s) = (i / sd, i % sd);
|
||
let a = h as f32 / hd as f32 * std::f32::consts::TAU;
|
||
[25.0 * a.sin(), 1.0 + 0.3 * a.cos() * s as f32 / 4.0, 1.0]
|
||
})
|
||
.collect();
|
||
let (lh, ls, lv) = (8u32, 4u32, 5u32);
|
||
let look = (0..lh * ls * lv)
|
||
.map(|i| {
|
||
let v = i / (lh * ls);
|
||
let h = (i / ls) % lh;
|
||
[
|
||
-15.0 + 4.0 * h as f32,
|
||
1.25 - 0.05 * v as f32,
|
||
0.85 + 0.06 * v as f32,
|
||
]
|
||
})
|
||
.collect();
|
||
let mut look = HueSatTable::new(lh, ls, lv, true, look).unwrap();
|
||
look.srgb_encoded = true;
|
||
ProfileTables {
|
||
name: "strong".into(),
|
||
origin: ProfileOrigin::Embedded,
|
||
hue_sat: Some(HueSatTable::new(hd, sd, 1, false, hue_sat).unwrap()),
|
||
look: Some(look),
|
||
tone_curve: None,
|
||
}
|
||
}
|
||
|
||
fn render(ctx: &GpuContext, raw: &RawImage, op: CameraProfile) -> Vec<[u8; 3]> {
|
||
let source = Demosaicer::new(ctx)
|
||
.expect("demosaicer")
|
||
.run(raw)
|
||
.expect("upload");
|
||
let ops: Vec<Box<dyn Operation>> = vec![Box::new(op), Box::new(IdentityView)];
|
||
let shader = dr_pipeline::compose(&ops);
|
||
let mut adjust = AdjustPass::new(ctx);
|
||
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
||
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||
pixels.chunks_exact(4).map(|p| [p[0], p[1], p[2]]).collect()
|
||
}
|
||
|
||
fn encode(c: [f32; 3]) -> [i32; 3] {
|
||
c.map(|v| (Transfer::Srgb.encode(v.clamp(0.0, 1.0)) * 255.0).round() as i32)
|
||
}
|
||
|
||
fn assert_agrees(got: &[[u8; 3]], expected: impl Fn([f32; 3]) -> [f32; 3], what: &str) {
|
||
let mut moved = 0;
|
||
for (i, (c, g)) in colours().into_iter().zip(got).enumerate() {
|
||
let want = encode(expected(c));
|
||
let g = g.map(i32::from);
|
||
// Two 8-bit steps: the half-float source and intermediate, and the
|
||
// rounding either side of the encode.
|
||
assert!(
|
||
want.iter().zip(g).all(|(w, g)| (w - g).abs() <= 2),
|
||
"{what}: pixel {i} {c:?} rendered {g:?}, the reference says {want:?}"
|
||
);
|
||
if want != encode(c) {
|
||
moved += 1;
|
||
}
|
||
}
|
||
assert!(
|
||
moved > 200,
|
||
"{what}: only {moved} of 256 colours moved; the test proves little"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_shader_agrees_with_the_cpu_reference() {
|
||
let Some(ctx) = ctx() else {
|
||
eprintln!("skipping: no GPU adapter");
|
||
return;
|
||
};
|
||
let tables = strong_tables();
|
||
let got = render(&ctx, &frame(Some(tables.clone())), CameraProfile::new());
|
||
assert_agrees(&got, |c| apply_reference(&tables, c, 1.0), "at defaults");
|
||
|
||
let mut doubled = CameraProfile::new();
|
||
doubled.set_param(LOOK, 200.0);
|
||
let got = render(&ctx, &frame(Some(tables.clone())), doubled);
|
||
assert_agrees(&got, |c| apply_reference(&tables, c, 2.0), "look at 200%");
|
||
}
|
||
|
||
#[test]
|
||
fn camera_raw_tone_agrees_with_its_cpu_reference() {
|
||
// TRACES: FR-DEV-3j
|
||
// D21's rendering on 256 colours: the ProPhoto round trip, the clip, the
|
||
// curve from the profile buffer's placeholder, and RGBTone's placement
|
||
// of the middle channel, against `camera_raw::apply_reference`.
|
||
let Some(ctx) = ctx() else {
|
||
eprintln!("skipping: no GPU adapter");
|
||
return;
|
||
};
|
||
let source = Demosaicer::new(&ctx)
|
||
.expect("demosaicer")
|
||
.run(&frame(None))
|
||
.expect("upload");
|
||
let mut view = dr_pipeline::ops::ViewTransform::new();
|
||
view.set_param(
|
||
dr_pipeline::ops::view_transform::CURVE,
|
||
dr_pipeline::ops::view_transform::CAMERA_RAW,
|
||
);
|
||
let ops: Vec<Box<dyn Operation>> = vec![Box::new(view)];
|
||
let shader = dr_pipeline::compose(&ops);
|
||
let mut adjust = AdjustPass::new(&ctx);
|
||
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
||
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||
let got: Vec<[u8; 3]> = pixels.chunks_exact(4).map(|p| [p[0], p[1], p[2]]).collect();
|
||
let curve = &dr_types::tone::ACR3_DEFAULT;
|
||
assert_agrees(
|
||
&got,
|
||
|c| {
|
||
dr_pipeline::camera_raw::apply_reference(
|
||
curve,
|
||
c,
|
||
dr_pipeline::view::DEFAULT_CONTRAST,
|
||
dr_pipeline::view::DEFAULT_WHITE,
|
||
)
|
||
},
|
||
"Camera Raw tone",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn switched_off_or_absent_the_render_is_unchanged() {
|
||
let Some(ctx) = ctx() else {
|
||
eprintln!("skipping: no GPU adapter");
|
||
return;
|
||
};
|
||
let bare = render(&ctx, &frame(None), CameraProfile::new());
|
||
let mut off = CameraProfile::new();
|
||
off.set_param(APPLY, 0.0);
|
||
let switched_off = render(&ctx, &frame(Some(strong_tables())), off);
|
||
assert_eq!(bare, switched_off, "the switch off is the matrix alone");
|
||
// Against the source colours, one 8-bit step for the half-float texture
|
||
// the source is uploaded in; the exact comparison is the one above.
|
||
for (c, g) in colours().into_iter().zip(&bare) {
|
||
let want = encode(c);
|
||
assert!(
|
||
want.iter()
|
||
.zip(g)
|
||
.all(|(w, g)| (w - i32::from(*g)).abs() <= 1),
|
||
"no tables, no change: {c:?} rendered {g:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_libraries_adobe_standard_renders_as_the_reference_does() {
|
||
// The real tables, when the library's 6D DNG is on this machine: a 90×30
|
||
// HueSatMap and a 36×8×16 LookTable, at the sizes no synthetic test
|
||
// reaches.
|
||
let Some(ctx) = ctx() else {
|
||
eprintln!("skipping: no GPU adapter");
|
||
return;
|
||
};
|
||
let path = std::env::var_os("DR_DCP_SAMPLE")
|
||
.map(std::path::PathBuf::from)
|
||
.or_else(|| {
|
||
std::env::var_os("HOME").map(|h| {
|
||
std::path::PathBuf::from(h).join("Nextcloud/PhotosRaw/2017/2017-08-12/_MG_9080.dng")
|
||
})
|
||
});
|
||
let Some(bytes) = path.and_then(|p| std::fs::read(p).ok()) else {
|
||
eprintln!("skipping: no sample DNG");
|
||
return;
|
||
};
|
||
let tables = dr_decode::dcp::embedded_in(&bytes)
|
||
.expect("Adobe Standard")
|
||
.tables(5000.0, ProfileOrigin::Embedded);
|
||
let got = render(&ctx, &frame(Some(tables.clone())), CameraProfile::new());
|
||
for (i, (c, g)) in colours().into_iter().zip(&got).enumerate() {
|
||
let want = encode(apply_reference(&tables, c, 1.0));
|
||
let g = g.map(i32::from);
|
||
assert!(
|
||
want.iter().zip(g).all(|(w, g)| (w - g).abs() <= 2),
|
||
"pixel {i} {c:?} rendered {g:?}, the reference says {want:?}"
|
||
);
|
||
}
|
||
}
|