diff --git a/CHANGELOG.md b/CHANGELOG.md index ab2f03d9..1687d1e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- `jxl-color`: Use better PQ to HLG method (#348). + ### Fixed - `jxl-color`: Fix generated `mluc` tag in ICC profile (#347). diff --git a/crates/jxl-color/src/convert.rs b/crates/jxl-color/src/convert.rs index d6c510c4..f9fd8bf7 100644 --- a/crates/jxl-color/src/convert.rs +++ b/crates/jxl-color/src/convert.rs @@ -113,6 +113,7 @@ impl ColorEncodingWithProfile { pub struct ColorTransformBuilder { detect_peak: bool, srgb_icc: bool, + from_pq: bool, } impl Default for ColorTransformBuilder { @@ -127,6 +128,7 @@ impl ColorTransformBuilder { Self { detect_peak: false, srgb_icc: false, + from_pq: false, } } @@ -140,6 +142,11 @@ impl ColorTransformBuilder { self } + pub fn from_pq(&mut self, from_pq: bool) -> &mut Self { + self.from_pq = from_pq; + self + } + pub fn build( self, from: &ColorEncodingWithProfile, @@ -193,6 +200,7 @@ impl ColorTransform { let ColorTransformBuilder { detect_peak, srgb_icc, + from_pq, } = builder; let connecting_tf = if srgb_icc { TransferFunction::Srgb @@ -456,7 +464,7 @@ impl ColorTransform { ); let luminances = [mat[3], mat[4], mat[5]]; - let hdr_params = HdrParams { + let mut hdr_params = HdrParams { luminances, intensity_target, min_nits, @@ -485,6 +493,40 @@ impl ColorTransform { } } + // PQ to HLG: tonemap to peak 1000 nits before applying inverse OOTF. + if from_pq && target_encoding.tf == TransferFunction::Hlg { + if !(999.0..=1001.0).contains(&hdr_params.intensity_target) { + if current_encoding.colour_space == ColourSpace::Grey { + ops.push(ColorTransformOp::ToneMapLumaRec2408 { + hdr_params, + target_display_luminance: 1000.0, + detect_peak: false, + }); + } else { + ops.push(ColorTransformOp::ToneMapRec2408 { + hdr_params, + target_display_luminance: 1000.0, + detect_peak: false, + }); + } + + hdr_params.intensity_target = 1000.0; + ops.push(ColorTransformOp::HlgInverseOotf(hdr_params)); + } + + if current_encoding.colour_space != ColourSpace::Grey + && current_encoding.rendering_intent == RenderingIntent::Perceptual + { + ops.push(ColorTransformOp::GamutMap { + luminances, + saturation_factor: 0.1, + }); + } + + // Skip inverse OOTF in transfer funcion conversion. + hdr_params.intensity_target = 300.0; + } + if target_encoding.tf != TransferFunction::Linear { ops.push(ColorTransformOp::TransferFunction { tf: target_encoding.tf, @@ -663,6 +705,7 @@ enum ColorTransformOp { hdr_params: HdrParams, inverse: bool, }, + HlgInverseOotf(HdrParams), ToneMapRec2408 { hdr_params: HdrParams, target_display_luminance: f32, @@ -734,6 +777,9 @@ impl std::fmt::Debug for ColorTransformOp { .field("target_display_luminance", target_display_luminance) .field("detect_peak", detect_peak) .finish(), + Self::HlgInverseOotf(hdr_params) => { + f.debug_tuple("HlgInverseOotf").field(hdr_params).finish() + } Self::GamutMap { luminances, saturation_factor, @@ -773,6 +819,7 @@ impl ColorTransformOp { .. } => Some(3), ColorTransformOp::TransferFunction { .. } => None, + ColorTransformOp::HlgInverseOotf(_) => Some(3), ColorTransformOp::ToneMapRec2408 { .. } => Some(3), ColorTransformOp::ToneMapLumaRec2408 { .. } => Some(1), ColorTransformOp::GamutMap { .. } => Some(3), @@ -793,6 +840,7 @@ impl ColorTransformOp { .. } => Some(3), ColorTransformOp::TransferFunction { .. } => None, + ColorTransformOp::HlgInverseOotf(_) => Some(3), ColorTransformOp::ToneMapRec2408 { .. } => Some(3), ColorTransformOp::ToneMapLumaRec2408 { .. } => Some(1), ColorTransformOp::GamutMap { .. } => Some(3), @@ -880,6 +928,18 @@ impl ColorTransformOp { ); num_input_channels } + Self::HlgInverseOotf(HdrParams { + luminances, + intensity_target, + .. + }) => { + // FIXME: allow grayscale images. + let [r, g, b, ..] = channels else { + unreachable!() + }; + tf::hlg_inverse_oo([r, g, b], *luminances, *intensity_target); + 3 + } Self::ToneMapRec2408 { hdr_params, target_display_luminance, diff --git a/crates/jxl-color/src/icc.rs b/crates/jxl-color/src/icc.rs index 09e35098..44acc948 100644 --- a/crates/jxl-color/src/icc.rs +++ b/crates/jxl-color/src/icc.rs @@ -9,7 +9,7 @@ mod parse; mod synthesize; pub use decode::{decode_icc, read_icc}; -pub use parse::is_hdr; +pub use parse::icc_tf; pub(crate) use parse::parse_icc; pub(crate) use parse::parse_icc_raw; pub use synthesize::colour_encoding_to_icc; diff --git a/crates/jxl-color/src/icc/parse.rs b/crates/jxl-color/src/icc/parse.rs index 4f89ca63..e212798c 100644 --- a/crates/jxl-color/src/icc/parse.rs +++ b/crates/jxl-color/src/icc/parse.rs @@ -555,15 +555,9 @@ pub(crate) fn parse_icc(profile: &[u8]) -> Result { } } -/// Checks whether a ICC profile has CICP tag with HDR transfer function. -pub fn is_hdr(profile: &[u8]) -> Result { - let profile = parse_icc_raw(profile)?; - for tag in profile.tags { - if &tag.tag == b"cicp" && matches!(tag.data.get(1), Some(16 | 18)) { - return Ok(true); - } - } - Ok(false) +#[inline] +pub fn icc_tf(profile: &[u8]) -> Option { + parse_icc(profile).ok().map(|profile| profile.tf) } #[cfg(test)] diff --git a/crates/jxl-color/src/tf.rs b/crates/jxl-color/src/tf.rs index 164e676e..e2f8fb58 100644 --- a/crates/jxl-color/src/tf.rs +++ b/crates/jxl-color/src/tf.rs @@ -123,6 +123,11 @@ pub fn hlg_inverse_oo( [lr, lg, lb]: [f32; 3], intensity_target: f32, ) { + // System gamma results to ~1 in this range. + if (295.0..=305.0).contains(&intensity_target) { + return; + } + let gamma = 1.2f32 * 1.111f32.powf((intensity_target / 1e3).log2()); // 1/g - 1 let exp = (1.0 - gamma) / gamma; diff --git a/crates/jxl-oxide/src/lib.rs b/crates/jxl-oxide/src/lib.rs index 50386740..84a30320 100644 --- a/crates/jxl-oxide/src/lib.rs +++ b/crates/jxl-oxide/src/lib.rs @@ -622,18 +622,12 @@ impl JxlImage { } } - pub fn is_hdr(&self) -> bool { - use jxl_color::TransferFunction; - - match &self.image_header.metadata.colour_encoding { - color::ColourEncoding::Enum(e) => { - matches!(e.tf, TransferFunction::Pq | TransferFunction::Hlg) - } - color::ColourEncoding::IccProfile(_) => { - let icc = self.ctx.embedded_icc().unwrap(); - jxl_color::icc::is_hdr(icc).unwrap_or(false) - } - } + pub fn hdr_type(&self) -> Option { + self.ctx.suggested_hdr_tf().and_then(|tf| match tf { + jxl_color::TransferFunction::Pq => Some(HdrType::Pq), + jxl_color::TransferFunction::Hlg => Some(HdrType::Hlg), + _ => None, + }) } /// Requests the decoder to render in specific color encoding, described by an ICC profile. @@ -876,6 +870,15 @@ impl PixelFormat { } } +/// HDR transfer function type, returned by [`JxlImage::hdr_type`]. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum HdrType { + /// Perceptual quantizer. + Pq, + /// Hybrid log-gamma. + Hlg, +} + /// The result of loading the keyframe. #[derive(Debug)] pub enum LoadResult { diff --git a/crates/jxl-render/src/lib.rs b/crates/jxl-render/src/lib.rs index ddf9dd62..85e24784 100644 --- a/crates/jxl-render/src/lib.rs +++ b/crates/jxl-render/src/lib.rs @@ -170,6 +170,21 @@ impl RenderContext { self.cms = Box::new(cms); } + pub fn suggested_hdr_tf(&self) -> Option { + let tf = match &self.image_header.metadata.colour_encoding { + jxl_color::ColourEncoding::Enum(e) => e.tf, + jxl_color::ColourEncoding::IccProfile(_) => { + let icc = self.embedded_icc().unwrap(); + jxl_color::icc::icc_tf(icc)? + } + }; + + match tf { + jxl_color::TransferFunction::Pq | jxl_color::TransferFunction::Hlg => Some(tf), + _ => None, + } + } + #[inline] pub fn request_color_encoding(&mut self, encoding: ColorEncodingWithProfile) { self.requested_color_encoding = encoding; @@ -795,6 +810,7 @@ impl RenderContext { let mut transform = jxl_color::ColorTransform::builder(); transform.set_srgb_icc(!self.cms.supports_linear_tf()); + transform.from_pq(self.suggested_hdr_tf() == Some(jxl_color::TransferFunction::Pq)); let transform = transform.build( &frame_color_encoding, &self.requested_color_encoding,