Skip to main content

videre_core/
image_decode.rs

1//! Orientation-aware decode of source image files.
2//!
3//! Contract: every function returns the image **as a human sees it**,
4//! display canvas, by reading the EXIF Orientation tag through the
5//! `image` crate's decoder and applying it.
6//!
7//! :warning: **Never call these on files videre itself produced.** QuickLook
8//! conversions (`videre_core::heic`, `videre_ml::preprocess::
9//! decode_via_quicklook`) and the thumbnail/`original` caches are already
10//! upright pixels with no orientation tag; running them through here would
11//! double-rotate. HEIC and video therefore do not route through this module.
12
13use std::path::Path;
14
15use image::ImageDecoder;
16
17pub fn decode_oriented_file(path: &Path) -> image::ImageResult<image::DynamicImage> {
18    let (img, orientation) = decode_raw_with_orientation(path)?;
19    Ok(apply(img, orientation))
20}
21
22/// Reader variant of [`decode_oriented_file`]: the same contract for an
23/// already-opened, already-validated handle, so callers that must not reopen
24/// the path (library I/O rules) keep that property.
25pub fn decode_oriented_reader<R: std::io::BufRead + std::io::Seek>(
26    reader: R,
27) -> image::ImageResult<image::DynamicImage> {
28    let (img, orientation) = decode_raw_with_orientation_reader(reader)?;
29    Ok(apply(img, orientation))
30}
31
32pub fn decode_oriented_bytes(bytes: &[u8]) -> Option<image::DynamicImage> {
33    let decoder = image::ImageReader::new(std::io::Cursor::new(bytes))
34        .with_guessed_format()
35        .ok()?
36        .into_decoder()
37        .ok()?;
38    let (img, orientation) = decode_decoder(decoder).ok()?;
39    Some(apply(img, orientation))
40}
41
42pub fn decode_raw_with_orientation(
43    path: &Path,
44) -> image::ImageResult<(image::DynamicImage, image::metadata::Orientation)> {
45    let reader = std::io::BufReader::new(std::fs::File::open(path)?);
46    decode_raw_with_orientation_reader(reader)
47}
48
49pub fn decode_raw_with_orientation_reader<R: std::io::BufRead + std::io::Seek>(
50    reader: R,
51) -> image::ImageResult<(image::DynamicImage, image::metadata::Orientation)> {
52    let decoder = image::ImageReader::new(reader)
53        .with_guessed_format()?
54        .into_decoder()?;
55    decode_decoder(decoder)
56}
57
58fn decode_decoder<D: ImageDecoder>(
59    mut decoder: D,
60) -> image::ImageResult<(image::DynamicImage, image::metadata::Orientation)> {
61    let orientation = decoder
62        .orientation()
63        .unwrap_or(image::metadata::Orientation::NoTransforms);
64    let img = image::DynamicImage::from_decoder(decoder)?;
65    Ok((img, orientation))
66}
67
68fn apply(
69    mut img: image::DynamicImage,
70    orientation: image::metadata::Orientation,
71) -> image::DynamicImage {
72    img.apply_orientation(orientation);
73    img
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use image::GenericImage;
80
81    /// Build a tiny JPEG with the given EXIF orientation tag by inserting an
82    /// APP1 EXIF segment after the SOI marker. The segment is a minimal
83    /// big-endian TIFF: IFD0 with a single SHORT entry, tag 0x0112.
84    fn jpeg_with_orientation(orientation: u16) -> Vec<u8> {
85        let mut img = image::DynamicImage::new_rgb8(4, 2);
86        // Asymmetric pixels: row 0 white, row 1 black, so orientation can be
87        // asserted from decoded pixel geometry, not just dimensions.
88        for y in 0..2u32 {
89            for x in 0..4u32 {
90                img.put_pixel(
91                    x,
92                    y,
93                    if y == 0 {
94                        image::Rgba([255, 255, 255, 255])
95                    } else {
96                        image::Rgba([0, 0, 0, 255])
97                    },
98                );
99            }
100        }
101        let mut jpeg = Vec::new();
102        img.write_to(
103            &mut std::io::Cursor::new(&mut jpeg),
104            image::ImageFormat::Jpeg,
105        )
106        .unwrap();
107
108        let mut tiff = Vec::new();
109        tiff.extend_from_slice(b"MM");
110        tiff.extend_from_slice(&42u16.to_be_bytes());
111        tiff.extend_from_slice(&8u32.to_be_bytes()); // IFD0 offset
112        tiff.extend_from_slice(&1u16.to_be_bytes()); // one entry
113        tiff.extend_from_slice(&0x0112u16.to_be_bytes()); // Orientation
114        tiff.extend_from_slice(&3u16.to_be_bytes()); // SHORT
115        tiff.extend_from_slice(&1u32.to_be_bytes()); // count
116        tiff.extend_from_slice(&(orientation as u16).to_be_bytes());
117        tiff.extend_from_slice(&0u16.to_be_bytes()); // value padding
118        tiff.extend_from_slice(&0u32.to_be_bytes()); // no next IFD
119
120        let mut app1 = Vec::new();
121        app1.extend_from_slice(b"Exif\0\0");
122        app1.extend_from_slice(&tiff);
123
124        let mut out = Vec::new();
125        out.extend_from_slice(&jpeg[0..2]); // SOI
126        out.extend_from_slice(&[0xFF, 0xE1]); // APP1 marker
127        out.extend_from_slice(&(app1.len() as u16 + 2).to_be_bytes());
128        out.extend_from_slice(&app1);
129        out.extend_from_slice(&jpeg[2..]);
130        out
131    }
132
133    fn top_left_is_white(img: &image::DynamicImage) -> bool {
134        let rgb = img.to_rgb8();
135        rgb.get_pixel(0, 0)[0] > 128
136    }
137
138    #[test]
139    fn untagged_file_passes_through_untouched() {
140        let mut plain = Vec::new();
141        let img = image::DynamicImage::new_rgb8(4, 2);
142        img.write_to(
143            &mut std::io::Cursor::new(&mut plain),
144            image::ImageFormat::Jpeg,
145        )
146        .unwrap();
147        let decoded = decode_oriented_bytes(&plain).unwrap();
148        assert_eq!((decoded.width(), decoded.height()), (4, 2));
149    }
150
151    #[test]
152    fn orientation6_yields_the_display_canvas() {
153        // Orientation 6 = rotate 90 CW to display: the 4x2 landscape becomes
154        // 2x4 portrait and the top row (white) lands on the right column.
155        let bytes = jpeg_with_orientation(6);
156        let decoded = decode_oriented_bytes(&bytes).unwrap();
157        assert_eq!((decoded.width(), decoded.height()), (2, 4));
158        let rgb = decoded.to_rgb8();
159        assert!(
160            rgb.get_pixel(1, 0)[0] > 128,
161            "white row must move to the right column"
162        );
163    }
164
165    #[test]
166    fn file_and_bytes_variants_agree() {
167        let dir = tempfile::tempdir().unwrap();
168        let path = dir.path().join("o6.jpg");
169        std::fs::write(&path, jpeg_with_orientation(6)).unwrap();
170        let via_file = decode_oriented_file(&path).unwrap();
171        let via_bytes = decode_oriented_bytes(&jpeg_with_orientation(6)).unwrap();
172        assert_eq!(
173            (via_file.width(), via_file.height()),
174            (via_bytes.width(), via_bytes.height())
175        );
176        assert_eq!(top_left_is_white(&via_file), top_left_is_white(&via_bytes));
177    }
178
179    #[test]
180    fn raw_variant_returns_the_tag_for_the_caller_to_apply() {
181        let dir = tempfile::tempdir().unwrap();
182        let path = dir.path().join("o6.jpg");
183        std::fs::write(&path, jpeg_with_orientation(6)).unwrap();
184        let (img, orientation) = decode_raw_with_orientation(&path).unwrap();
185        assert_eq!(
186            (img.width(), img.height()),
187            (4, 2),
188            "raw canvas is untouched"
189        );
190        assert!(matches!(
191            orientation,
192            image::metadata::Orientation::Rotate90
193        ));
194    }
195}