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;
#[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)?;
let sdr = decode_item_with(&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 max = f32::from(sdr.max_value());
let lut: Vec<f32> = (0..=sdr.max_value())
.map(|v| srgb_to_linear(f32::from(v) / max))
.collect();
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)
});
data.extend(px[..3].iter().map(|&v| lut[usize::from(v)] * factor));
}
Ok(HdrImage {
width: w,
height: h,
headroom: gain.as_ref().map_or(1.0, |g| 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(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,
}))
}
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 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]);
}
}