Skip to main content

oximedia_codec/
hdr.rs

1//! HDR Tone Mapping operators for High Dynamic Range to Standard Dynamic Range conversion.
2//!
3//! This module provides a comprehensive set of tone mapping algorithms operating directly
4//! on RGB f32 pixel data.  All operators work in linear light (scene-referred) and can be
5//! chained through the [`HdrToneMapper`] pipeline which handles:
6//!
7//! 1. PQ / HLG / BT.709 EOTF  (signal → linear light in nits)
8//! 2. Optional BT.2020 → BT.709 gamut conversion
9//! 3. Tone-mapping operator
10//! 4. Saturation correction in IPT-like perceptual space
11//! 5. BT.709 OETF  (linear light → gamma-encoded signal)
12//!
13//! # Operators
14//!
15//! | Operator | Description |
16//! |---|---|
17//! | [`ToneMapOperator::Clamp`] | Hard clip — fastest, no soft rolloff |
18//! | [`ToneMapOperator::ReinhardGlobal`] | Reinhard (2002) per-channel |
19//! | [`ToneMapOperator::ReinhardLocal`] | Luminance-domain Reinhard (colour ratios preserved) |
20//! | [`ToneMapOperator::AcesFilmic`] | ACES Narkowicz approximation |
21//! | [`ToneMapOperator::Hable`] | Hable / Uncharted 2 filmic |
22//! | [`ToneMapOperator::PqToSdr`] | Full ST.2084 → BT.709 pipeline |
23//!
24//! # Example
25//!
26//! ```rust
27//! use oximedia_codec::hdr::{HdrToneMapper, ToneMappingConfig, ToneMapOperator};
28//!
29//! // Build configuration
30//! let config = ToneMappingConfig::builder()
31//!     .operator(ToneMapOperator::AcesFilmic)
32//!     .peak_brightness(1000.0)
33//!     .white_point(203.0)
34//!     .saturation_correction(1.1)
35//!     .build();
36//!
37//! let mapper = HdrToneMapper::new(config);
38//!
39//! // Process a single HDR pixel (linear scene-referred, values may exceed 1.0)
40//! let hdr_pixel = [10.0_f32, 4.0, 1.5];
41//! let sdr_pixel = mapper.map_pixel(hdr_pixel);
42//!
43//! assert!(sdr_pixel[0] >= 0.0 && sdr_pixel[0] <= 1.0);
44//! assert!(sdr_pixel[1] >= 0.0 && sdr_pixel[1] <= 1.0);
45//! assert!(sdr_pixel[2] >= 0.0 && sdr_pixel[2] <= 1.0);
46//! ```
47
48#![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
60// ─────────────────────────────────────────────────────────────
61//  Transfer-function constants
62// ─────────────────────────────────────────────────────────────
63
64/// ST.2084 (PQ) constants per SMPTE ST 2084:2014.
65mod 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    /// Reference white in nits for PQ (where signal = 1.0 maps to 10 000 nits).
72    pub const PEAK_NITS: f64 = 10_000.0;
73}
74
75/// HLG constants per ITU-R BT.2100.
76mod 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
82/// BT.709 gamma constants.
83mod 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// ─────────────────────────────────────────────────────────────
91//  Tone-mapping operators
92// ─────────────────────────────────────────────────────────────
93
94/// Selection of tone-mapping operator applied by [`HdrToneMapper`].
95#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
96pub enum ToneMapOperator {
97    /// Hard clamp to [0, 1].  No artistic rolloff; only useful as a reference.
98    Clamp,
99    /// Reinhard (2002) global operator applied per channel independently.
100    ///
101    /// Formula: `y = x / (1 + x)` — maps [0, ∞) → [0, 1).
102    ReinhardGlobal,
103    /// Luminance-domain Reinhard.  The tone curve is applied to the luminance
104    /// channel only; RGB ratios are then restored, preserving hue and saturation.
105    #[default]
106    ReinhardLocal,
107    /// ACES approximation by Narkowicz (2015).
108    ///
109    /// `y = x(2.51x + 0.03) / (x(2.43x + 0.59) + 0.14)`
110    AcesFilmic,
111    /// Hable / Uncharted 2 filmic tone curve (John Hable, 2010).
112    Hable,
113    /// Full Perceptual Quantizer (ST.2084) → SDR pipeline.
114    ///
115    /// Converts PQ-encoded signal through linear-light space, applies a
116    /// luminance-domain Reinhard with configurable peak / white point, then
117    /// applies BT.709 OETF.  Suitable for HDR10 → SDR conversion.
118    PqToSdr,
119}
120
121// ─────────────────────────────────────────────────────────────
122//  Configuration
123// ─────────────────────────────────────────────────────────────
124
125/// Configuration for [`HdrToneMapper`].
126///
127/// Use [`ToneMappingConfig::builder()`] for ergonomic construction.
128#[derive(Clone, Debug)]
129pub struct ToneMappingConfig {
130    /// Tone-mapping operator to apply.
131    pub operator: ToneMapOperator,
132    /// Peak scene-referred brightness of the HDR input, in nits.
133    ///
134    /// For HDR10 content this is typically 1 000 – 4 000 nits.
135    /// Defaults to 1 000 nits.
136    pub peak_brightness: f32,
137    /// Scene white point in nits — the luminance that should map to SDR white (100 nits).
138    ///
139    /// Defaults to 203 nits (ITU-R BT.2408 reference diffuse white).
140    pub white_point: f32,
141    /// Multiplicative saturation correction applied in linear RGB after tone mapping.
142    ///
143    /// 1.0 = neutral.  Values > 1 boost saturation, < 1 desaturate.
144    /// Typical useful range: 0.5 – 2.0.  Defaults to 1.0.
145    pub saturation_correction: f32,
146    /// Exposure pre-gain (linear multiplier applied before the tone curve).
147    ///
148    /// 1.0 = no adjustment.  Use values < 1.0 to darken / protect highlights.
149    pub exposure: f32,
150    /// Soft-knee start, as a fraction of `peak_brightness` (0.0 → disabled).
151    ///
152    /// When > 0, a smooth cosine blend replaces the hard transition at the
153    /// shoulder, reducing clipping artefacts.  Defaults to 0.0 (off).
154    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    /// Create a builder for ergonomic configuration.
172    #[must_use]
173    pub fn builder() -> ToneMappingConfigBuilder {
174        ToneMappingConfigBuilder::default()
175    }
176}
177
178/// Builder for [`ToneMappingConfig`].
179#[derive(Default)]
180pub struct ToneMappingConfigBuilder {
181    inner: ToneMappingConfig,
182}
183
184impl ToneMappingConfigBuilder {
185    /// Set tone-mapping operator.
186    #[must_use]
187    pub fn operator(mut self, op: ToneMapOperator) -> Self {
188        self.inner.operator = op;
189        self
190    }
191
192    /// Set peak brightness of the HDR source in nits.
193    #[must_use]
194    pub fn peak_brightness(mut self, nits: f32) -> Self {
195        self.inner.peak_brightness = nits;
196        self
197    }
198
199    /// Set the white-point in nits.
200    #[must_use]
201    pub fn white_point(mut self, nits: f32) -> Self {
202        self.inner.white_point = nits;
203        self
204    }
205
206    /// Set saturation correction (1.0 = neutral).
207    #[must_use]
208    pub fn saturation_correction(mut self, s: f32) -> Self {
209        self.inner.saturation_correction = s;
210        self
211    }
212
213    /// Set linear pre-exposure multiplier (1.0 = neutral).
214    #[must_use]
215    pub fn exposure(mut self, e: f32) -> Self {
216        self.inner.exposure = e;
217        self
218    }
219
220    /// Enable soft-knee blending starting at this fraction of peak brightness.
221    ///
222    /// For example, `0.7` enables the knee above 70 % of peak.
223    #[must_use]
224    pub fn knee_start(mut self, k: f32) -> Self {
225        self.inner.knee_start = k;
226        self
227    }
228
229    /// Finalise and return the configuration.
230    #[must_use]
231    pub fn build(self) -> ToneMappingConfig {
232        self.inner
233    }
234}
235
236// ─────────────────────────────────────────────────────────────
237//  HdrToneMapper
238// ─────────────────────────────────────────────────────────────
239
240/// HDR tone mapper operating on RGB f32 pixel data.
241///
242/// Pixels are expected to arrive in **linear, scene-referred light** normalised
243/// so that 1.0 == `config.peak_brightness` nits (i.e. values may exceed 1.0
244/// for content brighter than the configured peak).
245///
246/// Use [`HdrToneMapper::map_pixel`] for single pixels or
247/// [`HdrToneMapper::map_frame`] to process a flat interleaved RGB f32 buffer.
248#[derive(Clone, Debug)]
249pub struct HdrToneMapper {
250    config: ToneMappingConfig,
251    /// Pre-computed scale: maps nits → normalised value (1/peak_brightness).
252    nit_scale: f32,
253    /// Pre-computed white-point in normalised units.
254    white_norm: f32,
255}
256
257impl HdrToneMapper {
258    /// Create a new tone mapper from configuration.
259    #[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    /// Return a reference to the current configuration.
271    #[must_use]
272    pub fn config(&self) -> &ToneMappingConfig {
273        &self.config
274    }
275
276    // ── Public pixel / frame API ─────────────────────────────
277
278    /// Apply tone mapping to a single RGB pixel.
279    ///
280    /// **Input**: linear, scene-referred RGB where values may exceed 1.0.
281    /// The absolute maximum representable brightness is `config.peak_brightness`.
282    ///
283    /// **Output**: gamma-encoded SDR RGB in [0, 1].
284    #[must_use]
285    pub fn map_pixel(&self, rgb: [f32; 3]) -> [f32; 3] {
286        // 1. Pre-exposure
287        let rgb = [
288            rgb[0] * self.config.exposure,
289            rgb[1] * self.config.exposure,
290            rgb[2] * self.config.exposure,
291        ];
292
293        // 2. Optional soft knee
294        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        // 3. Operator dispatch
301        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        // 4. Saturation correction
311        let mapped = saturation_correct(mapped, self.config.saturation_correction);
312
313        // 5. Final clamp to ensure valid [0, 1] output
314        [
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    /// Process a flat interleaved RGB f32 frame in-place.
322    ///
323    /// The buffer must have length == `width * height * 3`.
324    /// Each triple `[r, g, b]` is processed by [`Self::map_pixel`].
325    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    /// Process a frame from a read-only input buffer into a new output `Vec`.
336    ///
337    /// Allocates a new `Vec<f32>` of the same length as `input`.
338    #[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    /// Apply the PQ EOTF to a normalised signal `[0, 1]` and return linear nits.
346    ///
347    /// Useful when building custom pipelines that feed PQ-encoded data.
348    #[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    /// Apply the BT.709 OETF (gamma encode) to a linear value in [0, 1].
354    #[must_use]
355    pub fn bt709_oetf(linear: f32) -> f32 {
356        bt709_oetf_f64(f64::from(linear)) as f32
357    }
358}
359
360// ─────────────────────────────────────────────────────────────
361//  Operator implementations
362// ─────────────────────────────────────────────────────────────
363
364/// Hard clamp — maps any value in (−∞, +∞) to [0, 1].
365#[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/// Reinhard (2002) global operator: `y = x / (1 + x)` per channel.
375///
376/// Fast and simple; highlights never clip but asymptotically approach 1.
377#[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/// Luminance-domain (local-style) Reinhard.
386///
387/// Computes the BT.2020 luminance `Y`, applies Reinhard to it, then scales
388/// each channel by `Y_out / Y_in` so hue and chroma ratios are preserved.
389///
390/// This avoids the hue shifts that per-channel Reinhard can introduce.
391#[inline]
392fn reinhard_local(rgb: [f32; 3]) -> [f32; 3] {
393    // BT.2020 luminance coefficients
394    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/// ACES filmic approximation (Narkowicz 2015).
415///
416/// `y = x(2.51x + 0.03) / (x(2.43x + 0.59) + 0.14)`
417///
418/// Produces a natural S-curve with good shadow lift and smooth highlight rolloff.
419#[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/// Hable / Uncharted 2 filmic tone curve.
446///
447/// Introduced by John Hable for Uncharted 2. Parameters match the original
448/// published settings.  Produces a rich filmic look with good highlight
449/// compression and a slight toe lift.
450#[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; // Shoulder strength
458        const B: f32 = 0.50; // Linear strength
459        const C: f32 = 0.10; // Linear angle
460        const D: f32 = 0.20; // Toe strength
461        const E: f32 = 0.02; // Toe numerator
462        const F: f32 = 0.30; // Toe denominator
463        ((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
475/// Full PQ (ST.2084) → SDR pipeline.
476///
477/// Steps:
478/// 1. Treat input as already-linear scene-referred; apply `nit_scale` to
479///    put it in [0, 1] where 1.0 == peak.
480/// 2. Apply luminance-domain Reinhard with white-point `white_norm`.
481/// 3. Apply BT.709 OETF.
482///
483/// `nit_scale`  = 1 / peak_brightness
484/// `white_norm` = white_point / peak_brightness
485fn pq_to_sdr(rgb: [f32; 3], nit_scale: f32, white_norm: f32) -> [f32; 3] {
486    // Normalise scene-referred input to [0, 1]
487    let norm = [rgb[0] * nit_scale, rgb[1] * nit_scale, rgb[2] * nit_scale];
488
489    // Luminance-domain Reinhard with white point
490    // Formula: L_out = L_in * (1 + L_in / w²) / (1 + L_in)
491    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    // Scale channels and apply BT.709 OETF
511    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// ─────────────────────────────────────────────────────────────
520//  Saturation correction
521// ─────────────────────────────────────────────────────────────
522
523/// Adjust saturation in linear RGB using the BT.709 luma coefficient.
524///
525/// Saturation is defined as the departure from achromatic (grey).
526/// `factor = 1.0` → identity; `factor = 0.0` → greyscale; `factor > 1.0` → boost.
527#[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    // BT.709 luminance
534    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
546// ─────────────────────────────────────────────────────────────
547//  Soft-knee helper
548// ─────────────────────────────────────────────────────────────
549
550/// Apply a soft cosine knee to the luminance channel above `knee_start`.
551///
552/// Above `knee_start` the values are blended smoothly toward 1.0 using a
553/// half-cosine, preventing hard clipping.  The knee ends at 1.0.
554fn 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// ─────────────────────────────────────────────────────────────
570//  Transfer-function helpers (f64 for numerical precision)
571// ─────────────────────────────────────────────────────────────
572
573/// ST.2084 PQ EOTF: signal [0, 1] → linear [0, 1] (1.0 == 10 000 nits).
574#[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/// ST.2084 PQ inverse EOTF: linear [0, 1] → signal [0, 1].
588#[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/// HLG EOTF: signal [0, 1] → linear [0, 1].
598#[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/// HLG inverse EOTF: linear [0, 1] → signal [0, 1].
609#[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
619/// BT.709 OETF: linear [0, 1] → gamma-encoded [0, 1].
620pub 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/// BT.709 EOTF: gamma-encoded [0, 1] → linear [0, 1].
630#[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// ─────────────────────────────────────────────────────────────
642//  BT.2020 → BT.709 gamut conversion matrix
643// ─────────────────────────────────────────────────────────────
644
645/// Convert linear RGB from BT.2020 primaries to BT.709 primaries.
646///
647/// The matrix is the product `M_XYZ→709 * M_2020→XYZ`, pre-computed for
648/// performance.  Values outside gamut are clipped to zero.
649#[must_use]
650pub fn bt2020_to_bt709(rgb: [f32; 3]) -> [f32; 3] {
651    // Derived from the chromatic adaptation (Bradford) chain.
652    // Row-major 3×3 matrix.
653    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
666// ─────────────────────────────────────────────────────────────
667//  Convenience constructors
668// ─────────────────────────────────────────────────────────────
669
670impl HdrToneMapper {
671    /// Create a mapper using [`ToneMapOperator::AcesFilmic`] with typical HDR10 parameters.
672    ///
673    /// Peak: 1 000 nits, white point: 203 nits, neutral saturation.
674    #[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    /// Create a mapper using [`ToneMapOperator::Hable`] with typical HDR10 parameters.
687    #[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    /// Create a mapper using [`ToneMapOperator::PqToSdr`] for HDR10 → SDR conversion.
699    #[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    /// Create a mapper using [`ToneMapOperator::ReinhardLocal`] with default HDR10 parameters.
711    #[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// ─────────────────────────────────────────────────────────────
724//  Unit tests
725// ─────────────────────────────────────────────────────────────
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730
731    // ── Helpers ────────────────────────────────────────────────
732
733    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        // Brighter input ⇒ brighter output in each channel.
739        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    // ── Black preservation ─────────────────────────────────────
753
754    #[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        // ACES has a very small lift at zero
776        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        // Hable has a toe; absolute black may lift slightly
783        assert!(all_in_range(out));
784        assert!(out[0] < 0.1 && out[1] < 0.1 && out[2] < 0.1);
785    }
786
787    // ── Output range [0, 1] for all operators ─────────────────
788
789    #[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    // ── Monotonicity ───────────────────────────────────────────
850
851    #[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    // ── Saturation correction ──────────────────────────────────
878
879    #[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        // With saturation = 0 all channels should equal luma
911        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    // ── Builder API ────────────────────────────────────────────
918
919    #[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    // ── Convenience constructors ───────────────────────────────
938
939    #[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    // ── Frame processing ───────────────────────────────────────
968
969    #[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    // ── Transfer function helpers ──────────────────────────────
993
994    #[test]
995    fn test_pq_eotf_bounds() {
996        // Signal 0 → linear 0
997        let v = pq_eotf_f64(0.0);
998        assert!(v.abs() < 1e-10);
999        // Signal 1 → linear 1 (10 000 nits normalised)
1000        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        // HLG EOTF(1.0) ≈ 1.0 (may be slightly above due to floating point)
1024        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    // ── Gamut conversion ───────────────────────────────────────
1051
1052    #[test]
1053    fn test_bt2020_to_bt709_white() {
1054        // D65 white should be roughly (1, 1, 1) in BT.709 as well
1055        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        // Out-of-gamut colours should not produce negative channels after clipping
1065        let out = bt2020_to_bt709([0.0, 1.0, 0.0]); // Pure BT.2020 green
1066        assert!(
1067            out.iter().all(|&v| v >= 0.0),
1068            "Negative after gamut convert: {out:?}"
1069        );
1070    }
1071
1072    // ── Soft-knee ─────────────────────────────────────────────
1073
1074    #[test]
1075    fn test_soft_knee_below_knee() {
1076        // Below knee_start values should be unchanged
1077        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        // Everything at or above knee_end maps to knee_end (1.0)
1087        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}