Skip to main content

oximedia_graph/filters/video/
tonemap.rs

1//! HDR to SDR tone mapping filter.
2//!
3//! This filter converts High Dynamic Range (HDR) video to Standard Dynamic Range (SDR)
4//! using various tone mapping algorithms and transfer function conversions.
5//!
6//! # Features
7//!
8//! - **Tone Mapping Algorithms:**
9//!   - Reinhard (simple and extended)
10//!   - ACES Filmic
11//!   - Hable (Uncharted 2)
12//!
13//! - **HDR Transfer Functions:**
14//!   - ST.2084 (PQ) - HDR10
15//!   - HLG (Hybrid Log-Gamma)
16//!   - Linear
17//!
18//! - **Color Space Conversion:**
19//!   - BT.2020 -> BT.709 primaries
20//!   - Proper chromatic adaptation
21//!
22//! - **HDR Metadata Support:**
23//!   - MaxCLL (Maximum Content Light Level)
24//!   - MaxFALL (Maximum Frame-Average Light Level)
25//!   - Mastering display metadata
26//!
27//! # Example
28//!
29//! ```ignore
30//! use oximedia_graph::filters::video::{TonemapFilter, TonemapConfig, TonemapAlgorithm};
31//! use oximedia_graph::node::NodeId;
32//!
33//! let config = TonemapConfig::new()
34//!     .with_algorithm(TonemapAlgorithm::Aces)
35//!     .with_peak_luminance(1000.0)
36//!     .with_target_luminance(100.0);
37//!
38//! let filter = TonemapFilter::new(NodeId(0), "hdr_tonemap", config);
39//! ```
40
41#![forbid(unsafe_code)]
42#![allow(clippy::cast_lossless)]
43#![allow(clippy::cast_precision_loss)]
44#![allow(clippy::cast_possible_truncation)]
45#![allow(clippy::cast_sign_loss)]
46#![allow(clippy::cast_possible_wrap)]
47#![allow(clippy::similar_names)]
48#![allow(clippy::many_single_char_names)]
49#![allow(clippy::missing_errors_doc)]
50#![allow(clippy::match_same_arms)]
51#![allow(clippy::doc_markdown)]
52#![allow(clippy::unused_self)]
53#![allow(clippy::unnecessary_cast)]
54#![allow(clippy::bool_to_int_with_if)]
55#![allow(clippy::needless_range_loop)]
56#![allow(clippy::too_many_lines)]
57#![allow(clippy::unnecessary_wraps)]
58#![allow(clippy::map_unwrap_or)]
59#![allow(clippy::no_effect_underscore_binding)]
60#![allow(clippy::unreadable_literal)]
61#![allow(clippy::too_many_arguments)]
62#![allow(dead_code)]
63
64use crate::error::{GraphError, GraphResult};
65use crate::frame::FilterFrame;
66use crate::node::{Node, NodeId, NodeState, NodeType};
67use crate::port::{InputPort, OutputPort, PortFormat, PortId, PortType, VideoPortFormat};
68use oximedia_codec::{ColorInfo, Plane, VideoFrame};
69use oximedia_core::PixelFormat;
70
71/// HDR transfer function (EOTF - Electro-Optical Transfer Function).
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
73pub enum TransferFunction {
74    /// Linear (no transfer function).
75    Linear,
76    /// ST.2084 (PQ) - Perceptual Quantizer used in HDR10.
77    #[default]
78    Pq,
79    /// HLG - Hybrid Log-Gamma used in broadcast HDR.
80    Hlg,
81    /// BT.709/sRGB gamma.
82    Bt709,
83}
84
85impl TransferFunction {
86    /// Apply the Electro-Optical Transfer Function (EOTF) to convert signal to linear.
87    /// Input is normalized [0, 1], output is linear light in nits.
88    #[must_use]
89    pub fn eotf(&self, signal: f64, peak_nits: f64) -> f64 {
90        match self {
91            Self::Linear => signal * peak_nits,
92            Self::Pq => pq_eotf(signal) * peak_nits,
93            Self::Hlg => hlg_eotf(signal) * peak_nits,
94            Self::Bt709 => bt709_eotf(signal) * peak_nits,
95        }
96    }
97
98    /// Apply the Opto-Electronic Transfer Function (OETF) to convert linear to signal.
99    /// Input is linear light in nits, output is normalized [0, 1].
100    #[must_use]
101    pub fn oetf(&self, linear: f64, peak_nits: f64) -> f64 {
102        let normalized = (linear / peak_nits).clamp(0.0, 1.0);
103        match self {
104            Self::Linear => normalized,
105            Self::Pq => pq_oetf(normalized),
106            Self::Hlg => hlg_oetf(normalized),
107            Self::Bt709 => bt709_oetf(normalized),
108        }
109    }
110}
111
112/// ST.2084 (PQ) EOTF - converts PQ signal to linear light (normalized).
113/// Returns linear light in range [0, 1] where 1.0 represents 10000 nits.
114#[must_use]
115fn pq_eotf(e: f64) -> f64 {
116    const M1: f64 = 2610.0 / 16384.0;
117    const M2: f64 = 2523.0 / 4096.0 * 128.0;
118    const C1: f64 = 3424.0 / 4096.0;
119    const C2: f64 = 2413.0 / 4096.0 * 32.0;
120    const C3: f64 = 2392.0 / 4096.0 * 32.0;
121
122    let e = e.clamp(0.0, 1.0);
123    let e_m2 = e.powf(1.0 / M2);
124    let num = (e_m2 - C1).max(0.0);
125    let den = C2 - C3 * e_m2;
126
127    if den.abs() < 1e-10 {
128        0.0
129    } else {
130        (num / den).powf(1.0 / M1)
131    }
132}
133
134/// ST.2084 (PQ) inverse EOTF - converts linear light to PQ signal.
135/// Input is linear light in range [0, 1] where 1.0 represents 10000 nits.
136#[must_use]
137fn pq_oetf(y: f64) -> f64 {
138    const M1: f64 = 2610.0 / 16384.0;
139    const M2: f64 = 2523.0 / 4096.0 * 128.0;
140    const C1: f64 = 3424.0 / 4096.0;
141    const C2: f64 = 2413.0 / 4096.0 * 32.0;
142    const C3: f64 = 2392.0 / 4096.0 * 32.0;
143
144    let y = y.clamp(0.0, 1.0);
145    let y_m1 = y.powf(M1);
146    let num = C1 + C2 * y_m1;
147    let den = 1.0 + C3 * y_m1;
148
149    (num / den).powf(M2)
150}
151
152/// HLG EOTF - converts HLG signal to linear light (normalized).
153/// Returns linear light in range [0, 1].
154#[must_use]
155fn hlg_eotf(e: f64) -> f64 {
156    const A: f64 = 0.17883277;
157    const B: f64 = 0.28466892;
158    const C: f64 = 0.55991073;
159
160    let e = e.clamp(0.0, 1.0);
161
162    if e <= 0.5 {
163        (e * e) / 3.0
164    } else {
165        (((e - C) / A).exp() + B) / 12.0
166    }
167}
168
169/// HLG inverse EOTF - converts linear light to HLG signal.
170/// Input is linear light in range [0, 1].
171#[must_use]
172fn hlg_oetf(y: f64) -> f64 {
173    const A: f64 = 0.17883277;
174    const B: f64 = 0.28466892;
175    const C: f64 = 0.55991073;
176
177    let y = y.clamp(0.0, 1.0);
178
179    if y <= 1.0 / 12.0 {
180        (3.0 * y).sqrt()
181    } else {
182        A * (12.0 * y - B).ln() + C
183    }
184}
185
186/// BT.709 EOTF (gamma 2.4 with linear segment).
187#[must_use]
188fn bt709_eotf(e: f64) -> f64 {
189    const BETA: f64 = 0.018053968510807;
190    const ALPHA: f64 = 1.09929682680944;
191    const GAMMA: f64 = 1.0 / 0.45;
192
193    let e = e.clamp(0.0, 1.0);
194
195    if e < BETA * 4.5 {
196        e / 4.5
197    } else {
198        ((e + (ALPHA - 1.0)) / ALPHA).powf(GAMMA)
199    }
200}
201
202/// BT.709 inverse EOTF.
203#[must_use]
204fn bt709_oetf(y: f64) -> f64 {
205    const BETA: f64 = 0.018053968510807;
206    const ALPHA: f64 = 1.09929682680944;
207    const GAMMA: f64 = 0.45;
208
209    let y = y.clamp(0.0, 1.0);
210
211    if y < BETA {
212        4.5 * y
213    } else {
214        ALPHA * y.powf(GAMMA) - (ALPHA - 1.0)
215    }
216}
217
218/// Tone mapping algorithm.
219#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
220pub enum TonemapAlgorithm {
221    /// Reinhard global tone mapping.
222    Reinhard,
223    /// Reinhard extended with white point parameter.
224    ReinhardExtended,
225    /// ACES filmic tone mapping (widely used in film production).
226    #[default]
227    Aces,
228    /// Hable (Uncharted 2) tone mapping.
229    Hable,
230    /// Simple linear clipping (no tone mapping).
231    Clip,
232}
233
234impl TonemapAlgorithm {
235    /// Apply the tone mapping curve to linear light.
236    /// Input and output are in linear space [0, inf).
237    #[must_use]
238    pub fn tonemap(&self, linear: f64, params: &TonemapParams) -> f64 {
239        match self {
240            Self::Reinhard => reinhard_tonemap(linear, params),
241            Self::ReinhardExtended => reinhard_extended_tonemap(linear, params),
242            Self::Aces => aces_tonemap(linear),
243            Self::Hable => hable_tonemap(linear),
244            Self::Clip => linear.clamp(0.0, 1.0),
245        }
246    }
247}
248
249/// Tone mapping parameters.
250#[derive(Clone, Copy, Debug)]
251pub struct TonemapParams {
252    /// Peak luminance of input HDR content in nits.
253    pub peak_luminance: f64,
254    /// Target peak luminance for SDR output in nits.
255    pub target_luminance: f64,
256    /// White point for Reinhard extended (in nits).
257    pub white_point: f64,
258    /// Exposure adjustment (stops).
259    pub exposure: f64,
260    /// Contrast adjustment.
261    pub contrast: f64,
262    /// Saturation adjustment.
263    pub saturation: f64,
264}
265
266impl Default for TonemapParams {
267    fn default() -> Self {
268        Self {
269            peak_luminance: 1000.0,
270            target_luminance: 100.0,
271            white_point: 1000.0,
272            exposure: 0.0,
273            contrast: 1.0,
274            saturation: 1.0,
275        }
276    }
277}
278
279/// Reinhard global tone mapping.
280/// Simple and fast: L_out = L_in / (1 + L_in)
281#[must_use]
282fn reinhard_tonemap(linear: f64, params: &TonemapParams) -> f64 {
283    // Normalize to [0, 1] range based on peak luminance
284    let normalized = linear / params.peak_luminance;
285
286    // Apply exposure
287    let exposed = normalized * 2.0_f64.powf(params.exposure);
288
289    // Reinhard formula
290    let mapped = exposed / (1.0 + exposed);
291
292    // Scale to target luminance
293    mapped * params.target_luminance / 100.0
294}
295
296/// Reinhard extended tone mapping with white point.
297/// Allows bright values to reach pure white.
298#[must_use]
299fn reinhard_extended_tonemap(linear: f64, params: &TonemapParams) -> f64 {
300    let normalized = linear / params.peak_luminance;
301    let exposed = normalized * 2.0_f64.powf(params.exposure);
302
303    let white = params.white_point / params.peak_luminance;
304    let white_sq = white * white;
305
306    // Extended Reinhard formula
307    let mapped = (exposed * (1.0 + exposed / white_sq)) / (1.0 + exposed);
308
309    mapped * params.target_luminance / 100.0
310}
311
312/// ACES filmic tone mapping.
313/// Industry standard curve used in film production.
314/// Based on ACES RRT (Reference Rendering Transform).
315#[must_use]
316fn aces_tonemap(linear: f64) -> f64 {
317    // ACES approximation (Narkowicz 2015)
318    const A: f64 = 2.51;
319    const B: f64 = 0.03;
320    const C: f64 = 2.43;
321    const D: f64 = 0.59;
322    const E: f64 = 0.14;
323
324    let x = linear.max(0.0);
325    let num = x * (A * x + B);
326    let den = x * (C * x + D) + E;
327
328    if den.abs() < 1e-10 {
329        0.0
330    } else {
331        (num / den).clamp(0.0, 1.0)
332    }
333}
334
335/// Hable (Uncharted 2) tone mapping.
336/// Developed for the Uncharted 2 video game, provides filmic look.
337#[must_use]
338fn hable_tonemap(linear: f64) -> f64 {
339    const EXPOSURE_BIAS: f64 = 2.0;
340
341    fn hable_partial(x: f64) -> f64 {
342        const A: f64 = 0.15;
343        const B: f64 = 0.50;
344        const C: f64 = 0.10;
345        const D: f64 = 0.20;
346        const E: f64 = 0.02;
347        const F: f64 = 0.30;
348
349        ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F
350    }
351
352    let curr = hable_partial(linear * EXPOSURE_BIAS);
353    let white = hable_partial(11.2);
354
355    if white.abs() < 1e-10 {
356        0.0
357    } else {
358        (curr / white).clamp(0.0, 1.0)
359    }
360}
361
362/// Color space primaries for wide color gamut conversion.
363#[derive(Clone, Copy, Debug, PartialEq)]
364pub struct ColorPrimaries {
365    /// Red primary (x, y) in CIE 1931 xy chromaticity.
366    pub red: (f64, f64),
367    /// Green primary (x, y).
368    pub green: (f64, f64),
369    /// Blue primary (x, y).
370    pub blue: (f64, f64),
371    /// White point (x, y).
372    pub white: (f64, f64),
373}
374
375impl ColorPrimaries {
376    /// BT.709 / sRGB primaries (standard HD).
377    pub const BT709: Self = Self {
378        red: (0.64, 0.33),
379        green: (0.30, 0.60),
380        blue: (0.15, 0.06),
381        white: (0.3127, 0.3290), // D65
382    };
383
384    /// BT.2020 primaries (wide color gamut for UHD/HDR).
385    pub const BT2020: Self = Self {
386        red: (0.708, 0.292),
387        green: (0.170, 0.797),
388        blue: (0.131, 0.046),
389        white: (0.3127, 0.3290), // D65
390    };
391
392    /// DCI-P3 primaries (digital cinema).
393    pub const DCI_P3: Self = Self {
394        red: (0.680, 0.320),
395        green: (0.265, 0.690),
396        blue: (0.150, 0.060),
397        white: (0.3127, 0.3290), // D65 (adapted)
398    };
399}
400
401/// 3x3 matrix for color space transformations.
402#[derive(Clone, Copy, Debug)]
403pub struct ColorMatrix3x3 {
404    /// Matrix elements in row-major order.
405    pub m: [[f64; 3]; 3],
406}
407
408impl ColorMatrix3x3 {
409    /// Create identity matrix.
410    #[must_use]
411    pub fn identity() -> Self {
412        Self {
413            m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
414        }
415    }
416
417    /// Apply matrix to RGB triplet.
418    #[must_use]
419    pub fn apply(&self, rgb: [f64; 3]) -> [f64; 3] {
420        [
421            self.m[0][0] * rgb[0] + self.m[0][1] * rgb[1] + self.m[0][2] * rgb[2],
422            self.m[1][0] * rgb[0] + self.m[1][1] * rgb[1] + self.m[1][2] * rgb[2],
423            self.m[2][0] * rgb[0] + self.m[2][1] * rgb[1] + self.m[2][2] * rgb[2],
424        ]
425    }
426
427    /// Compute matrix for converting from source to destination primaries.
428    /// Uses chromatic adaptation (Bradford method).
429    #[must_use]
430    pub fn primaries_conversion(src: &ColorPrimaries, dst: &ColorPrimaries) -> Self {
431        // Compute XYZ to RGB matrices for both color spaces
432        let src_to_xyz = Self::rgb_to_xyz_matrix(src);
433        let xyz_to_dst = Self::xyz_to_rgb_matrix(dst);
434
435        // Multiply: RGB_dst = (XYZ->RGB_dst) * (RGB_src->XYZ) * RGB_src
436        xyz_to_dst.multiply(&src_to_xyz)
437    }
438
439    /// Multiply two matrices.
440    #[must_use]
441    fn multiply(&self, other: &Self) -> Self {
442        let mut result = Self::identity();
443
444        for i in 0..3 {
445            for j in 0..3 {
446                result.m[i][j] = 0.0;
447                for k in 0..3 {
448                    result.m[i][j] += self.m[i][k] * other.m[k][j];
449                }
450            }
451        }
452
453        result
454    }
455
456    /// Compute RGB to XYZ conversion matrix for given primaries.
457    #[must_use]
458    fn rgb_to_xyz_matrix(primaries: &ColorPrimaries) -> Self {
459        // XYZ coordinates of primaries
460        let xr = primaries.red.0;
461        let yr = primaries.red.1;
462        let zr = 1.0 - xr - yr;
463
464        let xg = primaries.green.0;
465        let yg = primaries.green.1;
466        let zg = 1.0 - xg - yg;
467
468        let xb = primaries.blue.0;
469        let yb = primaries.blue.1;
470        let zb = 1.0 - xb - yb;
471
472        // White point
473        let xw = primaries.white.0;
474        let yw = primaries.white.1;
475        let yw_y = 1.0; // Normalized
476        let xw_xyz = (yw_y / yw) * xw;
477        let zw_xyz = (yw_y / yw) * (1.0 - xw - yw);
478
479        // Compute scaling factors
480        let det = xr * (yg * zb - yb * zg) - xg * (yr * zb - yb * zr) + xb * (yr * zg - yg * zr);
481
482        if det.abs() < 1e-10 {
483            return Self::identity();
484        }
485
486        let sr = (xw_xyz * (yg * zb - yb * zg) - xg * (yw_y * zb - zw_xyz * yb)
487            + xb * (yw_y * zg - zw_xyz * yg))
488            / det;
489        let sg = (xr * (yw_y * zb - zw_xyz * yb) - xw_xyz * (yr * zb - yb * zr)
490            + xb * (yr * zw_xyz - yw_y * zr))
491            / det;
492        let sb = (xr * (yg * zw_xyz - yw_y * zg) - xg * (yr * zw_xyz - yw_y * zr)
493            + xw_xyz * (yr * zg - yg * zr))
494            / det;
495
496        Self {
497            m: [
498                [sr * xr, sg * xg, sb * xb],
499                [sr * yr, sg * yg, sb * yb],
500                [sr * zr, sg * zg, sb * zb],
501            ],
502        }
503    }
504
505    /// Compute XYZ to RGB conversion matrix (inverse of RGB to XYZ).
506    #[must_use]
507    fn xyz_to_rgb_matrix(primaries: &ColorPrimaries) -> Self {
508        let m = Self::rgb_to_xyz_matrix(primaries);
509
510        // Compute inverse using cofactor method for 3x3 matrix
511        let det = m.m[0][0] * (m.m[1][1] * m.m[2][2] - m.m[1][2] * m.m[2][1])
512            - m.m[0][1] * (m.m[1][0] * m.m[2][2] - m.m[1][2] * m.m[2][0])
513            + m.m[0][2] * (m.m[1][0] * m.m[2][1] - m.m[1][1] * m.m[2][0]);
514
515        if det.abs() < 1e-10 {
516            return Self::identity();
517        }
518
519        let inv_det = 1.0 / det;
520
521        Self {
522            m: [
523                [
524                    inv_det * (m.m[1][1] * m.m[2][2] - m.m[1][2] * m.m[2][1]),
525                    inv_det * (m.m[0][2] * m.m[2][1] - m.m[0][1] * m.m[2][2]),
526                    inv_det * (m.m[0][1] * m.m[1][2] - m.m[0][2] * m.m[1][1]),
527                ],
528                [
529                    inv_det * (m.m[1][2] * m.m[2][0] - m.m[1][0] * m.m[2][2]),
530                    inv_det * (m.m[0][0] * m.m[2][2] - m.m[0][2] * m.m[2][0]),
531                    inv_det * (m.m[0][2] * m.m[1][0] - m.m[0][0] * m.m[1][2]),
532                ],
533                [
534                    inv_det * (m.m[1][0] * m.m[2][1] - m.m[1][1] * m.m[2][0]),
535                    inv_det * (m.m[0][1] * m.m[2][0] - m.m[0][0] * m.m[2][1]),
536                    inv_det * (m.m[0][0] * m.m[1][1] - m.m[0][1] * m.m[1][0]),
537                ],
538            ],
539        }
540    }
541}
542
543/// HDR metadata.
544#[derive(Clone, Copy, Debug, Default)]
545pub struct HdrMetadata {
546    /// Maximum Content Light Level in nits.
547    pub max_cll: Option<f64>,
548    /// Maximum Frame-Average Light Level in nits.
549    pub max_fall: Option<f64>,
550    /// Mastering display peak luminance in nits.
551    pub master_peak: Option<f64>,
552    /// Mastering display minimum luminance in nits.
553    pub master_min: Option<f64>,
554}
555
556impl HdrMetadata {
557    /// Estimate peak luminance from available metadata.
558    #[must_use]
559    pub fn estimate_peak_luminance(&self) -> f64 {
560        self.max_cll.or(self.master_peak).unwrap_or(1000.0) // Default to HDR10 nominal peak
561    }
562}
563
564/// Tone mapping configuration.
565#[derive(Clone, Debug)]
566pub struct TonemapConfig {
567    /// Tone mapping algorithm.
568    pub algorithm: TonemapAlgorithm,
569    /// Source transfer function.
570    pub source_transfer: TransferFunction,
571    /// Target transfer function (usually BT.709).
572    pub target_transfer: TransferFunction,
573    /// Source color primaries.
574    pub source_primaries: ColorPrimaries,
575    /// Target color primaries.
576    pub target_primaries: ColorPrimaries,
577    /// Tone mapping parameters.
578    pub params: TonemapParams,
579    /// HDR metadata.
580    pub metadata: HdrMetadata,
581    /// Target output format.
582    pub target_format: PixelFormat,
583    /// Perform color gamut conversion.
584    pub convert_gamut: bool,
585}
586
587impl TonemapConfig {
588    /// Create a new tone mapping configuration with defaults.
589    #[must_use]
590    pub fn new() -> Self {
591        Self {
592            algorithm: TonemapAlgorithm::default(),
593            source_transfer: TransferFunction::Pq,
594            target_transfer: TransferFunction::Bt709,
595            source_primaries: ColorPrimaries::BT2020,
596            target_primaries: ColorPrimaries::BT709,
597            params: TonemapParams::default(),
598            metadata: HdrMetadata::default(),
599            target_format: PixelFormat::Yuv420p,
600            convert_gamut: true,
601        }
602    }
603
604    /// Set the tone mapping algorithm.
605    #[must_use]
606    pub fn with_algorithm(mut self, algorithm: TonemapAlgorithm) -> Self {
607        self.algorithm = algorithm;
608        self
609    }
610
611    /// Set the source transfer function.
612    #[must_use]
613    pub fn with_source_transfer(mut self, transfer: TransferFunction) -> Self {
614        self.source_transfer = transfer;
615        self
616    }
617
618    /// Set the target transfer function.
619    #[must_use]
620    pub fn with_target_transfer(mut self, transfer: TransferFunction) -> Self {
621        self.target_transfer = transfer;
622        self
623    }
624
625    /// Set the source color primaries.
626    #[must_use]
627    pub fn with_source_primaries(mut self, primaries: ColorPrimaries) -> Self {
628        self.source_primaries = primaries;
629        self
630    }
631
632    /// Set the target color primaries.
633    #[must_use]
634    pub fn with_target_primaries(mut self, primaries: ColorPrimaries) -> Self {
635        self.target_primaries = primaries;
636        self
637    }
638
639    /// Set the peak luminance of the source content.
640    #[must_use]
641    pub fn with_peak_luminance(mut self, nits: f64) -> Self {
642        self.params.peak_luminance = nits;
643        self
644    }
645
646    /// Set the target luminance for SDR output.
647    #[must_use]
648    pub fn with_target_luminance(mut self, nits: f64) -> Self {
649        self.params.target_luminance = nits;
650        self
651    }
652
653    /// Set the white point for Reinhard extended.
654    #[must_use]
655    pub fn with_white_point(mut self, nits: f64) -> Self {
656        self.params.white_point = nits;
657        self
658    }
659
660    /// Set exposure adjustment in stops.
661    #[must_use]
662    pub fn with_exposure(mut self, stops: f64) -> Self {
663        self.params.exposure = stops;
664        self
665    }
666
667    /// Set contrast adjustment.
668    #[must_use]
669    pub fn with_contrast(mut self, contrast: f64) -> Self {
670        self.params.contrast = contrast;
671        self
672    }
673
674    /// Set saturation adjustment.
675    #[must_use]
676    pub fn with_saturation(mut self, saturation: f64) -> Self {
677        self.params.saturation = saturation;
678        self
679    }
680
681    /// Set HDR metadata.
682    #[must_use]
683    pub fn with_metadata(mut self, metadata: HdrMetadata) -> Self {
684        self.metadata = metadata;
685        // Update peak luminance from metadata if not explicitly set
686        if self.params.peak_luminance == 1000.0 {
687            self.params.peak_luminance = metadata.estimate_peak_luminance();
688        }
689        self
690    }
691
692    /// Set target output format.
693    #[must_use]
694    pub fn with_target_format(mut self, format: PixelFormat) -> Self {
695        self.target_format = format;
696        self
697    }
698
699    /// Enable or disable color gamut conversion.
700    #[must_use]
701    pub fn with_gamut_conversion(mut self, enable: bool) -> Self {
702        self.convert_gamut = enable;
703        self
704    }
705}
706
707impl Default for TonemapConfig {
708    fn default() -> Self {
709        Self::new()
710    }
711}
712
713/// HDR tone mapping filter.
714///
715/// Converts High Dynamic Range (HDR) video to Standard Dynamic Range (SDR)
716/// using various tone mapping algorithms and proper color space handling.
717pub struct TonemapFilter {
718    id: NodeId,
719    name: String,
720    state: NodeState,
721    inputs: Vec<InputPort>,
722    outputs: Vec<OutputPort>,
723    config: TonemapConfig,
724    gamut_matrix: ColorMatrix3x3,
725}
726
727impl TonemapFilter {
728    /// Create a new tone mapping filter.
729    #[must_use]
730    pub fn new(id: NodeId, name: impl Into<String>, config: TonemapConfig) -> Self {
731        // Pre-compute gamut conversion matrix
732        let gamut_matrix = if config.convert_gamut {
733            ColorMatrix3x3::primaries_conversion(&config.source_primaries, &config.target_primaries)
734        } else {
735            ColorMatrix3x3::identity()
736        };
737
738        let output_format = PortFormat::Video(VideoPortFormat::new(config.target_format));
739
740        Self {
741            id,
742            name: name.into(),
743            state: NodeState::Idle,
744            inputs: vec![InputPort::new(PortId(0), "input", PortType::Video)
745                .with_format(PortFormat::Video(VideoPortFormat::any()))],
746            outputs: vec![
747                OutputPort::new(PortId(0), "output", PortType::Video).with_format(output_format)
748            ],
749            config,
750            gamut_matrix,
751        }
752    }
753
754    /// Get the current configuration.
755    #[must_use]
756    pub fn config(&self) -> &TonemapConfig {
757        &self.config
758    }
759
760    /// Process a single pixel through the tone mapping pipeline.
761    /// Input: RGB in source color space (8-bit or normalized).
762    /// Output: RGB in target color space (8-bit).
763    fn tonemap_pixel(&self, r: u8, g: u8, b: u8) -> (u8, u8, u8) {
764        // 1. Normalize to [0, 1]
765        let r_norm = r as f64 / 255.0;
766        let g_norm = g as f64 / 255.0;
767        let b_norm = b as f64 / 255.0;
768
769        // 2. Apply source EOTF to convert to linear light
770        let peak = self.config.params.peak_luminance;
771        let r_lin = self.config.source_transfer.eotf(r_norm, peak);
772        let g_lin = self.config.source_transfer.eotf(g_norm, peak);
773        let b_lin = self.config.source_transfer.eotf(b_norm, peak);
774
775        // 3. Color gamut conversion (BT.2020 -> BT.709)
776        let rgb_lin = if self.config.convert_gamut {
777            let converted = self.gamut_matrix.apply([r_lin, g_lin, b_lin]);
778            [
779                converted[0].max(0.0),
780                converted[1].max(0.0),
781                converted[2].max(0.0),
782            ]
783        } else {
784            [r_lin, g_lin, b_lin]
785        };
786
787        // 4. Apply tone mapping operator
788        let r_mapped = self
789            .config
790            .algorithm
791            .tonemap(rgb_lin[0], &self.config.params);
792        let g_mapped = self
793            .config
794            .algorithm
795            .tonemap(rgb_lin[1], &self.config.params);
796        let b_mapped = self
797            .config
798            .algorithm
799            .tonemap(rgb_lin[2], &self.config.params);
800
801        // 5. Apply saturation adjustment
802        if (self.config.params.saturation - 1.0).abs() > 0.001 {
803            let luma = 0.2126 * r_mapped + 0.7152 * g_mapped + 0.0722 * b_mapped;
804            let sat = self.config.params.saturation;
805
806            let r_sat = luma + (r_mapped - luma) * sat;
807            let g_sat = luma + (g_mapped - luma) * sat;
808            let b_sat = luma + (b_mapped - luma) * sat;
809
810            // 6. Apply target OETF and convert back to 8-bit
811            let r_out = (self.config.target_transfer.oetf(r_sat.max(0.0), 100.0) * 255.0)
812                .clamp(0.0, 255.0) as u8;
813            let g_out = (self.config.target_transfer.oetf(g_sat.max(0.0), 100.0) * 255.0)
814                .clamp(0.0, 255.0) as u8;
815            let b_out = (self.config.target_transfer.oetf(b_sat.max(0.0), 100.0) * 255.0)
816                .clamp(0.0, 255.0) as u8;
817
818            (r_out, g_out, b_out)
819        } else {
820            // 6. Apply target OETF and convert back to 8-bit
821            let r_out =
822                (self.config.target_transfer.oetf(r_mapped, 100.0) * 255.0).clamp(0.0, 255.0) as u8;
823            let g_out =
824                (self.config.target_transfer.oetf(g_mapped, 100.0) * 255.0).clamp(0.0, 255.0) as u8;
825            let b_out =
826                (self.config.target_transfer.oetf(b_mapped, 100.0) * 255.0).clamp(0.0, 255.0) as u8;
827
828            (r_out, g_out, b_out)
829        }
830    }
831
832    /// Convert YUV frame to RGB for processing.
833    fn yuv_to_rgb(&self, frame: &VideoFrame) -> Vec<u8> {
834        let width = frame.width as usize;
835        let height = frame.height as usize;
836
837        let y_plane = frame.planes.first();
838        let u_plane = frame.planes.get(1);
839        let v_plane = frame.planes.get(2);
840
841        let (h_sub, v_sub) = frame.format.chroma_subsampling();
842        let mut rgb_data = vec![0u8; width * height * 3];
843
844        // BT.2020 matrix for YUV to RGB conversion
845        const KR: f64 = 0.2627;
846        const KB: f64 = 0.0593;
847        const KG: f64 = 1.0 - KR - KB;
848
849        for y in 0..height {
850            for x in 0..width {
851                let y_val = y_plane
852                    .map(|p| p.row(y).get(x).copied().unwrap_or(16))
853                    .unwrap_or(16) as f64;
854
855                let chroma_x = x / h_sub as usize;
856                let chroma_y = y / v_sub as usize;
857
858                let u_val = u_plane
859                    .map(|p| p.row(chroma_y).get(chroma_x).copied().unwrap_or(128))
860                    .unwrap_or(128) as f64;
861                let v_val = v_plane
862                    .map(|p| p.row(chroma_y).get(chroma_x).copied().unwrap_or(128))
863                    .unwrap_or(128) as f64;
864
865                // YUV to RGB conversion (limited range)
866                let y_norm = (y_val - 16.0) * 255.0 / 219.0;
867                let cb = (u_val - 128.0) * 255.0 / 224.0;
868                let cr = (v_val - 128.0) * 255.0 / 224.0;
869
870                let r = y_norm + cr / (1.0 - KR) * KR;
871                let g = y_norm - cb / ((1.0 - KB) * KG) * KB - cr / ((1.0 - KR) * KG) * KR;
872                let b = y_norm + cb / (1.0 - KB) * KB;
873
874                let offset = (y * width + x) * 3;
875                rgb_data[offset] = r.clamp(0.0, 255.0) as u8;
876                rgb_data[offset + 1] = g.clamp(0.0, 255.0) as u8;
877                rgb_data[offset + 2] = b.clamp(0.0, 255.0) as u8;
878            }
879        }
880
881        rgb_data
882    }
883
884    /// Convert RGB back to YUV for output.
885    fn rgb_to_yuv(
886        &self,
887        rgb_data: &[u8],
888        width: usize,
889        height: usize,
890    ) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
891        // BT.709 matrix for RGB to YUV conversion
892        const KR: f64 = 0.2126;
893        const KB: f64 = 0.0722;
894        const KG: f64 = 1.0 - KR - KB;
895
896        let mut y_data = vec![0u8; width * height];
897        let chroma_width = width / 2;
898        let chroma_height = height / 2;
899        let mut u_data = vec![128u8; chroma_width * chroma_height];
900        let mut v_data = vec![128u8; chroma_width * chroma_height];
901
902        for y in 0..height {
903            for x in 0..width {
904                let offset = (y * width + x) * 3;
905                let r = rgb_data[offset] as f64;
906                let g = rgb_data[offset + 1] as f64;
907                let b = rgb_data[offset + 2] as f64;
908
909                // RGB to YUV conversion (limited range)
910                let y_val = KR * r + KG * g + KB * b;
911                let cb = (b - y_val) / (2.0 * (1.0 - KB));
912                let cr = (r - y_val) / (2.0 * (1.0 - KR));
913
914                let y_out = y_val * 219.0 / 255.0 + 16.0;
915                let cb_out = cb * 224.0 / 255.0 + 128.0;
916                let cr_out = cr * 224.0 / 255.0 + 128.0;
917
918                y_data[y * width + x] = y_out.clamp(16.0, 235.0) as u8;
919
920                // Subsample chroma (4:2:0)
921                if x % 2 == 0 && y % 2 == 0 {
922                    let chroma_x = x / 2;
923                    let chroma_y = y / 2;
924                    u_data[chroma_y * chroma_width + chroma_x] = cb_out.clamp(16.0, 240.0) as u8;
925                    v_data[chroma_y * chroma_width + chroma_x] = cr_out.clamp(16.0, 240.0) as u8;
926                }
927            }
928        }
929
930        (y_data, u_data, v_data)
931    }
932
933    /// Process an RGB frame directly.
934    fn process_rgb(&self, input: &VideoFrame) -> GraphResult<VideoFrame> {
935        let width = input.width as usize;
936        let height = input.height as usize;
937
938        let src_plane = input
939            .planes
940            .first()
941            .ok_or_else(|| GraphError::ProcessingError {
942                node: self.id,
943                message: "Missing RGB plane".to_string(),
944            })?;
945
946        let src_bpp = if input.format == PixelFormat::Rgba32 {
947            4
948        } else {
949            3
950        };
951        let mut output_rgb = vec![0u8; width * height * 3];
952
953        // Process each pixel through tone mapping pipeline
954        for y in 0..height {
955            for x in 0..width {
956                let row = src_plane.row(y);
957                let offset = x * src_bpp;
958
959                let r = row.get(offset).copied().unwrap_or(0);
960                let g = row.get(offset + 1).copied().unwrap_or(0);
961                let b = row.get(offset + 2).copied().unwrap_or(0);
962
963                let (r_out, g_out, b_out) = self.tonemap_pixel(r, g, b);
964
965                let out_offset = (y * width + x) * 3;
966                output_rgb[out_offset] = r_out;
967                output_rgb[out_offset + 1] = g_out;
968                output_rgb[out_offset + 2] = b_out;
969            }
970        }
971
972        // Convert to target format
973        if self.config.target_format.is_yuv() {
974            let (y_data, u_data, v_data) = self.rgb_to_yuv(&output_rgb, width, height);
975
976            let mut output = VideoFrame::new(self.config.target_format, input.width, input.height);
977            output.timestamp = input.timestamp;
978            output.frame_type = input.frame_type;
979            output.color_info = ColorInfo {
980                full_range: false,
981                ..input.color_info
982            };
983
984            let chroma_width = width / 2;
985            output.planes.push(Plane::new(y_data, width));
986            output.planes.push(Plane::new(u_data, chroma_width));
987            output.planes.push(Plane::new(v_data, chroma_width));
988
989            Ok(output)
990        } else {
991            let mut output = VideoFrame::new(self.config.target_format, input.width, input.height);
992            output.timestamp = input.timestamp;
993            output.frame_type = input.frame_type;
994            output.color_info = input.color_info;
995            output.planes.push(Plane::new(output_rgb, width * 3));
996
997            Ok(output)
998        }
999    }
1000
1001    /// Process a YUV frame.
1002    fn process_yuv(&self, input: &VideoFrame) -> GraphResult<VideoFrame> {
1003        let width = input.width as usize;
1004        let height = input.height as usize;
1005
1006        // Convert YUV to RGB
1007        let rgb_data = self.yuv_to_rgb(input);
1008
1009        // Process each pixel through tone mapping
1010        let mut output_rgb = vec![0u8; width * height * 3];
1011
1012        for y in 0..height {
1013            for x in 0..width {
1014                let offset = (y * width + x) * 3;
1015                let r = rgb_data[offset];
1016                let g = rgb_data[offset + 1];
1017                let b = rgb_data[offset + 2];
1018
1019                let (r_out, g_out, b_out) = self.tonemap_pixel(r, g, b);
1020
1021                output_rgb[offset] = r_out;
1022                output_rgb[offset + 1] = g_out;
1023                output_rgb[offset + 2] = b_out;
1024            }
1025        }
1026
1027        // Convert back to YUV
1028        if self.config.target_format.is_yuv() {
1029            let (y_data, u_data, v_data) = self.rgb_to_yuv(&output_rgb, width, height);
1030
1031            let mut output = VideoFrame::new(self.config.target_format, input.width, input.height);
1032            output.timestamp = input.timestamp;
1033            output.frame_type = input.frame_type;
1034            output.color_info = ColorInfo {
1035                full_range: false,
1036                ..input.color_info
1037            };
1038
1039            let chroma_width = width / 2;
1040            output.planes.push(Plane::new(y_data, width));
1041            output.planes.push(Plane::new(u_data, chroma_width));
1042            output.planes.push(Plane::new(v_data, chroma_width));
1043
1044            Ok(output)
1045        } else {
1046            let mut output = VideoFrame::new(self.config.target_format, input.width, input.height);
1047            output.timestamp = input.timestamp;
1048            output.frame_type = input.frame_type;
1049            output.color_info = input.color_info;
1050            output.planes.push(Plane::new(output_rgb, width * 3));
1051
1052            Ok(output)
1053        }
1054    }
1055
1056    /// Convert a frame through the tone mapping pipeline.
1057    fn tonemap_frame(&self, input: &VideoFrame) -> GraphResult<VideoFrame> {
1058        if input.format.is_yuv() {
1059            self.process_yuv(input)
1060        } else {
1061            self.process_rgb(input)
1062        }
1063    }
1064}
1065
1066impl Node for TonemapFilter {
1067    fn id(&self) -> NodeId {
1068        self.id
1069    }
1070
1071    fn name(&self) -> &str {
1072        &self.name
1073    }
1074
1075    fn node_type(&self) -> NodeType {
1076        NodeType::Filter
1077    }
1078
1079    fn state(&self) -> NodeState {
1080        self.state
1081    }
1082
1083    fn set_state(&mut self, state: NodeState) -> GraphResult<()> {
1084        if !self.state.can_transition_to(state) {
1085            return Err(GraphError::InvalidStateTransition {
1086                node: self.id,
1087                from: self.state.to_string(),
1088                to: state.to_string(),
1089            });
1090        }
1091        self.state = state;
1092        Ok(())
1093    }
1094
1095    fn inputs(&self) -> &[InputPort] {
1096        &self.inputs
1097    }
1098
1099    fn outputs(&self) -> &[OutputPort] {
1100        &self.outputs
1101    }
1102
1103    fn process(&mut self, input: Option<FilterFrame>) -> GraphResult<Option<FilterFrame>> {
1104        match input {
1105            Some(FilterFrame::Video(frame)) => {
1106                let tonemapped = self.tonemap_frame(&frame)?;
1107                Ok(Some(FilterFrame::Video(tonemapped)))
1108            }
1109            Some(_) => Err(GraphError::PortTypeMismatch {
1110                expected: "Video".to_string(),
1111                actual: "Audio".to_string(),
1112            }),
1113            None => Ok(None),
1114        }
1115    }
1116}