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.
724 lines
23 KiB
Rust
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]);
|
|
}
|
|
}
|