1use 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
16const PAD_COLOR: Scalar = Scalar::new(127.0, 127.0, 127.0, 0.0);
18const INFERENCE_BACKGROUND: (u8, u8, u8) = (0xfd, 0xfd, 0xfd);
20const WEBP_MAX_DIMENSION: i32 = 16383;
22const MAX_HEADER_ALLOC: u64 = 16 * 1024 * 1024;
24const WEBP_QUALITY: i32 = 90;
26
27#[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
46fn 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
60fn 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
69fn 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
92fn 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
123fn 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
144fn 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
194fn 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 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
233fn 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
248fn 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 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 let encode_succeeded = imgcodecs::imencode(".webp", &resized, &mut encoded, ¶ms)
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
290fn fit_webp_target_size(width: usize, height: usize) -> Result<(i32, i32), ImageError> {
299 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
318fn 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
340fn 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
396fn 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
411fn 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 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 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
446fn 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}