Skip to main content

edgefirst_tensor/
colorimetry.rs

1// SPDX-FileCopyrightText: Copyright 2026 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! Colorimetry metadata for image/video tensors.
5//!
6//! Four orthogonal axes mirroring V4L2's `struct v4l2_format` colorimetry
7//! fields and `libcamera::ColorSpace`, named to match the EdgeFirst
8//! `CameraFrame.msg` schema so values round-trip through the ROS layer.
9//! Each enum is `#[non_exhaustive]`; unknown/`_DEFAULT` values map to `None`.
10
11use core::fmt;
12use serde::{Deserialize, Serialize};
13
14// V4L2 UAPI constants (stable kernel ABI) — mirrored from <linux/videodev2.h>.
15const V4L2_COLORSPACE_SMPTE170M: u32 = 1;
16const V4L2_COLORSPACE_REC709: u32 = 3;
17const V4L2_COLORSPACE_470_SYSTEM_M: u32 = 5;
18const V4L2_COLORSPACE_470_SYSTEM_BG: u32 = 6;
19const V4L2_COLORSPACE_JPEG: u32 = 7;
20const V4L2_COLORSPACE_SRGB: u32 = 8;
21const V4L2_COLORSPACE_BT2020: u32 = 10;
22const V4L2_XFER_FUNC_709: u32 = 1;
23const V4L2_XFER_FUNC_SRGB: u32 = 2;
24const V4L2_XFER_FUNC_NONE: u32 = 5;
25const V4L2_XFER_FUNC_SMPTE2084: u32 = 7;
26const V4L2_YCBCR_ENC_DEFAULT: u32 = 0;
27const V4L2_YCBCR_ENC_601: u32 = 1;
28const V4L2_YCBCR_ENC_709: u32 = 2;
29const V4L2_YCBCR_ENC_BT2020: u32 = 6;
30const V4L2_QUANTIZATION_DEFAULT: u32 = 0;
31const V4L2_QUANTIZATION_FULL_RANGE: u32 = 1;
32const V4L2_QUANTIZATION_LIM_RANGE: u32 = 2;
33
34/// Color primaries (`color_space` in the EdgeFirst schema).
35#[non_exhaustive]
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
37pub enum ColorSpace {
38    Bt709,
39    Bt2020,
40    Srgb,
41    Smpte170m,
42}
43
44/// Transfer function (`color_transfer` in the EdgeFirst schema).
45///
46/// **Limitation:** this axis is stored, propagated, and round-tripped, but it is
47/// **not applied** by any conversion backend. All YUV↔RGB paths operate only on
48/// the matrix ([`ColorEncoding`]) and range ([`ColorRange`]); the transfer
49/// function (gamma / TRC) is assumed to be the platform-native curve and is left
50/// unchanged. HDR transfer curves ([`Self::Pq`] / [`Self::Hlg`]) are therefore
51/// *not* tone-mapped — consumers needing linear-light or HDR handling must apply
52/// the curve themselves.
53#[non_exhaustive]
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
55pub enum ColorTransfer {
56    Bt709,
57    Srgb,
58    Pq,
59    /// Hybrid Log-Gamma. Present for EdgeFirst-schema / libcamera parity; the
60    /// V4L2 UAPI defines no `V4L2_XFER_FUNC_HLG`, so `from_v4l2` never yields
61    /// this variant.
62    Hlg,
63    Linear,
64}
65
66/// YCbCr encoding matrix (`color_encoding` in the EdgeFirst schema).
67#[non_exhaustive]
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
69pub enum ColorEncoding {
70    Bt601,
71    Bt709,
72    Bt2020,
73}
74
75/// Quantization range (`color_range` in the EdgeFirst schema).
76#[non_exhaustive]
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
78pub enum ColorRange {
79    Full,
80    Limited,
81}
82
83impl ColorSpace {
84    /// Parse the schema's short string label — the inverse of [`Self::as_str`].
85    ///
86    /// Returns `None` for `""` and for any label this build does not know.
87    /// Colorimetry is descriptive metadata: a consumer that cannot name one
88    /// axis can still use the pixels, so an unknown label degrades to
89    /// "unspecified" rather than failing the whole import.
90    pub fn from_str_code(s: &str) -> Option<Self> {
91        match s {
92            "bt709" => Some(Self::Bt709),
93            "bt2020" => Some(Self::Bt2020),
94            "srgb" => Some(Self::Srgb),
95            "smpte170m" => Some(Self::Smpte170m),
96            _ => None,
97        }
98    }
99
100    /// Short string label matching the EdgeFirst schema.
101    pub fn as_str(self) -> &'static str {
102        match self {
103            Self::Bt709 => "bt709",
104            Self::Bt2020 => "bt2020",
105            Self::Srgb => "srgb",
106            Self::Smpte170m => "smpte170m",
107        }
108    }
109
110    /// Map a raw V4L2 `colorspace` field to a [`ColorSpace`].
111    ///
112    /// Returns `None` for `V4L2_COLORSPACE_DEFAULT` (0) and any
113    /// unrecognised value.
114    pub fn from_v4l2(v: u32) -> Option<Self> {
115        match v {
116            V4L2_COLORSPACE_SMPTE170M => Some(Self::Smpte170m),
117            V4L2_COLORSPACE_REC709 => Some(Self::Bt709),
118            // legacy NTSC (470M) / PAL-SECAM (470BG): close enough to SMPTE 170M primaries for conversion
119            V4L2_COLORSPACE_470_SYSTEM_M | V4L2_COLORSPACE_470_SYSTEM_BG => Some(Self::Smpte170m),
120            V4L2_COLORSPACE_JPEG | V4L2_COLORSPACE_SRGB => Some(Self::Srgb),
121            V4L2_COLORSPACE_BT2020 => Some(Self::Bt2020),
122            _ => None,
123        }
124    }
125}
126
127impl ColorTransfer {
128    /// Parse the schema's short string label — the inverse of [`Self::as_str`].
129    ///
130    /// Returns `None` for `""` and for any label this build does not know.
131    /// Colorimetry is descriptive metadata: a consumer that cannot name one
132    /// axis can still use the pixels, so an unknown label degrades to
133    /// "unspecified" rather than failing the whole import.
134    pub fn from_str_code(s: &str) -> Option<Self> {
135        match s {
136            "bt709" => Some(Self::Bt709),
137            "srgb" => Some(Self::Srgb),
138            "pq" => Some(Self::Pq),
139            "hlg" => Some(Self::Hlg),
140            "linear" => Some(Self::Linear),
141            _ => None,
142        }
143    }
144
145    /// Short string label matching the EdgeFirst schema.
146    pub fn as_str(self) -> &'static str {
147        match self {
148            Self::Bt709 => "bt709",
149            Self::Srgb => "srgb",
150            Self::Pq => "pq",
151            Self::Hlg => "hlg",
152            Self::Linear => "linear",
153        }
154    }
155
156    /// Map a raw V4L2 `xfer_func` field to a [`ColorTransfer`].
157    ///
158    /// Returns `None` for `V4L2_XFER_FUNC_DEFAULT` (0) and any
159    /// unrecognised value.
160    ///
161    /// Note: OPRGB (3), SMPTE240M (4), and DCI_P3 (6) have no HAL equivalent
162    /// and map to `None`. [`ColorTransfer::Hlg`] is never produced because the
163    /// V4L2 UAPI defines no `V4L2_XFER_FUNC_HLG` value.
164    pub fn from_v4l2(v: u32) -> Option<Self> {
165        match v {
166            V4L2_XFER_FUNC_709 => Some(Self::Bt709),
167            V4L2_XFER_FUNC_SRGB => Some(Self::Srgb),
168            V4L2_XFER_FUNC_NONE => Some(Self::Linear),
169            V4L2_XFER_FUNC_SMPTE2084 => Some(Self::Pq),
170            _ => None,
171        }
172    }
173}
174
175impl ColorEncoding {
176    /// Parse the schema's short string label — the inverse of [`Self::as_str`].
177    ///
178    /// Returns `None` for `""` and for any label this build does not know.
179    /// Colorimetry is descriptive metadata: a consumer that cannot name one
180    /// axis can still use the pixels, so an unknown label degrades to
181    /// "unspecified" rather than failing the whole import.
182    pub fn from_str_code(s: &str) -> Option<Self> {
183        match s {
184            "bt601" => Some(Self::Bt601),
185            "bt709" => Some(Self::Bt709),
186            "bt2020" => Some(Self::Bt2020),
187            _ => None,
188        }
189    }
190
191    /// Short string label matching the EdgeFirst schema.
192    pub fn as_str(self) -> &'static str {
193        match self {
194            Self::Bt601 => "bt601",
195            Self::Bt709 => "bt709",
196            Self::Bt2020 => "bt2020",
197        }
198    }
199
200    /// Map a raw V4L2 `ycbcr_enc` field to a [`ColorEncoding`].
201    ///
202    /// Returns `None` for `V4L2_YCBCR_ENC_DEFAULT` (0) and any
203    /// unrecognised value.
204    pub fn from_v4l2(v: u32) -> Option<Self> {
205        match v {
206            V4L2_YCBCR_ENC_601 => Some(Self::Bt601),
207            V4L2_YCBCR_ENC_709 => Some(Self::Bt709),
208            V4L2_YCBCR_ENC_BT2020 => Some(Self::Bt2020),
209            _ => None,
210        }
211    }
212}
213
214impl ColorRange {
215    /// Parse the schema's short string label — the inverse of [`Self::as_str`].
216    ///
217    /// Returns `None` for `""` and for any label this build does not know.
218    /// Colorimetry is descriptive metadata: a consumer that cannot name one
219    /// axis can still use the pixels, so an unknown label degrades to
220    /// "unspecified" rather than failing the whole import.
221    pub fn from_str_code(s: &str) -> Option<Self> {
222        match s {
223            "full" => Some(Self::Full),
224            "limited" => Some(Self::Limited),
225            _ => None,
226        }
227    }
228
229    /// Short string label matching the EdgeFirst schema.
230    pub fn as_str(self) -> &'static str {
231        match self {
232            Self::Full => "full",
233            Self::Limited => "limited",
234        }
235    }
236
237    /// Map a raw V4L2 `quantization` field to a [`ColorRange`].
238    ///
239    /// Returns `None` for `V4L2_QUANTIZATION_DEFAULT` (0) and any
240    /// unrecognised value.
241    pub fn from_v4l2(v: u32) -> Option<Self> {
242        match v {
243            V4L2_QUANTIZATION_FULL_RANGE => Some(Self::Full),
244            V4L2_QUANTIZATION_LIM_RANGE => Some(Self::Limited),
245            _ => None,
246        }
247    }
248}
249
250macro_rules! display_via_as_str {
251    ($($t:ty),*) => {$(
252        impl fmt::Display for $t {
253            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
254                f.write_str(self.as_str())
255            }
256        }
257    )*};
258}
259display_via_as_str!(ColorSpace, ColorTransfer, ColorEncoding, ColorRange);
260
261/// YCbCr matrix luma weights `(kr, kb)`; the green weight is `kg = 1 - kr - kb`.
262///
263/// This is the single source of the BT.601/709/2020 coefficients. Every
264/// YUV↔RGB path (the in-shader GL coefficients and the hand-rolled fixed-point
265/// CPU encoders) derives its matrix from here so a coefficient change is made
266/// in exactly one place.
267#[derive(Debug, Clone, Copy, PartialEq)]
268pub struct MatrixWeights {
269    pub kr: f64,
270    pub kb: f64,
271}
272
273impl MatrixWeights {
274    /// Green luma weight, `1 - kr - kb`.
275    pub fn kg(self) -> f64 {
276        1.0 - self.kr - self.kb
277    }
278}
279
280impl ColorEncoding {
281    /// The matrix luma weights `(kr, kb)` for this encoding — the canonical
282    /// BT.601/709/2020 coefficients shared by every YUV↔RGB conversion path.
283    pub fn luma_weights(self) -> MatrixWeights {
284        match self {
285            Self::Bt601 => MatrixWeights {
286                kr: 0.299,
287                kb: 0.114,
288            },
289            Self::Bt709 => MatrixWeights {
290                kr: 0.2126,
291                kb: 0.0722,
292            },
293            Self::Bt2020 => MatrixWeights {
294                kr: 0.2627,
295                kb: 0.0593,
296            },
297        }
298    }
299}
300
301/// Quantization swings (out of 255) for a YCbCr range: the luma black offset and
302/// the luma/chroma excursions. Full range is `0 / 255 / 255`; limited (studio)
303/// range is `16 / 219 / 224`.
304#[derive(Debug, Clone, Copy, PartialEq)]
305pub struct RangeScaling {
306    /// Luma black level (0 for full range, 16 for limited).
307    pub y_offset: f64,
308    /// Luma excursion (255 for full range, 219 for limited).
309    pub y_swing: f64,
310    /// Chroma excursion (255 for full range, 224 for limited).
311    pub c_swing: f64,
312}
313
314impl ColorRange {
315    /// The luma/chroma quantization swings for this range — the canonical
316    /// 0/255/255 (full) or 16/219/224 (limited) studio-swing constants shared
317    /// by every YUV↔RGB conversion path.
318    pub fn scaling(self) -> RangeScaling {
319        match self {
320            Self::Full => RangeScaling {
321                y_offset: 0.0,
322                y_swing: 255.0,
323                c_swing: 255.0,
324            },
325            Self::Limited => RangeScaling {
326                y_offset: 16.0,
327                y_swing: 219.0,
328                c_swing: 224.0,
329            },
330        }
331    }
332}
333
334/// Full 4-axis colorimetry. Each axis is `Option`; `None` means "undefined"
335/// and is never auto-filled — consumers (e.g. `convert()`) resolve missing
336/// axes at use-time without mutating the value.
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
338pub struct Colorimetry {
339    pub space: Option<ColorSpace>,
340    pub transfer: Option<ColorTransfer>,
341    pub encoding: Option<ColorEncoding>,
342    pub range: Option<ColorRange>,
343}
344
345impl Colorimetry {
346    /// JPEG/JFIF colorimetry: sRGB primaries, sRGB transfer, BT.601
347    /// encoding, full range.
348    pub fn jfif() -> Self {
349        Self {
350            space: Some(ColorSpace::Srgb),
351            transfer: Some(ColorTransfer::Srgb),
352            encoding: Some(ColorEncoding::Bt601),
353            range: Some(ColorRange::Full),
354        }
355    }
356
357    /// Build from the four raw V4L2 colorimetry integers.
358    ///
359    /// Explicit values map directly. For `ycbcr_enc`/`quantization`, the V4L2
360    /// `DEFAULT` (0) sentinel does NOT mean "unknown" — it means "derive from
361    /// the colorspace" (kernel `V4L2_MAP_YCBCR_ENC_DEFAULT` /
362    /// `V4L2_MAP_QUANTIZATION_DEFAULT`). So a recognised colorspace resolves
363    /// those axes here (e.g. `V4L2_COLORSPACE_JPEG` → BT.601 full-range) rather
364    /// than leaving them `None` and falling through to the at-use height
365    /// heuristic (which would wrongly pick BT.709/limited for an HD JPEG frame).
366    /// A `DEFAULT`/unrecognised colorspace still yields `None` (deferred to the
367    /// heuristic); unrecognised non-default values also map to `None`.
368    pub fn from_v4l2(colorspace: u32, xfer: u32, ycbcr_enc: u32, quant: u32) -> Self {
369        let encoding = ColorEncoding::from_v4l2(ycbcr_enc).or_else(|| {
370            if ycbcr_enc == V4L2_YCBCR_ENC_DEFAULT {
371                Self::default_encoding_for_colorspace(colorspace)
372            } else {
373                None
374            }
375        });
376        let range = ColorRange::from_v4l2(quant).or_else(|| {
377            if quant == V4L2_QUANTIZATION_DEFAULT {
378                Self::default_range_for_colorspace(colorspace)
379            } else {
380                None
381            }
382        });
383        Self {
384            space: ColorSpace::from_v4l2(colorspace),
385            transfer: ColorTransfer::from_v4l2(xfer),
386            encoding,
387            range,
388        }
389    }
390
391    /// V4L2 `ycbcr_enc=DEFAULT` → encoding implied by the colorspace
392    /// (`V4L2_MAP_YCBCR_ENC_DEFAULT`). `None` for default/unrecognised.
393    fn default_encoding_for_colorspace(colorspace: u32) -> Option<ColorEncoding> {
394        match colorspace {
395            V4L2_COLORSPACE_REC709 => Some(ColorEncoding::Bt709),
396            V4L2_COLORSPACE_BT2020 => Some(ColorEncoding::Bt2020),
397            V4L2_COLORSPACE_SMPTE170M
398            | V4L2_COLORSPACE_470_SYSTEM_M
399            | V4L2_COLORSPACE_470_SYSTEM_BG
400            | V4L2_COLORSPACE_JPEG
401            | V4L2_COLORSPACE_SRGB => Some(ColorEncoding::Bt601),
402            _ => None,
403        }
404    }
405
406    /// V4L2 `quantization=DEFAULT` → range implied by the colorspace
407    /// (`V4L2_MAP_QUANTIZATION_DEFAULT` for the YUV case: only JPEG is full,
408    /// every other recognised colorspace is limited). `None` for
409    /// default/unrecognised.
410    fn default_range_for_colorspace(colorspace: u32) -> Option<ColorRange> {
411        match colorspace {
412            V4L2_COLORSPACE_JPEG => Some(ColorRange::Full),
413            V4L2_COLORSPACE_SMPTE170M
414            | V4L2_COLORSPACE_REC709
415            | V4L2_COLORSPACE_BT2020
416            | V4L2_COLORSPACE_470_SYSTEM_M
417            | V4L2_COLORSPACE_470_SYSTEM_BG
418            | V4L2_COLORSPACE_SRGB => Some(ColorRange::Limited),
419            _ => None,
420        }
421    }
422
423    /// Set color primaries (consuming builder).
424    pub fn with_space(mut self, s: ColorSpace) -> Self {
425        self.space = Some(s);
426        self
427    }
428
429    /// Set transfer function (consuming builder).
430    pub fn with_transfer(mut self, t: ColorTransfer) -> Self {
431        self.transfer = Some(t);
432        self
433    }
434
435    /// Set YCbCr encoding matrix (consuming builder).
436    pub fn with_encoding(mut self, e: ColorEncoding) -> Self {
437        self.encoding = Some(e);
438        self
439    }
440
441    /// Set quantization range (consuming builder).
442    pub fn with_range(mut self, r: ColorRange) -> Self {
443        self.range = Some(r);
444        self
445    }
446
447    /// Pack all four axes into one `u32` for
448    /// [`crate::protocol::TensorDesc::colorimetry`].
449    ///
450    /// One byte per axis, `space` in the low byte through `range` in the
451    /// high byte; `0` on an axis means "undefined", so an all-`None`
452    /// `Colorimetry` packs to `0`. The per-axis codes are the same "stable
453    /// HAL constants" the C API's `ef_colorimetry` documents
454    /// — duplicated rather than shared
455    /// because the C leaves depend on this crate, not the reverse, but kept in
456    /// sync deliberately so a value means the same thing on both
457    /// boundaries. This crosses a package boundary like `TensorDesc`
458    /// itself: codes are appended, never renumbered.
459    pub fn pack(&self) -> u32 {
460        let space: u32 = match self.space {
461            None => 0,
462            Some(ColorSpace::Bt709) => 1,
463            Some(ColorSpace::Bt2020) => 2,
464            Some(ColorSpace::Srgb) => 3,
465            Some(ColorSpace::Smpte170m) => 4,
466        };
467        let transfer: u32 = match self.transfer {
468            None => 0,
469            Some(ColorTransfer::Bt709) => 1,
470            Some(ColorTransfer::Srgb) => 2,
471            Some(ColorTransfer::Pq) => 3,
472            Some(ColorTransfer::Hlg) => 4,
473            Some(ColorTransfer::Linear) => 5,
474        };
475        let encoding: u32 = match self.encoding {
476            None => 0,
477            Some(ColorEncoding::Bt601) => 1,
478            Some(ColorEncoding::Bt709) => 2,
479            Some(ColorEncoding::Bt2020) => 3,
480        };
481        let range: u32 = match self.range {
482            None => 0,
483            Some(ColorRange::Full) => 1,
484            Some(ColorRange::Limited) => 2,
485        };
486        space | (transfer << 8) | (encoding << 16) | (range << 24)
487    }
488
489    /// Inverse of [`Self::pack`]: rebuild a `Colorimetry` from a packed
490    /// `u32`, e.g. [`crate::protocol::TensorDesc::colorimetry`] on
491    /// import. `0` on an axis byte (undefined, including a build that
492    /// predates the axis) maps to `None` rather than erroring, the same
493    /// forward-compatibility rule [`crate::protocol::dtype_to_dtype`]
494    /// applies to another wire-coded axis -- a code this build does not
495    /// recognise (e.g. one a newer producer added) also maps to `None`
496    /// instead of guessing.
497    pub fn unpack(bits: u32) -> Self {
498        let space = match bits & 0xFF {
499            1 => Some(ColorSpace::Bt709),
500            2 => Some(ColorSpace::Bt2020),
501            3 => Some(ColorSpace::Srgb),
502            4 => Some(ColorSpace::Smpte170m),
503            _ => None,
504        };
505        let transfer = match (bits >> 8) & 0xFF {
506            1 => Some(ColorTransfer::Bt709),
507            2 => Some(ColorTransfer::Srgb),
508            3 => Some(ColorTransfer::Pq),
509            4 => Some(ColorTransfer::Hlg),
510            5 => Some(ColorTransfer::Linear),
511            _ => None,
512        };
513        let encoding = match (bits >> 16) & 0xFF {
514            1 => Some(ColorEncoding::Bt601),
515            2 => Some(ColorEncoding::Bt709),
516            3 => Some(ColorEncoding::Bt2020),
517            _ => None,
518        };
519        let range = match (bits >> 24) & 0xFF {
520            1 => Some(ColorRange::Full),
521            2 => Some(ColorRange::Limited),
522            _ => None,
523        };
524        Colorimetry {
525            space,
526            transfer,
527            encoding,
528            range,
529        }
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    #[test]
538    fn from_v4l2_known_and_default() {
539        assert_eq!(ColorEncoding::from_v4l2(1), Some(ColorEncoding::Bt601));
540        assert_eq!(ColorEncoding::from_v4l2(2), Some(ColorEncoding::Bt709));
541        assert_eq!(ColorEncoding::from_v4l2(6), Some(ColorEncoding::Bt2020));
542        assert_eq!(ColorEncoding::from_v4l2(0), None); // DEFAULT
543        assert_eq!(ColorEncoding::from_v4l2(3), None); // XV601 (unsurfaced)
544        assert_eq!(ColorRange::from_v4l2(1), Some(ColorRange::Full));
545        assert_eq!(ColorRange::from_v4l2(2), Some(ColorRange::Limited));
546        assert_eq!(ColorSpace::from_v4l2(7), Some(ColorSpace::Srgb)); // JPEG→sRGB
547        assert_eq!(ColorTransfer::from_v4l2(5), Some(ColorTransfer::Linear)); // NONE→linear
548
549        // ColorSpace: additional arms
550        assert_eq!(ColorSpace::from_v4l2(1), Some(ColorSpace::Smpte170m)); // SMPTE170M
551        assert_eq!(ColorSpace::from_v4l2(3), Some(ColorSpace::Bt709)); // REC709
552        assert_eq!(ColorSpace::from_v4l2(10), Some(ColorSpace::Bt2020)); // BT2020
553        assert_eq!(ColorSpace::from_v4l2(5), Some(ColorSpace::Smpte170m)); // 470_SYSTEM_M
554        assert_eq!(ColorSpace::from_v4l2(6), Some(ColorSpace::Smpte170m)); // 470_SYSTEM_BG
555
556        // ColorTransfer: additional arms and unmapped values
557        assert_eq!(ColorTransfer::from_v4l2(1), Some(ColorTransfer::Bt709)); // XFER_FUNC_709
558        assert_eq!(ColorTransfer::from_v4l2(2), Some(ColorTransfer::Srgb)); // XFER_FUNC_SRGB
559        assert_eq!(ColorTransfer::from_v4l2(7), Some(ColorTransfer::Pq)); // XFER_FUNC_SMPTE2084
560        assert_eq!(ColorTransfer::from_v4l2(3), None); // OPRGB — no HAL equivalent
561        assert_eq!(ColorTransfer::from_v4l2(6), None); // DCI_P3 — no HAL equivalent
562
563        // Hlg is never produced by from_v4l2 — no V4L2_XFER_FUNC_HLG exists in the kernel UAPI
564        for v in 0u32..=10 {
565            assert_ne!(ColorTransfer::from_v4l2(v), Some(ColorTransfer::Hlg));
566        }
567    }
568
569    #[test]
570    fn jfif_is_bt601_full_srgb() {
571        let c = Colorimetry::jfif();
572        assert_eq!(c.space, Some(ColorSpace::Srgb));
573        assert_eq!(c.transfer, Some(ColorTransfer::Srgb));
574        assert_eq!(c.encoding, Some(ColorEncoding::Bt601));
575        assert_eq!(c.range, Some(ColorRange::Full));
576    }
577
578    #[test]
579    fn from_v4l2_struct_maps_all_axes_and_unknown_to_none() {
580        let c = Colorimetry::from_v4l2(3, 1, 2, 1); // REC709, XFER709, ENC709, FULL
581        assert_eq!(c.space, Some(ColorSpace::Bt709));
582        assert_eq!(c.transfer, Some(ColorTransfer::Bt709));
583        assert_eq!(c.encoding, Some(ColorEncoding::Bt709));
584        assert_eq!(c.range, Some(ColorRange::Full));
585        let d = Colorimetry::from_v4l2(0, 0, 0, 0); // all DEFAULT
586        assert_eq!(d, Colorimetry::default()); // all None
587    }
588
589    #[test]
590    fn from_v4l2_default_enc_quant_derive_from_colorspace() {
591        // COLORSPACE_JPEG (7) with DEFAULT ycbcr_enc/quant must resolve to
592        // BT.601 full-range per V4L2_MAP_*_DEFAULT — NOT be left None (which
593        // would let the height heuristic wrongly pick BT.709/limited for HD).
594        let jpeg = Colorimetry::from_v4l2(7, 0, 0, 0);
595        assert_eq!(jpeg.encoding, Some(ColorEncoding::Bt601));
596        assert_eq!(jpeg.range, Some(ColorRange::Full));
597
598        // REC709 colorspace, DEFAULT enc/quant → BT.709 limited.
599        let rec709 = Colorimetry::from_v4l2(3, 0, 0, 0);
600        assert_eq!(rec709.encoding, Some(ColorEncoding::Bt709));
601        assert_eq!(rec709.range, Some(ColorRange::Limited));
602
603        // Explicit ycbcr_enc/quant still win over the colorspace default.
604        let explicit = Colorimetry::from_v4l2(7, 0, 2, 2); // JPEG but enc=709, quant=limited
605        assert_eq!(explicit.encoding, Some(ColorEncoding::Bt709));
606        assert_eq!(explicit.range, Some(ColorRange::Limited));
607
608        // Unrecognised non-default values stay None (not derived).
609        let unknown_enc = Colorimetry::from_v4l2(7, 0, 99, 0);
610        assert_eq!(unknown_enc.encoding, None);
611        assert_eq!(unknown_enc.range, Some(ColorRange::Full)); // quant still defaulted from JPEG
612    }
613
614    #[test]
615    fn luma_weights_are_the_canonical_bt_constants() {
616        assert_eq!(
617            ColorEncoding::Bt601.luma_weights(),
618            MatrixWeights {
619                kr: 0.299,
620                kb: 0.114
621            }
622        );
623        assert_eq!(
624            ColorEncoding::Bt709.luma_weights(),
625            MatrixWeights {
626                kr: 0.2126,
627                kb: 0.0722
628            }
629        );
630        assert_eq!(
631            ColorEncoding::Bt2020.luma_weights(),
632            MatrixWeights {
633                kr: 0.2627,
634                kb: 0.0593
635            }
636        );
637        // kg = 1 - kr - kb (BT.709 green weight ≈ 0.7152).
638        assert!((ColorEncoding::Bt709.luma_weights().kg() - 0.7152).abs() < 1e-9);
639    }
640
641    #[test]
642    fn range_scaling_is_full_or_studio_swing() {
643        let full = ColorRange::Full.scaling();
644        assert_eq!(
645            full,
646            RangeScaling {
647                y_offset: 0.0,
648                y_swing: 255.0,
649                c_swing: 255.0
650            }
651        );
652        let limited = ColorRange::Limited.scaling();
653        assert_eq!(
654            limited,
655            RangeScaling {
656                y_offset: 16.0,
657                y_swing: 219.0,
658                c_swing: 224.0
659            }
660        );
661    }
662
663    #[test]
664    fn as_str_and_from_str_code_are_inverses_for_every_variant() {
665        // The wire round-trip depends on this, and `as_str` alone cannot show
666        // it: a parser missing one arm silently degrades that axis to None.
667        for v in [
668            ColorSpace::Bt709,
669            ColorSpace::Bt2020,
670            ColorSpace::Srgb,
671            ColorSpace::Smpte170m,
672        ] {
673            assert_eq!(ColorSpace::from_str_code(v.as_str()), Some(v), "{v:?}");
674        }
675        for v in [
676            ColorTransfer::Bt709,
677            ColorTransfer::Srgb,
678            ColorTransfer::Pq,
679            ColorTransfer::Hlg,
680            ColorTransfer::Linear,
681        ] {
682            assert_eq!(ColorTransfer::from_str_code(v.as_str()), Some(v), "{v:?}");
683        }
684        for v in [
685            ColorEncoding::Bt601,
686            ColorEncoding::Bt709,
687            ColorEncoding::Bt2020,
688        ] {
689            assert_eq!(ColorEncoding::from_str_code(v.as_str()), Some(v), "{v:?}");
690        }
691        for v in [ColorRange::Full, ColorRange::Limited] {
692            assert_eq!(ColorRange::from_str_code(v.as_str()), Some(v), "{v:?}");
693        }
694    }
695
696    #[test]
697    fn an_unknown_or_empty_label_is_unspecified_not_an_error() {
698        assert_eq!(ColorSpace::from_str_code(""), None);
699        assert_eq!(ColorSpace::from_str_code("not-a-colour-space"), None);
700        assert_eq!(
701            ColorRange::from_str_code("FULL"),
702            None,
703            "labels are case-sensitive"
704        );
705    }
706
707    #[test]
708    fn as_str_matches_schema() {
709        assert_eq!(ColorEncoding::Bt601.as_str(), "bt601");
710        assert_eq!(ColorRange::Full.as_str(), "full");
711        assert_eq!(ColorSpace::Smpte170m.as_str(), "smpte170m");
712        assert_eq!(ColorTransfer::Pq.as_str(), "pq");
713        assert_eq!(ColorTransfer::Hlg.as_str(), "hlg");
714    }
715
716    #[test]
717    fn pack_unpack_round_trips() {
718        // The exact round trip the cross-package import path relies on:
719        // `TensorDyn::import_descriptor` unpacks what `descriptor_pinned`
720        // packed. All-None must round-trip through 0, and every populated
721        // axis must survive.
722        assert_eq!(
723            Colorimetry::unpack(Colorimetry::default().pack()),
724            Colorimetry::default()
725        );
726        assert_eq!(Colorimetry::default().pack(), 0);
727
728        let jfif = Colorimetry::jfif();
729        assert_eq!(Colorimetry::unpack(jfif.pack()), jfif);
730
731        let full = Colorimetry {
732            space: Some(ColorSpace::Bt2020),
733            transfer: Some(ColorTransfer::Hlg),
734            encoding: Some(ColorEncoding::Bt2020),
735            range: Some(ColorRange::Limited),
736        };
737        assert_eq!(Colorimetry::unpack(full.pack()), full);
738    }
739
740    #[test]
741    fn unpack_maps_unrecognised_codes_to_none() {
742        // A code this build does not recognise (e.g. from a newer producer)
743        // must not panic or guess -- same forward-compat rule as
744        // `dtype_to_dtype`/`format_from_code`.
745        let bits = 0xFFu32 | (0xFFu32 << 8) | (0xFFu32 << 16) | (0xFFu32 << 24);
746        assert_eq!(Colorimetry::unpack(bits), Colorimetry::default());
747    }
748}