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;
7#[cfg(not(feature = "png"))]
8use crate::error::UnsupportedFeature;
9use crate::options::ImageInfo;
10use crate::pixel::ImagePixel;
11use edgefirst_tensor::{DType, Tensor, TensorDyn};
12use std::io::Read;
13
14/// IDCT accuracy/speed selection for the software JPEG decoder.
15///
16/// [`Accurate`](Self::Accurate) is the default and the kernel every published
17/// benchmark quotes: the `islow`-class Loeffler IDCT, bit-comparable to
18/// libjpeg-turbo's default. [`Fast`](Self::Fast) opts into the AAN
19/// `ifast`-class kernel — roughly an eighth of the multiplies, at a small,
20/// bounded pixel accuracy cost (and the documented `ifast` degeneration at
21/// very high quality factors). Fast is advisory: paths without a fast kernel
22/// (non-NEON tiers, hardware V4L2/nvJPEG decoders, PNG) use their normal
23/// accurate path.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum DctMethod {
26 /// Accurate `islow`-class IDCT (default).
27 #[default]
28 Accurate,
29 /// Fast AAN `ifast`-class IDCT (opt-in).
30 Fast,
31}
32
33/// Chroma upsampling filter for a fused native-4:2:0 `Rgb` decode (see
34/// [`ImageDecoder::set_output_format`]).
35///
36/// [`Box`](Self::Box) — 2×2 nearest-neighbour replication — is the only
37/// filter implemented today; the type exists so that a future
38/// fancy/triangle-filtered mode (libjpeg-turbo's default, and a materially
39/// more expensive one — see BENCHMARKS.md) can be added as a new variant
40/// without a breaking API change to
41/// [`ImageDecoder::set_chroma_upsample`]. `#[non_exhaustive]` for the same
42/// reason: adding a variant later must not be a semver break for callers who
43/// match on this type.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45#[non_exhaustive]
46pub enum ChromaUpsample {
47 /// 2×2 nearest-neighbour (box) chroma replication.
48 #[default]
49 Box,
50}
51
52/// Reusable image decoder with internal scratch buffers.
53///
54/// Create one `ImageDecoder` at program initialisation and pass it to
55/// [`ImageLoad::load_image`](crate::ImageLoad::load_image) in the hot loop.
56/// The scratch buffers grow to the high-water mark and are reused across calls
57/// — no per-frame allocations after the first few frames.
58///
59/// By default the decoder produces the source's native format and configures
60/// the destination tensor's dimensions + pixel format accordingly (JPEG →
61/// `Nv12`/`Nv16`/`Nv24`/`Grey`, PNG → `Rgb`/`Rgba`/`Grey`).
62/// [`set_output_format`](Self::set_output_format) opts a JPEG decode into a
63/// fused `Rgb` or `Nv12` output, where the colour conversion or chroma
64/// downsample happens at the MCU write stage instead of in a second pass. The
65/// decoder never rotates or resizes; use `ImageProcessor::convert()` for that.
66///
67/// # Example
68///
69/// ```rust,no_run
70/// use edgefirst_codec::{ImageDecoder, ImageLoad};
71/// use edgefirst_tensor::{CpuAccess, Tensor, TensorTrait, TensorMemory, PixelFormat};
72///
73/// let mut decoder = ImageDecoder::new();
74/// // Allocate a buffer large enough for the biggest expected image. The
75/// // decoder CPU-writes the pixels, so declare `CpuAccess::Write`.
76/// let mut tensor = Tensor::<u8>::image(1920, 1080, PixelFormat::Nv12, Some(TensorMemory::Mem),
77/// CpuAccess::Write)
78/// .expect("alloc");
79///
80/// for _ in 0..100 {
81/// let frame = std::fs::read("frame.jpg").unwrap();
82/// let _info = tensor.load_image(&mut decoder, &frame);
83/// }
84/// ```
85pub struct ImageDecoder {
86 /// Reusable JPEG decoder state (Huffman tables, MCU scratch buffers).
87 pub(crate) jpeg_state: crate::jpeg::JpegDecoderState,
88 /// Scratch buffer for PNG decode output.
89 pub(crate) scratch: Vec<u8>,
90 /// Buffer for `Read` → `&[u8]` conversion.
91 pub(crate) input_buffer: Vec<u8>,
92}
93
94impl ImageDecoder {
95 /// Create a new decoder with empty scratch buffers.
96 pub fn new() -> Self {
97 Self {
98 jpeg_state: crate::jpeg::JpegDecoderState::new(),
99 scratch: Vec::new(),
100 input_buffer: Vec::new(),
101 }
102 }
103
104 /// Request a fused decode output format instead of the source's native
105 /// format (JPEG only; PNG is unaffected).
106 ///
107 /// This is a **pure CPU, single-pass** path inside the software JPEG
108 /// decoder — colour conversion / chroma downsample happens at the MCU
109 /// write stage. It is **not** a GPU hybrid or nvJPEG path; V4L2/nvJPEG
110 /// are bypassed whenever the resolved output differs from native.
111 ///
112 /// - `Some(Rgb)`: 4:4:4 colour JPEGs decode straight to interleaved RGB
113 /// (no chroma resampling); native 4:2:0 colour JPEGs also decode
114 /// straight to RGB, with a 2×2 nearest-neighbour (box) chroma upsample
115 /// fused into the write — not libjpeg's fancy/triangle upsampling, a
116 /// deliberate speed tradeoff (see BENCHMARKS.md). Other sources (4:2:2,
117 /// greyscale, non-standard subsamplings) cannot honour an RGB request:
118 /// the decode returns [`CodecError::UnsupportedFormat`] rather than
119 /// silently substituting the native semi-planar format. Check the
120 /// source's subsampling first (e.g. via [`crate::peek_info`]) if it
121 /// isn't known to be 4:4:4 or 4:2:0.
122 /// - `Some(Nv12)`: colour JPEGs decode to NV12, downsampling chroma at
123 /// the write stage (2×2 average for 4:4:4, vertical for 4:2:2).
124 /// - `None` (default): native format (`Nv12`/`Nv16`/`Nv24`/`Grey`).
125 ///
126 /// Callers still run `ImageProcessor::convert()` on the result for
127 /// model-input preprocessing (letterbox, resize, EXIF orientation). With
128 /// fused RGB that convert step is typically a pure resize.
129 pub fn set_output_format(&mut self, format: Option<edgefirst_tensor::PixelFormat>) {
130 self.jpeg_state.preferred_format = format;
131 }
132
133 /// Select the software JPEG IDCT kernel class — see [`DctMethod`].
134 /// Off (Accurate) by default; `EDGEFIRST_CODEC_DCT=fast` in the
135 /// environment flips a **new** decoder's default for A/B runs.
136 pub fn set_dct_method(&mut self, method: DctMethod) {
137 self.jpeg_state.fast_dct = method == DctMethod::Fast;
138 }
139
140 /// Select the chroma upsampling filter for a fused native-4:2:0 `Rgb`
141 /// decode — see [`ChromaUpsample`]. [`ChromaUpsample::Box`] (the
142 /// default) is the only filter implemented today; this setter exists so
143 /// a future filter can be selected without changing
144 /// [`set_output_format`](Self::set_output_format)'s signature. Has no
145 /// effect on 4:4:4 sources (no chroma resampling occurs there) or when
146 /// the output format isn't `Rgb`.
147 pub fn set_chroma_upsample(&mut self, mode: ChromaUpsample) {
148 self.jpeg_state.chroma_upsample = mode;
149 }
150
151 /// Decode image data into a typed tensor, configuring its dimensions and
152 /// pixel format to the decoded output format — the source's native format
153 /// unless [`set_output_format`](Self::set_output_format) selected a fused
154 /// one.
155 ///
156 /// Detects the image format (JPEG or PNG) from magic bytes.
157 ///
158 /// # Errors
159 ///
160 /// - [`CodecError::InsufficientCapacity`] if the image is larger than the
161 /// tensor's allocation
162 /// - [`CodecError::UnsupportedDtype`] if `T` is not valid for the output
163 /// format (JPEG NV12/NV16/NV24/RGB/GREY require `u8`)
164 /// - [`CodecError::InvalidData`] if the data is not a valid JPEG or PNG,
165 /// including a JPEG whose entropy-coded scan is truncated
166 /// - [`CodecError::Tensor`] carrying
167 /// [`CpuAccessDenied`](edgefirst_tensor::Error::CpuAccessDenied) when
168 /// the CPU decoder must write a tensor whose declared
169 /// [`CpuAccess`](edgefirst_tensor::CpuAccess) has no write direction
170 pub fn decode_into<T: ImagePixel>(
171 &mut self,
172 data: &[u8],
173 dst: &mut Tensor<T>,
174 ) -> crate::Result<ImageInfo> {
175 if is_jpeg(data) {
176 crate::jpeg::decode_jpeg_into(data, dst, &mut self.jpeg_state)
177 } else if is_png(data) {
178 {
179 #[cfg(feature = "png")]
180 {
181 crate::png::decode_png_into(data, dst, &mut self.scratch)
182 }
183 #[cfg(not(feature = "png"))]
184 {
185 Err(CodecError::Unsupported(UnsupportedFeature::PngNotEnabled))
186 }
187 }
188 } else {
189 Err(CodecError::InvalidData(
190 "unrecognized image format (expected JPEG or PNG magic bytes)".into(),
191 ))
192 }
193 }
194
195 /// Decode image data into a type-erased tensor.
196 pub fn decode_into_dyn(
197 &mut self,
198 data: &[u8],
199 dst: &mut TensorDyn,
200 ) -> crate::Result<ImageInfo> {
201 match dst.dtype() {
202 DType::U8 => self.decode_into(data, dst.as_typed_mut::<u8>().expect("dtype checked")),
203 DType::I8 => self.decode_into(data, dst.as_typed_mut::<i8>().expect("dtype checked")),
204 DType::U16 => self.decode_into(data, dst.as_typed_mut::<u16>().expect("dtype checked")),
205 DType::I16 => self.decode_into(data, dst.as_typed_mut::<i16>().expect("dtype checked")),
206 DType::F32 => self.decode_into(data, dst.as_typed_mut::<f32>().expect("dtype checked")),
207 other => Err(CodecError::UnsupportedDtype(other)),
208 }
209 }
210
211 /// Read all bytes from a `Read` source into the internal input buffer, then
212 /// decode. The input buffer is reused across calls.
213 pub fn decode_from_reader<T: ImagePixel, R: Read>(
214 &mut self,
215 mut reader: R,
216 dst: &mut Tensor<T>,
217 ) -> crate::Result<ImageInfo> {
218 self.input_buffer.clear();
219 reader.read_to_end(&mut self.input_buffer)?;
220 decode_into_inner(
221 &mut self.jpeg_state,
222 &mut self.scratch,
223 &self.input_buffer,
224 dst,
225 )
226 }
227
228 /// Read all bytes from a `Read` source, then decode into a type-erased
229 /// tensor.
230 pub fn decode_from_reader_dyn<R: Read>(
231 &mut self,
232 mut reader: R,
233 dst: &mut TensorDyn,
234 ) -> crate::Result<ImageInfo> {
235 self.input_buffer.clear();
236 reader.read_to_end(&mut self.input_buffer)?;
237 decode_into_inner_dyn(
238 &mut self.jpeg_state,
239 &mut self.scratch,
240 &self.input_buffer,
241 dst,
242 )
243 }
244}
245
246/// Internal decode entry point parameterised over disjoint `&mut` borrows so
247/// callers can read from a `&[u8]` borrowed from one field of [`ImageDecoder`]
248/// while mutably borrowing the others.
249pub(crate) fn decode_into_inner<T: ImagePixel>(
250 jpeg_state: &mut crate::jpeg::JpegDecoderState,
251 scratch: &mut Vec<u8>,
252 data: &[u8],
253 dst: &mut Tensor<T>,
254) -> crate::Result<ImageInfo> {
255 if is_jpeg(data) {
256 crate::jpeg::decode_jpeg_into(data, dst, jpeg_state)
257 } else if is_png(data) {
258 {
259 #[cfg(feature = "png")]
260 {
261 crate::png::decode_png_into(data, dst, scratch)
262 }
263 #[cfg(not(feature = "png"))]
264 {
265 let _ = scratch;
266 Err(CodecError::Unsupported(UnsupportedFeature::PngNotEnabled))
267 }
268 }
269 } else {
270 Err(CodecError::InvalidData(
271 "unrecognized image format (expected JPEG or PNG magic bytes)".into(),
272 ))
273 }
274}
275
276pub(crate) fn decode_into_inner_dyn(
277 jpeg_state: &mut crate::jpeg::JpegDecoderState,
278 scratch: &mut Vec<u8>,
279 data: &[u8],
280 dst: &mut TensorDyn,
281) -> crate::Result<ImageInfo> {
282 match dst.dtype() {
283 DType::U8 => decode_into_inner(
284 jpeg_state,
285 scratch,
286 data,
287 dst.as_typed_mut::<u8>().expect("dtype checked"),
288 ),
289 DType::I8 => decode_into_inner(
290 jpeg_state,
291 scratch,
292 data,
293 dst.as_typed_mut::<i8>().expect("dtype checked"),
294 ),
295 DType::U16 => decode_into_inner(
296 jpeg_state,
297 scratch,
298 data,
299 dst.as_typed_mut::<u16>().expect("dtype checked"),
300 ),
301 DType::I16 => decode_into_inner(
302 jpeg_state,
303 scratch,
304 data,
305 dst.as_typed_mut::<i16>().expect("dtype checked"),
306 ),
307 DType::F32 => decode_into_inner(
308 jpeg_state,
309 scratch,
310 data,
311 dst.as_typed_mut::<f32>().expect("dtype checked"),
312 ),
313 other => Err(CodecError::UnsupportedDtype(other)),
314 }
315}
316
317impl Default for ImageDecoder {
318 fn default() -> Self {
319 Self::new()
320 }
321}
322
323/// Parse image headers and return the native dimensions, format, and EXIF
324/// orientation without decoding pixels.
325///
326/// Recommended for one-shot flows that allocate a tensor sized to the image:
327///
328/// ```rust,no_run
329/// use edgefirst_codec::{peek_info, ImageDecoder, ImageLoad};
330/// use edgefirst_tensor::{CpuAccess, Tensor, TensorMemory};
331///
332/// let data = std::fs::read("image.jpg").unwrap();
333/// let info = peek_info(&data).unwrap();
334/// let mut tensor = Tensor::<u8>::image(info.width, info.height, info.format,
335/// Some(TensorMemory::Mem),
336/// CpuAccess::Write).unwrap();
337/// let mut decoder = ImageDecoder::new();
338/// tensor.load_image(&mut decoder, &data).unwrap();
339/// ```
340pub fn peek_info(data: &[u8]) -> crate::Result<ImageInfo> {
341 if is_jpeg(data) {
342 crate::jpeg::peek_jpeg_info(data)
343 } else if is_png(data) {
344 {
345 #[cfg(feature = "png")]
346 {
347 crate::png::peek_png_info(data)
348 }
349 #[cfg(not(feature = "png"))]
350 {
351 Err(CodecError::Unsupported(UnsupportedFeature::PngNotEnabled))
352 }
353 }
354 } else {
355 Err(CodecError::InvalidData(
356 "unrecognized image format (expected JPEG or PNG magic bytes)".into(),
357 ))
358 }
359}
360
361/// Check for JPEG magic bytes (FFD8FF).
362fn is_jpeg(data: &[u8]) -> bool {
363 data.len() >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF
364}
365
366/// Check for PNG magic bytes (89504E47).
367fn is_png(data: &[u8]) -> bool {
368 data.len() >= 4 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn magic_bytes_jpeg() {
377 assert!(is_jpeg(&[0xFF, 0xD8, 0xFF, 0xE0]));
378 assert!(!is_jpeg(&[0x89, 0x50, 0x4E, 0x47]));
379 assert!(!is_jpeg(&[]));
380 assert!(!is_jpeg(&[0xFF]));
381 }
382
383 #[test]
384 fn magic_bytes_png() {
385 assert!(is_png(&[0x89, 0x50, 0x4E, 0x47]));
386 assert!(!is_png(&[0xFF, 0xD8, 0xFF, 0xE0]));
387 assert!(!is_png(&[]));
388 }
389
390 #[test]
391 fn invalid_data() {
392 let mut decoder = ImageDecoder::new();
393 let mut tensor = Tensor::<u8>::image(
394 100,
395 100,
396 edgefirst_tensor::PixelFormat::Nv12,
397 Some(edgefirst_tensor::TensorMemory::Mem),
398 edgefirst_tensor::CpuAccess::ReadWrite,
399 )
400 .unwrap();
401 let result = decoder.decode_into(b"not an image", &mut tensor);
402 assert!(matches!(result, Err(CodecError::InvalidData(_))));
403 }
404}