mod iso21496;
pub(crate) use iso21496::payload as tmap_payload;
use heifer_isobmff::HeifFile;
use heifer_isobmff::boxes::Property;
use crate::{Error, Exif, Options, decode_item_with};
pub const APPLE_GAIN_MAP: &str = "urn:com:apple:photo:2020:aux:hdrgainmap";
pub const APPLE_MAX_HEADROOM: f32 = 4.0;
const REFERENCE_WHITE: f32 = 203.0;
const PQ_PEAK: f32 = 10_000.0;
const HLG_PEAK: f32 = 1_000.0;
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Transfer {
Sdr,
Pq,
Hlg,
}
impl Transfer {
pub(super) fn from_nclx(transfer: Option<u16>) -> Self {
match transfer {
Some(16) => Self::Pq,
Some(18) => Self::Hlg,
_ => Self::Sdr,
}
}
fn headroom(self) -> f32 {
match self {
Self::Sdr => 1.0,
Self::Pq => PQ_PEAK / REFERENCE_WHITE,
Self::Hlg => HLG_PEAK / REFERENCE_WHITE,
}
}
}
pub(super) struct Linearizer {
transfer: Transfer,
max: f32,
lut: Vec<f32>,
}
impl Linearizer {
pub(super) fn new(transfer: Transfer, max: u16) -> Self {
let lut = match transfer {
Transfer::Sdr => (0..=max)
.map(|v| srgb_to_linear(f32::from(v) / f32::from(max)))
.collect(),
Transfer::Pq | Transfer::Hlg => Vec::new(),
};
Self {
transfer,
max: f32::from(max),
lut,
}
}
pub(super) fn apply(&self, rgb: [u16; 3]) -> [f32; 3] {
match self.transfer {
Transfer::Sdr => [
self.lut[usize::from(rgb[0])],
self.lut[usize::from(rgb[1])],
self.lut[usize::from(rgb[2])],
],
Transfer::Pq => [
pq_to_linear(f32::from(rgb[0]) / self.max),
pq_to_linear(f32::from(rgb[1]) / self.max),
pq_to_linear(f32::from(rgb[2]) / self.max),
],
Transfer::Hlg => hlg_to_linear([
f32::from(rgb[0]) / self.max,
f32::from(rgb[1]) / self.max,
f32::from(rgb[2]) / self.max,
]),
}
}
}
fn pq_to_linear(value: f32) -> f32 {
const M1: f32 = 2610.0 / 16384.0;
const M2: f32 = 2523.0 / 4096.0 * 128.0;
const C1: f32 = 3424.0 / 4096.0;
const C2: f32 = 2413.0 / 4096.0 * 32.0;
const C3: f32 = 2392.0 / 4096.0 * 32.0;
let value = value.clamp(0.0, 1.0).powf(1.0 / M2);
let numerator = (value - C1).max(0.0);
let denominator = C2 - C3 * value;
(numerator / denominator).powf(1.0 / M1) * (PQ_PEAK / REFERENCE_WHITE)
}
fn hlg_to_linear(rgb: [f32; 3]) -> [f32; 3] {
const A: f32 = 0.178_832_77;
const B: f32 = 0.284_668_92;
const C: f32 = 0.559_910_7;
let scene = rgb.map(|value| {
let value = value.clamp(0.0, 1.0);
if value <= 0.5 {
value * value / 3.0
} else {
(((value - C) / A).exp() + B) / 12.0
}
});
let luma = 0.2627 * scene[0] + 0.6780 * scene[1] + 0.0593 * scene[2];
let scale = luma.powf(0.2) * (HLG_PEAK / REFERENCE_WHITE);
scene.map(|value| value * scale)
}
#[derive(Debug, Clone, PartialEq)]
pub struct GainMap {
pub width: u32,
pub height: u32,
pub data: Vec<f32>,
pub headroom: f32,
}
impl GainMap {
pub fn sample(&self, x: u32, y: u32, width: u32, height: u32) -> f32 {
let coord = |p: u32, out: u32, size: u32| {
let f =
((p as f32 + 0.5) * size as f32 / out as f32 - 0.5).clamp(0.0, (size - 1) as f32);
let i = (f as u32).min(size - 1);
(i, (i + 1).min(size - 1), f - i as f32)
};
let (x0, x1, fx) = coord(x, width, self.width);
let (y0, y1, fy) = coord(y, height, self.height);
let at = |x: u32, y: u32| self.data[(y * self.width + x) as usize];
let top = at(x0, y0) * (1.0 - fx) + at(x1, y0) * fx;
let bottom = at(x0, y1) * (1.0 - fx) + at(x1, y1) * fx;
top * (1.0 - fy) + bottom * fy
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct HdrImage {
pub width: u32,
pub height: u32,
pub headroom: f32,
pub data: Vec<f32>,
}
pub fn read_gain_map(bytes: &[u8]) -> Result<Option<GainMap>, Error> {
read_gain_map_with_options(bytes, &Options::default())
}
pub fn read_gain_map_with_options(
bytes: &[u8],
options: &Options,
) -> Result<Option<GainMap>, Error> {
gain_map(&HeifFile::parse(bytes)?, options)
}
pub fn decode_hdr(bytes: &[u8]) -> Result<HdrImage, Error> {
decode_hdr_with_options(bytes, &Options::default(), APPLE_MAX_HEADROOM)
}
pub fn decode_hdr_with_options(
bytes: &[u8],
options: &Options,
max_headroom: f32,
) -> Result<HdrImage, Error> {
let file = HeifFile::parse(bytes)?;
if let Some(hdr) = iso21496::decode(&file, options, max_headroom)? {
return Ok(hdr);
}
let transfer = Transfer::from_nclx(crate::nclx(&file, file.primary_id)?.map(|(_, t)| t));
let sdr = decode_item_with::<u16>(&file, file.primary_id, 0, options)?;
let gain = gain_map(&file, options)?.map(|mut g| {
g.headroom = g.headroom.min(max_headroom).max(1.0);
g
});
let linearizer = Linearizer::new(transfer, sdr.max_value());
let (w, h) = (sdr.width, sdr.height);
let mut data = Vec::with_capacity(w as usize * h as usize * 3);
for (i, px) in sdr.data.chunks_exact(4).enumerate() {
let factor = gain.as_ref().map_or(1.0, |g| {
let (x, y) = (i as u32 % w, i as u32 / w);
1.0 + (g.headroom - 1.0) * g.sample(x, y, w, h)
});
let linear = linearizer.apply([px[0], px[1], px[2]]);
data.extend(linear.map(|value| value * factor));
}
let base_headroom = transfer.headroom();
Ok(HdrImage {
width: w,
height: h,
headroom: gain
.as_ref()
.map_or(base_headroom, |g| base_headroom * g.headroom),
data,
})
}
fn gain_map(file: &HeifFile<'_>, options: &Options) -> Result<Option<GainMap>, Error> {
let primary = file.primary_id;
let mut aux_items = file.referencing_items(primary, b"auxl").into_iter();
let found = aux_items.try_fold(None, |found, aux| -> Result<_, Error> {
if found.is_some() {
return Ok(found);
}
let is_gain_map = file
.item_properties(aux)?
.any(|p| matches!(p, Property::AuxiliaryType(t) if t == APPLE_GAIN_MAP));
Ok(is_gain_map.then_some(aux))
})?;
let Some(aux) = found else {
return Ok(None);
};
let has_version = file
.xmp(aux)?
.is_some_and(|x| x.windows(17).any(|w| w == b"HDRGainMapVersion"));
let exif = file.exif(primary)?;
let headroom = exif
.as_deref()
.and_then(Exif::parse)
.and_then(|e| e.apple_hdr_headroom());
let (true, Some(headroom)) = (has_version, headroom) else {
return Ok(None);
};
let image = decode_item_with::<u16>(file, aux, 1, options)?;
let max = f32::from(image.max_value());
let data = image
.data
.chunks_exact(4)
.map(|p| (f32::from(p[0]) / max).powf(2.2))
.collect();
Ok(Some(GainMap {
width: image.width,
height: image.height,
data,
headroom,
}))
}
pub(crate) fn srgb_to_linear(v: f32) -> f32 {
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transfer_functions() {
assert!((srgb_to_linear(1.0) - 1.0).abs() < 1e-6);
assert!((srgb_to_linear(0.5) - 0.214).abs() < 1e-3);
}
#[test]
fn pq_and_hlg_linearization() {
assert_eq!(pq_to_linear(0.0), 0.0);
assert!((pq_to_linear(0.58) - 1.0).abs() < 0.01);
assert!((pq_to_linear(1.0) - 49.261).abs() < 0.01);
assert_eq!(hlg_to_linear([0.0; 3]), [0.0; 3]);
assert!((hlg_to_linear([0.75; 3])[0] - 1.0).abs() < 0.01);
assert!((hlg_to_linear([1.0; 3])[0] - 4.926).abs() < 0.01);
}
#[test]
fn linearizer_scales_by_coded_depth() {
let pq = Linearizer::new(Transfer::Pq, 1023);
assert_eq!(pq.apply([0, 0, 0]), [0.0; 3]);
assert!((pq.apply([1023, 0, 0])[0] - 49.261).abs() < 0.02);
let sdr = Linearizer::new(Transfer::Sdr, 255);
assert!((sdr.apply([255, 0, 0])[0] - 1.0).abs() < 1e-6);
}
#[test]
fn bilinear_sampling() {
let g = GainMap {
width: 2,
height: 1,
data: vec![0.0, 1.0],
headroom: 2.0,
};
let row: Vec<f32> = (0..4).map(|x| g.sample(x, 0, 4, 1)).collect();
assert_eq!(row, vec![0.0, 0.25, 0.75, 1.0]);
}
}