From e11db8fea0283008c63a3daef8fbbff738be7704 Mon Sep 17 00:00:00 2001 From: akvaithi Date: Mon, 5 Oct 2026 14:24:38 -0500 Subject: [PATCH] LR-HDR-EXPORT: ISO 21496-1 gain map JPEG codec `lightcraft_codecs::gainmap`: - `compute` measures a gain map (luminance or RGB, log2, block-averaged) from an SDR and an HDR rendition; `apply` rebuilds the HDR rendition. - ISO 21496-1 metadata: writer (fixed denominator) and reader (also the common-denominator form; refuses unknown versions, backward maps and zero denominators). Adobe `hdrgm` XMP reader and writer, per-channel values as rdf:Seq; Apple's HDRGainMap/HDRToneMap description of the same map. - `encode_jpeg`: base with CIPA DC-007 MPF index (gain map typed 0x050000), the ISO marker and Container XMP merged into the user's packet, followed by the gain map JPEG. `read_jpeg` / `is_gain_map_jpeg` find and decode the map. Also: gain map images are no longer picked as embedded thumbnails (an Ultra HDR JPEG could show its greyscale gain map in the grid). The JPEG APP-segment building is shared with `encode_jpeg`. Fuzzed with the other decoders. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/README.md | 7 + crates/codecs/src/encode.rs | 70 +-- crates/codecs/src/gainmap.rs | 834 +++++++++++++++++++++++++++++++++++ crates/codecs/src/jpeg.rs | 67 ++- crates/codecs/src/lib.rs | 1 + crates/codecs/tests/fuzz.rs | 14 +- 6 files changed, 957 insertions(+), 36 deletions(-) create mode 100644 crates/codecs/src/gainmap.rs diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 18743ad0..575a9170 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -23,6 +23,12 @@ encode_png(&EncodeImage::new(w, h, 4, Samples::U16(&px)), &meta)?; encode_tiff(&EncodeImage::new(w, h, 3, Samples::F32(&px)), TiffCompression::Deflate, &meta)?; encode_webp_lossless(&img, &meta)?; encode_avif(&img, 70, 6, &meta)?; // native + feature `avif` + +// HDR gain map JPEG (ISO 21496-1): SDR + HDR renditions → base + gain map, and back +let (map, gm) = gainmap::compute(&sdr_linear, &hdr_linear, w, h, luma, &GainMapOptions::default())?; +gainmap::encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&base8)), &map, &gm, 90, ChromaSubsampling::S444, &meta)?; +let found = gainmap::read_jpeg(&bytes); // ours, cameras', Lightroom's +gainmap::apply(&mut base_linear, w, h, &found.gain, &found.meta, 1.0)?; ``` Thumbnail fallback decoding limits the source to 64 million pixels by default. @@ -48,6 +54,7 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d | JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | — | enum colour → rendered straight to linear Rec.2020; ICC → our ICC path; orientation applied by the decoder (reported as 1) | | AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8-bit sRGB, EXIF; no ICC in the muxer | | HEIC/HEIF | **no** (sniff only) | — | see gaps | +| Gain map JPEG (HDR) | gain map found and decoded (`gainmap::read_jpeg`): ISO 21496-1 metadata, else Adobe `hdrgm` XMP | yes (`gainmap::encode_jpeg`) | CIPA DC-007 MPF index (gain map typed `0x050000`), ISO 21496-1 APP2 on both images, `hdrgm` + Container XMP for Android/Chrome, Apple `HDRGainMap`/`HDRToneMap` XMP; gain maps are never used as thumbnails | ## Colour diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index 59622073..7552eede 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -79,6 +79,46 @@ pub enum ChromaSubsampling { const XMP_NS: &[u8] = b"http://ns.adobe.com/xap/1.0/\0"; +/// The APP segments [`encode_jpeg`] writes for `meta` (marker byte, payload): JFIF density, EXIF, +/// XMP and the ICC profile in chunks, in that order. +pub(crate) fn jpeg_app_segments(meta: &EncodeMeta) -> Result)>> { + let mut segs: Vec<(u8, Vec)> = Vec::new(); + let ppi = meta.ppi.filter(|p| *p > 0).map_or([0u8, 0, 1, 0, 1], |p| { + let [hi, lo] = p.to_be_bytes(); + [1, hi, lo, hi, lo] + }); + segs.push((0xE0, [&b"JFIF\0\x01\x02"[..], &ppi, &[0, 0]].concat())); + if let Some(exif) = meta.exif { + if exif.len() > 65_527 { + return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into())); + } + segs.push((0xE1, [&b"Exif\0\0"[..], exif].concat())); + } + if let Some(xmp) = meta.xmp { + segs.push((0xE1, xmp_segment(xmp)?)); + } + if let Some(icc) = meta.icc { + // ICC.1 Annex B.4: "ICC_PROFILE\0", 1-based chunk number, chunk count + let chunks: Vec<&[u8]> = icc.chunks(65_519).collect(); + if chunks.len() > 255 { + return Err(Error::Encode("ICC profile too large for JPEG".into())); + } + for (i, c) in chunks.iter().enumerate() { + segs.push((0xE2, [&b"ICC_PROFILE\0"[..], &[i as u8 + 1, chunks.len() as u8], c].concat())); + } + } + Ok(segs) +} + +/// An APP1 XMP payload (namespace + packet), refusing packets larger than one segment. +pub(crate) fn xmp_segment(xmp: &str) -> Result> { + let seg = [XMP_NS, xmp.as_bytes()].concat(); + if seg.len() > 65_533 { + return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into())); + } + Ok(seg) +} + /// Encode baseline JPEG (8-bit; alpha is dropped). `quality` 1..=100. pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsampling, meta: &EncodeMeta) -> Result> { img.validate()?; @@ -97,35 +137,7 @@ pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsamplin }; if subsampling != ChromaSubsampling::S422 { // Parallel encoder (restart-interval bands on all cores). - let mut segs: Vec<(u8, Vec)> = Vec::new(); - let ppi = meta.ppi.filter(|p| *p > 0).map_or([0u8, 0, 1, 0, 1], |p| { - let [hi, lo] = p.to_be_bytes(); - [1, hi, lo, hi, lo] - }); - segs.push((0xE0, [&b"JFIF\0\x01\x02"[..], &ppi, &[0, 0]].concat())); - if let Some(exif) = meta.exif { - if exif.len() > 65_527 { - return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into())); - } - segs.push((0xE1, [&b"Exif\0\0"[..], exif].concat())); - } - if let Some(xmp) = meta.xmp { - let seg = [XMP_NS, xmp.as_bytes()].concat(); - if seg.len() > 65_533 { - return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into())); - } - segs.push((0xE1, seg)); - } - if let Some(icc) = meta.icc { - // ICC.1 Annex B.4: "ICC_PROFILE\0", 1-based chunk number, chunk count - let chunks: Vec<&[u8]> = icc.chunks(65_519).collect(); - if chunks.len() > 255 { - return Err(Error::Encode("ICC profile too large for JPEG".into())); - } - for (i, c) in chunks.iter().enumerate() { - segs.push((0xE2, [&b"ICC_PROFILE\0"[..], &[i as u8 + 1, chunks.len() as u8], c].concat())); - } - } + let segs = jpeg_app_segments(meta)?; let ch = if img.channels == 2 { 1 } else { img.channels as usize }; // `data` is already gray for 2-channel input (see above) return Ok(crate::jpeg_par::encode(&data, img.width as usize, img.height as usize, ch, quality, subsampling, &segs)); diff --git a/crates/codecs/src/gainmap.rs b/crates/codecs/src/gainmap.rs new file mode 100644 index 00000000..13c3c0e5 --- /dev/null +++ b/crates/codecs/src/gainmap.rs @@ -0,0 +1,834 @@ +//! Gain map HDR images (ISO 21496-1): an SDR base image plus a gain map that a decoder uses to +//! rebuild the HDR rendition, so the file looks right everywhere and lights up on HDR displays. +//! +//! - [`compute`] measures a gain map from an SDR and an HDR rendition of the same picture (linear +//! light, same primaries). Measure it against the base *as written* (decoded from its 8-bit +//! codes), so quantisation in the base is handed back by the gain map. +//! - [`apply`] rebuilds the HDR rendition from a base and its gain map. +//! - [`encode_jpeg`] writes a gain map JPEG: the base with an MPF index (CIPA DC-007), the ISO +//! 21496-1 marker and Adobe/Google container XMP, followed by the gain map JPEG carrying the ISO +//! 21496-1 metadata, the Adobe `hdrgm` XMP (Android, Chrome) and Apple's description of the same +//! map (what survives an iMessage send). +//! - [`read_jpeg`] finds and decodes the gain map of a gain map JPEG (ours, cameras, Lightroom), +//! from its ISO 21496-1 metadata or, failing that, its `hdrgm` XMP. +//! +//! All gains are log2. A decoder recovers `log2(hdr + offset_hdr) − log2(sdr + offset_sdr)` as +//! `min + (max − min) · (code / 255)^(1 / gamma)`, scaled by a weight in 0..1 that depends on how +//! much headroom the display has between `base_headroom` and `alternate_headroom`. + +use crate::encode::{EncodeImage, EncodeMeta, Samples, jpeg_app_segments, xmp_segment}; +use crate::{ChromaSubsampling, Error, Format, Result, jpeg, jpeg_par}; + +/// The ISO 21496-1 namespace that opens its APP2 payloads (NUL terminated). +pub const ISO_URN: &[u8] = b"urn:iso:std:iso:ts:21496:-1\0"; + +/// Fixed denominator for the metadata's rationals: one value per setting, byte-identical output. +const DENOMINATOR: u32 = 1_000_000; + +/// Gain map parameters (ISO 21496-1 / Adobe `hdrgm`). Per-channel arrays repeat the first value +/// when the map is single-channel. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GainMapMeta { + /// Three gain channels (R, G, B) instead of one luminance gain. + pub multichannel: bool, + /// The gain applies in the base image's colour space. + pub use_base_colour_space: bool, + /// log2 headroom at which the base is shown as is (0: an SDR base). + pub base_headroom: f32, + /// log2 headroom at which the gain map applies in full. + pub alternate_headroom: f32, + pub min: [f32; 3], + pub max: [f32; 3], + pub gamma: [f32; 3], + pub base_offset: [f32; 3], + pub alternate_offset: [f32; 3], +} + +impl Default for GainMapMeta { + fn default() -> Self { + GainMapMeta { + multichannel: false, + use_base_colour_space: true, + base_headroom: 0.0, + alternate_headroom: 1.0, + min: [0.0; 3], + max: [1.0; 3], + gamma: [1.0; 3], + base_offset: [1.0 / 64.0; 3], + alternate_offset: [1.0 / 64.0; 3], + } + } +} + +impl GainMapMeta { + fn channels(&self) -> usize { + if self.multichannel { 3 } else { 1 } + } + + /// Usable by [`apply`]: finite, positive gammas, max ≥ min, headrooms in order. + pub fn is_valid(&self) -> bool { + let fin = |a: &[f32; 3]| a.iter().all(|v| v.is_finite()); + fin(&self.min) + && fin(&self.max) + && fin(&self.gamma) + && fin(&self.base_offset) + && fin(&self.alternate_offset) + && self.gamma.iter().all(|g| *g > 0.0) + && self.min.iter().zip(&self.max).all(|(a, b)| b >= a) + && self.base_headroom.is_finite() + && self.alternate_headroom.is_finite() + && self.alternate_headroom > self.base_headroom + } + + /// The ISO 21496-1 APP2 payload of the gain map image, URN included. + pub fn to_iso(&self) -> Vec { + let mut p = ISO_URN.to_vec(); + p.extend_from_slice(&0u16.to_be_bytes()); // minimum_version: readers gate on this + p.extend_from_slice(&0u16.to_be_bytes()); // writer_version + let mut flags = 0u8; + if self.multichannel { + flags |= FLAG_MULTICHANNEL; + } + if self.use_base_colour_space { + flags |= FLAG_BASE_COLOUR_SPACE; + } + p.push(flags); + let unsigned = |p: &mut Vec, v: f32| { + let n = (v as f64 * DENOMINATOR as f64).round().clamp(0.0, u32::MAX as f64) as u32; + p.extend_from_slice(&n.to_be_bytes()); + p.extend_from_slice(&DENOMINATOR.to_be_bytes()); + }; + let signed = |p: &mut Vec, v: f32| { + let n = (v as f64 * DENOMINATOR as f64).round().clamp(i32::MIN as f64, i32::MAX as f64) as i32; + p.extend_from_slice(&n.to_be_bytes()); + p.extend_from_slice(&DENOMINATOR.to_be_bytes()); + }; + unsigned(&mut p, self.base_headroom.max(0.0)); + unsigned(&mut p, self.alternate_headroom.max(0.0)); + for c in 0..self.channels() { + signed(&mut p, self.min[c]); + signed(&mut p, self.max[c]); + unsigned(&mut p, self.gamma[c]); + signed(&mut p, self.base_offset[c]); + signed(&mut p, self.alternate_offset[c]); + } + p + } + + /// Parse an ISO 21496-1 payload (the bytes after [`ISO_URN`]). `None` for the versions-only + /// marker a primary image carries, unknown versions, a base that is the HDR rendition, or + /// values no decoder could use. + pub fn from_iso(p: &[u8]) -> Option { + let mut r = Reader { b: p, at: 0 }; + let min_version = r.u16()?; + let _writer_version = r.u16()?; + if min_version != 0 { + return None; + } + let flags = r.u8()?; + if flags & FLAG_BACKWARD != 0 { + return None; + } + let multichannel = flags & FLAG_MULTICHANNEL != 0; + let common = flags & FLAG_COMMON_DENOMINATOR != 0; + let common_d = if common { r.u32()? } else { 0 }; + let unsigned = |r: &mut Reader| -> Option { + let n = r.u32()?; + let d = if common { common_d } else { r.u32()? }; + (d != 0).then(|| (n as f64 / d as f64) as f32) + }; + let signed = |r: &mut Reader| -> Option { + let n = r.u32()? as i32; + let d = if common { common_d } else { r.u32()? }; + (d != 0).then(|| (n as f64 / d as f64) as f32) + }; + let mut m = GainMapMeta { + multichannel, + use_base_colour_space: flags & FLAG_BASE_COLOUR_SPACE != 0, + base_headroom: unsigned(&mut r)?, + alternate_headroom: unsigned(&mut r)?, + ..GainMapMeta::default() + }; + for c in 0..m.channels() { + m.min[c] = signed(&mut r)?; + m.max[c] = signed(&mut r)?; + m.gamma[c] = unsigned(&mut r)?; + m.base_offset[c] = signed(&mut r)?; + m.alternate_offset[c] = signed(&mut r)?; + } + if !multichannel { + m.fill_from_first(); + } + m.is_valid().then_some(m) + } + + fn fill_from_first(&mut self) { + for a in [&mut self.min, &mut self.max, &mut self.gamma, &mut self.base_offset, &mut self.alternate_offset] { + a[1] = a[0]; + a[2] = a[0]; + } + } + + /// The gain parameters from Adobe `hdrgm` XMP (the gain map image's packet), for files + /// without ISO 21496-1 metadata (the first Ultra HDR files). Attributes or `rdf:Seq` lists. + pub fn from_hdrgm_xmp(xmp: &str) -> Option { + if !xmp.contains("hdrgm:") { + return None; + } + let base_rendition_hdr = xmp_values(xmp, "BaseRenditionIsHDR").is_some_and(|v| v.first().is_some_and(|s| s.eq_ignore_ascii_case("true"))); + if base_rendition_hdr { + return None; + } + let get = |name: &str, default: f32| -> Option<([f32; 3], bool)> { + let Some(v) = xmp_values(xmp, name) else { return Some(([default; 3], false)) }; + let f: Vec = v.iter().map(|s| s.trim().parse::().ok()).collect::>()?; + match f.as_slice() { + [a] => Some(([*a; 3], false)), + [a, b, c] => Some(([*a, *b, *c], true)), + _ => None, + } + }; + let (max, m1) = get("GainMapMax", f32::NAN)?; + if max.iter().any(|v| v.is_nan()) { + return None; // the one required parameter + } + let (min, m2) = get("GainMapMin", 0.0)?; + let (gamma, m3) = get("Gamma", 1.0)?; + let (base_offset, m4) = get("OffsetSDR", 1.0 / 64.0)?; + let (alternate_offset, m5) = get("OffsetHDR", 1.0 / 64.0)?; + let (cap_min, _) = get("HDRCapacityMin", 0.0)?; + let max_gain = max.iter().copied().fold(f32::MIN, f32::max); + let (cap_max, _) = get("HDRCapacityMax", max_gain)?; + let m = GainMapMeta { + multichannel: m1 || m2 || m3 || m4 || m5, + use_base_colour_space: true, + base_headroom: cap_min[0].max(0.0), + alternate_headroom: cap_max[0], + min, + max, + gamma, + base_offset, + alternate_offset, + }; + m.is_valid().then_some(m) + } + + /// The gain map image's XMP: the Adobe `hdrgm` parameters (read by Android and Chrome, which + /// ignore the ISO payload) and Apple's description of the same map (what iMessage keeps). + pub fn to_gain_map_xmp(&self) -> String { + let names = ["GainMapMin", "GainMapMax", "Gamma", "OffsetSDR", "OffsetHDR"]; + let values = [&self.min, &self.max, &self.gamma, &self.base_offset, &self.alternate_offset]; + let uniform = |v: &[f32; 3]| !self.multichannel || (v[0] == v[1] && v[1] == v[2]); + let mut s = String::from(XMP_OPEN); + s.push_str(r#"'); + // per-channel values are an rdf:Seq of three, not a comma list: readers parse each item + for (n, v) in names.iter().zip(values) { + if !uniform(v) { + s.push_str(&format!("")); + for x in v { + s.push_str(&format!("{}", num(*x))); + } + s.push_str(&format!("")); + } + } + s.push_str(""); + s.push_str(&self.apple_description()); + s.push_str(XMP_CLOSE); + s + } + + /// Apple's own description of the map (`apdi`, `HDRGainMap`, `HDRToneMap` namespaces). + fn apple_description(&self) -> String { + // 'L008' (8-bit luminance) or '444f'; the stored map must really have that many channels + let format = if self.multichannel { 0x3434_3466u32 } else { 0x4C30_3038 }; + let mut s = String::from( + r#""#, + ); + s.push_str("urn:com:apple:photo:2020:aux:hdrgainmap"); + s.push_str(&format!("{format}{format}")); + s.push_str("131072"); + // linear, not log2: the headroom multiplier itself + s.push_str(&format!("{}", num(self.alternate_headroom.exp2()))); + s.push_str(&format!("{}", num(self.alternate_headroom))); + s.push_str(""); + for c in 0..self.channels() { + s.push_str(&format!( + r#"{}{}{}{}{}"#, + num(self.min[c]), + num(self.max[c]), + num(self.gamma[c]), + num(self.base_offset[c]), + num(self.alternate_offset[c]) + )); + } + s.push_str(""); + s.push_str(&format!("{}", num(self.base_headroom))); + s.push_str("True1"); + s.push_str(""); + s + } +} + +const FLAG_MULTICHANNEL: u8 = 0x80; +const FLAG_BASE_COLOUR_SPACE: u8 = 0x40; +const FLAG_COMMON_DENOMINATOR: u8 = 0x08; +const FLAG_BACKWARD: u8 = 0x04; + +const XMP_OPEN: &str = ""; +const XMP_CLOSE: &str = ""; + +/// A short decimal (up to 6 significant digits, no exponent for ordinary values). +fn num(v: f32) -> String { + if !v.is_finite() { + return "0".into(); + } + let s = format!("{:.6}", v); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" || s.is_empty() { "0".into() } else { s.to_string() } +} + +/// The values of `hdrgm:`: an attribute (`name="v"`) or an element holding an `rdf:Seq`. +fn xmp_values(xmp: &str, name: &str) -> Option> { + let attr = format!("hdrgm:{name}="); + if let Some(i) = xmp.find(&attr) { + let rest = xmp.get(i + attr.len()..)?; + let q = rest.chars().next()?; + if q == '"' || q == '\'' { + let body = rest.get(1..)?; + let end = body.find(q)?; + return Some(vec![body.get(..end)?.to_string()]); + } + } + let open = format!(""); + let close = format!(""); + let i = xmp.find(&open)?; + let body = xmp.get(i + open.len()..)?; + let body = body.get(..body.find(&close)?)?; + let mut out = Vec::new(); + let mut rest = body; + while let Some(a) = rest.find("") { + let after = rest.get(a + 8..)?; + let b = after.find("")?; + out.push(after.get(..b)?.to_string()); + rest = after.get(b..)?; + if out.len() > 3 { + return None; + } + } + if out.is_empty() { + // a plain element value + out.push(body.trim().to_string()); + } + Some(out) +} + +struct Reader<'a> { + b: &'a [u8], + at: usize, +} + +impl Reader<'_> { + fn take(&mut self) -> Option<[u8; N]> { + let s = self.b.get(self.at..self.at.checked_add(N)?)?; + self.at += N; + s.try_into().ok() + } + fn u8(&mut self) -> Option { + self.take::<1>().map(|a| a[0]) + } + fn u16(&mut self) -> Option { + self.take().map(u16::from_be_bytes) + } + fn u32(&mut self) -> Option { + self.take().map(u32::from_be_bytes) + } +} + +/// A gain map image: `channels` (1 or 3) interleaved 8-bit codes. +#[derive(Clone, Debug, PartialEq)] +pub struct GainImage { + pub width: usize, + pub height: usize, + pub channels: usize, + pub data: Vec, +} + +impl GainImage { + /// Bilinear code of channel `c` at gain map coordinates (`x`, `y`) (pixel centres at +0.5). + fn sample(&self, x: f32, y: f32, c: usize) -> f32 { + let (w, h) = (self.width, self.height); + let fx = (x - 0.5).clamp(0.0, (w - 1) as f32); + let fy = (y - 0.5).clamp(0.0, (h - 1) as f32); + let (x0, y0) = (fx as usize, fy as usize); + let (x1, y1) = ((x0 + 1).min(w - 1), (y0 + 1).min(h - 1)); + let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); + let at = |x: usize, y: usize| self.data.get((y * w + x) * self.channels + c).copied().unwrap_or(0) as f32; + let a = at(x0, y0) + (at(x1, y0) - at(x0, y0)) * tx; + let b = at(x0, y1) + (at(x1, y1) - at(x0, y1)) * tx; + a + (b - a) * ty + } +} + +/// How [`compute`] builds the map. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GainMapOptions { + /// Downscale factor of the map (1, 2 or 4). + pub scale: usize, + /// Three gains (R, G, B) instead of one luminance gain. Apple's readers want a single + /// channel, so this is off by default. + pub multichannel: bool, + pub gamma: f32, + /// Offsets added to SDR and HDR before the ratio (keep blacks finite and noise quiet). + pub offset: f32, +} + +impl Default for GainMapOptions { + fn default() -> Self { + GainMapOptions { scale: 2, multichannel: false, gamma: 1.0, offset: 1.0 / 64.0 } + } +} + +/// Measure the gain map that turns `sdr` into `hdr` (both `width × height` linear RGB in the same +/// primaries; `luma` = that space's luminance weights). +pub fn compute( + sdr: &[[f32; 3]], + hdr: &[[f32; 3]], + width: usize, + height: usize, + luma: [f32; 3], + o: &GainMapOptions, +) -> Result<(GainImage, GainMapMeta)> { + let n = width.checked_mul(height).ok_or_else(|| Error::Encode("gain map: image too large".into()))?; + if n == 0 || sdr.len() < n || hdr.len() < n { + return Err(Error::Encode("gain map: SDR and HDR renditions must match the image size".into())); + } + if !matches!(o.scale, 1 | 2 | 4) || !(o.gamma > 0.0 && o.gamma.is_finite()) || !(o.offset > 0.0 && o.offset.is_finite()) { + return Err(Error::Encode("gain map: scale must be 1, 2 or 4, gamma and offset positive".into())); + } + let s = o.scale; + let (gw, gh) = (width.div_ceil(s), height.div_ceil(s)); + let ch = if o.multichannel { 3 } else { 1 }; + let y = |p: &[f32; 3]| luma[0] * p[0] + luma[1] * p[1] + luma[2] * p[2]; + let ratio = |a: f32, b: f32| ((b.max(0.0) + o.offset) / (a.max(0.0) + o.offset)).log2(); + // block means of the log gains + let mut gains = vec![0f32; gw * gh * ch]; + let mut lo = [f32::MAX; 3]; + let mut hi = [f32::MIN; 3]; + for gy in 0..gh { + for gx in 0..gw { + let mut acc = [0f32; 3]; + let mut count = 0f32; + for yy in gy * s..((gy + 1) * s).min(height) { + for xx in gx * s..((gx + 1) * s).min(width) { + let i = yy * width + xx; + let (Some(a), Some(b)) = (sdr.get(i), hdr.get(i)) else { continue }; + if o.multichannel { + for c in 0..3 { + acc[c] += ratio(a[c], b[c]); + } + } else { + acc[0] += ratio(y(a), y(b)); + } + count += 1.0; + } + } + for c in 0..ch { + let g = if count > 0.0 { acc[c] / count } else { 0.0 }; + let g = if g.is_finite() { g } else { 0.0 }; + gains[(gy * gw + gx) * ch + c] = g; + lo[c] = lo[c].min(g); + hi[c] = hi[c].max(g); + } + } + } + let mut m = GainMapMeta { + multichannel: o.multichannel, + gamma: [o.gamma; 3], + base_offset: [o.offset; 3], + alternate_offset: [o.offset; 3], + ..GainMapMeta::default() + }; + for c in 0..ch { + // the range always includes 0 (no change) so an SDR-only area encodes exactly + m.min[c] = lo[c].min(0.0); + m.max[c] = hi[c].max(m.min[c] + 1e-4); + } + if !o.multichannel { + m.fill_from_first(); + } + m.base_headroom = 0.0; + m.alternate_headroom = m.max.iter().copied().fold(0.0f32, f32::max).max(1e-3); + let data = gains + .iter() + .enumerate() + .map(|(i, g)| { + let c = i % ch; + let norm = ((g - m.min[c]) / (m.max[c] - m.min[c])).clamp(0.0, 1.0); + (norm.powf(o.gamma) * 255.0).round() as u8 + }) + .collect(); + Ok((GainImage { width: gw, height: gh, channels: ch, data }, m)) +} + +/// Rebuild the HDR rendition in place: `base` (`width × height` linear RGB) is replaced by the +/// rendition at `weight` (0 = the base, 1 = the full alternate rendition; a display with headroom +/// `H` uses `(H − base_headroom) / (alternate_headroom − base_headroom)`, clamped). +pub fn apply(base: &mut [[f32; 3]], width: usize, height: usize, gain: &GainImage, m: &GainMapMeta, weight: f32) -> Result<()> { + let n = width.checked_mul(height).ok_or_else(|| Error::Encode("gain map: image too large".into()))?; + if base.len() < n + || gain.width == 0 + || gain.height == 0 + || !matches!(gain.channels, 1 | 3) + || gain.data.len() < gain.width * gain.height * gain.channels + { + return Err(Error::Encode("gain map: base and gain map sizes don't match".into())); + } + if !m.is_valid() { + return Err(Error::Encode("gain map: unusable metadata".into())); + } + let weight = if weight.is_finite() { weight.clamp(0.0, 1.0) } else { 1.0 }; + let (sx, sy) = (gain.width as f32 / width as f32, gain.height as f32 / height as f32); + let inv_gamma = m.gamma.map(|g| 1.0 / g); + for (i, p) in base.iter_mut().enumerate().take(n) { + let (x, y) = ((i % width) as f32 + 0.5, (i / width) as f32 + 0.5); + let (gx, gy) = (x * sx, y * sy); + let mut g = [0f32; 3]; + for (c, gc) in g.iter_mut().enumerate() { + let src = if gain.channels == 3 { c } else { 0 }; + let norm = (gain.sample(gx, gy, src) / 255.0).clamp(0.0, 1.0).powf(inv_gamma[c]); + *gc = m.min[c] + (m.max[c] - m.min[c]) * norm; + } + for c in 0..3 { + let v = (p[c] + m.base_offset[c]) * (g[c] * weight).exp2() - m.alternate_offset[c]; + p[c] = v.max(0.0); + } + } + Ok(()) +} + +/// The primary image's container XMP: `hdrgm:Version` (marks the file as a gain map image for +/// Adobe/Google readers) and the Container directory naming the gain map and its length. +fn primary_description(gain_len: usize) -> String { + format!( + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""# + ), + gain_len + ) +} + +/// `xmp` with `description` added as another `rdf:Description` (a fresh packet when there is +/// none, or when it has no `rdf:RDF` to add to). +fn merge_xmp(xmp: Option<&str>, description: &str) -> String { + if let Some(x) = xmp + && let Some(i) = x.rfind("") + && let (Some(a), Some(b)) = (x.get(..i), x.get(i..)) + { + return format!("{a}{description}{b}"); + } + format!("{XMP_OPEN}{description}{XMP_CLOSE}") +} + +/// Offsets within the MPF payload (from "MPF\0"): the endian field, then the index IFD (3 tags). +const MPF_ENTRIES_FROM_ENDIAN: usize = 8 + 2 + 3 * 12 + 4; + +/// The primary image's MPF payload, entry sizes and offset to be patched once the file exists. +fn mpf_placeholder() -> Vec { + let mut p = b"MPF\0MM".to_vec(); + p.extend_from_slice(&42u16.to_be_bytes()); + p.extend_from_slice(&8u32.to_be_bytes()); // first IFD + p.extend_from_slice(&3u16.to_be_bytes()); + let tag = |p: &mut Vec, tag: u16, ty: u16, count: u32, value: [u8; 4]| { + p.extend_from_slice(&tag.to_be_bytes()); + p.extend_from_slice(&ty.to_be_bytes()); + p.extend_from_slice(&count.to_be_bytes()); + p.extend_from_slice(&value); + }; + tag(&mut p, 0xB000, 7, 4, *b"0100"); // MPFVersion + tag(&mut p, 0xB001, 4, 1, 2u32.to_be_bytes()); // NumberOfImages + tag(&mut p, 0xB002, 7, 32, (MPF_ENTRIES_FROM_ENDIAN as u32).to_be_bytes()); // MPEntry + p.extend_from_slice(&0u32.to_be_bytes()); // no next IFD + // entry 0: baseline primary (0x030000) flagged as the representative image (bit 29) + for attr in [0x2003_0000u32, jpeg::MPF_GAIN_MAP] { + p.extend_from_slice(&attr.to_be_bytes()); + p.extend_from_slice(&[0; 12]); // size, offset, two dependent-image entries + } + p +} + +/// Encode a gain map JPEG: `base` (8-bit SDR, 3 or 4 channels, colour-managed by `meta.icc`) and +/// its gain map, both at `quality`. `meta.xmp` is kept, with the gain map container added. +pub fn encode_jpeg(base: &EncodeImage, gain: &GainImage, m: &GainMapMeta, quality: u8, sub: ChromaSubsampling, meta: &EncodeMeta) -> Result> { + let Samples::U8(data) = base.samples else { + return Err(Error::Encode("gain map JPEG needs an 8-bit base image".into())); + }; + let (w, h) = (base.width as usize, base.height as usize); + if !matches!(base.channels, 3 | 4) || data.len() < w * h * base.channels as usize { + return Err(Error::Encode("gain map JPEG needs an RGB base image".into())); + } + if w == 0 || h == 0 || w > u16::MAX as usize || h > u16::MAX as usize { + return Err(Error::Encode("JPEG dimensions are limited to 65535".into())); + } + if !matches!(gain.channels, 1 | 3) || gain.width == 0 || gain.height == 0 || gain.data.len() < gain.width * gain.height * gain.channels { + return Err(Error::Encode("gain map image is malformed".into())); + } + if !m.is_valid() || m.multichannel != (gain.channels == 3) { + return Err(Error::Encode("gain map metadata doesn't match the map".into())); + } + // jpeg_par writes 4:4:4 or 4:2:0 + let sub = if sub == ChromaSubsampling::S422 { ChromaSubsampling::S420 } else { sub }; + + let gain_segs = vec![(0xE1, xmp_segment(&m.to_gain_map_xmp())?), (0xE2, m.to_iso())]; + let gain_jpeg = jpeg_par::encode(&gain.data, gain.width, gain.height, gain.channels, quality, ChromaSubsampling::S444, &gain_segs); + + let xmp = merge_xmp(meta.xmp, &primary_description(gain_jpeg.len())); + let mut segs = jpeg_app_segments(&EncodeMeta { xmp: Some(&xmp), ..*meta })?; + let mut iso_marker = ISO_URN.to_vec(); + iso_marker.extend_from_slice(&[0, 0, 0, 0]); // versions only: "this image has a gain map" + segs.push((0xE2, iso_marker)); + segs.push((0xE2, mpf_placeholder())); + let mut primary = jpeg_par::encode(data, w, h, base.channels as usize, quality, sub, &segs); + + // patch the MPF index: sizes, and the gain map's offset from the MPF endian field + let markers = jpeg::parse_markers(&primary).ok_or_else(|| Error::Encode("gain map JPEG: primary image unreadable".into()))?; + let endian = markers.mpf.as_ref().map(|m| m.0).ok_or_else(|| Error::Encode("gain map JPEG: MPF segment missing".into()))?; + let entries = endian + MPF_ENTRIES_FROM_ENDIAN; + let (plen, glen) = (primary.len(), gain_jpeg.len()); + let fields = [(entries + 4, plen), (entries + 8, 0), (entries + 20, glen), (entries + 24, plen - endian)]; + for (at, v) in fields { + let v = u32::try_from(v).map_err(|_| Error::Encode("gain map JPEG larger than 4 GiB".into()))?; + let slot = primary.get_mut(at..at + 4).ok_or_else(|| Error::Encode("gain map JPEG: truncated MPF segment".into()))?; + slot.copy_from_slice(&v.to_be_bytes()); + } + primary.extend_from_slice(&gain_jpeg); + Ok(primary) +} + +/// The gain map of a gain map JPEG. +#[derive(Clone, Debug, PartialEq)] +pub struct JpegGainMap { + pub gain: GainImage, + pub meta: GainMapMeta, +} + +/// Find and decode the gain map of a JPEG (`None` when it has none or it's unusable). The +/// primary image is decoded as usual ([`crate::decode`]); combine the two with [`apply`]. +pub fn read_jpeg(bytes: &[u8]) -> Option { + let m = jpeg::parse_markers(bytes)?; + let marked = m.iso_gainmap.is_some() || m.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:Version")); + if !marked { + return None; + } + for e in jpeg::mpf_images(&m) { + let Some(data) = bytes.get(e.offset..e.offset.saturating_add(e.len)) else { continue }; + let Some(gm) = jpeg::parse_markers(data) else { continue }; + let meta = gm.iso_gainmap.as_deref().and_then(GainMapMeta::from_iso).or_else(|| gm.xmp.as_deref().and_then(GainMapMeta::from_hdrgm_xmp)); + let Some(meta) = meta else { continue }; + let Ok((w, h, ch, codes)) = jpeg::decode_codes8(data) else { continue }; + if ch == 3 && !meta.multichannel { + // a single-channel map stored as RGB: use its first channel + let g: Vec = codes.as_chunks::<3>().0.iter().map(|c| c[0]).collect(); + return Some(JpegGainMap { gain: GainImage { width: w, height: h, channels: 1, data: g }, meta }); + } + let mut meta = meta; + if ch == 1 && meta.multichannel { + meta.multichannel = false; + meta.fill_from_first(); + } + return Some(JpegGainMap { gain: GainImage { width: w, height: h, channels: ch, data: codes }, meta }); + } + None +} + +/// Whether `bytes` is a gain map JPEG with a usable gain map (cheap: no pixel decode). +pub fn is_gain_map_jpeg(bytes: &[u8]) -> bool { + crate::sniff(bytes) == Some(Format::Jpeg) + && jpeg::parse_markers(bytes).is_some_and(|m| { + (m.iso_gainmap.is_some() || m.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:Version"))) + && jpeg::mpf_images(&m) + .iter() + .any(|e| bytes.get(e.offset..e.offset.saturating_add(e.len)).and_then(jpeg::parse_markers).is_some_and(|g| g.is_gain_map())) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn meta3() -> GainMapMeta { + GainMapMeta { + multichannel: true, + use_base_colour_space: true, + base_headroom: 0.0, + alternate_headroom: 2.5, + min: [-0.25, -0.5, 0.0], + max: [2.5, 2.25, 2.0], + gamma: [1.0, 1.25, 0.75], + base_offset: [0.015625; 3], + alternate_offset: [0.015625, 0.03125, 0.015625], + } + } + + #[test] + fn iso_metadata_round_trips() { + for m in [GainMapMeta { alternate_headroom: 3.0, max: [3.0; 3], ..GainMapMeta::default() }, meta3()] { + let p = m.to_iso(); + assert!(p.starts_with(ISO_URN)); + let back = GainMapMeta::from_iso(&p[ISO_URN.len()..]).unwrap(); + for (a, b) in [(back.min, m.min), (back.max, m.max), (back.gamma, m.gamma), (back.alternate_offset, m.alternate_offset)] { + for c in 0..3 { + assert!((a[c] - b[c]).abs() < 1e-6, "{a:?} {b:?}"); + } + } + assert_eq!(back.multichannel, m.multichannel); + assert!((back.alternate_headroom - m.alternate_headroom).abs() < 1e-6); + } + } + + #[test] + fn iso_common_denominator_and_rejections() { + // common-denominator form, single channel: d = 4, headrooms 0 and 2, min 0, max 2, gamma 1, offsets 0 + let mut p = vec![0, 0, 0, 0, FLAG_COMMON_DENOMINATOR]; + for v in [4u32, 0, 8, 0, 8, 4, 0, 0] { + p.extend_from_slice(&v.to_be_bytes()); + } + let m = GainMapMeta::from_iso(&p).unwrap(); + assert_eq!((m.alternate_headroom, m.max[2], m.gamma[1]), (2.0, 2.0, 1.0)); + // versions only (a primary image's marker), unknown version, truncated, backward + assert!(GainMapMeta::from_iso(&[0, 0, 0, 0]).is_none()); + assert!(GainMapMeta::from_iso(&[0, 1, 0, 0, 0]).is_none()); + assert!(GainMapMeta::from_iso(&p[..p.len() - 1]).is_none()); + let mut back = p.clone(); + back[4] |= FLAG_BACKWARD; + assert!(GainMapMeta::from_iso(&back).is_none()); + // a zero denominator is rejected, not divided by + let mut z = p.clone(); + z[5..9].copy_from_slice(&0u32.to_be_bytes()); + assert!(GainMapMeta::from_iso(&z).is_none()); + } + + #[test] + fn hdrgm_xmp_round_trips_including_per_channel_sequences() { + for m in [GainMapMeta { alternate_headroom: 2.0, max: [2.0; 3], ..GainMapMeta::default() }, meta3()] { + let x = m.to_gain_map_xmp(); + assert!(x.contains("apdi:AuxiliaryImageType")); + if m.multichannel { + assert!(x.contains("2.5"), "{x}"); + } + let back = GainMapMeta::from_hdrgm_xmp(&x).unwrap(); + assert_eq!(back.multichannel, m.multichannel); + for c in 0..3 { + assert!((back.max[c] - m.max[c]).abs() < 1e-5 && (back.gamma[c] - m.gamma[c]).abs() < 1e-5); + } + assert!((back.alternate_headroom - m.alternate_headroom).abs() < 1e-5); + } + assert!(GainMapMeta::from_hdrgm_xmp("").is_none(), "no GainMapMax"); + assert!(GainMapMeta::from_hdrgm_xmp("hdrgm:GainMapMax=\"nope\"").is_none()); + } + + fn renditions(w: usize, h: usize) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) { + // a ramp: SDR rolls off at 1, HDR keeps going to 6 + let hdr: Vec<[f32; 3]> = (0..w * h) + .map(|i| { + let t = (i % w) as f32 / (w - 1) as f32; + let v = 0.01 + 6.0 * t * t; + [v, v * 0.8, v * 0.6] + }) + .collect(); + // a luminance tone map (one scale per pixel, as LightCraft's renders do), so a single + // luminance gain can rebuild it exactly + let sdr = hdr + .iter() + .map(|p| { + let y = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]; + let k = 1.0 / (1.0 + y * 0.5); + p.map(|v| v * k) + }) + .collect(); + (sdr, hdr) + } + + #[test] + fn compute_then_apply_recovers_the_hdr_rendition() { + let (w, h) = (64, 8); + let (sdr, hdr) = renditions(w, h); + for multichannel in [false, true] { + let o = GainMapOptions { scale: 1, multichannel, ..GainMapOptions::default() }; + let (g, m) = compute(&sdr, &hdr, w, h, [0.2126, 0.7152, 0.0722], &o).unwrap(); + assert!(m.is_valid() && m.alternate_headroom > 1.0); + let mut out = sdr.clone(); + apply(&mut out, w, h, &g, &m, 1.0).unwrap(); + for (a, b) in out.iter().zip(&hdr) { + for c in 0..3 { + // 8-bit codes over the gain range: within ~2 % in linear light + assert!((a[c] - b[c]).abs() <= 0.03 * b[c] + 0.01, "{multichannel}: {a:?} vs {b:?}"); + } + } + // weight 0 is the base + let mut base = sdr.clone(); + apply(&mut base, w, h, &g, &m, 0.0).unwrap(); + for (a, b) in base.iter().zip(&sdr) { + assert!((a[0] - b[0]).abs() < 1e-5); + } + } + } + + #[test] + fn jpeg_round_trip_and_container_structure() { + let (w, h) = (96, 64); + let (sdr, hdr) = renditions(w, h); + let (g, m) = compute(&sdr, &hdr, w, h, [0.2126, 0.7152, 0.0722], &GainMapOptions::default()).unwrap(); + let base: Vec = + sdr.iter().flat_map(|p| p.map(|v| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8)).collect(); + let user_xmp = r#""#; + let meta = EncodeMeta { xmp: Some(user_xmp), ..EncodeMeta::default() }; + let file = encode_jpeg(&EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&base)), &g, &m, 90, ChromaSubsampling::S444, &meta).unwrap(); + assert!(is_gain_map_jpeg(&file)); + // the primary decodes as an ordinary JPEG of the right size, and keeps the user's XMP + let d = crate::decode(&file, crate::DecodeOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (w as u32, h as u32)); + let x = d.xmp.unwrap(); + assert!(x.contains("xmlns:dc=") && x.contains("hdrgm:Version") && x.contains("Item:Semantic=\"GainMap\"")); + // the MPF index points at the gain map, typed as one + let pm = jpeg::parse_markers(&file).unwrap(); + let e = jpeg::mpf_images(&pm); + assert_eq!(e.len(), 1); + assert_eq!(e[0].kind, jpeg::MPF_GAIN_MAP); + assert_eq!(e[0].offset + e[0].len, file.len()); + // and the gain map reads back + let r = read_jpeg(&file).unwrap(); + assert_eq!((r.gain.width, r.gain.height, r.gain.channels), (48, 32, 1)); + assert!((r.meta.max[0] - m.max[0]).abs() < 1e-5); + // the gain map is never a thumbnail + if let Some(t) = jpeg::embedded_thumbnail(&file, 32, 16) { + assert_ne!(t.source, crate::ThumbnailSource::MpfPreview); + } + } + + #[test] + fn garbage_never_panics() { + assert!(read_jpeg(b"").is_none()); + assert!(read_jpeg(&[0xFF, 0xD8, 0xFF, 0xE2, 0x00, 0x04, 0, 0]).is_none()); + assert!(!is_gain_map_jpeg(&[0xFF, 0xD8])); + let mut b = vec![0xFF, 0xD8, 0xFF, 0xE2, 0, 40]; + b.extend_from_slice(ISO_URN); + b.extend_from_slice(&[0, 0, 0, 0, 0xFF, 1, 2, 3, 4]); + assert!(read_jpeg(&b).is_none()); + let g = GainImage { width: 2, height: 2, channels: 1, data: vec![0; 3] }; + assert!(apply(&mut [[0.0; 3]; 4], 2, 2, &g, &GainMapMeta::default(), 1.0).is_err()); + assert!(compute(&[], &[], 0, 0, [0.3, 0.6, 0.1], &GainMapOptions::default()).is_err()); + } +} diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs index 92409af3..e40cf7e7 100644 --- a/crates/codecs/src/jpeg.rs +++ b/crates/codecs/src/jpeg.rs @@ -25,6 +25,17 @@ pub(crate) struct Markers { pub adobe_transform: Option, /// Offset of the MPF TIFF header within the file and its bytes. pub mpf: Option<(usize, Vec)>, + /// The ISO 21496-1 APP2 payload after its URN: 4 bytes (versions only) on a primary image + /// that has a gain map, the full metadata on the gain map image itself. + pub iso_gainmap: Option>, +} + +impl Markers { + /// This image is a gain map (ISO 21496-1 metadata or Adobe `hdrgm` gain parameters), not a + /// picture: never a preview, never the image to show. + pub fn is_gain_map(&self) -> bool { + self.iso_gainmap.as_ref().is_some_and(|p| p.len() > 4) || self.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:GainMapMax")) + } } pub(crate) fn parse_markers(b: &[u8]) -> Option { @@ -88,6 +99,10 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option { icc_chunks.push((seg[12], &seg[14..])); } else if seg.starts_with(b"MPF\0") && m.mpf.is_none() { m.mpf = Some((start + 4, seg[4..].to_vec())); + } else if let Some(p) = seg.strip_prefix(crate::gainmap::ISO_URN) + && m.iso_gainmap.is_none() + { + m.iso_gainmap = Some(p.to_vec()); } } 0xEE if seg.starts_with(b"Adobe") && seg.len() >= 12 => { @@ -260,8 +275,11 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) -> if let (Some(ex), Some((o, l))) = (m.exif.as_deref(), summary.thumbnail) { candidates.push((&ex[o..o + l], ThumbnailSource::ExifThumbnail)); } - for (o, l) in mpf_images(&m) { - if let Some(s) = bytes.get(o..o.saturating_add(l)) { + for e in mpf_images(&m) { + if e.kind == MPF_GAIN_MAP { + continue; + } + if let Some(s) = bytes.get(e.offset..e.offset.saturating_add(e.len)) { candidates.push((s, ThumbnailSource::MpfPreview)); } } @@ -269,6 +287,10 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) -> let mut best: Option<(u32, &[u8], ThumbnailSource)> = None; for (data, src) in candidates { let Some(pm) = parse_markers(data) else { continue }; + // a gain map (also when its MPF entry doesn't say so) is greyscale gain data, not a preview + if pm.is_gain_map() { + continue; + } let long = pm.width.max(pm.height); // Previews must have the main image's aspect ratio (within 2%), else they are letterboxed/cropped. let aspect_ok = m.width > 0 @@ -290,8 +312,21 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) -> Some(Thumbnail { image: d.to_srgb8(), orientation, source, source_width: m.width, source_height: m.height }) } -/// (file offset, length) of MPF-listed images other than the primary. -fn mpf_images(m: &Markers) -> Vec<(usize, usize)> { +/// MP type code of a gain map image (CIPA DC-007 entry attribute, low 24 bits). +pub(crate) const MPF_GAIN_MAP: u32 = 0x05_0000; + +/// An MPF-listed image other than the primary. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct MpfEntry { + /// File offset and length of the image. + pub offset: usize, + pub len: usize, + /// MP type code (attribute low 24 bits). + pub kind: u32, +} + +/// The MPF-listed images other than the primary. +pub(crate) fn mpf_images(m: &Markers) -> Vec { let Some((base, data)) = &m.mpf else { return vec![] }; let Some(t) = exif::Tiff::new(data) else { return vec![] }; let Some(ifd) = t.first_ifd() else { return vec![] }; @@ -307,14 +342,34 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> { let a = [rec[o], rec[o + 1], rec[o + 2], rec[o + 3]]; if data.starts_with(b"II") { u32::from_le_bytes(a) } else { u32::from_be_bytes(a) } }; - let (size, off) = (rd(4) as usize, rd(8) as usize); + let (attr, size, off) = (rd(0), rd(4) as usize, rd(8) as usize); if size > 0 && off > 0 { - out.push((base + off, size)); + out.push(MpfEntry { offset: base.saturating_add(off), len: size, kind: attr & 0x00FF_FFFF }); } } out } +/// Decode to the file's 8-bit samples as stored (no colour management): 1 channel for greyscale +/// files, else 3 (RGB). For data images such as gain maps, whose values are codes, not colours. +pub(crate) fn decode_codes8(bytes: &[u8]) -> Result<(usize, usize, usize, Vec)> { + let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?; + if m.components == 0 { + return Err(Error::Malformed(F, "no frame header".into())); + } + check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?; + if !matches!(m.components, 1 | 3) || m.precision != 8 { + return Err(Error::Malformed(F, "expected an 8-bit greyscale or RGB image".into())); + } + let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components; + let raw = if non_interleaved { decode_jpeg_decoder(bytes, &m, None)? } else { decode_zune(bytes, &m)? }; + let ch = raw.model.channels(); + match raw.buf { + Buf::U8(v) if v.len() >= raw.width * raw.height * ch => Ok((raw.width, raw.height, ch, v)), + _ => Err(Error::Malformed(F, "unexpected sample format".into())), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 169f40c1..b0cdd5a0 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -19,6 +19,7 @@ mod convert; pub mod encode; pub mod exif; +pub mod gainmap; mod heif; pub mod icc; mod jpeg; diff --git a/crates/codecs/tests/fuzz.rs b/crates/codecs/tests/fuzz.rs index 15b236b0..bfee84df 100644 --- a/crates/codecs/tests/fuzz.rs +++ b/crates/codecs/tests/fuzz.rs @@ -36,6 +36,17 @@ fn try_all(bytes: &[u8]) { let _ = read_header(bytes); let _ = decode_thumbnail_with(bytes, &ThumbnailOptions { max_pixels: small_opts().max_pixels, ..ThumbnailOptions::new(16) }); let _ = lightcraft_codecs::icc::parse(bytes); + let _ = lightcraft_codecs::gainmap::read_jpeg(bytes); + let _ = lightcraft_codecs::gainmap::is_gain_map_jpeg(bytes); +} + +fn gain_map_jpeg(meta: &EncodeMeta) -> Vec { + use lightcraft_codecs::gainmap::{GainMapOptions, compute, encode_jpeg}; + let hdr: Vec<[f32; 3]> = (0..24 * 16).map(|i| [i as f32 * 0.02; 3]).collect(); + let sdr: Vec<[f32; 3]> = hdr.iter().map(|p| p.map(|v| v / (1.0 + v))).collect(); + let (g, m) = compute(&sdr, &hdr, 24, 16, [0.2126, 0.7152, 0.0722], &GainMapOptions::default()).unwrap(); + let base: Vec = sdr.iter().flat_map(|p| p.map(|v| (v * 255.0) as u8)).collect(); + encode_jpeg(&EncodeImage::new(24, 16, 3, Samples::U8(&base)), &g, &m, 80, ChromaSubsampling::S420, meta).unwrap() } #[test] @@ -91,6 +102,7 @@ fn seeds() -> &'static Vec> { encode_tiff(&e, TiffCompression::Lzw, &meta).unwrap(), encode_tiff(&EncodeImage::new(24, 16, 3, Samples::F32(&f32s)), TiffCompression::Deflate, &meta).unwrap(), encode_webp_lossless(&e, &meta).unwrap(), + gain_map_jpeg(&meta), ] }) } @@ -106,7 +118,7 @@ proptest! { } #[test] - fn mutated_valid_files(which in 0usize..6, flips in proptest::collection::vec((any::(), any::()), 1..12), cut in any::()) { + fn mutated_valid_files(which in 0usize..7, flips in proptest::collection::vec((any::(), any::()), 1..12), cut in any::()) { let mut b = seeds()[which].clone(); for (i, v) in flips { let i = i.index(b.len());