mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 20:48:33 -06:00
@@ -121,8 +121,11 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<C
|
||||
if lightcraft_raw::color::has_matrix(&raw.color) {
|
||||
return None;
|
||||
}
|
||||
// the white the source shows, decided before `starting_colour` may fill in measured matrices
|
||||
let scale = crate::files::WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
|
||||
let white = scale.dng_white(&raw.color, lightcraft_raw::color::as_shot_white_xy(raw));
|
||||
let (_, t, look) = starting_colour(raw, bytes);
|
||||
dng_colour(&raw.color, &t, look.as_ref())
|
||||
dng_colour(&raw.color, &white, &t, look.as_ref())
|
||||
}
|
||||
|
||||
/// Give a raw about to be written as a DNG of its own (Export as DNG, Convert / Copy as DNG) the colour it opens
|
||||
@@ -148,8 +151,13 @@ pub(crate) fn with_starting_colour(raw: &mut RawImage, bytes: &[u8]) {
|
||||
/// doesn't keep each pixel's luminance as the fitted one does ([`HueSat`]), and the chroma curve has no tag; on two
|
||||
/// CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies renders within mean CIE76 ΔE 1.07 and 0.47 of the photo
|
||||
/// (1.47 and 0.66 without the table; 26 and 23 with no colour tags at all).
|
||||
/// The colour matrices declare `declared`, the white balance the source shows ([`crate::files::WbScaleOf::dng_white`]),
|
||||
/// so the DNG opens at the source's As Shot temperature and tint and a white-balance edit starts from it. Matrices
|
||||
/// derived from `forward` put the declared white wherever the look and the generic camera model took it: ARW merges
|
||||
/// opened at about 7000 K / +115 where the ARWs show 4800 K / +8, and any white-balance edit turned them green.
|
||||
/// `forward` is used only with the as-shot neutral, so the render at As Shot doesn't depend on them.
|
||||
/// `None` when the white balance or the matrix can't give tags.
|
||||
fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) -> Option<ColorData> {
|
||||
pub(crate) fn dng_colour(tags: &ColorData, declared: &crate::files::DngWhite, t: &CameraTransform, look: Option<&CameraLook>) -> Option<ColorData> {
|
||||
let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix));
|
||||
// the DNG model renders the camera's white at unit brightness: what a look does to white's brightness goes
|
||||
// into the baseline exposure
|
||||
@@ -162,11 +170,19 @@ fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>)
|
||||
let forward = bradford(D65, D50).mul(&REC2020.to_xyz()).mul(&m);
|
||||
let wb = t.wb.map(f64::from);
|
||||
let neutral = wb.map(|w| wb.iter().cloned().fold(f64::MAX, f64::min) / w);
|
||||
let camera_to_xyz = bradford(D50, t.white_xy).mul(&forward).mul(&Mat3::diag(1.0 / neutral[0], 1.0 / neutral[1], 1.0 / neutral[2]));
|
||||
let (illuminant, color_matrix) = match declared {
|
||||
crate::files::DngWhite::Matrices(c) if lightcraft_raw::color::has_matrix(c) => (c.illuminant, c.color_matrix),
|
||||
crate::files::DngWhite::Matrices(_) => ([21, 0], [Some(declaring(t.white_xy, neutral)?), None]),
|
||||
crate::files::DngWhite::At(xy) => ([21, 0], [Some(declaring(*xy, neutral)?), None]),
|
||||
};
|
||||
if !color_matrix.iter().flatten().all(|m| m.0.iter().flatten().all(|v| v.is_finite())) {
|
||||
return None;
|
||||
}
|
||||
Some(ColorData {
|
||||
illuminant: [21, 0],
|
||||
color_matrix: [Some(camera_to_xyz.inverse()?), None],
|
||||
forward_matrix: [Some(forward), None],
|
||||
illuminant,
|
||||
color_matrix,
|
||||
// a second colour matrix comes with a second forward matrix (DNG spec): the same one, fitted at one white
|
||||
forward_matrix: [Some(forward), color_matrix[1].map(|_| forward)],
|
||||
camera_calibration: [None, None],
|
||||
analog_balance: None,
|
||||
as_shot_neutral: Some(neutral),
|
||||
@@ -182,6 +198,17 @@ fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>)
|
||||
})
|
||||
}
|
||||
|
||||
/// An XYZ → camera colour matrix under which camera `neutral` is white `xy`: the generic camera model, scaled per
|
||||
/// channel. `None` when the model gives no positive neutral there.
|
||||
fn declaring(xy: Xy, neutral: [f64; 3]) -> Option<Mat3> {
|
||||
let g = lightcraft_raw::color::xyz_to_camera(&ColorData::default(), xy);
|
||||
let n = g.apply(xy.to_xyz());
|
||||
if !n.iter().all(|v| v.is_finite() && *v > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
Some(Mat3::diag(neutral[0] / n[0], neutral[1] / n[1], neutral[2] / n[2]).mul(&g))
|
||||
}
|
||||
|
||||
/// A camera tone curve as a DNG `ProfileToneCurve` (linear in, linear out, 1 = white): sampled densely over the
|
||||
/// 12 stops below white, the range the curve is read back over (`files::dng_tone_curve`).
|
||||
fn dng_tone_curve(tone: &CameraTone) -> Option<ToneCurve> {
|
||||
@@ -2410,7 +2437,7 @@ mod tests {
|
||||
let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]];
|
||||
let render = |m: &Mat3, wb: [f32; 3], ev: f64, c: [f64; 3]| m.apply(std::array::from_fn(|i| c[i] * wb[i] as f64)).map(|v| v * ev.exp2());
|
||||
for look in [Some(&look), None] {
|
||||
let dng = dng_colour(&ColorData::default(), &t, look).unwrap();
|
||||
let dng = dng_colour(&ColorData::default(), &crate::files::DngWhite::At(t.white_xy), &t, look).unwrap();
|
||||
let back = camera_transform_of(&dng, as_shot_white_of(&dng, None));
|
||||
for i in 0..3 {
|
||||
assert!((back.wb[i] - t.wb[i]).abs() < 1e-4 * t.wb[i], "{:?} vs {:?}", back.wb, t.wb);
|
||||
@@ -2435,6 +2462,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The merged DNG declares the white balance its source shows, also when the look's matrix doesn't keep white
|
||||
/// neutral, as fitted looks often don't, and renders at As Shot as the source does. Real ARW merges declared their
|
||||
/// colour matrix from the look: As Shot about 7000 K / +115 where the ARWs show 4800 K / +8.
|
||||
#[test]
|
||||
fn dng_colour_declares_the_white_balance_the_source_shows() {
|
||||
use crate::files::DngWhite;
|
||||
use lightcraft_raw::color::{as_shot_white_of, camera_transform_of, wb_multipliers};
|
||||
let tags = ColorData::default();
|
||||
let as_shot = [2.5039f32, 1.0, 1.5078];
|
||||
let t = camera_transform_of(&tags, as_shot_white_of(&tags, Some(as_shot)));
|
||||
let mut look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1));
|
||||
look.matrix = Mat3([[1.75, -0.4, -0.2], [-0.2, 1.35, -0.2], [0.05, -0.3, 1.3]]);
|
||||
let tint = look.matrix.apply([1.0; 3]);
|
||||
assert!((tint[0] - tint[1]).abs() > 0.1, "a look that tints white: {tint:?}");
|
||||
let renders_as_shot = |dng: &ColorData| {
|
||||
let r = camera_transform_of(dng, as_shot_white_of(dng, None));
|
||||
assert!(r.wb.iter().zip(t.wb).all(|(a, b)| (a - b).abs() < 1e-4 * b), "{:?} vs {:?}", r.wb, t.wb);
|
||||
let m = look.matrix.mul(&t.matrix);
|
||||
for c in [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6]] {
|
||||
let want = m.apply(std::array::from_fn(|i| c[i] * t.wb[i] as f64)).map(|v| v * t.baseline_exposure.exp2());
|
||||
let got = r.matrix.apply(std::array::from_fn(|i| c[i] * r.wb[i] as f64)).map(|v| v * dng.baseline_exposure.exp2());
|
||||
assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-4), "{c:?}: {want:?} vs {got:?}");
|
||||
}
|
||||
};
|
||||
// a white the source's scale shows (Sony's presets, the relative scale's 6500 K / 0)
|
||||
for xy in [lightcraft_color::cct::temp_tint_to_xy(5600.0, 3.0), lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)] {
|
||||
let dng = dng_colour(&tags, &DngWhite::At(xy), &t, Some(&look)).unwrap();
|
||||
let back = as_shot_white_of(&dng, None);
|
||||
assert!((back.x - xy.x).abs() < 1e-6 && (back.y - xy.y).abs() < 1e-6, "as shot {back:?}, shown {xy:?}");
|
||||
renders_as_shot(&dng);
|
||||
}
|
||||
// a camera with measured matrices (the ILCE-7M3): every white read through them, as for the source
|
||||
let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M3").expect("a covered camera");
|
||||
let mut model = tags.clone();
|
||||
assert!(camera.fill(&mut model));
|
||||
let shown = lightcraft_raw::spectral::as_shot_white_through(camera, &tags, Some(as_shot)).unwrap();
|
||||
let dng = dng_colour(&tags, &DngWhite::Matrices(Box::new(model.clone())), &t, Some(&look)).unwrap();
|
||||
let back = as_shot_white_of(&dng, None);
|
||||
assert!((back.x - shown.x).abs() < 1e-5 && (back.y - shown.y).abs() < 1e-5, "as shot {back:?}, shown {shown:?}");
|
||||
for w in [Xy::new(0.3127, 0.329), Xy::new(0.4476, 0.4074), Xy::new(0.3457, 0.3585)] {
|
||||
let (a, b) = (wb_multipliers(&model, w), wb_multipliers(&dng, w));
|
||||
assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6 * a), "{w:?}: {a:?} vs {b:?}");
|
||||
}
|
||||
renders_as_shot(&dng);
|
||||
}
|
||||
|
||||
/// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep.
|
||||
fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook {
|
||||
CameraLook {
|
||||
|
||||
@@ -517,17 +517,28 @@ fn load_bytes_now(
|
||||
/// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one
|
||||
/// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the
|
||||
/// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot).
|
||||
struct WbScaleOf {
|
||||
pub(crate) struct WbScaleOf {
|
||||
own_matrix: bool,
|
||||
relative: bool,
|
||||
/// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used.
|
||||
legacy_relative: bool,
|
||||
/// The as-shot white through the measured matrices or the presets, when that is where the scale comes from.
|
||||
scale_white: Option<lightcraft_color::Xy>,
|
||||
/// The camera's measured matrices, when they give the scale.
|
||||
camera: Option<&'static lightcraft_raw::spectral::Camera>,
|
||||
}
|
||||
|
||||
/// The white balance a DNG of a raw's camera RGB (Photo Merge) declares so that it shows as its source does
|
||||
/// ([`WbScaleOf::dng_white`]).
|
||||
pub(crate) enum DngWhite {
|
||||
/// Colour tags with the camera's measured matrices: the DNG reads every white through them, as the source does.
|
||||
Matrices(Box<lightcraft_raw::ColorData>),
|
||||
/// The white the source's scale shows at As Shot.
|
||||
At(lightcraft_color::Xy),
|
||||
}
|
||||
|
||||
impl WbScaleOf {
|
||||
fn new(
|
||||
pub(crate) fn new(
|
||||
format: lightcraft_raw::RawFormat,
|
||||
color: &lightcraft_raw::ColorData,
|
||||
wb_multipliers: Option<[f32; 3]>,
|
||||
@@ -544,7 +555,23 @@ impl WbScaleOf {
|
||||
None => None,
|
||||
};
|
||||
let covered = camera.is_some() || presets;
|
||||
WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white }
|
||||
WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white, camera }
|
||||
}
|
||||
|
||||
/// What a DNG of this raw's camera RGB declares so that it shows the white balance as this scale does: the
|
||||
/// camera's measured matrices when they give the scale, else the white the scale shows at As Shot (the presets'
|
||||
/// reading; 6500 K / 0 on the relative scale; else `own`, the white the file's tags give).
|
||||
pub(crate) fn dng_white(&self, color: &lightcraft_raw::ColorData, own: lightcraft_color::Xy) -> DngWhite {
|
||||
if let Some(camera) = self.camera {
|
||||
let mut c = lightcraft_raw::ColorData { profile: Default::default(), kelvin_presets: None, ..color.clone() };
|
||||
if camera.fill(&mut c) {
|
||||
return DngWhite::Matrices(Box::new(c));
|
||||
}
|
||||
}
|
||||
if self.relative {
|
||||
return DngWhite::At(lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0));
|
||||
}
|
||||
DngWhite::At(self.scale_white.unwrap_or(own))
|
||||
}
|
||||
/// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through
|
||||
/// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the
|
||||
@@ -1142,6 +1169,40 @@ pub(crate) mod tests {
|
||||
assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3"));
|
||||
}
|
||||
|
||||
/// A merged DNG (Photo Merge) of a raw without colour matrices shows the As Shot temperature and tint its source
|
||||
/// shows, on every scale: through the measured matrices (ILCE-7M3), through the presets (ILCA-99M2), the relative
|
||||
/// scale's 6500 K / 0 (ILCE-7M2) and a file's own white (an unfitted format). Merges of real ILCE-7M3 brackets
|
||||
/// opened at 6113–7307 K / +115 where the ARWs show 3932–5268 K / +7.
|
||||
#[test]
|
||||
fn a_merged_dng_shows_the_white_balance_its_source_shows() {
|
||||
use lightcraft_raw::{ColorData, RawFormat};
|
||||
let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() };
|
||||
let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0];
|
||||
let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([
|
||||
(2500.0, g(1336.0, 3672.0)),
|
||||
(3200.0, g(1720.0, 2620.0)),
|
||||
(4500.0, g(2256.0, 1880.0)),
|
||||
(6000.0, g(2680.0, 1544.0)),
|
||||
(8500.0, g(3136.0, 1300.0)),
|
||||
]);
|
||||
let with_presets = ColorData { kelvin_presets: presets, ..Default::default() };
|
||||
let cases = [
|
||||
(RawFormat::Arw, ColorData::default(), [2.5039f32, 1.0, 1.5078], meta("SONY", "ILCE-7M3")),
|
||||
(RawFormat::Arw, with_presets, g(2500.0, 1668.0), meta("SONY", "ILCA-99M2")),
|
||||
(RawFormat::Arw, ColorData::default(), [2.2, 1.0, 1.6], meta("SONY", "ILCE-7M2")),
|
||||
(RawFormat::Rw2, ColorData::default(), [2.0, 1.0, 1.4], meta("Panasonic", "DC-G9")),
|
||||
];
|
||||
for (format, color, mul, meta) in cases {
|
||||
let own = lightcraft_raw::color::as_shot_white_of(&color, Some(mul));
|
||||
let source = WbScaleOf::new(format, &color, Some(mul), &meta);
|
||||
let t = lightcraft_raw::color::camera_transform_of(&color, own);
|
||||
let dng = crate::camera_preview::dng_colour(&color, &source.dng_white(&color, own), &t, None).unwrap();
|
||||
let merged = WbScaleOf::new(RawFormat::Dng, &dng, None, &meta);
|
||||
let shown = source.as_shot(own);
|
||||
assert_eq!(merged.as_shot(lightcraft_raw::color::as_shot_white_of(&dng, None)), shown, "{:?}", meta.model);
|
||||
}
|
||||
}
|
||||
|
||||
/// A RAF whose preview pointer is in its header, then a large raw tail the reader must not need.
|
||||
fn raf_with_embedded_jpeg(jpeg: &[u8], tail: usize) -> Vec<u8> {
|
||||
let head = 160;
|
||||
|
||||
@@ -199,10 +199,22 @@ A merge (HDR, panorama, HDR panorama) writes a linear DNG of the sources' camera
|
||||
tags. A raw without colour matrices of its own has none to give, so its merge used to render with no camera colour: an
|
||||
HDR merge of CR2s came out green and dark (issue #965). Each source now goes through `starting_colour` as when it is
|
||||
opened alone, and `starting_colour_tags` writes the result as DNG tags: one forward matrix (from the look's matrix on
|
||||
top of the camera transform) with a colour matrix and `AsShotNeutral` that agree with it at the as-shot white, the
|
||||
look's brightness of white as baseline exposure, its hue/saturation table as `ProfileHueSatMap` and its tone curve as
|
||||
`ProfileToneCurve`. Raws with their own matrices (DNG) keep their tags. Without a look the tags give the colour the raw
|
||||
falls back to (spectral matrices or the neutral fallback).
|
||||
top of the camera transform) with `AsShotNeutral`, the look's brightness of white as baseline exposure, its
|
||||
hue/saturation table as `ProfileHueSatMap` and its tone curve as `ProfileToneCurve`. Raws with their own matrices (DNG)
|
||||
keep their tags. Without a look the tags give the colour the raw falls back to (spectral matrices or the neutral
|
||||
fallback).
|
||||
|
||||
The colour matrices declare the white balance the source shows (`files::WbScaleOf::dng_white`), so the DNG opens at the
|
||||
source's As Shot temperature and tint and a white-balance edit starts from there: the camera's measured matrices
|
||||
(`lightcraft_raw::spectral`) when they give the source's scale, which reads every white as the source does; else a
|
||||
matrix under which the as-shot neutral is the white the source's scale shows (Sony's presets' reading, 6500 K / 0 on
|
||||
the relative scale, the file's own white otherwise). The forward matrix is used only with the as-shot neutral, so the
|
||||
render at As Shot doesn't depend on them. A colour matrix derived from the forward matrix, as first written, declared
|
||||
wherever the look and the generic camera model put the white: merges of real ILCE-7M3 brackets opened at 6113–7307 K /
|
||||
+115 where the ARWs show 3932–5268 K / +7, and any white-balance edit turned them green (CIEDE2000 11–13 against the
|
||||
ARW at the same Temp/Tint). With the measured matrices they open at the ARWs' As Shot, and the same Temp/Tint renders
|
||||
within CIEDE2000 0.8–1.7 of the ARW for most changes, about 5 for the largest (5268 → 3200 K, 3932 → 6500 K): the ARW
|
||||
adapts its fitted look with Bradford, the DNG rebalances in camera space.
|
||||
|
||||
The DNG renders close to, not exactly like, the source: a DNG hue/saturation table doesn't keep each pixel's luminance
|
||||
as the fitted table does, and the chroma curve has no DNG tag, so it is left out. On two CC0 CR2s (EOS 550D, 40D), an
|
||||
|
||||
@@ -95,6 +95,12 @@
|
||||
- A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity
|
||||
changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.
|
||||
|
||||
### HDR merges keep the white balance
|
||||
- An HDR merged from raws that LightCraft colours from the camera's JPEG (CR2, NEF, ARW…) now opens at the same
|
||||
As Shot temperature and tint as its photos, and changing the white balance no longer turns it green. Sony ARW
|
||||
merges used to open at about 7000 K / +115 where the photos show 4000–5300 K / +8. HDRs merged before this keep
|
||||
their old white balance; merge them again.
|
||||
|
||||
## 2026-10-13
|
||||
|
||||
### Edits keep up in a large library
|
||||
|
||||
Reference in New Issue
Block a user