Skip to main content

oximedia_codec/
color_range.rs

1#![allow(dead_code)]
2//! Color range and level mapping for codec output.
3//!
4//! Handles the distinction between limited/full range color representations
5//! used by video codecs (BT.601/709/2020), and provides conversions between them.
6//!
7//! # Overview
8//!
9//! Video codecs typically encode luma in the range \[16, 235\] (limited) while
10//! full-range uses \[0, 255\]. This module provides utilities for converting
11//! between these representations, verifying compliance, and clamping values.
12
13/// Color range type for video signals.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
15pub enum ColorRange {
16    /// Limited range (BT.601/709): Y \[16..235\], UV \[16..240\].
17    Limited,
18    /// Full range: Y \[0..255\], UV \[0..255\].
19    Full,
20}
21
22impl Default for ColorRange {
23    fn default() -> Self {
24        Self::Limited
25    }
26}
27
28impl ColorRange {
29    /// Returns true if this is limited range.
30    pub fn is_limited(&self) -> bool {
31        *self == Self::Limited
32    }
33
34    /// Returns true if this is full range.
35    pub fn is_full(&self) -> bool {
36        *self == Self::Full
37    }
38}
39
40/// Bit depth for color levels.
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
42pub enum BitDepth {
43    /// 8-bit depth (0..255).
44    Eight,
45    /// 10-bit depth (0..1023).
46    Ten,
47    /// 12-bit depth (0..4095).
48    Twelve,
49}
50
51impl BitDepth {
52    /// Maximum value for this bit depth.
53    #[allow(clippy::cast_precision_loss)]
54    pub fn max_value(&self) -> u16 {
55        match self {
56            Self::Eight => 255,
57            Self::Ten => 1023,
58            Self::Twelve => 4095,
59        }
60    }
61
62    /// Number of bits.
63    pub fn bits(&self) -> u8 {
64        match self {
65            Self::Eight => 8,
66            Self::Ten => 10,
67            Self::Twelve => 12,
68        }
69    }
70}
71
72impl Default for BitDepth {
73    fn default() -> Self {
74        Self::Eight
75    }
76}
77
78/// Level range for a given color range and bit depth.
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub struct LevelRange {
81    /// Minimum luma value.
82    pub luma_min: u16,
83    /// Maximum luma value.
84    pub luma_max: u16,
85    /// Minimum chroma value.
86    pub chroma_min: u16,
87    /// Maximum chroma value.
88    pub chroma_max: u16,
89}
90
91impl LevelRange {
92    /// Create a level range for the given color range and bit depth.
93    #[allow(clippy::cast_precision_loss)]
94    pub fn new(range: ColorRange, depth: BitDepth) -> Self {
95        let shift = depth.bits() - 8;
96        match range {
97            ColorRange::Limited => Self {
98                luma_min: 16 << shift,
99                luma_max: 235 << shift,
100                chroma_min: 16 << shift,
101                chroma_max: 240 << shift,
102            },
103            ColorRange::Full => Self {
104                luma_min: 0,
105                luma_max: depth.max_value(),
106                chroma_min: 0,
107                chroma_max: depth.max_value(),
108            },
109        }
110    }
111
112    /// Luma span (max - min).
113    pub fn luma_span(&self) -> u16 {
114        self.luma_max - self.luma_min
115    }
116
117    /// Chroma span (max - min).
118    pub fn chroma_span(&self) -> u16 {
119        self.chroma_max - self.chroma_min
120    }
121}
122
123/// Clamp a luma value to the given level range.
124pub fn clamp_luma(value: u16, levels: &LevelRange) -> u16 {
125    value.clamp(levels.luma_min, levels.luma_max)
126}
127
128/// Clamp a chroma value to the given level range.
129pub fn clamp_chroma(value: u16, levels: &LevelRange) -> u16 {
130    value.clamp(levels.chroma_min, levels.chroma_max)
131}
132
133/// Convert a luma value from limited range to full range.
134#[allow(clippy::cast_precision_loss)]
135pub fn limited_to_full_luma(value: u16, depth: BitDepth) -> u16 {
136    let limited = LevelRange::new(ColorRange::Limited, depth);
137    let max = depth.max_value();
138    if value <= limited.luma_min {
139        return 0;
140    }
141    if value >= limited.luma_max {
142        return max;
143    }
144    let span = limited.luma_span() as f64;
145    let scaled = (value - limited.luma_min) as f64 / span * max as f64;
146    (scaled.round() as u16).min(max)
147}
148
149/// Convert a luma value from full range to limited range.
150#[allow(clippy::cast_precision_loss)]
151pub fn full_to_limited_luma(value: u16, depth: BitDepth) -> u16 {
152    let limited = LevelRange::new(ColorRange::Limited, depth);
153    let max = depth.max_value();
154    let span = limited.luma_span() as f64;
155    let scaled = value as f64 / max as f64 * span + limited.luma_min as f64;
156    (scaled.round() as u16).clamp(limited.luma_min, limited.luma_max)
157}
158
159/// Convert a chroma value from limited range to full range.
160#[allow(clippy::cast_precision_loss)]
161pub fn limited_to_full_chroma(value: u16, depth: BitDepth) -> u16 {
162    let limited = LevelRange::new(ColorRange::Limited, depth);
163    let max = depth.max_value();
164    if value <= limited.chroma_min {
165        return 0;
166    }
167    if value >= limited.chroma_max {
168        return max;
169    }
170    let span = limited.chroma_span() as f64;
171    let scaled = (value - limited.chroma_min) as f64 / span * max as f64;
172    (scaled.round() as u16).min(max)
173}
174
175/// Convert a chroma value from full range to limited range.
176#[allow(clippy::cast_precision_loss)]
177pub fn full_to_limited_chroma(value: u16, depth: BitDepth) -> u16 {
178    let limited = LevelRange::new(ColorRange::Limited, depth);
179    let max = depth.max_value();
180    let span = limited.chroma_span() as f64;
181    let scaled = value as f64 / max as f64 * span + limited.chroma_min as f64;
182    (scaled.round() as u16).clamp(limited.chroma_min, limited.chroma_max)
183}
184
185/// Result of a compliance check on a buffer.
186#[derive(Clone, Debug, PartialEq, Eq)]
187pub struct ComplianceReport {
188    /// Number of out-of-range luma samples.
189    pub luma_violations: usize,
190    /// Number of out-of-range chroma samples.
191    pub chroma_violations: usize,
192    /// Total samples checked.
193    pub total_samples: usize,
194}
195
196impl ComplianceReport {
197    /// Returns true if all samples are within the valid range.
198    pub fn is_compliant(&self) -> bool {
199        self.luma_violations == 0 && self.chroma_violations == 0
200    }
201
202    /// Violation ratio as a fraction of total samples.
203    #[allow(clippy::cast_precision_loss)]
204    pub fn violation_ratio(&self) -> f64 {
205        if self.total_samples == 0 {
206            return 0.0;
207        }
208        (self.luma_violations + self.chroma_violations) as f64 / self.total_samples as f64
209    }
210}
211
212/// Check luma buffer compliance against a level range.
213pub fn check_luma_compliance(samples: &[u16], levels: &LevelRange) -> usize {
214    samples
215        .iter()
216        .filter(|&&v| v < levels.luma_min || v > levels.luma_max)
217        .count()
218}
219
220/// Check chroma buffer compliance against a level range.
221pub fn check_chroma_compliance(samples: &[u16], levels: &LevelRange) -> usize {
222    samples
223        .iter()
224        .filter(|&&v| v < levels.chroma_min || v > levels.chroma_max)
225        .count()
226}
227
228/// Convert an entire luma buffer from one range to another.
229#[allow(clippy::cast_precision_loss)]
230pub fn convert_luma_buffer(
231    src: &[u16],
232    src_range: ColorRange,
233    dst_range: ColorRange,
234    depth: BitDepth,
235) -> Vec<u16> {
236    if src_range == dst_range {
237        return src.to_vec();
238    }
239    match (src_range, dst_range) {
240        (ColorRange::Limited, ColorRange::Full) => src
241            .iter()
242            .map(|&v| limited_to_full_luma(v, depth))
243            .collect(),
244        (ColorRange::Full, ColorRange::Limited) => src
245            .iter()
246            .map(|&v| full_to_limited_luma(v, depth))
247            .collect(),
248        _ => src.to_vec(),
249    }
250}
251
252/// Convert an entire chroma buffer from one range to another.
253#[allow(clippy::cast_precision_loss)]
254pub fn convert_chroma_buffer(
255    src: &[u16],
256    src_range: ColorRange,
257    dst_range: ColorRange,
258    depth: BitDepth,
259) -> Vec<u16> {
260    if src_range == dst_range {
261        return src.to_vec();
262    }
263    match (src_range, dst_range) {
264        (ColorRange::Limited, ColorRange::Full) => src
265            .iter()
266            .map(|&v| limited_to_full_chroma(v, depth))
267            .collect(),
268        (ColorRange::Full, ColorRange::Limited) => src
269            .iter()
270            .map(|&v| full_to_limited_chroma(v, depth))
271            .collect(),
272        _ => src.to_vec(),
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn test_color_range_default() {
282        let range = ColorRange::default();
283        assert_eq!(range, ColorRange::Limited);
284    }
285
286    #[test]
287    fn test_color_range_predicates() {
288        assert!(ColorRange::Limited.is_limited());
289        assert!(!ColorRange::Limited.is_full());
290        assert!(ColorRange::Full.is_full());
291        assert!(!ColorRange::Full.is_limited());
292    }
293
294    #[test]
295    fn test_bit_depth_max_value() {
296        assert_eq!(BitDepth::Eight.max_value(), 255);
297        assert_eq!(BitDepth::Ten.max_value(), 1023);
298        assert_eq!(BitDepth::Twelve.max_value(), 4095);
299    }
300
301    #[test]
302    fn test_bit_depth_bits() {
303        assert_eq!(BitDepth::Eight.bits(), 8);
304        assert_eq!(BitDepth::Ten.bits(), 10);
305        assert_eq!(BitDepth::Twelve.bits(), 12);
306    }
307
308    #[test]
309    fn test_level_range_limited_8bit() {
310        let levels = LevelRange::new(ColorRange::Limited, BitDepth::Eight);
311        assert_eq!(levels.luma_min, 16);
312        assert_eq!(levels.luma_max, 235);
313        assert_eq!(levels.chroma_min, 16);
314        assert_eq!(levels.chroma_max, 240);
315    }
316
317    #[test]
318    fn test_level_range_full_8bit() {
319        let levels = LevelRange::new(ColorRange::Full, BitDepth::Eight);
320        assert_eq!(levels.luma_min, 0);
321        assert_eq!(levels.luma_max, 255);
322        assert_eq!(levels.chroma_min, 0);
323        assert_eq!(levels.chroma_max, 255);
324    }
325
326    #[test]
327    fn test_level_range_limited_10bit() {
328        let levels = LevelRange::new(ColorRange::Limited, BitDepth::Ten);
329        assert_eq!(levels.luma_min, 64);
330        assert_eq!(levels.luma_max, 940);
331        assert_eq!(levels.chroma_min, 64);
332        assert_eq!(levels.chroma_max, 960);
333    }
334
335    #[test]
336    fn test_clamp_luma() {
337        let levels = LevelRange::new(ColorRange::Limited, BitDepth::Eight);
338        assert_eq!(clamp_luma(0, &levels), 16);
339        assert_eq!(clamp_luma(128, &levels), 128);
340        assert_eq!(clamp_luma(255, &levels), 235);
341    }
342
343    #[test]
344    fn test_limited_to_full_luma_8bit() {
345        let depth = BitDepth::Eight;
346        assert_eq!(limited_to_full_luma(16, depth), 0);
347        assert_eq!(limited_to_full_luma(235, depth), 255);
348        // Mid-range should map roughly to mid-range
349        let mid = limited_to_full_luma(126, depth);
350        assert!(mid > 100 && mid < 160);
351    }
352
353    #[test]
354    fn test_full_to_limited_luma_8bit() {
355        let depth = BitDepth::Eight;
356        assert_eq!(full_to_limited_luma(0, depth), 16);
357        assert_eq!(full_to_limited_luma(255, depth), 235);
358    }
359
360    #[test]
361    fn test_roundtrip_luma() {
362        let depth = BitDepth::Eight;
363        for v in (16..=235).step_by(10) {
364            let full = limited_to_full_luma(v, depth);
365            let back = full_to_limited_luma(full, depth);
366            assert!(
367                (back as i32 - v as i32).unsigned_abs() <= 1,
368                "roundtrip failed for {v}"
369            );
370        }
371    }
372
373    #[test]
374    fn test_compliance_report() {
375        let report = ComplianceReport {
376            luma_violations: 0,
377            chroma_violations: 0,
378            total_samples: 100,
379        };
380        assert!(report.is_compliant());
381        assert!((report.violation_ratio() - 0.0).abs() < f64::EPSILON);
382
383        let bad = ComplianceReport {
384            luma_violations: 5,
385            chroma_violations: 3,
386            total_samples: 100,
387        };
388        assert!(!bad.is_compliant());
389        assert!((bad.violation_ratio() - 0.08).abs() < f64::EPSILON);
390    }
391
392    #[test]
393    fn test_check_luma_compliance() {
394        let levels = LevelRange::new(ColorRange::Limited, BitDepth::Eight);
395        let samples = vec![0, 16, 128, 235, 255];
396        let violations = check_luma_compliance(&samples, &levels);
397        assert_eq!(violations, 2); // 0 and 255
398    }
399
400    #[test]
401    fn test_convert_luma_buffer_same_range() {
402        let buf = vec![16, 128, 235];
403        let result = convert_luma_buffer(
404            &buf,
405            ColorRange::Limited,
406            ColorRange::Limited,
407            BitDepth::Eight,
408        );
409        assert_eq!(result, buf);
410    }
411
412    #[test]
413    fn test_convert_chroma_buffer() {
414        let buf = vec![16, 128, 240];
415        let result =
416            convert_chroma_buffer(&buf, ColorRange::Limited, ColorRange::Full, BitDepth::Eight);
417        assert_eq!(result[0], 0);
418        assert_eq!(result[2], 255);
419    }
420}