Skip to main content

deepseek_recipe_image/
opencv.rs

1//! The default image preprocessor for DeepSeek V4.1.
2
3use std::io::Cursor;
4
5use deepseek_recipe_core::multimodal::{ImageInfo, ImageMediaType, ImageTokenSpec};
6use image::{ImageDecoder, Limits};
7use opencv::core::{Mat, MatTraitConst, Scalar, Vector, VectorToVec};
8use opencv::imgcodecs;
9use opencv::imgproc;
10use opencv::prelude::*;
11
12use crate::ImagePreprocessor;
13use crate::error::ImageError;
14use crate::limits::PreprocessOptions;
15
16/// Padding color (BGR) matching the model image transform mean of 0.5.
17const PAD_COLOR: Scalar = Scalar::new(127.0, 127.0, 127.0, 0.0);
18/// Background (BGR) applied where an image has transparency: `#FDFDFD`.
19const INFERENCE_BACKGROUND: (u8, u8, u8) = (0xfd, 0xfd, 0xfd);
20/// Maximum size of one dimension of a WebP image.
21const WEBP_MAX_DIMENSION: i32 = 16383;
22/// Largest allocation allowed while reading only an image header.
23const MAX_HEADER_ALLOC: u64 = 16 * 1024 * 1024;
24/// WebP quality of a preprocessed image.
25const WEBP_QUALITY: i32 = 90;
26
27/// Preprocesses images with OpenCV.
28///
29/// Preprocessing decodes the image, applies the low-detail limit, fits the image
30/// into the V4.1 token budget, and encodes it as WebP at quality 90.
31#[derive(Debug, Clone, Copy, Default)]
32pub struct OpenCvImagePreprocessor;
33
34impl ImagePreprocessor for OpenCvImagePreprocessor {
35    async fn preprocess(
36        &self,
37        data: Vec<u8>,
38        options: PreprocessOptions,
39    ) -> Result<ImageInfo, ImageError> {
40        tokio::task::spawn_blocking(move || preprocess(&data, options))
41            .await
42            .map_err(|err| ImageError::Decode(err.to_string()))?
43    }
44}
45
46/// Preprocess one encoded image.
47fn preprocess(data: &[u8], options: PreprocessOptions) -> Result<ImageInfo, ImageError> {
48    if data.is_empty() {
49        return Err(ImageError::EmptyImage);
50    }
51    let media_type = detect_media_type(data)?;
52    check_dimensions(data, options.max_dimension_px)?;
53    let mut mat = decode_image_mat(data, media_type)?;
54    if options.detail.is_low() {
55        mat = limit_image_mat(mat, options.low_detail_max_dimension_px)?;
56    }
57    preprocess_mat(mat, options.max_dimension_px)
58}
59
60/// Return the media type of the image bytes, read from the file header.
61fn detect_media_type(data: &[u8]) -> Result<ImageMediaType, ImageError> {
62    let kind = infer::get(data)
63        .ok_or_else(|| ImageError::UnsupportedMediaType("unknown media type".to_string()))?;
64    let mime = kind.mime_type();
65    ImageMediaType::from_mime(mime)
66        .ok_or_else(|| ImageError::UnsupportedMediaType(mime.to_string()))
67}
68
69/// Reject an image whose declared dimensions exceed `max_dimension`, reading the
70/// file header only.
71fn check_dimensions(data: &[u8], max_dimension: u32) -> Result<(), ImageError> {
72    let mut reader = image::ImageReader::new(Cursor::new(data))
73        .with_guessed_format()
74        .map_err(|err| ImageError::Decode(err.to_string()))?;
75    let mut limits = Limits::default();
76    limits.max_image_width = Some(max_dimension);
77    limits.max_image_height = Some(max_dimension);
78    limits.max_alloc = Some(MAX_HEADER_ALLOC);
79    reader.limits(limits);
80
81    let decoder = match reader.into_decoder() {
82        Ok(decoder) => decoder,
83        Err(err) => return check_dimensions_without_limits(err, data, max_dimension),
84    };
85    let (width, height) = decoder.dimensions();
86    if width > max_dimension || height > max_dimension {
87        return Err(ImageError::ImageDimensionsTooLarge);
88    }
89    Ok(())
90}
91
92/// Handle a header read that failed under the configured limits.
93///
94/// Decoders that do not support limits report the limit as unsupported. Their
95/// dimensions are read directly instead.
96fn check_dimensions_without_limits(
97    err: image::ImageError,
98    data: &[u8],
99    max_dimension: u32,
100) -> Result<(), ImageError> {
101    let image::ImageError::Limits(limit_error) = &err else {
102        return Err(ImageError::Decode(err.to_string()));
103    };
104    match limit_error.kind() {
105        image::error::LimitErrorKind::DimensionError => Err(ImageError::ImageDimensionsTooLarge),
106        image::error::LimitErrorKind::Unsupported { .. } => {
107            let reader = image::ImageReader::new(Cursor::new(data))
108                .with_guessed_format()
109                .map_err(|err| ImageError::Decode(err.to_string()))?;
110            let decoder = reader
111                .into_decoder()
112                .map_err(|err| ImageError::Decode(err.to_string()))?;
113            let (width, height) = decoder.dimensions();
114            if width > max_dimension || height > max_dimension {
115                return Err(ImageError::ImageDimensionsTooLarge);
116            }
117            Ok(())
118        }
119        _ => Err(ImageError::Decode(err.to_string())),
120    }
121}
122
123/// Decode one image into an 8-bit BGR matrix.
124///
125/// GIF images are decoded with the `image` crate because OpenCV does not support
126/// them. Images with transparency are blended onto the inference background.
127fn decode_image_mat(data: &[u8], media_type: ImageMediaType) -> Result<Mat, ImageError> {
128    let mat = if media_type == ImageMediaType::Gif {
129        gif_to_mat(data)?
130    } else {
131        imgcodecs::imdecode(&Vector::from_slice(data), imgcodecs::IMREAD_UNCHANGED)
132            .map_err(|err| ImageError::Decode(err.to_string()))?
133    };
134
135    let size = mat
136        .size()
137        .map_err(|err| ImageError::Decode(err.to_string()))?;
138    if size.width == 0 || size.height == 0 {
139        return Err(ImageError::EmptyImage);
140    }
141    normalize_decoded_mat(mat)
142}
143
144/// Reduce a decoded matrix to 8-bit BGR.
145fn normalize_decoded_mat(mat: Mat) -> Result<Mat, ImageError> {
146    let mat = if mat.depth() == opencv::core::CV_16U {
147        let mut mat8 = Mat::default();
148        mat.convert_to(&mut mat8, opencv::core::CV_8U, 1.0 / 257.0, 0.0)
149            .map_err(|err| ImageError::Decode(err.to_string()))?;
150        mat8
151    } else {
152        mat
153    };
154
155    match mat.channels() {
156        3 => Ok(mat),
157        4 => alpha_blend(&mat),
158        1 => {
159            let mut channels = Vector::<Mat>::new();
160            channels.push(mat.clone());
161            channels.push(mat.clone());
162            channels.push(mat);
163            let mut bgr = Mat::default();
164            opencv::core::merge(&channels, &mut bgr)
165                .map_err(|err| ImageError::Decode(err.to_string()))?;
166            Ok(bgr)
167        }
168        2 => {
169            let mut channels = Vector::<Mat>::new();
170            opencv::core::split(&mat, &mut channels)
171                .map_err(|err| ImageError::Decode(err.to_string()))?;
172            let gray = channels
173                .get(0)
174                .map_err(|err| ImageError::Decode(err.to_string()))?;
175            let alpha = channels
176                .get(1)
177                .map_err(|err| ImageError::Decode(err.to_string()))?;
178            let mut bgra_channels = Vector::<Mat>::new();
179            bgra_channels.push(gray.clone());
180            bgra_channels.push(gray.clone());
181            bgra_channels.push(gray);
182            bgra_channels.push(alpha);
183            let mut bgra = Mat::default();
184            opencv::core::merge(&bgra_channels, &mut bgra)
185                .map_err(|err| ImageError::Decode(err.to_string()))?;
186            alpha_blend(&bgra)
187        }
188        channels => Err(ImageError::Decode(format!(
189            "unsupported image channel count: {channels}"
190        ))),
191    }
192}
193
194/// Blend a 4-channel BGRA matrix onto the inference background.
195fn alpha_blend(bgra: &Mat) -> Result<Mat, ImageError> {
196    if !bgra.is_continuous() {
197        return Err(ImageError::Decode(
198            "image matrix is not continuous".to_string(),
199        ));
200    }
201    let (rows, cols) = (bgra.rows(), bgra.cols());
202    // SAFETY: the allocation is owned by the returned matrix and is fully
203    // written by the loop below.
204    let mut blended = unsafe { Mat::new_rows_cols(rows, cols, opencv::core::CV_8UC3) }
205        .map_err(|err| ImageError::Decode(err.to_string()))?;
206
207    let source = bgra
208        .data_bytes()
209        .map_err(|err| ImageError::Decode(err.to_string()))?;
210    let target = blended
211        .data_bytes_mut()
212        .map_err(|err| ImageError::Decode(err.to_string()))?;
213    let (background_b, background_g, background_r) = INFERENCE_BACKGROUND;
214    let (background_b, background_g, background_r) = (
215        u32::from(background_b),
216        u32::from(background_g),
217        u32::from(background_r),
218    );
219
220    for (source_pixel, target_pixel) in source.chunks_exact(4).zip(target.chunks_exact_mut(3)) {
221        let alpha = u32::from(source_pixel[3]);
222        let inverse_alpha = 255 - alpha;
223        target_pixel[0] =
224            ((u32::from(source_pixel[0]) * alpha + background_b * inverse_alpha + 127) / 255) as u8;
225        target_pixel[1] =
226            ((u32::from(source_pixel[1]) * alpha + background_g * inverse_alpha + 127) / 255) as u8;
227        target_pixel[2] =
228            ((u32::from(source_pixel[2]) * alpha + background_r * inverse_alpha + 127) / 255) as u8;
229    }
230    Ok(blended)
231}
232
233/// Scale an image down so that its long side is at most `max_dimension`.
234fn limit_image_mat(mat: Mat, max_dimension: u32) -> Result<Mat, ImageError> {
235    let size = mat
236        .size()
237        .map_err(|err| ImageError::Resize(err.to_string()))?;
238    let long_side = size.width.max(size.height);
239    if long_side <= max_dimension as i32 {
240        return Ok(mat);
241    }
242    let ratio = max_dimension as f64 / long_side as f64;
243    let width = ((size.width as f64 * ratio).round() as i32).max(1);
244    let height = ((size.height as f64 * ratio).round() as i32).max(1);
245    direct_resize(&mat, width, height)
246}
247
248/// Fit an image into the V4.1 token budget and encode it as WebP.
249fn preprocess_mat(mat: Mat, max_dimension_px: u32) -> Result<ImageInfo, ImageError> {
250    let size = mat
251        .size()
252        .map_err(|err| ImageError::Resize(err.to_string()))?;
253    // `check_dimensions` reads the size the header declares. The decoded matrix
254    // is the image the request carries, so it is checked against the limit too.
255    let max_dimension = max_dimension_px as i32;
256    if size.width > max_dimension || size.height > max_dimension {
257        return Err(ImageError::ImageDimensionsTooLarge);
258    }
259    let (best_width, best_height) =
260        fit_webp_target_size(size.width as usize, size.height as usize)?;
261    if best_width <= 0 || best_height <= 0 {
262        return Err(ImageError::Resize(format!(
263            "invalid target size: {best_width}x{best_height}"
264        )));
265    }
266
267    let resized = if size.width != best_width || size.height != best_height {
268        resize_to_best(&mat, size.width, size.height, best_width, best_height)
269            .or_else(|_| direct_resize(&mat, best_width, best_height))?
270    } else {
271        mat
272    };
273
274    let mut encoded = Vector::new();
275    let params = Vector::from_slice(&[imgcodecs::IMWRITE_WEBP_QUALITY, WEBP_QUALITY]);
276    // A false return value reports an encoding failure, leaving the output
277    // buffer without an encoded image.
278    let encode_succeeded = imgcodecs::imencode(".webp", &resized, &mut encoded, &params)
279        .map_err(|err| ImageError::Encode(err.to_string()))?;
280    if !encode_succeeded {
281        return Err(ImageError::EncodeFailed);
282    }
283    Ok(ImageInfo {
284        data: encoded.to_vec(),
285        width: best_width as u32,
286        height: best_height as u32,
287    })
288}
289
290/// Return the V4.1 target size of an image of `width`×`height` pixels that WebP
291/// can encode.
292///
293/// [`ImageTokenSpec::v41`] fits the token budget without a dimension limit, and
294/// an elongated image can require a side above [`WEBP_MAX_DIMENSION`]. A size
295/// outside the limit is scaled into it and placed on the patch grid, and fitting
296/// is repeated for that size, so the returned size is both a size the
297/// specification leaves unchanged and a size WebP encodes.
298fn fit_webp_target_size(width: usize, height: usize) -> Result<(i32, i32), ImageError> {
299    // The specification scales a size below its minimum area back up, so the
300    // limit is reached after a few rounds that each raise the short side.
301    const MAX_FIT_ROUNDS: usize = 6;
302
303    let spec = ImageTokenSpec::v41();
304    let limit = WEBP_MAX_DIMENSION as usize;
305    let mut source = (width, height);
306    for _ in 0..MAX_FIT_ROUNDS {
307        let fitted = spec.calc_resize(source.0, source.1)?;
308        if fitted.best_width <= limit && fitted.best_height <= limit {
309            return Ok((fitted.best_width as i32, fitted.best_height as i32));
310        }
311        source = scale_into_webp_limit(spec.patch_size(), fitted.best_width, fitted.best_height);
312    }
313    Err(ImageError::Resize(format!(
314        "no V4.1 target size within {WEBP_MAX_DIMENSION} pixels for an image of {width}x{height} pixels"
315    )))
316}
317
318/// Scale a size into the WebP dimension limit.
319///
320/// The long side is placed on the patch grid at or below the limit, and the
321/// short side is placed on the patch grid at or above its scaled value. Raising
322/// the short side keeps the scaled size at or above the minimum area of the
323/// specification, which otherwise scales the long side past the limit again.
324fn scale_into_webp_limit(patch_size: usize, width: usize, height: usize) -> (usize, usize) {
325    let limit = WEBP_MAX_DIMENSION as usize;
326    let long_side = width.max(height);
327    if long_side <= limit {
328        return (width, height);
329    }
330    let scaled_long = limit / patch_size * patch_size;
331    let scaled_short = (width.min(height) as f64 * scaled_long as f64 / long_side as f64).round();
332    let short_side = (scaled_short as usize).max(1).div_ceil(patch_size) * patch_size;
333    if width >= height {
334        (scaled_long, short_side)
335    } else {
336        (short_side, scaled_long)
337    }
338}
339
340/// Scale an image to fit `best_width`×`best_height` while preserving its aspect
341/// ratio, and center it on a background of [`PAD_COLOR`].
342fn resize_to_best(
343    mat: &Mat,
344    source_width: i32,
345    source_height: i32,
346    best_width: i32,
347    best_height: i32,
348) -> Result<Mat, ImageError> {
349    let image_ratio = source_width as f64 / source_height as f64;
350    let target_ratio = best_width as f64 / best_height as f64;
351    let (width, height) = if image_ratio > target_ratio {
352        (
353            best_width,
354            round_half_even(source_height as f64 / source_width as f64 * best_width as f64).max(1),
355        )
356    } else if image_ratio < target_ratio {
357        (
358            round_half_even(source_width as f64 / source_height as f64 * best_height as f64).max(1),
359            best_height,
360        )
361    } else {
362        (best_width, best_height)
363    };
364
365    let mut scaled = Mat::default();
366    imgproc::resize(
367        mat,
368        &mut scaled,
369        opencv::core::Size::new(width, height),
370        0.0,
371        0.0,
372        imgproc::INTER_CUBIC,
373    )
374    .map_err(|err| ImageError::Resize(err.to_string()))?;
375
376    let left = round_half_even((best_width - width) as f64 * 0.5);
377    let top = round_half_even((best_height - height) as f64 * 0.5);
378    let right = best_width - width - left;
379    let bottom = best_height - height - top;
380
381    let mut padded = Mat::default();
382    opencv::core::copy_make_border(
383        &scaled,
384        &mut padded,
385        top,
386        bottom,
387        left,
388        right,
389        opencv::core::BORDER_CONSTANT,
390        PAD_COLOR,
391    )
392    .map_err(|err| ImageError::Resize(err.to_string()))?;
393    Ok(padded)
394}
395
396/// Scale an image directly to `width`×`height`.
397fn direct_resize(mat: &Mat, width: i32, height: i32) -> Result<Mat, ImageError> {
398    let mut resized = Mat::default();
399    imgproc::resize(
400        mat,
401        &mut resized,
402        opencv::core::Size::new(width, height),
403        0.0,
404        0.0,
405        imgproc::INTER_CUBIC,
406    )
407    .map_err(|err| ImageError::Resize(err.to_string()))?;
408    Ok(resized)
409}
410
411/// Decode the first frame of a GIF into a 4-channel BGRA matrix.
412fn gif_to_mat(data: &[u8]) -> Result<Mat, ImageError> {
413    let reader = image::ImageReader::new(Cursor::new(data))
414        .with_guessed_format()
415        .map_err(|err| ImageError::Decode(err.to_string()))?;
416    let format = reader
417        .format()
418        .ok_or_else(|| ImageError::Decode("unknown image format".to_string()))?;
419    if format != image::ImageFormat::Gif {
420        return Err(ImageError::Decode(format!("expected GIF, got {format:?}")));
421    }
422    let frame = reader
423        .decode()
424        .map_err(|err| ImageError::Decode(err.to_string()))?;
425    let (width, height) = (frame.width(), frame.height());
426    if width == 0 || height == 0 {
427        return Err(ImageError::EmptyImage);
428    }
429
430    // OpenCV expects BGRA; the `image` crate decodes to RGBA.
431    let mut bgra = frame.to_rgba8().into_raw();
432    for pixel in bgra.chunks_exact_mut(4) {
433        pixel.swap(0, 2);
434    }
435
436    // SAFETY: the allocation is owned by the returned matrix and is fully
437    // written from `bgra` immediately below.
438    let mut mat = unsafe { Mat::new_rows_cols(height as i32, width as i32, opencv::core::CV_8UC4) }
439        .map_err(|err| ImageError::Decode(err.to_string()))?;
440    mat.data_bytes_mut()
441        .map_err(|err| ImageError::Decode(err.to_string()))?
442        .copy_from_slice(&bgra);
443    Ok(mat)
444}
445
446/// Round to the nearest integer, with a value exactly between two integers
447/// rounding to the even one.
448fn round_half_even(value: f64) -> i32 {
449    let floor = value.floor();
450    let fraction = value - floor;
451    if fraction < 0.5 || (fraction == 0.5 && (floor as i64) % 2 == 0) {
452        floor as i32
453    } else {
454        (floor + 1.0) as i32
455    }
456}