Files
DarkRoom/core/dr-pipeline/src/ops/camera_profile.rs
T
dtourolle 8294e6b59f Call the reference curve what it is, and drop wording that reads as copying
The view transform's second curve is the DNG SDK's published reference
rendering — the ACR3 default curve applied by RefBaselineRGBTone — so
it is the "DNG Reference" curve in the panel, D21 and the code, not a
name borrowed from another product. Comments and docs that justified a
choice by another editor doing it ("as their Amount", "so a
photographer arriving from it finds the name") now give the actual
reason. The Vivid presets no longer describe themselves as reaching
for another editor's look; they are DarkRoom's own.

Factual mentions stay: which program wrote the library's DNGs, what
was measured against, and preset import. camera-profiles.md gains §15,
on starting a photograph from the edit it already carries.
2026-10-03 14:22:49 -04:00

724 lines
23 KiB
Rust

//! TRACES: FR-DEV-3e
//! The camera profile's tables as an operation (D20).
//!
//! The matrix turns camera RGB into colour; a DNG camera profile adds two
//! lookups over hue, saturation and value on top of it — the `HueSatMap`, a
//! calibration, and the `LookTable`, a rendering intent. This operation
//! applies them. `docs/dev/camera-profiles.md` is the design.
//!
//! # Where the tables come from
//!
//! Not from here. They belong to the *source*, like the matrix: `dr-decode`
//! resolves them per file and `dr-gpu` uploads them to the storage buffer
//! every generated shader declares at `@binding(8)`, laid out by
//! [`profile_buffer`]. This operation holds only the photographer's two
//! settings — whether to use the profile, and how strongly to apply its look
//! — so a render path never has to remember to hand it anything.
//!
//! # Why it is composed at its defaults
//!
//! A profile that is on is the rendering, not an edit: an untouched raw
//! renders through it and writes no parameters. So [`Operation::composes`]
//! answers "is the switch on", not "has anything moved". The fragment then
//! branches on the buffer's header, which says whether this source has tables
//! at all; a JPEG, or a raw with no profile, reads two zeros and passes
//! through.
//!
//! # The lookup
//!
//! The DNG SDK's `RefBaselineHueSatMap`, with the two departures §2 of the
//! design gives for scene-referred values: value is not clamped on the way
//! out, and a colour with a negative ProPhoto component passes through.
//! [`apply_reference`] is the same arithmetic on the CPU, and the GPU tests
//! hold the shader to it.
use std::sync::{Arc, LazyLock};
use dr_types::{HueSatTable, ProfileTables};
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
};
use crate::operation::{Helper, Operation, Uniform};
pub const ID: OpId = OpId("camera_profile");
pub const APPLY: ParamId = ParamId("apply");
pub const LOOK: ParamId = ParamId("look");
/// The look's strength at which the LookTable is applied as the profile
/// states it, in percent.
pub const DEFAULT_LOOK: f32 = 100.0;
/// Twice the profile's look.
pub const MAX_LOOK: f32 = 200.0;
/// Entries of the buffer's header, before the entries themselves: one
/// `vec4` describing each table — `(hue divisions, saturation divisions,
/// value divisions, sRGB-encoded)`, zero hue divisions meaning absent — and
/// a third whose `.x` is the tone curve's length (camera-profiles.md §12).
pub const HEADER_ENTRIES: usize = 3;
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
attributes: vec![Attribute::Colour],
id: ID,
label: LocalizedKey("op.camera_profile"),
params: vec![
ParamDescriptor::switch_on("apply", "param.camera_profile.apply"),
ParamDescriptor::scalar(
"look",
"param.camera_profile.look",
0.0,
MAX_LOOK,
DEFAULT_LOOK,
Unit::Percent,
Scale::Linear,
0,
),
],
})
});
/// Linear sRGB (the working space) to linear ProPhoto, and back, row-major,
/// each row scaled to sum to one so that working white is ProPhoto white
/// exactly and a neutral reaches the tables with zero saturation.
pub(crate) fn working_prophoto() -> &'static ([f32; 9], [f32; 9]) {
static M: LazyLock<([f32; 9], [f32; 9])> = LazyLock::new(|| {
let to = normalise_rows(dr_types::ColourSpace::ProPhoto.from_linear_srgb());
let back = normalise_rows(invert(&to).expect("ProPhoto's matrix is invertible"));
(to, back)
});
&M
}
fn normalise_rows(mut m: [f32; 9]) -> [f32; 9] {
for row in m.chunks_exact_mut(3) {
let sum: f32 = row.iter().sum();
row.iter_mut().for_each(|v| *v /= sum);
}
m
}
fn invert(m: &[f32; 9]) -> Option<[f32; 9]> {
let [a, b, c, d, e, f, g, h, i] = m.map(f64::from);
let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
if det.abs() < 1e-12 {
return None;
}
let inv = [
(e * i - f * h) / det,
(c * h - b * i) / det,
(b * f - c * e) / det,
(f * g - d * i) / det,
(a * i - c * g) / det,
(c * d - a * f) / det,
(d * h - e * g) / det,
(b * g - a * h) / det,
(a * e - b * d) / det,
];
Some(inv.map(|v| v as f32))
}
pub(crate) fn mul(m: &[f32; 9], c: [f32; 3]) -> [f32; 3] {
std::array::from_fn(|r| m[r * 3] * c[0] + m[r * 3 + 1] * c[1] + m[r * 3 + 2] * c[2])
}
/// A row-major matrix as a WGSL `mat3x3`, whose constructor takes columns.
fn wgsl_mat(m: &[f32; 9]) -> String {
let col = |j: usize| format!("vec3<f32>({:e}, {:e}, {:e})", m[j], m[3 + j], m[6 + j]);
format!("mat3x3<f32>({}, {}, {})", col(0), col(1), col(2))
}
/// The working space to ProPhoto and back, as WGSL constants, and where
/// the profile buffer's sections begin. A helper of its own because the
/// view transform's DNG reference curve needs it too, and helpers are emitted
/// once each, in the order first asked for.
pub(crate) static PROPHOTO_HELPER: LazyLock<Helper> = LazyLock::new(|| {
let (to, back) = working_prophoto();
let source = format!(
"const PROFILE_FROM_WORKING = {};\nconst PROFILE_TO_WORKING = {};\n{SECTIONS_WGSL}",
wgsl_mat(to),
wgsl_mat(back)
);
Helper {
name: "profile_curve_base",
source: Box::leak(source.into_boxed_str()),
}
});
static HELPERS: LazyLock<[Helper; 2]> = LazyLock::new(|| {
[
*PROPHOTO_HELPER,
Helper {
name: "profile_apply",
source: LOOKUP_WGSL,
},
]
});
/// Where each section of the profile buffer starts, from its header.
const SECTIONS_WGSL: &str = "
fn profile_entries(dims: vec4<f32>) -> u32 {
return u32(dims.x * dims.y * dims.z);
}
fn profile_look_base() -> u32 {
return 3u + profile_entries(profile_table[0]);
}
fn profile_curve_base() -> u32 {
return profile_look_base() + profile_entries(profile_table[1]);
}
";
/// The lookup, in WGSL. Mirrors [`apply_reference`] line for line.
const LOOKUP_WGSL: &str = r#"
fn profile_srgb_encode(v: f32) -> f32 {
if (v <= 0.0031308) { return v * 12.92; }
return 1.055 * pow(v, 1.0 / 2.4) - 0.055;
}
fn profile_srgb_decode(v: f32) -> f32 {
if (v <= 0.04045) { return v / 12.92; }
return pow((v + 0.055) / 1.055, 2.4);
}
// The DNG SDK's HSV: hue in [0, 6), saturation (max - min) / max, value max.
fn profile_rgb_to_hsv(c: vec3<f32>) -> vec3<f32> {
let v = max(c.r, max(c.g, c.b));
let gap = v - min(c.r, min(c.g, c.b));
if (gap <= 0.0) {
return vec3<f32>(0.0, 0.0, v);
}
var h: f32;
if (c.r == v) {
h = (c.g - c.b) / gap;
if (h < 0.0) { h += 6.0; }
} else if (c.g == v) {
h = 2.0 + (c.b - c.r) / gap;
} else {
h = 4.0 + (c.r - c.g) / gap;
}
return vec3<f32>(h, gap / v, v);
}
fn profile_hsv_to_rgb(hsv: vec3<f32>) -> vec3<f32> {
let s = hsv.y;
let v = hsv.z;
if (s <= 0.0) {
return vec3<f32>(v);
}
let h = hsv.x - 6.0 * floor(hsv.x / 6.0);
let i = min(floor(h), 5.0);
let f = h - i;
let p = v * (1.0 - s);
let q = v * (1.0 - s * f);
let t = v * (1.0 - s * (1.0 - f));
switch (i32(i)) {
case 0: { return vec3<f32>(v, t, p); }
case 1: { return vec3<f32>(q, v, p); }
case 2: { return vec3<f32>(p, v, t); }
case 3: { return vec3<f32>(p, q, v); }
case 4: { return vec3<f32>(t, p, v); }
default: { return vec3<f32>(v, p, q); }
}
}
fn profile_entry(base: u32, at: u32) -> vec3<f32> {
return profile_table[base + at].xyz;
}
// (hue shift in degrees, saturation scale, value scale) at `hsv`: bilinear
// over hue and saturation, hue wrapping, and linear over value for a 3-D
// table. Indices are the SDK's.
fn profile_lookup(dims: vec4<f32>, base: u32, hsv: vec3<f32>) -> vec3<f32> {
let hd = u32(dims.x);
let sd = u32(dims.y);
let vd = u32(dims.z);
var h0 = 0u;
var h1 = 0u;
var hf = 0.0;
if (hd > 1u) {
let hs = hsv.x * f32(hd) / 6.0;
h0 = min(u32(hs), hd - 1u);
hf = hs - f32(h0);
h1 = h0 + 1u;
if (h1 >= hd) { h1 = 0u; }
}
let ss = hsv.y * f32(sd - 1u);
let s0 = min(u32(ss), sd - 2u);
let sf = ss - f32(s0);
var v0 = 0u;
var vf = 0.0;
if (vd > 1u) {
var ve = clamp(hsv.z, 0.0, 1.0);
if (dims.w > 0.5) { ve = profile_srgb_encode(ve); }
let vs = ve * f32(vd - 1u);
v0 = min(u32(vs), vd - 2u);
vf = vs - f32(v0);
}
let val_step = hd * sd;
let lo = v0 * val_step;
var d = mix(
mix(profile_entry(base, lo + h0 * sd + s0), profile_entry(base, lo + h1 * sd + s0), hf),
mix(profile_entry(base, lo + h0 * sd + s0 + 1u), profile_entry(base, lo + h1 * sd + s0 + 1u), hf),
sf);
if (vd > 1u) {
let hi = lo + val_step;
let e = mix(
mix(profile_entry(base, hi + h0 * sd + s0), profile_entry(base, hi + h1 * sd + s0), hf),
mix(profile_entry(base, hi + h0 * sd + s0 + 1u), profile_entry(base, hi + h1 * sd + s0 + 1u), hf),
sf);
d = mix(d, e, vf);
}
return d;
}
// One table applied to a ProPhoto colour, its deltas scaled by `amount`.
fn profile_apply(dims: vec4<f32>, base: u32, c: vec3<f32>, amount: f32) -> vec3<f32> {
let hsv = profile_rgb_to_hsv(c);
var d = profile_lookup(dims, base, hsv);
d = vec3<f32>(d.x * amount, max(1.0 + (d.y - 1.0) * amount, 0.0), max(1.0 + (d.z - 1.0) * amount, 0.0));
let h = hsv.x + d.x * (6.0 / 360.0);
let s = min(hsv.y * d.y, 1.0);
var v = hsv.z * d.z;
if (dims.w > 0.5) {
// The scale is defined on the encoded value; applied as the ratio it
// makes at min(v, 1), so a value above 1.0 is scaled, not clipped.
let vc = min(hsv.z, 1.0);
v = hsv.z;
if (vc > 0.0) {
v = hsv.z * profile_srgb_decode(profile_srgb_encode(vc) * d.z) / vc;
}
}
return profile_hsv_to_rgb(vec3<f32>(h, s, v));
}
"#;
#[derive(Debug, Clone)]
pub struct CameraProfile {
apply: bool,
look: f32,
}
impl Default for CameraProfile {
fn default() -> Self {
Self {
apply: true,
look: DEFAULT_LOOK,
}
}
}
impl CameraProfile {
pub fn new() -> Self {
Self::default()
}
}
impl Operation for CameraProfile {
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
APPLY => self.apply = value != 0.0,
LOOK => self.look = value,
_ => log::warn!("camera_profile: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
APPLY => f32::from(u8::from(self.apply)),
LOOK => self.look,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
!self.apply || self.look != DEFAULT_LOOK
}
fn composes(&self) -> bool {
self.apply
}
fn wgsl_body(&self) -> String {
"\
let hue_sat_dims = profile_table[0];
let look_dims = profile_table[1];
if (hue_sat_dims.x > 0.0 || look_dims.x > 0.0) {
var p = PROFILE_FROM_WORKING * c;
// A colour outside ProPhoto has no HSV the tables were made for; it
// passes through rather than being floored, which would clip it (D19).
if (min(p.r, min(p.g, p.b)) >= 0.0) {
if (hue_sat_dims.x > 0.0) {
p = profile_apply(hue_sat_dims, 3u, p, 1.0);
}
if (look_dims.x > 0.0 && look > 0.0) {
p = profile_apply(look_dims, profile_look_base(), p, look);
}
c = PROFILE_TO_WORKING * p;
}
}"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
vec![Uniform {
name: "look",
value: self.look / 100.0,
}]
}
fn helpers(&self) -> &[Helper] {
HELPERS.as_slice()
}
}
/// TRACES: FR-DEV-3e | FR-DEV-3j
/// The storage buffer a source's profile is uploaded as: the three header
/// `vec4`s, the HueSatMap's entries, the LookTable's, each entry
/// `(hue shift, saturation scale, value scale, 0)`, then the tone curve's
/// samples in `.x`.
///
/// The curve is always there: the profile's own where it has one, Camera
/// Raw's ACR3 default otherwise — including in the placeholder every source
/// without a profile binds, whose tables are absent, so a raw with no
/// profile still has the reference tone curve when it is chosen (D21).
pub fn profile_buffer(tables: Option<&ProfileTables>) -> Vec<[f32; 4]> {
let header = |t: Option<&HueSatTable>| match t {
Some(t) => [
t.hue_divisions as f32,
t.sat_divisions as f32,
t.val_divisions as f32,
if t.srgb_encoded { 1.0 } else { 0.0 },
],
None => [0.0; 4],
};
let hue_sat = tables.and_then(|t| t.hue_sat.as_ref());
let look = tables.and_then(|t| t.look.as_ref());
let curve: &[f32] = tables
.and_then(|t| t.tone_curve.as_deref())
.unwrap_or(&dr_types::tone::ACR3_DEFAULT);
let mut out = vec![
header(hue_sat),
header(look),
[curve.len() as f32, 0.0, 0.0, 0.0],
];
for t in [hue_sat, look].into_iter().flatten() {
out.extend(t.entries.iter().map(|e| [e[0], e[1], e[2], 0.0]));
}
out.extend(curve.iter().map(|&v| [v, 0.0, 0.0, 0.0]));
out
}
/// TRACES: FR-DEV-3e
/// The fragment's arithmetic on the CPU: a working-space colour through the
/// source's tables, the look at `look` (1.0 = as the profile states it).
///
/// The reference the shader is tested against, and the statement of the
/// algorithm a reader can step through.
pub fn apply_reference(tables: &ProfileTables, c: [f32; 3], look: f32) -> [f32; 3] {
let (to, back) = working_prophoto();
let mut p = mul(to, c);
if p.iter().any(|v| *v < 0.0) {
return c;
}
if let Some(t) = &tables.hue_sat {
p = apply_table(t, p, 1.0);
}
if let Some(t) = tables.look.as_ref().filter(|_| look > 0.0) {
p = apply_table(t, p, look);
}
mul(back, p)
}
fn srgb_encode(v: f32) -> f32 {
if v <= 0.003_130_8 {
v * 12.92
} else {
1.055 * v.powf(1.0 / 2.4) - 0.055
}
}
fn srgb_decode(v: f32) -> f32 {
if v <= 0.040_45 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}
/// The SDK's `DNG_RGBtoHSV`: hue in `[0, 6)`.
pub fn rgb_to_hsv([r, g, b]: [f32; 3]) -> [f32; 3] {
let v = r.max(g).max(b);
let gap = v - r.min(g).min(b);
if gap <= 0.0 {
return [0.0, 0.0, v];
}
let h = if r == v {
let h = (g - b) / gap;
if h < 0.0 {
h + 6.0
} else {
h
}
} else if g == v {
2.0 + (b - r) / gap
} else {
4.0 + (r - g) / gap
};
[h, gap / v, v]
}
pub fn hsv_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] {
if s <= 0.0 {
return [v; 3];
}
let h = h - 6.0 * (h / 6.0).floor();
let i = h.floor().min(5.0);
let f = h - i;
let p = v * (1.0 - s);
let q = v * (1.0 - s * f);
let t = v * (1.0 - s * (1.0 - f));
match i as i32 {
0 => [v, t, p],
1 => [q, v, p],
2 => [p, v, t],
3 => [p, q, v],
4 => [t, p, v],
_ => [v, p, q],
}
}
fn lookup(t: &HueSatTable, [h, s, v]: [f32; 3]) -> [f32; 3] {
let (hd, sd, vd) = (t.hue_divisions, t.sat_divisions, t.val_divisions);
let (mut h0, mut h1, mut hf) = (0u32, 0u32, 0.0f32);
if hd > 1 {
let hs = h * hd as f32 / 6.0;
h0 = (hs as u32).min(hd - 1);
hf = hs - h0 as f32;
h1 = if h0 + 1 >= hd { 0 } else { h0 + 1 };
}
let ss = s * (sd - 1) as f32;
let s0 = (ss as u32).min(sd - 2);
let sf = ss - s0 as f32;
let (mut v0, mut vf) = (0u32, 0.0f32);
if vd > 1 {
let mut ve = v.clamp(0.0, 1.0);
if t.srgb_encoded {
ve = srgb_encode(ve);
}
let vs = ve * (vd - 1) as f32;
v0 = (vs as u32).min(vd - 2);
vf = vs - v0 as f32;
}
let mix = |a: [f32; 3], b: [f32; 3], w: f32| -> [f32; 3] {
std::array::from_fn(|i| a[i] + (b[i] - a[i]) * w)
};
let at = |v: u32, h: u32, s: u32| t.entries[t.index(h, s, v)];
let plane = |v: u32| {
mix(
mix(at(v, h0, s0), at(v, h1, s0), hf),
mix(at(v, h0, s0 + 1), at(v, h1, s0 + 1), hf),
sf,
)
};
let d = plane(v0);
if vd > 1 {
mix(d, plane(v0 + 1), vf)
} else {
d
}
}
fn apply_table(t: &HueSatTable, c: [f32; 3], amount: f32) -> [f32; 3] {
let hsv = rgb_to_hsv(c);
let d = lookup(t, hsv);
let d = [
d[0] * amount,
(1.0 + (d[1] - 1.0) * amount).max(0.0),
(1.0 + (d[2] - 1.0) * amount).max(0.0),
];
let h = hsv[0] + d[0] * (6.0 / 360.0);
let s = (hsv[1] * d[1]).min(1.0);
let v = if t.srgb_encoded {
let vc = hsv[2].min(1.0);
if vc > 0.0 {
hsv[2] * srgb_decode(srgb_encode(vc) * d[2]) / vc
} else {
hsv[2]
}
} else {
hsv[2] * d[2]
};
hsv_to_rgb([h, s, v])
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::ProfileOrigin;
fn uniform(h: u32, s: u32, v: u32, e: [f32; 3]) -> HueSatTable {
HueSatTable::new(h, s, v, false, vec![e; (h * s * v) as usize]).unwrap()
}
fn tables(hue_sat: Option<HueSatTable>, look: Option<HueSatTable>) -> ProfileTables {
ProfileTables {
name: "test".into(),
origin: ProfileOrigin::Embedded,
hue_sat,
look,
tone_curve: None,
}
}
fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool {
a.iter()
.zip(b)
.all(|(x, y)| (x - y).abs() <= tol * y.abs().max(1.0))
}
#[test]
fn it_starts_neutral_and_composed() {
let op = CameraProfile::new();
assert!(!op.is_active(), "an untouched photograph writes nothing");
assert!(op.composes(), "and still renders through its profile");
let mut off = CameraProfile::new();
off.set_param(APPLY, 0.0);
assert!(off.is_active() && !off.composes());
}
#[test]
fn the_working_space_round_trips_through_prophoto() {
let (to, back) = working_prophoto();
for c in [[1.0, 1.0, 1.0], [0.2, 0.5, 0.1], [4.0, 0.3, 0.02]] {
assert!(close(mul(back, mul(to, c)), c, 1e-5), "{c:?}");
}
let white = mul(to, [1.0; 3]);
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-6), "{white:?}");
}
#[test]
fn hsv_round_trips() {
for c in [
[0.9, 0.2, 0.1],
[0.1, 0.7, 0.3],
[0.2, 0.3, 0.8],
[0.5, 0.5, 0.5],
[3.0, 1.0, 2.0],
] {
assert!(close(hsv_to_rgb(rgb_to_hsv(c)), c, 1e-6), "{c:?}");
}
}
#[test]
fn grey_passes_through() {
let t = tables(
Some(uniform(6, 3, 1, [30.0, 1.5, 1.0])),
Some(uniform(6, 3, 1, [-20.0, 1.3, 1.0])),
);
for v in [0.0, 0.18, 1.0, 8.0] {
let out = apply_reference(&t, [v; 3], 1.0);
assert!(close(out, [v; 3], 1e-5), "{v}: {out:?}");
}
}
#[test]
fn an_identity_table_changes_nothing() {
let t = tables(
Some(uniform(90, 30, 1, [0.0, 1.0, 1.0])),
Some(uniform(36, 8, 16, [0.0, 1.0, 1.0])),
);
for c in [[0.9, 0.2, 0.1], [0.05, 0.4, 0.2], [2.0, 0.5, 0.3]] {
assert!(close(apply_reference(&t, c, 1.0), c, 1e-5), "{c:?}");
}
}
#[test]
fn a_saturation_scale_scales_saturation() {
let t = tables(Some(uniform(6, 3, 1, [0.0, 1.2, 1.0])), None);
let (to, _) = working_prophoto();
let c = [0.6, 0.3, 0.2];
let before = rgb_to_hsv(mul(to, c));
let after = rgb_to_hsv(mul(to, apply_reference(&t, c, 1.0)));
assert!(
(after[1] - before[1] * 1.2).abs() < 1e-4,
"{before:?} {after:?}"
);
assert!((after[0] - before[0]).abs() < 1e-4);
assert!((after[2] - before[2]).abs() < 1e-4);
}
#[test]
fn hue_interpolation_wraps_from_the_last_column_to_the_first() {
// Four hue columns: a shift only in the first. A hue just short of
// 6.0 (red, from the magenta side) sits between the last column and
// the first, and must take most of the first's shift.
let mut e = vec![[0.0, 1.0, 1.0]; 4 * 2];
e[0] = [40.0, 1.0, 1.0];
e[1] = [40.0, 1.0, 1.0];
let t = HueSatTable::new(4, 2, 1, false, e).unwrap();
let d = lookup(&t, [5.9, 0.5, 0.5]);
assert!(d[0] > 30.0, "{d:?}");
// Columns sit at hue 0, 1.5, 3 and 4.5; between the third and the
// fourth, neither of which shifts, nothing moves.
let d = lookup(&t, [3.7, 0.5, 0.5]);
assert!(d[0].abs() < 1e-6, "{d:?}");
}
#[test]
fn a_value_above_one_stays_above_one() {
let t = tables(None, Some(uniform(6, 3, 4, [5.0, 1.1, 0.9])));
let out = apply_reference(&t, [6.0, 3.0, 2.0], 1.0);
assert!(out.iter().any(|v| *v > 1.0), "{out:?}");
let mut srgb = uniform(6, 3, 4, [0.0, 1.0, 0.9]);
srgb.srgb_encoded = true;
let out = apply_reference(&tables(None, Some(srgb)), [6.0, 3.0, 2.0], 1.0);
assert!(out.iter().all(|v| v.is_finite()) && out[0] > 1.0, "{out:?}");
}
#[test]
fn the_look_strength_scales_the_look_alone() {
let hs = uniform(6, 3, 1, [0.0, 1.1, 1.0]);
let look = uniform(6, 3, 1, [0.0, 1.2, 1.0]);
let t = tables(Some(hs.clone()), Some(look));
let c = [0.5, 0.3, 0.2];
let none = apply_reference(&t, c, 0.0);
assert!(close(
none,
apply_reference(&tables(Some(hs), None), c, 1.0),
1e-6
));
let (to, _) = working_prophoto();
let s = |x| rgb_to_hsv(mul(to, x))[1];
assert!(s(apply_reference(&t, c, 2.0)) > s(apply_reference(&t, c, 1.0)));
}
#[test]
fn the_buffer_puts_the_header_first_and_the_look_after_the_hue_sat_map() {
let bare = profile_buffer(None);
assert_eq!(bare[..2], [[0.0; 4]; 2], "no tables");
assert_eq!(bare[2][0], 1025.0, "and the reference default curve");
assert_eq!(bare.len(), HEADER_ENTRIES + 1025);
let t = tables(
Some(uniform(2, 2, 1, [1.0, 2.0, 3.0])),
Some(uniform(3, 2, 2, [4.0, 5.0, 6.0])),
);
let b = profile_buffer(Some(&t));
assert_eq!(b[0], [2.0, 2.0, 1.0, 0.0]);
assert_eq!(b[1], [3.0, 2.0, 2.0, 0.0]);
assert_eq!(b.len(), HEADER_ENTRIES + 4 + 12 + 1025);
assert_eq!(b[HEADER_ENTRIES], [1.0, 2.0, 3.0, 0.0]);
assert_eq!(b[HEADER_ENTRIES + 4], [4.0, 5.0, 6.0, 0.0]);
assert_eq!(b[HEADER_ENTRIES + 16][0], dr_types::tone::ACR3_DEFAULT[0]);
}
}