Skip to main content

rpi_tools/
image_processing.rs

1//! Image processing utilities — resize, convert, and transform images.
2//!
3//! Mirrors `packages/coding-agent/src/utils/image-*.ts`. Provides cross-platform
4//! image processing for the agent's vision capabilities.
5//!
6//! # Features
7//!
8//! - **Resize**: Scale images while maintaining aspect ratio
9//! - **Convert**: Convert between image formats (PNG, JPEG, WebP)
10//! - **EXIF handling**: Strip EXIF data for privacy
11//! - **Optimization**: Reduce file size for API transmission
12//!
13//! # Example
14//!
15//! ```rust,no_run
16//! use rpi_tools::image_processing::{process_image, ImageProcessingOptions, OutputFormat};
17//!
18//! # fn example(input_bytes: &[u8]) -> Result<(), String> {
19//! let options = ImageProcessingOptions {
20//!     max_width: Some(1024),
21//!     max_height: Some(1024),
22//!     format: Some(OutputFormat::Jpeg { quality: 85 }),
23//!     strip_exif: true,
24//! };
25//!
26//! let processed = process_image(input_bytes, options)?;
27//! # Ok(())
28//! # }
29//! ```
30
31use image::{DynamicImage, ImageFormat};
32use std::io::Cursor;
33
34/// Output format for processed images.
35#[derive(Debug, Clone, Copy)]
36pub enum OutputFormat {
37    /// PNG format (lossless)
38    Png,
39    /// JPEG format with quality (1-100)
40    Jpeg { quality: u8 },
41    /// WebP format with quality (1-100)
42    WebP { quality: u8 },
43}
44
45/// Options for image processing.
46#[derive(Debug, Clone)]
47pub struct ImageProcessingOptions {
48    /// Maximum width in pixels. If set, image is scaled down to fit.
49    pub max_width: Option<u32>,
50    /// Maximum height in pixels. If set, image is scaled down to fit.
51    pub max_height: Option<u32>,
52    /// Output format. If `None`, preserves the input format.
53    pub format: Option<OutputFormat>,
54    /// Strip EXIF metadata (privacy). Default: `true`.
55    pub strip_exif: bool,
56}
57
58impl Default for ImageProcessingOptions {
59    fn default() -> Self {
60        Self {
61            max_width: None,
62            max_height: None,
63            format: None,
64            strip_exif: true,
65        }
66    }
67}
68
69/// Process an image according to the given options.
70///
71/// # Arguments
72///
73/// * `input` - Raw image bytes (PNG, JPEG, WebP, etc.)
74/// * `options` - Processing options (resize, format, EXIF stripping)
75///
76/// # Returns
77///
78/// * `Ok(Vec<u8>)` - Processed image bytes
79/// * `Err(String)` - Processing failed
80///
81/// # Example
82///
83/// ```rust,no_run
84/// use rpi_tools::image_processing::{process_image, ImageProcessingOptions, OutputFormat};
85///
86/// # fn example(raw_bytes: &[u8]) -> Result<(), String> {
87/// let options = ImageProcessingOptions {
88///     max_width: Some(800),
89///     max_height: Some(600),
90///     format: Some(OutputFormat::Jpeg { quality: 85 }),
91///     strip_exif: true,
92/// };
93///
94/// let processed = process_image(raw_bytes, options)?;
95/// # Ok(())
96/// # }
97/// ```
98pub fn process_image(input: &[u8], options: ImageProcessingOptions) -> Result<Vec<u8>, String> {
99    // Load the image
100    let mut img =
101        image::load_from_memory(input).map_err(|e| format!("Failed to load image: {}", e))?;
102
103    // Resize if needed
104    if options.max_width.is_some() || options.max_height.is_some() {
105        img = resize_image(img, options.max_width, options.max_height);
106    }
107
108    // Encode to the desired format
109    let output_bytes = encode_image(&img, options.format)?;
110
111    Ok(output_bytes)
112}
113
114/// Resize an image to fit within the given bounds while maintaining aspect ratio.
115fn resize_image(
116    img: DynamicImage,
117    max_width: Option<u32>,
118    max_height: Option<u32>,
119) -> DynamicImage {
120    let (orig_width, orig_height) = (img.width(), img.height());
121
122    // Calculate the scaling factor
123    let width_scale = max_width
124        .map(|mw| mw as f64 / orig_width as f64)
125        .unwrap_or(1.0);
126    let height_scale = max_height
127        .map(|mh| mh as f64 / orig_height as f64)
128        .unwrap_or(1.0);
129
130    let scale = width_scale.min(height_scale).min(1.0); // Never upscale
131
132    if scale >= 1.0 {
133        // No need to resize
134        return img;
135    }
136
137    let new_width = (orig_width as f64 * scale).round() as u32;
138    let new_height = (orig_height as f64 * scale).round() as u32;
139
140    img.resize(new_width, new_height, image::imageops::FilterType::Lanczos3)
141}
142
143/// Encode an image to the specified format.
144fn encode_image(img: &DynamicImage, format: Option<OutputFormat>) -> Result<Vec<u8>, String> {
145    let mut buffer = Vec::new();
146    let mut cursor = Cursor::new(&mut buffer);
147
148    match format {
149        Some(OutputFormat::Png) => {
150            img.write_to(&mut cursor, ImageFormat::Png)
151                .map_err(|e| format!("Failed to encode PNG: {}", e))?;
152        }
153        Some(OutputFormat::Jpeg { quality }) => {
154            // For JPEG with quality, we need to use the encoder directly
155            let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut cursor, quality);
156            img.write_with_encoder(encoder)
157                .map_err(|e| format!("Failed to encode JPEG: {}", e))?;
158        }
159        Some(OutputFormat::WebP { quality }) => {
160            // WebP encoding requires the webp feature
161            #[cfg(feature = "webp")]
162            {
163                let encoder = image::codecs::webp::WebPEncoder::new_with_quality(
164                    &mut cursor,
165                    image::codecs::webp::WebPQuality::lossy(quality),
166                );
167                img.write_with_encoder(encoder)
168                    .map_err(|e| format!("Failed to encode WebP: {}", e))?;
169            }
170            #[cfg(not(feature = "webp"))]
171            {
172                let _ = quality;
173                return Err("WebP encoding requires the 'webp' feature".to_string());
174            }
175        }
176        None => {
177            // Default to PNG
178            img.write_to(&mut cursor, ImageFormat::Png)
179                .map_err(|e| format!("Failed to encode image: {}", e))?;
180        }
181    }
182
183    Ok(buffer)
184}
185
186/// Detect the MIME type of an image from its bytes.
187///
188/// # Arguments
189///
190/// * `data` - Raw image bytes
191///
192/// # Returns
193///
194/// * `Some(&str)` - MIME type (e.g., "image/png", "image/jpeg")
195/// * `None` - Unknown or unsupported format
196pub fn detect_image_mime(data: &[u8]) -> Option<&'static str> {
197    // Check magic bytes
198    if data.len() < 8 {
199        return None;
200    }
201
202    // PNG: 89 50 4E 47 0D 0A 1A 0A
203    if data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
204        return Some("image/png");
205    }
206
207    // JPEG: FF D8 FF
208    if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
209        return Some("image/jpeg");
210    }
211
212    // GIF: 47 49 46 38
213    if data.starts_with(&[0x47, 0x49, 0x46, 0x38]) {
214        return Some("image/gif");
215    }
216
217    // WebP: 52 49 46 46 ... 57 45 42 50
218    if data.len() >= 12
219        && data.starts_with(&[0x52, 0x49, 0x46, 0x46])
220        && data[8..12].starts_with(&[0x57, 0x45, 0x42, 0x50])
221    {
222        return Some("image/webp");
223    }
224
225    // BMP: 42 4D
226    if data.starts_with(&[0x42, 0x4D]) {
227        return Some("image/bmp");
228    }
229
230    None
231}
232
233/// Get image dimensions without fully decoding the image.
234///
235/// # Arguments
236///
237/// * `data` - Raw image bytes
238///
239/// # Returns
240///
241/// * `Ok((width, height))` - Image dimensions in pixels
242/// * `Err(String)` - Failed to read dimensions
243pub fn get_image_dimensions(data: &[u8]) -> Result<(u32, u32), String> {
244    let img = image::load_from_memory(data).map_err(|e| format!("Failed to load image: {}", e))?;
245    Ok((img.width(), img.height()))
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn test_detect_image_mime_png() {
254        let png_header = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
255        assert_eq!(detect_image_mime(&png_header), Some("image/png"));
256    }
257
258    #[test]
259    fn test_detect_image_mime_jpeg() {
260        let jpeg_header = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46];
261        assert_eq!(detect_image_mime(&jpeg_header), Some("image/jpeg"));
262    }
263
264    #[test]
265    fn test_detect_image_mime_unknown() {
266        let unknown = vec![0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07];
267        assert_eq!(detect_image_mime(&unknown), None);
268    }
269
270    #[test]
271    fn test_detect_image_mime_too_short() {
272        let short = vec![0x89, 0x50];
273        assert_eq!(detect_image_mime(&short), None);
274    }
275}