diff --git a/crates/heif/README.md b/crates/heif/README.md index 742a3a3f..07097346 100644 --- a/crates/heif/README.md +++ b/crates/heif/README.md @@ -26,6 +26,11 @@ let img = craft_heif::decode(&bytes, &craft_heif::Options::default())?; let thumb = craft_heif::decode_thumbnail(&bytes, 256, &Default::default())?; // Option ``` +Chroma is upsampled like libheif by default. +`craft_heif::Options { chroma: craft_heif::Chroma::Apple, ..Default::default() }` matches Apple's +decoder (and so Photoshop on macOS) instead; the two differ only next to sharp colour edges, where +Apple's blends the neighbouring column and, for 4:2:0, the neighbouring row. + Errors are `Error::Unsupported` (image sequences, overlays, 4:2:2/4:4:4, …), `Error::Limit` or `Error::Malformed`. It never panics: every call into heic-rs runs under `catch_unwind`, and a panic inside it (PhotoCraft's fuzzing found two in 0.1.1) becomes `Error::Malformed`. diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index b114c234..03fbe517 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -58,14 +58,26 @@ pub struct Options { pub max_pixels: u64, /// Apply the container's rotation, mirror and crop (`irot`/`imir`/`clap`). pub apply_transforms: bool, + /// Whose chroma upsampling to match. + pub chroma: Chroma, } impl Default for Options { fn default() -> Self { - Options { max_pixels: 1 << 28, apply_transforms: true } + Options { max_pixels: 1 << 28, apply_transforms: true, chroma: Chroma::Libheif } } } +/// How 4:2:0 and 4:2:2 chroma is brought to full resolution. The two differ only next to sharp +/// colour edges, where Apple's blends the neighbouring column and, for 4:2:0, the neighbouring row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Chroma { + /// libheif: each chroma sample covers its luma block, blended only after an odd `clap` crop. + Libheif, + /// Apple's decoder (and so Photoshop on macOS): a left-sited bilinear filter. + Apple, +} + /// How the file describes its colour: the code points of ITU-T H.273, from the `colr` `nclx` /// box, else the HEVC VUI, else the standard defaults (2 = unspecified, limited range). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -337,7 +349,11 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) } _ => return Err(Error::Limit(format!("the image canvas is {w}x{h}, beyond the {limit}-pixel limit"))), } - let mode = if options.apply_transforms && orient::odd_crop(w, h, &p.transforms)? { ycc::Upsampling::Bilinear } else { ycc::Upsampling::Nearest }; + let mode = match options.chroma { + Chroma::Apple => ycc::Upsampling::Sited, + Chroma::Libheif if options.apply_transforms && orient::odd_crop(w, h, &p.transforms)? => ycc::Upsampling::Bilinear, + Chroma::Libheif => ycc::Upsampling::Nearest, + }; let mut data = ycc::to_rgb16(&planes, signal, alpha_id.is_some(), mode) .ok_or_else(|| Error::Malformed("the decoded pictures don't cover the image".into()))?; if let Some(aux) = alpha_id { diff --git a/crates/heif/src/ycc.rs b/crates/heif/src/ycc.rs index 384d25f2..2e81eabf 100644 --- a/crates/heif/src/ycc.rs +++ b/crates/heif/src/ycc.rs @@ -113,6 +113,9 @@ pub enum Upsampling { Nearest, /// Centred bilinear: chroma sits between its two luma columns (and rows for 4:2:0). Bilinear, + /// heic-rs's left-sited bilinear: even columns copy their chroma sample, odd ones average + /// two, and 4:2:0 rows blend 3:1. Matches Apple's decoder. + Sited, } /// One chroma row brought to the full width `w` of luma row `y`. @@ -121,6 +124,14 @@ fn chroma_at(planes: &Planes<'_>, cr: bool, y: usize, w: usize, mode: Upsampling let (xs, ys) = (chroma.x_shift(), chroma.y_shift()); let (wf, hf) = planes.size(); let rows = chroma.chroma_size(wf as u32, hf as u32).1 as usize; + if mode == Upsampling::Sited { + let (r0, r1, w0) = heic_rs::upsample::row_pair(y, rows, chroma); + planes.chroma_row(cr, r0, buf)?; + planes.chroma_row(cr, r1, next)?; + let mut out = vec![0u16; w]; + heic_rs::upsample::row(&mut out, buf, next, w0, xs); + return Some(out); + } let cy = y >> ys; planes.chroma_row(cr, cy, buf)?; let last = buf.len().checked_sub(1)?; @@ -310,6 +321,20 @@ mod tests { assert_eq!(chroma_at(&p, true, 1, 4, Upsampling::Nearest, &mut buf, &mut next).unwrap(), [200, 200, 128, 128]); } + #[test] + fn sited_chroma_averages_odd_columns_and_blends_rows_3_1() { + let mut f = frame(4, 4, 8, ChromaFormat::Yuv420, 128, 128, 128); + f.cr = vec![200, 128, 128, 128]; + let p = Planes::Picture(&f); + let (mut buf, mut next) = (vec![0; 2], vec![0; 2]); + let rows: Vec> = (0..4).map(|y| chroma_at(&p, true, y, 4, Upsampling::Sited, &mut buf, &mut next).unwrap()).collect(); + // Column 0 is chroma column 0, column 1 the midpoint, columns 2–3 chroma column 1. + assert_eq!(rows[0], [200, 164, 128, 128]); + assert_eq!(rows[1], [182, 155, 128, 128]); + assert_eq!(rows[2], [146, 137, 128, 128]); + assert_eq!(rows[3], [128; 4]); + } + #[test] fn alpha_and_bad_planes() { let f = frame(2, 2, 8, ChromaFormat::Yuv420, 128, 128, 128);