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}