1use crate::adapt::{convert_buffer_with_anchor, convert_into_with_anchor};
11use crate::error::ConvertError;
12use crate::{PixelBuffer, PixelDescriptor, PixelFormat, PixelSlice, TransferFunction};
13use alloc::sync::Arc;
14use whereat::At;
15use zenpixels::{Cicp, ColorContext};
16
17pub use zenpixels::hdr::{ContentLightLevel, MasteringDisplay};
19use zenpixels::hdr::DiffuseWhite;
23
24#[deprecated(
43 since = "0.2.14",
44 note = "redundant with zencodec::Metadata and frozen-shaped; carry ContentLightLevel / MasteringDisplay directly. Removal queued for 0.3.0."
45)]
46#[derive(Clone, Copy, Debug, PartialEq)]
47pub struct HdrMetadata {
48 pub transfer: TransferFunction,
50 pub content_light_level: Option<ContentLightLevel>,
52 pub mastering_display: Option<MasteringDisplay>,
54}
55
56#[allow(deprecated)]
57impl HdrMetadata {
58 #[must_use]
60 pub fn is_hdr(&self) -> bool {
61 matches!(self.transfer, TransferFunction::Pq | TransferFunction::Hlg)
62 }
63
64 #[must_use]
66 pub fn is_sdr(&self) -> bool {
67 !self.is_hdr()
68 }
69
70 pub fn hdr10(cll: ContentLightLevel) -> Self {
77 Self {
78 transfer: TransferFunction::Pq,
79 content_light_level: Some(cll),
80 mastering_display: Some(MasteringDisplay::HDR10_REFERENCE),
81 }
82 }
83
84 pub fn hlg() -> Self {
86 Self {
87 transfer: TransferFunction::Hlg,
88 content_light_level: None,
89 mastering_display: None,
90 }
91 }
92}
93
94#[inline]
113#[must_use]
114pub fn reinhard_tonemap(v: f32) -> f32 {
115 let v = v.max(0.0);
117 if v == f32::INFINITY {
118 return 1.0;
119 }
120 v / (1.0 + v)
121}
122
123#[inline]
129#[must_use]
130pub fn reinhard_inverse(v: f32) -> f32 {
131 let v = v.max(0.0);
132 if v >= 1.0 {
133 return f32::MAX;
134 }
135 v / (1.0 - v)
136}
137
138#[cfg(feature = "std")]
146#[inline]
147#[must_use]
148#[allow(clippy::manual_clamp)]
151pub fn exposure_tonemap(v: f32, exposure: f32) -> f32 {
152 (v * 2.0f32.powf(exposure)).max(0.0).min(1.0)
153}
154
155fn quantize_setup(
166 px: &PixelSlice<'_>,
167 target: PixelDescriptor,
168) -> Result<(PixelDescriptor, DiffuseWhite, u32, u32), At<ConvertError>> {
169 let diffuse_white = px
170 .color_context()
171 .and_then(|c| c.diffuse_white)
172 .unwrap_or(DiffuseWhite::BT2408);
173 let desc = px.descriptor();
174 let src = match desc.pixel_format() {
175 PixelFormat::RgbF32 => PixelDescriptor::RGBF32_LINEAR,
176 PixelFormat::RgbaF32 => PixelDescriptor::RGBAF32_LINEAR,
177 _ => return Err(whereat::at!(ConvertError::NoMatch { source: desc })),
178 }
179 .with_primaries(target.primaries);
180 if desc.transfer != TransferFunction::Linear {
181 return Err(whereat::at!(ConvertError::UnsupportedTransfer {
182 from: desc.transfer,
183 to: TransferFunction::Linear,
184 }));
185 }
186 if target.transfer != TransferFunction::Pq {
188 return Err(whereat::at!(ConvertError::NoPath {
189 from: desc,
190 to: target,
191 }));
192 }
193 let w = px.width();
194 let h = px.rows();
195 if w == 0 || h == 0 {
196 return Err(whereat::at!(ConvertError::InvalidWidth(w)));
197 }
198 Ok((src, diffuse_white, w, h))
199}
200
201pub fn quantize_to(
240 px: PixelSlice<'_>,
241 target: PixelDescriptor,
242) -> Result<PixelBuffer, At<ConvertError>> {
243 let (src, diffuse_white, w, h) = quantize_setup(&px, target)?;
249 let out = convert_buffer_with_anchor(
250 px.as_strided_bytes(),
251 w,
252 h,
253 px.stride(),
254 src,
255 target,
256 diffuse_white,
257 )?;
258 let context = match Cicp::from_descriptor(&target) {
264 Some(cicp) => ColorContext::from_cicp(cicp),
265 None => ColorContext::default(),
266 }
267 .with_diffuse_white(diffuse_white);
268 Ok(out.with_color_context(Arc::new(context)))
269}
270
271#[allow(dead_code)]
297pub(crate) fn quantize_into(
298 px: PixelSlice<'_>,
299 target: PixelDescriptor,
300 dst: &mut [u8],
301 dst_stride: usize,
302) -> Result<(), At<ConvertError>> {
303 let (src, diffuse_white, w, h) = quantize_setup(&px, target)?;
304 convert_into_with_anchor(
305 px.as_strided_bytes(),
306 w,
307 h,
308 px.stride(),
309 src,
310 target,
311 diffuse_white,
312 dst,
313 dst_stride,
314 )
315}
316
317#[cfg(test)]
318#[allow(deprecated)]
320mod tests {
321 use super::*;
322
323 #[test]
324 fn reinhard_boundaries() {
325 assert_eq!(reinhard_tonemap(0.0), 0.0);
326 assert!((reinhard_tonemap(1.0) - 0.5).abs() < 1e-6);
327 assert!(reinhard_tonemap(1000.0) > 0.99);
328 assert!(reinhard_tonemap(1000.0) < 1.0);
329 }
330
331 #[test]
332 fn reinhard_roundtrip() {
333 for &v in &[0.0, 0.1, 0.5, 1.0, 2.0, 10.0, 100.0] {
334 let mapped = reinhard_tonemap(v);
335 let unmapped = reinhard_inverse(mapped);
336 assert!(
337 (unmapped - v).abs() < 1e-4,
338 "Reinhard roundtrip failed for {v}: got {unmapped}"
339 );
340 }
341 }
342
343 #[test]
344 fn hdr_metadata_is_hdr() {
345 assert!(HdrMetadata::hdr10(ContentLightLevel::default()).is_hdr());
346 assert!(HdrMetadata::hlg().is_hdr());
347 assert!(
348 HdrMetadata {
349 transfer: TransferFunction::Srgb,
350 content_light_level: None,
351 mastering_display: None,
352 }
353 .is_sdr()
354 );
355 }
356
357 #[test]
358 fn hdr10_constructor() {
359 let cll = ContentLightLevel::new(4000, 1000);
360 let meta = HdrMetadata::hdr10(cll);
361 assert!(meta.is_hdr());
362 assert_eq!(meta.transfer, TransferFunction::Pq);
363 assert_eq!(meta.content_light_level, Some(cll));
364 assert!(meta.mastering_display.is_some());
365 }
366
367 #[test]
368 fn hlg_constructor() {
369 let meta = HdrMetadata::hlg();
370 assert!(meta.is_hdr());
371 assert_eq!(meta.transfer, TransferFunction::Hlg);
372 assert!(meta.content_light_level.is_none());
373 assert!(meta.mastering_display.is_none());
374 }
375
376 #[test]
377 #[cfg(feature = "std")]
378 fn exposure_tonemap_values() {
379 assert!((exposure_tonemap(0.5, 0.0) - 0.5).abs() < 1e-6);
381 assert!((exposure_tonemap(0.25, 1.0) - 0.5).abs() < 1e-5);
383 assert!((exposure_tonemap(0.5, -1.0) - 0.25).abs() < 1e-5);
385 assert_eq!(exposure_tonemap(0.8, 1.0), 1.0);
387 assert_eq!(exposure_tonemap(0.0, 5.0), 0.0);
388 }
389
390 #[test]
391 fn reinhard_inverse_at_one() {
392 assert_eq!(reinhard_inverse(1.0), f32::MAX);
393 }
394
395 #[test]
396 fn hdr_metadata_clone_partial_eq() {
397 let a = HdrMetadata::hlg();
398 let b = a;
399 assert_eq!(a, b);
400 }
401
402 fn reinhard_f64(v: f64) -> f64 {
406 v / (1.0 + v)
407 }
408
409 #[test]
410 fn reinhard_clamps_negatives_and_nan_to_zero() {
411 assert_eq!(reinhard_tonemap(-0.25), 0.0);
413 assert_eq!(reinhard_tonemap(-1.0), 0.0);
414 assert_eq!(reinhard_tonemap(-2.0), 0.0);
415 assert_eq!(reinhard_tonemap(f32::NEG_INFINITY), 0.0);
416 assert_eq!(reinhard_tonemap(f32::NAN), 0.0);
417
418 assert_eq!(reinhard_inverse(-0.25), 0.0);
419 assert_eq!(reinhard_inverse(-1.0), 0.0);
420 assert_eq!(reinhard_inverse(f32::NAN), 0.0);
421 }
422
423 #[test]
424 fn reinhard_infinity_saturates_to_one() {
425 assert_eq!(reinhard_tonemap(f32::INFINITY), 1.0);
427 assert_eq!(reinhard_tonemap(f32::MAX), 1.0);
429 }
430
431 #[test]
432 fn reinhard_output_range_and_monotonicity() {
433 let grid: [f32; 13] = [
434 0.0,
435 1e-6,
436 1e-3,
437 0.05,
438 0.1,
439 0.5,
440 1.0,
441 2.0,
442 10.0,
443 1e3,
444 1e6,
445 1e9,
446 f32::MAX,
447 ];
448 let mut prev = -1.0f32;
449 for &v in &grid {
450 let out = reinhard_tonemap(v);
451 assert!(
452 (0.0..=1.0).contains(&out) && out.is_finite(),
453 "reinhard_tonemap({v}) = {out} escapes [0, 1]"
454 );
455 assert!(out >= prev, "not monotonic at {v}: {out} < {prev}");
456 if v <= 1e6 && prev >= 0.0 {
458 assert!(out > prev, "not strictly increasing at {v}");
459 }
460 prev = out;
461 }
462 }
463
464 #[test]
465 fn reinhard_matches_f64_oracle() {
466 for &v in &[0.0f32, 1e-6, 1e-3, 0.1, 0.5, 1.0, 2.0, 10.0, 1e3, 1e5] {
467 let got = reinhard_tonemap(v) as f64;
468 let want = reinhard_f64(v as f64);
469 assert!(
470 (got - want).abs() < 1e-6,
471 "f32 impl diverges from f64 oracle at {v}: {got} vs {want}"
472 );
473 }
474 }
475
476 #[test]
477 fn reinhard_roundtrip_relative_error_bound() {
478 let mut v = 1e-4f32;
483 while v <= 1e4 {
484 let rt = reinhard_inverse(reinhard_tonemap(v));
485 let rel = ((f64::from(rt) - f64::from(v)) / f64::from(v)).abs();
486 let bound = 4.0 * f64::from(f32::EPSILON) * (1.0 + f64::from(v));
487 assert!(
488 rel < bound,
489 "roundtrip rel err {rel} > bound {bound} at {v} (got {rt})"
490 );
491 v *= 3.7;
492 }
493 }
494
495 #[test]
496 #[cfg(feature = "std")]
497 fn exposure_tonemap_nan_maps_to_zero() {
498 assert_eq!(exposure_tonemap(f32::NAN, 0.0), 0.0);
499 assert_eq!(exposure_tonemap(f32::NAN, 2.0), 0.0);
500 assert_eq!(exposure_tonemap(-0.5, 0.0), 0.0);
502 }
503
504 use alloc::vec;
507 use alloc::vec::Vec;
508
509 fn pq_oracle(x: f64) -> f64 {
511 if x <= 0.0 {
512 return 0.0;
513 }
514 let m1 = 2610.0 / 16384.0;
515 let m2 = 2523.0 / 4096.0 * 128.0;
516 let c1 = 3424.0 / 4096.0;
517 let c2 = 2413.0 / 4096.0 * 32.0;
518 let c3 = 2392.0 / 4096.0 * 32.0;
519 let xp = x.powf(m1);
520 ((c1 + c2 * xp) / (1.0 + c3 * xp)).powf(m2)
521 }
522
523 fn rgbf32(pixels: &[[f32; 3]], w: u32, h: u32) -> PixelBuffer {
524 let mut data = Vec::with_capacity(pixels.len() * 12);
525 for p in pixels {
526 for c in p {
527 data.extend_from_slice(&c.to_ne_bytes());
528 }
529 }
530 PixelBuffer::from_vec(data, w, h, PixelDescriptor::RGBF32_LINEAR).unwrap()
531 }
532
533 fn rgbaf32(pixels: &[[f32; 4]], w: u32, h: u32) -> PixelBuffer {
534 let mut data = Vec::with_capacity(pixels.len() * 16);
535 for p in pixels {
536 for c in p {
537 data.extend_from_slice(&c.to_ne_bytes());
538 }
539 }
540 PixelBuffer::from_vec(data, w, h, PixelDescriptor::RGBAF32_LINEAR).unwrap()
541 }
542
543 fn rgba16_pq() -> PixelDescriptor {
545 PixelDescriptor::RGBA16
546 .with_transfer(TransferFunction::Pq)
547 .with_primaries(PixelDescriptor::RGB16_BT2100_PQ.primaries)
548 }
549
550 #[test]
551 fn quantize_to_pq16_white_and_peak() {
552 let peak = 10_000.0 / 203.0;
554 let buf = rgbf32(&[[1.0; 3], [peak; 3]], 2, 1);
555 let out = quantize_to(buf.as_slice(), PixelDescriptor::RGB16_BT2100_PQ).unwrap();
556 assert_eq!(out.descriptor(), PixelDescriptor::RGB16_BT2100_PQ);
557 let bytes = out.as_slice().as_strided_bytes();
558 let code = |i: usize| u16::from_ne_bytes([bytes[2 * i], bytes[2 * i + 1]]);
559
560 let want_white = (pq_oracle(203.0 / 10_000.0) * 65535.0).round() as i64;
561 assert!((i64::from(code(0)) - want_white).abs() <= 1);
562 assert_eq!(code(3), 65535, "10000-nit peak clips to full code");
563 }
564
565 #[test]
566 fn quantize_to_pq16_matches_oracle_across_decades() {
567 let values = [0.001f32, 0.01, 0.1, 0.5, 1.0, 2.0, 8.0, 20.0, 49.0];
568 let pixels: Vec<[f32; 3]> = values.iter().map(|&v| [v; 3]).collect();
569 let buf = rgbf32(&pixels, values.len() as u32, 1);
570 let out = quantize_to(buf.as_slice(), PixelDescriptor::RGB16_BT2100_PQ).unwrap();
571 let bytes = out.as_slice().as_strided_bytes();
572 for (i, &v) in values.iter().enumerate() {
573 let got = i64::from(u16::from_ne_bytes([bytes[6 * i], bytes[6 * i + 1]]));
574 let x = f64::from(v) * 203.0 / 10_000.0;
575 let want = (pq_oracle(x) * 65535.0).round() as i64;
576 assert!(
577 (got - want).abs() <= 1,
578 "PQ16 at {v}: got {got}, oracle {want}"
579 );
580 }
581 }
582
583 #[test]
584 fn quantize_to_rejects_non_pq_target_and_non_linear_src() {
585 let buf = rgbf32(&[[0.5; 3]], 1, 1);
586 let err = quantize_to(buf.as_slice(), PixelDescriptor::RGB16_BT2100_HLG).unwrap_err();
588 assert!(matches!(*err.error(), ConvertError::NoPath { .. }));
589 let srgb = PixelDescriptor::RGBF32_LINEAR.with_transfer(TransferFunction::Srgb);
591 let mut d = Vec::new();
592 for c in [0.5f32; 3] {
593 d.extend_from_slice(&c.to_ne_bytes());
594 }
595 let nb = PixelBuffer::from_vec(d, 1, 1, srgb).unwrap();
596 assert!(quantize_to(nb.as_slice(), PixelDescriptor::RGB16_BT2100_PQ).is_err());
597 }
598
599 #[test]
600 fn quantize_to_reads_anchor_from_color_context() {
601 use alloc::sync::Arc;
602 use zenpixels::{Cicp, ColorContext};
603 let buf = rgbf32(&[[1.0; 3]], 1, 1).with_color_context(Arc::new(
606 ColorContext::from_cicp(Cicp::BT2100_PQ).with_diffuse_white(DiffuseWhite::new(100.0)),
607 ));
608 let out = quantize_to(buf.as_slice(), PixelDescriptor::RGB16_BT2100_PQ).unwrap();
609 let bytes = out.as_slice().as_strided_bytes();
610 let got = i64::from(u16::from_ne_bytes([bytes[0], bytes[1]]));
611 let want = (pq_oracle(100.0 / 10_000.0) * 65535.0).round() as i64;
612 assert!(
613 (got - want).abs() <= 1,
614 "100-nit anchor: got {got}, want {want}"
615 );
616 let want_203 = (pq_oracle(203.0 / 10_000.0) * 65535.0).round() as i64;
618 assert_ne!(want, want_203);
619 }
620
621 #[test]
622 fn quantize_to_preserves_alpha_for_rgba_target() {
623 let target = rgba16_pq();
627 let buf = rgbaf32(&[[1.0, 1.0, 1.0, 0.5], [2.0, 2.0, 2.0, 0.25]], 2, 1);
628 let out = quantize_to(buf.as_slice(), target).unwrap();
629 assert_eq!(out.descriptor(), target);
630 let bytes = out.as_slice().as_strided_bytes();
631 let code = |i: usize| u16::from_ne_bytes([bytes[2 * i], bytes[2 * i + 1]]);
632 for (px, g, a) in [(0usize, 1.0f64, 0.5f64), (1, 2.0, 0.25)] {
633 let r = i64::from(code(px * 4));
634 let want_rgb = (pq_oracle(g * 203.0 / 10_000.0) * 65535.0).round() as i64;
635 assert!(
636 (r - want_rgb).abs() <= 1,
637 "rgb @203: got {r} want {want_rgb}"
638 );
639 let alpha = code(px * 4 + 3);
640 let want_a = (a * 65535.0).round() as u16;
641 assert_eq!(
642 alpha, want_a,
643 "alpha linear passthrough: got {alpha} want {want_a}"
644 );
645 }
646 }
647
648 #[test]
649 fn quantize_to_honors_strided_input() {
650 let target = PixelDescriptor::RGB16_BT2100_PQ;
653 let stride = 2 * 12 + 12; let mut data = vec![0u8; stride * 2];
655 for y in 0..2usize {
656 let mut off = y * stride;
657 for c in [0.1f32, 0.1, 0.1, 1.0, 1.0, 1.0] {
658 data[off..off + 4].copy_from_slice(&c.to_ne_bytes());
659 off += 4;
660 }
661 data[off..off + 4].copy_from_slice(&999.0f32.to_ne_bytes()); }
663 let strided = PixelSlice::new(&data, 2, 2, stride, PixelDescriptor::RGBF32_LINEAR).unwrap();
664 let got = quantize_to(strided, target).unwrap();
665
666 let packed = rgbf32(&[[0.1; 3], [1.0; 3], [0.1; 3], [1.0; 3]], 2, 2);
667 let want = quantize_to(packed.as_slice(), target).unwrap();
668 assert_eq!(
669 got.as_slice().as_strided_bytes(),
670 want.as_slice().as_strided_bytes(),
671 "strided input must quantize identically to packed"
672 );
673 }
674
675 #[test]
676 fn quantize_into_matches_quantize_to() {
677 let target = PixelDescriptor::RGB16_BT2100_PQ;
678 let buf = rgbf32(&[[0.1; 3], [1.0; 3], [2.0; 3]], 3, 1);
679 let want = quantize_to(buf.as_slice(), target).unwrap();
680 let row = 3 * target.bytes_per_pixel();
681 let mut dst = vec![0u8; row];
682 quantize_into(buf.as_slice(), target, &mut dst, row).unwrap();
683 assert_eq!(dst, want.as_slice().as_strided_bytes());
684 }
685
686 #[test]
687 fn quantize_into_honors_dst_stride() {
688 let target = PixelDescriptor::RGB16_BT2100_PQ;
691 let buf = rgbf32(&[[0.1; 3], [1.0; 3], [0.1; 3], [1.0; 3]], 2, 2);
692 let want = quantize_to(buf.as_slice(), target).unwrap();
693 let want_bytes = want.as_slice().as_strided_bytes();
694 let row = 2 * target.bytes_per_pixel(); let dst_stride = row + 8; let mut dst = vec![0xAAu8; dst_stride * 2];
697 quantize_into(buf.as_slice(), target, &mut dst, dst_stride).unwrap();
698 for y in 0..2 {
699 assert_eq!(
700 &dst[y * dst_stride..y * dst_stride + row],
701 &want_bytes[y * row..(y + 1) * row]
702 );
703 assert!(
704 dst[y * dst_stride + row..y * dst_stride + dst_stride]
705 .iter()
706 .all(|&b| b == 0xAA),
707 "padding row {y} must be untouched"
708 );
709 }
710 }
711
712 #[test]
713 fn quantize_into_rejects_undersized_dst() {
714 let target = PixelDescriptor::RGB16_BT2100_PQ;
715 let buf = rgbf32(&[[1.0; 3]], 1, 1);
716 let mut dst = vec![0u8; 2]; let row = target.bytes_per_pixel();
718 let err = quantize_into(buf.as_slice(), target, &mut dst, row).unwrap_err();
719 assert!(matches!(*err.error(), ConvertError::BufferSize { .. }));
720 }
721
722 #[test]
723 fn quantize_to_carries_diffuse_white_anchor_onto_output() {
724 use alloc::sync::Arc;
725 use zenpixels::{Cicp, ColorContext};
726 let target = PixelDescriptor::RGB16_BT2100_PQ;
727
728 let buf = rgbf32(&[[1.0; 3]], 1, 1).with_color_context(Arc::new(
732 ColorContext::from_cicp(Cicp::BT2100_PQ).with_diffuse_white(DiffuseWhite::new(100.0)),
733 ));
734 let out = quantize_to(buf.as_slice(), target).unwrap();
735 let ctx = out.color_context().expect("output carries a ColorContext");
736 assert_eq!(ctx.diffuse_white, Some(DiffuseWhite::new(100.0)));
737 assert!(ctx.cicp.is_some(), "target CICP rides along for re-encode");
738
739 let plain = rgbf32(&[[1.0; 3]], 1, 1);
741 let out = quantize_to(plain.as_slice(), target).unwrap();
742 assert_eq!(
743 out.color_context().unwrap().diffuse_white,
744 Some(DiffuseWhite::BT2408)
745 );
746 }
747}