Colour came from whichever matrix rawler happened to key `D65`, the second one was discarded, and the rendering was left linear. That is the dcraw default, and FR-DEV-3e names it as the reason people abandon a converter in the first hour: correct in the abstract, flat and poor on skin in practice. The decoder now builds a camera profile. - `ColorMatrix1/2` and `CalibrationIlluminant1/2`. rawler surfaces these as an illuminant-keyed map — for DNGs from the tags, and for native formats from its own camera database — so a Canon CR2 arrives with a tungsten matrix and a daylight matrix exactly as an Adobe DNG of the same frame would. Dual-illuminant support is therefore not a DNG feature here. - `ForwardMatrix1/2`, read straight from the root IFD, because rawler parses them and never surfaces them. Where a file carries both, they replace the inverted colour matrix: the same relationship measured in the direction rendering actually wants, rather than an inversion that amplifies the measurement error exactly where skin lives. - `AsShotNeutral`, used to estimate what the scene was lit by and to interpolate between the two calibrations in mireds. The estimate is circular — the temperature needs a matrix and the matrix needs the temperature — so it is a fixed point, three rounds, as Adobe's SDK does it. Bodies calibrated at neither D65 nor A stopped rendering uncalibrated as a side effect: a Phase One IQ3 carries D55 and D75 and used to get no matrix at all. And a base curve, applied per channel in camera RGB between the last adjustment and the conversion out of camera space — a toe, a steep midtone and a shoulder, which is the difference between a photograph and a scan of one. It is not an edit: no slider, nothing in the sidecar, because it belongs to the body rather than to anything anyone decided, and a sidecar is shared between bodies. It is not a develop node either, and `ops/README.md` now records why. It evaluates on the tone curve's own spline rather than a second copy, so a profile author placing a control point and a photographer dragging one mean the same thing by it. The curves are data. `core/dr-decode/profiles/base_curves.yaml` ships inside the binary as a floor and is superseded by any copy on disk carrying a higher `version:`, so a body can be added and distributed without a release — and, under the GPL, contributed. The comparison runs both ways: a stale pack cannot hold an upgraded binary back at last year's rendering. Canon EOS 6D and R6, Nikon Z 6 and D750, Sony A7 III and Fujifilm X-T3 ship with their own curves. Every other body gets a conservative default, which is much closer to right than the identity is for any of them. A JPEG gets none — it has already been rendered once, by the camera. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
764 lines
29 KiB
Rust
764 lines
29 KiB
Rust
//! TRACES: FR-DEV-3e
|
|
//! Base curves — the per-body rendering that turns a correct exposure into a
|
|
//! photograph.
|
|
//!
|
|
//! # What this is for
|
|
//!
|
|
//! A camera matrix gets the *colours* right and leaves the picture flat. Sensor
|
|
//! data is scene-referred and very nearly linear; a print, a screen and a
|
|
//! camera's own JPEG are none of those things. Rendering linear data straight
|
|
//! out is the dcraw default, and FR-DEV-3e names it precisely: "the flat,
|
|
//! poor-skin-tone rendering characteristic of dcraw defaults, which is the
|
|
//! documented reason people abandon darktable in the first hour."
|
|
//!
|
|
//! The fix is a tone curve applied as part of *reading* the file rather than as
|
|
//! an edit — a toe, a steep midtone, and a shoulder that rolls highlights off
|
|
//! instead of clipping them. Every raw converter has one. Adobe calls it the
|
|
//! camera profile's tone curve, darktable calls it the base curve, and the name
|
|
//! here follows darktable's because the placement does too: it runs in camera
|
|
//! RGB, after white balance and the user's adjustments, immediately before the
|
|
//! conversion out to a working space.
|
|
//!
|
|
//! # Why it is not an edit
|
|
//!
|
|
//! It never reaches the sidecar and there is no slider for it, for the same
|
|
//! reason the EXIF orientation is not an edit (FR-DEV-3h): it is a property of
|
|
//! the body that took the frame, not of what anyone decided about the frame.
|
|
//! Sidecars are shared between devices and bodies (FR-NC-9), and one camera's
|
|
//! rendering must not follow an edit onto another camera's file.
|
|
//!
|
|
//! # Why it is data
|
|
//!
|
|
//! FR-DEV-3e requires the profile database to be "versioned independently of
|
|
//! the app binary so bodies and curves can be added without a release — and,
|
|
//! under D8's GPLv3, contributed by users". So the curves live in
|
|
//! `profiles/base_curves.yaml`, a file that is compiled in as a floor and
|
|
//! *overridden* by a copy on disk carrying a higher `version:`. Adding a body
|
|
//! is adding ten numbers to a YAML file; shipping that body to users is
|
|
//! publishing the file. Neither is a code change and neither needs a release.
|
|
//!
|
|
//! See [`load`] for the search path and [`Curves::body`] for the matching.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::OnceLock;
|
|
|
|
/// How many control points a base curve has.
|
|
///
|
|
/// Five, which is not a coincidence: it is what the tone curve widget uses
|
|
/// (`dr_pipeline::ops::curve::POINTS`), so the shader evaluates a profile's
|
|
/// curve and a photographer's curve through exactly the same spline. A profile
|
|
/// author and a photographer dragging a point mean the same thing by it, and
|
|
/// the generated shader carries one implementation rather than two that could
|
|
/// disagree.
|
|
pub const POINTS: usize = 5;
|
|
|
|
/// TRACES: FR-DEV-3e
|
|
/// A base curve: five points on a monotone spline through the unit square.
|
|
///
|
|
/// `xs` is scene-linear camera RGB, normalised so that 1.0 is the sensor's
|
|
/// saturation point. `ys` is display-referred linear — *not* gamma-encoded,
|
|
/// because the sRGB transfer function is applied at the very end of the
|
|
/// generated shader and applying it twice would wash the image out.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct BaseCurve {
|
|
pub xs: [f32; POINTS],
|
|
pub ys: [f32; POINTS],
|
|
}
|
|
|
|
impl BaseCurve {
|
|
/// The curve that does nothing — the identity diagonal.
|
|
///
|
|
/// What an unrecognised body gets if the database carries no default, and
|
|
/// what a JPEG gets always: an already-rendered image must not be rendered
|
|
/// a second time.
|
|
pub const IDENTITY: Self = Self {
|
|
xs: [0.0, 0.25, 0.5, 0.75, 1.0],
|
|
ys: [0.0, 0.25, 0.5, 0.75, 1.0],
|
|
};
|
|
|
|
/// Whether this curve would leave the image alone.
|
|
///
|
|
/// The shader is told to skip the stage entirely when it would, so an
|
|
/// unprofiled body costs a branch that is uniform across the dispatch
|
|
/// rather than a spline evaluation per channel per pixel.
|
|
pub fn is_identity(&self) -> bool {
|
|
self.xs
|
|
.iter()
|
|
.zip(self.ys.iter())
|
|
.all(|(x, y)| (x - y).abs() < 1e-6)
|
|
}
|
|
|
|
/// Build from raw pairs, rejecting anything that is not a curve.
|
|
///
|
|
/// A profile file is data a user may have edited, so this is the boundary
|
|
/// where "ten numbers" becomes "a curve": the x coordinates must increase,
|
|
/// the y coordinates must not decrease, and both must lie in the unit
|
|
/// square. A non-monotone x sends the spline's span search backwards and
|
|
/// divides by a negative width; a decreasing y inverts tones locally,
|
|
/// which reads as a dark halo through smooth gradients rather than as a
|
|
/// bad profile.
|
|
///
|
|
/// Endpoints are not forced to (0,0) and (1,1). A curve that lifts black
|
|
/// slightly, or that places the shoulder below white, is a legitimate
|
|
/// rendering choice and several bodies make it.
|
|
pub fn from_points(points: &[[f32; 2]]) -> Option<Self> {
|
|
if points.len() != POINTS {
|
|
return None;
|
|
}
|
|
let mut xs = [0.0f32; POINTS];
|
|
let mut ys = [0.0f32; POINTS];
|
|
for (i, p) in points.iter().enumerate() {
|
|
if !p[0].is_finite() || !p[1].is_finite() {
|
|
return None;
|
|
}
|
|
if !(0.0..=1.0).contains(&p[0]) || !(0.0..=1.0).contains(&p[1]) {
|
|
return None;
|
|
}
|
|
xs[i] = p[0];
|
|
ys[i] = p[1];
|
|
}
|
|
for i in 1..POINTS {
|
|
// Strictly increasing in x — the spline divides by the span width.
|
|
if xs[i] <= xs[i - 1] {
|
|
return None;
|
|
}
|
|
// Non-decreasing in y. Flat is allowed: a curve that holds a
|
|
// highlight range at white is clipping deliberately.
|
|
if ys[i] < ys[i - 1] {
|
|
return None;
|
|
}
|
|
}
|
|
Some(Self { xs, ys })
|
|
}
|
|
}
|
|
|
|
/// One body's entry in the database.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct BodyCurve {
|
|
/// The manufacturer, as the file writes it — "Canon", "NIKON CORPORATION".
|
|
pub make: String,
|
|
/// The model, as the file writes it — "EOS 6D", "ILCE-7M3".
|
|
pub model: String,
|
|
pub curve: BaseCurve,
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3e
|
|
/// The base curve database.
|
|
///
|
|
/// Versioned as a whole rather than per body, because that is the unit a user
|
|
/// downloads and the unit that has to beat the built-in copy. See [`load`].
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct Curves {
|
|
version: u32,
|
|
default: Option<BaseCurve>,
|
|
bodies: Vec<BodyCurve>,
|
|
}
|
|
|
|
impl Curves {
|
|
/// TRACES: FR-DEV-3e
|
|
/// The curve to render a frame from this body with.
|
|
///
|
|
/// Falls back, in order, to the database's `default:` and then to the
|
|
/// identity. **The default is deliberately not the identity**: an
|
|
/// unrecognised body rendered flat is the failure this requirement exists
|
|
/// to prevent, and a gentle, conservative curve is much closer to right for
|
|
/// every body than no curve is for any of them. A body with its own entry
|
|
/// gets that instead.
|
|
///
|
|
/// # What "this body" has to survive
|
|
///
|
|
/// The same camera names itself three ways depending on which program last
|
|
/// touched the file. A native NEF says make "NIKON CORPORATION", model
|
|
/// "NIKON Z 6"; rawler's own database cleans that to "Nikon" and "Z 6"; an
|
|
/// Adobe-converted DNG keeps the uncleaned pair. A database that had to
|
|
/// spell every variant would go stale the first time a maker changed its
|
|
/// mind about its own name, so the matching does the folding instead:
|
|
///
|
|
/// - Case, punctuation and runs of whitespace are flattened, so
|
|
/// "ILCE-7M3", "ILCE 7M3" and "ilce-7m3" are one body.
|
|
/// - The make is compared on its **first word only**. Every maker's
|
|
/// trailing corporate boilerplate — "CORPORATION", "IMAGING CORP" — is
|
|
/// noise, and no two camera manufacturers share a first word.
|
|
/// - The model is tried both as written and with a leading copy of the
|
|
/// make removed, which is what lets one "Canon"/"EOS 6D" entry cover
|
|
/// "Canon EOS 6D" as well.
|
|
pub fn body(&self, make: &str, model: &str) -> BaseCurve {
|
|
let (make, model) = (make_key(make), normalise(model));
|
|
// The model with a leading copy of the maker's name removed.
|
|
let bare = model.strip_prefix(&format!("{make} ")).unwrap_or(&model);
|
|
|
|
self.bodies
|
|
.iter()
|
|
.find(|b| {
|
|
let entry_model = normalise(&b.model);
|
|
make_key(&b.make) == make && (entry_model == model || entry_model == bare)
|
|
})
|
|
.map(|b| b.curve)
|
|
.or(self.default)
|
|
.unwrap_or(BaseCurve::IDENTITY)
|
|
}
|
|
|
|
/// The database version. Higher wins; see [`load`].
|
|
pub fn version(&self) -> u32 {
|
|
self.version
|
|
}
|
|
|
|
/// How many bodies have their own curve, excluding the default.
|
|
pub fn len(&self) -> usize {
|
|
self.bodies.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.bodies.is_empty()
|
|
}
|
|
|
|
/// Parse a database from YAML.
|
|
///
|
|
/// Entries that are not curves are dropped with a warning rather than
|
|
/// failing the parse. A user-contributed file with one bad body should
|
|
/// cost that body's rendering, not every body's — and the alternative is an
|
|
/// application that will not open a photograph because somebody typed a
|
|
/// comma.
|
|
pub fn parse(yaml: &str) -> Result<Self, String> {
|
|
let file: File = serde_norway::from_str(yaml).map_err(|e| e.to_string())?;
|
|
|
|
let default = file.default.and_then(|d| {
|
|
BaseCurve::from_points(&d.points).or_else(|| {
|
|
log::warn!("base curves: the default entry is not a monotone curve; ignoring it");
|
|
None
|
|
})
|
|
});
|
|
|
|
let bodies = file
|
|
.bodies
|
|
.into_iter()
|
|
.filter_map(|b| match BaseCurve::from_points(&b.points) {
|
|
Some(curve) => Some(BodyCurve {
|
|
make: b.make,
|
|
model: b.model,
|
|
curve,
|
|
}),
|
|
None => {
|
|
log::warn!(
|
|
"base curves: {} {} is not a monotone curve; ignoring it",
|
|
b.make,
|
|
b.model
|
|
);
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Ok(Self {
|
|
version: file.version,
|
|
default,
|
|
bodies,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// The copy that ships inside the binary.
|
|
///
|
|
/// A floor, not the answer: [`load`] prefers a newer file on disk. Compiled in
|
|
/// so that a fresh install with no profile directory — and every Android build,
|
|
/// where there is no such directory to speak of — still renders properly.
|
|
const BUILT_IN: &str = include_str!("../profiles/base_curves.yaml");
|
|
|
|
/// TRACES: FR-DEV-3e
|
|
/// The base curve database, loaded once.
|
|
///
|
|
/// # The search path, and why it is a version comparison
|
|
///
|
|
/// 1. `$DARKROOM_PROFILES`, a directory, when set. The escape hatch: a profile
|
|
/// author iterating on a curve points this at their working copy and does
|
|
/// not have to install anything.
|
|
/// 2. `$XDG_DATA_HOME/darkroom/profiles/`, else `$HOME/.local/share/darkroom/profiles/`.
|
|
/// The same base directory the catalog uses, chosen there for the same
|
|
/// reason — it is data, not cache, and must survive a storage sweep.
|
|
/// 3. The copy compiled into the binary.
|
|
///
|
|
/// The first file that parses *and carries a higher `version:` than the
|
|
/// built-in copy* wins. The version check is the whole mechanism the
|
|
/// requirement asks for, and it runs in both directions:
|
|
///
|
|
/// - A downloaded pack at version 7 supersedes a binary shipping version 3, so
|
|
/// a body added after the release renders correctly with no release.
|
|
/// - A stale pack at version 2 does **not** supersede a binary shipping version
|
|
/// 3, so upgrading the application cannot silently lose curves to a file
|
|
/// somebody downloaded a year ago and forgot.
|
|
///
|
|
/// Failures are warnings, never errors. A malformed profile file must cost the
|
|
/// user their curves, not their photographs.
|
|
pub fn load() -> &'static Curves {
|
|
static LOADED: OnceLock<Curves> = OnceLock::new();
|
|
LOADED.get_or_init(|| {
|
|
let built_in = Curves::parse(BUILT_IN).unwrap_or_else(|e| {
|
|
// Unreachable in a build that ran its tests — `the_shipped_database_parses`
|
|
// asserts exactly this — but a panic here would mean an
|
|
// application that cannot open a photograph because of a typo in a
|
|
// data file, which is never the right trade.
|
|
log::error!("base curves: the built-in database does not parse: {e}");
|
|
Curves {
|
|
version: 0,
|
|
default: None,
|
|
bodies: Vec::new(),
|
|
}
|
|
});
|
|
|
|
choose(built_in, &search_path())
|
|
})
|
|
}
|
|
|
|
/// The version comparison, separated from where the directories come from.
|
|
///
|
|
/// Split out so it can be tested against real files in a real directory
|
|
/// without the process-wide `OnceLock` and the environment `load` reads. The
|
|
/// rule this implements is the whole of what FR-DEV-3e asks for, so it is
|
|
/// worth being able to state it as a test rather than as a comment.
|
|
fn choose(built_in: Curves, dirs: &[PathBuf]) -> Curves {
|
|
for dir in dirs {
|
|
let path = dir.join("base_curves.yaml");
|
|
let Ok(text) = std::fs::read_to_string(&path) else {
|
|
continue;
|
|
};
|
|
match Curves::parse(&text) {
|
|
Ok(external) if external.version > built_in.version => {
|
|
log::info!(
|
|
"base curves: using {} (version {}, {} bodies) over the built-in version {}",
|
|
path.display(),
|
|
external.version,
|
|
external.len(),
|
|
built_in.version
|
|
);
|
|
return external;
|
|
}
|
|
Ok(external) => log::info!(
|
|
"base curves: ignoring {} at version {}; the built-in database is version {}",
|
|
path.display(),
|
|
external.version,
|
|
built_in.version
|
|
),
|
|
Err(e) => log::warn!("base curves: {} does not parse: {e}", path.display()),
|
|
}
|
|
}
|
|
built_in
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3e
|
|
/// The curve for a body, from the loaded database.
|
|
///
|
|
/// The one call site the decoder needs; everything above is reachable for
|
|
/// tests and for a future profile editor.
|
|
pub fn for_body(make: &str, model: &str) -> BaseCurve {
|
|
load().body(make, model)
|
|
}
|
|
|
|
/// Directories that may hold a `base_curves.yaml`, most specific first.
|
|
fn search_path() -> Vec<PathBuf> {
|
|
let mut dirs = Vec::new();
|
|
if let Some(explicit) = std::env::var_os("DARKROOM_PROFILES") {
|
|
dirs.push(PathBuf::from(explicit));
|
|
}
|
|
// The same resolution `dr_ui::library::catalog_path` uses, and for the
|
|
// same reason: this is data a user may have installed, not a cache. It is
|
|
// duplicated rather than shared because `dr-decode` sits far below the UI
|
|
// and must not acquire a dependency on it to find a directory.
|
|
let base = std::env::var_os("XDG_DATA_HOME")
|
|
.map(PathBuf::from)
|
|
.or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".local/share")));
|
|
if let Some(base) = base {
|
|
dirs.push(base.join("darkroom").join("profiles"));
|
|
}
|
|
dirs
|
|
}
|
|
|
|
/// A manufacturer's first word, folded.
|
|
///
|
|
/// "NIKON CORPORATION", "Nikon" and "nikon" all become `NIKON`. The corporate
|
|
/// suffixes are not information — they appear or not depending on whether the
|
|
/// file went through a DNG converter — and no two camera manufacturers share a
|
|
/// first word, so nothing is lost by dropping them.
|
|
fn make_key(s: &str) -> String {
|
|
normalise(s)
|
|
.split(' ')
|
|
.next()
|
|
.unwrap_or_default()
|
|
.to_string()
|
|
}
|
|
|
|
/// Fold a make or model into something two files can agree on.
|
|
///
|
|
/// Upper-cased, with every run of non-alphanumeric characters collapsed to one
|
|
/// space and the ends trimmed, so that "ILCE-7M3", "ILCE 7M3" and "ilce-7m3"
|
|
/// become one.
|
|
fn normalise(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
let mut pending_space = false;
|
|
for c in s.chars() {
|
|
if c.is_ascii_alphanumeric() {
|
|
if pending_space && !out.is_empty() {
|
|
out.push(' ');
|
|
}
|
|
pending_space = false;
|
|
out.push(c.to_ascii_uppercase());
|
|
} else {
|
|
pending_space = true;
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
// ---- The on-disk shape, kept apart from the in-memory one ----------------
|
|
//
|
|
// Deliberately separate types. The file is data a user edits and is allowed to
|
|
// be wrong; `Curves` is a parsed database whose every entry is known to be a
|
|
// monotone curve. Deriving `Deserialize` on `BaseCurve` directly would delete
|
|
// that boundary and let an unchecked five-point array reach the shader.
|
|
//
|
|
// Unknown fields are **accepted**, which is not laziness. The database is
|
|
// versioned independently of the binary and moves in both directions: a pack
|
|
// published after this release may carry keys this build has never heard of —
|
|
// a hue twist, a look table (FR-DEV-3f) — and it must still deliver its curves
|
|
// to an older DarkRoom rather than failing to parse and leaving every body
|
|
// flat. `deny_unknown_fields` would trade that for a diagnostic nobody needs.
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct File {
|
|
version: u32,
|
|
#[serde(default)]
|
|
default: Option<Entry>,
|
|
#[serde(default)]
|
|
bodies: Vec<BodyEntry>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct Entry {
|
|
points: Vec<[f32; 2]>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct BodyEntry {
|
|
make: String,
|
|
model: String,
|
|
points: Vec<[f32; 2]>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_shipped_database_parses_and_carries_a_default() {
|
|
// The one test that must never be allowed to fail quietly: `load`
|
|
// degrades to an empty database rather than panicking, so without this
|
|
// a typo in the YAML would ship as "every photograph renders flat"
|
|
// rather than as a build failure.
|
|
let curves = Curves::parse(BUILT_IN).expect("the shipped database parses");
|
|
assert!(curves.version() >= 1);
|
|
assert!(!curves.is_empty(), "the database ships bodies");
|
|
assert!(
|
|
!curves.body("Nobody", "Nothing").is_identity(),
|
|
"an unknown body must still get the default rendering"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn every_shipped_curve_lifts_the_midtones_and_rolls_the_highlights() {
|
|
// What makes a base curve a base curve rather than a decoration. If a
|
|
// shipped curve failed either half it would be a worse rendering than
|
|
// the flat one it replaced, which is the one outcome forbidden.
|
|
let curves = Curves::parse(BUILT_IN).expect("parses");
|
|
let all = curves
|
|
.bodies
|
|
.iter()
|
|
.map(|b| (format!("{} {}", b.make, b.model), b.curve))
|
|
.chain(curves.default.map(|c| ("default".to_string(), c)));
|
|
|
|
for (name, curve) in all {
|
|
// The midtone point sits above the diagonal: a linear midtone is
|
|
// roughly a stop and a half darker than any camera renders it.
|
|
let mid = 2;
|
|
assert!(
|
|
curve.ys[mid] > curve.xs[mid],
|
|
"{name} does not lift its midtones ({} -> {})",
|
|
curve.xs[mid],
|
|
curve.ys[mid]
|
|
);
|
|
// And the last span is shallower than the one before it, which is
|
|
// what a shoulder *is*. Without one the curve clips highlights
|
|
// harder than the linear rendering did.
|
|
let slope = |i: usize| (curve.ys[i + 1] - curve.ys[i]) / (curve.xs[i + 1] - curve.xs[i]);
|
|
assert!(
|
|
slope(POINTS - 2) < slope(POINTS - 3),
|
|
"{name} has no highlight shoulder"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_curve_that_is_not_monotone_is_refused() {
|
|
// The profile file is user-editable, so this is a real boundary and
|
|
// not a formality. A decreasing y inverts tones locally and shows up
|
|
// as a dark halo in a gradient, which reads as a rendering fault
|
|
// rather than as a bad profile.
|
|
assert_eq!(
|
|
BaseCurve::from_points(&[
|
|
[0.0, 0.0],
|
|
[0.25, 0.4],
|
|
[0.5, 0.3],
|
|
[0.75, 0.8],
|
|
[1.0, 1.0]
|
|
]),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_curve_whose_x_does_not_advance_is_refused() {
|
|
// The spline divides by the span width; a repeated x is a division by
|
|
// zero in the shader, which is a NaN pixel rather than an error.
|
|
assert_eq!(
|
|
BaseCurve::from_points(&[
|
|
[0.0, 0.0],
|
|
[0.25, 0.3],
|
|
[0.25, 0.5],
|
|
[0.75, 0.8],
|
|
[1.0, 1.0]
|
|
]),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_curve_of_the_wrong_length_is_refused() {
|
|
assert_eq!(BaseCurve::from_points(&[[0.0, 0.0], [1.0, 1.0]]), None);
|
|
}
|
|
|
|
#[test]
|
|
fn values_outside_the_unit_square_are_refused() {
|
|
// The shader clamps its output at the very end anyway, but a control
|
|
// point above 1.0 would put the shoulder outside the range the curve
|
|
// is defined over and silently flatten everything below it.
|
|
assert_eq!(
|
|
BaseCurve::from_points(&[
|
|
[0.0, 0.0],
|
|
[0.25, 0.3],
|
|
[0.5, 1.4],
|
|
[0.75, 1.5],
|
|
[1.0, 1.6]
|
|
]),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_body_with_its_own_entry_beats_the_default() {
|
|
let curves = Curves::parse(
|
|
"version: 2
|
|
default:
|
|
points: [[0.0, 0.0], [0.25, 0.3], [0.5, 0.6], [0.75, 0.85], [1.0, 1.0]]
|
|
bodies:
|
|
- make: Canon
|
|
model: EOS 6D
|
|
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
|
",
|
|
)
|
|
.expect("parses");
|
|
|
|
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
|
|
assert_eq!(curves.body("Canon", "EOS 5D").ys[1], 0.30);
|
|
}
|
|
|
|
#[test]
|
|
fn the_make_may_be_repeated_in_the_model() {
|
|
// Canon writes "Canon" as the make and "Canon EOS 6D" as the model;
|
|
// rawler's cleaned strings drop the repetition and both reach here.
|
|
// One entry has to cover both or half the files on a card miss.
|
|
let curves = Curves::parse(
|
|
"version: 1
|
|
bodies:
|
|
- make: Canon
|
|
model: EOS 6D
|
|
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
|
",
|
|
)
|
|
.expect("parses");
|
|
|
|
assert_eq!(curves.body("Canon", "Canon EOS 6D").ys[1], 0.35);
|
|
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
|
|
assert_eq!(curves.body("CANON", "eos 6d").ys[1], 0.35);
|
|
}
|
|
|
|
#[test]
|
|
fn a_corporate_suffix_does_not_hide_a_body() {
|
|
// The same Z 6 arrives as "Nikon"/"Z 6" from rawler's camera database
|
|
// and as "NIKON CORPORATION"/"NIKON Z 6" from a DNG converted out of
|
|
// the same file. Both must find the entry, or converting a file to
|
|
// DNG would silently change how it renders.
|
|
let curves = Curves::parse(
|
|
"version: 1
|
|
bodies:
|
|
- make: Nikon
|
|
model: Z 6
|
|
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
|
",
|
|
)
|
|
.expect("parses");
|
|
|
|
assert_eq!(curves.body("Nikon", "Z 6").ys[1], 0.35);
|
|
assert_eq!(curves.body("NIKON CORPORATION", "NIKON Z 6").ys[1], 0.35);
|
|
}
|
|
|
|
#[test]
|
|
fn punctuation_and_spacing_do_not_decide_whether_a_body_is_known() {
|
|
let curves = Curves::parse(
|
|
"version: 1
|
|
bodies:
|
|
- make: Sony
|
|
model: ILCE-7M3
|
|
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
|
",
|
|
)
|
|
.expect("parses");
|
|
|
|
assert_eq!(curves.body("SONY", "ILCE 7M3").ys[1], 0.35);
|
|
assert_eq!(curves.body("sony", "ilce-7m3").ys[1], 0.35);
|
|
}
|
|
|
|
#[test]
|
|
fn one_bad_entry_does_not_cost_the_rest() {
|
|
// A user-contributed file with one typo should cost that body's
|
|
// rendering, not every body's.
|
|
let curves = Curves::parse(
|
|
"version: 1
|
|
bodies:
|
|
- make: Broken
|
|
model: Body
|
|
points: [[0.0, 0.0], [0.25, 0.9], [0.5, 0.1], [0.75, 0.9], [1.0, 1.0]]
|
|
- make: Canon
|
|
model: EOS 6D
|
|
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
|
",
|
|
)
|
|
.expect("parses");
|
|
|
|
assert_eq!(curves.len(), 1);
|
|
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
|
|
assert!(curves.body("Broken", "Body").is_identity());
|
|
}
|
|
|
|
#[test]
|
|
fn a_pack_from_the_future_still_delivers_its_curves() {
|
|
// The database is versioned independently of the binary, so a pack
|
|
// published after this build may carry keys this build has never heard
|
|
// of. It must still hand over the curves it does understand — failing
|
|
// the parse would leave every body flat, which is the exact failure
|
|
// FR-DEV-3e exists to prevent, delivered by the mechanism meant to
|
|
// prevent it.
|
|
let curves = Curves::parse(
|
|
"version: 9
|
|
look_table: ambitious
|
|
bodies:
|
|
- make: Canon
|
|
model: EOS 6D
|
|
hue_twist: [1, 2, 3]
|
|
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
|
",
|
|
)
|
|
.expect("an unfamiliar key must not fail the parse");
|
|
|
|
assert_eq!(curves.version(), 9);
|
|
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_body_with_no_default_gets_the_identity() {
|
|
// Graceful fallback, stated as a property: never worse than a flat
|
|
// render, and never a curve tuned for somebody else's sensor when the
|
|
// database declines to offer one.
|
|
let curves = Curves::parse("version: 1\nbodies: []\n").expect("parses");
|
|
assert!(curves.body("Nobody", "Nothing").is_identity());
|
|
}
|
|
|
|
/// A directory holding one `base_curves.yaml`, unique to the caller.
|
|
fn a_pack_dir(name: &str, yaml: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("darkroom-base-curves-{name}"));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).expect("a writable temp directory");
|
|
std::fs::write(dir.join("base_curves.yaml"), yaml).expect("write");
|
|
dir
|
|
}
|
|
|
|
const A_CANON_ENTRY: &str = "bodies:
|
|
- make: Canon
|
|
model: EOS 6D
|
|
points: [[0.0, 0.0], [0.25, 0.42], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
|
";
|
|
|
|
#[test]
|
|
fn a_newer_pack_on_disk_supersedes_the_built_in_database() {
|
|
// **This is the requirement.** FR-DEV-3e asks for a profile database
|
|
// versioned independently of the app binary "so bodies and curves can
|
|
// be added without a release". A file with a higher version, dropped
|
|
// in the profile directory, is what that means in practice.
|
|
let built_in = Curves::parse(BUILT_IN).expect("parses");
|
|
let newer = format!("version: {}\n{A_CANON_ENTRY}", built_in.version() + 1);
|
|
let dir = a_pack_dir("newer", &newer);
|
|
|
|
let chosen = choose(built_in.clone(), &[dir]);
|
|
assert_eq!(chosen.version(), built_in.version() + 1);
|
|
assert_eq!(chosen.body("Canon", "EOS 6D").ys[1], 0.42);
|
|
}
|
|
|
|
#[test]
|
|
fn a_stale_pack_does_not_survive_an_upgrade() {
|
|
// The other direction, and the one that protects the user. Somebody
|
|
// downloads a pack, a release later ships better curves for the same
|
|
// bodies, and the forgotten file must not quietly hold the application
|
|
// back at last year's rendering.
|
|
let built_in = Curves::parse(BUILT_IN).expect("parses");
|
|
let stale = format!("version: {}\n{A_CANON_ENTRY}", built_in.version());
|
|
let dir = a_pack_dir("stale", &stale);
|
|
|
|
let chosen = choose(built_in.clone(), &[dir]);
|
|
assert_eq!(chosen.version(), built_in.version());
|
|
assert_ne!(
|
|
chosen.body("Canon", "EOS 6D").ys[1],
|
|
0.42,
|
|
"an equal version must not displace the built-in database"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_broken_pack_costs_the_curves_and_not_the_photographs() {
|
|
// A malformed profile file must degrade to the built-in database, not
|
|
// to an error. The user came here to look at a photograph.
|
|
let built_in = Curves::parse(BUILT_IN).expect("parses");
|
|
let dir = a_pack_dir("broken", "version: [this is not a number\n");
|
|
|
|
let chosen = choose(built_in.clone(), &[dir]);
|
|
assert_eq!(chosen.version(), built_in.version());
|
|
assert_eq!(chosen.len(), built_in.len());
|
|
}
|
|
|
|
#[test]
|
|
fn a_directory_with_no_pack_in_it_is_simply_skipped() {
|
|
// The ordinary case on every machine: the search path exists, the file
|
|
// does not. It must not be a warning, an error, or a slow path.
|
|
let built_in = Curves::parse(BUILT_IN).expect("parses");
|
|
let missing = std::env::temp_dir().join("darkroom-base-curves-nothing-here");
|
|
let _ = std::fs::remove_dir_all(&missing);
|
|
|
|
assert_eq!(choose(built_in.clone(), &[missing]), built_in);
|
|
}
|
|
|
|
#[test]
|
|
fn the_identity_is_recognised_as_doing_nothing() {
|
|
assert!(BaseCurve::IDENTITY.is_identity());
|
|
assert!(!Curves::parse(BUILT_IN)
|
|
.expect("parses")
|
|
.body("Canon", "EOS 6D")
|
|
.is_identity());
|
|
}
|
|
}
|