1#![allow(clippy::cast_lossless)]
49#![allow(clippy::cast_precision_loss)]
50#![allow(clippy::cast_possible_truncation)]
51#![allow(clippy::cast_sign_loss)]
52#![allow(clippy::many_single_char_names)]
53#![allow(clippy::similar_names)]
54#![allow(clippy::too_many_lines)]
55#![allow(clippy::missing_errors_doc)]
56#![allow(clippy::doc_markdown)]
57
58use std::f32::consts::PI;
59
60mod pq {
66 pub const M1: f64 = 2610.0 / 16384.0;
67 pub const M2: f64 = 2523.0 / 4096.0 * 128.0;
68 pub const C1: f64 = 3424.0 / 4096.0;
69 pub const C2: f64 = 2413.0 / 4096.0 * 32.0;
70 pub const C3: f64 = 2392.0 / 4096.0 * 32.0;
71 pub const PEAK_NITS: f64 = 10_000.0;
73}
74
75mod hlg {
77 pub const A: f64 = 0.178_832_77;
78 pub const B: f64 = 0.284_668_92;
79 pub const C: f64 = 0.559_910_73;
80}
81
82mod bt709 {
84 pub const ALPHA: f64 = 1.099_296_826_809_44;
85 pub const BETA: f64 = 0.018_053_968_510_807;
86 pub const GAMMA_OETF: f64 = 0.45;
87 pub const GAMMA_EOTF: f64 = 1.0 / GAMMA_OETF;
88}
89
90#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
96pub enum ToneMapOperator {
97 Clamp,
99 ReinhardGlobal,
103 #[default]
106 ReinhardLocal,
107 AcesFilmic,
111 Hable,
113 PqToSdr,
119}
120
121#[derive(Clone, Debug)]
129pub struct ToneMappingConfig {
130 pub operator: ToneMapOperator,
132 pub peak_brightness: f32,
137 pub white_point: f32,
141 pub saturation_correction: f32,
146 pub exposure: f32,
150 pub knee_start: f32,
155}
156
157impl Default for ToneMappingConfig {
158 fn default() -> Self {
159 Self {
160 operator: ToneMapOperator::default(),
161 peak_brightness: 1000.0,
162 white_point: 203.0,
163 saturation_correction: 1.0,
164 exposure: 1.0,
165 knee_start: 0.0,
166 }
167 }
168}
169
170impl ToneMappingConfig {
171 #[must_use]
173 pub fn builder() -> ToneMappingConfigBuilder {
174 ToneMappingConfigBuilder::default()
175 }
176}
177
178#[derive(Default)]
180pub struct ToneMappingConfigBuilder {
181 inner: ToneMappingConfig,
182}
183
184impl ToneMappingConfigBuilder {
185 #[must_use]
187 pub fn operator(mut self, op: ToneMapOperator) -> Self {
188 self.inner.operator = op;
189 self
190 }
191
192 #[must_use]
194 pub fn peak_brightness(mut self, nits: f32) -> Self {
195 self.inner.peak_brightness = nits;
196 self
197 }
198
199 #[must_use]
201 pub fn white_point(mut self, nits: f32) -> Self {
202 self.inner.white_point = nits;
203 self
204 }
205
206 #[must_use]
208 pub fn saturation_correction(mut self, s: f32) -> Self {
209 self.inner.saturation_correction = s;
210 self
211 }
212
213 #[must_use]
215 pub fn exposure(mut self, e: f32) -> Self {
216 self.inner.exposure = e;
217 self
218 }
219
220 #[must_use]
224 pub fn knee_start(mut self, k: f32) -> Self {
225 self.inner.knee_start = k;
226 self
227 }
228
229 #[must_use]
231 pub fn build(self) -> ToneMappingConfig {
232 self.inner
233 }
234}
235
236#[derive(Clone, Debug)]
249pub struct HdrToneMapper {
250 config: ToneMappingConfig,
251 nit_scale: f32,
253 white_norm: f32,
255}
256
257impl HdrToneMapper {
258 #[must_use]
260 pub fn new(config: ToneMappingConfig) -> Self {
261 let nit_scale = 1.0 / config.peak_brightness.max(f32::EPSILON);
262 let white_norm = config.white_point * nit_scale;
263 Self {
264 config,
265 nit_scale,
266 white_norm,
267 }
268 }
269
270 #[must_use]
272 pub fn config(&self) -> &ToneMappingConfig {
273 &self.config
274 }
275
276 #[must_use]
285 pub fn map_pixel(&self, rgb: [f32; 3]) -> [f32; 3] {
286 let rgb = [
288 rgb[0] * self.config.exposure,
289 rgb[1] * self.config.exposure,
290 rgb[2] * self.config.exposure,
291 ];
292
293 let rgb = if self.config.knee_start > 0.0 {
295 soft_knee(rgb, self.config.knee_start, 1.0)
296 } else {
297 rgb
298 };
299
300 let mapped = match self.config.operator {
302 ToneMapOperator::Clamp => clamp_op(rgb),
303 ToneMapOperator::ReinhardGlobal => reinhard_global(rgb),
304 ToneMapOperator::ReinhardLocal => reinhard_local(rgb),
305 ToneMapOperator::AcesFilmic => aces_filmic(rgb),
306 ToneMapOperator::Hable => hable(rgb),
307 ToneMapOperator::PqToSdr => pq_to_sdr(rgb, self.nit_scale, self.white_norm),
308 };
309
310 let mapped = saturation_correct(mapped, self.config.saturation_correction);
312
313 [
315 mapped[0].clamp(0.0, 1.0),
316 mapped[1].clamp(0.0, 1.0),
317 mapped[2].clamp(0.0, 1.0),
318 ]
319 }
320
321 pub fn map_frame(&self, buffer: &mut [f32]) {
326 for chunk in buffer.chunks_exact_mut(3) {
327 let rgb = [chunk[0], chunk[1], chunk[2]];
328 let out = self.map_pixel(rgb);
329 chunk[0] = out[0];
330 chunk[1] = out[1];
331 chunk[2] = out[2];
332 }
333 }
334
335 #[must_use]
339 pub fn map_frame_owned(&self, input: &[f32]) -> Vec<f32> {
340 let mut out = input.to_vec();
341 self.map_frame(&mut out);
342 out
343 }
344
345 #[must_use]
349 pub fn pq_eotf_nits(signal: f32) -> f32 {
350 pq_eotf_f64(f64::from(signal)) as f32 * pq::PEAK_NITS as f32
351 }
352
353 #[must_use]
355 pub fn bt709_oetf(linear: f32) -> f32 {
356 bt709_oetf_f64(f64::from(linear)) as f32
357 }
358}
359
360#[inline]
366fn clamp_op(rgb: [f32; 3]) -> [f32; 3] {
367 [
368 rgb[0].clamp(0.0, 1.0),
369 rgb[1].clamp(0.0, 1.0),
370 rgb[2].clamp(0.0, 1.0),
371 ]
372}
373
374#[inline]
378fn reinhard_global(rgb: [f32; 3]) -> [f32; 3] {
379 let r = rgb[0].max(0.0);
380 let g = rgb[1].max(0.0);
381 let b = rgb[2].max(0.0);
382 [r / (1.0 + r), g / (1.0 + g), b / (1.0 + b)]
383}
384
385#[inline]
392fn reinhard_local(rgb: [f32; 3]) -> [f32; 3] {
393 const KR: f32 = 0.2627;
395 const KG: f32 = 0.6780;
396 const KB: f32 = 0.0593;
397
398 let luma = KR * rgb[0] + KG * rgb[1] + KB * rgb[2];
399
400 if luma < f32::EPSILON {
401 return [0.0, 0.0, 0.0];
402 }
403
404 let luma_tm = luma / (1.0 + luma);
405 let scale = luma_tm / luma;
406
407 [
408 (rgb[0] * scale).max(0.0),
409 (rgb[1] * scale).max(0.0),
410 (rgb[2] * scale).max(0.0),
411 ]
412}
413
414#[inline]
420fn aces_filmic(rgb: [f32; 3]) -> [f32; 3] {
421 #[inline]
422 fn aces_channel(x: f32) -> f32 {
423 const A: f32 = 2.51;
424 const B: f32 = 0.03;
425 const C: f32 = 2.43;
426 const D: f32 = 0.59;
427 const E: f32 = 0.14;
428 let x = x.max(0.0);
429 let num = x * (A * x + B);
430 let den = x * (C * x + D) + E;
431 if den.abs() < f32::EPSILON {
432 0.0
433 } else {
434 (num / den).clamp(0.0, 1.0)
435 }
436 }
437
438 [
439 aces_channel(rgb[0]),
440 aces_channel(rgb[1]),
441 aces_channel(rgb[2]),
442 ]
443}
444
445#[inline]
451fn hable(rgb: [f32; 3]) -> [f32; 3] {
452 const EXPOSURE_BIAS: f32 = 2.0;
453 const WHITE_POINT: f32 = 11.2;
454
455 #[inline]
456 fn partial(x: f32) -> f32 {
457 const A: f32 = 0.15; const B: f32 = 0.50; const C: f32 = 0.10; const D: f32 = 0.20; const E: f32 = 0.02; const F: f32 = 0.30; ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F
464 }
465
466 let w = partial(WHITE_POINT);
467 if w.abs() < f32::EPSILON {
468 return [0.0, 0.0, 0.0];
469 }
470
471 let map = |v: f32| (partial(v * EXPOSURE_BIAS) / w).clamp(0.0, 1.0);
472 [map(rgb[0]), map(rgb[1]), map(rgb[2])]
473}
474
475fn pq_to_sdr(rgb: [f32; 3], nit_scale: f32, white_norm: f32) -> [f32; 3] {
486 let norm = [rgb[0] * nit_scale, rgb[1] * nit_scale, rgb[2] * nit_scale];
488
489 const KR: f32 = 0.2627;
492 const KG: f32 = 0.6780;
493 const KB: f32 = 0.0593;
494 let luma = KR * norm[0] + KG * norm[1] + KB * norm[2];
495
496 let luma_out = if luma < f32::EPSILON {
497 0.0_f32
498 } else {
499 let w2 = white_norm * white_norm;
500 let out = luma * (1.0 + luma / w2.max(f32::EPSILON)) / (1.0 + luma);
501 out.clamp(0.0, 1.0)
502 };
503
504 let scale = if luma < f32::EPSILON {
505 0.0_f32
506 } else {
507 luma_out / luma
508 };
509
510 let apply = |v: f32| {
512 let linear = (v * scale).clamp(0.0, 1.0);
513 bt709_oetf_f64(f64::from(linear)) as f32
514 };
515
516 [apply(norm[0]), apply(norm[1]), apply(norm[2])]
517}
518
519#[inline]
528fn saturation_correct(rgb: [f32; 3], factor: f32) -> [f32; 3] {
529 if (factor - 1.0).abs() < f32::EPSILON {
530 return rgb;
531 }
532
533 const KR: f32 = 0.2126;
535 const KG: f32 = 0.7152;
536 const KB: f32 = 0.0722;
537 let luma = KR * rgb[0] + KG * rgb[1] + KB * rgb[2];
538
539 [
540 luma + (rgb[0] - luma) * factor,
541 luma + (rgb[1] - luma) * factor,
542 luma + (rgb[2] - luma) * factor,
543 ]
544}
545
546fn soft_knee(rgb: [f32; 3], knee_start: f32, knee_end: f32) -> [f32; 3] {
555 let apply = |v: f32| -> f32 {
556 if v <= knee_start || knee_end <= knee_start {
557 v
558 } else if v >= knee_end {
559 knee_end
560 } else {
561 let t = (v - knee_start) / (knee_end - knee_start);
562 let smooth = (1.0 - (t * PI).cos()) * 0.5;
563 knee_start + (knee_end - knee_start) * smooth
564 }
565 };
566 [apply(rgb[0]), apply(rgb[1]), apply(rgb[2])]
567}
568
569#[allow(dead_code)]
575pub fn pq_eotf_f64(e: f64) -> f64 {
576 let e = e.clamp(0.0, 1.0);
577 let e_m2 = e.powf(1.0 / pq::M2);
578 let num = (e_m2 - pq::C1).max(0.0);
579 let den = pq::C2 - pq::C3 * e_m2;
580 if den.abs() < 1e-10 {
581 0.0
582 } else {
583 (num / den).powf(1.0 / pq::M1)
584 }
585}
586
587#[allow(dead_code)]
589pub fn pq_oetf_f64(y: f64) -> f64 {
590 let y = y.clamp(0.0, 1.0);
591 let y_m1 = y.powf(pq::M1);
592 let num = pq::C1 + pq::C2 * y_m1;
593 let den = 1.0 + pq::C3 * y_m1;
594 (num / den).powf(pq::M2)
595}
596
597#[allow(dead_code)]
599pub fn hlg_eotf_f64(e: f64) -> f64 {
600 let e = e.clamp(0.0, 1.0);
601 if e <= 0.5 {
602 (e * e) / 3.0
603 } else {
604 (((e - hlg::C) / hlg::A).exp() + hlg::B) / 12.0
605 }
606}
607
608#[allow(dead_code)]
610pub fn hlg_oetf_f64(y: f64) -> f64 {
611 let y = y.clamp(0.0, 1.0);
612 if y <= 1.0 / 12.0 {
613 (3.0 * y).sqrt()
614 } else {
615 hlg::A * (12.0 * y - hlg::B).ln() + hlg::C
616 }
617}
618
619pub fn bt709_oetf_f64(y: f64) -> f64 {
621 let y = y.clamp(0.0, 1.0);
622 if y < bt709::BETA {
623 4.5 * y
624 } else {
625 bt709::ALPHA * y.powf(bt709::GAMMA_OETF) - (bt709::ALPHA - 1.0)
626 }
627}
628
629#[allow(dead_code)]
631pub fn bt709_eotf_f64(e: f64) -> f64 {
632 let e = e.clamp(0.0, 1.0);
633 let threshold = 4.5 * bt709::BETA;
634 if e < threshold {
635 e / 4.5
636 } else {
637 ((e + (bt709::ALPHA - 1.0)) / bt709::ALPHA).powf(bt709::GAMMA_EOTF)
638 }
639}
640
641#[must_use]
650pub fn bt2020_to_bt709(rgb: [f32; 3]) -> [f32; 3] {
651 const M: [[f32; 3]; 3] = [
654 [1.660_491, -0.587_641, -0.072_850],
655 [-0.124_551, 1.132_9, -0.008_349],
656 [-0.018_151, -0.100_579, 1.118_73],
657 ];
658
659 let r = M[0][0] * rgb[0] + M[0][1] * rgb[1] + M[0][2] * rgb[2];
660 let g = M[1][0] * rgb[0] + M[1][1] * rgb[1] + M[1][2] * rgb[2];
661 let b = M[2][0] * rgb[0] + M[2][1] * rgb[1] + M[2][2] * rgb[2];
662
663 [r.max(0.0), g.max(0.0), b.max(0.0)]
664}
665
666impl HdrToneMapper {
671 #[must_use]
675 pub fn aces_hdr10() -> Self {
676 Self::new(
677 ToneMappingConfig::builder()
678 .operator(ToneMapOperator::AcesFilmic)
679 .peak_brightness(1000.0)
680 .white_point(203.0)
681 .saturation_correction(1.0)
682 .build(),
683 )
684 }
685
686 #[must_use]
688 pub fn hable_hdr10() -> Self {
689 Self::new(
690 ToneMappingConfig::builder()
691 .operator(ToneMapOperator::Hable)
692 .peak_brightness(1000.0)
693 .white_point(203.0)
694 .build(),
695 )
696 }
697
698 #[must_use]
700 pub fn pq_to_sdr_hdr10() -> Self {
701 Self::new(
702 ToneMappingConfig::builder()
703 .operator(ToneMapOperator::PqToSdr)
704 .peak_brightness(1000.0)
705 .white_point(203.0)
706 .build(),
707 )
708 }
709
710 #[must_use]
712 pub fn reinhard_hdr10() -> Self {
713 Self::new(
714 ToneMappingConfig::builder()
715 .operator(ToneMapOperator::ReinhardLocal)
716 .peak_brightness(1000.0)
717 .white_point(203.0)
718 .build(),
719 )
720 }
721}
722
723#[cfg(test)]
728mod tests {
729 use super::*;
730
731 fn all_in_range(rgb: [f32; 3]) -> bool {
734 rgb.iter().all(|&v| v >= 0.0 && v <= 1.0)
735 }
736
737 fn is_monotone(a: [f32; 3], b: [f32; 3]) -> bool {
738 a[0] <= b[0] && a[1] <= b[1] && a[2] <= b[2]
740 }
741
742 fn mapper(op: ToneMapOperator) -> HdrToneMapper {
743 HdrToneMapper::new(
744 ToneMappingConfig::builder()
745 .operator(op)
746 .peak_brightness(1000.0)
747 .white_point(203.0)
748 .build(),
749 )
750 }
751
752 #[test]
755 fn test_clamp_preserves_black() {
756 let out = mapper(ToneMapOperator::Clamp).map_pixel([0.0, 0.0, 0.0]);
757 assert_eq!(out, [0.0, 0.0, 0.0]);
758 }
759
760 #[test]
761 fn test_reinhard_global_preserves_black() {
762 let out = mapper(ToneMapOperator::ReinhardGlobal).map_pixel([0.0, 0.0, 0.0]);
763 assert!(out[0].abs() < 1e-6 && out[1].abs() < 1e-6 && out[2].abs() < 1e-6);
764 }
765
766 #[test]
767 fn test_reinhard_local_preserves_black() {
768 let out = mapper(ToneMapOperator::ReinhardLocal).map_pixel([0.0, 0.0, 0.0]);
769 assert_eq!(out, [0.0, 0.0, 0.0]);
770 }
771
772 #[test]
773 fn test_aces_preserves_black() {
774 let out = mapper(ToneMapOperator::AcesFilmic).map_pixel([0.0, 0.0, 0.0]);
775 assert!(out[0] < 0.05 && out[1] < 0.05 && out[2] < 0.05);
777 }
778
779 #[test]
780 fn test_hable_near_black() {
781 let out = mapper(ToneMapOperator::Hable).map_pixel([0.0, 0.0, 0.0]);
782 assert!(all_in_range(out));
784 assert!(out[0] < 0.1 && out[1] < 0.1 && out[2] < 0.1);
785 }
786
787 #[test]
790 fn test_clamp_output_range() {
791 for &v in &[0.0f32, 0.5, 1.0, 2.0, 10.0, 100.0] {
792 assert!(all_in_range(mapper(ToneMapOperator::Clamp).map_pixel([
793 v,
794 v * 0.5,
795 v * 0.25
796 ])));
797 }
798 }
799
800 #[test]
801 fn test_reinhard_global_output_range() {
802 for &v in &[0.5f32, 1.0, 2.0, 10.0, 1000.0] {
803 assert!(all_in_range(
804 mapper(ToneMapOperator::ReinhardGlobal).map_pixel([v, v * 0.7, v * 0.3])
805 ));
806 }
807 }
808
809 #[test]
810 fn test_reinhard_local_output_range() {
811 for &v in &[0.5f32, 1.0, 2.0, 10.0, 1000.0] {
812 assert!(all_in_range(
813 mapper(ToneMapOperator::ReinhardLocal).map_pixel([v, v * 0.7, v * 0.3])
814 ));
815 }
816 }
817
818 #[test]
819 fn test_aces_output_range() {
820 for &v in &[0.1f32, 0.5, 1.0, 2.0, 5.0, 50.0] {
821 assert!(all_in_range(
822 mapper(ToneMapOperator::AcesFilmic).map_pixel([v, v * 0.6, v * 0.2])
823 ));
824 }
825 }
826
827 #[test]
828 fn test_hable_output_range() {
829 for &v in &[0.5f32, 1.0, 2.0, 10.0, 100.0] {
830 assert!(all_in_range(mapper(ToneMapOperator::Hable).map_pixel([
831 v,
832 v * 0.6,
833 v * 0.25
834 ])));
835 }
836 }
837
838 #[test]
839 fn test_pq_to_sdr_output_range() {
840 for &v in &[0.001f32, 0.01, 0.1, 0.5, 1.0, 2.0] {
841 assert!(all_in_range(mapper(ToneMapOperator::PqToSdr).map_pixel([
842 v,
843 v * 0.5,
844 v * 0.1
845 ])));
846 }
847 }
848
849 #[test]
852 fn test_reinhard_local_monotone() {
853 let m = mapper(ToneMapOperator::ReinhardLocal);
854 let dim = m.map_pixel([0.5, 0.3, 0.1]);
855 let bright = m.map_pixel([5.0, 3.0, 1.0]);
856 assert!(is_monotone(dim, bright));
857 }
858
859 #[test]
860 fn test_aces_monotone() {
861 let m = mapper(ToneMapOperator::AcesFilmic);
862 let dim = m.map_pixel([0.2, 0.1, 0.05]);
863 let mid = m.map_pixel([2.0, 1.0, 0.5]);
864 let bright = m.map_pixel([10.0, 5.0, 2.5]);
865 assert!(is_monotone(dim, mid));
866 assert!(is_monotone(mid, bright));
867 }
868
869 #[test]
870 fn test_hable_monotone() {
871 let m = mapper(ToneMapOperator::Hable);
872 let dim = m.map_pixel([0.1, 0.05, 0.01]);
873 let bright = m.map_pixel([5.0, 2.5, 0.5]);
874 assert!(is_monotone(dim, bright));
875 }
876
877 #[test]
880 fn test_saturation_neutral() {
881 let config = ToneMappingConfig::builder()
882 .operator(ToneMapOperator::AcesFilmic)
883 .saturation_correction(1.0)
884 .build();
885 let config_sat = ToneMappingConfig::builder()
886 .operator(ToneMapOperator::AcesFilmic)
887 .saturation_correction(1.0)
888 .build();
889 let m1 = HdrToneMapper::new(config);
890 let m2 = HdrToneMapper::new(config_sat);
891 let rgb = [1.0, 0.5, 0.2];
892 let a = m1.map_pixel(rgb);
893 let b = m2.map_pixel(rgb);
894 for i in 0..3 {
895 assert!(
896 (a[i] - b[i]).abs() < 1e-5,
897 "Neutral saturation should be identity"
898 );
899 }
900 }
901
902 #[test]
903 fn test_saturation_desaturate() {
904 let config = ToneMappingConfig::builder()
905 .operator(ToneMapOperator::ReinhardGlobal)
906 .saturation_correction(0.0)
907 .build();
908 let m = HdrToneMapper::new(config);
909 let out = m.map_pixel([2.0, 1.0, 0.5]);
910 let diff_rg = (out[0] - out[1]).abs();
912 let diff_rb = (out[0] - out[2]).abs();
913 assert!(diff_rg < 0.01, "Fully desaturated: R≈G (diff={diff_rg:.4})");
914 assert!(diff_rb < 0.01, "Fully desaturated: R≈B (diff={diff_rb:.4})");
915 }
916
917 #[test]
920 fn test_builder_round_trip() {
921 let config = ToneMappingConfig::builder()
922 .operator(ToneMapOperator::AcesFilmic)
923 .peak_brightness(4000.0)
924 .white_point(400.0)
925 .saturation_correction(1.2)
926 .exposure(0.9)
927 .knee_start(0.7)
928 .build();
929 assert_eq!(config.operator, ToneMapOperator::AcesFilmic);
930 assert!((config.peak_brightness - 4000.0).abs() < f32::EPSILON);
931 assert!((config.white_point - 400.0).abs() < f32::EPSILON);
932 assert!((config.saturation_correction - 1.2).abs() < f32::EPSILON);
933 assert!((config.exposure - 0.9).abs() < f32::EPSILON);
934 assert!((config.knee_start - 0.7).abs() < f32::EPSILON);
935 }
936
937 #[test]
940 fn test_aces_hdr10_constructor() {
941 let m = HdrToneMapper::aces_hdr10();
942 assert_eq!(m.config().operator, ToneMapOperator::AcesFilmic);
943 assert!(all_in_range(m.map_pixel([3.0, 1.5, 0.5])));
944 }
945
946 #[test]
947 fn test_hable_hdr10_constructor() {
948 let m = HdrToneMapper::hable_hdr10();
949 assert_eq!(m.config().operator, ToneMapOperator::Hable);
950 assert!(all_in_range(m.map_pixel([5.0, 2.0, 0.8])));
951 }
952
953 #[test]
954 fn test_pq_to_sdr_constructor() {
955 let m = HdrToneMapper::pq_to_sdr_hdr10();
956 assert_eq!(m.config().operator, ToneMapOperator::PqToSdr);
957 assert!(all_in_range(m.map_pixel([0.5, 0.3, 0.1])));
958 }
959
960 #[test]
961 fn test_reinhard_hdr10_constructor() {
962 let m = HdrToneMapper::reinhard_hdr10();
963 assert_eq!(m.config().operator, ToneMapOperator::ReinhardLocal);
964 assert!(all_in_range(m.map_pixel([2.0, 1.0, 0.4])));
965 }
966
967 #[test]
970 fn test_map_frame_in_place() {
971 let m = HdrToneMapper::aces_hdr10();
972 let mut buf = vec![10.0f32, 5.0, 2.0, 0.1, 0.05, 0.02];
973 m.map_frame(&mut buf);
974 for chunk in buf.chunks_exact(3) {
975 assert!(chunk[0] >= 0.0 && chunk[0] <= 1.0);
976 assert!(chunk[1] >= 0.0 && chunk[1] <= 1.0);
977 assert!(chunk[2] >= 0.0 && chunk[2] <= 1.0);
978 }
979 }
980
981 #[test]
982 fn test_map_frame_owned() {
983 let m = HdrToneMapper::hable_hdr10();
984 let input = vec![3.0f32, 1.5, 0.7, 0.3, 0.15, 0.07];
985 let output = m.map_frame_owned(&input);
986 assert_eq!(output.len(), input.len());
987 for chunk in output.chunks_exact(3) {
988 assert!(chunk.iter().all(|&v| (0.0..=1.0).contains(&v)));
989 }
990 }
991
992 #[test]
995 fn test_pq_eotf_bounds() {
996 let v = pq_eotf_f64(0.0);
998 assert!(v.abs() < 1e-10);
999 let v = pq_eotf_f64(1.0);
1001 assert!(
1002 (v - 1.0).abs() < 1e-6,
1003 "PQ EOTF(1.0) should be 1.0, got {v}"
1004 );
1005 }
1006
1007 #[test]
1008 fn test_pq_round_trip() {
1009 for signal in [0.1f64, 0.3, 0.5, 0.7, 0.9] {
1010 let linear = pq_eotf_f64(signal);
1011 let back = pq_oetf_f64(linear);
1012 assert!(
1013 (back - signal).abs() < 1e-6,
1014 "PQ round-trip failed at {signal}: got {back}"
1015 );
1016 }
1017 }
1018
1019 #[test]
1020 fn test_hlg_eotf_bounds() {
1021 assert!(hlg_eotf_f64(0.0).abs() < 1e-10);
1022 let v = hlg_eotf_f64(1.0);
1023 assert!(v > 0.0 && v <= 1.0 + 1e-6, "HLG EOTF(1.0) = {v}");
1025 }
1026
1027 #[test]
1028 fn test_hlg_round_trip() {
1029 for y in [0.05f64, 0.2, 0.5, 0.8, 0.95] {
1030 let sig = hlg_oetf_f64(y);
1031 let back = hlg_eotf_f64(sig);
1032 assert!(
1033 (back - y).abs() < 1e-6,
1034 "HLG round-trip failed at {y}: got {back}"
1035 );
1036 }
1037 }
1038
1039 #[test]
1040 fn test_bt709_oetf_monotone() {
1041 let mut prev = bt709_oetf_f64(0.0);
1042 for i in 1..=20 {
1043 let y = i as f64 / 20.0;
1044 let v = bt709_oetf_f64(y);
1045 assert!(v >= prev, "BT.709 OETF not monotone at {y}");
1046 prev = v;
1047 }
1048 }
1049
1050 #[test]
1053 fn test_bt2020_to_bt709_white() {
1054 let out = bt2020_to_bt709([1.0, 1.0, 1.0]);
1056 assert!(
1057 out[0] > 0.9 && out[1] > 0.9 && out[2] > 0.9,
1058 "D65 white gamut convert: {out:?}"
1059 );
1060 }
1061
1062 #[test]
1063 fn test_bt2020_to_bt709_no_negative() {
1064 let out = bt2020_to_bt709([0.0, 1.0, 0.0]); assert!(
1067 out.iter().all(|&v| v >= 0.0),
1068 "Negative after gamut convert: {out:?}"
1069 );
1070 }
1071
1072 #[test]
1075 fn test_soft_knee_below_knee() {
1076 let out = soft_knee([0.3, 0.2, 0.1], 0.7, 1.0);
1078 assert!((out[0] - 0.3).abs() < 1e-6);
1079 assert!((out[1] - 0.2).abs() < 1e-6);
1080 assert!((out[2] - 0.1).abs() < 1e-6);
1081 }
1082
1083 #[test]
1084 fn test_soft_knee_above_knee_end_clamps() {
1085 let out = soft_knee([2.0, 1.5, 1.2], 0.7, 1.0);
1086 assert!((out[0] - 1.0).abs() < 1e-5);
1088 assert!((out[1] - 1.0).abs() < 1e-5);
1089 assert!((out[2] - 1.0).abs() < 1e-5);
1090 }
1091}