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 of a negotiated
358    /// format whose pixel encoding is RGB or HSV when `is_rgb_or_hsv` is
359    /// `true` (see [`PixelFormat::is_rgb_or_hsv`]).
360    ///
361    /// Explicit values map directly. For `ycbcr_enc`/`quantization`, the V4L2
362    /// `DEFAULT` (0) sentinel does NOT mean "unknown" — it means "derive"
363    /// (kernel `V4L2_MAP_YCBCR_ENC_DEFAULT` / `V4L2_MAP_QUANTIZATION_DEFAULT`),
364    /// and those axes are resolved here rather than left `None` for the
365    /// at-use height heuristic (which would wrongly pick BT.709/limited for an
366    /// HD JPEG frame):
367    ///
368    /// - `quantization = DEFAULT` is full range for an RGB/HSV format,
369    ///   whatever the colorspace, and for `V4L2_COLORSPACE_JPEG`; limited for
370    ///   a YUV format in any other recognised colorspace; and unset (`None`)
371    ///   for a YUV format in a `DEFAULT` or unrecognised colorspace, so the
372    ///   at-use height heuristic decides.
373    /// - `ycbcr_enc = DEFAULT` is the encoding the colorspace implies, and
374    ///   unset (`None`) for a `DEFAULT` or unrecognised colorspace.
375    ///
376    /// Unrecognised non-default values also map to `None`.
377    pub fn from_v4l2(
378        colorspace: u32,
379        xfer: u32,
380        ycbcr_enc: u32,
381        quant: u32,
382        is_rgb_or_hsv: bool,
383    ) -> Self {
384        let encoding = ColorEncoding::from_v4l2(ycbcr_enc).or_else(|| {
385            if ycbcr_enc == V4L2_YCBCR_ENC_DEFAULT {
386                Self::default_encoding_for_colorspace(colorspace)
387            } else {
388                None
389            }
390        });
391        let range = ColorRange::from_v4l2(quant).or_else(|| {
392            if quant == V4L2_QUANTIZATION_DEFAULT {
393                Self::default_range(is_rgb_or_hsv, colorspace)
394            } else {
395                None
396            }
397        });
398        Self {
399            space: ColorSpace::from_v4l2(colorspace),
400            transfer: ColorTransfer::from_v4l2(xfer),
401            encoding,
402            range,
403        }
404    }
405
406    /// V4L2 `ycbcr_enc=DEFAULT` → encoding implied by the colorspace, per
407    /// `V4L2_MAP_YCBCR_ENC_DEFAULT` for every colorspace [`ColorSpace`]
408    /// recognises: BT.709 for REC709, BT.2020 for BT2020, BT.601 otherwise.
409    /// `None` for default/unrecognised.
410    fn default_encoding_for_colorspace(colorspace: u32) -> Option<ColorEncoding> {
411        match colorspace {
412            V4L2_COLORSPACE_REC709 => Some(ColorEncoding::Bt709),
413            V4L2_COLORSPACE_BT2020 => Some(ColorEncoding::Bt2020),
414            V4L2_COLORSPACE_SMPTE170M
415            | V4L2_COLORSPACE_470_SYSTEM_M
416            | V4L2_COLORSPACE_470_SYSTEM_BG
417            | V4L2_COLORSPACE_JPEG
418            | V4L2_COLORSPACE_SRGB => Some(ColorEncoding::Bt601),
419            _ => None,
420        }
421    }
422
423    /// V4L2 `quantization=DEFAULT` → range, per
424    /// `V4L2_MAP_QUANTIZATION_DEFAULT`: full for an RGB/HSV format whatever
425    /// the colorspace, full for JPEG, limited for every other recognised
426    /// colorspace. `None` for a YUV format in a default/unrecognised
427    /// colorspace.
428    fn default_range(is_rgb_or_hsv: bool, colorspace: u32) -> Option<ColorRange> {
429        if is_rgb_or_hsv {
430            return Some(ColorRange::Full);
431        }
432        match colorspace {
433            V4L2_COLORSPACE_JPEG => Some(ColorRange::Full),
434            V4L2_COLORSPACE_SMPTE170M
435            | V4L2_COLORSPACE_REC709
436            | V4L2_COLORSPACE_BT2020
437            | V4L2_COLORSPACE_470_SYSTEM_M
438            | V4L2_COLORSPACE_470_SYSTEM_BG
439            | V4L2_COLORSPACE_SRGB => Some(ColorRange::Limited),
440            _ => None,
441        }
442    }
443
444    /// Set color primaries (consuming builder).
445    pub fn with_space(mut self, s: ColorSpace) -> Self {
446        self.space = Some(s);
447        self
448    }
449
450    /// Set transfer function (consuming builder).
451    pub fn with_transfer(mut self, t: ColorTransfer) -> Self {
452        self.transfer = Some(t);
453        self
454    }
455
456    /// Set YCbCr encoding matrix (consuming builder).
457    pub fn with_encoding(mut self, e: ColorEncoding) -> Self {
458        self.encoding = Some(e);
459        self
460    }
461
462    /// Set quantization range (consuming builder).
463    pub fn with_range(mut self, r: ColorRange) -> Self {
464        self.range = Some(r);
465        self
466    }
467
468    /// Pack all four axes into one `u32` for
469    /// [`crate::protocol::TensorDesc::colorimetry`].
470    ///
471    /// One byte per axis, `space` in the low byte through `range` in the
472    /// high byte; `0` on an axis means "undefined", so an all-`None`
473    /// `Colorimetry` packs to `0`. The per-axis codes are the same "stable
474    /// HAL constants" the C API's `ef_colorimetry` documents
475    /// — duplicated rather than shared
476    /// because the C leaves depend on this crate, not the reverse, but kept in
477    /// sync deliberately so a value means the same thing on both
478    /// boundaries. This crosses a package boundary like `TensorDesc`
479    /// itself: codes are appended, never renumbered.
480    pub fn pack(&self) -> u32 {
481        let space: u32 = match self.space {
482            None => 0,
483            Some(ColorSpace::Bt709) => 1,
484            Some(ColorSpace::Bt2020) => 2,
485            Some(ColorSpace::Srgb) => 3,
486            Some(ColorSpace::Smpte170m) => 4,
487        };
488        let transfer: u32 = match self.transfer {
489            None => 0,
490            Some(ColorTransfer::Bt709) => 1,
491            Some(ColorTransfer::Srgb) => 2,
492            Some(ColorTransfer::Pq) => 3,
493            Some(ColorTransfer::Hlg) => 4,
494            Some(ColorTransfer::Linear) => 5,
495        };
496        let encoding: u32 = match self.encoding {
497            None => 0,
498            Some(ColorEncoding::Bt601) => 1,
499            Some(ColorEncoding::Bt709) => 2,
500            Some(ColorEncoding::Bt2020) => 3,
501        };
502        let range: u32 = match self.range {
503            None => 0,
504            Some(ColorRange::Full) => 1,
505            Some(ColorRange::Limited) => 2,
506        };
507        space | (transfer << 8) | (encoding << 16) | (range << 24)
508    }
509
510    /// Inverse of [`Self::pack`]: rebuild a `Colorimetry` from a packed
511    /// `u32`, e.g. [`crate::protocol::TensorDesc::colorimetry`] on
512    /// import. `0` on an axis byte (undefined, including a build that
513    /// predates the axis) maps to `None` rather than erroring, the same
514    /// forward-compatibility rule [`crate::protocol::dtype_to_dtype`]
515    /// applies to another wire-coded axis -- a code this build does not
516    /// recognise (e.g. one a newer producer added) also maps to `None`
517    /// instead of guessing.
518    pub fn unpack(bits: u32) -> Self {
519        let space = match bits & 0xFF {
520            1 => Some(ColorSpace::Bt709),
521            2 => Some(ColorSpace::Bt2020),
522            3 => Some(ColorSpace::Srgb),
523            4 => Some(ColorSpace::Smpte170m),
524            _ => None,
525        };
526        let transfer = match (bits >> 8) & 0xFF {
527            1 => Some(ColorTransfer::Bt709),
528            2 => Some(ColorTransfer::Srgb),
529            3 => Some(ColorTransfer::Pq),
530            4 => Some(ColorTransfer::Hlg),
531            5 => Some(ColorTransfer::Linear),
532            _ => None,
533        };
534        let encoding = match (bits >> 16) & 0xFF {
535            1 => Some(ColorEncoding::Bt601),
536            2 => Some(ColorEncoding::Bt709),
537            3 => Some(ColorEncoding::Bt2020),
538            _ => None,
539        };
540        let range = match (bits >> 24) & 0xFF {
541            1 => Some(ColorRange::Full),
542            2 => Some(ColorRange::Limited),
543            _ => None,
544        };
545        Colorimetry {
546            space,
547            transfer,
548            encoding,
549            range,
550        }
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    #[test]
559    fn from_v4l2_known_and_default() {
560        assert_eq!(ColorEncoding::from_v4l2(1), Some(ColorEncoding::Bt601));
561        assert_eq!(ColorEncoding::from_v4l2(2), Some(ColorEncoding::Bt709));
562        assert_eq!(ColorEncoding::from_v4l2(6), Some(ColorEncoding::Bt2020));
563        assert_eq!(ColorEncoding::from_v4l2(0), None); // DEFAULT
564        assert_eq!(ColorEncoding::from_v4l2(3), None); // XV601 (unsurfaced)
565        assert_eq!(ColorRange::from_v4l2(1), Some(ColorRange::Full));
566        assert_eq!(ColorRange::from_v4l2(2), Some(ColorRange::Limited));
567        assert_eq!(ColorSpace::from_v4l2(7), Some(ColorSpace::Srgb)); // JPEG→sRGB
568        assert_eq!(ColorTransfer::from_v4l2(5), Some(ColorTransfer::Linear)); // NONE→linear
569
570        // ColorSpace: additional arms
571        assert_eq!(ColorSpace::from_v4l2(1), Some(ColorSpace::Smpte170m)); // SMPTE170M
572        assert_eq!(ColorSpace::from_v4l2(3), Some(ColorSpace::Bt709)); // REC709
573        assert_eq!(ColorSpace::from_v4l2(10), Some(ColorSpace::Bt2020)); // BT2020
574        assert_eq!(ColorSpace::from_v4l2(5), Some(ColorSpace::Smpte170m)); // 470_SYSTEM_M
575        assert_eq!(ColorSpace::from_v4l2(6), Some(ColorSpace::Smpte170m)); // 470_SYSTEM_BG
576
577        // ColorTransfer: additional arms and unmapped values
578        assert_eq!(ColorTransfer::from_v4l2(1), Some(ColorTransfer::Bt709)); // XFER_FUNC_709
579        assert_eq!(ColorTransfer::from_v4l2(2), Some(ColorTransfer::Srgb)); // XFER_FUNC_SRGB
580        assert_eq!(ColorTransfer::from_v4l2(7), Some(ColorTransfer::Pq)); // XFER_FUNC_SMPTE2084
581        assert_eq!(ColorTransfer::from_v4l2(3), None); // OPRGB — no HAL equivalent
582        assert_eq!(ColorTransfer::from_v4l2(6), None); // DCI_P3 — no HAL equivalent
583
584        // Hlg is never produced by from_v4l2 — no V4L2_XFER_FUNC_HLG exists in the kernel UAPI
585        for v in 0u32..=10 {
586            assert_ne!(ColorTransfer::from_v4l2(v), Some(ColorTransfer::Hlg));
587        }
588    }
589
590    #[test]
591    fn jfif_is_bt601_full_srgb() {
592        let c = Colorimetry::jfif();
593        assert_eq!(c.space, Some(ColorSpace::Srgb));
594        assert_eq!(c.transfer, Some(ColorTransfer::Srgb));
595        assert_eq!(c.encoding, Some(ColorEncoding::Bt601));
596        assert_eq!(c.range, Some(ColorRange::Full));
597    }
598
599    #[test]
600    fn from_v4l2_struct_maps_all_axes_and_unknown_to_none() {
601        let c = Colorimetry::from_v4l2(3, 1, 2, 1, false); // REC709, XFER709, ENC709, FULL
602        assert_eq!(c.space, Some(ColorSpace::Bt709));
603        assert_eq!(c.transfer, Some(ColorTransfer::Bt709));
604        assert_eq!(c.encoding, Some(ColorEncoding::Bt709));
605        assert_eq!(c.range, Some(ColorRange::Full));
606        let d = Colorimetry::from_v4l2(0, 0, 0, 0, false); // all DEFAULT, YUV
607        assert_eq!(d, Colorimetry::default()); // all None
608    }
609
610    #[test]
611    fn from_v4l2_default_enc_quant_derive_from_colorspace() {
612        // COLORSPACE_JPEG (7) with DEFAULT ycbcr_enc/quant must resolve to
613        // BT.601 full-range per V4L2_MAP_*_DEFAULT — NOT be left None (which
614        // would let the height heuristic wrongly pick BT.709/limited for HD).
615        let jpeg = Colorimetry::from_v4l2(7, 0, 0, 0, false);
616        assert_eq!(jpeg.encoding, Some(ColorEncoding::Bt601));
617        assert_eq!(jpeg.range, Some(ColorRange::Full));
618
619        // REC709 colorspace, DEFAULT enc/quant → BT.709 limited.
620        let rec709 = Colorimetry::from_v4l2(3, 0, 0, 0, false);
621        assert_eq!(rec709.encoding, Some(ColorEncoding::Bt709));
622        assert_eq!(rec709.range, Some(ColorRange::Limited));
623
624        // Explicit ycbcr_enc/quant still win over the colorspace default.
625        let explicit = Colorimetry::from_v4l2(7, 0, 2, 2, false); // JPEG but enc=709, quant=limited
626        assert_eq!(explicit.encoding, Some(ColorEncoding::Bt709));
627        assert_eq!(explicit.range, Some(ColorRange::Limited));
628
629        // Unrecognised non-default values stay None (not derived).
630        let unknown_enc = Colorimetry::from_v4l2(7, 0, 99, 0, false);
631        assert_eq!(unknown_enc.encoding, None);
632        assert_eq!(unknown_enc.range, Some(ColorRange::Full)); // quant still defaulted from JPEG
633    }
634
635    #[test]
636    fn luma_weights_are_the_canonical_bt_constants() {
637        assert_eq!(
638            ColorEncoding::Bt601.luma_weights(),
639            MatrixWeights {
640                kr: 0.299,
641                kb: 0.114
642            }
643        );
644        assert_eq!(
645            ColorEncoding::Bt709.luma_weights(),
646            MatrixWeights {
647                kr: 0.2126,
648                kb: 0.0722
649            }
650        );
651        assert_eq!(
652            ColorEncoding::Bt2020.luma_weights(),
653            MatrixWeights {
654                kr: 0.2627,
655                kb: 0.0593
656            }
657        );
658        // kg = 1 - kr - kb (BT.709 green weight ≈ 0.7152).
659        assert!((ColorEncoding::Bt709.luma_weights().kg() - 0.7152).abs() < 1e-9);
660    }
661
662    #[test]
663    fn range_scaling_is_full_or_studio_swing() {
664        let full = ColorRange::Full.scaling();
665        assert_eq!(
666            full,
667            RangeScaling {
668                y_offset: 0.0,
669                y_swing: 255.0,
670                c_swing: 255.0
671            }
672        );
673        let limited = ColorRange::Limited.scaling();
674        assert_eq!(
675            limited,
676            RangeScaling {
677                y_offset: 16.0,
678                y_swing: 219.0,
679                c_swing: 224.0
680            }
681        );
682    }
683
684    #[test]
685    fn as_str_and_from_str_code_are_inverses_for_every_variant() {
686        // The wire round-trip depends on this, and `as_str` alone cannot show
687        // it: a parser missing one arm silently degrades that axis to None.
688        for v in [
689            ColorSpace::Bt709,
690            ColorSpace::Bt2020,
691            ColorSpace::Srgb,
692            ColorSpace::Smpte170m,
693        ] {
694            assert_eq!(ColorSpace::from_str_code(v.as_str()), Some(v), "{v:?}");
695        }
696        for v in [
697            ColorTransfer::Bt709,
698            ColorTransfer::Srgb,
699            ColorTransfer::Pq,
700            ColorTransfer::Hlg,
701            ColorTransfer::Linear,
702        ] {
703            assert_eq!(ColorTransfer::from_str_code(v.as_str()), Some(v), "{v:?}");
704        }
705        for v in [
706            ColorEncoding::Bt601,
707            ColorEncoding::Bt709,
708            ColorEncoding::Bt2020,
709        ] {
710            assert_eq!(ColorEncoding::from_str_code(v.as_str()), Some(v), "{v:?}");
711        }
712        for v in [ColorRange::Full, ColorRange::Limited] {
713            assert_eq!(ColorRange::from_str_code(v.as_str()), Some(v), "{v:?}");
714        }
715    }
716
717    #[test]
718    fn an_unknown_or_empty_label_is_unspecified_not_an_error() {
719        assert_eq!(ColorSpace::from_str_code(""), None);
720        assert_eq!(ColorSpace::from_str_code("not-a-colour-space"), None);
721        assert_eq!(
722            ColorRange::from_str_code("FULL"),
723            None,
724            "labels are case-sensitive"
725        );
726    }
727
728    #[test]
729    fn as_str_matches_schema() {
730        assert_eq!(ColorEncoding::Bt601.as_str(), "bt601");
731        assert_eq!(ColorRange::Full.as_str(), "full");
732        assert_eq!(ColorSpace::Smpte170m.as_str(), "smpte170m");
733        assert_eq!(ColorTransfer::Pq.as_str(), "pq");
734        assert_eq!(ColorTransfer::Hlg.as_str(), "hlg");
735    }
736
737    #[test]
738    fn pack_unpack_round_trips() {
739        // The exact round trip the cross-package import path relies on:
740        // `TensorDyn::import_descriptor` unpacks what `descriptor_pinned`
741        // packed. All-None must round-trip through 0, and every populated
742        // axis must survive.
743        assert_eq!(
744            Colorimetry::unpack(Colorimetry::default().pack()),
745            Colorimetry::default()
746        );
747        assert_eq!(Colorimetry::default().pack(), 0);
748
749        let jfif = Colorimetry::jfif();
750        assert_eq!(Colorimetry::unpack(jfif.pack()), jfif);
751
752        let full = Colorimetry {
753            space: Some(ColorSpace::Bt2020),
754            transfer: Some(ColorTransfer::Hlg),
755            encoding: Some(ColorEncoding::Bt2020),
756            range: Some(ColorRange::Limited),
757        };
758        assert_eq!(Colorimetry::unpack(full.pack()), full);
759    }
760
761    #[test]
762    fn unpack_maps_unrecognised_codes_to_none() {
763        // A code this build does not recognise (e.g. from a newer producer)
764        // must not panic or guess -- same forward-compat rule as
765        // `dtype_to_dtype`/`format_from_code`.
766        let bits = 0xFFu32 | (0xFFu32 << 8) | (0xFFu32 << 16) | (0xFFu32 << 24);
767        assert_eq!(Colorimetry::unpack(bits), Colorimetry::default());
768    }
769
770    const SPACES: [(ColorSpace, u32); 4] = [
771        (ColorSpace::Bt709, 1),
772        (ColorSpace::Bt2020, 2),
773        (ColorSpace::Srgb, 3),
774        (ColorSpace::Smpte170m, 4),
775    ];
776    const TRANSFERS: [(ColorTransfer, u32); 5] = [
777        (ColorTransfer::Bt709, 1),
778        (ColorTransfer::Srgb, 2),
779        (ColorTransfer::Pq, 3),
780        (ColorTransfer::Hlg, 4),
781        (ColorTransfer::Linear, 5),
782    ];
783    const ENCODINGS: [(ColorEncoding, u32); 3] = [
784        (ColorEncoding::Bt601, 1),
785        (ColorEncoding::Bt709, 2),
786        (ColorEncoding::Bt2020, 3),
787    ];
788    const RANGES: [(ColorRange, u32); 2] = [(ColorRange::Full, 1), (ColorRange::Limited, 2)];
789
790    #[test]
791    fn pack_places_each_axis_code_in_its_own_byte() {
792        // The per-axis codes are a cross-package wire contract: pin them.
793        for (s, code) in SPACES {
794            let c = Colorimetry::default().with_space(s);
795            assert_eq!(c.pack(), code, "{s:?}");
796            assert_eq!(Colorimetry::unpack(code), c, "{s:?}");
797        }
798        for (t, code) in TRANSFERS {
799            let c = Colorimetry::default().with_transfer(t);
800            assert_eq!(c.pack(), code << 8, "{t:?}");
801            assert_eq!(Colorimetry::unpack(code << 8), c, "{t:?}");
802        }
803        for (e, code) in ENCODINGS {
804            let c = Colorimetry::default().with_encoding(e);
805            assert_eq!(c.pack(), code << 16, "{e:?}");
806            assert_eq!(Colorimetry::unpack(code << 16), c, "{e:?}");
807        }
808        for (r, code) in RANGES {
809            let c = Colorimetry::default().with_range(r);
810            assert_eq!(c.pack(), code << 24, "{r:?}");
811            assert_eq!(Colorimetry::unpack(code << 24), c, "{r:?}");
812        }
813    }
814
815    #[test]
816    fn unpack_decodes_a_fully_populated_word() {
817        assert_eq!(
818            Colorimetry::unpack(0x02_02_05_04),
819            Colorimetry {
820                space: Some(ColorSpace::Smpte170m),
821                transfer: Some(ColorTransfer::Linear),
822                encoding: Some(ColorEncoding::Bt709),
823                range: Some(ColorRange::Limited),
824            }
825        );
826    }
827
828    #[test]
829    fn every_axis_combination_round_trips_through_pack() {
830        for (s, _) in SPACES {
831            for (t, _) in TRANSFERS {
832                for (e, _) in ENCODINGS {
833                    for (r, _) in RANGES {
834                        let c = Colorimetry {
835                            space: Some(s),
836                            transfer: Some(t),
837                            encoding: Some(e),
838                            range: Some(r),
839                        };
840                        assert_eq!(Colorimetry::unpack(c.pack()), c);
841                    }
842                }
843            }
844        }
845    }
846
847    #[test]
848    fn builders_set_exactly_their_own_axis() {
849        let c = Colorimetry::default()
850            .with_space(ColorSpace::Bt2020)
851            .with_transfer(ColorTransfer::Pq)
852            .with_encoding(ColorEncoding::Bt2020)
853            .with_range(ColorRange::Full);
854        assert_eq!(
855            c,
856            Colorimetry {
857                space: Some(ColorSpace::Bt2020),
858                transfer: Some(ColorTransfer::Pq),
859                encoding: Some(ColorEncoding::Bt2020),
860                range: Some(ColorRange::Full),
861            }
862        );
863        // Each builder leaves the other axes as they were.
864        let base = Colorimetry::jfif();
865        assert_eq!(
866            base.with_space(ColorSpace::Bt709),
867            Colorimetry {
868                space: Some(ColorSpace::Bt709),
869                ..base
870            }
871        );
872        assert_eq!(
873            base.with_transfer(ColorTransfer::Hlg),
874            Colorimetry {
875                transfer: Some(ColorTransfer::Hlg),
876                ..base
877            }
878        );
879        assert_eq!(
880            base.with_encoding(ColorEncoding::Bt709),
881            Colorimetry {
882                encoding: Some(ColorEncoding::Bt709),
883                ..base
884            }
885        );
886        assert_eq!(
887            base.with_range(ColorRange::Limited),
888            Colorimetry {
889                range: Some(ColorRange::Limited),
890                ..base
891            }
892        );
893    }
894
895    #[test]
896    fn from_v4l2_derives_encoding_and_range_for_every_recognised_yuv_colorspace() {
897        use ColorEncoding as E;
898        use ColorRange as R;
899        let table = [
900            (V4L2_COLORSPACE_SMPTE170M, E::Bt601, R::Limited),
901            (V4L2_COLORSPACE_REC709, E::Bt709, R::Limited),
902            (V4L2_COLORSPACE_470_SYSTEM_M, E::Bt601, R::Limited),
903            (V4L2_COLORSPACE_470_SYSTEM_BG, E::Bt601, R::Limited),
904            (V4L2_COLORSPACE_JPEG, E::Bt601, R::Full),
905            (V4L2_COLORSPACE_SRGB, E::Bt601, R::Limited),
906            (V4L2_COLORSPACE_BT2020, E::Bt2020, R::Limited),
907        ];
908        for (colorspace, encoding, range) in table {
909            let c = Colorimetry::from_v4l2(colorspace, 0, 0, 0, false);
910            assert_eq!(c.encoding, Some(encoding), "colorspace {colorspace}");
911            assert_eq!(c.range, Some(range), "colorspace {colorspace}");
912        }
913        // Unrecognised colorspaces leave both axes to the at-use heuristic.
914        for colorspace in [0, 2, 4, 9, 11, 99] {
915            let c = Colorimetry::from_v4l2(colorspace, 0, 0, 0, false);
916            assert_eq!(
917                (c.encoding, c.range),
918                (None, None),
919                "colorspace {colorspace}"
920            );
921        }
922    }
923
924    /// Every V4L2 colorspace code `videodev2.h` defines, plus an
925    /// unassigned one.
926    const ALL_V4L2_COLORSPACES: [u32; 14] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 99];
927
928    /// `V4L2_MAP_QUANTIZATION_DEFAULT(is_rgb_or_hsv, colsp, _)`, restated
929    /// from the header for the colorspaces this crate recognises.
930    fn kernel_default_range(is_rgb_or_hsv: bool, colorspace: u32) -> ColorRange {
931        if is_rgb_or_hsv || colorspace == V4L2_COLORSPACE_JPEG {
932            ColorRange::Full
933        } else {
934            ColorRange::Limited
935        }
936    }
937
938    #[test]
939    fn from_v4l2_quantization_matches_the_kernel_for_every_colorspace_format_and_quant() {
940        for colorspace in ALL_V4L2_COLORSPACES {
941            let recognised = ColorSpace::from_v4l2(colorspace).is_some();
942            for is_rgb_or_hsv in [false, true] {
943                for quant in [
944                    V4L2_QUANTIZATION_DEFAULT,
945                    V4L2_QUANTIZATION_FULL_RANGE,
946                    V4L2_QUANTIZATION_LIM_RANGE,
947                    99,
948                ] {
949                    let range =
950                        Colorimetry::from_v4l2(colorspace, 0, 0, quant, is_rgb_or_hsv).range;
951                    let expected = match quant {
952                        V4L2_QUANTIZATION_FULL_RANGE => Some(ColorRange::Full),
953                        V4L2_QUANTIZATION_LIM_RANGE => Some(ColorRange::Limited),
954                        V4L2_QUANTIZATION_DEFAULT if is_rgb_or_hsv || recognised => {
955                            Some(kernel_default_range(is_rgb_or_hsv, colorspace))
956                        }
957                        _ => None,
958                    };
959                    assert_eq!(
960                        range, expected,
961                        "colorspace {colorspace}, rgb/hsv {is_rgb_or_hsv}, quant {quant}"
962                    );
963                }
964            }
965        }
966    }
967
968    #[test]
969    fn rgb_default_quantization_is_full_and_yuv_follows_the_colorspace() {
970        let range = |colorspace, is_rgb_or_hsv| {
971            Colorimetry::from_v4l2(colorspace, 0, 0, V4L2_QUANTIZATION_DEFAULT, is_rgb_or_hsv).range
972        };
973        assert_eq!(range(V4L2_COLORSPACE_SRGB, true), Some(ColorRange::Full));
974        assert_eq!(
975            range(V4L2_COLORSPACE_SRGB, false),
976            Some(ColorRange::Limited)
977        );
978        assert_eq!(range(V4L2_COLORSPACE_JPEG, false), Some(ColorRange::Full));
979        // The kernel ignores the colorspace for an RGB/HSV format, so an
980        // unrecognised or DEFAULT one is still full range.
981        assert_eq!(range(0, true), Some(ColorRange::Full));
982        assert_eq!(range(V4L2_COLORSPACE_REC709, true), Some(ColorRange::Full));
983    }
984
985    #[test]
986    fn from_v4l2_encoding_matches_the_kernel_for_every_recognised_colorspace() {
987        // `V4L2_MAP_YCBCR_ENC_DEFAULT`: REC709 and DCI_P3 → 709, BT2020 →
988        // BT2020, SMPTE240M → SMPTE240M, else 601. DCI_P3 and SMPTE240M are
989        // not colorspaces `ColorSpace` recognises, so they stay `None`.
990        for colorspace in ALL_V4L2_COLORSPACES {
991            for is_rgb_or_hsv in [false, true] {
992                let encoding = Colorimetry::from_v4l2(colorspace, 0, 0, 0, is_rgb_or_hsv).encoding;
993                let expected = ColorSpace::from_v4l2(colorspace).map(|_| match colorspace {
994                    V4L2_COLORSPACE_REC709 => ColorEncoding::Bt709,
995                    V4L2_COLORSPACE_BT2020 => ColorEncoding::Bt2020,
996                    _ => ColorEncoding::Bt601,
997                });
998                assert_eq!(encoding, expected, "colorspace {colorspace}");
999            }
1000        }
1001    }
1002}