Skip to main content

resopt/
image_backend.rs

1#[cfg(target_os = "macos")]
2use anyhow::Context;
3use anyhow::{Result, ensure};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ImageInfo {
8    pub decoder_type: String,
9    pub width: usize,
10    pub height: usize,
11    pub frames: usize,
12    pub bits_per_component: usize,
13    pub orientation: i64,
14    pub transparent_pixels: usize,
15    pub has_transparent_pixels: bool,
16}
17
18pub(crate) struct Decoded {
19    pub info: ImageInfo,
20    /// Premultiplied RGBA, rendered into a common sRGB float context.
21    pub pixels: Vec<f32>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ImageDifference {
26    pub rgb_mae_255: f64,
27    pub psnr_db: Option<f64>,
28    pub max_alpha_error: f32,
29}
30
31pub(crate) fn compare(a: &Decoded, b: &Decoded) -> Result<ImageDifference> {
32    ensure!(
33        a.info.width == b.info.width && a.info.height == b.info.height,
34        "dimensions_changed"
35    );
36    ensure!(
37        a.info.orientation == b.info.orientation,
38        "orientation_changed"
39    );
40    ensure!(
41        a.info.frames == 1 && b.info.frames == 1 && !a.pixels.is_empty(),
42        "multiple_frames"
43    );
44    ensure!(a.pixels.len() == b.pixels.len(), "pixel_buffer_mismatch");
45    let mut absolute = 0.0_f64;
46    let mut squared = 0.0_f64;
47    let mut alpha = 0.0_f32;
48    for (a, b) in a
49        .pixels
50        .as_chunks::<4>()
51        .0
52        .iter()
53        .zip(b.pixels.as_chunks::<4>().0.iter())
54    {
55        for channel in 0..3 {
56            let delta = f64::from(a[channel] - b[channel]);
57            absolute += delta.abs();
58            squared += delta * delta;
59        }
60        alpha = alpha.max((a[3] - b[3]).abs());
61    }
62    let channels = (a.pixels.len() / 4 * 3) as f64;
63    let mse = squared / channels;
64    Ok(ImageDifference {
65        rgb_mae_255: absolute / channels * 255.0,
66        psnr_db: (mse > 0.0).then(|| -10.0 * mse.log10()),
67        max_alpha_error: alpha,
68    })
69}
70
71pub(crate) fn preview(image: &Decoded) -> Result<Vec<u8>> {
72    let (display_width, display_height) = if (5..=8).contains(&image.info.orientation) {
73        (image.info.height, image.info.width)
74    } else {
75        (image.info.width, image.info.height)
76    };
77    let scale = (256.0 / display_width.max(display_height) as f64).min(1.0);
78    let width = (display_width as f64 * scale).round().max(1.0) as usize;
79    let height = (display_height as f64 * scale).round().max(1.0) as usize;
80    let mut bytes = Vec::with_capacity(width * height * 4);
81    for y in 0..height {
82        for x in 0..width {
83            let dx = x * display_width / width;
84            let dy = y * display_height / height;
85            let w = image.info.width;
86            let h = image.info.height;
87            let (sx, sy) = match image.info.orientation {
88                2 => (w - 1 - dx, dy),
89                3 => (w - 1 - dx, h - 1 - dy),
90                4 => (dx, h - 1 - dy),
91                5 => (dy, dx),
92                6 => (dy, h - 1 - dx),
93                7 => (w - 1 - dy, h - 1 - dx),
94                8 => (w - 1 - dy, dx),
95                _ => (dx, dy),
96            };
97            let index = (sy * image.info.width + sx) * 4;
98            let pixel = &image.pixels[index..index + 4];
99            let alpha = pixel[3].clamp(0.0, 1.0);
100            for value in &pixel[..3] {
101                bytes.push(if alpha == 0.0 {
102                    0
103                } else {
104                    (value / alpha * 255.0).round().clamp(0.0, 255.0) as u8
105                });
106            }
107            bytes.push((alpha * 255.0).round() as u8);
108        }
109    }
110    let mut output = Vec::new();
111    {
112        let mut encoder = png::Encoder::new(&mut output, width as u32, height as u32);
113        encoder.set_color(png::ColorType::Rgba);
114        encoder.set_depth(png::BitDepth::Eight);
115        encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
116        encoder.write_header()?.write_image_data(&bytes)?;
117    }
118    Ok(output)
119}
120
121pub fn image_backend_available() -> bool {
122    cfg!(target_os = "macos")
123}
124
125pub(crate) fn check_encoders() -> Result<()> {
126    let mut bytes = Vec::new();
127    {
128        let mut encoder = png::Encoder::new(&mut bytes, 64, 64);
129        encoder.set_color(png::ColorType::Rgb);
130        encoder.set_depth(png::BitDepth::Eight);
131        encoder
132            .write_header()?
133            .write_image_data(&vec![128; 64 * 64 * 3])?;
134    }
135    for format in ["jpeg", "heic"] {
136        let encoded = encode(&bytes, format, 85).map_err(|error| anyhow::anyhow!("{format} encoder unavailable: {error}; image analysis requires macOS ImageIO access (restrictive sandboxes can block HEIC); use --probe-only for detection without encoding"))?;
137        decode(&encoded)?;
138    }
139    Ok(())
140}
141
142#[cfg(not(target_os = "macos"))]
143pub(crate) fn decode(_: &[u8]) -> Result<Decoded> {
144    anyhow::bail!("image_analysis_requires_macos_imageio")
145}
146#[cfg(not(target_os = "macos"))]
147pub(crate) fn encode(_: &[u8], _: &str, _: u8) -> Result<Vec<u8>> {
148    anyhow::bail!("image_encoding_requires_macos_imageio")
149}
150
151#[cfg(target_os = "macos")]
152mod apple {
153    use super::*;
154    use objc2_core_foundation::{
155        CFData, CFDictionary, CFMutableData, CFNumber, CFRetained, CFString, CFType, CGPoint,
156        CGRect, CGSize,
157    };
158    use objc2_core_graphics::{
159        CGBitmapContextCreate, CGColorSpace, CGContext, CGImage, kCGColorSpaceSRGB,
160    };
161    use objc2_image_io::{
162        CGImageDestination, CGImageSource, kCGImageDestinationLossyCompressionQuality,
163        kCGImagePropertyOrientation,
164    };
165
166    fn source(bytes: &[u8]) -> Result<CFRetained<CGImageSource>> {
167        ensure!(bytes.len() <= 64 * 1024 * 1024, "input_exceeds_64_mib");
168        let data = CFData::from_bytes(bytes);
169        // SAFETY: No options dictionary; CFData owns copied input bytes and the
170        // retained source keeps the backing data alive for decoding.
171        unsafe { CGImageSource::with_data(&data, None) }.context("imageio_cannot_read_image")
172    }
173
174    pub(crate) fn decode(bytes: &[u8]) -> Result<Decoded> {
175        let source = source(bytes)?;
176        // SAFETY: Retained source; no mutable aliases or incorrectly typed options.
177        let (frames, decoder_type, image, orientation) = unsafe {
178            let frames = source.count();
179            ensure!(frames > 0, "image_has_no_frames");
180            let image = source
181                .image_at_index(0, None)
182                .context("imageio_decode_failed")?;
183            let properties = source.properties_at_index(0, None);
184            let orientation = properties
185                .as_ref()
186                .and_then(|dictionary| {
187                    dictionary
188                        .cast_unchecked::<CFString, CFType>()
189                        .get(kCGImagePropertyOrientation)
190                })
191                .and_then(|value| value.downcast_ref::<CFNumber>().and_then(CFNumber::as_i64))
192                .unwrap_or(1);
193            (
194                frames,
195                source
196                    .r#type()
197                    .map(|value| value.to_string())
198                    .unwrap_or_default(),
199                image,
200                orientation,
201            )
202        };
203        let width = CGImage::width(Some(&image));
204        let height = CGImage::height(Some(&image));
205        let length = width
206            .checked_mul(height)
207            .and_then(|n| n.checked_mul(4))
208            .context("image_dimensions_overflow")?;
209        ensure!(
210            width > 0 && height > 0 && length <= 16 * 1024 * 1024,
211            "decoded_image_exceeds_64_mib_float_buffer"
212        );
213        let mut pixels = vec![0_f32; length];
214        // SAFETY: Static color space identifier is provided by CoreGraphics.
215        let space = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB }))
216            .context("srgb_unavailable")?;
217        // SAFETY: `pixels` is initialized, 32-bit aligned and large enough for
218        // width*height*4 floats. It does not move or get accessed while the context
219        // uses its pointer. Drop context before reading the buffer. Flags specify
220        // float32 little-endian RGBA with premultiplied-last alpha on macOS.
221        let context = unsafe {
222            CGBitmapContextCreate(
223                pixels.as_mut_ptr().cast(),
224                width,
225                height,
226                32,
227                width * 16,
228                Some(&space),
229                1 | (1 << 8) | (2 << 12),
230            )
231        }
232        .context("float_bitmap_context_unavailable")?;
233        CGContext::draw_image(
234            Some(&context),
235            CGRect {
236                origin: CGPoint { x: 0.0, y: 0.0 },
237                size: CGSize {
238                    width: width as f64,
239                    height: height as f64,
240                },
241            },
242            Some(&image),
243        );
244        drop(context);
245        ensure!(
246            pixels.iter().all(|v| v.is_finite()),
247            "non_finite_decoded_samples"
248        );
249        let transparent_pixels = pixels
250            .as_chunks::<4>()
251            .0
252            .iter()
253            .filter(|pixel| pixel[3] < 1.0)
254            .count();
255        Ok(Decoded {
256            info: ImageInfo {
257                decoder_type,
258                width,
259                height,
260                frames,
261                bits_per_component: CGImage::bits_per_component(Some(&image)),
262                orientation,
263                transparent_pixels,
264                has_transparent_pixels: transparent_pixels > 0,
265            },
266            pixels,
267        })
268    }
269
270    pub(crate) fn encode(bytes: &[u8], format: &str, quality: u8) -> Result<Vec<u8>> {
271        ensure!(
272            matches!(format, "jpeg" | "heic") && (1..=100).contains(&quality),
273            "invalid_encoding_options"
274        );
275        let source = source(bytes)?;
276        // SAFETY: All objects are retained for the duration of encoding. The
277        // dictionary contains the documented CFString quality key and CFNumber
278        // value in 0..1. Destination owns no Rust buffer pointers.
279        unsafe {
280            ensure!(source.count() == 1, "multiple_frames_not_transcoded");
281            let output = CFMutableData::new(None, 0).context("cannot_allocate_encoded_buffer")?;
282            let type_id = CFString::from_str(if format == "jpeg" {
283                "public.jpeg"
284            } else {
285                "public.heic"
286            });
287            let destination = CGImageDestination::with_data(&output, &type_id, 1, None)
288                .context("requested_encoder_unavailable")?;
289            let quality = CFNumber::new_f64(f64::from(quality) / 100.0);
290            let options = CFDictionary::<CFString, CFType>::from_slices(
291                &[kCGImageDestinationLossyCompressionQuality],
292                &[quality.as_ref()],
293            );
294            destination.add_image_from_source(&source, 0, Some(options.as_opaque()));
295            ensure!(destination.finalize(), "imageio_encoding_failed");
296            drop(destination);
297            Ok(output.to_vec())
298        }
299    }
300}
301
302#[cfg(target_os = "macos")]
303pub(crate) use apple::{decode, encode};
304
305#[cfg(all(test, target_os = "macos"))]
306mod tests {
307    use super::*;
308
309    fn png(alpha: u8) -> Vec<u8> {
310        let mut bytes = Vec::new();
311        {
312            let mut encoder = png::Encoder::new(&mut bytes, 128, 128);
313            encoder.set_color(png::ColorType::Rgba);
314            encoder.set_depth(png::BitDepth::Eight);
315            let mut data = Vec::new();
316            for i in 0..128 * 128 {
317                data.extend_from_slice(&[
318                    (i % 256) as u8,
319                    100,
320                    75,
321                    if i % 2 == 0 { alpha } else { 255 },
322                ]);
323            }
324            encoder
325                .write_header()
326                .unwrap()
327                .write_image_data(&data)
328                .unwrap();
329        }
330        bytes
331    }
332
333    #[test]
334    fn opaque_alpha_channel_is_not_transparency() {
335        let decoded = decode(&png(255)).unwrap();
336        assert!(!decoded.info.has_transparent_pixels);
337        assert_eq!(decoded.info.transparent_pixels, 0);
338    }
339
340    #[test]
341    fn sixteen_bit_near_opaque_alpha_is_still_transparency() {
342        let mut bytes = Vec::new();
343        {
344            let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
345            encoder.set_color(png::ColorType::Rgba);
346            encoder.set_depth(png::BitDepth::Sixteen);
347            encoder
348                .write_header()
349                .unwrap()
350                .write_image_data(&[0, 0, 0, 0, 0, 0, 255, 254])
351                .unwrap();
352        }
353        let image = decode(&bytes).unwrap();
354        assert!(image.info.has_transparent_pixels);
355        assert_eq!(image.info.transparent_pixels, 1);
356    }
357
358    #[test]
359    fn transparent_heic_retains_alpha_and_can_be_decoded() {
360        let bytes = png(128);
361        let original = decode(&bytes).unwrap();
362        assert_eq!(original.info.transparent_pixels, 8192);
363        let heic = encode(&bytes, "heic", 85).unwrap();
364        let decoded = decode(&heic).unwrap();
365        assert!(decoded.info.decoder_type.contains("heic"));
366        assert!(decoded.info.has_transparent_pixels);
367        let difference = compare(&original, &decoded).unwrap();
368        assert!(
369            difference.max_alpha_error <= 1.0 / 255.0 + 0.000001,
370            "{difference:?}"
371        );
372    }
373
374    #[test]
375    fn opaque_image_can_compare_jpeg_and_heic() {
376        let bytes = png(255);
377        let original = decode(&bytes).unwrap();
378        for format in ["jpeg", "heic"] {
379            let encoded = encode(&bytes, format, 75).unwrap();
380            let decoded = decode(&encoded).unwrap();
381            let difference = compare(&original, &decoded).unwrap();
382            assert!(difference.rgb_mae_255.is_finite());
383            assert!(difference.max_alpha_error <= 0.000001);
384        }
385    }
386}