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    /// Short string label matching the EdgeFirst schema.
85    pub fn as_str(self) -> &'static str {
86        match self {
87            Self::Bt709 => "bt709",
88            Self::Bt2020 => "bt2020",
89            Self::Srgb => "srgb",
90            Self::Smpte170m => "smpte170m",
91        }
92    }
93
94    /// Map a raw V4L2 `colorspace` field to a [`ColorSpace`].
95    ///
96    /// Returns `None` for `V4L2_COLORSPACE_DEFAULT` (0) and any
97    /// unrecognised value.
98    pub fn from_v4l2(v: u32) -> Option<Self> {
99        match v {
100            V4L2_COLORSPACE_SMPTE170M => Some(Self::Smpte170m),
101            V4L2_COLORSPACE_REC709 => Some(Self::Bt709),
102            // legacy NTSC (470M) / PAL-SECAM (470BG): close enough to SMPTE 170M primaries for conversion
103            V4L2_COLORSPACE_470_SYSTEM_M | V4L2_COLORSPACE_470_SYSTEM_BG => Some(Self::Smpte170m),
104            V4L2_COLORSPACE_JPEG | V4L2_COLORSPACE_SRGB => Some(Self::Srgb),
105            V4L2_COLORSPACE_BT2020 => Some(Self::Bt2020),
106            _ => None,
107        }
108    }
109}
110
111impl ColorTransfer {
112    /// Short string label matching the EdgeFirst schema.
113    pub fn as_str(self) -> &'static str {
114        match self {
115            Self::Bt709 => "bt709",
116            Self::Srgb => "srgb",
117            Self::Pq => "pq",
118            Self::Hlg => "hlg",
119            Self::Linear => "linear",
120        }
121    }
122
123    /// Map a raw V4L2 `xfer_func` field to a [`ColorTransfer`].
124    ///
125    /// Returns `None` for `V4L2_XFER_FUNC_DEFAULT` (0) and any
126    /// unrecognised value.
127    ///
128    /// Note: OPRGB (3), SMPTE240M (4), and DCI_P3 (6) have no HAL equivalent
129    /// and map to `None`. [`ColorTransfer::Hlg`] is never produced because the
130    /// V4L2 UAPI defines no `V4L2_XFER_FUNC_HLG` value.
131    pub fn from_v4l2(v: u32) -> Option<Self> {
132        match v {
133            V4L2_XFER_FUNC_709 => Some(Self::Bt709),
134            V4L2_XFER_FUNC_SRGB => Some(Self::Srgb),
135            V4L2_XFER_FUNC_NONE => Some(Self::Linear),
136            V4L2_XFER_FUNC_SMPTE2084 => Some(Self::Pq),
137            _ => None,
138        }
139    }
140}
141
142impl ColorEncoding {
143    /// Short string label matching the EdgeFirst schema.
144    pub fn as_str(self) -> &'static str {
145        match self {
146            Self::Bt601 => "bt601",
147            Self::Bt709 => "bt709",
148            Self::Bt2020 => "bt2020",
149        }
150    }
151
152    /// Map a raw V4L2 `ycbcr_enc` field to a [`ColorEncoding`].
153    ///
154    /// Returns `None` for `V4L2_YCBCR_ENC_DEFAULT` (0) and any
155    /// unrecognised value.
156    pub fn from_v4l2(v: u32) -> Option<Self> {
157        match v {
158            V4L2_YCBCR_ENC_601 => Some(Self::Bt601),
159            V4L2_YCBCR_ENC_709 => Some(Self::Bt709),
160            V4L2_YCBCR_ENC_BT2020 => Some(Self::Bt2020),
161            _ => None,
162        }
163    }
164}
165
166impl ColorRange {
167    /// Short string label matching the EdgeFirst schema.
168    pub fn as_str(self) -> &'static str {
169        match self {
170            Self::Full => "full",
171            Self::Limited => "limited",
172        }
173    }
174
175    /// Map a raw V4L2 `quantization` field to a [`ColorRange`].
176    ///
177    /// Returns `None` for `V4L2_QUANTIZATION_DEFAULT` (0) and any
178    /// unrecognised value.
179    pub fn from_v4l2(v: u32) -> Option<Self> {
180        match v {
181            V4L2_QUANTIZATION_FULL_RANGE => Some(Self::Full),
182            V4L2_QUANTIZATION_LIM_RANGE => Some(Self::Limited),
183            _ => None,
184        }
185    }
186}
187
188macro_rules! display_via_as_str {
189    ($($t:ty),*) => {$(
190        impl fmt::Display for $t {
191            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192                f.write_str(self.as_str())
193            }
194        }
195    )*};
196}
197display_via_as_str!(ColorSpace, ColorTransfer, ColorEncoding, ColorRange);
198
199/// YCbCr matrix luma weights `(kr, kb)`; the green weight is `kg = 1 - kr - kb`.
200///
201/// This is the single source of the BT.601/709/2020 coefficients. Every
202/// YUV↔RGB path (the in-shader GL coefficients and the hand-rolled fixed-point
203/// CPU encoders) derives its matrix from here so a coefficient change is made
204/// in exactly one place.
205#[derive(Debug, Clone, Copy, PartialEq)]
206pub struct MatrixWeights {
207    pub kr: f64,
208    pub kb: f64,
209}
210
211impl MatrixWeights {
212    /// Green luma weight, `1 - kr - kb`.
213    pub fn kg(self) -> f64 {
214        1.0 - self.kr - self.kb
215    }
216}
217
218impl ColorEncoding {
219    /// The matrix luma weights `(kr, kb)` for this encoding — the canonical
220    /// BT.601/709/2020 coefficients shared by every YUV↔RGB conversion path.
221    pub fn luma_weights(self) -> MatrixWeights {
222        match self {
223            Self::Bt601 => MatrixWeights {
224                kr: 0.299,
225                kb: 0.114,
226            },
227            Self::Bt709 => MatrixWeights {
228                kr: 0.2126,
229                kb: 0.0722,
230            },
231            Self::Bt2020 => MatrixWeights {
232                kr: 0.2627,
233                kb: 0.0593,
234            },
235        }
236    }
237}
238
239/// Quantization swings (out of 255) for a YCbCr range: the luma black offset and
240/// the luma/chroma excursions. Full range is `0 / 255 / 255`; limited (studio)
241/// range is `16 / 219 / 224`.
242#[derive(Debug, Clone, Copy, PartialEq)]
243pub struct RangeScaling {
244    /// Luma black level (0 for full range, 16 for limited).
245    pub y_offset: f64,
246    /// Luma excursion (255 for full range, 219 for limited).
247    pub y_swing: f64,
248    /// Chroma excursion (255 for full range, 224 for limited).
249    pub c_swing: f64,
250}
251
252impl ColorRange {
253    /// The luma/chroma quantization swings for this range — the canonical
254    /// 0/255/255 (full) or 16/219/224 (limited) studio-swing constants shared
255    /// by every YUV↔RGB conversion path.
256    pub fn scaling(self) -> RangeScaling {
257        match self {
258            Self::Full => RangeScaling {
259                y_offset: 0.0,
260                y_swing: 255.0,
261                c_swing: 255.0,
262            },
263            Self::Limited => RangeScaling {
264                y_offset: 16.0,
265                y_swing: 219.0,
266                c_swing: 224.0,
267            },
268        }
269    }
270}
271
272/// Full 4-axis colorimetry. Each axis is `Option`; `None` means "undefined"
273/// and is never auto-filled — consumers (e.g. `convert()`) resolve missing
274/// axes at use-time without mutating the value.
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
276pub struct Colorimetry {
277    pub space: Option<ColorSpace>,
278    pub transfer: Option<ColorTransfer>,
279    pub encoding: Option<ColorEncoding>,
280    pub range: Option<ColorRange>,
281}
282
283impl Colorimetry {
284    /// JPEG/JFIF colorimetry: sRGB primaries, sRGB transfer, BT.601
285    /// encoding, full range.
286    pub fn jfif() -> Self {
287        Self {
288            space: Some(ColorSpace::Srgb),
289            transfer: Some(ColorTransfer::Srgb),
290            encoding: Some(ColorEncoding::Bt601),
291            range: Some(ColorRange::Full),
292        }
293    }
294
295    /// Build from the four raw V4L2 colorimetry integers.
296    ///
297    /// Explicit values map directly. For `ycbcr_enc`/`quantization`, the V4L2
298    /// `DEFAULT` (0) sentinel does NOT mean "unknown" — it means "derive from
299    /// the colorspace" (kernel `V4L2_MAP_YCBCR_ENC_DEFAULT` /
300    /// `V4L2_MAP_QUANTIZATION_DEFAULT`). So a recognised colorspace resolves
301    /// those axes here (e.g. `V4L2_COLORSPACE_JPEG` → BT.601 full-range) rather
302    /// than leaving them `None` and falling through to the at-use height
303    /// heuristic (which would wrongly pick BT.709/limited for an HD JPEG frame).
304    /// A `DEFAULT`/unrecognised colorspace still yields `None` (deferred to the
305    /// heuristic); unrecognised non-default values also map to `None`.
306    pub fn from_v4l2(colorspace: u32, xfer: u32, ycbcr_enc: u32, quant: u32) -> Self {
307        let encoding = ColorEncoding::from_v4l2(ycbcr_enc).or_else(|| {
308            if ycbcr_enc == V4L2_YCBCR_ENC_DEFAULT {
309                Self::default_encoding_for_colorspace(colorspace)
310            } else {
311                None
312            }
313        });
314        let range = ColorRange::from_v4l2(quant).or_else(|| {
315            if quant == V4L2_QUANTIZATION_DEFAULT {
316                Self::default_range_for_colorspace(colorspace)
317            } else {
318                None
319            }
320        });
321        Self {
322            space: ColorSpace::from_v4l2(colorspace),
323            transfer: ColorTransfer::from_v4l2(xfer),
324            encoding,
325            range,
326        }
327    }
328
329    /// V4L2 `ycbcr_enc=DEFAULT` → encoding implied by the colorspace
330    /// (`V4L2_MAP_YCBCR_ENC_DEFAULT`). `None` for default/unrecognised.
331    fn default_encoding_for_colorspace(colorspace: u32) -> Option<ColorEncoding> {
332        match colorspace {
333            V4L2_COLORSPACE_REC709 => Some(ColorEncoding::Bt709),
334            V4L2_COLORSPACE_BT2020 => Some(ColorEncoding::Bt2020),
335            V4L2_COLORSPACE_SMPTE170M
336            | V4L2_COLORSPACE_470_SYSTEM_M
337            | V4L2_COLORSPACE_470_SYSTEM_BG
338            | V4L2_COLORSPACE_JPEG
339            | V4L2_COLORSPACE_SRGB => Some(ColorEncoding::Bt601),
340            _ => None,
341        }
342    }
343
344    /// V4L2 `quantization=DEFAULT` → range implied by the colorspace
345    /// (`V4L2_MAP_QUANTIZATION_DEFAULT` for the YUV case: only JPEG is full,
346    /// every other recognised colorspace is limited). `None` for
347    /// default/unrecognised.
348    fn default_range_for_colorspace(colorspace: u32) -> Option<ColorRange> {
349        match colorspace {
350            V4L2_COLORSPACE_JPEG => Some(ColorRange::Full),
351            V4L2_COLORSPACE_SMPTE170M
352            | V4L2_COLORSPACE_REC709
353            | V4L2_COLORSPACE_BT2020
354            | V4L2_COLORSPACE_470_SYSTEM_M
355            | V4L2_COLORSPACE_470_SYSTEM_BG
356            | V4L2_COLORSPACE_SRGB => Some(ColorRange::Limited),
357            _ => None,
358        }
359    }
360
361    /// Set color primaries (consuming builder).
362    pub fn with_space(mut self, s: ColorSpace) -> Self {
363        self.space = Some(s);
364        self
365    }
366
367    /// Set transfer function (consuming builder).
368    pub fn with_transfer(mut self, t: ColorTransfer) -> Self {
369        self.transfer = Some(t);
370        self
371    }
372
373    /// Set YCbCr encoding matrix (consuming builder).
374    pub fn with_encoding(mut self, e: ColorEncoding) -> Self {
375        self.encoding = Some(e);
376        self
377    }
378
379    /// Set quantization range (consuming builder).
380    pub fn with_range(mut self, r: ColorRange) -> Self {
381        self.range = Some(r);
382        self
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn from_v4l2_known_and_default() {
392        assert_eq!(ColorEncoding::from_v4l2(1), Some(ColorEncoding::Bt601));
393        assert_eq!(ColorEncoding::from_v4l2(2), Some(ColorEncoding::Bt709));
394        assert_eq!(ColorEncoding::from_v4l2(6), Some(ColorEncoding::Bt2020));
395        assert_eq!(ColorEncoding::from_v4l2(0), None); // DEFAULT
396        assert_eq!(ColorEncoding::from_v4l2(3), None); // XV601 (unsurfaced)
397        assert_eq!(ColorRange::from_v4l2(1), Some(ColorRange::Full));
398        assert_eq!(ColorRange::from_v4l2(2), Some(ColorRange::Limited));
399        assert_eq!(ColorSpace::from_v4l2(7), Some(ColorSpace::Srgb)); // JPEG→sRGB
400        assert_eq!(ColorTransfer::from_v4l2(5), Some(ColorTransfer::Linear)); // NONE→linear
401
402        // ColorSpace: additional arms
403        assert_eq!(ColorSpace::from_v4l2(1), Some(ColorSpace::Smpte170m)); // SMPTE170M
404        assert_eq!(ColorSpace::from_v4l2(3), Some(ColorSpace::Bt709)); // REC709
405        assert_eq!(ColorSpace::from_v4l2(10), Some(ColorSpace::Bt2020)); // BT2020
406        assert_eq!(ColorSpace::from_v4l2(5), Some(ColorSpace::Smpte170m)); // 470_SYSTEM_M
407        assert_eq!(ColorSpace::from_v4l2(6), Some(ColorSpace::Smpte170m)); // 470_SYSTEM_BG
408
409        // ColorTransfer: additional arms and unmapped values
410        assert_eq!(ColorTransfer::from_v4l2(1), Some(ColorTransfer::Bt709)); // XFER_FUNC_709
411        assert_eq!(ColorTransfer::from_v4l2(2), Some(ColorTransfer::Srgb)); // XFER_FUNC_SRGB
412        assert_eq!(ColorTransfer::from_v4l2(7), Some(ColorTransfer::Pq)); // XFER_FUNC_SMPTE2084
413        assert_eq!(ColorTransfer::from_v4l2(3), None); // OPRGB — no HAL equivalent
414        assert_eq!(ColorTransfer::from_v4l2(6), None); // DCI_P3 — no HAL equivalent
415
416        // Hlg is never produced by from_v4l2 — no V4L2_XFER_FUNC_HLG exists in the kernel UAPI
417        for v in 0u32..=10 {
418            assert_ne!(ColorTransfer::from_v4l2(v), Some(ColorTransfer::Hlg));
419        }
420    }
421
422    #[test]
423    fn jfif_is_bt601_full_srgb() {
424        let c = Colorimetry::jfif();
425        assert_eq!(c.space, Some(ColorSpace::Srgb));
426        assert_eq!(c.transfer, Some(ColorTransfer::Srgb));
427        assert_eq!(c.encoding, Some(ColorEncoding::Bt601));
428        assert_eq!(c.range, Some(ColorRange::Full));
429    }
430
431    #[test]
432    fn from_v4l2_struct_maps_all_axes_and_unknown_to_none() {
433        let c = Colorimetry::from_v4l2(3, 1, 2, 1); // REC709, XFER709, ENC709, FULL
434        assert_eq!(c.space, Some(ColorSpace::Bt709));
435        assert_eq!(c.transfer, Some(ColorTransfer::Bt709));
436        assert_eq!(c.encoding, Some(ColorEncoding::Bt709));
437        assert_eq!(c.range, Some(ColorRange::Full));
438        let d = Colorimetry::from_v4l2(0, 0, 0, 0); // all DEFAULT
439        assert_eq!(d, Colorimetry::default()); // all None
440    }
441
442    #[test]
443    fn from_v4l2_default_enc_quant_derive_from_colorspace() {
444        // COLORSPACE_JPEG (7) with DEFAULT ycbcr_enc/quant must resolve to
445        // BT.601 full-range per V4L2_MAP_*_DEFAULT — NOT be left None (which
446        // would let the height heuristic wrongly pick BT.709/limited for HD).
447        let jpeg = Colorimetry::from_v4l2(7, 0, 0, 0);
448        assert_eq!(jpeg.encoding, Some(ColorEncoding::Bt601));
449        assert_eq!(jpeg.range, Some(ColorRange::Full));
450
451        // REC709 colorspace, DEFAULT enc/quant → BT.709 limited.
452        let rec709 = Colorimetry::from_v4l2(3, 0, 0, 0);
453        assert_eq!(rec709.encoding, Some(ColorEncoding::Bt709));
454        assert_eq!(rec709.range, Some(ColorRange::Limited));
455
456        // Explicit ycbcr_enc/quant still win over the colorspace default.
457        let explicit = Colorimetry::from_v4l2(7, 0, 2, 2); // JPEG but enc=709, quant=limited
458        assert_eq!(explicit.encoding, Some(ColorEncoding::Bt709));
459        assert_eq!(explicit.range, Some(ColorRange::Limited));
460
461        // Unrecognised non-default values stay None (not derived).
462        let unknown_enc = Colorimetry::from_v4l2(7, 0, 99, 0);
463        assert_eq!(unknown_enc.encoding, None);
464        assert_eq!(unknown_enc.range, Some(ColorRange::Full)); // quant still defaulted from JPEG
465    }
466
467    #[test]
468    fn luma_weights_are_the_canonical_bt_constants() {
469        assert_eq!(
470            ColorEncoding::Bt601.luma_weights(),
471            MatrixWeights {
472                kr: 0.299,
473                kb: 0.114
474            }
475        );
476        assert_eq!(
477            ColorEncoding::Bt709.luma_weights(),
478            MatrixWeights {
479                kr: 0.2126,
480                kb: 0.0722
481            }
482        );
483        assert_eq!(
484            ColorEncoding::Bt2020.luma_weights(),
485            MatrixWeights {
486                kr: 0.2627,
487                kb: 0.0593
488            }
489        );
490        // kg = 1 - kr - kb (BT.709 green weight ≈ 0.7152).
491        assert!((ColorEncoding::Bt709.luma_weights().kg() - 0.7152).abs() < 1e-9);
492    }
493
494    #[test]
495    fn range_scaling_is_full_or_studio_swing() {
496        let full = ColorRange::Full.scaling();
497        assert_eq!(
498            full,
499            RangeScaling {
500                y_offset: 0.0,
501                y_swing: 255.0,
502                c_swing: 255.0
503            }
504        );
505        let limited = ColorRange::Limited.scaling();
506        assert_eq!(
507            limited,
508            RangeScaling {
509                y_offset: 16.0,
510                y_swing: 219.0,
511                c_swing: 224.0
512            }
513        );
514    }
515
516    #[test]
517    fn as_str_matches_schema() {
518        assert_eq!(ColorEncoding::Bt601.as_str(), "bt601");
519        assert_eq!(ColorRange::Full.as_str(), "full");
520        assert_eq!(ColorSpace::Smpte170m.as_str(), "smpte170m");
521        assert_eq!(ColorTransfer::Pq.as_str(), "pq");
522        assert_eq!(ColorTransfer::Hlg.as_str(), "hlg");
523    }
524}