The second half of D20: a camera_profile scene operation at order 25 that converts working colour into linear ProPhoto, runs the DNG SDK's HSV lookup through the HueSatMap and then the LookTable, and converts back. Hue and saturation do not change under the uniform gains before it, so a 2.5-D HueSatMap gives the same answer as straight after the matrix, and the look sees the photographer's exposure as it does in the SDK. Two departures for scene-referred values: value is not clamped on the way out, and a colour outside ProPhoto passes through. The operation holds only the switch (on by default) and a look strength of 0-200 %. It is composed while the switch is on — a new Operation::composes() separates "does something" from "moved from the defaults", so an untouched raw renders through its profile and still writes nothing. The tables come from the source: dr-gpu uploads the ones DemosaicedImage carries into a storage buffer at @binding(8), whose two-entry header tells the fragment whether there is anything to apply, and binds a header of zeros for every other source. apply_reference is the lookup on the CPU. The GPU test holds the shader to it over 256 colours, through synthetic tables strong enough that a wrong index shows, and through the library's real Adobe Standard tables when the 6D DNG is present.
679 lines
21 KiB
Rust
679 lines
21 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: Lightroom's *Amount* reaches the same.
|
|
pub const MAX_LOOK: f32 = 200.0;
|
|
|
|
/// Entries of the buffer's header: one `vec4` describing each table —
|
|
/// `(hue divisions, saturation divisions, value divisions, sRGB-encoded)`,
|
|
/// zero hue divisions meaning absent — before the entries themselves.
|
|
pub const HEADER_ENTRIES: usize = 2;
|
|
|
|
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.
|
|
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))
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
static HELPERS: LazyLock<[Helper; 1]> = LazyLock::new(|| {
|
|
let (to, back) = working_prophoto();
|
|
let source = format!(
|
|
"const PROFILE_FROM_WORKING = {};\nconst PROFILE_TO_WORKING = {};\n{LOOKUP_WGSL}",
|
|
wgsl_mat(to),
|
|
wgsl_mat(back)
|
|
);
|
|
[Helper {
|
|
name: "profile_apply",
|
|
source: Box::leak(source.into_boxed_str()),
|
|
}]
|
|
});
|
|
|
|
/// 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) {
|
|
let look_base = 2u + u32(hue_sat_dims.x * hue_sat_dims.y * hue_sat_dims.z);
|
|
if (hue_sat_dims.x > 0.0) {
|
|
p = profile_apply(hue_sat_dims, 2u, p, 1.0);
|
|
}
|
|
if (look_dims.x > 0.0 && look > 0.0) {
|
|
p = profile_apply(look_dims, 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
|
|
/// The storage buffer a source's tables are uploaded as: the two header
|
|
/// `vec4`s, the HueSatMap's entries, then the LookTable's, each entry
|
|
/// `(hue shift, saturation scale, value scale, 0)`.
|
|
///
|
|
/// Two zero `vec4`s where there are no tables — the placeholder every source
|
|
/// without a profile binds, and what makes the fragment pass through.
|
|
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 mut out = vec![header(hue_sat), header(look)];
|
|
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
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
}
|
|
|
|
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() {
|
|
assert_eq!(profile_buffer(None), vec![[0.0; 4]; 2]);
|
|
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);
|
|
assert_eq!(b[2], [1.0, 2.0, 3.0, 0.0]);
|
|
assert_eq!(b[HEADER_ENTRIES + 4], [4.0, 5.0, 6.0, 0.0]);
|
|
}
|
|
}
|