Issue #138: DNG: apply the file's own camera-profile look (HueSatMap, LookTable, ToneCurve)
Lightroom-converted DNGs carry their camera profile's ProfileHueSatMapData1/2 and ProfileLookTableData (and sometimes ProfileToneCurve). We used only the colour matrices, so such DNGs rendered flat and muted. - raw: new `profile` module reads the tags (DNG spec ch. 6: value-major, hue-middle, saturation-minor tables, wrap-around hue, tri-linear lookups, sRGB value encoding) from the raw IFD or IFD 0; malformed tags are ignored. - engine: the raw loader applies the hue/sat map (interpolated by the same illuminant weight as the matrices) before BaselineExposure and the look table after it, in linear ProPhoto; a ProfileToneCurve becomes the source's camera tone. Without one the standard base tone is kept. - dngwrite keeps the look with the data (conversions, smart previews, merges). - Render cache version 7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fc3fa1a71b
commit
71eac70696
|
|
@ -253,13 +253,19 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
|||
let m = camera_look.map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix).to_f32();
|
||||
let gain = 2f32.powf(t.baseline_exposure as f32);
|
||||
let wb = t.wb;
|
||||
// A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6.
|
||||
let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy));
|
||||
img.map_in_place(|p| {
|
||||
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
|
||||
[
|
||||
((m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2]) * gain).max(0.0),
|
||||
((m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2]) * gain).max(0.0),
|
||||
((m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2]) * gain).max(0.0),
|
||||
]
|
||||
let rgb = [
|
||||
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
|
||||
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
|
||||
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
|
||||
];
|
||||
match &tables {
|
||||
Some(tables) => tables.apply(rgb, gain).map(|v| v.max(0.0)),
|
||||
None => rgb.map(|v| (v * gain).max(0.0)),
|
||||
}
|
||||
});
|
||||
stages.push(("colour", t0.elapsed()));
|
||||
let img = fit(&img, max_edge, max_edge, Filter::Box);
|
||||
|
|
@ -280,11 +286,9 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
|||
}
|
||||
let (temp, tint) = xy_to_temp_tint(xy);
|
||||
let relative = raw.format == lightcraft_raw::RawFormat::Arw && t.matrix_is_fallback;
|
||||
let camera_tone = camera_look.map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
|
||||
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
|
||||
return Ok((
|
||||
img,
|
||||
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone: camera_look.map(|p| p.tone) },
|
||||
));
|
||||
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone }));
|
||||
}
|
||||
let d = lightcraft_codecs::decode(&bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).map_err(|e| e.to_string())?;
|
||||
drop(bytes);
|
||||
|
|
@ -293,6 +297,17 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
|
|||
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), SourceInfo::default()))
|
||||
}
|
||||
|
||||
/// A DNG `ProfileToneCurve` (linear in, linear out, 1.0 = white after exposure compensation) as the
|
||||
/// finish stage's camera tone curve: 32 knots, log-spaced over 12 stops below white; above white
|
||||
/// the camera tone's shoulder continues it.
|
||||
pub(crate) fn dng_tone_curve(curve: &lightcraft_raw::profile::ToneCurve) -> Option<lightcraft_pipeline::tone::CameraTone> {
|
||||
let knots: [[f32; 2]; 32] = std::array::from_fn(|i| {
|
||||
let x = 2f32.powf(-12.0 + 12.0 * i as f32 / 31.0);
|
||||
[x, curve.eval(x).min(0.9995)]
|
||||
});
|
||||
lightcraft_pipeline::tone::CameraTone::new(knots)
|
||||
}
|
||||
|
||||
/// Orientation for an embedded preview: its own EXIF orientation when it has one, else the raw file's.
|
||||
fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
|
||||
if jpeg_orientation > 1 {
|
||||
|
|
@ -398,6 +413,41 @@ mod tests {
|
|||
b
|
||||
}
|
||||
|
||||
/// Issue #138: a DNG's own profile look (hue/saturation map, look table, tone curve) is
|
||||
/// applied when it's developed — Lightroom-converted DNGs rendered flat and muted without it.
|
||||
#[test]
|
||||
fn dng_profile_look_is_applied() {
|
||||
use lightcraft_raw::profile::{HsvTable, ProfileLook, ToneCurve};
|
||||
let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||||
let mut raw = lightcraft_raw::decode(&plain).unwrap();
|
||||
let sat = |img: &Rgb32f| {
|
||||
img.data.iter().map(|p| (p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2])) / p[0].max(p[1]).max(p[2]).max(1e-6)).sum::<f32>()
|
||||
/ img.data.len() as f32
|
||||
};
|
||||
let (before, info) = load_bytes(&plain, 64).unwrap();
|
||||
assert!(sat(&before) > 0.05, "the synthetic scene is coloured");
|
||||
assert!(info.camera_tone.is_none());
|
||||
// a map that removes all saturation, and a tone curve
|
||||
let grey = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 0.0, 1.0]; 8], srgb_value: false };
|
||||
raw.color.profile =
|
||||
ProfileLook { hue_sat_map: [Some(grey), None], look_table: None, tone_curve: ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.3, 1.0, 1.0]) };
|
||||
let with = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
|
||||
let (after, info) = load_bytes(&with, 64).unwrap();
|
||||
assert!(sat(&after) < 1e-3, "saturation {} → {}", sat(&before), sat(&after));
|
||||
// a saturation-only map keeps brightness roughly (HSV value is kept in ProPhoto RGB)
|
||||
let mean = |img: &Rgb32f| img.data.iter().map(|p| p[0].max(p[1]).max(p[2])).sum::<f32>() / img.data.len() as f32;
|
||||
assert!((0.8..1.1).contains(&(mean(&after) / mean(&before))), "{} vs {}", mean(&after), mean(&before));
|
||||
let tone = info.camera_tone.expect("the DNG tone curve becomes the camera tone");
|
||||
assert!((tone.apply(0.18) - 0.3).abs() < 0.01, "{}", tone.apply(0.18));
|
||||
// a look table alone changes the render too (applied after exposure)
|
||||
raw.color.profile = ProfileLook {
|
||||
look_table: Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 1.0, 0.5]; 2], srgb_value: false }),
|
||||
..Default::default()
|
||||
};
|
||||
let (dim, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap();
|
||||
assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_raw_falls_back_to_embedded_preview() {
|
||||
let b = cr3_with_preview(48, 32);
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 6;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 7;
|
||||
|
||||
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
||||
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ pub fn color_data(color: &FrameColor) -> ColorData {
|
|||
as_shot_neutral: Some([1.0, 1.0, 1.0]),
|
||||
as_shot_white_xy: None,
|
||||
baseline_exposure: 0.0,
|
||||
profile: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! DNG (Adobe Digital Negative Specification 1.7): raw IFD selection, pixel data (via [`crate::tiffraw`]),
|
||||
//! linearization, black/white levels, active area, default crop, CFA description, colour tags, opcode lists.
|
||||
|
||||
use crate::profile::{HsvTable, ProfileLook, ToneCurve};
|
||||
use crate::tiffraw::{Packing, read_image_in};
|
||||
use crate::{BlackLevel, Cfa, ColorData, Mat3, Mode, RawData, RawError, RawFormat, RawImage, Rect, Result, opcodes};
|
||||
use lightcraft_color::Xy;
|
||||
|
|
@ -45,6 +46,27 @@ pub(crate) fn color_data(ifd0: &Ifd, raw: &Ifd) -> ColorData {
|
|||
as_shot_white_xy: get(t::AS_SHOT_WHITE_XY).filter(|v| v.len() == 2 && v[0] > 0.0 && v[1] > 0.0).map(|v| Xy::new(v[0], v[1])),
|
||||
baseline_exposure: get(t::BASELINE_EXPOSURE).and_then(|v| v.first().copied()).filter(|v| v.is_finite()).unwrap_or(0.0)
|
||||
+ get(t::BASELINE_EXPOSURE_OFFSET).and_then(|v| v.first().copied()).filter(|v| v.is_finite()).unwrap_or(0.0),
|
||||
profile: profile_look(ifd0, raw),
|
||||
}
|
||||
}
|
||||
|
||||
/// The profile look tags (`ProfileHueSatMap*`, `ProfileLookTable*`, `ProfileToneCurve`); malformed
|
||||
/// ones are ignored. Like the colour tags, read from the raw IFD first, else IFD 0.
|
||||
pub(crate) fn profile_look(ifd0: &Ifd, raw: &Ifd) -> ProfileLook {
|
||||
let pick = |tag: u16| if raw.contains(tag) { raw } else { ifd0 };
|
||||
let table = |dims: u16, data: u16, enc: u16| {
|
||||
let ifd = pick(dims);
|
||||
let dims = ifd.u64s(dims)?;
|
||||
let data = ifd.f64s(data)?;
|
||||
HsvTable::from_tags(&dims, &data, ifd.u64s(enc).and_then(|v| v.first().copied()).unwrap_or(0))
|
||||
};
|
||||
ProfileLook {
|
||||
hue_sat_map: [
|
||||
table(t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_DATA_1, t::PROFILE_HUE_SAT_MAP_ENCODING),
|
||||
table(t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_DATA_2, t::PROFILE_HUE_SAT_MAP_ENCODING),
|
||||
],
|
||||
look_table: table(t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_DATA, t::PROFILE_LOOK_TABLE_ENCODING),
|
||||
tone_curve: pick(t::PROFILE_TONE_CURVE).f64s(t::PROFILE_TONE_CURVE).and_then(|v| ToneCurve::from_tag(&v)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -179,6 +179,38 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> {
|
|||
ifd.set(t::AS_SHOT_WHITE_XY, rat_vec(&[xy.x, xy.y]));
|
||||
}
|
||||
ifd.set(t::BASELINE_EXPOSURE, Value::SRational(vec![srational(c.baseline_exposure)]));
|
||||
// the source's own profile look travels with its data (conversions, smart previews, merges)
|
||||
let p = &c.profile;
|
||||
let table_tags = |ifd: &mut IfdBuilder, table: &crate::profile::HsvTable, dims: u16, enc: u16| {
|
||||
ifd.set(dims, Value::Long(vec![table.hue_divisions as u32, table.sat_divisions as u32, table.val_divisions as u32]));
|
||||
if table.srgb_value {
|
||||
ifd.set(enc, Value::Long(vec![1]));
|
||||
}
|
||||
};
|
||||
let flat = |table: &crate::profile::HsvTable| Value::Float(table.data.iter().flatten().copied().collect());
|
||||
match &p.hue_sat_map {
|
||||
[Some(a), b] => {
|
||||
table_tags(&mut ifd, a, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING);
|
||||
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(a));
|
||||
if let Some(b) =
|
||||
b.as_ref().filter(|b| (b.hue_divisions, b.sat_divisions, b.val_divisions) == (a.hue_divisions, a.sat_divisions, a.val_divisions))
|
||||
{
|
||||
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_2, flat(b));
|
||||
}
|
||||
}
|
||||
[None, Some(b)] => {
|
||||
table_tags(&mut ifd, b, t::PROFILE_HUE_SAT_MAP_DIMS, t::PROFILE_HUE_SAT_MAP_ENCODING);
|
||||
ifd.set(t::PROFILE_HUE_SAT_MAP_DATA_1, flat(b));
|
||||
}
|
||||
[None, None] => {}
|
||||
}
|
||||
if let Some(l) = &p.look_table {
|
||||
table_tags(&mut ifd, l, t::PROFILE_LOOK_TABLE_DIMS, t::PROFILE_LOOK_TABLE_ENCODING);
|
||||
ifd.set(t::PROFILE_LOOK_TABLE_DATA, flat(l));
|
||||
}
|
||||
if let Some(curve) = &p.tone_curve {
|
||||
ifd.set(t::PROFILE_TONE_CURVE, Value::Float(curve.points.iter().flatten().copied().collect()));
|
||||
}
|
||||
for (list, tag) in [(&raw.opcodes.list1, t::OPCODE_LIST_1), (&raw.opcodes.list2, t::OPCODE_LIST_2), (&raw.opcodes.list3, t::OPCODE_LIST_3)] {
|
||||
if !list.is_empty() {
|
||||
ifd.set(tag, Value::Undefined(opcodes::write_list(list)));
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
//! [`RawImage::develop`] does all of it plus `OpcodeList3` and the default crop; [`RawImage::develop_binned`]
|
||||
//! produces the same at 1/k of the size straight from the mosaic (previews, thumbnails).
|
||||
//! - [`color`] implements the DNG colour model (dual-illuminant interpolation, forward matrices, white balance)
|
||||
//! and produces camera → linear Rec.2020 D65 matrices.
|
||||
//! and produces camera → linear Rec.2020 D65 matrices; [`profile`] reads and applies a DNG's own profile
|
||||
//! look tables and tone curve.
|
||||
//!
|
||||
//! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw),
|
||||
//! Canon CR2, Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer
|
||||
|
|
@ -29,6 +30,7 @@ pub mod highlight;
|
|||
pub mod ljpeg;
|
||||
pub mod opcodes;
|
||||
mod preview;
|
||||
pub mod profile;
|
||||
mod tiffraw;
|
||||
mod unpack;
|
||||
mod vendor;
|
||||
|
|
@ -393,6 +395,10 @@ pub struct ColorData {
|
|||
pub as_shot_white_xy: Option<Xy>,
|
||||
/// EV to add for a "normal" rendering (`BaselineExposure` + `BaselineExposureOffset`).
|
||||
pub baseline_exposure: f64,
|
||||
/// The file's own camera-profile look (`ProfileHueSatMap*`, `ProfileLookTable*`,
|
||||
/// `ProfileToneCurve`), applied by [`color`]'s users at render time.
|
||||
#[serde(default)]
|
||||
pub profile: profile::ProfileLook,
|
||||
}
|
||||
|
||||
/// A decoded raw image.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,387 @@
|
|||
//! DNG camera-profile look data carried by the file itself (Adobe DNG Specification 1.6/1.7,
|
||||
//! chapter 6 and the `ProfileHueSatMap*`, `ProfileLookTable*` and `ProfileToneCurve` tags).
|
||||
//!
|
||||
//! - A hue/saturation/value table holds, for a grid of (value, hue, saturation) inputs, a hue shift
|
||||
//! in degrees, a saturation scale and a value scale (value-major, hue-middle, saturation-minor).
|
||||
//! Hue samples are spread evenly around the circle with wrap-around, saturation and value samples
|
||||
//! evenly over 0..=1; a dimension with one division is constant. Lookups are tri-linear.
|
||||
//! - Tables are applied in HSV of linear ProPhoto RGB (D50). With encoding 1 ("sRGB") the value
|
||||
//! axis is indexed through the sRGB transfer curve (3D tables only).
|
||||
//! - `ProfileHueSatMapData1/2` follow the colour matrices (interpolated by the same illuminant
|
||||
//! weight); `ProfileLookTableData` comes after exposure compensation and before any tone curve.
|
||||
//! - `ProfileToneCurve`: (input, output) pairs in linear gamma from (0, 0) to (1, 1).
|
||||
//!
|
||||
//! The data is read from the user's own file at run time; nothing here ships profile data.
|
||||
//! Values above 1.0 (highlight headroom) keep their headroom: the table's value scale only ever
|
||||
//! lowers them, so a lookup can't clip what the scene-referred pipeline still needs.
|
||||
|
||||
use lightcraft_color::transfer::{linear_to_srgb, srgb_to_linear};
|
||||
use lightcraft_color::{D50, D65, Mat3, PROPHOTO, REC2020, bradford};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Upper bound on table entries (Adobe profiles use ≤ 90 × 30 × 16; guards header-driven sizes).
|
||||
const MAX_ENTRIES: usize = 1 << 20;
|
||||
|
||||
/// A DNG hue/saturation/value mapping table (`ProfileHueSatMapData*` / `ProfileLookTableData`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HsvTable {
|
||||
pub hue_divisions: usize,
|
||||
pub sat_divisions: usize,
|
||||
pub val_divisions: usize,
|
||||
/// `(hue shift °, saturation scale, value scale)`, value-major, hue-middle, saturation-minor.
|
||||
pub data: Vec<[f32; 3]>,
|
||||
/// The value axis is indexed through the sRGB encoding curve (`…Encoding` = 1).
|
||||
pub srgb_value: bool,
|
||||
}
|
||||
|
||||
impl HsvTable {
|
||||
/// A table from its `…Dims` (hue, saturation, value divisions), data floats and encoding.
|
||||
/// `None` when the dimensions are out of range, the data length doesn't match or a value is
|
||||
/// not finite.
|
||||
pub fn from_tags(dims: &[u64], data: &[f64], encoding: u64) -> Option<HsvTable> {
|
||||
let [h, s, v] = <[u64; 3]>::try_from(dims).ok()?;
|
||||
let (h, s, v) = (usize::try_from(h).ok()?, usize::try_from(s).ok()?, usize::try_from(v).ok()?);
|
||||
if h < 1 || s < 2 || v < 1 || h > 4096 || s > 4096 || v > 4096 {
|
||||
return None;
|
||||
}
|
||||
let n = h.checked_mul(s)?.checked_mul(v)?;
|
||||
if n > MAX_ENTRIES || data.len() != n * 3 || data.iter().any(|x| !x.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let data = data.as_chunks::<3>().0.iter().map(|c| [c[0] as f32, (c[1] as f32).max(0.0), (c[2] as f32).max(0.0)]).collect();
|
||||
Some(HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data, srgb_value: encoding == 1 && v > 1 })
|
||||
}
|
||||
|
||||
/// The entry-wise blend `g · a + (1 − g) · b` of two tables of the same shape (`None` otherwise).
|
||||
pub fn blend(a: &HsvTable, b: &HsvTable, g: f64) -> Option<HsvTable> {
|
||||
if (a.hue_divisions, a.sat_divisions, a.val_divisions) != (b.hue_divisions, b.sat_divisions, b.val_divisions) {
|
||||
return None;
|
||||
}
|
||||
let g = g.clamp(0.0, 1.0) as f32;
|
||||
let data = a.data.iter().zip(&b.data).map(|(x, y)| std::array::from_fn(|i| g * x[i] + (1.0 - g) * y[i])).collect();
|
||||
Some(HsvTable { data, ..a.clone() })
|
||||
}
|
||||
|
||||
fn entry(&self, v: usize, h: usize, s: usize) -> [f32; 3] {
|
||||
self.data.get((v * self.hue_divisions + h) * self.sat_divisions + s).copied().unwrap_or([0.0, 1.0, 1.0])
|
||||
}
|
||||
|
||||
/// Tri-linear lookup at hue `h` (degrees), saturation `s` and value `v` (table coordinates).
|
||||
pub fn lookup(&self, h: f32, s: f32, v: f32) -> [f32; 3] {
|
||||
let hf = (h.rem_euclid(360.0) / 360.0 * self.hue_divisions as f32).clamp(0.0, self.hue_divisions as f32);
|
||||
let h0 = (hf as usize).min(self.hue_divisions - 1);
|
||||
let h1 = (h0 + 1) % self.hue_divisions;
|
||||
let th = (hf - h0 as f32).clamp(0.0, 1.0);
|
||||
let axis = |x: f32, n: usize| {
|
||||
let f = x.clamp(0.0, 1.0) * (n - 1) as f32;
|
||||
let i0 = (f as usize).min(n.saturating_sub(2));
|
||||
let i1 = (i0 + 1).min(n - 1);
|
||||
(i0, i1, (f - i0 as f32).clamp(0.0, 1.0))
|
||||
};
|
||||
let (s0, s1, ts) = axis(s, self.sat_divisions);
|
||||
let (v0, v1, tv) = if self.val_divisions > 1 { axis(v, self.val_divisions) } else { (0, 0, 0.0) };
|
||||
let lerp = |a: [f32; 3], b: [f32; 3], t: f32| -> [f32; 3] { std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t) };
|
||||
let plane = |vi: usize| {
|
||||
let lo = lerp(self.entry(vi, h0, s0), self.entry(vi, h0, s1), ts);
|
||||
let hi = lerp(self.entry(vi, h1, s0), self.entry(vi, h1, s1), ts);
|
||||
lerp(lo, hi, th)
|
||||
};
|
||||
if v0 == v1 { plane(v0) } else { lerp(plane(v0), plane(v1), tv) }
|
||||
}
|
||||
|
||||
/// Apply the table to one linear ProPhoto RGB colour.
|
||||
pub fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
|
||||
let max = rgb[0].max(rgb[1]).max(rgb[2]);
|
||||
if max.is_nan() || max <= 0.0 || !max.is_finite() {
|
||||
return rgb;
|
||||
}
|
||||
let min = rgb[0].min(rgb[1]).min(rgb[2]);
|
||||
let d = max - min;
|
||||
let hue = hue_of(rgb, max, d);
|
||||
let sat = d / max;
|
||||
let v_index = if self.srgb_value { linear_to_srgb(max.min(1.0)) } else { max };
|
||||
let [shift, ss, vs] = self.lookup(hue, sat, v_index);
|
||||
// saturation and value: scaled and clipped to 1.0 (DNG chapter 6), except that inputs
|
||||
// already above 1 (out-of-ProPhoto colours, highlight headroom) are only ever lowered.
|
||||
let s2 = limited(sat, ss);
|
||||
let v2 = if self.srgb_value && max <= 1.0 {
|
||||
srgb_to_linear((linear_to_srgb(max) * vs).min(1.0))
|
||||
} else if self.srgb_value {
|
||||
max * srgb_to_linear(vs.min(1.0)).min(1.0)
|
||||
} else {
|
||||
limited(max, vs)
|
||||
};
|
||||
hsv_to_rgb(hue + shift, s2, v2)
|
||||
}
|
||||
}
|
||||
|
||||
/// `x · k` clipped to 1, for `x ≤ 1`; above 1, only scale factors below 1 apply (continuous at 1).
|
||||
fn limited(x: f32, k: f32) -> f32 {
|
||||
if x <= 1.0 { (x * k).min(1.0) } else { x * k.min(1.0) }
|
||||
}
|
||||
|
||||
fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 {
|
||||
if d <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let h = if max == c[0] {
|
||||
((c[1] - c[2]) / d).rem_euclid(6.0)
|
||||
} else if max == c[1] {
|
||||
(c[2] - c[0]) / d + 2.0
|
||||
} else {
|
||||
(c[0] - c[1]) / d + 4.0
|
||||
};
|
||||
h * 60.0
|
||||
}
|
||||
|
||||
/// HSV → RGB for any saturation ≥ 0 (above 1 yields negative components, the inverse of
|
||||
/// [`hue_of`] for out-of-gamut inputs).
|
||||
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] {
|
||||
let hp = h.rem_euclid(360.0) / 60.0;
|
||||
let sector = (hp as u32).min(5);
|
||||
let f = hp - sector as f32;
|
||||
let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f)));
|
||||
match sector {
|
||||
0 => [v, t, p],
|
||||
1 => [q, v, p],
|
||||
2 => [p, v, t],
|
||||
3 => [p, q, v],
|
||||
4 => [t, p, v],
|
||||
_ => [v, p, q],
|
||||
}
|
||||
}
|
||||
|
||||
/// A DNG tone curve (`ProfileToneCurve`): linear-gamma (input, output) pairs, increasing input.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ToneCurve {
|
||||
pub points: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
impl ToneCurve {
|
||||
/// From the tag's flat float pairs. `None` unless there are 2..=65536 finite pairs within
|
||||
/// 0..=1 with strictly increasing inputs.
|
||||
pub fn from_tag(v: &[f64]) -> Option<ToneCurve> {
|
||||
if !v.len().is_multiple_of(2) || v.len() < 4 || v.len() > 131_072 {
|
||||
return None;
|
||||
}
|
||||
let points: Vec<[f32; 2]> = v.as_chunks::<2>().0.iter().map(|p| [p[0] as f32, p[1] as f32]).collect();
|
||||
let ok = points.iter().all(|p| p.iter().all(|x| x.is_finite() && (0.0..=1.0).contains(x))) && points.windows(2).all(|w| w[1][0] > w[0][0]);
|
||||
ok.then_some(ToneCurve { points })
|
||||
}
|
||||
|
||||
/// The curve at `x` (0..=1): monotone cubic (Fritsch–Carlson) through the samples.
|
||||
pub fn eval(&self, x: f32) -> f32 {
|
||||
let p = &self.points;
|
||||
let n = p.len();
|
||||
let x = x.clamp(p[0][0], p[n - 1][0]);
|
||||
let i = p.partition_point(|q| q[0] <= x).clamp(1, n - 1) - 1;
|
||||
let slope = |k: usize| (p[k + 1][1] - p[k][1]) / (p[k + 1][0] - p[k][0]);
|
||||
let tangent = |k: usize| -> f32 {
|
||||
if k == 0 {
|
||||
return slope(0);
|
||||
}
|
||||
if k == n - 1 {
|
||||
return slope(n - 2);
|
||||
}
|
||||
let (a, b) = (slope(k - 1), slope(k));
|
||||
if a * b <= 0.0 { 0.0 } else { 2.0 / (1.0 / a + 1.0 / b) }
|
||||
};
|
||||
let (x0, x1) = (p[i][0], p[i + 1][0]);
|
||||
let h = x1 - x0;
|
||||
let t = (x - x0) / h;
|
||||
let (t2, t3) = (t * t, t * t * t);
|
||||
let y = (2.0 * t3 - 3.0 * t2 + 1.0) * p[i][1]
|
||||
+ (t3 - 2.0 * t2 + t) * h * tangent(i)
|
||||
+ (-2.0 * t3 + 3.0 * t2) * p[i + 1][1]
|
||||
+ (t3 - t2) * h * tangent(i + 1);
|
||||
y.clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The DNG profile look tags of a file (all optional).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProfileLook {
|
||||
/// `ProfileHueSatMapData1/2` (same shape, `ProfileHueSatMapDims`).
|
||||
pub hue_sat_map: [Option<HsvTable>; 2],
|
||||
/// `ProfileLookTableData`.
|
||||
pub look_table: Option<HsvTable>,
|
||||
/// `ProfileToneCurve`.
|
||||
pub tone_curve: Option<ToneCurve>,
|
||||
}
|
||||
|
||||
impl ProfileLook {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.hue_sat_map.iter().all(Option::is_none) && self.look_table.is_none() && self.tone_curve.is_none()
|
||||
}
|
||||
|
||||
/// The hue/saturation map for calibration-1 weight `g` (one table: used for any white).
|
||||
pub fn hue_sat_for(&self, g: f64) -> Option<HsvTable> {
|
||||
match &self.hue_sat_map {
|
||||
[Some(a), Some(b)] => HsvTable::blend(a, b, g).or_else(|| Some(a.clone())),
|
||||
[Some(a), None] | [None, Some(a)] => Some(a.clone()),
|
||||
[None, None] => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tables resolved for one white balance, applied to linear Rec.2020 D65 pixels.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ProfileTables {
|
||||
hue_sat: Option<HsvTable>,
|
||||
look: Option<HsvTable>,
|
||||
to_prophoto: [[f32; 3]; 3],
|
||||
from_prophoto: [[f32; 3]; 3],
|
||||
}
|
||||
|
||||
impl ProfileTables {
|
||||
/// `None` when the profile has no hue/saturation map and no look table.
|
||||
pub fn new(look: &ProfileLook, illuminant_weight: f64) -> Option<ProfileTables> {
|
||||
let hue_sat = look.hue_sat_for(illuminant_weight);
|
||||
if hue_sat.is_none() && look.look_table.is_none() {
|
||||
return None;
|
||||
}
|
||||
let to: Mat3 = PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz());
|
||||
let from = to.inverse()?;
|
||||
Some(ProfileTables { hue_sat, look: look.look_table.clone(), to_prophoto: to.to_f32(), from_prophoto: from.to_f32() })
|
||||
}
|
||||
|
||||
/// Hue/saturation map, then `gain` (exposure compensation), then the look table:
|
||||
/// linear Rec.2020 D65 in, linear Rec.2020 D65 out.
|
||||
#[inline]
|
||||
pub fn apply(&self, rgb: [f32; 3], gain: f32) -> [f32; 3] {
|
||||
let m = &self.to_prophoto;
|
||||
let mut p: [f32; 3] = std::array::from_fn(|i| m[i][0] * rgb[0] + m[i][1] * rgb[1] + m[i][2] * rgb[2]);
|
||||
if let Some(t) = &self.hue_sat {
|
||||
p = t.apply(p);
|
||||
}
|
||||
p = p.map(|v| v * gain);
|
||||
if let Some(t) = &self.look {
|
||||
p = t.apply(p);
|
||||
}
|
||||
let m = &self.from_prophoto;
|
||||
std::array::from_fn(|i| m[i][0] * p[0] + m[i][1] * p[1] + m[i][2] * p[2])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn identity(h: usize, s: usize, v: usize) -> HsvTable {
|
||||
HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data: vec![[0.0, 1.0, 1.0]; h * s * v], srgb_value: false }
|
||||
}
|
||||
|
||||
fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool {
|
||||
a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_table_is_identity() {
|
||||
let t = identity(6, 3, 1);
|
||||
for c in [[0.2, 0.5, 0.1], [0.9, 0.9, 0.9], [0.0, 0.0, 0.4], [3.0, 1.0, 0.5], [0.3, -0.05, 0.2]] {
|
||||
assert!(close(t.apply(c), c, 1e-5), "{c:?} → {:?}", t.apply(c));
|
||||
}
|
||||
assert_eq!(t.apply([0.0; 3]), [0.0; 3]);
|
||||
assert_eq!(t.apply([-1.0, -2.0, -0.5]), [-1.0, -2.0, -0.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_tags_and_rejects_bad_shapes() {
|
||||
let data: Vec<f64> = (0..4).flat_map(|_| [0.0, 1.0, 1.0]).collect();
|
||||
assert!(HsvTable::from_tags(&[2, 2, 1], &data, 0).is_some());
|
||||
assert!(HsvTable::from_tags(&[2, 2], &data, 0).is_none());
|
||||
assert!(HsvTable::from_tags(&[2, 1, 2], &data, 0).is_none(), "saturation needs ≥ 2 divisions");
|
||||
assert!(HsvTable::from_tags(&[2, 2, 2], &data, 0).is_none(), "length mismatch");
|
||||
assert!(HsvTable::from_tags(&[u64::MAX, 2, 2], &data, 0).is_none());
|
||||
let mut nan = data.clone();
|
||||
nan[1] = f64::NAN;
|
||||
assert!(HsvTable::from_tags(&[2, 2, 1], &nan, 0).is_none());
|
||||
// the sRGB encoding only applies to 3D tables
|
||||
assert!(!HsvTable::from_tags(&[2, 2, 1], &data, 1).unwrap().srgb_value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saturation_scale_follows_the_table_layout() {
|
||||
// 4 hues (0°, 90°, 180°, 270°), 2 saturations; saturation-minor: entry (h, s) at h·2 + s.
|
||||
let mut t = identity(4, 2, 1);
|
||||
t.data[1] = [0.0, 1.5, 1.0]; // hue 0°, saturation 1 → ×1.5
|
||||
let red = [0.5, 0.4, 0.4]; // hue 0°, saturation 0.2
|
||||
let out = t.apply(red);
|
||||
// half-way along the saturation axis is ×1.1 (0.2 → 0.22 sat), value kept
|
||||
let sat = (out[0] - out[1].min(out[2])) / out[0];
|
||||
assert!((sat - 0.2 * 1.1).abs() < 1e-4, "{out:?}");
|
||||
assert!((out[0] - 0.5).abs() < 1e-6);
|
||||
// other hues are untouched (hue 180°)
|
||||
let cyan = [0.3, 0.4, 0.4];
|
||||
assert!(close(t.apply(cyan), cyan, 1e-6));
|
||||
// saturation clips to 1.0
|
||||
assert!(t.apply([1.0, 0.1, 0.1]).iter().all(|v| *v >= -1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_wraps_and_value_scales() {
|
||||
// 2 hues: entries at 0° and 180°, hue shift +20° at 0°, value ×0.5 at 180°.
|
||||
let mut t = identity(2, 2, 1);
|
||||
t.data = vec![[20.0, 1.0, 1.0], [20.0, 1.0, 1.0], [0.0, 1.0, 0.5], [0.0, 1.0, 0.5]];
|
||||
// 350° sits between the last sample (180°) and the first (0° = 360°), 10/180 from 360
|
||||
assert!((t.lookup(350.0, 0.5, 0.5)[0] - 20.0 * (170.0 / 180.0)).abs() < 1e-3);
|
||||
assert!((t.lookup(180.0, 0.5, 0.5)[2] - 0.5).abs() < 1e-6);
|
||||
// headroom above 1 only goes down
|
||||
let bright = [2.0, 2.4, 2.4];
|
||||
let out = t.apply(bright);
|
||||
assert!((out[1] - 1.2).abs() < 1e-5, "{out:?}");
|
||||
let mut up = identity(1, 2, 1);
|
||||
up.data = vec![[0.0, 1.0, 2.0]; 2];
|
||||
assert!((up.apply([0.4, 0.4, 0.4])[0] - 0.8).abs() < 1e-6);
|
||||
assert!((up.apply([0.8, 0.8, 0.8])[0] - 1.0).abs() < 1e-6, "clipped to 1");
|
||||
assert!((up.apply([3.0, 3.0, 3.0])[0] - 3.0).abs() < 1e-6, "headroom kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_axis_and_srgb_encoding() {
|
||||
// 1 hue, 2 sats, 2 values: saturation ×2 at value 1 only
|
||||
let mut t = identity(1, 2, 2);
|
||||
t.data[3] = [0.0, 2.0, 1.0];
|
||||
let sat = |c: [f32; 3]| (c[0] - c[2]) / c[0];
|
||||
let dark = [0.25, 0.2, 0.2]; // V 0.25, S 0.2
|
||||
// linear: 1/4 of the way → S scale at s=0.2: lerp(1, lerp(1,2,0.2)=1.2, 0.25) = 1.05
|
||||
assert!((sat(t.apply(dark)) - 0.2 * 1.05).abs() < 1e-4);
|
||||
t.srgb_value = true;
|
||||
let k = linear_to_srgb(0.25); // ≈ 0.537
|
||||
let want = 0.2 * (1.0 + 0.2 * k);
|
||||
assert!((sat(t.apply(dark)) - want).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_and_tables_round_trip_through_prophoto() {
|
||||
let a = identity(2, 2, 1);
|
||||
let mut b = identity(2, 2, 1);
|
||||
b.data[1] = [10.0, 2.0, 1.0];
|
||||
let m = HsvTable::blend(&a, &b, 0.25).unwrap();
|
||||
assert_eq!(m.data[1], [7.5, 1.75, 1.0]);
|
||||
assert!(HsvTable::blend(&a, &identity(3, 2, 1), 0.5).is_none());
|
||||
let look = ProfileLook { hue_sat_map: [Some(a.clone()), None], look_table: Some(a), tone_curve: None };
|
||||
let t = ProfileTables::new(&look, 0.5).unwrap();
|
||||
let c = [0.3, 0.2, 0.1];
|
||||
assert!(close(t.apply(c, 2.0), [0.6, 0.4, 0.2], 1e-4), "{:?}", t.apply(c, 2.0));
|
||||
assert!(ProfileTables::new(&ProfileLook::default(), 0.5).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tone_curve_interpolates_monotonically() {
|
||||
assert!(ToneCurve::from_tag(&[0.0, 0.0]).is_none());
|
||||
assert!(ToneCurve::from_tag(&[0.0, 0.0, 0.5, 0.6, 0.4, 0.7, 1.0, 1.0]).is_none(), "inputs must increase");
|
||||
assert!(ToneCurve::from_tag(&[0.0, 0.0, 1.0, 1.5]).is_none());
|
||||
let c = ToneCurve::from_tag(&[0.0, 0.0, 0.25, 0.4, 0.5, 0.7, 1.0, 1.0]).unwrap();
|
||||
assert_eq!(c.eval(0.0), 0.0);
|
||||
assert!((c.eval(0.25) - 0.4).abs() < 1e-6);
|
||||
assert!((c.eval(1.0) - 1.0).abs() < 1e-6);
|
||||
let mut last = 0.0;
|
||||
for i in 0..=100 {
|
||||
let y = c.eval(i as f32 / 100.0);
|
||||
assert!(y >= last - 1e-6, "monotone at {i}");
|
||||
last = y;
|
||||
}
|
||||
assert!(c.eval(0.1) > 0.1 && c.eval(0.1) < 0.4);
|
||||
}
|
||||
}
|
||||
|
|
@ -193,3 +193,40 @@ fn corpus_nef_compressed_matches_uncompressed() {
|
|||
assert!(hi <= 4095.0 && hi - lo > 500.0, "D7000 block means {lo}..{hi}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #138: DNGs converted by Adobe software carry their camera profile's hue/saturation map and
|
||||
/// look table; we read them (and render with them). Camera-written DNGs here carry none.
|
||||
#[test]
|
||||
fn corpus_adobe_dngs_carry_profile_looks() {
|
||||
let dir = corpus_root().join("raw");
|
||||
let Ok(rd) = std::fs::read_dir(&dir) else {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
return;
|
||||
};
|
||||
let mut seen = 0;
|
||||
for p in rd.flatten().map(|e| e.path()) {
|
||||
let name = p.file_name().unwrap().to_string_lossy().to_lowercase();
|
||||
if !name.starts_with("dng-") || !name.ends_with(".dng") {
|
||||
continue;
|
||||
}
|
||||
let info = probe_info(&std::fs::read(&p).unwrap()).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let look = &info.color.profile;
|
||||
if name.starts_with("dng-adobe-") {
|
||||
seen += 1;
|
||||
let hsm = look.hue_sat_map[0].as_ref().unwrap_or_else(|| panic!("{name}: no hue/sat map"));
|
||||
assert!(hsm.hue_divisions > 1 && hsm.sat_divisions > 1, "{name}");
|
||||
assert!(look.look_table.is_some(), "{name}: no look table");
|
||||
// a profile applied to a mid grey keeps it (close to) neutral
|
||||
let t = lightcraft_raw::profile::ProfileTables::new(look, 0.5).unwrap();
|
||||
let g = t.apply([0.18; 3], 1.0);
|
||||
assert!(g.iter().all(|v| (v - g[0]).abs() < 0.01 * g[0].max(0.01)), "{name}: grey → {g:?}");
|
||||
}
|
||||
eprintln!(
|
||||
"{name:44} profile look: hsm {} look {} tone {}",
|
||||
look.hue_sat_map[0].is_some(),
|
||||
look.look_table.is_some(),
|
||||
look.tone_curve.is_some()
|
||||
);
|
||||
}
|
||||
eprintln!("{seen} Adobe-converted DNGs checked");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,6 +182,11 @@ fn base_ifd(w: usize, h: usize, bits: u16, cpp: usize, cfa: bool) -> IfdBuilder
|
|||
}
|
||||
|
||||
fn dng(raw: IfdBuilder, order: ByteOrder) -> Vec<u8> {
|
||||
dng_with(raw, order, |_| {})
|
||||
}
|
||||
|
||||
/// [`dng`] with extra IFD 0 tags (where DNG writers put camera-profile tags).
|
||||
fn dng_with(raw: IfdBuilder, order: ByteOrder, extra: impl FnOnce(&mut IfdBuilder)) -> Vec<u8> {
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
|
||||
ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 6, 0, 0]));
|
||||
|
|
@ -200,6 +205,7 @@ fn dng(raw: IfdBuilder, order: ByteOrder) -> Vec<u8> {
|
|||
exif.set(t::ISO_SPEED, Value::Short(vec![200]));
|
||||
ifd0.set_child(t::EXIF_IFD, exif);
|
||||
ifd0.add_sub_ifd(raw);
|
||||
extra(&mut ifd0);
|
||||
TiffWriter::new(order, false).write(&[ifd0]).unwrap()
|
||||
}
|
||||
|
||||
|
|
@ -461,3 +467,68 @@ fn rejects_unsupported_and_broken() {
|
|||
raw.set_image(ImageData::Strips { rows_per_strip: 8, strips: vec![vec![0; 128]] });
|
||||
assert!(decode(&dng(raw, ByteOrder::Little)).is_err());
|
||||
}
|
||||
|
||||
/// A Lightroom-style DNG: profile look tags in IFD 0, LinearRaw data in a SubIFD (issue #138).
|
||||
fn profile_dng(order: ByteOrder, extra: impl FnOnce(&mut IfdBuilder)) -> Vec<u8> {
|
||||
let (w, h) = (8, 6);
|
||||
let px = pattern(w, h, 3, 16);
|
||||
let mut raw = base_ifd(w, h, 16, 3, false);
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![u16_bytes(&px, order)] });
|
||||
dng_with(raw, order, extra)
|
||||
}
|
||||
|
||||
fn hsv_floats(h: usize, s: usize, v: usize, f: impl Fn(usize, usize, usize) -> [f32; 3]) -> Vec<f32> {
|
||||
(0..v).flat_map(|vi| (0..h).flat_map(move |hi| (0..s).map(move |si| (vi, hi, si)))).flat_map(|(vi, hi, si)| f(vi, hi, si)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_look_tags_are_read_from_ifd0() {
|
||||
for order in [ByteOrder::Little, ByteOrder::Big] {
|
||||
let bytes = profile_dng(order, |ifd0| {
|
||||
ifd0.set(t::PROFILE_HUE_SAT_MAP_DIMS, Value::Long(vec![6, 3, 1]));
|
||||
ifd0.set(t::PROFILE_HUE_SAT_MAP_DATA_1, Value::Float(hsv_floats(6, 3, 1, |_, h, s| [h as f32, 1.0 + s as f32 * 0.1, 1.0])));
|
||||
ifd0.set(t::PROFILE_HUE_SAT_MAP_DATA_2, Value::Float(hsv_floats(6, 3, 1, |_, _, _| [0.0, 1.0, 1.0])));
|
||||
ifd0.set(t::PROFILE_LOOK_TABLE_DIMS, Value::Long(vec![4, 2, 3]));
|
||||
ifd0.set(t::PROFILE_LOOK_TABLE_DATA, Value::Float(hsv_floats(4, 2, 3, |v, _, _| [0.0, 1.0, 1.0 - v as f32 * 0.05])));
|
||||
ifd0.set(t::PROFILE_LOOK_TABLE_ENCODING, Value::Long(vec![1]));
|
||||
ifd0.set(t::PROFILE_TONE_CURVE, Value::Float(vec![0.0, 0.0, 0.25, 0.15, 0.5, 0.55, 1.0, 1.0]));
|
||||
});
|
||||
let img = decode(&bytes).unwrap();
|
||||
assert_eq!(img.cpp, 3);
|
||||
let p = &img.color.profile;
|
||||
let hsm = p.hue_sat_map[0].as_ref().unwrap();
|
||||
assert_eq!((hsm.hue_divisions, hsm.sat_divisions, hsm.val_divisions), (6, 3, 1));
|
||||
// value-major, hue-middle, saturation-minor: entry (h = 2, s = 1) is at 2·3 + 1
|
||||
assert_eq!(hsm.data[7], [2.0, 1.1, 1.0]);
|
||||
assert!(p.hue_sat_map[1].is_some());
|
||||
let look = p.look_table.as_ref().unwrap();
|
||||
assert_eq!((look.hue_divisions, look.sat_divisions, look.val_divisions, look.srgb_value), (4, 2, 3, true));
|
||||
assert_eq!(look.data[2 * 4 * 2], [0.0, 1.0, 0.9]);
|
||||
assert_eq!(p.tone_curve.as_ref().unwrap().points.len(), 4);
|
||||
// the header-only probe sees the same profile
|
||||
assert_eq!(lightcraft_raw::probe_info(&bytes).unwrap().color.profile, *p);
|
||||
// our DNG writer keeps the look with the data (conversions, smart previews)
|
||||
let again = decode(&write_dng(&img, &DngWriteOptions::default()).unwrap()).unwrap();
|
||||
assert_eq!(again.color.profile, *p);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_profile_look_tags_are_ignored() {
|
||||
let bytes = profile_dng(ByteOrder::Little, |ifd0| {
|
||||
// data count doesn't match the dimensions
|
||||
ifd0.set(t::PROFILE_HUE_SAT_MAP_DIMS, Value::Long(vec![6, 3, 1]));
|
||||
ifd0.set(t::PROFILE_HUE_SAT_MAP_DATA_1, Value::Float(vec![0.0, 1.0, 1.0]));
|
||||
// one saturation division is not allowed
|
||||
ifd0.set(t::PROFILE_LOOK_TABLE_DIMS, Value::Long(vec![2, 1, 1]));
|
||||
ifd0.set(t::PROFILE_LOOK_TABLE_DATA, Value::Float(vec![0.0, 1.0, 1.0, 0.0, 1.0, 1.0]));
|
||||
// decreasing inputs and a NaN
|
||||
ifd0.set(t::PROFILE_TONE_CURVE, Value::Float(vec![0.0, 0.0, 0.6, 0.5, 0.4, f32::NAN, 1.0, 1.0]));
|
||||
});
|
||||
let img = decode(&bytes).unwrap();
|
||||
assert!(img.color.profile.is_empty(), "{:?}", img.color.profile);
|
||||
// and without any of the tags there is nothing to apply
|
||||
let plain = decode(&profile_dng(ByteOrder::Little, |_| {})).unwrap();
|
||||
assert!(plain.color.profile.is_empty());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,6 +152,15 @@ pub const CAMERA_CALIBRATION_SIGNATURE: u16 = 50931;
|
|||
pub const PROFILE_CALIBRATION_SIGNATURE: u16 = 50932;
|
||||
pub const AS_SHOT_PROFILE_NAME: u16 = 50934;
|
||||
pub const PROFILE_NAME: u16 = 50936;
|
||||
pub const PROFILE_HUE_SAT_MAP_DIMS: u16 = 50937;
|
||||
pub const PROFILE_HUE_SAT_MAP_DATA_1: u16 = 50938;
|
||||
pub const PROFILE_HUE_SAT_MAP_DATA_2: u16 = 50939;
|
||||
pub const PROFILE_TONE_CURVE: u16 = 50940;
|
||||
pub const PROFILE_EMBED_POLICY: u16 = 50941;
|
||||
pub const PROFILE_LOOK_TABLE_DIMS: u16 = 50981;
|
||||
pub const PROFILE_LOOK_TABLE_DATA: u16 = 50982;
|
||||
pub const PROFILE_HUE_SAT_MAP_ENCODING: u16 = 51107;
|
||||
pub const PROFILE_LOOK_TABLE_ENCODING: u16 = 51108;
|
||||
pub const BASELINE_EXPOSURE_OFFSET: u16 = 51109;
|
||||
pub const DEFAULT_BLACK_RENDER: u16 = 51110;
|
||||
pub const FORWARD_MATRIX_1: u16 = 50964;
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome |
|
||||
| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | |
|
||||
| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | |
|
||||
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices they carry; Sony ARW gets a guarded file-local fit to its own embedded JPEG — chromaticity matrix + tone curve, relative WB, kept with smart previews (see docs/camera-preview-colour.md); on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
|
||||
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW gets a guarded file-local fit to its own embedded JPEG — chromaticity matrix + tone curve, relative WB, kept with smart previews (see docs/camera-preview-colour.md); on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
|
||||
| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched |
|
||||
| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | |
|
||||
| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF |
|
||||
|
|
|
|||
Loading…
Reference in New Issue