Skip to main content

edgefirst_codec/
decoder.rs

1// SPDX-FileCopyrightText: Copyright 2026 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4//! [`ImageDecoder`] — reusable decoder state for zero-allocation hot loops.
5
6use crate::error::CodecError;
7use crate::options::ImageInfo;
8use crate::pixel::ImagePixel;
9use edgefirst_tensor::{Tensor, TensorDyn};
10use std::io::Read;
11
12/// IDCT accuracy/speed selection for the software JPEG decoder.
13///
14/// [`Accurate`](Self::Accurate) is the default and the kernel every published
15/// benchmark quotes: the `islow`-class Loeffler IDCT, bit-comparable to
16/// libjpeg-turbo's default. [`Fast`](Self::Fast) opts into the AAN
17/// `ifast`-class kernel — roughly an eighth of the multiplies, at a small,
18/// bounded pixel accuracy cost (and the documented `ifast` degeneration at
19/// very high quality factors). Fast is advisory: paths without a fast kernel
20/// (non-NEON tiers, hardware V4L2/nvJPEG decoders, PNG) use their normal
21/// accurate path.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum DctMethod {
24    /// Accurate `islow`-class IDCT (default).
25    #[default]
26    Accurate,
27    /// Fast AAN `ifast`-class IDCT (opt-in).
28    Fast,
29}
30
31/// Reusable image decoder with internal scratch buffers.
32///
33/// Create one `ImageDecoder` at program initialisation and pass it to
34/// [`ImageLoad::load_image`](crate::ImageLoad::load_image) in the hot loop.
35/// The scratch buffers grow to the high-water mark and are reused across calls
36/// — no per-frame allocations after the first few frames.
37///
38/// By default the decoder produces the source's native format and configures
39/// the destination tensor's dimensions + pixel format accordingly (JPEG →
40/// `Nv12`/`Nv16`/`Nv24`/`Grey`, PNG → `Rgb`/`Rgba`/`Grey`).
41/// [`set_output_format`](Self::set_output_format) opts a JPEG decode into a
42/// fused `Rgb` or `Nv12` output, where the colour conversion or chroma
43/// downsample happens at the MCU write stage instead of in a second pass. The
44/// decoder never rotates or resizes; use `ImageProcessor::convert()` for that.
45///
46/// # Example
47///
48/// ```rust,no_run
49/// use edgefirst_codec::{ImageDecoder, ImageLoad};
50/// use edgefirst_tensor::{CpuAccess, Tensor, TensorTrait, TensorMemory, PixelFormat};
51///
52/// let mut decoder = ImageDecoder::new();
53/// // Allocate a buffer large enough for the biggest expected image. The
54/// // decoder CPU-writes the pixels, so declare `CpuAccess::Write`.
55/// let mut tensor = Tensor::<u8>::image(1920, 1080, PixelFormat::Nv12, Some(TensorMemory::Mem),
56///                                       CpuAccess::Write)
57///     .expect("alloc");
58///
59/// for _ in 0..100 {
60///     let frame = std::fs::read("frame.jpg").unwrap();
61///     let _info = tensor.load_image(&mut decoder, &frame);
62/// }
63/// ```
64pub struct ImageDecoder {
65    /// Reusable JPEG decoder state (Huffman tables, MCU scratch buffers).
66    pub(crate) jpeg_state: crate::jpeg::JpegDecoderState,
67    /// Scratch buffer for PNG decode output.
68    pub(crate) scratch: Vec<u8>,
69    /// Buffer for `Read` → `&[u8]` conversion.
70    pub(crate) input_buffer: Vec<u8>,
71}
72
73impl ImageDecoder {
74    /// Create a new decoder with empty scratch buffers.
75    pub fn new() -> Self {
76        Self {
77            jpeg_state: crate::jpeg::JpegDecoderState::new(),
78            scratch: Vec::new(),
79            input_buffer: Vec::new(),
80        }
81    }
82
83    /// Request a fused decode output format instead of the source's native
84    /// format (JPEG only; PNG is unaffected).
85    ///
86    /// This is a **pure CPU, single-pass** path inside the software JPEG
87    /// decoder — colour conversion / chroma downsample happens at the MCU
88    /// write stage. It is **not** a GPU hybrid or nvJPEG path; V4L2/nvJPEG
89    /// are bypassed whenever the resolved output differs from native.
90    ///
91    /// - `Some(Rgb)`: 4:4:4 colour JPEGs decode straight to interleaved RGB.
92    ///   Other sources fall back to native.
93    /// - `Some(Nv12)`: colour JPEGs decode to NV12, downsampling chroma at
94    ///   the write stage (2×2 average for 4:4:4, vertical for 4:2:2).
95    /// - `None` (default): native format (`Nv12`/`Nv16`/`Nv24`/`Grey`).
96    ///
97    /// Callers still run `ImageProcessor::convert()` on the result for
98    /// model-input preprocessing (letterbox, resize, EXIF orientation). With
99    /// fused RGB that convert step is typically a pure resize.
100    pub fn set_output_format(&mut self, format: Option<edgefirst_tensor::PixelFormat>) {
101        self.jpeg_state.preferred_format = format;
102    }
103
104    /// Select the software JPEG IDCT kernel class — see [`DctMethod`].
105    /// Off (Accurate) by default; `EDGEFIRST_CODEC_DCT=fast` in the
106    /// environment flips a **new** decoder's default for A/B runs.
107    pub fn set_dct_method(&mut self, method: DctMethod) {
108        self.jpeg_state.fast_dct = method == DctMethod::Fast;
109    }
110
111    /// Decode image data into a typed tensor, configuring its dimensions and
112    /// pixel format to the decoded output format — the source's native format
113    /// unless [`set_output_format`](Self::set_output_format) selected a fused
114    /// one.
115    ///
116    /// Detects the image format (JPEG or PNG) from magic bytes.
117    ///
118    /// # Errors
119    ///
120    /// - [`CodecError::InsufficientCapacity`] if the image is larger than the
121    ///   tensor's allocation
122    /// - [`CodecError::UnsupportedDtype`] if `T` is not valid for the output
123    ///   format (JPEG NV12/NV16/NV24/RGB/GREY require `u8`)
124    /// - [`CodecError::InvalidData`] if the data is not a valid JPEG or PNG,
125    ///   including a JPEG whose entropy-coded scan is truncated
126    pub fn decode_into<T: ImagePixel>(
127        &mut self,
128        data: &[u8],
129        dst: &mut Tensor<T>,
130    ) -> crate::Result<ImageInfo> {
131        if is_jpeg(data) {
132            crate::jpeg::decode_jpeg_into(data, dst, &mut self.jpeg_state)
133        } else if is_png(data) {
134            crate::png::decode_png_into(data, dst, &mut self.scratch)
135        } else {
136            Err(CodecError::InvalidData(
137                "unrecognized image format (expected JPEG or PNG magic bytes)".into(),
138            ))
139        }
140    }
141
142    /// Decode image data into a type-erased tensor.
143    pub fn decode_into_dyn(
144        &mut self,
145        data: &[u8],
146        dst: &mut TensorDyn,
147    ) -> crate::Result<ImageInfo> {
148        match dst {
149            TensorDyn::U8(t) => self.decode_into(data, t),
150            TensorDyn::I8(t) => self.decode_into(data, t),
151            TensorDyn::U16(t) => self.decode_into(data, t),
152            TensorDyn::I16(t) => self.decode_into(data, t),
153            TensorDyn::F32(t) => self.decode_into(data, t),
154            other => Err(CodecError::UnsupportedDtype(other.dtype())),
155        }
156    }
157
158    /// Read all bytes from a `Read` source into the internal input buffer, then
159    /// decode. The input buffer is reused across calls.
160    pub fn decode_from_reader<T: ImagePixel, R: Read>(
161        &mut self,
162        mut reader: R,
163        dst: &mut Tensor<T>,
164    ) -> crate::Result<ImageInfo> {
165        self.input_buffer.clear();
166        reader.read_to_end(&mut self.input_buffer)?;
167        decode_into_inner(
168            &mut self.jpeg_state,
169            &mut self.scratch,
170            &self.input_buffer,
171            dst,
172        )
173    }
174
175    /// Read all bytes from a `Read` source, then decode into a type-erased
176    /// tensor.
177    pub fn decode_from_reader_dyn<R: Read>(
178        &mut self,
179        mut reader: R,
180        dst: &mut TensorDyn,
181    ) -> crate::Result<ImageInfo> {
182        self.input_buffer.clear();
183        reader.read_to_end(&mut self.input_buffer)?;
184        decode_into_inner_dyn(
185            &mut self.jpeg_state,
186            &mut self.scratch,
187            &self.input_buffer,
188            dst,
189        )
190    }
191}
192
193/// Internal decode entry point parameterised over disjoint `&mut` borrows so
194/// callers can read from a `&[u8]` borrowed from one field of [`ImageDecoder`]
195/// while mutably borrowing the others.
196pub(crate) fn decode_into_inner<T: ImagePixel>(
197    jpeg_state: &mut crate::jpeg::JpegDecoderState,
198    scratch: &mut Vec<u8>,
199    data: &[u8],
200    dst: &mut Tensor<T>,
201) -> crate::Result<ImageInfo> {
202    if is_jpeg(data) {
203        crate::jpeg::decode_jpeg_into(data, dst, jpeg_state)
204    } else if is_png(data) {
205        crate::png::decode_png_into(data, dst, scratch)
206    } else {
207        Err(CodecError::InvalidData(
208            "unrecognized image format (expected JPEG or PNG magic bytes)".into(),
209        ))
210    }
211}
212
213pub(crate) fn decode_into_inner_dyn(
214    jpeg_state: &mut crate::jpeg::JpegDecoderState,
215    scratch: &mut Vec<u8>,
216    data: &[u8],
217    dst: &mut TensorDyn,
218) -> crate::Result<ImageInfo> {
219    match dst {
220        TensorDyn::U8(t) => decode_into_inner(jpeg_state, scratch, data, t),
221        TensorDyn::I8(t) => decode_into_inner(jpeg_state, scratch, data, t),
222        TensorDyn::U16(t) => decode_into_inner(jpeg_state, scratch, data, t),
223        TensorDyn::I16(t) => decode_into_inner(jpeg_state, scratch, data, t),
224        TensorDyn::F32(t) => decode_into_inner(jpeg_state, scratch, data, t),
225        other => Err(CodecError::UnsupportedDtype(other.dtype())),
226    }
227}
228
229impl Default for ImageDecoder {
230    fn default() -> Self {
231        Self::new()
232    }
233}
234
235/// Parse image headers and return the native dimensions, format, and EXIF
236/// orientation without decoding pixels.
237///
238/// Recommended for one-shot flows that allocate a tensor sized to the image:
239///
240/// ```rust,no_run
241/// use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
242/// use edgefirst_tensor::{CpuAccess, Tensor, TensorMemory};
243///
244/// let data = std::fs::read("image.jpg").unwrap();
245/// let info = peek_info(&data).unwrap();
246/// let mut tensor = Tensor::<u8>::image(info.width, info.height, info.format,
247///                                       Some(TensorMemory::Mem),
248///                                       CpuAccess::Write).unwrap();
249/// let mut decoder = ImageDecoder::new();
250/// tensor.load_image(&mut decoder, &data).unwrap();
251/// ```
252pub fn peek_info(data: &[u8]) -> crate::Result<ImageInfo> {
253    if is_jpeg(data) {
254        crate::jpeg::peek_jpeg_info(data)
255    } else if is_png(data) {
256        crate::png::peek_png_info(data)
257    } else {
258        Err(CodecError::InvalidData(
259            "unrecognized image format (expected JPEG or PNG magic bytes)".into(),
260        ))
261    }
262}
263
264/// Check for JPEG magic bytes (FFD8FF).
265fn is_jpeg(data: &[u8]) -> bool {
266    data.len() >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF
267}
268
269/// Check for PNG magic bytes (89504E47).
270fn is_png(data: &[u8]) -> bool {
271    data.len() >= 4 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn magic_bytes_jpeg() {
280        assert!(is_jpeg(&[0xFF, 0xD8, 0xFF, 0xE0]));
281        assert!(!is_jpeg(&[0x89, 0x50, 0x4E, 0x47]));
282        assert!(!is_jpeg(&[]));
283        assert!(!is_jpeg(&[0xFF]));
284    }
285
286    #[test]
287    fn magic_bytes_png() {
288        assert!(is_png(&[0x89, 0x50, 0x4E, 0x47]));
289        assert!(!is_png(&[0xFF, 0xD8, 0xFF, 0xE0]));
290        assert!(!is_png(&[]));
291    }
292
293    #[test]
294    fn invalid_data() {
295        let mut decoder = ImageDecoder::new();
296        let mut tensor = Tensor::<u8>::image(
297            100,
298            100,
299            edgefirst_tensor::PixelFormat::Nv12,
300            Some(edgefirst_tensor::TensorMemory::Mem),
301            edgefirst_tensor::CpuAccess::ReadWrite,
302        )
303        .unwrap();
304        let result = decoder.decode_into(b"not an image", &mut tensor);
305        assert!(matches!(result, Err(CodecError::InvalidData(_))));
306    }
307}