mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 02:30:15 -06:00
proxies() fitted the decoded camera JPEG into the sensor proxy's box, which keeps the JPEG's own aspect and can round a pixel short: an ILCE-7RM4's 1616x1080 preview, decoded at 384x257, fits a 192x128 profile proxy as 191x128, and collect_pairs rejected the mismatch. So `lightcraft-cli calibrate` skipped every file from this camera and no profile could be written. Resize the JPEG to exactly the sensor proxy's size instead; proxies() has already rejected references whose aspect differs by more than 2%. Where fitting already landed on the proxy's size the result is identical (fit is resize at a rounded size), so working renders don't change: 34 real ILCE-7RM4 ARWs render bit-identically. On 923 files from one trip calibrate goes from 0 to 843 accepted. Refs #515 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1258 lines
60 KiB
Rust
1258 lines
60 KiB
Rust
//! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF, Panasonic
|
||
//! RW2, Fujifilm RAF, Canon CR3) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
|
||
//! never output pixels or a replacement for RAW editing.
|
||
//! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a
|
||
//! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals
|
||
//! (applied like a DNG `ProfileHueSatMap`) corrects that when it also improves the held-out pixels.
|
||
use lightcraft_color::{D50, D65, Mat3, PROPHOTO, REC2020, bradford, luminance_2020};
|
||
use lightcraft_pipeline::tone::{CameraTone, ToneMap};
|
||
use lightcraft_raster::{
|
||
Rgb32f,
|
||
resample::{Filter, fit, resize},
|
||
};
|
||
use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTable};
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct CameraLook {
|
||
pub matrix: Mat3,
|
||
pub tone: CameraTone,
|
||
/// Hue/saturation correction after `matrix`, in linear ProPhoto RGB (DNG `ProfileHueSatMap`).
|
||
pub hue_sat: Option<HsvTable>,
|
||
}
|
||
|
||
/// Long edge of the sensor/JPEG proxy a single photo's look is fitted on (the acceptance gates
|
||
/// below were set at this size).
|
||
const PROXY: usize = 96;
|
||
/// Long edge of the proxies pooled for a camera profile: small objects (a shirt) get 4× the samples.
|
||
pub(crate) const PROFILE_PROXY: usize = 192;
|
||
|
||
/// Raw formats whose decoder supplies vendor white-balance multipliers but no camera colour matrix:
|
||
/// their starting look is fitted to the file's own JPEG, and white balance is relative to the
|
||
/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the
|
||
/// same formats by file extension.
|
||
pub(crate) fn file_local_look(format: RawFormat) -> bool {
|
||
matches!(
|
||
format,
|
||
RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3 | RawFormat::Cr2 | RawFormat::Pef
|
||
)
|
||
}
|
||
|
||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
|
||
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
|
||
return None;
|
||
}
|
||
let (sensor, reference) = proxies(raw, bytes, transform, PROXY)?;
|
||
// A camera profile pooled from many photos knows colours this photo shows too little of;
|
||
// try its colour first and fit tone/chroma per photo (DRO and picture styles vary).
|
||
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
|
||
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
|
||
let look = fit_pairs_with(&sensor, &reference, colour)?;
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!(
|
||
"[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile available {}",
|
||
raw.format,
|
||
look.matrix.0,
|
||
look.tone,
|
||
look.hue_sat.is_some(),
|
||
profile.is_some()
|
||
);
|
||
}
|
||
Some(look)
|
||
}
|
||
|
||
/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s
|
||
/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020).
|
||
fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f)> {
|
||
let edge = (2 * size).max(384) as u32;
|
||
let decoded = crate::files::decode_raw_preview(bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: 64_000_000 })?;
|
||
let mut reference = decoded.to_working();
|
||
let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height));
|
||
if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 {
|
||
return None;
|
||
}
|
||
let matches = |w: usize, h: usize, rw: usize, rh: usize| (rw as f64 / rh as f64 / (w as f64 / h as f64) - 1.0).abs() <= 0.02;
|
||
if !matches(crop.width, crop.height, reference.width, reference.height) {
|
||
// Panasonic previews show the whole active area while the default crop is the in-camera aspect ratio
|
||
if !matches(a.width, a.height, reference.width, reference.height) {
|
||
return None;
|
||
}
|
||
let (sx, sy) = (reference.width as f64 / a.width as f64, reference.height as f64 / a.height as f64);
|
||
let (x, y) = ((crop.x as f64 * sx).round() as usize, (crop.y as f64 * sy).round() as usize);
|
||
let (w, h) = ((crop.width as f64 * sx).round() as usize, (crop.height as f64 * sy).round() as usize);
|
||
if w < 16 || h < 16 || x + w > reference.width || y + h > reference.height {
|
||
return None;
|
||
}
|
||
reference = reference.into_crop(x, y, w, h);
|
||
}
|
||
// Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution.
|
||
let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2;
|
||
let mut sensor = sensor_proxy(raw, k, edge as usize)?;
|
||
let gain = 2f32.powf(transform.baseline_exposure as f32);
|
||
let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain));
|
||
if raw.format == RawFormat::Cr3 {
|
||
// Orient the camera JPEG and the sensor identically before collecting pixel correspondences.
|
||
sensor = sensor.into_oriented(raw.orientation);
|
||
reference = reference.into_oriented(raw.orientation);
|
||
sensor.map_in_place(to_working);
|
||
// Canon's JPEG can be cropped differently from the sensor. Estimate only that common
|
||
// framing from edge directions, independently of the subsequent colour fit.
|
||
sensor = align_cr3_framing(sensor, &reference);
|
||
}
|
||
let mut sensor = fit(&sensor, size, size, Filter::Box);
|
||
let reference = reference_at(&reference, &sensor);
|
||
if raw.format != RawFormat::Cr3 {
|
||
sensor.map_in_place(to_working);
|
||
}
|
||
Some((sensor, reference))
|
||
}
|
||
|
||
/// The camera JPEG at exactly the sensor proxy's size, so the two pair pixel for pixel. Fitting it
|
||
/// into the proxy's box instead keeps its own aspect, which can round a pixel short: an ILCE-7RM4's
|
||
/// 1616×1080 preview, decoded at 384×257, fits a 192×128 proxy as 191×128, and `collect_pairs`
|
||
/// then rejected every file `lightcraft-cli calibrate` was given. `proxies` has already checked
|
||
/// that the aspects agree within 2 %.
|
||
fn reference_at(reference: &Rgb32f, sensor: &Rgb32f) -> Rgb32f {
|
||
resize(reference, sensor.width, sensor.height, Filter::Box)
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
struct Cr3Framing {
|
||
scale: f32,
|
||
/// Translation as a fraction of the oriented frame's width and height.
|
||
offset: [f32; 2],
|
||
}
|
||
|
||
impl Cr3Framing {
|
||
const IDENTITY: Self = Self { scale: 1.0, offset: [0.0; 2] };
|
||
|
||
fn source(self, x: f32, y: f32, w: usize, h: usize) -> (f32, f32) {
|
||
let (w, h) = (w as f32, h as f32);
|
||
((x - w * 0.5) * self.scale + w * (0.5 + self.offset[0]), (y - h * 0.5) * self.scale + h * (0.5 + self.offset[1]))
|
||
}
|
||
}
|
||
|
||
/// Register the JPEG's remaining framing on a fixed proxy. Edge directions survive different
|
||
/// white balance and monotone camera tone; no target RGB values train this three-parameter fit.
|
||
/// A separate third of the edges must confirm a substantial improvement and a close match.
|
||
/// A weak, flat or unrelated preview leaves the sensor framing unchanged.
|
||
fn estimate_cr3_framing(sensor: &Rgb32f, reference: &Rgb32f) -> Option<Cr3Framing> {
|
||
if sensor.width.checked_mul(sensor.height)? != sensor.data.len() || reference.width.checked_mul(reference.height)? != reference.data.len() {
|
||
return None;
|
||
}
|
||
let sensor = fit(sensor, PROXY, PROXY, Filter::Box);
|
||
let (w, h) = (sensor.width, sensor.height);
|
||
if w < 16 || h < 16 {
|
||
return None;
|
||
}
|
||
let reference = fit(reference, w, h, Filter::Box);
|
||
if (reference.width, reference.height) != (w, h) {
|
||
return None;
|
||
}
|
||
let mut gradients = Rgb32f::new(w, h);
|
||
let mut edges = Vec::new();
|
||
for y in 1..h - 1 {
|
||
for x in 1..w - 1 {
|
||
let derivative = |image: &Rgb32f| {
|
||
[
|
||
luminance_2020(image.data[y * w + x + 1]) - luminance_2020(image.data[y * w + x - 1]),
|
||
luminance_2020(image.data[(y + 1) * w + x]) - luminance_2020(image.data[(y - 1) * w + x]),
|
||
]
|
||
};
|
||
let d = derivative(&sensor);
|
||
gradients.data[y * w + x] = [d[0], d[1], 0.0];
|
||
let d = derivative(&reference);
|
||
let magnitude = d[0].hypot(d[1]);
|
||
let value = luminance_2020(reference.data[y * w + x]);
|
||
if (0.02..0.9).contains(&value) && magnitude.is_finite() && magnitude > 0.03 {
|
||
edges.push((x as f32 + 0.5, y as f32 + 0.5, d, magnitude, (x * 17 + y * 11) % 3 == 0));
|
||
}
|
||
}
|
||
}
|
||
if edges.len() < 192 {
|
||
return None;
|
||
}
|
||
let score = |framing: Cr3Framing, held_out: bool| -> Option<f64> {
|
||
let (mut agreement, mut weight, mut valid, mut total) = (0.0, 0.0, 0usize, 0usize);
|
||
for &(x, y, target, magnitude, holdout) in &edges {
|
||
if holdout != held_out {
|
||
continue;
|
||
}
|
||
total += 1;
|
||
let (sx, sy) = framing.source(x, y, w, h);
|
||
if sx < 1.5 || sy < 1.5 || sx > w as f32 - 1.5 || sy > h as f32 - 1.5 {
|
||
continue;
|
||
}
|
||
let sample = gradients.sample_bilinear(sx, sy);
|
||
let length = sample[0].hypot(sample[1]);
|
||
if !length.is_finite() || length <= 0.01 {
|
||
continue;
|
||
}
|
||
let k = f64::from(magnitude.min(0.25));
|
||
agreement += k * f64::from((sample[0] * target[0] + sample[1] * target[1]) / (length * magnitude));
|
||
weight += k;
|
||
valid += 1;
|
||
}
|
||
(valid >= 64 && valid * 5 >= total * 4 && weight > 0.0).then_some(agreement / weight)
|
||
};
|
||
let before = score(Cr3Framing::IDENTITY, false)?;
|
||
let mut best = (before, Cr3Framing::IDENTITY);
|
||
// At most 10% zoom and two proxy pixels of translation; a coarse search followed by one
|
||
// local refinement prevents unbounded optimisation on the photograph's contents.
|
||
for s in -10..=10 {
|
||
for tx in -4..=4 {
|
||
for ty in -4..=4 {
|
||
let framing = Cr3Framing { scale: 1.0 + s as f32 * 0.01, offset: [tx as f32 * 0.5 / w as f32, ty as f32 * 0.5 / h as f32] };
|
||
if let Some(value) = score(framing, false)
|
||
&& value > best.0
|
||
{
|
||
best = (value, framing);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let coarse = best.1;
|
||
for s in -5..=5 {
|
||
for tx in -4..=4 {
|
||
for ty in -4..=4 {
|
||
let framing = Cr3Framing {
|
||
scale: coarse.scale + s as f32 * 0.002,
|
||
offset: [coarse.offset[0] + tx as f32 * 0.125 / w as f32, coarse.offset[1] + ty as f32 * 0.125 / h as f32],
|
||
};
|
||
if !(0.9..=1.1).contains(&framing.scale) || framing.offset[0].abs() > 2.0 / w as f32 || framing.offset[1].abs() > 2.0 / h as f32 {
|
||
continue;
|
||
}
|
||
if let Some(value) = score(framing, false)
|
||
&& value > best.0
|
||
{
|
||
best = (value, framing);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let (held_before, held_after) = (score(Cr3Framing::IDENTITY, true)?, score(best.1, true)?);
|
||
if best.0 < 0.9 || best.0 < before + 0.04 || held_after < 0.9 || held_after < held_before + 0.04 {
|
||
return None;
|
||
}
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!("[profile] CR3 JPEG framing {:?}, held-out edge agreement {held_before:.4} -> {held_after:.4}", best.1);
|
||
}
|
||
Some(best.1)
|
||
}
|
||
|
||
fn align_cr3_framing(sensor: Rgb32f, reference: &Rgb32f) -> Rgb32f {
|
||
let Some(framing) = estimate_cr3_framing(&sensor, reference) else { return sensor };
|
||
let (w, h) = (sensor.width, sensor.height);
|
||
let mut aligned = Rgb32f::new(w, h);
|
||
for (i, pixel) in aligned.data.iter_mut().enumerate() {
|
||
let (x, y) = framing.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h);
|
||
*pixel = if x >= 0.5 && y >= 0.5 && x <= w as f32 - 0.5 && y <= h as f32 - 0.5 { sensor.sample_bilinear(x, y) } else { [f32::NAN; 3] };
|
||
}
|
||
aligned
|
||
}
|
||
|
||
/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the
|
||
/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own
|
||
/// colour matrices, other files or unusable previews.
|
||
pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option<Vec<([f64; 3], [f64; 3])>> {
|
||
if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) {
|
||
return None;
|
||
}
|
||
let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw));
|
||
let (sensor, reference) = proxies(raw, bytes, &transform, PROFILE_PROXY)?;
|
||
let to_camera = transform.matrix.inverse()?;
|
||
let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None)?;
|
||
Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect())
|
||
}
|
||
|
||
/// A camera profile's colour model (matrix from white-balanced camera RGB, hue/saturation table)
|
||
/// fitted to pairs pooled from many photos.
|
||
pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Option<HsvTable>)> {
|
||
let matrix = fit_matrix(pairs)?;
|
||
Some((matrix, fit_hue_sat(pairs, &matrix)))
|
||
}
|
||
|
||
fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option<Rgb32f> {
|
||
Some(match raw.develop_binned(k, 0.99).ok()? {
|
||
Some(sensor) => sensor,
|
||
None if raw.cpp == 3 && raw.cfa.is_none() => fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box),
|
||
None => return None,
|
||
})
|
||
}
|
||
|
||
/// A fit must cut the held-out squared error to below this share of the fallback's.
|
||
const MIN_IMPROVEMENT: f64 = 0.7;
|
||
/// ...and stay within this per-channel RMS of the camera JPEG (linear display values). On public
|
||
/// raw.pixls.us samples (eight Sony bodies) good fits that still beat the fallback 1.5–3× landed
|
||
/// at 0.065–0.093 (camera local tone, vignetting and lens processing that a global matrix + curve
|
||
/// cannot follow): visibly better renders that 0.055 rejected.
|
||
const MAX_HOLDOUT_RMS: f64 = 0.10;
|
||
|
||
fn luma(p: [f64; 3]) -> f64 {
|
||
p[0] * 0.2627 + p[1] * 0.6780 + p[2] * 0.0593
|
||
}
|
||
|
||
/// The finish stage's tone map and chroma curve (`lightcraft_pipeline::finish`).
|
||
fn displayed(scene: [f64; 3], tone: &ToneMap) -> [f64; 3] {
|
||
let scene = scene.map(|v| v.max(0.0));
|
||
let y = luma(scene);
|
||
if y <= 0.0 {
|
||
return [0.0; 3];
|
||
}
|
||
let o = f64::from(tone.apply(y as f32));
|
||
let k = f64::from(tone.chroma_scale(o as f32));
|
||
scene.map(|v| o + (v * o / y - o) * k)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
|
||
fit_pairs_with(sensor, reference, None)
|
||
}
|
||
|
||
/// Largest luminance ratio within the 3×3 neighbourhood of pixel `i` (1 on flat areas, large on edges).
|
||
fn local_contrast(image: &Rgb32f, i: usize) -> f32 {
|
||
let (w, h) = (image.width, image.height);
|
||
if w == 0 {
|
||
return f32::INFINITY;
|
||
}
|
||
let (x, y) = (i % w, i / w);
|
||
let (mut lo, mut hi) = (f32::INFINITY, 0f32);
|
||
for row in y.saturating_sub(1)..(y + 2).min(h) {
|
||
for col in x.saturating_sub(1)..(x + 2).min(w) {
|
||
let l = image.data.get(row * w + col).map_or(0.0, |p| luminance_2020(*p));
|
||
lo = lo.min(l);
|
||
hi = hi.max(l);
|
||
}
|
||
}
|
||
if hi.is_finite() { hi / lo.max(1e-4) } else { f32::INFINITY }
|
||
}
|
||
|
||
/// Training pairs (sensor → JPEG, unclipped midtones) and the wider set including highlights
|
||
/// (for the chroma curve); `None` when too few, or the photo has too little colour. With
|
||
/// `edge_limit`, pixels whose 3×3 neighbourhood in either proxy spans a larger luminance ratio
|
||
/// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated
|
||
/// colours (see [`EDGE_CONTRAST`]).
|
||
type Pairs = Vec<([f64; 3], [f64; 3])>;
|
||
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option<f32>) -> Option<(Pairs, Pairs)> {
|
||
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
|
||
return None;
|
||
}
|
||
let mut pairs = Vec::new();
|
||
// Highlights too (camera JPEGs bleach colours toward white there), for the chroma curve only.
|
||
let mut bright = Vec::new();
|
||
let mut colour = 0;
|
||
for (i, (input, output)) in sensor.data.iter().zip(&reference.data).enumerate() {
|
||
if edge_limit.is_some_and(|limit| local_contrast(sensor, i) > limit || local_contrast(reference, i) > limit) {
|
||
continue;
|
||
}
|
||
let y = luminance_2020(*output);
|
||
if !input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) || !output.iter().all(|v| v.is_finite() && *v >= 0.0) {
|
||
continue;
|
||
}
|
||
if (0.015..=1.0).contains(&y) {
|
||
bright.push((input.map(f64::from), output.map(f64::from)));
|
||
}
|
||
if !output.iter().all(|v| *v > 0.004 && *v < 0.98) || !(0.015..0.85).contains(&y) {
|
||
continue;
|
||
}
|
||
let min = output.iter().copied().fold(f32::INFINITY, f32::min);
|
||
let max = output.iter().copied().fold(0.0, f32::max);
|
||
colour += usize::from(max - min > min_chroma);
|
||
pairs.push((input.map(f64::from), output.map(f64::from)));
|
||
}
|
||
(pairs.len() >= 256 && colour >= pairs.len() / 20).then_some((pairs, bright))
|
||
}
|
||
|
||
/// Ridge-regularised 3×3 chromaticity matrix (luminance-normalised RGB) on the training pairs.
|
||
fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option<Mat3> {
|
||
let mut gram = [[0.0; 3]; 3];
|
||
let mut cross = [[0.0; 3]; 3];
|
||
for (i, (x, y)) in pairs.iter().enumerate() {
|
||
if i % 3 == 0 {
|
||
continue;
|
||
}
|
||
let (lx, ly) = (luma(*x), luma(*y));
|
||
if lx <= 0.0 || ly <= 0.0 {
|
||
continue;
|
||
}
|
||
for row in 0..3 {
|
||
for col in 0..3 {
|
||
// Normalising by luminance prevents a camera S-curve from corrupting colour.
|
||
gram[row][col] += x[row] * x[col] / (lx * lx);
|
||
cross[row][col] += y[row] * x[col] / (ly * lx);
|
||
}
|
||
}
|
||
}
|
||
let trace: f64 = (0..3).map(|i| gram[i][i]).sum();
|
||
let inverse = Mat3(gram).inverse()?;
|
||
let condition = trace * (0..3).map(|i| inverse.0[i][i].abs()).sum::<f64>();
|
||
if trace <= 0.0 || !condition.is_finite() || condition > 1e6 {
|
||
return None;
|
||
}
|
||
let regularization = trace * 1e-4;
|
||
for i in 0..3 {
|
||
gram[i][i] += regularization;
|
||
cross[i][i] += regularization;
|
||
}
|
||
let matrix = Mat3(cross).mul(&Mat3(gram).inverse()?);
|
||
matrix.0.iter().flatten().all(|v| v.is_finite() && v.abs() < 8.0).then_some(matrix)
|
||
}
|
||
|
||
/// Neighbourhood luminance ratio above which a pixel counts as an edge for the second attempt
|
||
/// of [`fit_pairs_with`]. Camera JPEGs are often lens-corrected (Sony "Distortion Comp.: Auto",
|
||
/// compacts and kit zooms): on a 51 mm ILCE-7RM2 shot of a glass façade the JPEG is up to 15 px
|
||
/// of 1440 (about one proxy pixel) off the raw, and the fit failed the gate on the mismatched
|
||
/// window frames alone (held-out RMS 0.111; 0.059 for the same shot with distortion correction
|
||
/// off). Away from edges: 0.049. Issue #232.
|
||
const EDGE_CONTRAST: f32 = 3.0;
|
||
|
||
/// The photo's look: its own matrix (with and without a hue/saturation table), or the given
|
||
/// colour model (a camera profile's, in the sensor proxy's space), each completed with a tone
|
||
/// and chroma curve fitted to this photo. When the fit on all pixels fails the acceptance gates,
|
||
/// it is tried once more on the pixels away from edges (same gates): there colour pairs stay
|
||
/// valid when the camera JPEG's geometry differs slightly from the raw's.
|
||
fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
|
||
let has_profile = colour.is_some();
|
||
// A different picture style can make a camera profile fail the gates. Preserve the
|
||
// photo's own colour fit before resorting to the neutral fallback.
|
||
fit_candidate(sensor, reference, colour).or_else(|| has_profile.then(|| fit_candidate(sensor, reference, None)).flatten())
|
||
}
|
||
|
||
/// One candidate colour model, fitted on all pixels and, when that fails the gates, once more on
|
||
/// the pixels away from edges (issue #232).
|
||
fn fit_candidate(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
|
||
fit_pairs_on(sensor, reference, colour.clone(), None).or_else(|| fit_pairs_on(sensor, reference, colour, Some(EDGE_CONTRAST)))
|
||
}
|
||
|
||
fn fit_pairs_on(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> {
|
||
// A known colour model needs enough signal for tone fitting, not a scene rich enough to
|
||
// learn a new colour matrix. Still reject monochrome references.
|
||
let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit)?;
|
||
let candidates = match colour {
|
||
Some(given) => vec![given],
|
||
None => {
|
||
let matrix = fit_matrix(&pairs)?;
|
||
vec![(matrix, fit_hue_sat(&pairs, &matrix)), (matrix, None)]
|
||
}
|
||
};
|
||
// Matrix + table when the table helps the held-out pixels, else the matrix alone. Chosen by
|
||
// error in gamma-encoded display values (closer to what is seen: in linear values a slightly
|
||
// missed bright rock outweighs a clearly wrong dark shirt); the acceptance gates below stay linear.
|
||
let mut best: Option<(f64, f64, usize, CameraLook)> = None;
|
||
for (matrix, hue_sat) in candidates {
|
||
let correction = hue_sat.as_ref().and_then(HueSat::new);
|
||
let colour = |x: [f64; 3]| {
|
||
let p = matrix.apply(x);
|
||
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
|
||
};
|
||
let tone_pairs: Vec<_> =
|
||
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(colour(*x).map(|v| v.max(0.0))), luma(*y))).collect();
|
||
let Some(curve) = fit_tone(tone_pairs) else { continue };
|
||
let mut look = CameraLook { matrix, tone: curve, hue_sat: hue_sat.clone() };
|
||
if let Some(tone) = fit_chroma(&bright, &look) {
|
||
look.tone = tone;
|
||
}
|
||
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||
let (mut linear, mut perceptual, mut samples) = (0.0, 0.0, 0);
|
||
for (x, target) in pairs.iter().step_by(3) {
|
||
let corrected = displayed(colour(*x), &tone);
|
||
for c in 0..3 {
|
||
linear += (corrected[c] - target[c]).powi(2);
|
||
perceptual += (corrected[c].max(0.0).powf(1.0 / 2.2) - target[c].max(0.0).powf(1.0 / 2.2)).powi(2);
|
||
samples += 1;
|
||
}
|
||
}
|
||
if linear.is_finite() && perceptual.is_finite() && best.as_ref().is_none_or(|b| perceptual < b.0) {
|
||
best = Some((perceptual, linear, samples, look));
|
||
}
|
||
}
|
||
let (_, after, samples, look) = best?;
|
||
let original_tone = ToneMap::new(0.0, 0.0, 0.0);
|
||
let before: f64 = pairs
|
||
.iter()
|
||
.step_by(3)
|
||
.map(|(x, target)| {
|
||
let original = displayed(*x, &original_tone);
|
||
(0..3).map(|c| (original[c] - target[c]).powi(2)).sum::<f64>()
|
||
})
|
||
.sum();
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!(
|
||
"[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels{})",
|
||
(before / samples as f64).sqrt(),
|
||
(after / samples as f64).sqrt(),
|
||
if edge_limit.is_some() { ", away from edges" } else { "" }
|
||
);
|
||
}
|
||
if samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) {
|
||
return None;
|
||
}
|
||
Some(look)
|
||
}
|
||
|
||
/// Linear Rec.2020 D65 → linear ProPhoto RGB D50, the space DNG hue/saturation tables work in.
|
||
fn to_prophoto() -> Mat3 {
|
||
PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz())
|
||
}
|
||
|
||
/// A fitted [`CameraLook::hue_sat`] table, ready to apply to linear Rec.2020 pixels. It changes
|
||
/// hue and saturation only: luminance is restored, the camera tone curve owns it.
|
||
pub(crate) struct HueSat<'a> {
|
||
table: &'a HsvTable,
|
||
to: [[f32; 3]; 3],
|
||
from: [[f32; 3]; 3],
|
||
}
|
||
|
||
impl<'a> HueSat<'a> {
|
||
pub fn new(table: &'a HsvTable) -> Option<HueSat<'a>> {
|
||
let to = to_prophoto();
|
||
Some(HueSat { table, to: to.to_f32(), from: to.inverse()?.to_f32() })
|
||
}
|
||
|
||
#[inline]
|
||
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
|
||
let mul = |m: &[[f32; 3]; 3], v: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) };
|
||
let out = mul(&self.from, self.table.apply(mul(&self.to, rgb)));
|
||
let (before, after) = (luminance_2020(rgb), luminance_2020(out));
|
||
if before > 0.0 && after > 0.0 && out.iter().all(|v| v.is_finite()) { out.map(|v| v * before / after) } else { rgb }
|
||
}
|
||
}
|
||
|
||
/// HSV hue (degrees) and saturation; `None` for black or non-finite colours.
|
||
fn hue_saturation(p: [f64; 3]) -> Option<(f64, f64)> {
|
||
let max = p[0].max(p[1]).max(p[2]);
|
||
let min = p[0].min(p[1]).min(p[2]);
|
||
if !(max.is_finite() && min.is_finite()) || max <= 0.0 {
|
||
return None;
|
||
}
|
||
let d = max - min;
|
||
if d <= 0.0 {
|
||
return Some((0.0, 0.0));
|
||
}
|
||
let h = if max == p[0] {
|
||
((p[1] - p[2]) / d).rem_euclid(6.0)
|
||
} else if max == p[1] {
|
||
(p[2] - p[0]) / d + 2.0
|
||
} else {
|
||
(p[0] - p[1]) / d + 4.0
|
||
};
|
||
Some((h * 60.0, d / max))
|
||
}
|
||
|
||
/// Table resolution: 5° hue steps (a coarser grid blurred the lime shirt into neighbouring browns
|
||
/// that want the opposite shift), saturation 0, 0.25 … 1. Value is not an axis: tone is fitted separately.
|
||
const TABLE_HUES: usize = 72;
|
||
const TABLE_SATS: usize = 5;
|
||
/// Value levels (sRGB-encoded, as DNG tables with encoding 1): cameras turn dark yellows toward
|
||
/// orange but bright yellow-greens toward green, which one shift per hue averages away.
|
||
const TABLE_VALS: usize = 5;
|
||
/// Kernel widths around each table node (hue in degrees, saturation, encoded value).
|
||
const KERNEL_HUE: f64 = 2.5;
|
||
const KERNEL_SAT: f64 = 0.15;
|
||
const KERNEL_VAL: f64 = 0.12;
|
||
/// Kernel weight at which a node keeps half of its estimate; sparse nodes shrink to identity.
|
||
const SHRINK_WEIGHT: f64 = 5.0;
|
||
|
||
/// Fit hue shifts and saturation scales of the training pairs left after `matrix`, per (hue,
|
||
/// saturation) node, kernel-weighted and shrunk toward identity where the photo has few samples.
|
||
/// Saturation 0 stays identity, so neutrals are never tinted.
|
||
fn fit_hue_sat(pairs: &[([f64; 3], [f64; 3])], matrix: &Mat3) -> Option<HsvTable> {
|
||
let to = to_prophoto();
|
||
let samples: Vec<(f64, f64, f64, f64, f64)> = pairs
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(i, _)| i % 3 != 0)
|
||
.filter_map(|(_, (x, y))| {
|
||
let p = to.apply(matrix.apply(*x));
|
||
let (hp, sp) = hue_saturation(p)?;
|
||
let value = f64::from(lightcraft_color::transfer::linear_to_srgb(p[0].max(p[1]).max(p[2]).min(1.0) as f32));
|
||
let (ht, st) = hue_saturation(to.apply(*y))?;
|
||
// hue is meaningless near neutral
|
||
if sp < 0.08 || st < 0.02 {
|
||
return None;
|
||
}
|
||
let shift = ((ht - hp + 180.0).rem_euclid(360.0) - 180.0).clamp(-30.0, 30.0);
|
||
let log_scale = (st / sp).ln().clamp(-0.7, 0.7);
|
||
Some((hp, sp.min(1.0), value, shift, log_scale))
|
||
})
|
||
.collect();
|
||
if samples.len() < 64 {
|
||
return None;
|
||
}
|
||
// Samples by table hue step: a node only looks at hues within its kernel's reach.
|
||
let step = 360.0 / TABLE_HUES as f64;
|
||
let mut by_hue: Vec<Vec<(f64, f64, f64, f64, f64)>> = vec![Vec::new(); TABLE_HUES];
|
||
for sample in samples {
|
||
if let Some(bin) = by_hue.get_mut(((sample.0.rem_euclid(360.0) / step) as usize).min(TABLE_HUES - 1)) {
|
||
bin.push(sample);
|
||
}
|
||
}
|
||
let reach = (4.0 * KERNEL_HUE / step).ceil() as usize + 1;
|
||
use rayon::prelude::*;
|
||
let data: Vec<[f32; 3]> = (0..TABLE_VALS * TABLE_HUES * TABLE_SATS)
|
||
.into_par_iter()
|
||
.map(|index| {
|
||
let (v, h, s) = (index / (TABLE_HUES * TABLE_SATS), index / TABLE_SATS % TABLE_HUES, index % TABLE_SATS);
|
||
if s == 0 {
|
||
return [0.0, 1.0, 1.0];
|
||
}
|
||
let (val, hue, sat) = (v as f64 / (TABLE_VALS - 1) as f64, h as f64 * step, s as f64 / (TABLE_SATS - 1) as f64);
|
||
let (mut weight, mut shift, mut log_scale) = (0.0, 0.0, 0.0);
|
||
for offset in 0..=2 * reach {
|
||
let Some(bin) = by_hue.get((h + TABLE_HUES * 2 + offset - reach) % TABLE_HUES) else { continue };
|
||
for &(hp, sp, vp, dh, ls) in bin {
|
||
let dhue = (hp - hue + 180.0).rem_euclid(360.0) - 180.0;
|
||
if dhue.abs() > 4.0 * KERNEL_HUE {
|
||
continue;
|
||
}
|
||
let d2 = (dhue / KERNEL_HUE).powi(2) + ((sp - sat) / KERNEL_SAT).powi(2) + ((vp - val) / KERNEL_VAL).powi(2);
|
||
let k = (-0.5 * d2).exp() * sp;
|
||
weight += k;
|
||
shift += k * dh;
|
||
log_scale += k * ls;
|
||
}
|
||
}
|
||
if weight <= 0.0 {
|
||
return [0.0, 1.0, 1.0];
|
||
}
|
||
let shrink = 1.0 / (weight + SHRINK_WEIGHT);
|
||
[(shift * shrink) as f32, (log_scale * shrink).exp() as f32, 1.0]
|
||
})
|
||
.collect();
|
||
data.iter().all(|e| e.iter().all(|v| v.is_finite())).then_some(HsvTable {
|
||
hue_divisions: TABLE_HUES,
|
||
sat_divisions: TABLE_SATS,
|
||
val_divisions: TABLE_VALS,
|
||
data,
|
||
srgb_value: true,
|
||
})
|
||
}
|
||
|
||
/// Kernel width of the chroma curve's nodes (display luminance) and the weight at which a node
|
||
/// keeps half of its estimate.
|
||
const CHROMA_KERNEL: f64 = 0.08;
|
||
const CHROMA_SHRINK: f64 = 2.0;
|
||
|
||
/// The look's tone curve with a chroma-by-display-luminance curve fitted to `pairs` (two thirds
|
||
/// train, one third held out), when that lowers the held-out error. Colourfulness is compared as
|
||
/// the distance from neutral of luminance-normalised RGB.
|
||
fn fit_chroma(pairs: &[([f64; 3], [f64; 3])], look: &CameraLook) -> Option<CameraTone> {
|
||
let correction = look.hue_sat.as_ref().and_then(HueSat::new);
|
||
let scene = |x: &[f64; 3]| {
|
||
let p = look.matrix.apply(*x);
|
||
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
|
||
};
|
||
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||
let predict = |x: &[f64; 3]| displayed(scene(x), &tone);
|
||
let chroma = |p: [f64; 3]| {
|
||
let y = luma(p);
|
||
(y > 0.0).then(|| p.iter().map(|v| (v / y - 1.0).powi(2)).sum::<f64>().sqrt())
|
||
};
|
||
let samples: Vec<(f64, f64, f64)> = pairs
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(i, _)| i % 3 != 0)
|
||
.filter_map(|(_, (x, y))| {
|
||
let p = predict(x);
|
||
let (cp, cy) = (chroma(p)?, chroma(*y)?);
|
||
(cp > 0.05).then(|| (luma(p), cp, (cy.max(1e-3) / cp).ln().clamp(0.05f64.ln(), 2.5f64.ln())))
|
||
})
|
||
.collect();
|
||
if samples.len() < 64 {
|
||
return None;
|
||
}
|
||
let mut curve = [1.0f32; lightcraft_pipeline::tone::CHROMA_N];
|
||
for (j, node) in curve.iter_mut().enumerate() {
|
||
let at = j as f64 / (lightcraft_pipeline::tone::CHROMA_N - 1) as f64;
|
||
let (mut weight, mut sum) = (0.0, 0.0);
|
||
for &(o, cp, log_ratio) in &samples {
|
||
let k = (-0.5 * ((o - at) / CHROMA_KERNEL).powi(2)).exp() * cp;
|
||
weight += k;
|
||
sum += k * log_ratio;
|
||
}
|
||
*node = (sum / (weight + CHROMA_SHRINK)).exp() as f32;
|
||
}
|
||
let fitted = look.tone.with_chroma(curve)?;
|
||
let with = ToneMap::camera(&fitted, 0.0, 0.0, 0.0);
|
||
let error = |map: &ToneMap| -> f64 {
|
||
pairs
|
||
.iter()
|
||
.step_by(3)
|
||
.map(|(x, y)| {
|
||
let p = displayed(scene(x), map);
|
||
(0..3).map(|c| (p[c] - y[c]).powi(2)).sum::<f64>()
|
||
})
|
||
.sum()
|
||
};
|
||
let (before, after) = (error(&tone), error(&with));
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!("[profile] ARW chroma curve {curve:?}: held-out error {before:.4} -> {after:.4}");
|
||
}
|
||
(after.is_finite() && after < before).then_some(fitted)
|
||
}
|
||
|
||
fn median(values: &mut [f64]) -> Option<f64> {
|
||
values.sort_by(f64::total_cmp);
|
||
values.get(values.len() / 2).copied()
|
||
}
|
||
|
||
fn fit_tone(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
|
||
if pairs.len() < 128 || !pairs.iter().all(|(x, y)| x.is_finite() && *x > 0.0 && y.is_finite()) {
|
||
return None;
|
||
}
|
||
pairs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||
let mut knots = [[0.0; 2]; 32];
|
||
for (i, knot) in knots.iter_mut().enumerate() {
|
||
let bin = pairs.get(i * pairs.len() / 32..(i + 1) * pairs.len() / 32)?;
|
||
let mut xs: Vec<_> = bin.iter().map(|p| p.0).collect();
|
||
let mut ys: Vec<_> = bin.iter().map(|p| p.1).collect();
|
||
*knot = [median(&mut xs)? as f32, median(&mut ys)? as f32];
|
||
}
|
||
if knots[31][0] < knots[0][0] * 1.5 {
|
||
return None;
|
||
}
|
||
// Pool adjacent violating bins (isotonic regression): no reversals or arbitrary polynomial.
|
||
let mut blocks: Vec<(f32, usize)> = Vec::new();
|
||
for knot in knots {
|
||
blocks.push((knot[1], 1));
|
||
while blocks.len() >= 2 {
|
||
let (a, an) = *blocks.get(blocks.len() - 2)?;
|
||
let (b, bn) = *blocks.last()?;
|
||
if a <= b {
|
||
break;
|
||
}
|
||
blocks.truncate(blocks.len() - 2);
|
||
blocks.push(((a * an as f32 + b * bn as f32) / (an + bn) as f32, an + bn));
|
||
}
|
||
}
|
||
let mut i = 0;
|
||
for (y, n) in blocks {
|
||
for knot in knots.get_mut(i..i + n)? {
|
||
knot[1] = y;
|
||
}
|
||
i += n;
|
||
}
|
||
CameraTone::new(knots)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn camera_jpeg_matches_the_sensor_proxy_size_exactly() {
|
||
// An ILCE-7RM4 ARW (9504×6336 crop) embeds a 1616×1080 preview; for the camera-profile proxy
|
||
// it is decoded at 384×257 (`proxies`: twice the proxy edge) next to a 192×128 sensor proxy.
|
||
let sensor = fit(&Rgb32f::new(9504, 6336), PROFILE_PROXY, PROFILE_PROXY, Filter::Box);
|
||
assert_eq!((sensor.width, sensor.height), (192, 128));
|
||
let jpeg = Rgb32f::new(384, 257);
|
||
// what fitting gave before: a pixel short, so every pair was rejected
|
||
assert_eq!(fit(&jpeg, sensor.width, sensor.height, Filter::Box).width, 191);
|
||
let r = reference_at(&jpeg, &sensor);
|
||
assert_eq!((r.width, r.height), (192, 128));
|
||
// and at the single-photo proxy size, where fitting happened to land on the same size
|
||
let sensor = fit(&Rgb32f::new(9504, 6336), PROXY, PROXY, Filter::Box);
|
||
let r = reference_at(&Rgb32f::new(192, 129), &sensor);
|
||
assert_eq!((r.width, r.height), (sensor.width, sensor.height));
|
||
}
|
||
|
||
#[test]
|
||
fn linear_arw_gets_a_sensor_proxy_without_demosaicing() {
|
||
use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, RawFormat, Rect};
|
||
let mut raw = RawImage {
|
||
format: RawFormat::Arw,
|
||
width: 32,
|
||
height: 32,
|
||
cpp: 3,
|
||
data: RawData::F32([0.2, 0.3, 0.4].repeat(32 * 32)),
|
||
cfa: None,
|
||
bits: 16,
|
||
black: BlackLevel::uniform(0.0),
|
||
white: vec![1.0],
|
||
active_area: Rect::new(0, 0, 32, 32),
|
||
crop: Rect::new(0, 0, 32, 32),
|
||
orientation: Orientation::from_exif(1),
|
||
color: ColorData::default(),
|
||
wb_multipliers: Some([1.0; 3]),
|
||
linearized: true,
|
||
opcodes: OpcodeLists::default(),
|
||
metadata: lightcraft_meta::Metadata::default(),
|
||
};
|
||
let proxy = sensor_proxy(&raw, 2, 384).unwrap();
|
||
assert_eq!((proxy.width, proxy.height), (32, 32));
|
||
assert_eq!(proxy.data[0], [0.2, 0.3, 0.4]);
|
||
raw.data = RawData::F32(Vec::new());
|
||
assert!(sensor_proxy(&raw, 2, 384).is_none());
|
||
}
|
||
#[test]
|
||
fn separates_nonlinear_tone_from_colour_and_keeps_sensor_headroom() {
|
||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||
let mut sensor = Rgb32f::new(64, 64);
|
||
let mut reference = sensor.clone();
|
||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||
let ev = 0.05 + (i % 31) as f32 * 0.017;
|
||
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
|
||
let p = known.apply_f32(*src);
|
||
let y = luminance_2020(p);
|
||
*dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y);
|
||
}
|
||
let original = sensor.clone();
|
||
let fit = fit_pairs(&sensor, &reference).unwrap();
|
||
assert_eq!(sensor.data, original.data);
|
||
let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0);
|
||
let error: f64 = sensor
|
||
.data
|
||
.iter()
|
||
.zip(&reference.data)
|
||
.map(|(x, y)| {
|
||
let p = displayed(fit.matrix.apply(x.map(f64::from)), &tone);
|
||
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>() / 3.0
|
||
})
|
||
.sum::<f64>()
|
||
/ sensor.data.len() as f64;
|
||
assert!(error.sqrt() < 0.025, "{error}");
|
||
// The colour transform is homogeneous; tone mapping happens only after exposure.
|
||
let p = fit.matrix.apply([2.0, 2.0, 2.0]);
|
||
assert!(luma(p) > 1.0);
|
||
assert!(tone.apply(0.2) < tone.apply(0.4));
|
||
}
|
||
#[test]
|
||
fn accepts_a_much_better_fit_despite_local_camera_processing() {
|
||
// The camera JPEG departs from any global matrix + curve (local tone, vignetting): ±0.12
|
||
// per-pixel deviations, ~0.07 RMS. The fit is still far closer than the fallback.
|
||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||
let mut sensor = Rgb32f::new(64, 64);
|
||
let mut reference = sensor.clone();
|
||
let mut seed = 0x2545_f491_u32;
|
||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||
let ev = 0.05 + (i % 31) as f32 * 0.017;
|
||
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
|
||
let p = known.apply_f32(*src);
|
||
let y = luminance_2020(p);
|
||
*dst = p.map(|v| {
|
||
seed ^= seed << 13;
|
||
seed ^= seed >> 17;
|
||
seed ^= seed << 5;
|
||
let noise = (seed % 2001) as f32 / 1000.0 - 1.0;
|
||
(v * (1.0 - (-2.5 * y).exp()) / y + 0.12 * noise).clamp(0.005, 0.97)
|
||
});
|
||
}
|
||
assert!(fit_pairs(&sensor, &reference).is_some());
|
||
}
|
||
|
||
/// A camera that rotates saturated yellow-greens toward green (like Sony's "Standard" on a lime
|
||
/// shirt) can't be followed by a matrix alone: the fitted hue/saturation table must close most
|
||
/// of the gap, keep neutrals neutral and keep luminance.
|
||
#[test]
|
||
fn hue_table_follows_a_hue_dependent_camera_rendering() {
|
||
let to = to_prophoto();
|
||
let from = to.inverse().unwrap();
|
||
let mut sensor = Rgb32f::new(96, 64);
|
||
let mut reference = sensor.clone();
|
||
let mut lime = Vec::new();
|
||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||
let ev = 0.05 + (i % 29) as f32 * 0.02;
|
||
// mostly greys and mild colours, plus a patch of saturated yellow-green
|
||
*src = if i % 7 == 0 {
|
||
[ev * 0.75, ev, ev * 0.2]
|
||
} else {
|
||
[ev * (0.85 + (i % 11) as f32 * 0.03), ev, ev * (0.85 + (i % 13) as f32 * 0.025)]
|
||
};
|
||
let (h, s) = hue_saturation(to.apply(src.map(f64::from))).unwrap();
|
||
// the camera turns hues in 50°..110° (ProPhoto) by up to +15° and boosts their saturation
|
||
let k = (1.0 - ((h - 80.0) / 30.0).powi(2)).max(0.0) * s.min(1.0);
|
||
let mut p = to.apply(src.map(f64::from)).map(|v| v as f32);
|
||
let table = HsvTable {
|
||
hue_divisions: 1,
|
||
sat_divisions: 2,
|
||
val_divisions: 1,
|
||
data: vec![[0.0, 1.0, 1.0], [15.0 * k as f32, 1.0 + 0.3 * k as f32, 1.0]],
|
||
srgb_value: false,
|
||
};
|
||
p = table.apply(p);
|
||
let p = from.apply(p.map(f64::from)).map(|v| v as f32);
|
||
let scale = luminance_2020(*src) / luminance_2020(p);
|
||
let p = p.map(|v| v * scale);
|
||
let y = luminance_2020(p);
|
||
*dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y);
|
||
if i % 7 == 0 {
|
||
lime.push(i);
|
||
}
|
||
}
|
||
let fit = fit_pairs(&sensor, &reference).unwrap();
|
||
let table = fit.hue_sat.as_ref().expect("a hue/saturation table is fitted");
|
||
let hue_sat = HueSat::new(table).unwrap();
|
||
let hue_error = |with_table: bool| {
|
||
lime.iter()
|
||
.map(|&i| {
|
||
let p = fit.matrix.apply(sensor.data[i].map(f64::from)).map(|v| v as f32);
|
||
let p = if with_table { hue_sat.apply(p) } else { p };
|
||
let (h, _) = hue_saturation(to.apply(p.map(f64::from))).unwrap();
|
||
let (t, _) = hue_saturation(to.apply(reference.data[i].map(f64::from))).unwrap();
|
||
((h - t + 180.0).rem_euclid(360.0) - 180.0).abs()
|
||
})
|
||
.sum::<f64>()
|
||
/ lime.len() as f64
|
||
};
|
||
let (before, after) = (hue_error(false), hue_error(true));
|
||
assert!(after < before * 0.5, "lime hue error {before:.2}° -> {after:.2}°");
|
||
// neutrals pass through and luminance is kept
|
||
let grey = [0.3, 0.3, 0.3];
|
||
assert!(hue_sat.apply(grey).iter().all(|v| (v - 0.3).abs() < 1e-4), "{:?}", hue_sat.apply(grey));
|
||
let green = [0.2, 0.4, 0.05];
|
||
assert!((luminance_2020(hue_sat.apply(green)) - luminance_2020(green)).abs() < 1e-5);
|
||
}
|
||
|
||
/// A camera that saturates shadows and bleaches highlights toward white (a per-channel curve),
|
||
/// which a luminance tone curve can't: the fitted chroma curve must follow it, so bright
|
||
/// warm-tinted rock renders white instead of cream.
|
||
#[test]
|
||
fn chroma_curve_follows_highlight_bleaching() {
|
||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||
let camera_chroma = |o: f32| 1.3 - 1.1 * o;
|
||
let mut sensor = Rgb32f::new(96, 64);
|
||
let mut reference = sensor.clone();
|
||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||
let ev = 0.02 + (i % 37) as f32 * 0.03;
|
||
*src = [ev * (0.75 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)];
|
||
let p = known.apply_f32(*src);
|
||
let y = luminance_2020(p);
|
||
let o = 1.0 - (-2.5 * y).exp();
|
||
let k = camera_chroma(o);
|
||
*dst = p.map(|v| (o + (v * o / y - o) * k).clamp(0.0, 0.999));
|
||
}
|
||
let fit = fit_pairs(&sensor, &reference).unwrap();
|
||
// relative to the matrix, which already carries the average colourfulness
|
||
let chroma = fit.tone.chroma();
|
||
assert!(chroma[6] < 0.5 * chroma[1], "highlights bleach relative to shadows: {chroma:?}");
|
||
// and the rendered highlights land on the camera's, far closer than without the curve
|
||
let with = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0);
|
||
let without = ToneMap::camera(&fit.tone.with_chroma([1.0; lightcraft_pipeline::tone::CHROMA_N]).unwrap(), 0.0, 0.0, 0.0);
|
||
let highlight_error = |map: &ToneMap| -> f64 {
|
||
sensor
|
||
.data
|
||
.iter()
|
||
.zip(&reference.data)
|
||
.filter(|(_, y)| luminance_2020(**y) > 0.7)
|
||
.map(|(x, y)| {
|
||
let p = displayed(fit.matrix.apply(x.map(f64::from)), map);
|
||
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>()
|
||
})
|
||
.sum()
|
||
};
|
||
let (a, b) = (highlight_error(&without), highlight_error(&with));
|
||
assert!(b < 0.5 * a, "highlight error {a:.4} -> {b:.4}");
|
||
}
|
||
|
||
/// Pooled pairs of several "photos" recover the camera's matrix, and a photo given that
|
||
/// colour model (a camera profile) keeps it, fitting only its own tone.
|
||
#[test]
|
||
fn profile_colour_is_pooled_and_then_used_as_given() {
|
||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||
let photo = |seed: usize, strength: f32| {
|
||
let mut sensor = Rgb32f::new(64, 48);
|
||
let mut reference = sensor.clone();
|
||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||
let i = i + seed * 7;
|
||
let ev = 0.04 + (i % 23) as f32 * 0.02;
|
||
*src = [ev * (0.7 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.05)];
|
||
let p = known.apply_f32(*src);
|
||
let y = luminance_2020(p);
|
||
// each photo has its own tone (DRO, picture style)
|
||
*dst = p.map(|v| v * (1.0 - (-strength * y).exp()) / y);
|
||
}
|
||
(sensor, reference)
|
||
};
|
||
let mut pool = Vec::new();
|
||
for seed in 0..4 {
|
||
let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5);
|
||
pool.extend(collect_pairs(&sensor, &reference, 0.05, None).unwrap().0);
|
||
}
|
||
let (matrix, _) = fit_profile(&pool).unwrap();
|
||
// the camera's colours (luminance-normalised: the tone curve sets brightness)
|
||
for x in [[0.8, 1.0, 0.75], [1.1, 1.0, 0.8], [0.75, 1.0, 1.2], [1.0; 3]] {
|
||
let (a, b) = (matrix.apply(x), known.apply(x));
|
||
let (la, lb) = (luma(a), luma(b));
|
||
assert!((0..3).all(|c| (a[c] / la - b[c] / lb).abs() < 0.01), "{x:?}: {a:?} vs {b:?}");
|
||
}
|
||
let (sensor, reference) = photo(9, 3.5);
|
||
let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap();
|
||
assert_eq!(look.matrix, matrix);
|
||
assert!(look.hue_sat.is_none());
|
||
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||
let error = sensor
|
||
.data
|
||
.iter()
|
||
.zip(&reference.data)
|
||
.map(|(x, y)| {
|
||
let p = displayed(look.matrix.apply(x.map(f64::from)), &tone);
|
||
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>() / 3.0
|
||
})
|
||
.sum::<f64>()
|
||
/ sensor.data.len() as f64;
|
||
assert!(error.sqrt() < 0.02, "this photo's own tone is followed: RMS {}", error.sqrt());
|
||
}
|
||
|
||
#[test]
|
||
fn profile_fits_tone_without_learning_colour_from_a_dull_scene() {
|
||
let matrix = Mat3([[2.0, -0.02, 0.0], [0.0, 2.0, 0.0], [0.0, -0.03, 2.0]]);
|
||
let mut sensor = Rgb32f::new(64, 48);
|
||
let mut reference = sensor.clone();
|
||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||
*src = [0.02 + (i % 997) as f32 * 0.00045; 3];
|
||
let p = matrix.apply_f32(*src);
|
||
let y = luminance_2020(p);
|
||
*dst = p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y);
|
||
}
|
||
assert!(fit_pairs(&sensor, &reference).is_none(), "not enough colour to learn a matrix");
|
||
let look = fit_pairs_with(&sensor, &reference, Some((matrix, None))).unwrap();
|
||
assert_eq!(look.matrix, matrix);
|
||
reference.map_in_place(|p| [luminance_2020(p); 3]);
|
||
assert!(fit_pairs_with(&sensor, &reference, Some((matrix, None))).is_none(), "monochrome JPEGs remain rejected");
|
||
}
|
||
|
||
#[test]
|
||
fn rejected_profile_preserves_a_usable_per_photo_fit() {
|
||
let matrix = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||
let wrong = Mat3([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]);
|
||
let mut sensor = Rgb32f::new(64, 64);
|
||
let mut reference = sensor.clone();
|
||
let mut seed = 0x2545_f491_u32;
|
||
for (src, dst) in sensor.data.iter_mut().zip(&mut reference.data) {
|
||
*src = std::array::from_fn(|_| {
|
||
seed ^= seed << 13;
|
||
seed ^= seed >> 17;
|
||
seed ^= seed << 5;
|
||
0.05 + (seed % 1000) as f32 * 0.0003
|
||
});
|
||
let p = matrix.apply_f32(*src);
|
||
let y = luminance_2020(p);
|
||
*dst = p.map(|v| (v * (1.0 - (-2.5 * y).exp()) / y).clamp(0.0, 0.999));
|
||
}
|
||
let per_photo = fit_pairs(&sensor, &reference).unwrap();
|
||
let look = fit_pairs_with(&sensor, &reference, Some((wrong, None))).unwrap();
|
||
assert_eq!(look.matrix, per_photo.matrix, "a rejected camera profile must not suppress the per-photo fit");
|
||
assert_eq!(look.tone, per_photo.tone);
|
||
}
|
||
|
||
#[test]
|
||
fn hue_sat_passes_black_and_non_finite_pixels_through() {
|
||
let table = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false };
|
||
let hue_sat = HueSat::new(&table).unwrap();
|
||
assert_eq!(hue_sat.apply([0.0; 3]), [0.0; 3]);
|
||
let nan = hue_sat.apply([f32::NAN, 0.2, 0.1]);
|
||
assert!(nan[0].is_nan() && nan[1] == 0.2 && nan[2] == 0.1);
|
||
assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples");
|
||
}
|
||
|
||
#[test]
|
||
fn supported_raws_get_a_file_local_look() {
|
||
// every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback)
|
||
let local = [RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3, RawFormat::Cr2, RawFormat::Pef];
|
||
assert!(local.into_iter().all(file_local_look));
|
||
// DNG carries its own colour model
|
||
assert!(!file_local_look(RawFormat::Dng));
|
||
}
|
||
|
||
#[test]
|
||
fn cr3_camera_framing_is_measured_independently_of_tone_and_rejects_unrelated_edges() {
|
||
let (w, h) = (192, 128);
|
||
let mut sensor = Rgb32f::new(w, h);
|
||
for (i, pixel) in sensor.data.iter_mut().enumerate() {
|
||
let (x, y) = ((i % w) as f32, (i / w) as f32);
|
||
let value = 0.35 + 0.13 * (x * 0.15 + y * 0.22).sin() + 0.1 * (x * 0.33 - y * 0.14).sin() + 0.07 * (x * 0.09 - y * 0.31).cos();
|
||
*pixel = [value; 3];
|
||
}
|
||
let expected = Cr3Framing { scale: 0.96, offset: [0.01, -0.006] };
|
||
let mut reference = Rgb32f::new(w, h);
|
||
for (i, pixel) in reference.data.iter_mut().enumerate() {
|
||
let (x, y) = expected.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h);
|
||
*pixel = sensor.sample_bilinear(x, y).map(|v| v.powf(0.7));
|
||
}
|
||
let measured = estimate_cr3_framing(&sensor, &reference).expect("coherent held-out edges locate camera framing despite nonlinear tone");
|
||
assert!((measured.scale - expected.scale).abs() < 0.006, "{measured:?}");
|
||
assert!((measured.offset[0] - expected.offset[0]).abs() < 0.003, "{measured:?}");
|
||
assert!((measured.offset[1] - expected.offset[1]).abs() < 0.003, "{measured:?}");
|
||
assert!(estimate_cr3_framing(&sensor, &sensor).is_none(), "already aligned pixels stay unchanged");
|
||
reference.data.fill([0.2; 3]);
|
||
assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "flat previews cannot establish framing");
|
||
for (i, pixel) in reference.data.iter_mut().enumerate() {
|
||
let (x, y) = ((i % w) as f32, (i / w) as f32);
|
||
*pixel = [0.35 + 0.13 * (x * 0.41 - y * 0.07).sin() + 0.1 * (x * 0.04 + y * 0.39).cos(); 3];
|
||
}
|
||
assert!(estimate_cr3_framing(&sensor, &reference).is_none(), "unrelated edges cannot change the framing");
|
||
}
|
||
|
||
/// The guarded fit is assessed on real sensor data, never on the JPEG fallback. A corpus
|
||
/// download or an unfinished codec cannot make this test claim a successful RAW calibration.
|
||
#[test]
|
||
fn corpus_cr3_gets_a_framing_aligned_camera_look() {
|
||
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||
.map(std::path::PathBuf::from)
|
||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||
.join("raw/cr3-canon-r100-raw.cr3");
|
||
let Ok(bytes) = std::fs::read(&path) else {
|
||
eprintln!("skip: {} absent", path.display());
|
||
return;
|
||
};
|
||
let raw = match lightcraft_raw::decode(&bytes) {
|
||
Ok(raw) => raw,
|
||
Err(lightcraft_raw::RawError::Unsupported(why)) => {
|
||
eprintln!("skip CR3 sensor decoder: {why}");
|
||
return;
|
||
}
|
||
Err(error) => panic!("CR3 corpus failed to decode: {error}"),
|
||
};
|
||
assert_eq!(raw.format, RawFormat::Cr3);
|
||
assert_eq!(raw.info().developed_size(), (6000, 4000));
|
||
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||
let look = fit_preview(&raw, &bytes, &transform);
|
||
eprintln!("R100 guarded colour fit accepted: {}", look.is_some());
|
||
assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment");
|
||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||
assert!(info.raw && info.relative_wb);
|
||
assert_eq!((info.as_shot_temp, info.as_shot_tint), (6500.0, 0.0));
|
||
assert_eq!(info.camera_tone.is_some(), look.is_some());
|
||
}
|
||
|
||
/// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white
|
||
/// balance is relative to the as-shot look.
|
||
#[test]
|
||
fn corpus_nef_gets_a_camera_look() {
|
||
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||
.map(std::path::PathBuf::from)
|
||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||
.join("raw/nef-nikon-d7500-lossless14.nef");
|
||
let Ok(bytes) = std::fs::read(&path) else {
|
||
eprintln!("skip: {} absent", path.display());
|
||
return;
|
||
};
|
||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||
assert!(info.camera_tone.is_some(), "no camera look fitted");
|
||
assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0);
|
||
}
|
||
|
||
/// A public DC-FZ1000 II RW2 shot at 4:3 on its 3:2 sensor (skipped without the corpus): the default crop is 4:3
|
||
/// while the embedded JPEG shows the whole sensor; the look is still fitted, against the matching part of it.
|
||
#[test]
|
||
fn corpus_rw2_with_an_in_camera_crop_gets_a_camera_look() {
|
||
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||
.map(std::path::PathBuf::from)
|
||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||
.join("raw/rw2-panasonic-fz1000m2-4x3.rw2");
|
||
let Ok(bytes) = std::fs::read(&path) else {
|
||
eprintln!("skip: {} absent", path.display());
|
||
return;
|
||
};
|
||
let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||
assert_eq!((img.width, img.height), (400, 300), "framed in the in-camera aspect ratio");
|
||
assert!(info.camera_tone.is_some(), "no camera look fitted");
|
||
assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0);
|
||
}
|
||
|
||
/// Issue #232 (skipped without the corpus): the public ILCE-7RM2 sample's camera JPEG is
|
||
/// distortion-corrected, and on its glass façade the fit on all pixels misses the gate
|
||
/// (held-out RMS 0.113): it used to open grey with the neutral fallback.
|
||
#[test]
|
||
fn corpus_arw_with_a_lens_corrected_preview_gets_a_camera_look() {
|
||
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||
.map(std::path::PathBuf::from)
|
||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||
.join("raw/arw-sony-a7rm2-12bit-uncompressed.arw");
|
||
let Ok(bytes) = std::fs::read(&path) else {
|
||
eprintln!("skip: {} absent", path.display());
|
||
return;
|
||
};
|
||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||
assert!(info.camera_tone.is_some(), "no camera look fitted");
|
||
}
|
||
|
||
/// Issue #232: an ILCE-7RM2 camera JPEG is lens-corrected ("Distortion Comp.: Auto"), so on a
|
||
/// façade of window frames the raw and its JPEG are a pixel or two apart. The colours agree
|
||
/// everywhere away from the edges, and the look must still be found there.
|
||
#[test]
|
||
fn fits_a_slightly_misaligned_camera_jpeg_away_from_edges() {
|
||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||
let (w, h, block, shift) = (96, 64, 8, 2);
|
||
// Patches of 8×8 pixels, alternately dark and bright (frames and panes), varied in colour.
|
||
let scene = |x: usize, y: usize| -> [f32; 3] {
|
||
let (bx, by) = (x / block, y / block);
|
||
let ev = if (bx + by) % 2 == 0 { 0.04 + (bx % 3) as f32 * 0.01 } else { 0.3 + (by % 4) as f32 * 0.08 };
|
||
[ev * (0.7 + ((bx * 7 + by * 3) % 11) as f32 * 0.05), ev, ev * (0.7 + ((bx * 5 + by) % 13) as f32 * 0.04)]
|
||
};
|
||
let camera = |p: [f32; 3]| {
|
||
let q = known.apply_f32(p);
|
||
let y = luminance_2020(q);
|
||
q.map(|v| v * (1.0 - (-2.5 * y).exp()) / y)
|
||
};
|
||
let mut sensor = Rgb32f::new(w, h);
|
||
let mut reference = sensor.clone();
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
sensor.data[y * w + x] = scene(x, y);
|
||
// The JPEG shows the scene `shift` pixels to the right of where the raw has it.
|
||
reference.data[y * w + x] = camera(scene(x.saturating_sub(shift), y));
|
||
}
|
||
}
|
||
let fit = fit_pairs(&sensor, &reference).expect("look fitted away from the misaligned edges");
|
||
// The look itself is right: on the aligned scene it reproduces the camera.
|
||
let tone = ToneMap::camera(&fit.tone, 0.0, 0.0, 0.0);
|
||
let (mut error, mut n) = (0.0, 0);
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let p = displayed(fit.matrix.apply(scene(x, y).map(f64::from)), &tone);
|
||
let t = camera(scene(x, y));
|
||
error += (0..3).map(|c| (p[c] - f64::from(t[c])).powi(2)).sum::<f64>();
|
||
n += 3;
|
||
}
|
||
}
|
||
assert!((error / n as f64).sqrt() < 0.03, "{}", (error / n as f64).sqrt());
|
||
// Unrelated colours stay rejected away from edges too (covered below); a flat JPEG as well.
|
||
reference.data.fill([0.2; 3]);
|
||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||
}
|
||
|
||
/// X-Trans and 16-bit Bayer RAFs use the same fixed fit at thumbnail and export sizes.
|
||
/// The X-T20 fails the gates on all pixels (held-out RMS 0.114 against 0.10) and passes away
|
||
/// from edges (0.056, issue #232), so it is fitted too. Skips cleanly without these CC0
|
||
/// corpus files.
|
||
#[test]
|
||
fn corpus_raf_colour_and_white_balance() {
|
||
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||
.map(std::path::PathBuf::from)
|
||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||
.join("raw");
|
||
for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", true), ("raf-fuji-xt20-compressed.raf", true)] {
|
||
let Ok(bytes) = std::fs::read(dir.join(name)) else { continue };
|
||
let header = crate::files::probe_bytes(name, &bytes).unwrap();
|
||
assert_eq!(header.as_shot_wb, Some((6500.0, 0.0)), "{name}");
|
||
let (_, small) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||
let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap();
|
||
assert_eq!(small.camera_tone.is_some(), accepted, "{name}");
|
||
assert_eq!(small.camera_tone, large.camera_tone, "{name}: resolution changed the fit");
|
||
assert!(small.raw && small.relative_wb && small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_monochrome_invalid_and_unrelated_previews() {
|
||
let mut sensor = Rgb32f::new(32, 32);
|
||
let mut reference = sensor.clone();
|
||
for (i, p) in sensor.data.iter_mut().enumerate() {
|
||
*p = [0.1 + (i % 13) as f32 * 0.02, 0.15, 0.1];
|
||
}
|
||
reference.data.fill([0.2; 3]);
|
||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||
reference.data.fill([f32::NAN; 3]);
|
||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||
*src = [0.04 + (i % 11) as f32 * 0.02, 0.05 + (i % 17) as f32 * 0.01, 0.03 + (i % 23) as f32 * 0.01];
|
||
*dst = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01];
|
||
}
|
||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||
sensor.data.fill([0.1, 0.15, 0.12]);
|
||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||
reference.data.truncate(8);
|
||
assert!(fit_pairs(&sensor, &reference).is_none());
|
||
}
|
||
}
|