Pushing was not a thing to simulate. It was measured data being thrown away: Double-X and 2302 each ship five characteristic curves, one per development time, and this shipped the 6.5-minute column and discarded four. All five now ship and interpolate. The axis is real. Double-X runs 4 to 12 minutes, and across it the average gradient goes 0.472 to 1.034 while Dmax goes 1.19 to 2.56. The control is in stops, because that is what a photographer means, and one stop is a factor of about 1.41 in time. That mapping is checked rather than assumed: against Double-X's own axis it lands within 2% of the 9-minute column for +1, and near 12 minutes for +2, which are the times the datasheet gives for exactly that. There is a test. **Pushing must not recover shadow detail, and this does not.** Across the whole measured range the speed point moves about a third of a stop while the gradient doubles; three stops under mid-grey, density goes from 0.008 to 0.035, which is still nothing. Developing longer multiplies what was already recorded and cannot record what never hit the film. A push built as added exposure or global contrast brightens those shadows instead and looks convincing until someone who shoots film sees it, so that property has a test of its own. Interpolated in *log* time, because development is multiplicative: 4 to 5 minutes is the same amount of push as 9 to 12, and interpolating linearly would bunch the control at one end. Clamped at both ends, because past the published range there is no data and extrapolating a contrast curve invents an emulsion nobody tested. A stock measured at one process ignores the control entirely rather than inventing a curve for it -- Portra 800's pushes are separate *measured* profiles, which is the honest way to offer those. Costs nothing per pixel and changes no shader. The curves are a per-stock table, so the interpolation happens on the CPU at bake time, where choosing a stock and moving its sliders already rebakes. The Vulkan shader is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
555 lines
21 KiB
Rust
555 lines
21 KiB
Rust
//! Turning a stock into something a shader can run.
|
||
//!
|
||
//! # The decomposition
|
||
//!
|
||
//! A physically-honest film simulation looks like it needs a spectral
|
||
//! integration per pixel, and vkdt's does exactly that. It does not have to,
|
||
//! and the reason is worth writing down because it is what makes this cheap
|
||
//! enough to run on a phone:
|
||
//!
|
||
//! 1. **Exposure is a 3×3 matrix.** A layer's exposure is
|
||
//! `∫ S(λ)·L(λ) dλ`, and the scene spectrum `L` reconstructed from an sRGB
|
||
//! triple is *linear* in that triple — that is what a spectral basis is. So
|
||
//! the whole integral collapses into nine numbers, computed once, exactly.
|
||
//! No approximation is involved.
|
||
//!
|
||
//! 2. **The characteristic curve is three 1D functions.** Sampled exactly, at
|
||
//! [`crate::profile::CURVE_SAMPLES`].
|
||
//!
|
||
//! 3. **Everything after that is a function of three densities.** The dye
|
||
//! transmittance, the print exposure through the negative, the paper's own
|
||
//! curves and dyes, the viewing illuminant, the adaptation — all of it takes
|
||
//! three numbers in and gives three numbers out. So it bakes into one small
|
||
//! 3D lookup, and the per-pixel cost is a matrix multiply, three curve taps
|
||
//! and one texture fetch.
|
||
//!
|
||
//! Splitting 2 from 3 rather than baking a single LUT over exposure is
|
||
//! deliberate and measured: the curve carries all of the sharp shape and the
|
||
//! dye mixing is smooth, so putting the curve in the 3D LUT would force it
|
||
//! three times larger for the same error.
|
||
|
||
use crate::profile::Profile;
|
||
use crate::spectrum::{illuminant, Spectrum, Viewing};
|
||
use crate::tables::{SPECTRUM, SRGB_BASIS};
|
||
|
||
/// The mid-grey a photographic exposure is reckoned from.
|
||
///
|
||
/// 18.4% rather than 18%: it is the value the upstream profiles are calibrated
|
||
/// against, and a profile calibrated at one grey and rendered at another is
|
||
/// off by a fraction of a stop everywhere.
|
||
pub const MID_GREY: f32 = 0.184;
|
||
|
||
/// The edge length of the baked density lookup.
|
||
///
|
||
/// 32 holds the worst-case interpolation error to about 0.003 in linear sRGB,
|
||
/// which is below one 8-bit code value, in 384 kB. Doubling it buys a factor
|
||
/// of four in error for eight times the memory, and there is nothing to spend
|
||
/// that on: the error is already under what the output can represent.
|
||
pub const LUT_SIZE: usize = 32;
|
||
|
||
/// What to develop, and how.
|
||
pub struct Recipe<'a> {
|
||
/// The stock the picture was taken on.
|
||
pub film: &'a Profile,
|
||
/// The paper it is printed on. `None` views the film directly, which is
|
||
/// what a reversal stock wants and what makes a negative come out orange
|
||
/// and inverted — that being what a negative actually looks like.
|
||
pub print: Option<&'a Profile>,
|
||
/// Camera exposure, in stops.
|
||
pub exposure_ev: f32,
|
||
/// Enlarger exposure, in stops. Ignored without a `print`.
|
||
pub print_exposure_ev: f32,
|
||
/// TRACES: FR-DEV-3f
|
||
/// Development, in stops of push. Positive develops longer.
|
||
///
|
||
/// Ignored by a stock measured at one process, of which there are many —
|
||
/// see [`crate::profile::Profile::curves_at_push`], which returns the one
|
||
/// measured curve rather than inventing a pushed one.
|
||
pub push_stops: f32,
|
||
}
|
||
|
||
impl<'a> Recipe<'a> {
|
||
/// The straightforward reading of a stock: reversal viewed directly,
|
||
/// negative printed on the paper its datasheet names.
|
||
pub fn new(film: &'a Profile, print: Option<&'a Profile>) -> Self {
|
||
Self {
|
||
film,
|
||
print,
|
||
exposure_ev: 0.0,
|
||
print_exposure_ev: 0.0,
|
||
push_stops: 0.0,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A recipe reduced to three tables.
|
||
///
|
||
/// Plain `f32` with a documented layout, and no notion of a texture: what to
|
||
/// bind this to is dr-gpu's decision, and keeping it out of here is what lets
|
||
/// the whole model be tested on the CPU.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Baked {
|
||
/// Linear sRGB to the three layers' log₁₀ exposure, before the log — row
|
||
/// `l`, column `c` is layer `l`'s response to sRGB channel `c`.
|
||
pub exposure_matrix: [[f32; 3]; 3],
|
||
/// The characteristic curves, `CURVE_SAMPLES` samples per layer, uniform
|
||
/// over `[curve_log_min, curve_log_max]`.
|
||
pub curves: Vec<[f32; 3]>,
|
||
pub curve_log_min: f32,
|
||
pub curve_log_max: f32,
|
||
/// Density to linear sRGB, `LUT_SIZE³` entries uniform over
|
||
/// `[0, density_max]` on each axis.
|
||
///
|
||
/// **The red axis varies fastest**, then green, then blue — that is,
|
||
/// `lut[(b * size + g) * size + r]`. Stated because it is not the order
|
||
/// this loop reads most naturally, and it is not arbitrary: it is the
|
||
/// order a 3D texture upload expects, so the consumer can hand the slice
|
||
/// straight to the driver. Filling it the other way round renders a
|
||
/// picture with red and blue transposed, which looks like a plausible
|
||
/// photograph of the wrong colour.
|
||
pub lut: Vec<[f32; 3]>,
|
||
pub density_max: f32,
|
||
pub lut_size: usize,
|
||
}
|
||
|
||
impl Baked {
|
||
/// Look a colour up the way the shader will, for tests and for previews.
|
||
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
|
||
let mut log_exposure = [0.0f32; 3];
|
||
for (l, slot) in log_exposure.iter_mut().enumerate() {
|
||
let m = self.exposure_matrix[l];
|
||
let e = m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2];
|
||
*slot = (e.max(0.0) + 1e-10).log10();
|
||
}
|
||
self.sample_lut(self.sample_curves(log_exposure))
|
||
}
|
||
|
||
fn sample_curves(&self, log_exposure: [f32; 3]) -> [f32; 3] {
|
||
let last = self.curves.len() - 1;
|
||
let span = self.curve_log_max - self.curve_log_min;
|
||
let mut out = [0.0f32; 3];
|
||
for (c, slot) in out.iter_mut().enumerate() {
|
||
let t = ((log_exposure[c] - self.curve_log_min) / span).clamp(0.0, 1.0) * last as f32;
|
||
let i = (t.floor() as usize).min(last - 1);
|
||
let f = t - i as f32;
|
||
*slot = self.curves[i][c] * (1.0 - f) + self.curves[i + 1][c] * f;
|
||
}
|
||
out
|
||
}
|
||
|
||
fn sample_lut(&self, density: [f32; 3]) -> [f32; 3] {
|
||
let n = self.lut_size;
|
||
let mut base = [0usize; 3];
|
||
let mut frac = [0f32; 3];
|
||
for c in 0..3 {
|
||
let t = (density[c] / self.density_max).clamp(0.0, 1.0) * (n - 1) as f32;
|
||
base[c] = (t.floor() as usize).min(n - 2);
|
||
frac[c] = t - base[c] as f32;
|
||
}
|
||
let mut out = [0.0f32; 3];
|
||
for dx in 0..2 {
|
||
for dy in 0..2 {
|
||
for dz in 0..2 {
|
||
let w = if dx == 0 { 1.0 - frac[0] } else { frac[0] }
|
||
* if dy == 0 { 1.0 - frac[1] } else { frac[1] }
|
||
* if dz == 0 { 1.0 - frac[2] } else { frac[2] };
|
||
let e = self.lut[((base[2] + dz) * n + base[1] + dy) * n + base[0] + dx];
|
||
for c in 0..3 {
|
||
out[c] += w * e[c];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
}
|
||
|
||
/// Linear sRGB to the three layers' exposure, mid-grey normalised.
|
||
///
|
||
/// Normalised on the *green* layer alone, one shared scalar for all three.
|
||
/// Doing it per layer is tempting and wrong: it would silently flatten the
|
||
/// film's own channel balance, which is a large part of what distinguishes one
|
||
/// stock from another. Where the balance genuinely has to come out — printing a
|
||
/// negative — it is [`print_balance`]'s job, which is also where it belongs
|
||
/// physically.
|
||
pub fn exposure_matrix(film: &Profile) -> [[f32; 3]; 3] {
|
||
let reference = illuminant(&film.reference_illuminant);
|
||
let sensitivity = film.sensitivity();
|
||
|
||
let mut m = [[0.0f32; 3]; 3];
|
||
let mut mid_grey = [0.0f32; 3];
|
||
for i in 0..SPECTRUM {
|
||
for layer in 0..3 {
|
||
let s = sensitivity[i][layer] * reference[i];
|
||
mid_grey[layer] += s * MID_GREY;
|
||
for channel in 0..3 {
|
||
m[layer][channel] += s * SRGB_BASIS[i][channel];
|
||
}
|
||
}
|
||
}
|
||
|
||
let scale = 1.0 / mid_grey[1];
|
||
for row in &mut m {
|
||
for v in row.iter_mut() {
|
||
*v *= scale;
|
||
}
|
||
}
|
||
m
|
||
}
|
||
|
||
/// The enlarger head's filtration, solved rather than dialled.
|
||
///
|
||
/// Returns the per-layer log exposure offsets that make a mid-grey scene print
|
||
/// as a neutral mid-grey. This is also where a colour negative's orange mask
|
||
/// goes: the mask is a fixed density, so balancing mid-grey to neutral cancels
|
||
/// it — which is why a printed negative looks like a photograph while a scanned
|
||
/// one looks orange.
|
||
fn print_balance(
|
||
film: &Profile,
|
||
paper: &Profile,
|
||
exposure_ev: f32,
|
||
print_exposure_ev: f32,
|
||
) -> [f32; 3] {
|
||
let matrix = exposure_matrix(film);
|
||
let scene = MID_GREY * 2f32.powf(exposure_ev);
|
||
let mut log_exposure = [0.0f32; 3];
|
||
for (l, slot) in log_exposure.iter_mut().enumerate() {
|
||
let m = matrix[l];
|
||
*slot = ((m[0] + m[1] + m[2]) * scene + 1e-10).log10();
|
||
}
|
||
let mid_raw = paper_exposure(film, paper, film.density_at(log_exposure));
|
||
|
||
// Where on the paper's curve mid-grey belongs: the density that reflects
|
||
// 18%, read off the average of the three curves. Averaged because the
|
||
// point of the balance is that the three end up at the same place.
|
||
let target_density = -MID_GREY.log10() - mean(&paper.base_density);
|
||
let target = invert_mean_curve(paper, target_density);
|
||
|
||
let mut offsets = [0.0f32; 3];
|
||
for (l, slot) in offsets.iter_mut().enumerate() {
|
||
*slot = target - (mid_raw[l] + 1e-10).log10() + print_exposure_ev * 2f32.log10();
|
||
}
|
||
offsets
|
||
}
|
||
|
||
/// The paper's three layer exposures, printing through a negative at these
|
||
/// densities.
|
||
///
|
||
/// The one genuinely spectral step left in the chain — the negative's
|
||
/// transmittance is `10^-D`, so this is not a matrix and cannot be made into
|
||
/// one. It takes exactly three numbers in, which is what lets the whole
|
||
/// negative-and-print chain still bake into a 3D lookup.
|
||
fn paper_exposure(film: &Profile, paper: &Profile, density: [f32; 3]) -> [f32; 3] {
|
||
let enlarger = illuminant(&paper.reference_illuminant);
|
||
let sensitivity = paper.sensitivity();
|
||
let transmittance = film.transmittance(density);
|
||
|
||
let mut raw = [0.0f32; 3];
|
||
for i in 0..SPECTRUM {
|
||
let light = transmittance[i] * enlarger[i];
|
||
for layer in 0..3 {
|
||
raw[layer] += light * sensitivity[i][layer];
|
||
}
|
||
}
|
||
raw
|
||
}
|
||
|
||
/// Bake a recipe into the tables a shader runs.
|
||
pub fn bake(recipe: &Recipe) -> Baked {
|
||
let film = recipe.film;
|
||
let mut matrix = exposure_matrix(film);
|
||
// Camera exposure rides in the matrix rather than in the shader: it is a
|
||
// scalar on a linear quantity, and folding it in here costs nothing and
|
||
// keeps the per-pixel work identical whether or not it has been moved.
|
||
let gain = 2f32.powf(recipe.exposure_ev);
|
||
for row in &mut matrix {
|
||
for v in row.iter_mut() {
|
||
*v *= gain;
|
||
}
|
||
}
|
||
|
||
// TRACES: FR-DEV-3f
|
||
// Developed to the requested push before anything else reads the curves:
|
||
// the density ceiling, the print balance and the grain all depend on how
|
||
// far this film was taken, and a push that only reached one of them would
|
||
// be a contrast change wearing a push's name.
|
||
let curves = film.curves_at_push(recipe.push_stops);
|
||
let density_max = curves
|
||
.iter()
|
||
.flat_map(|row| row.iter())
|
||
.fold(0.0f32, |a, &b| a.max(b))
|
||
.max(1e-3);
|
||
|
||
let viewing = match recipe.print {
|
||
Some(paper) => Viewing::new(&paper.viewing_illuminant),
|
||
None => Viewing::new(&film.viewing_illuminant),
|
||
};
|
||
let balance = recipe
|
||
.print
|
||
.map(|paper| print_balance(film, paper, recipe.exposure_ev, recipe.print_exposure_ev));
|
||
|
||
let n = LUT_SIZE;
|
||
let mut lut = Vec::with_capacity(n * n * n);
|
||
// Blue outermost and red innermost, so the red axis varies fastest. See
|
||
// `Baked::lut`: this is the layout a 3D texture upload wants, and getting
|
||
// it backwards transposes red and blue in the finished picture.
|
||
for b in 0..n {
|
||
for g in 0..n {
|
||
for r in 0..n {
|
||
let density = [
|
||
density_max * r as f32 / (n - 1) as f32,
|
||
density_max * g as f32 / (n - 1) as f32,
|
||
density_max * b as f32 / (n - 1) as f32,
|
||
];
|
||
lut.push(match recipe.print.zip(balance) {
|
||
Some((paper, offsets)) => {
|
||
let raw = paper_exposure(film, paper, density);
|
||
let mut log_exposure = [0.0f32; 3];
|
||
for (l, slot) in log_exposure.iter_mut().enumerate() {
|
||
*slot = (raw[l] + 1e-10).log10() + offsets[l];
|
||
}
|
||
let paper_density = paper.density_at(log_exposure);
|
||
viewing.to_srgb(&paper.transmittance(paper_density))
|
||
}
|
||
None => viewing.to_srgb(&film.transmittance(density)),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
Baked {
|
||
exposure_matrix: matrix,
|
||
curves,
|
||
curve_log_min: film.log_exposure_min,
|
||
curve_log_max: film.log_exposure_max,
|
||
lut,
|
||
density_max,
|
||
lut_size: n,
|
||
}
|
||
}
|
||
|
||
fn mean(s: &Spectrum) -> f32 {
|
||
s.iter().sum::<f32>() / SPECTRUM as f32
|
||
}
|
||
|
||
/// The log exposure at which the paper's average curve reaches `density`.
|
||
fn invert_mean_curve(paper: &Profile, density: f32) -> f32 {
|
||
let curves = &paper.density_curves;
|
||
let last = curves.len() - 1;
|
||
let span = paper.log_exposure_max - paper.log_exposure_min;
|
||
let at = |i: usize| (curves[i][0] + curves[i][1] + curves[i][2]) / 3.0;
|
||
let log_at = |i: usize| paper.log_exposure_min + span * i as f32 / last as f32;
|
||
|
||
// A paper is negative-working, so its mean curve rises. Walk it rather
|
||
// than binary-search: 256 samples is nothing, and a linear scan is correct
|
||
// even where the curve is flat, which a bisection is not.
|
||
let ascending = at(last) >= at(0);
|
||
for i in 0..last {
|
||
let (lo, hi) = (at(i), at(i + 1));
|
||
let brackets = if ascending {
|
||
lo <= density && density <= hi
|
||
} else {
|
||
hi <= density && density <= lo
|
||
};
|
||
if brackets && (hi - lo).abs() > f32::EPSILON {
|
||
let f = (density - lo) / (hi - lo);
|
||
return log_at(i) + (log_at(i + 1) - log_at(i)) * f;
|
||
}
|
||
}
|
||
// Off the end of the curve: the nearest end is the honest answer, and it
|
||
// keeps a badly-scaled contributed profile from producing a NaN that would
|
||
// propagate silently through the whole LUT.
|
||
if (density <= at(0)) == ascending {
|
||
paper.log_exposure_min
|
||
} else {
|
||
paper.log_exposure_max
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::profile::CURVE_SAMPLES;
|
||
|
||
fn profile(yaml: &str) -> Profile {
|
||
Profile::parse(yaml).unwrap()
|
||
}
|
||
|
||
fn portra() -> Profile {
|
||
profile(include_str!("../profiles/kodak_portra_400.yaml"))
|
||
}
|
||
|
||
fn endura() -> Profile {
|
||
profile(include_str!("../profiles/kodak_portra_endura.yaml"))
|
||
}
|
||
|
||
fn kodachrome() -> Profile {
|
||
profile(include_str!("../profiles/kodak_kodachrome_64.yaml"))
|
||
}
|
||
|
||
fn spread(rgb: [f32; 3]) -> f32 {
|
||
rgb.iter().cloned().fold(f32::MIN, f32::max) - rgb.iter().cloned().fold(f32::MAX, f32::min)
|
||
}
|
||
|
||
#[test]
|
||
fn the_exposure_matrix_is_diagonally_dominant() {
|
||
// Each layer must respond most strongly to its own primary. A matrix
|
||
// that failed this would mean the sensitivity table had been pasted in
|
||
// the wrong channel order, which produces a picture that renders
|
||
// perfectly and has its colours swapped.
|
||
for film in [portra(), kodachrome()] {
|
||
let m = exposure_matrix(&film);
|
||
for layer in 0..3 {
|
||
for channel in 0..3 {
|
||
if channel != layer {
|
||
assert!(
|
||
m[layer][layer] > m[layer][channel] * 3.0,
|
||
"{}: layer {layer} responds to channel {channel} too strongly: {m:?}",
|
||
film.stock
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_reversal_stock_renders_a_positive() {
|
||
let film = kodachrome();
|
||
let baked = bake(&Recipe::new(&film, None));
|
||
let shadow = baked.apply([0.02; 3]);
|
||
let mid = baked.apply([MID_GREY; 3]);
|
||
let highlight = baked.apply([0.8; 3]);
|
||
assert!(
|
||
shadow[1] < mid[1] && mid[1] < highlight[1],
|
||
"{shadow:?} {mid:?} {highlight:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_scanned_negative_is_inverted_and_orange() {
|
||
// Not a defect: it is what a negative looks like, and rendering it any
|
||
// other way would mean the print stage was silently applied.
|
||
let film = portra();
|
||
let baked = bake(&Recipe::new(&film, None));
|
||
let shadow = baked.apply([0.02; 3]);
|
||
let highlight = baked.apply([0.8; 3]);
|
||
assert!(
|
||
shadow[1] > highlight[1],
|
||
"not inverted: {shadow:?} -> {highlight:?}"
|
||
);
|
||
let mid = baked.apply([MID_GREY; 3]);
|
||
assert!(mid[0] > mid[2] * 4.0, "no orange mask: {mid:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn printing_a_negative_restores_the_picture() {
|
||
// The property the whole print stage exists for: through the paper,
|
||
// the same negative is the right way up and neutral again.
|
||
let film = portra();
|
||
let paper = endura();
|
||
let baked = bake(&Recipe::new(&film, Some(&paper)));
|
||
|
||
let shadow = baked.apply([0.02; 3]);
|
||
let mid = baked.apply([MID_GREY; 3]);
|
||
let highlight = baked.apply([0.8; 3]);
|
||
assert!(
|
||
shadow[1] < mid[1] && mid[1] < highlight[1],
|
||
"print is not a positive: {shadow:?} {mid:?} {highlight:?}"
|
||
);
|
||
for grey in [shadow, mid, highlight] {
|
||
assert!(
|
||
spread(grey) < 0.06,
|
||
"print of a neutral is not neutral: {grey:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn exposure_moves_the_print_the_way_it_moves_a_photograph() {
|
||
let film = portra();
|
||
let paper = endura();
|
||
let brighter = bake(&Recipe {
|
||
exposure_ev: 1.0,
|
||
..Recipe::new(&film, Some(&paper))
|
||
});
|
||
let base = bake(&Recipe::new(&film, Some(&paper)));
|
||
assert!(brighter.apply([MID_GREY; 3])[1] > base.apply([MID_GREY; 3])[1]);
|
||
}
|
||
|
||
#[test]
|
||
fn the_lut_holds_its_error_under_a_code_value() {
|
||
// The claim LUT_SIZE is chosen on. Compared against the same chain
|
||
// evaluated exactly, so it measures interpolation error and nothing
|
||
// else.
|
||
let film = kodachrome();
|
||
let baked = bake(&Recipe::new(&film, None));
|
||
let viewing = Viewing::new(&film.viewing_illuminant);
|
||
|
||
let mut worst = 0.0f32;
|
||
for i in 0..40 {
|
||
for j in 0..40 {
|
||
let rgb = [
|
||
i as f32 / 39.0,
|
||
j as f32 / 39.0,
|
||
((i + j) % 40) as f32 / 39.0,
|
||
];
|
||
let mut log_exposure = [0.0f32; 3];
|
||
for (l, slot) in log_exposure.iter_mut().enumerate() {
|
||
let m = baked.exposure_matrix[l];
|
||
*slot =
|
||
((m[0] * rgb[0] + m[1] * rgb[1] + m[2] * rgb[2]).max(0.0) + 1e-10).log10();
|
||
}
|
||
let exact = viewing.to_srgb(&film.transmittance(film.density_at(log_exposure)));
|
||
let approx = baked.apply(rgb);
|
||
for c in 0..3 {
|
||
worst = worst.max((exact[c] - approx[c]).abs());
|
||
}
|
||
}
|
||
}
|
||
assert!(
|
||
worst < 1.0 / 255.0,
|
||
"worst LUT error {worst} exceeds one code value"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_lut_stores_red_along_its_fastest_axis() {
|
||
// The layout a 3D texture upload expects, and the one bug this whole
|
||
// decomposition is most exposed to: fill it the other way round and
|
||
// the picture comes back with red and blue transposed -- entirely
|
||
// plausible-looking, and wrong. Asserted here rather than only in the
|
||
// end-to-end GPU test, because that one needs a device and this one
|
||
// does not.
|
||
let film = kodachrome();
|
||
let baked = bake(&Recipe::new(&film, None));
|
||
let n = baked.lut_size;
|
||
|
||
// Step one along each axis from the origin, and check that the entry
|
||
// found is the one the *density* moved along that axis should give.
|
||
let viewing = Viewing::new(&film.viewing_illuminant);
|
||
let step = baked.density_max / (n - 1) as f32;
|
||
for (axis, offset) in [(0usize, 1usize), (1, n), (2, n * n)] {
|
||
let mut density = [0.0f32; 3];
|
||
density[axis] = step;
|
||
let expected = viewing.to_srgb(&film.transmittance(density));
|
||
let stored = baked.lut[offset];
|
||
for c in 0..3 {
|
||
assert!(
|
||
(stored[c] - expected[c]).abs() < 1e-4,
|
||
"axis {axis} is not at stride {offset}: stored {stored:?}, \
|
||
the density one step along that axis gives {expected:?}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_lut_is_the_size_it_says_it_is() {
|
||
let film = kodachrome();
|
||
let baked = bake(&Recipe::new(&film, None));
|
||
assert_eq!(baked.lut.len(), LUT_SIZE * LUT_SIZE * LUT_SIZE);
|
||
assert_eq!(baked.curves.len(), CURVE_SAMPLES);
|
||
}
|
||
}
|