Skip to main content

nv_frame/
convert.rs

1//! Pixel format conversion utilities.
2//!
3//! These are **opt-in** and allocate a new [`FrameEnvelope`]. They are not
4//! used on the hot path — stages that need a specific format should convert
5//! explicitly.
6
7use crate::frame::{FrameAccessError, FrameEnvelope, PixelFormat};
8
9/// Errors from [`convert()`].
10#[derive(Debug, thiserror::Error)]
11pub enum ConvertError {
12    /// The source and target formats are identical — clone instead.
13    #[error("source format already matches target")]
14    SameFormat,
15    /// No conversion path exists for the given format pair.
16    #[error("unsupported conversion: {from:?} -> {to:?}")]
17    Unsupported {
18        /// Source pixel format.
19        from: PixelFormat,
20        /// Target pixel format.
21        to: PixelFormat,
22    },
23    /// Host bytes could not be obtained from the frame.
24    #[error("frame data not accessible: {0}")]
25    Access(#[from] FrameAccessError),
26}
27
28/// Convert a frame to a different pixel format.
29///
30/// Works with host-resident **and** [`MappableToHost`](crate::DataAccess::MappableToHost)
31/// device frames. Conversion always allocates a new output buffer, so the
32/// additional cost of materializing device data is negligible.
33///
34/// # Errors
35///
36/// - [`ConvertError::SameFormat`] — source already matches `target`; clone instead.
37/// - [`ConvertError::Unsupported`] — no conversion path for this format pair.
38/// - [`ConvertError::Access`] — host bytes could not be obtained (opaque device frame).
39///
40/// Currently supported paths:
41/// - `Bgr8` → `Rgb8`
42/// - `Rgb8` → `Bgr8`
43/// - `Rgba8` → `Rgb8`
44/// - `Rgb8` → `Gray8`
45///
46/// Other conversions can be contributed by adding a match arm below.
47pub fn convert(frame: &FrameEnvelope, target: PixelFormat) -> Result<FrameEnvelope, ConvertError> {
48    if frame.format() == target {
49        return Err(ConvertError::SameFormat);
50    }
51
52    let host_bytes = frame.require_host_data()?;
53
54    let w = frame.width();
55    let h = frame.height();
56    let src_stride = frame.stride();
57
58    // Validate that the frame data is large enough for the declared dimensions.
59    // A truncated or corrupt frame would otherwise panic in pixel_rows().
60    // All arithmetic is checked to prevent overflow from adversarial dimensions.
61    let src_bpp = match frame.format() {
62        PixelFormat::Gray8 => 1u32,
63        PixelFormat::Rgb8 | PixelFormat::Bgr8 => 3,
64        PixelFormat::Rgba8 => 4,
65        _ => {
66            return Err(ConvertError::Unsupported {
67                from: frame.format(),
68                to: target,
69            });
70        }
71    };
72    let required = checked_frame_size(w, h, src_stride, src_bpp).ok_or_else(|| {
73        ConvertError::Access(FrameAccessError::MaterializationFailed {
74            detail: format!(
75                "dimension overflow: {}x{} stride={} bpp={}",
76                w, h, src_stride, src_bpp,
77            ),
78        })
79    })?;
80    if host_bytes.len() < required {
81        return Err(ConvertError::Access(
82            FrameAccessError::MaterializationFailed {
83                detail: format!(
84                    "frame data too short: {} bytes for {}x{} stride={} bpp={}",
85                    host_bytes.len(),
86                    w,
87                    h,
88                    src_stride,
89                    src_bpp,
90                ),
91            },
92        ));
93    }
94
95    let converted = match (frame.format(), target) {
96        (PixelFormat::Bgr8, PixelFormat::Rgb8) | (PixelFormat::Rgb8, PixelFormat::Bgr8) => {
97            swap_rb(&host_bytes, w, h, src_stride)
98        }
99        (PixelFormat::Rgba8, PixelFormat::Rgb8) => rgba_to_rgb(&host_bytes, w, h, src_stride),
100        (PixelFormat::Rgb8, PixelFormat::Gray8) => rgb_to_gray(&host_bytes, w, h, src_stride),
101        _ => {
102            return Err(ConvertError::Unsupported {
103                from: frame.format(),
104                to: target,
105            });
106        }
107    };
108
109    let out_stride = match target {
110        PixelFormat::Gray8 => w,
111        PixelFormat::Rgb8 | PixelFormat::Bgr8 => w.checked_mul(3).ok_or_else(|| {
112            ConvertError::Access(FrameAccessError::MaterializationFailed {
113                detail: format!("output stride overflow: width={} bpp=3", w),
114            })
115        })?,
116        PixelFormat::Rgba8 => w.checked_mul(4).ok_or_else(|| {
117            ConvertError::Access(FrameAccessError::MaterializationFailed {
118                detail: format!("output stride overflow: width={} bpp=4", w),
119            })
120        })?,
121        _ => {
122            return Err(ConvertError::Unsupported {
123                from: frame.format(),
124                to: target,
125            });
126        }
127    };
128
129    Ok(FrameEnvelope::new_owned(
130        frame.feed_id(),
131        frame.seq(),
132        frame.ts(),
133        frame.wall_ts(),
134        w,
135        h,
136        target,
137        out_stride,
138        converted,
139        frame.metadata().clone(),
140    ))
141}
142
143/// Compute the minimum buffer size for the given frame dimensions using
144/// checked arithmetic. Returns `None` on overflow.
145fn checked_frame_size(width: u32, height: u32, stride: u32, bpp: u32) -> Option<usize> {
146    if height == 0 {
147        return Some(0);
148    }
149    let last_row_bytes = (width as usize).checked_mul(bpp as usize)?;
150    let prefix_rows = (height as usize).checked_sub(1)?;
151    let prefix_bytes = prefix_rows.checked_mul(stride as usize)?;
152    prefix_bytes.checked_add(last_row_bytes)
153}
154
155/// Extract tightly-packed pixel rows from potentially padded data.
156///
157/// GStreamer (and other backends) may deliver rows with padding bytes
158/// at the end (stride > width × bpp). This iterator yields only the
159/// meaningful bytes of each row, skipping any trailing padding.
160///
161/// # Safety contract
162///
163/// The caller must ensure that `data.len()` is at least
164/// `checked_frame_size(width, height, stride, bpp)`. The `convert()`
165/// function validates this before calling `pixel_rows`.
166fn pixel_rows(data: &[u8], width: u32, height: u32, stride: u32, bpp: u32) -> Vec<&[u8]> {
167    let row_bytes = (width as usize) * (bpp as usize);
168    let stride = stride as usize;
169    (0..height as usize)
170        .map(|y| {
171            let start = y * stride;
172            &data[start..start + row_bytes]
173        })
174        .collect()
175}
176
177/// Swap R and B channels in a 3-byte-per-pixel buffer, respecting stride.
178fn swap_rb(data: &[u8], width: u32, height: u32, stride: u32) -> Vec<u8> {
179    let rows = pixel_rows(data, width, height, stride, 3);
180    let mut out = Vec::with_capacity((width * height * 3) as usize);
181    for row in rows {
182        for pixel in row.chunks_exact(3) {
183            out.extend_from_slice(&[pixel[2], pixel[1], pixel[0]]);
184        }
185    }
186    out
187}
188
189/// Drop the alpha channel from RGBA data, respecting stride.
190fn rgba_to_rgb(data: &[u8], width: u32, height: u32, stride: u32) -> Vec<u8> {
191    let rows = pixel_rows(data, width, height, stride, 4);
192    let mut out = Vec::with_capacity((width * height * 3) as usize);
193    for row in rows {
194        for px in row.chunks_exact(4) {
195            out.extend_from_slice(&[px[0], px[1], px[2]]);
196        }
197    }
198    out
199}
200
201/// Convert RGB to grayscale using luminance weights, respecting stride.
202fn rgb_to_gray(data: &[u8], width: u32, height: u32, stride: u32) -> Vec<u8> {
203    let rows = pixel_rows(data, width, height, stride, 3);
204    let mut out = Vec::with_capacity((width * height) as usize);
205    for row in rows {
206        for px in row.chunks_exact(3) {
207            let r = px[0] as f32;
208            let g = px[1] as f32;
209            let b = px[2] as f32;
210            out.push((0.299 * r + 0.587 * g + 0.114 * b).round() as u8);
211        }
212    }
213    out
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::frame::PixelFormat;
220    use nv_core::{FeedId, MonotonicTs, TypedMetadata, WallTs};
221
222    fn make_frame(format: PixelFormat, data: Vec<u8>, w: u32, h: u32) -> FrameEnvelope {
223        let stride = match format {
224            PixelFormat::Rgb8 | PixelFormat::Bgr8 => w * 3,
225            PixelFormat::Rgba8 => w * 4,
226            PixelFormat::Gray8 => w,
227            _ => w,
228        };
229        FrameEnvelope::new_owned(
230            FeedId::new(1),
231            0,
232            MonotonicTs::ZERO,
233            WallTs::from_micros(0),
234            w,
235            h,
236            format,
237            stride,
238            data,
239            TypedMetadata::new(),
240        )
241    }
242
243    #[test]
244    fn same_format_returns_error() {
245        let f = make_frame(PixelFormat::Rgb8, vec![0; 12], 2, 2);
246        assert!(matches!(
247            convert(&f, PixelFormat::Rgb8),
248            Err(ConvertError::SameFormat)
249        ));
250    }
251
252    #[test]
253    fn bgr_to_rgb() {
254        let data = vec![10, 20, 30, 40, 50, 60];
255        let f = make_frame(PixelFormat::Bgr8, data, 2, 1);
256        let converted = convert(&f, PixelFormat::Rgb8).unwrap();
257        assert_eq!(converted.format(), PixelFormat::Rgb8);
258        assert_eq!(converted.host_data().unwrap(), &[30, 20, 10, 60, 50, 40]);
259    }
260
261    #[test]
262    fn bgr_to_rgb_with_stride_padding() {
263        // 2 pixels wide × 2 rows, BGR8 = 6 bytes/row, but stride = 8 (2 padding bytes)
264        let data = vec![
265            10, 20, 30, 40, 50, 60, 0xAA, 0xBB, // row 0 + 2 pad bytes
266            70, 80, 90, 11, 22, 33, 0xCC, 0xDD, // row 1 + 2 pad bytes
267        ];
268        let f = FrameEnvelope::new_owned(
269            FeedId::new(1),
270            0,
271            MonotonicTs::ZERO,
272            WallTs::from_micros(0),
273            2,
274            2,
275            PixelFormat::Bgr8,
276            8, // stride > width*3
277            data,
278            TypedMetadata::new(),
279        );
280        let converted = convert(&f, PixelFormat::Rgb8).unwrap();
281        // Padding bytes must NOT appear in output
282        assert_eq!(
283            converted.host_data().unwrap(),
284            &[30, 20, 10, 60, 50, 40, 90, 80, 70, 33, 22, 11]
285        );
286        assert_eq!(converted.stride(), 6); // tightly packed output
287    }
288
289    #[test]
290    fn conversion_preserves_metadata() {
291        #[derive(Clone, Debug, PartialEq)]
292        struct Tag(u32);
293
294        let mut meta = TypedMetadata::new();
295        meta.insert(Tag(42));
296
297        let f = FrameEnvelope::new_owned(
298            FeedId::new(1),
299            7,
300            MonotonicTs::ZERO,
301            WallTs::from_micros(0),
302            2,
303            1,
304            PixelFormat::Bgr8,
305            6,
306            vec![10, 20, 30, 40, 50, 60],
307            meta,
308        );
309        let converted = convert(&f, PixelFormat::Rgb8).unwrap();
310        assert_eq!(converted.metadata().get::<Tag>(), Some(&Tag(42)));
311        assert_eq!(converted.seq(), 7);
312    }
313}