Skip to main content

rusty_esp_image_core/
jpeg.rs

1//! Reading a JPEG's header without decoding it.
2//!
3//! A camera sensor in JPEG mode hands over coded bytes and nothing else; the
4//! geometry has to be read back out of the SOF segment to build a `Frame` or
5//! a stream header. [`probe`] walks the marker segments up to the first
6//! start-of-frame; [`find_eoi`] trims the padding a DMA transfer appends after
7//! the end-of-image marker.
8
9use rusty_esp_core::error::{Error, Result};
10use rusty_esp_core::frame::{Geometry, PixelFormat};
11
12/// What the header says about a JPEG.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct JpegInfo {
15    /// Width, height, `PixelFormat::Jpeg`.
16    pub geometry: Geometry,
17    /// True for a progressive (SOF2/6/10/14) scan structure.
18    pub progressive: bool,
19    /// 1 (grayscale), 3 (YCbCr/RGB) or 4 (CMYK).
20    pub components: u8,
21    /// Sample precision in bits; 8 for every camera sensor.
22    pub precision: u8,
23    /// Offset of the SOF marker's `0xFF`, useful for header rewriting.
24    pub sof_offset: usize,
25}
26
27const SOI: u8 = 0xD8;
28const EOI: u8 = 0xD9;
29const SOS: u8 = 0xDA;
30
31fn is_sof(marker: u8) -> bool {
32    matches!(
33        marker,
34        0xC0 | 0xC1 | 0xC2 | 0xC3 | 0xC5 | 0xC6 | 0xC7 | 0xC9 | 0xCA | 0xCB | 0xCD | 0xCE | 0xCF
35    )
36}
37
38fn is_progressive(marker: u8) -> bool {
39    matches!(marker, 0xC2 | 0xC6 | 0xCA | 0xCE)
40}
41
42/// True when `bytes` start with the SOI marker.
43#[must_use]
44pub fn is_jpeg(bytes: &[u8]) -> bool {
45    bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == SOI
46}
47
48/// Parse the header up to the first SOF segment.
49pub fn probe(bytes: &[u8]) -> Result<JpegInfo> {
50    if !is_jpeg(bytes) {
51        return Err(Error::InvalidFormat);
52    }
53    let mut i = 2usize;
54    loop {
55        // Every segment starts with 0xFF; fill bytes (repeated 0xFF) are allowed.
56        let &ff = bytes.get(i).ok_or(Error::InvalidFormat)?;
57        if ff != 0xFF {
58            return Err(Error::InvalidFormat);
59        }
60        while bytes.get(i) == Some(&0xFF) {
61            i += 1;
62        }
63        let &marker = bytes.get(i).ok_or(Error::InvalidFormat)?;
64        i += 1;
65        match marker {
66            0x00 => return Err(Error::InvalidFormat), // stuffed byte outside scan data
67            0x01 | 0xD0..=0xD7 => continue,           // standalone markers
68            EOI | SOS => return Err(Error::InvalidFormat), // no frame header seen
69            _ => {}
70        }
71        let len = read_u16(bytes, i)? as usize;
72        if len < 2 {
73            return Err(Error::InvalidFormat);
74        }
75        if is_sof(marker) {
76            let seg = bytes.get(i + 2..i + len).ok_or(Error::InvalidFormat)?;
77            if seg.len() < 6 {
78                return Err(Error::InvalidFormat);
79            }
80            let precision = seg[0];
81            let height = u32::from(u16::from_be_bytes([seg[1], seg[2]]));
82            let width = u32::from(u16::from_be_bytes([seg[3], seg[4]]));
83            let components = seg[5];
84            if height == 0 {
85                // Height defined later by a DNL marker; no camera does this.
86                return Err(Error::Unsupported);
87            }
88            let geometry = Geometry::new(width, height, PixelFormat::Jpeg)?;
89            return Ok(JpegInfo {
90                geometry,
91                progressive: is_progressive(marker),
92                components,
93                precision,
94                sof_offset: i - 2,
95            });
96        }
97        i += len;
98    }
99}
100
101fn read_u16(bytes: &[u8], at: usize) -> Result<u16> {
102    let hi = *bytes.get(at).ok_or(Error::InvalidFormat)?;
103    let lo = *bytes.get(at + 1).ok_or(Error::InvalidFormat)?;
104    Ok(u16::from_be_bytes([hi, lo]))
105}
106
107/// Length of the JPEG up to and including its EOI marker, scanning back
108/// from the end so trailing DMA padding is skipped. `None` when no EOI is
109/// found.
110#[must_use]
111pub fn find_eoi(bytes: &[u8]) -> Option<usize> {
112    // Two shapes were tried here on an ESP32-S3 (2026-09-19) and NEITHER beat
113    // this one, so it stands unchanged:
114    //   `bytes.windows(2).rposition(..)`  +35.7%  -- building a two-element
115    //       slice per position costs more than the second load it saves;
116    //   carrying the previous byte in a local, one load per position instead
117    //       of two: 175 274 vs 175 258 ps/byte, i.e. FLAT. The second load is
118    //       an L1 hit on a line already resident, so removing it buys nothing.
119    // Reverted as "inside the noise", not as "measured worse".
120    if bytes.len() < 2 {
121        return None;
122    }
123    let mut i = bytes.len() - 1;
124    while i >= 1 {
125        if bytes[i] == EOI && bytes[i - 1] == 0xFF {
126            return Some(i + 1);
127        }
128        i -= 1;
129    }
130    None
131}
132
133/// JPEG encoding on the chip: a raw frame into a caller-owned buffer through
134/// `rusty_jpeg` (`no_std` + `alloc`, the 0.4 release made for this).
135///
136/// YUYV is coded as the sensor delivered it (`rusty_jpeg::YuyvImage`, no
137/// colour conversion of our own); RGB888, BGR888, RGBA8888 and Gray8 go
138/// through the encoder's own colour types. The output is a baseline JPEG
139/// with the standard Huffman tables, which is what the RTP/JPEG payloader
140/// and every browser expect. Feature `jpeg`.
141#[cfg(feature = "jpeg")]
142pub mod encode {
143    use rusty_esp_core::error::{Error, Result};
144    use rusty_esp_core::frame::{Frame, Geometry, PixelFormat, Planes};
145    use rusty_jpeg::encode::{ColorType, Encoder, EncodingError, SliceWriter, YuyvImage};
146
147    /// A buffer this large always holds the JPEG of a `geometry` frame at
148    /// any quality: three bytes per pixel (a baseline JPEG of noise at
149    /// quality 100 stays under that) plus room for the headers.
150    #[must_use]
151    pub fn max_bytes(geometry: &Geometry) -> usize {
152        (geometry.width as usize)
153            .saturating_mul(geometry.height as usize)
154            .saturating_mul(3)
155            .saturating_add(4096)
156    }
157
158    /// Encode packed `data` of `geometry` at `quality` (1–100) into `out`;
159    /// returns the JPEG's length. `Unsupported` for a format with no packed
160    /// pixels (planar, coded), `InvalidGeometry` when `data` is short or a
161    /// side exceeds 65 535, `BufferTooSmall` (with [`max_bytes`] as the need)
162    /// when `out` cannot hold it.
163    pub fn encode_packed(
164        geometry: Geometry,
165        data: &[u8],
166        quality: u8,
167        out: &mut [u8],
168    ) -> Result<usize> {
169        let (w, h) = (
170            u16::try_from(geometry.width).map_err(|_| Error::InvalidGeometry)?,
171            u16::try_from(geometry.height).map_err(|_| Error::InvalidGeometry)?,
172        );
173        if w == 0 || h == 0 {
174            return Err(Error::InvalidGeometry);
175        }
176        let needed = geometry.byte_len().ok_or(Error::Unsupported)?;
177        if data.len() < needed {
178            return Err(Error::InvalidGeometry);
179        }
180        let quality = quality.clamp(1, 100);
181        let mut writer = SliceWriter::new(out);
182        let result = match geometry.format {
183            PixelFormat::Yuyv422 => {
184                let image =
185                    YuyvImage::new(data, usize::from(w) * 2, w, h).ok_or(Error::InvalidGeometry)?;
186                Encoder::new(&mut writer, quality).encode_image(image)
187            }
188            PixelFormat::Rgb888 => {
189                Encoder::new(&mut writer, quality).encode(data, w, h, ColorType::Rgb)
190            }
191            PixelFormat::Bgr888 => {
192                Encoder::new(&mut writer, quality).encode(data, w, h, ColorType::Bgr)
193            }
194            PixelFormat::Rgba8888 => {
195                Encoder::new(&mut writer, quality).encode(data, w, h, ColorType::Rgba)
196            }
197            PixelFormat::Gray8 => {
198                Encoder::new(&mut writer, quality).encode(data, w, h, ColorType::Luma)
199            }
200            _ => return Err(Error::Unsupported),
201        };
202        match result {
203            Ok(()) => Ok(writer.written()),
204            Err(EncodingError::BufferTooSmall) => Err(Error::BufferTooSmall {
205                needed: max_bytes(&geometry),
206            }),
207            Err(_) => Err(Error::InvalidFormat),
208        }
209    }
210
211    /// [`encode_packed`] for a borrowed frame.
212    pub fn encode_frame(frame: &Frame<'_>, quality: u8, out: &mut [u8]) -> Result<usize> {
213        match frame.planes {
214            Planes::Packed(data) => encode_packed(frame.geometry, data, quality, out),
215            Planes::Planar { .. } => Err(Error::Unsupported),
216        }
217    }
218
219    #[cfg(all(test, feature = "std"))]
220    mod tests {
221        use super::*;
222        use crate::jpeg::{find_eoi, probe};
223        use crate::source::{ImageSource, TestPattern};
224
225        #[test]
226        fn colour_bars_round_trip_through_the_house_decoder() {
227            let g = Geometry::new(96, 64, PixelFormat::Rgb888).unwrap();
228            let mut pattern = TestPattern::new(g, 10).unwrap();
229            let mut rgb = vec![0u8; g.byte_len().unwrap()];
230            let frame = pattern.grab(&mut rgb).unwrap();
231            let mut out = vec![0u8; max_bytes(&g)];
232            let n = encode_frame(&frame, 85, &mut out).unwrap();
233            let info = probe(&out[..n]).unwrap();
234            assert_eq!(info.geometry.width, 96);
235            assert_eq!(info.geometry.height, 64);
236            assert!(!info.progressive);
237            assert_eq!(find_eoi(&out[..n]), Some(n));
238            let mut d = rusty_jpeg::Decoder::new(&out[..n]);
239            let pixels = d.decode().unwrap();
240            let back = d.info().unwrap();
241            assert_eq!((back.width, back.height), (96, 64));
242            assert_eq!(pixels.len(), 96 * 64 * 3);
243            let err: u64 = pixels
244                .iter()
245                .zip(&rgb)
246                .map(|(&a, &b)| u64::from(a.abs_diff(b)))
247                .sum();
248            let mean = err / (96 * 64 * 3);
249            // colour bars are all hard edges: ringing puts the mean near 7 at q85
250            assert!(mean < 12, "mean abs error {mean}");
251        }
252
253        #[test]
254        fn yuyv_is_coded_as_delivered_and_the_luma_survives() {
255            let g = Geometry::new(64, 32, PixelFormat::Yuyv422).unwrap();
256            let mut yuyv = vec![0u8; g.byte_len().unwrap()];
257            for (i, px) in yuyv.chunks_exact_mut(4).enumerate() {
258                let y = ((i % 32) * 8) as u8;
259                px.copy_from_slice(&[y, 128, y, 128]);
260            }
261            let mut out = vec![0u8; max_bytes(&g)];
262            let n = encode_packed(g, &yuyv, 90, &mut out).unwrap();
263            assert_eq!(probe(&out[..n]).unwrap().geometry.width, 64);
264            let mut d = rusty_jpeg::Decoder::new(&out[..n]);
265            let pixels = d.decode().unwrap();
266            let info = d.info().unwrap();
267            assert_eq!((info.width, info.height), (64, 32));
268            // gray in, gray out: the decoder's green channel is the luma
269            let mut err = 0u64;
270            let mut count = 0u64;
271            for (px, y) in pixels
272                .chunks_exact(3)
273                .zip(yuyv.chunks_exact(2).map(|p| p[0]))
274            {
275                err += u64::from(px[1].abs_diff(y));
276                count += 1;
277            }
278            assert!(err / count < 8, "mean luma error {}", err / count);
279        }
280
281        #[test]
282        fn the_refusals_name_their_reason() {
283            let g = Geometry::new(64, 32, PixelFormat::Rgb888).unwrap();
284            let rgb = vec![90u8; g.byte_len().unwrap()];
285            let mut small = [0u8; 100];
286            assert_eq!(
287                encode_packed(g, &rgb, 80, &mut small),
288                Err(Error::BufferTooSmall {
289                    needed: max_bytes(&g)
290                })
291            );
292            let mut out = vec![0u8; max_bytes(&g)];
293            assert_eq!(
294                encode_packed(g, &rgb[..10], 80, &mut out),
295                Err(Error::InvalidGeometry)
296            );
297            let planar = Geometry::new(64, 32, PixelFormat::Yuv420p).unwrap();
298            assert_eq!(
299                encode_packed(planar, &rgb, 80, &mut out),
300                Err(Error::Unsupported)
301            );
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    /// A minimal header: SOI, an APP0 segment, then SOF of the given kind.
311    fn header(sof: u8, width: u16, height: u16, components: u8) -> [u8; 4 + 6 + 2 + 2 + 6] {
312        let mut h = [0u8; 20];
313        h[0..2].copy_from_slice(&[0xFF, 0xD8]);
314        h[2..4].copy_from_slice(&[0xFF, 0xE0]); // APP0
315        h[4..6].copy_from_slice(&4u16.to_be_bytes()); // len 4: two payload bytes
316        h[6..8].copy_from_slice(b"JF");
317        h[8..10].copy_from_slice(&[0xFF, sof]);
318        h[10..12].copy_from_slice(&8u16.to_be_bytes()); // len 8: 6 payload bytes
319        h[12] = 8;
320        h[13..15].copy_from_slice(&height.to_be_bytes());
321        h[15..17].copy_from_slice(&width.to_be_bytes());
322        h[17] = components;
323        h[18..20].copy_from_slice(&[0xFF, 0xD9]);
324        h
325    }
326
327    #[test]
328    fn baseline_and_progressive_headers() {
329        let b = header(0xC0, 320, 240, 3);
330        let info = probe(&b).unwrap();
331        assert_eq!(info.geometry.width, 320);
332        assert_eq!(info.geometry.height, 240);
333        assert_eq!(info.geometry.format, PixelFormat::Jpeg);
334        assert!(!info.progressive);
335        assert_eq!(info.components, 3);
336        assert_eq!(info.precision, 8);
337        assert_eq!(info.sof_offset, 8);
338        let p = header(0xC2, 1600, 1200, 1);
339        let info = probe(&p).unwrap();
340        assert!(info.progressive);
341        assert_eq!(info.components, 1);
342        assert_eq!((info.geometry.width, info.geometry.height), (1600, 1200));
343    }
344
345    #[test]
346    fn rejects_non_jpeg_truncated_and_headerless() {
347        assert_eq!(probe(&[0x89, b'P', b'N', b'G']), Err(Error::InvalidFormat));
348        assert_eq!(probe(&[0xFF, 0xD8]), Err(Error::InvalidFormat));
349        let mut h = header(0xC0, 8, 8, 3);
350        assert!(probe(&h[..12]).is_err());
351        // SOS before SOF
352        h[9] = 0xDA;
353        assert_eq!(probe(&h), Err(Error::InvalidFormat));
354        // DNL-style zero height
355        let z = header(0xC0, 8, 0, 3);
356        assert_eq!(probe(&z), Err(Error::Unsupported));
357    }
358
359    #[test]
360    fn eoi_scan_skips_padding() {
361        let mut buf = header(0xC0, 8, 8, 3).to_vec();
362        let len = buf.len();
363        buf.extend_from_slice(&[0, 0, 0, 0, 0xFF, 0xFF]);
364        assert_eq!(find_eoi(&buf), Some(len));
365        assert_eq!(find_eoi(&[0xFF, 0xD8, 0x00]), None);
366        assert_eq!(find_eoi(&[]), None);
367    }
368
369    #[test]
370    fn real_jpegs_from_the_house_encoder() {
371        // rusty_jpeg on the host is the oracle: encode known geometries,
372        // baseline and progressive, colour and grayscale; the probe must read
373        // them back.
374        for (w, h) in [(16u16, 8u16), (160, 120), (320, 240), (99, 33)] {
375            for progressive in [false, true] {
376                let rgb: std::vec::Vec<u8> = (0..(w as usize * h as usize * 3))
377                    .map(|i| (i % 251) as u8)
378                    .collect();
379                let mut out = std::vec::Vec::new();
380                let mut enc = rusty_jpeg::encode::Encoder::new(&mut out, 80);
381                enc.set_progressive(progressive);
382                enc.encode(&rgb, w, h, rusty_jpeg::encode::ColorType::Rgb)
383                    .unwrap();
384                let info = probe(&out).unwrap();
385                assert_eq!(info.geometry.width, u32::from(w));
386                assert_eq!(info.geometry.height, u32::from(h));
387                assert_eq!(info.progressive, progressive, "{w}x{h}");
388                assert_eq!(info.components, 3);
389                assert_eq!(find_eoi(&out), Some(out.len()));
390
391                let gray: std::vec::Vec<u8> = rgb.iter().step_by(3).copied().collect();
392                let mut out = std::vec::Vec::new();
393                let mut enc = rusty_jpeg::encode::Encoder::new(&mut out, 80);
394                enc.set_progressive(progressive);
395                enc.encode(&gray, w, h, rusty_jpeg::encode::ColorType::Luma)
396                    .unwrap();
397                let info = probe(&out).unwrap();
398                assert_eq!(info.components, 1);
399                assert_eq!(info.geometry.width, u32::from(w));
400            }
401        }
402    }
403}