LR-PROF-CAMERACOLOR: read an untagged camera JPEG in the colour space the raw's EXIF states, for every make (#778)

A Sony body shot in Adobe RGB writes ColorSpace Uncalibrated with InteropIndex R03 in the raw's EXIF and leaves the
embedded JPEG untagged, so the look was fitted against the JPEG decoded as sRGB. The EXIF fallback now applies to
all makes (the Nikon maker note still wins); DNGs are skipped, since their previews come from the converter.
This commit is contained in:
radialmonster
2026-10-10 20:06:50 -04:00
parent 9e6c4c6fc9
commit f032d6e389
4 changed files with 68 additions and 9 deletions
+3 -2
View File
@@ -22,8 +22,9 @@ use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTa
/// that leave the fitted look alone. `RENDER_CACHE_VERSION` covers renders; this covers proxies.
///
/// 1: the fit as of #499's follow-up; 2: Sony DRO (tone curve lowered to Sony's curve without DRO)
/// and the ILCE-7CR profile (#528, #583, #568, #616).
pub const LOOK_VERSION: u32 = 2;
/// and the ILCE-7CR profile (#528, #583, #568, #616); 3: the camera JPEG read in the colour space the raw's EXIF
/// states, for every make (#778).
pub const LOOK_VERSION: u32 = 3;
#[derive(Clone, Debug)]
pub(crate) struct CameraLook {
+1 -1
View File
@@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 42;
pub const RENDER_CACHE_VERSION: u64 = 43;
/// 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];
+25 -6
View File
@@ -14,16 +14,35 @@ pub enum PreviewColorSpace {
AdobeRgb,
}
/// Nikon maker-note ColorSpace (0x001e: 1 = sRGB, 2 = Adobe RGB), per Nikon tag documentation.
/// This is a fallback only: a JPEG's own ICC/EXIF colour declaration takes precedence.
/// The colour space the raw says its embedded JPEG is in, for a JPEG that declares none itself (no ICC profile,
/// no EXIF), first available first: the Nikon maker-note ColorSpace (0x001e: 1 = sRGB, 2 = Adobe RGB, per Nikon
/// tag documentation); the raw's own EXIF ColorSpace (0xA001: 1 = sRGB), or Uncalibrated (0xFFFF) with the
/// Interoperability IFD's InteropIndex "R03", the DCF option file for Adobe RGB (as a Sony body shot in Adobe RGB
/// writes, issue #778; per the Exif and DCF specifications), not for a DNG. This is a fallback only: a JPEG's own ICC/EXIF colour
/// declaration takes precedence.
pub fn embedded_preview_color_space(bytes: &[u8]) -> Option<PreviewColorSpace> {
let tiff = Tiff::parse(bytes).ok()?;
let make = tiff.ifds.first()?.string(t::MAKE)?;
if !make.to_ascii_uppercase().starts_with("NIKON") {
let make = tiff.ifds.first()?.string(t::MAKE).unwrap_or_default();
let exif = tiff.exif()?;
if make.to_ascii_uppercase().starts_with("NIKON")
&& let Some(space) = nikon_color_space(bytes, &tiff, exif, &make)
{
return Some(space);
}
// a DNG's EXIF describes the original capture; its previews are rendered by the converter
if tiff.ifds.first()?.contains(t::DNG_VERSION) {
return None;
}
let e = tiff.exif()?.get(t::MAKER_NOTE)?;
let mn = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)?;
match exif.u16(0xA001)? {
1 => Some(PreviewColorSpace::Srgb),
0xFFFF if exif.interop.as_deref()?.bytes(0x0001)?.starts_with(b"R03") => Some(PreviewColorSpace::AdobeRgb),
_ => None,
}
}
fn nikon_color_space(bytes: &[u8], tiff: &Tiff, exif: &Ifd, make: &str) -> Option<PreviewColorSpace> {
let e = exif.get(t::MAKER_NOTE)?;
let mn = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, make)?;
match mn.ifd.u16(0x001e)? {
1 => Some(PreviewColorSpace::Srgb),
2 => Some(PreviewColorSpace::AdobeRgb),
+39
View File
@@ -251,6 +251,45 @@ mod tests {
assert_eq!(crate::embedded_preview_color_space(b"not a TIFF"), None);
}
/// Issue #778: any make's raw falls back to its EXIF ColorSpace, with Uncalibrated + InteropIndex "R03" read as
/// Adobe RGB; the Nikon note wins; a DNG is left alone.
#[test]
fn preview_color_space_falls_back_to_the_exif_color_space() {
let file = |make: &str, cs: u16, interop: Option<&str>, note: Option<u16>, dng: bool| {
let mut exif = IfdBuilder::new();
exif.set(0xA001, Value::Short(vec![cs]));
if let Some(i) = interop {
let mut io = IfdBuilder::new();
io.set(0x0001, Value::Ascii(i.into()));
exif.set_child(t::INTEROP_IFD, io);
}
if let Some(v) = note {
let mut mn = IfdBuilder::new();
mn.set(0x001e, Value::Short(vec![v]));
let mut bytes = b"Nikon".to_vec();
bytes.extend(TiffWriter::new(ByteOrder::Little, false).write(&[mn]).unwrap());
exif.set(t::MAKER_NOTE, Value::Undefined(bytes));
}
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::MAKE, Value::Ascii(make.into()));
if dng {
ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
}
ifd0.set_child(t::EXIF_IFD, exif);
TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap()
};
use crate::PreviewColorSpace::{AdobeRgb, Srgb};
let f = crate::embedded_preview_color_space;
assert_eq!(f(&file("SONY", 0xFFFF, Some("R03"), None, false)), Some(AdobeRgb));
assert_eq!(f(&file("SONY", 1, Some("R98"), None, false)), Some(Srgb));
assert_eq!(f(&file("SONY", 0xFFFF, Some("R98"), None, false)), None);
assert_eq!(f(&file("SONY", 0xFFFF, None, None, false)), None);
assert_eq!(f(&file("Canon", 0xFFFF, Some("R03"), None, false)), Some(AdobeRgb));
assert_eq!(f(&file("NIKON CORPORATION", 1, Some("R98"), Some(2), false)), Some(AdobeRgb));
assert_eq!(f(&file("NIKON CORPORATION", 0xFFFF, Some("R03"), None, false)), Some(AdobeRgb));
assert_eq!(f(&file("Canon", 0xFFFF, Some("R03"), None, true)), None);
}
#[test]
fn nikon_crop_area_preserves_sensor_origin_and_rejects_invalid_rectangles() {
let (w, h) = (32, 24);