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) from <linux/videodev2.h>. Declared
15// here because this crate builds on every platform and `edgefirst-v4l2` is
16// Linux-only; `v4l2_uapi_tests` below asserts each against
17// `edgefirst_v4l2::uapi` on Linux.
18const V4L2_COLORSPACE_SMPTE170M: u32 = 1;
19const V4L2_COLORSPACE_REC709: u32 = 3;
20const V4L2_COLORSPACE_470_SYSTEM_M: u32 = 5;
21const V4L2_COLORSPACE_470_SYSTEM_BG: u32 = 6;
22const V4L2_COLORSPACE_JPEG: u32 = 7;
23const V4L2_COLORSPACE_SRGB: u32 = 8;
24const V4L2_COLORSPACE_BT2020: u32 = 10;
25const V4L2_XFER_FUNC_709: u32 = 1;
26const V4L2_XFER_FUNC_SRGB: u32 = 2;
27const V4L2_XFER_FUNC_NONE: u32 = 5;
28const V4L2_XFER_FUNC_SMPTE2084: u32 = 7;
29const V4L2_YCBCR_ENC_DEFAULT: u32 = 0;
30const V4L2_YCBCR_ENC_601: u32 = 1;
31const V4L2_YCBCR_ENC_709: u32 = 2;
32const V4L2_YCBCR_ENC_BT2020: u32 = 6;
33const V4L2_QUANTIZATION_DEFAULT: u32 = 0;
34const V4L2_QUANTIZATION_FULL_RANGE: u32 = 1;
35const V4L2_QUANTIZATION_LIM_RANGE: u32 = 2;
36
37// Each axis is declared through `ef_vocabulary!` (see `vocabulary.rs`):
38// `.code()` is the per-axis byte `Colorimetry::pack` writes and `from_code`
39// the one `unpack` reads; `as_str` is the EdgeFirst schema label. Code 0 is
40// reserved on every axis for "unspecified", which this crate spells
41// `Option::None`. The codes cross a package boundary inside
42// `TensorDesc::colorimetry`: append, never renumber.
43crate::ef_vocabulary! {
44    /// Color primaries (`color_space` in the EdgeFirst schema).
45    #[non_exhaustive]
46    #[derive(Serialize, Deserialize)]
47    pub enum ColorSpace {
48        Bt709 = 1, "bt709", BT709,
49        Bt2020 = 2, "bt2020", BT2020,
50        Srgb = 3, "srgb", SRGB,
51        Smpte170m = 4, "smpte170m", SMPTE170M,
52    }
53    #[doc(hidden)]
54    pub mod color_space_wire;
55}
56
57crate::ef_vocabulary! {
58    /// Transfer function (`color_transfer` in the EdgeFirst schema).
59    ///
60    /// **Limitation:** this axis is stored, propagated, and round-tripped, but it is
61    /// **not applied** by any conversion backend. All YUV↔RGB paths operate only on
62    /// the matrix ([`ColorEncoding`]) and range ([`ColorRange`]); the transfer
63    /// function (gamma / TRC) is assumed to be the platform-native curve and is left
64    /// unchanged. HDR transfer curves ([`Self::Pq`] / [`Self::Hlg`]) are therefore
65    /// *not* tone-mapped — consumers needing linear-light or HDR handling must apply
66    /// the curve themselves.
67    #[non_exhaustive]
68    #[derive(Serialize, Deserialize)]
69    pub enum ColorTransfer {
70        Bt709 = 1, "bt709", BT709,
71        Srgb = 2, "srgb", SRGB,
72        Pq = 3, "pq", PQ,
73        /// Hybrid Log-Gamma. Present for EdgeFirst-schema / libcamera parity; the
74        /// V4L2 UAPI defines no `V4L2_XFER_FUNC_HLG`, so `from_v4l2` never yields
75        /// this variant.
76        Hlg = 4, "hlg", HLG,
77        Linear = 5, "linear", LINEAR,
78    }
79    #[doc(hidden)]
80    pub mod color_transfer_wire;
81}
82
83crate::ef_vocabulary! {
84    /// YCbCr encoding matrix (`color_encoding` in the EdgeFirst schema).
85    #[non_exhaustive]
86    #[derive(Serialize, Deserialize)]
87    pub enum ColorEncoding {
88        Bt601 = 1, "bt601", BT601,
89        Bt709 = 2, "bt709", BT709,
90        Bt2020 = 3, "bt2020", BT2020,
91    }
92    #[doc(hidden)]
93    pub mod color_encoding_wire;
94}
95
96crate::ef_vocabulary! {
97    /// Quantization range (`color_range` in the EdgeFirst schema).
98    #[non_exhaustive]
99    #[derive(Serialize, Deserialize)]
100    pub enum ColorRange {
101        Full = 1, "full", FULL,
102        Limited = 2, "limited", LIMITED,
103    }
104    #[doc(hidden)]
105    pub mod color_range_wire;
106}
107
108impl ColorSpace {
109    /// Map a raw V4L2 `colorspace` field to a [`ColorSpace`].
110    ///
111    /// Returns `None` for `V4L2_COLORSPACE_DEFAULT` (0) and any
112    /// unrecognised value.
113    pub fn from_v4l2(v: u32) -> Option<Self> {
114        match v {
115            V4L2_COLORSPACE_SMPTE170M => Some(Self::Smpte170m),
116            V4L2_COLORSPACE_REC709 => Some(Self::Bt709),
117            // legacy NTSC (470M) / PAL-SECAM (470BG): close enough to SMPTE 170M primaries for conversion
118            V4L2_COLORSPACE_470_SYSTEM_M | V4L2_COLORSPACE_470_SYSTEM_BG => Some(Self::Smpte170m),
119            V4L2_COLORSPACE_JPEG | V4L2_COLORSPACE_SRGB => Some(Self::Srgb),
120            V4L2_COLORSPACE_BT2020 => Some(Self::Bt2020),
121            _ => None,
122        }
123    }
124}
125
126impl ColorTransfer {
127    /// Map a raw V4L2 `xfer_func` field to a [`ColorTransfer`].
128    ///
129    /// Returns `None` for `V4L2_XFER_FUNC_DEFAULT` (0) and any
130    /// unrecognised value.
131    ///
132    /// Note: OPRGB (3), SMPTE240M (4), and DCI_P3 (6) have no HAL equivalent
133    /// and map to `None`. [`ColorTransfer::Hlg`] is never produced because the
134    /// V4L2 UAPI defines no `V4L2_XFER_FUNC_HLG` value.
135    pub fn from_v4l2(v: u32) -> Option<Self> {
136        match v {
137            V4L2_XFER_FUNC_709 => Some(Self::Bt709),
138            V4L2_XFER_FUNC_SRGB => Some(Self::Srgb),
139            V4L2_XFER_FUNC_NONE => Some(Self::Linear),
140            V4L2_XFER_FUNC_SMPTE2084 => Some(Self::Pq),
141            _ => None,
142        }
143    }
144}
145
146impl ColorEncoding {
147    /// Map a raw V4L2 `ycbcr_enc` field to a [`ColorEncoding`].
148    ///
149    /// Returns `None` for `V4L2_YCBCR_ENC_DEFAULT` (0) and any
150    /// unrecognised value.
151    pub fn from_v4l2(v: u32) -> Option<Self> {
152        match v {
153            V4L2_YCBCR_ENC_601 => Some(Self::Bt601),
154            V4L2_YCBCR_ENC_709 => Some(Self::Bt709),
155            V4L2_YCBCR_ENC_BT2020 => Some(Self::Bt2020),
156            _ => None,
157        }
158    }
159}
160
161impl ColorRange {
162    /// Map a raw V4L2 `quantization` field to a [`ColorRange`].
163    ///
164    /// Returns `None` for `V4L2_QUANTIZATION_DEFAULT` (0) and any
165    /// unrecognised value.
166    pub fn from_v4l2(v: u32) -> Option<Self> {
167        match v {
168            V4L2_QUANTIZATION_FULL_RANGE => Some(Self::Full),
169            V4L2_QUANTIZATION_LIM_RANGE => Some(Self::Limited),
170            _ => None,
171        }
172    }
173}
174
175macro_rules! display_via_as_str {
176    ($($t:ty),*) => {$(
177        impl fmt::Display for $t {
178            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179                f.write_str(self.as_str())
180            }
181        }
182    )*};
183}
184display_via_as_str!(ColorSpace, ColorTransfer, ColorEncoding, ColorRange);
185
186/// YCbCr matrix luma weights `(kr, kb)`; the green weight is `kg = 1 - kr - kb`.
187///
188/// This is the single source of the BT.601/709/2020 coefficients. Every
189/// YUV↔RGB path (the in-shader GL coefficients and the hand-rolled fixed-point
190/// CPU encoders) derives its matrix from here so a coefficient change is made
191/// in exactly one place.
192#[derive(Debug, Clone, Copy, PartialEq)]
193pub struct MatrixWeights {
194    pub kr: f64,
195    pub kb: f64,
196}
197
198impl MatrixWeights {
199    /// Green luma weight, `1 - kr - kb`.
200    pub fn kg(self) -> f64 {
201        1.0 - self.kr - self.kb
202    }
203}
204
205impl ColorEncoding {
206    /// The matrix luma weights `(kr, kb)` for this encoding — the canonical
207    /// BT.601/709/2020 coefficients shared by every YUV↔RGB conversion path.
208    pub fn luma_weights(self) -> MatrixWeights {
209        match self {
210            Self::Bt601 => MatrixWeights {
211                kr: 0.299,
212                kb: 0.114,
213            },
214            Self::Bt709 => MatrixWeights {
215                kr: 0.2126,
216                kb: 0.0722,
217            },
218            Self::Bt2020 => MatrixWeights {
219                kr: 0.2627,
220                kb: 0.0593,
221            },
222        }
223    }
224}
225
226/// Quantization swings (out of 255) for a YCbCr range: the luma black offset and
227/// the luma/chroma excursions. Full range is `0 / 255 / 255`; limited (studio)
228/// range is `16 / 219 / 224`.
229#[derive(Debug, Clone, Copy, PartialEq)]
230pub struct RangeScaling {
231    /// Luma black level (0 for full range, 16 for limited).
232    pub y_offset: f64,
233    /// Luma excursion (255 for full range, 219 for limited).
234    pub y_swing: f64,
235    /// Chroma excursion (255 for full range, 224 for limited).
236    pub c_swing: f64,
237}
238
239impl ColorRange {
240    /// The luma/chroma quantization swings for this range — the canonical
241    /// 0/255/255 (full) or 16/219/224 (limited) studio-swing constants shared
242    /// by every YUV↔RGB conversion path.
243    pub fn scaling(self) -> RangeScaling {
244        match self {
245            Self::Full => RangeScaling {
246                y_offset: 0.0,
247                y_swing: 255.0,
248                c_swing: 255.0,
249            },
250            Self::Limited => RangeScaling {
251                y_offset: 16.0,
252                y_swing: 219.0,
253                c_swing: 224.0,
254            },
255        }
256    }
257}
258
259/// Full 4-axis colorimetry. Each axis is `Option`; `None` means "undefined"
260/// and is never auto-filled — consumers (e.g. `convert()`) resolve missing
261/// axes at use-time without mutating the value.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
263pub struct Colorimetry {
264    pub space: Option<ColorSpace>,
265    pub transfer: Option<ColorTransfer>,
266    pub encoding: Option<ColorEncoding>,
267    pub range: Option<ColorRange>,
268}
269
270impl Colorimetry {
271    /// JPEG/JFIF colorimetry: sRGB primaries, sRGB transfer, BT.601
272    /// encoding, full range.
273    pub fn jfif() -> Self {
274        Self {
275            space: Some(ColorSpace::Srgb),
276            transfer: Some(ColorTransfer::Srgb),
277            encoding: Some(ColorEncoding::Bt601),
278            range: Some(ColorRange::Full),
279        }
280    }
281
282    /// Build from the four raw V4L2 colorimetry integers of a negotiated
283    /// format whose pixel encoding is RGB or HSV when `is_rgb_or_hsv` is
284    /// `true` (see [`PixelFormat::is_rgb_or_hsv`]).
285    ///
286    /// Explicit values map directly. For `ycbcr_enc`/`quantization`, the V4L2
287    /// `DEFAULT` (0) sentinel does NOT mean "unknown" — it means "derive"
288    /// (kernel `V4L2_MAP_YCBCR_ENC_DEFAULT` / `V4L2_MAP_QUANTIZATION_DEFAULT`),
289    /// and those axes are resolved here rather than left `None` for the
290    /// at-use height heuristic (which would wrongly pick BT.709/limited for an
291    /// HD JPEG frame):
292    ///
293    /// - `quantization = DEFAULT` is full range for an RGB/HSV format,
294    ///   whatever the colorspace, and for `V4L2_COLORSPACE_JPEG`; limited for
295    ///   a YUV format in any other recognised colorspace; and unset (`None`)
296    ///   for a YUV format in a `DEFAULT` or unrecognised colorspace, so the
297    ///   at-use height heuristic decides.
298    /// - `ycbcr_enc = DEFAULT` is the encoding the colorspace implies, and
299    ///   unset (`None`) for a `DEFAULT` or unrecognised colorspace.
300    ///
301    /// Unrecognised non-default values also map to `None`.
302    pub fn from_v4l2(
303        colorspace: u32,
304        xfer: u32,
305        ycbcr_enc: u32,
306        quant: u32,
307        is_rgb_or_hsv: bool,
308    ) -> Self {
309        let encoding = ColorEncoding::from_v4l2(ycbcr_enc).or_else(|| {
310            if ycbcr_enc == V4L2_YCBCR_ENC_DEFAULT {
311                Self::default_encoding_for_colorspace(colorspace)
312            } else {
313                None
314            }
315        });
316        let range = ColorRange::from_v4l2(quant).or_else(|| {
317            if quant == V4L2_QUANTIZATION_DEFAULT {
318                Self::default_range(is_rgb_or_hsv, colorspace)
319            } else {
320                None
321            }
322        });
323        Self {
324            space: ColorSpace::from_v4l2(colorspace),
325            transfer: ColorTransfer::from_v4l2(xfer),
326            encoding,
327            range,
328        }
329    }
330
331    /// V4L2 `ycbcr_enc=DEFAULT` → encoding implied by the colorspace, per
332    /// `V4L2_MAP_YCBCR_ENC_DEFAULT` for every colorspace [`ColorSpace`]
333    /// recognises: BT.709 for REC709, BT.2020 for BT2020, BT.601 otherwise.
334    /// `None` for default/unrecognised.
335    fn default_encoding_for_colorspace(colorspace: u32) -> Option<ColorEncoding> {
336        match colorspace {
337            V4L2_COLORSPACE_REC709 => Some(ColorEncoding::Bt709),
338            V4L2_COLORSPACE_BT2020 => Some(ColorEncoding::Bt2020),
339            V4L2_COLORSPACE_SMPTE170M
340            | V4L2_COLORSPACE_470_SYSTEM_M
341            | V4L2_COLORSPACE_470_SYSTEM_BG
342            | V4L2_COLORSPACE_JPEG
343            | V4L2_COLORSPACE_SRGB => Some(ColorEncoding::Bt601),
344            _ => None,
345        }
346    }
347
348    /// V4L2 `quantization=DEFAULT` → range, per
349    /// `V4L2_MAP_QUANTIZATION_DEFAULT`: full for an RGB/HSV format whatever
350    /// the colorspace, full for JPEG, limited for every other recognised
351    /// colorspace. `None` for a YUV format in a default/unrecognised
352    /// colorspace.
353    fn default_range(is_rgb_or_hsv: bool, colorspace: u32) -> Option<ColorRange> {
354        if is_rgb_or_hsv {
355            return Some(ColorRange::Full);
356        }
357        match colorspace {
358            V4L2_COLORSPACE_JPEG => Some(ColorRange::Full),
359            V4L2_COLORSPACE_SMPTE170M
360            | V4L2_COLORSPACE_REC709
361            | V4L2_COLORSPACE_BT2020
362            | V4L2_COLORSPACE_470_SYSTEM_M
363            | V4L2_COLORSPACE_470_SYSTEM_BG
364            | V4L2_COLORSPACE_SRGB => Some(ColorRange::Limited),
365            _ => None,
366        }
367    }
368
369    /// Set color primaries (consuming builder).
370    pub fn with_space(mut self, s: ColorSpace) -> Self {
371        self.space = Some(s);
372        self
373    }
374
375    /// Set transfer function (consuming builder).
376    pub fn with_transfer(mut self, t: ColorTransfer) -> Self {
377        self.transfer = Some(t);
378        self
379    }
380
381    /// Set YCbCr encoding matrix (consuming builder).
382    pub fn with_encoding(mut self, e: ColorEncoding) -> Self {
383        self.encoding = Some(e);
384        self
385    }
386
387    /// Set quantization range (consuming builder).
388    pub fn with_range(mut self, r: ColorRange) -> Self {
389        self.range = Some(r);
390        self
391    }
392
393    /// Pack all four axes into one `u32` for
394    /// [`crate::protocol::TensorDesc::colorimetry`].
395    ///
396    /// One byte per axis, `space` in the low byte through `range` in the
397    /// high byte; `0` on an axis means "undefined", so an all-`None`
398    /// `Colorimetry` packs to `0`. Each byte is that axis's `code()`, the
399    /// same word the C API's `ef_tensor_colorimetry` returns. This crosses a
400    /// package boundary like `TensorDesc` itself: codes are appended, never
401    /// renumbered.
402    pub fn pack(&self) -> u32 {
403        let space = self.space.map_or(0, ColorSpace::code);
404        let transfer = self.transfer.map_or(0, ColorTransfer::code);
405        let encoding = self.encoding.map_or(0, ColorEncoding::code);
406        let range = self.range.map_or(0, ColorRange::code);
407        space | (transfer << 8) | (encoding << 16) | (range << 24)
408    }
409
410    /// Inverse of [`Self::pack`]: rebuild a `Colorimetry` from a packed
411    /// `u32`, e.g. [`crate::protocol::TensorDesc::colorimetry`] on
412    /// import. `0` on an axis byte (undefined, including a build that
413    /// predates the axis) maps to `None` rather than erroring, the same
414    /// forward-compatibility rule [`crate::protocol::dtype_to_dtype`]
415    /// applies to another wire-coded axis -- a code this build does not
416    /// recognise (e.g. one a newer producer added) also maps to `None`
417    /// instead of guessing.
418    pub fn unpack(bits: u32) -> Self {
419        let space = ColorSpace::from_code(bits & 0xFF);
420        let transfer = ColorTransfer::from_code((bits >> 8) & 0xFF);
421        let encoding = ColorEncoding::from_code((bits >> 16) & 0xFF);
422        let range = ColorRange::from_code((bits >> 24) & 0xFF);
423        Colorimetry {
424            space,
425            transfer,
426            encoding,
427            range,
428        }
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn from_v4l2_known_and_default() {
438        assert_eq!(ColorEncoding::from_v4l2(1), Some(ColorEncoding::Bt601));
439        assert_eq!(ColorEncoding::from_v4l2(2), Some(ColorEncoding::Bt709));
440        assert_eq!(ColorEncoding::from_v4l2(6), Some(ColorEncoding::Bt2020));
441        assert_eq!(ColorEncoding::from_v4l2(0), None); // DEFAULT
442        assert_eq!(ColorEncoding::from_v4l2(3), None); // XV601 (unsurfaced)
443        assert_eq!(ColorRange::from_v4l2(1), Some(ColorRange::Full));
444        assert_eq!(ColorRange::from_v4l2(2), Some(ColorRange::Limited));
445        assert_eq!(ColorSpace::from_v4l2(7), Some(ColorSpace::Srgb)); // JPEG→sRGB
446        assert_eq!(ColorTransfer::from_v4l2(5), Some(ColorTransfer::Linear)); // NONE→linear
447
448        // ColorSpace: additional arms
449        assert_eq!(ColorSpace::from_v4l2(1), Some(ColorSpace::Smpte170m)); // SMPTE170M
450        assert_eq!(ColorSpace::from_v4l2(3), Some(ColorSpace::Bt709)); // REC709
451        assert_eq!(ColorSpace::from_v4l2(10), Some(ColorSpace::Bt2020)); // BT2020
452        assert_eq!(ColorSpace::from_v4l2(5), Some(ColorSpace::Smpte170m)); // 470_SYSTEM_M
453        assert_eq!(ColorSpace::from_v4l2(6), Some(ColorSpace::Smpte170m)); // 470_SYSTEM_BG
454
455        // ColorTransfer: additional arms and unmapped values
456        assert_eq!(ColorTransfer::from_v4l2(1), Some(ColorTransfer::Bt709)); // XFER_FUNC_709
457        assert_eq!(ColorTransfer::from_v4l2(2), Some(ColorTransfer::Srgb)); // XFER_FUNC_SRGB
458        assert_eq!(ColorTransfer::from_v4l2(7), Some(ColorTransfer::Pq)); // XFER_FUNC_SMPTE2084
459        assert_eq!(ColorTransfer::from_v4l2(3), None); // OPRGB — no HAL equivalent
460        assert_eq!(ColorTransfer::from_v4l2(6), None); // DCI_P3 — no HAL equivalent
461
462        // Hlg is never produced by from_v4l2 — no V4L2_XFER_FUNC_HLG exists in the kernel UAPI
463        for v in 0u32..=10 {
464            assert_ne!(ColorTransfer::from_v4l2(v), Some(ColorTransfer::Hlg));
465        }
466    }
467
468    #[test]
469    fn jfif_is_bt601_full_srgb() {
470        let c = Colorimetry::jfif();
471        assert_eq!(c.space, Some(ColorSpace::Srgb));
472        assert_eq!(c.transfer, Some(ColorTransfer::Srgb));
473        assert_eq!(c.encoding, Some(ColorEncoding::Bt601));
474        assert_eq!(c.range, Some(ColorRange::Full));
475    }
476
477    #[test]
478    fn from_v4l2_struct_maps_all_axes_and_unknown_to_none() {
479        let c = Colorimetry::from_v4l2(3, 1, 2, 1, false); // REC709, XFER709, ENC709, FULL
480        assert_eq!(c.space, Some(ColorSpace::Bt709));
481        assert_eq!(c.transfer, Some(ColorTransfer::Bt709));
482        assert_eq!(c.encoding, Some(ColorEncoding::Bt709));
483        assert_eq!(c.range, Some(ColorRange::Full));
484        let d = Colorimetry::from_v4l2(0, 0, 0, 0, false); // all DEFAULT, YUV
485        assert_eq!(d, Colorimetry::default()); // all None
486    }
487
488    #[test]
489    fn from_v4l2_default_enc_quant_derive_from_colorspace() {
490        // COLORSPACE_JPEG (7) with DEFAULT ycbcr_enc/quant must resolve to
491        // BT.601 full-range per V4L2_MAP_*_DEFAULT — NOT be left None (which
492        // would let the height heuristic wrongly pick BT.709/limited for HD).
493        let jpeg = Colorimetry::from_v4l2(7, 0, 0, 0, false);
494        assert_eq!(jpeg.encoding, Some(ColorEncoding::Bt601));
495        assert_eq!(jpeg.range, Some(ColorRange::Full));
496
497        // REC709 colorspace, DEFAULT enc/quant → BT.709 limited.
498        let rec709 = Colorimetry::from_v4l2(3, 0, 0, 0, false);
499        assert_eq!(rec709.encoding, Some(ColorEncoding::Bt709));
500        assert_eq!(rec709.range, Some(ColorRange::Limited));
501
502        // Explicit ycbcr_enc/quant still win over the colorspace default.
503        let explicit = Colorimetry::from_v4l2(7, 0, 2, 2, false); // JPEG but enc=709, quant=limited
504        assert_eq!(explicit.encoding, Some(ColorEncoding::Bt709));
505        assert_eq!(explicit.range, Some(ColorRange::Limited));
506
507        // Unrecognised non-default values stay None (not derived).
508        let unknown_enc = Colorimetry::from_v4l2(7, 0, 99, 0, false);
509        assert_eq!(unknown_enc.encoding, None);
510        assert_eq!(unknown_enc.range, Some(ColorRange::Full)); // quant still defaulted from JPEG
511    }
512
513    #[test]
514    fn luma_weights_are_the_canonical_bt_constants() {
515        assert_eq!(
516            ColorEncoding::Bt601.luma_weights(),
517            MatrixWeights {
518                kr: 0.299,
519                kb: 0.114
520            }
521        );
522        assert_eq!(
523            ColorEncoding::Bt709.luma_weights(),
524            MatrixWeights {
525                kr: 0.2126,
526                kb: 0.0722
527            }
528        );
529        assert_eq!(
530            ColorEncoding::Bt2020.luma_weights(),
531            MatrixWeights {
532                kr: 0.2627,
533                kb: 0.0593
534            }
535        );
536        // kg = 1 - kr - kb (BT.709 green weight ≈ 0.7152).
537        assert!((ColorEncoding::Bt709.luma_weights().kg() - 0.7152).abs() < 1e-9);
538    }
539
540    #[test]
541    fn range_scaling_is_full_or_studio_swing() {
542        let full = ColorRange::Full.scaling();
543        assert_eq!(
544            full,
545            RangeScaling {
546                y_offset: 0.0,
547                y_swing: 255.0,
548                c_swing: 255.0
549            }
550        );
551        let limited = ColorRange::Limited.scaling();
552        assert_eq!(
553            limited,
554            RangeScaling {
555                y_offset: 16.0,
556                y_swing: 219.0,
557                c_swing: 224.0
558            }
559        );
560    }
561
562    #[test]
563    fn as_str_and_from_str_code_are_inverses_for_every_variant() {
564        // The wire round-trip depends on this, and `as_str` alone cannot show
565        // it: a parser missing one arm silently degrades that axis to None.
566        for v in [
567            ColorSpace::Bt709,
568            ColorSpace::Bt2020,
569            ColorSpace::Srgb,
570            ColorSpace::Smpte170m,
571        ] {
572            assert_eq!(ColorSpace::from_str_code(v.as_str()), Some(v), "{v:?}");
573        }
574        for v in [
575            ColorTransfer::Bt709,
576            ColorTransfer::Srgb,
577            ColorTransfer::Pq,
578            ColorTransfer::Hlg,
579            ColorTransfer::Linear,
580        ] {
581            assert_eq!(ColorTransfer::from_str_code(v.as_str()), Some(v), "{v:?}");
582        }
583        for v in [
584            ColorEncoding::Bt601,
585            ColorEncoding::Bt709,
586            ColorEncoding::Bt2020,
587        ] {
588            assert_eq!(ColorEncoding::from_str_code(v.as_str()), Some(v), "{v:?}");
589        }
590        for v in [ColorRange::Full, ColorRange::Limited] {
591            assert_eq!(ColorRange::from_str_code(v.as_str()), Some(v), "{v:?}");
592        }
593    }
594
595    #[test]
596    fn an_unknown_or_empty_label_is_unspecified_not_an_error() {
597        assert_eq!(ColorSpace::from_str_code(""), None);
598        assert_eq!(ColorSpace::from_str_code("not-a-colour-space"), None);
599        assert_eq!(
600            ColorRange::from_str_code("FULL"),
601            None,
602            "labels are case-sensitive"
603        );
604    }
605
606    #[test]
607    fn as_str_matches_schema() {
608        assert_eq!(ColorEncoding::Bt601.as_str(), "bt601");
609        assert_eq!(ColorRange::Full.as_str(), "full");
610        assert_eq!(ColorSpace::Smpte170m.as_str(), "smpte170m");
611        assert_eq!(ColorTransfer::Pq.as_str(), "pq");
612        assert_eq!(ColorTransfer::Hlg.as_str(), "hlg");
613    }
614
615    #[test]
616    fn pack_unpack_round_trips() {
617        // The exact round trip the cross-package import path relies on:
618        // `TensorDyn::import_descriptor` unpacks what `descriptor_pinned`
619        // packed. All-None must round-trip through 0, and every populated
620        // axis must survive.
621        assert_eq!(
622            Colorimetry::unpack(Colorimetry::default().pack()),
623            Colorimetry::default()
624        );
625        assert_eq!(Colorimetry::default().pack(), 0);
626
627        let jfif = Colorimetry::jfif();
628        assert_eq!(Colorimetry::unpack(jfif.pack()), jfif);
629
630        let full = Colorimetry {
631            space: Some(ColorSpace::Bt2020),
632            transfer: Some(ColorTransfer::Hlg),
633            encoding: Some(ColorEncoding::Bt2020),
634            range: Some(ColorRange::Limited),
635        };
636        assert_eq!(Colorimetry::unpack(full.pack()), full);
637    }
638
639    #[test]
640    fn unpack_maps_unrecognised_codes_to_none() {
641        // A code this build does not recognise (e.g. from a newer producer)
642        // must not panic or guess -- same forward-compat rule as
643        // `dtype_to_dtype`/`format_from_code`.
644        let bits = 0xFFu32 | (0xFFu32 << 8) | (0xFFu32 << 16) | (0xFFu32 << 24);
645        assert_eq!(Colorimetry::unpack(bits), Colorimetry::default());
646    }
647
648    const SPACES: [(ColorSpace, u32); 4] = [
649        (ColorSpace::Bt709, 1),
650        (ColorSpace::Bt2020, 2),
651        (ColorSpace::Srgb, 3),
652        (ColorSpace::Smpte170m, 4),
653    ];
654    const TRANSFERS: [(ColorTransfer, u32); 5] = [
655        (ColorTransfer::Bt709, 1),
656        (ColorTransfer::Srgb, 2),
657        (ColorTransfer::Pq, 3),
658        (ColorTransfer::Hlg, 4),
659        (ColorTransfer::Linear, 5),
660    ];
661    const ENCODINGS: [(ColorEncoding, u32); 3] = [
662        (ColorEncoding::Bt601, 1),
663        (ColorEncoding::Bt709, 2),
664        (ColorEncoding::Bt2020, 3),
665    ];
666    const RANGES: [(ColorRange, u32); 2] = [(ColorRange::Full, 1), (ColorRange::Limited, 2)];
667
668    #[test]
669    fn pack_places_each_axis_code_in_its_own_byte() {
670        // The per-axis codes are a cross-package wire contract: pin them,
671        // for every variant.
672        assert_eq!(SPACES.len(), ColorSpace::all().len());
673        assert_eq!(TRANSFERS.len(), ColorTransfer::all().len());
674        assert_eq!(ENCODINGS.len(), ColorEncoding::all().len());
675        assert_eq!(RANGES.len(), ColorRange::all().len());
676        for (s, code) in SPACES {
677            let c = Colorimetry::default().with_space(s);
678            assert_eq!(c.pack(), code, "{s:?}");
679            assert_eq!(Colorimetry::unpack(code), c, "{s:?}");
680        }
681        for (t, code) in TRANSFERS {
682            let c = Colorimetry::default().with_transfer(t);
683            assert_eq!(c.pack(), code << 8, "{t:?}");
684            assert_eq!(Colorimetry::unpack(code << 8), c, "{t:?}");
685        }
686        for (e, code) in ENCODINGS {
687            let c = Colorimetry::default().with_encoding(e);
688            assert_eq!(c.pack(), code << 16, "{e:?}");
689            assert_eq!(Colorimetry::unpack(code << 16), c, "{e:?}");
690        }
691        for (r, code) in RANGES {
692            let c = Colorimetry::default().with_range(r);
693            assert_eq!(c.pack(), code << 24, "{r:?}");
694            assert_eq!(Colorimetry::unpack(code << 24), c, "{r:?}");
695        }
696    }
697
698    #[test]
699    fn unpack_decodes_a_fully_populated_word() {
700        assert_eq!(
701            Colorimetry::unpack(0x02_02_05_04),
702            Colorimetry {
703                space: Some(ColorSpace::Smpte170m),
704                transfer: Some(ColorTransfer::Linear),
705                encoding: Some(ColorEncoding::Bt709),
706                range: Some(ColorRange::Limited),
707            }
708        );
709    }
710
711    #[test]
712    fn every_axis_combination_round_trips_through_pack() {
713        for (s, _) in SPACES {
714            for (t, _) in TRANSFERS {
715                for (e, _) in ENCODINGS {
716                    for (r, _) in RANGES {
717                        let c = Colorimetry {
718                            space: Some(s),
719                            transfer: Some(t),
720                            encoding: Some(e),
721                            range: Some(r),
722                        };
723                        assert_eq!(Colorimetry::unpack(c.pack()), c);
724                    }
725                }
726            }
727        }
728    }
729
730    #[test]
731    fn builders_set_exactly_their_own_axis() {
732        let c = Colorimetry::default()
733            .with_space(ColorSpace::Bt2020)
734            .with_transfer(ColorTransfer::Pq)
735            .with_encoding(ColorEncoding::Bt2020)
736            .with_range(ColorRange::Full);
737        assert_eq!(
738            c,
739            Colorimetry {
740                space: Some(ColorSpace::Bt2020),
741                transfer: Some(ColorTransfer::Pq),
742                encoding: Some(ColorEncoding::Bt2020),
743                range: Some(ColorRange::Full),
744            }
745        );
746        // Each builder leaves the other axes as they were.
747        let base = Colorimetry::jfif();
748        assert_eq!(
749            base.with_space(ColorSpace::Bt709),
750            Colorimetry {
751                space: Some(ColorSpace::Bt709),
752                ..base
753            }
754        );
755        assert_eq!(
756            base.with_transfer(ColorTransfer::Hlg),
757            Colorimetry {
758                transfer: Some(ColorTransfer::Hlg),
759                ..base
760            }
761        );
762        assert_eq!(
763            base.with_encoding(ColorEncoding::Bt709),
764            Colorimetry {
765                encoding: Some(ColorEncoding::Bt709),
766                ..base
767            }
768        );
769        assert_eq!(
770            base.with_range(ColorRange::Limited),
771            Colorimetry {
772                range: Some(ColorRange::Limited),
773                ..base
774            }
775        );
776    }
777
778    #[test]
779    fn from_v4l2_derives_encoding_and_range_for_every_recognised_yuv_colorspace() {
780        use ColorEncoding as E;
781        use ColorRange as R;
782        let table = [
783            (V4L2_COLORSPACE_SMPTE170M, E::Bt601, R::Limited),
784            (V4L2_COLORSPACE_REC709, E::Bt709, R::Limited),
785            (V4L2_COLORSPACE_470_SYSTEM_M, E::Bt601, R::Limited),
786            (V4L2_COLORSPACE_470_SYSTEM_BG, E::Bt601, R::Limited),
787            (V4L2_COLORSPACE_JPEG, E::Bt601, R::Full),
788            (V4L2_COLORSPACE_SRGB, E::Bt601, R::Limited),
789            (V4L2_COLORSPACE_BT2020, E::Bt2020, R::Limited),
790        ];
791        for (colorspace, encoding, range) in table {
792            let c = Colorimetry::from_v4l2(colorspace, 0, 0, 0, false);
793            assert_eq!(c.encoding, Some(encoding), "colorspace {colorspace}");
794            assert_eq!(c.range, Some(range), "colorspace {colorspace}");
795        }
796        // Unrecognised colorspaces leave both axes to the at-use heuristic.
797        for colorspace in [0, 2, 4, 9, 11, 99] {
798            let c = Colorimetry::from_v4l2(colorspace, 0, 0, 0, false);
799            assert_eq!(
800                (c.encoding, c.range),
801                (None, None),
802                "colorspace {colorspace}"
803            );
804        }
805    }
806
807    /// Every V4L2 colorspace code `videodev2.h` defines, plus an
808    /// unassigned one.
809    const ALL_V4L2_COLORSPACES: [u32; 14] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 99];
810
811    /// `V4L2_MAP_QUANTIZATION_DEFAULT(is_rgb_or_hsv, colsp, _)`, restated
812    /// from the header for the colorspaces this crate recognises.
813    fn kernel_default_range(is_rgb_or_hsv: bool, colorspace: u32) -> ColorRange {
814        if is_rgb_or_hsv || colorspace == V4L2_COLORSPACE_JPEG {
815            ColorRange::Full
816        } else {
817            ColorRange::Limited
818        }
819    }
820
821    #[test]
822    fn from_v4l2_quantization_matches_the_kernel_for_every_colorspace_format_and_quant() {
823        for colorspace in ALL_V4L2_COLORSPACES {
824            let recognised = ColorSpace::from_v4l2(colorspace).is_some();
825            for is_rgb_or_hsv in [false, true] {
826                for quant in [
827                    V4L2_QUANTIZATION_DEFAULT,
828                    V4L2_QUANTIZATION_FULL_RANGE,
829                    V4L2_QUANTIZATION_LIM_RANGE,
830                    99,
831                ] {
832                    let range =
833                        Colorimetry::from_v4l2(colorspace, 0, 0, quant, is_rgb_or_hsv).range;
834                    let expected = match quant {
835                        V4L2_QUANTIZATION_FULL_RANGE => Some(ColorRange::Full),
836                        V4L2_QUANTIZATION_LIM_RANGE => Some(ColorRange::Limited),
837                        V4L2_QUANTIZATION_DEFAULT if is_rgb_or_hsv || recognised => {
838                            Some(kernel_default_range(is_rgb_or_hsv, colorspace))
839                        }
840                        _ => None,
841                    };
842                    assert_eq!(
843                        range, expected,
844                        "colorspace {colorspace}, rgb/hsv {is_rgb_or_hsv}, quant {quant}"
845                    );
846                }
847            }
848        }
849    }
850
851    #[test]
852    fn rgb_default_quantization_is_full_and_yuv_follows_the_colorspace() {
853        let range = |colorspace, is_rgb_or_hsv| {
854            Colorimetry::from_v4l2(colorspace, 0, 0, V4L2_QUANTIZATION_DEFAULT, is_rgb_or_hsv).range
855        };
856        assert_eq!(range(V4L2_COLORSPACE_SRGB, true), Some(ColorRange::Full));
857        assert_eq!(
858            range(V4L2_COLORSPACE_SRGB, false),
859            Some(ColorRange::Limited)
860        );
861        assert_eq!(range(V4L2_COLORSPACE_JPEG, false), Some(ColorRange::Full));
862        // The kernel ignores the colorspace for an RGB/HSV format, so an
863        // unrecognised or DEFAULT one is still full range.
864        assert_eq!(range(0, true), Some(ColorRange::Full));
865        assert_eq!(range(V4L2_COLORSPACE_REC709, true), Some(ColorRange::Full));
866    }
867
868    #[test]
869    fn from_v4l2_encoding_matches_the_kernel_for_every_recognised_colorspace() {
870        // `V4L2_MAP_YCBCR_ENC_DEFAULT`: REC709 and DCI_P3 → 709, BT2020 →
871        // BT2020, SMPTE240M → SMPTE240M, else 601. DCI_P3 and SMPTE240M are
872        // not colorspaces `ColorSpace` recognises, so they stay `None`.
873        for colorspace in ALL_V4L2_COLORSPACES {
874            for is_rgb_or_hsv in [false, true] {
875                let encoding = Colorimetry::from_v4l2(colorspace, 0, 0, 0, is_rgb_or_hsv).encoding;
876                let expected = ColorSpace::from_v4l2(colorspace).map(|_| match colorspace {
877                    V4L2_COLORSPACE_REC709 => ColorEncoding::Bt709,
878                    V4L2_COLORSPACE_BT2020 => ColorEncoding::Bt2020,
879                    _ => ColorEncoding::Bt601,
880                });
881                assert_eq!(encoding, expected, "colorspace {colorspace}");
882            }
883        }
884    }
885}
886
887#[cfg(all(test, target_os = "linux"))]
888mod v4l2_uapi_tests {
889    use edgefirst_v4l2::uapi;
890
891    #[test]
892    fn colorimetry_constants_match_edgefirst_v4l2() {
893        assert_eq!(
894            super::V4L2_COLORSPACE_SMPTE170M,
895            uapi::V4L2_COLORSPACE_SMPTE170M
896        );
897        assert_eq!(super::V4L2_COLORSPACE_REC709, uapi::V4L2_COLORSPACE_REC709);
898        assert_eq!(
899            super::V4L2_COLORSPACE_470_SYSTEM_M,
900            uapi::V4L2_COLORSPACE_470_SYSTEM_M
901        );
902        assert_eq!(
903            super::V4L2_COLORSPACE_470_SYSTEM_BG,
904            uapi::V4L2_COLORSPACE_470_SYSTEM_BG
905        );
906        assert_eq!(super::V4L2_COLORSPACE_JPEG, uapi::V4L2_COLORSPACE_JPEG);
907        assert_eq!(super::V4L2_COLORSPACE_SRGB, uapi::V4L2_COLORSPACE_SRGB);
908        assert_eq!(super::V4L2_COLORSPACE_BT2020, uapi::V4L2_COLORSPACE_BT2020);
909
910        assert_eq!(
911            super::V4L2_XFER_FUNC_709,
912            u32::from(uapi::V4L2_XFER_FUNC_709)
913        );
914        assert_eq!(
915            super::V4L2_XFER_FUNC_SRGB,
916            u32::from(uapi::V4L2_XFER_FUNC_SRGB)
917        );
918        assert_eq!(
919            super::V4L2_XFER_FUNC_NONE,
920            u32::from(uapi::V4L2_XFER_FUNC_NONE)
921        );
922        assert_eq!(
923            super::V4L2_XFER_FUNC_SMPTE2084,
924            u32::from(uapi::V4L2_XFER_FUNC_SMPTE2084)
925        );
926
927        assert_eq!(
928            super::V4L2_YCBCR_ENC_DEFAULT,
929            u32::from(uapi::V4L2_YCBCR_ENC_DEFAULT)
930        );
931        assert_eq!(
932            super::V4L2_YCBCR_ENC_601,
933            u32::from(uapi::V4L2_YCBCR_ENC_601)
934        );
935        assert_eq!(
936            super::V4L2_YCBCR_ENC_709,
937            u32::from(uapi::V4L2_YCBCR_ENC_709)
938        );
939        assert_eq!(
940            super::V4L2_YCBCR_ENC_BT2020,
941            u32::from(uapi::V4L2_YCBCR_ENC_BT2020)
942        );
943
944        assert_eq!(
945            super::V4L2_QUANTIZATION_DEFAULT,
946            u32::from(uapi::V4L2_QUANTIZATION_DEFAULT)
947        );
948        assert_eq!(
949            super::V4L2_QUANTIZATION_FULL_RANGE,
950            u32::from(uapi::V4L2_QUANTIZATION_FULL_RANGE)
951        );
952        assert_eq!(
953            super::V4L2_QUANTIZATION_LIM_RANGE,
954            u32::from(uapi::V4L2_QUANTIZATION_LIM_RANGE)
955        );
956    }
957}