From 2af26888d708a585fd1c31bbb3618ea387c333ad Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Wed, 8 Jul 2026 13:26:30 +0530 Subject: [PATCH 1/2] heic color management improvements --- Cargo.toml | 1 + src/isobmff.rs | 38 +++++++++----- src/lib.rs | 132 ++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 136 insertions(+), 35 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ecfa006..22210f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ brotli = "8.0" flate2 = "1.1" image = { version = "0.25", default-features = false, features = ["png"], optional = true } libc = "0.2" +moxcms = "0.8.1" png = "0.18" rav1d = { git = "https://github.com/ente-io/rav1d.git", branch = "arm32_stable", default-features = false, features = [ "bitdepth_8", diff --git a/src/isobmff.rs b/src/isobmff.rs index e5b74d1..c879c36 100644 --- a/src/isobmff.rs +++ b/src/isobmff.rs @@ -4587,28 +4587,33 @@ fn resolve_grid_tile_hvcc_colr_and_transforms( Ok((hvcc, colr, transforms)) } -/// ICC colour profile for the primary HEIC item, mirroring libheif: the -/// primary item's own `colr` ICC wins; a `grid` primary without one inherits -/// the first tile's ICC (libheif/libheif/context.cc). -pub fn primary_heic_icc_profile(input: &[u8]) -> Option> { +/// Colour properties for the primary HEIC item, mirroring libheif: the primary +/// item's own `colr` properties win; a `grid` primary without one inherits the +/// first tile's colour properties (libheif/libheif/context.cc). +pub fn primary_heic_color_properties(input: &[u8]) -> Option { let (_meta, resolved) = resolve_primary_heic_item_graph(input).ok()?; - let mut icc = None; + let mut colr = PrimaryItemColorProperties::default(); for property in &resolved.primary_item.properties { if property.property.header.box_type.as_bytes() != COLR_BOX_TYPE { continue; } - if let Ok(parsed_colr) = property.property.parse_colr() - && let ColorInformation::Icc(profile) = parsed_colr.information - { - icc = Some(profile.profile); + if let Ok(parsed_colr) = property.property.parse_colr() { + match parsed_colr.information { + ColorInformation::Nclx(profile) => { + colr.nclx = Some(profile); + } + ColorInformation::Icc(profile) => { + colr.icc = Some(profile); + } + } } } - if icc.is_some() { - return icc; + if colr.icc.is_some() || colr.nclx.is_some() { + return Some(colr); } - // Grid primary without its own ICC: inherit the first tile's. + // Grid primary without its own colour properties: inherit the first tile's. let item_type = resolved.primary_item.item_info.item_type?; if item_type.as_bytes() != GRID_ITEM_TYPE { return None; @@ -4632,7 +4637,14 @@ pub fn primary_heic_icc_profile(input: &[u8]) -> Option> { &flattened_properties, ) .ok()?; - tile_colr.icc.map(|profile| profile.profile) + Some(tile_colr) +} + +/// ICC colour profile for the primary HEIC item, mirroring libheif: the +/// primary item's own `colr` ICC wins; a `grid` primary without one inherits +/// the first tile's ICC (libheif/libheif/context.cc). +pub fn primary_heic_icc_profile(input: &[u8]) -> Option> { + primary_heic_color_properties(input).and_then(|colr| colr.icc.map(|profile| profile.profile)) } fn resolve_primary_heic_item_graph<'a>( diff --git a/src/lib.rs b/src/lib.rs index 5ccbe24..d8d3ff5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,9 @@ extern crate alloc; use brotli::Decompressor as BrotliDecompressor; use flate2::read::{DeflateDecoder, ZlibDecoder}; use heic_decoder::DecodedFrame as HeicFrame; +use moxcms::{ + CicpColorPrimaries, CicpProfile, ColorProfile, MatrixCoefficients, TransferCharacteristics, +}; use rav1d::include::dav1d::data::Dav1dData; use rav1d::include::dav1d::dav1d::{Dav1dContext, Dav1dSettings}; use rav1d::include::dav1d::headers::{ @@ -2406,12 +2409,14 @@ fn decode_primary_uncompressed_to_image_internal( }); } + let icc_profile = icc_profile_from_color_properties(&properties.colr); + Ok(DecodedUncompressedImage { width, height, bit_depth: output_bit_depth, rgba, - icc_profile: properties.colr.icc.map(|profile| profile.profile), + icc_profile, }) } @@ -6426,20 +6431,26 @@ fn primary_icc_profile_from_resolved_avif_graph( // Provenance: primary-item colr extraction follows libheif item-property // traversal in libheif/libheif/context.cc, with colr payload parsing from // libheif/libheif/nclx.cc:Box_colr::parse. - let mut icc_profile = None; + let mut colr = isobmff::PrimaryItemColorProperties::default(); for property in &resolved.primary_item.properties { if property.property.header.box_type.as_bytes() != *b"colr" { continue; } // Tolerate individually malformed colr boxes (libheif parses each // independently) instead of discarding an already-found profile. - if let Ok(parsed_colr) = property.property.parse_colr() - && let isobmff::ColorInformation::Icc(profile) = parsed_colr.information - { - icc_profile = Some(profile.profile); + if let Ok(parsed_colr) = property.property.parse_colr() { + match parsed_colr.information { + isobmff::ColorInformation::Nclx(profile) => { + colr.nclx = Some(profile); + } + isobmff::ColorInformation::Icc(profile) => { + colr.icc = Some(profile); + } + } } } - icc_profile + + icc_profile_from_color_properties(&colr) } fn primary_icc_profile_from_heic(input: &[u8]) -> Option> { @@ -6447,7 +6458,45 @@ fn primary_icc_profile_from_heic(input: &[u8]) -> Option> { // traversal in libheif/libheif/context.cc (including the grid item's // first-tile ICC inheritance), with colr payload parsing from // libheif/libheif/nclx.cc:Box_colr::parse. - isobmff::primary_heic_icc_profile(input) + isobmff::primary_heic_color_properties(input) + .and_then(|colr| icc_profile_from_color_properties(&colr)) +} + +fn icc_profile_from_color_properties( + colr: &isobmff::PrimaryItemColorProperties, +) -> Option> { + if let Some(profile) = &colr.icc { + return Some(profile.profile.clone()); + } + + colr.nclx.as_ref().and_then(nclx_to_icc_profile) +} + +fn nclx_to_icc_profile(nclx: &isobmff::NclxColorProfile) -> Option> { + if nclx_is_undefined(nclx) { + return None; + } + + let cicp = CicpProfile { + color_primaries: CicpColorPrimaries::try_from(u8::try_from(nclx.colour_primaries).ok()?) + .ok()?, + transfer_characteristics: TransferCharacteristics::try_from( + u8::try_from(nclx.transfer_characteristics).ok()?, + ) + .ok()?, + matrix_coefficients: MatrixCoefficients::try_from( + u8::try_from(nclx.matrix_coefficients).ok()?, + ) + .ok()?, + full_range: nclx.full_range_flag, + }; + + let profile = ColorProfile::new_from_cicp(cicp); + if !profile.is_matrix_shaper() { + return None; + } + + profile.encode().ok() } fn ycbcr_range_from_primary_colr(colr: &isobmff::PrimaryItemColorProperties) -> YCbCrRange { @@ -6472,13 +6521,16 @@ fn ycbcr_range_override_from_primary_colr( // stream metadata remains in effect (libheif/libheif/color-conversion/ // yuv2rgb.cc:Op_YCbCr_to_RGB::convert_colorspace and // libheif/libheif/plugins/decoder_libde265.cc color-profile population). - colr.nclx.as_ref().filter(|nclx| !nclx_is_undefined(nclx)).map(|nclx| { - if nclx.full_range_flag { - YCbCrRange::Full - } else { - YCbCrRange::Limited - } - }) + colr.nclx + .as_ref() + .filter(|nclx| !nclx_is_undefined(nclx)) + .map(|nclx| { + if nclx.full_range_flag { + YCbCrRange::Full + } else { + YCbCrRange::Limited + } + }) } fn ycbcr_matrix_from_primary_colr( @@ -6496,9 +6548,9 @@ fn ycbcr_matrix_override_from_primary_colr( .as_ref() .filter(|nclx| !nclx_is_undefined(nclx)) .map(|nclx| YCbCrMatrixCoefficients { - matrix_coefficients: nclx.matrix_coefficients, - colour_primaries: nclx.colour_primaries, - }) + matrix_coefficients: nclx.matrix_coefficients, + colour_primaries: nclx.colour_primaries, + }) } #[derive(Clone, Copy)] @@ -7745,8 +7797,7 @@ fn convert_avif_to_rgba8(decoded: &DecodedAvifImage) -> Result, DecodeAv decoded.layout == AvifPixelLayout::Yuv420, ); // 8-bit grayscale: libheif's Op_mono_to_RGB24_32 copies Y verbatim. - let mono_verbatim = - matches!(chroma, ChromaPlanesU8::Monochrome) && decoded.bit_depth == 8; + let mono_verbatim = matches!(chroma, ChromaPlanesU8::Monochrome) && decoded.bit_depth == 8; for y in 0..height { let row_start = y * width; @@ -8751,8 +8802,8 @@ fn scale_sample_to_u16(sample: u16, bit_depth: u8) -> u16 { // the low bits only and vanishes in the harness's 8-bit pixel comparison. let shift = 16 - u32::from(bit_depth); let v = u32::from(sample); - ((v << shift) | (v >> (u32::from(bit_depth).saturating_sub(shift)))) - .min(u32::from(u16::MAX)) as u16 + ((v << shift) | (v >> (u32::from(bit_depth).saturating_sub(shift)))).min(u32::from(u16::MAX)) + as u16 } #[derive(Default)] @@ -9085,3 +9136,40 @@ fn copy_plane_samples( Ok(AvifPlaneSamples::U16(out)) } + +#[cfg(test)] +mod tests { + use moxcms::{CicpColorPrimaries, ColorProfile, DataColorSpace, TransferCharacteristics}; + + use super::{isobmff, nclx_to_icc_profile}; + + #[test] + fn synthesizes_icc_profile_from_display_p3_nclx() { + let nclx = isobmff::NclxColorProfile { + colour_primaries: 12, + transfer_characteristics: 13, + matrix_coefficients: 1, + full_range_flag: true, + }; + + let icc = nclx_to_icc_profile(&nclx).expect("expected synthesized ICC"); + let parsed = ColorProfile::new_from_slice(&icc).expect("synthesized ICC should parse"); + + assert_eq!(parsed.color_space, DataColorSpace::Rgb); + let cicp = parsed.cicp.expect("synthesized ICC should carry CICP"); + assert_eq!(cicp.color_primaries, CicpColorPrimaries::Smpte432); + assert_eq!(cicp.transfer_characteristics, TransferCharacteristics::Srgb); + } + + #[test] + fn skips_undefined_nclx_profile() { + let nclx = isobmff::NclxColorProfile { + colour_primaries: 2, + transfer_characteristics: 2, + matrix_coefficients: 2, + full_range_flag: true, + }; + + assert!(nclx_to_icc_profile(&nclx).is_none()); + } +} From a36d10831b98be3aa37db785eba41a0a9b9e3efc Mon Sep 17 00:00:00 2001 From: laurenspriem Date: Wed, 8 Jul 2026 13:34:32 +0530 Subject: [PATCH 2/2] Fix HEIC grid ICC fallback --- src/isobmff.rs | 123 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 10 deletions(-) diff --git a/src/isobmff.rs b/src/isobmff.rs index c879c36..9a81276 100644 --- a/src/isobmff.rs +++ b/src/isobmff.rs @@ -4593,7 +4593,7 @@ fn resolve_grid_tile_hvcc_colr_and_transforms( pub fn primary_heic_color_properties(input: &[u8]) -> Option { let (_meta, resolved) = resolve_primary_heic_item_graph(input).ok()?; - let mut colr = PrimaryItemColorProperties::default(); + let mut primary_colr = PrimaryItemColorProperties::default(); for property in &resolved.primary_item.properties { if property.property.header.box_type.as_bytes() != COLR_BOX_TYPE { continue; @@ -4601,22 +4601,26 @@ pub fn primary_heic_color_properties(input: &[u8]) -> Option { - colr.nclx = Some(profile); + primary_colr.nclx = Some(profile); } ColorInformation::Icc(profile) => { - colr.icc = Some(profile); + primary_colr.icc = Some(profile); } } } } - if colr.icc.is_some() || colr.nclx.is_some() { - return Some(colr); + if primary_colr.icc.is_some() { + return Some(primary_colr); } - // Grid primary without its own colour properties: inherit the first tile's. - let item_type = resolved.primary_item.item_info.item_type?; - if item_type.as_bytes() != GRID_ITEM_TYPE { - return None; + let is_grid_primary = resolved + .primary_item + .item_info + .item_type + .map(|item_type| item_type.as_bytes() == GRID_ITEM_TYPE) + .unwrap_or(false); + if !is_grid_primary { + return primary_colr.nclx.is_some().then_some(primary_colr); } let tile_item_id = resolved .primary_item @@ -4637,7 +4641,33 @@ pub fn primary_heic_color_properties(input: &[u8]) -> Option, +) -> Option { + if primary_colr.icc.is_some() { + return Some(primary_colr); + } + + let Some(tile_colr) = tile_colr else { + return primary_colr.nclx.is_some().then_some(primary_colr); + }; + + if tile_colr.icc.is_some() { + return Some(PrimaryItemColorProperties { + nclx: primary_colr.nclx.or(tile_colr.nclx), + icc: tile_colr.icc, + }); + } + + if primary_colr.nclx.is_some() { + return Some(primary_colr); + } + + tile_colr.nclx.is_some().then_some(tile_colr) } /// ICC colour profile for the primary HEIC item, mirroring libheif: the @@ -7033,3 +7063,76 @@ fn read_u64_be(input: &[u8]) -> u64 { input[0], input[1], input[2], input[3], input[4], input[5], input[6], input[7], ]) } + +#[cfg(test)] +mod tests { + use super::{ + FourCc, IccColorProfile, NclxColorProfile, PrimaryItemColorProperties, + merge_primary_and_grid_tile_color_properties, + }; + + #[test] + fn grid_primary_nclx_preserves_first_tile_icc_fallback() { + let primary = PrimaryItemColorProperties { + nclx: Some(nclx_profile(12)), + icc: None, + }; + let tile = PrimaryItemColorProperties { + nclx: None, + icc: Some(icc_profile(vec![1, 2, 3])), + }; + + let merged = merge_primary_and_grid_tile_color_properties(primary, Some(tile)) + .expect("expected merged color properties"); + + assert_eq!(merged.icc.unwrap().profile, vec![1, 2, 3]); + assert_eq!(merged.nclx.unwrap().colour_primaries, 12); + } + + #[test] + fn grid_primary_icc_wins_over_first_tile_icc() { + let primary = PrimaryItemColorProperties { + nclx: None, + icc: Some(icc_profile(vec![4, 5, 6])), + }; + let tile = PrimaryItemColorProperties { + nclx: None, + icc: Some(icc_profile(vec![1, 2, 3])), + }; + + let merged = merge_primary_and_grid_tile_color_properties(primary, Some(tile)) + .expect("expected merged color properties"); + + assert_eq!(merged.icc.unwrap().profile, vec![4, 5, 6]); + } + + #[test] + fn grid_primary_nclx_survives_without_first_tile_icc() { + let primary = PrimaryItemColorProperties { + nclx: Some(nclx_profile(12)), + icc: None, + }; + + let merged = merge_primary_and_grid_tile_color_properties(primary, None) + .expect("expected primary nclx color properties"); + + assert_eq!(merged.nclx.unwrap().colour_primaries, 12); + assert!(merged.icc.is_none()); + } + + fn nclx_profile(colour_primaries: u16) -> NclxColorProfile { + NclxColorProfile { + colour_primaries, + transfer_characteristics: 13, + matrix_coefficients: 1, + full_range_flag: true, + } + } + + fn icc_profile(profile: Vec) -> IccColorProfile { + IccColorProfile { + profile_type: FourCc::new(*b"prof"), + profile, + } + } +}