1use std::io::Cursor;
2
3use base64::{Engine, engine::general_purpose::STANDARD as B64};
4use chrono::Utc;
5use image::{DynamicImage, ImageBuffer, Luma, imageops::FilterType};
6use rusqlite::{Connection, params};
7use std::path::PathBuf;
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum OutputFormat {
13 #[default]
15 Jpeg,
16 WebP,
18 Avif,
20}
21
22#[derive(Clone, Debug)]
24pub struct ProcessConfig {
25 pub quality: u8,
27 pub tile_size: u32,
29 pub crop: bool,
31 pub bg_tolerance: u8,
33 pub output_format: OutputFormat,
35 pub target_model: Option<VisionModel>,
37 pub max_tiles: Option<u32>,
39 pub smart_crop: bool,
41}
42
43impl Default for ProcessConfig {
44 fn default() -> Self {
45 Self {
46 quality: 75,
47 tile_size: 512,
48 crop: true,
49 bg_tolerance: 15,
50 output_format: OutputFormat::Jpeg,
51 target_model: None,
52 max_tiles: None,
53 smart_crop: false,
54 }
55 }
56}
57
58impl ProcessConfig {
59 pub fn builder() -> ProcessConfigBuilder {
60 ProcessConfigBuilder(Self::default())
61 }
62}
63
64pub struct ProcessConfigBuilder(ProcessConfig);
65
66impl ProcessConfigBuilder {
67 pub fn quality(mut self, q: u8) -> Self {
68 self.0.quality = q.clamp(1, 100);
69 self
70 }
71 pub fn tile_size(mut self, t: u32) -> Self {
72 self.0.tile_size = t.max(1);
73 self
74 }
75 pub fn crop(mut self, c: bool) -> Self {
76 self.0.crop = c;
77 self
78 }
79 pub fn bg_tolerance(mut self, t: u8) -> Self {
80 self.0.bg_tolerance = t;
81 self
82 }
83 pub fn output_format(mut self, f: OutputFormat) -> Self {
84 self.0.output_format = f;
85 self
86 }
87 pub fn target_model(mut self, m: VisionModel) -> Self {
88 self.0.target_model = Some(m);
89 self
90 }
91 pub fn max_tiles(mut self, m: u32) -> Self {
92 self.0.max_tiles = Some(m);
93 self
94 }
95 pub fn smart_crop(mut self, b: bool) -> Self {
96 self.0.smart_crop = b;
97 self
98 }
99 pub fn build(self) -> ProcessConfig {
100 self.0
101 }
102}
103
104#[derive(Clone, Copy, Debug)]
108pub enum VisionModel {
109 Claude,
111 Gpt4o,
113 Gpt5,
115 Gemini15,
117}
118
119#[derive(Debug)]
120pub struct TokenEstimate {
121 pub model: VisionModel,
122 pub tokens: u32,
123 pub tiles: u32,
124}
125
126pub fn estimate_tokens(width: u32, height: u32, model: VisionModel) -> TokenEstimate {
128 match model {
129 VisionModel::Claude => {
130 let tokens = ((width as u64 * height as u64) / 750) as u32;
132 TokenEstimate {
133 model,
134 tiles: 1,
135 tokens: tokens.max(85),
136 }
137 }
138 VisionModel::Gpt4o => {
139 let (mut w, mut h) = fit_within(width, height, 2048);
141 let short_side = w.min(h);
142 if short_side > 768 {
143 let scale = 768.0 / short_side as f64;
144 w = (w as f64 * scale).round() as u32;
145 h = (h as f64 * scale).round() as u32;
146 }
147 let tiles = tile_count(w, 512) * tile_count(h, 512);
148 TokenEstimate {
149 model,
150 tiles,
151 tokens: 85 + tiles * 170,
152 }
153 }
154 VisionModel::Gpt5 => {
155 let (w, h) = fit_within_pixels(width, height, 6000, 10_240_000);
156 let tiles = tile_count(w, 512) * tile_count(h, 512);
157 TokenEstimate {
158 model,
159 tiles,
160 tokens: (85 + tiles * 170).min(1536),
161 }
162 }
163 VisionModel::Gemini15 => {
164 if width <= 384 && height <= 384 {
166 TokenEstimate {
167 model,
168 tiles: 1,
169 tokens: 258,
170 }
171 } else {
172 let tiles = tile_count(width, 768) * tile_count(height, 768);
173 TokenEstimate {
174 model,
175 tiles,
176 tokens: tiles * 258,
177 }
178 }
179 }
180 }
181}
182
183pub fn fit_within(width: u32, height: u32, max_side: u32) -> (u32, u32) {
185 if width <= max_side && height <= max_side {
186 return (width, height);
187 }
188 let scale = max_side as f64 / width.max(height) as f64;
189 (
190 (width as f64 * scale) as u32,
191 (height as f64 * scale) as u32,
192 )
193}
194
195pub fn fit_within_pixels(width: u32, height: u32, max_side: u32, max_pixels: u64) -> (u32, u32) {
197 let (mut w, mut h) = fit_within(width, height, max_side);
198 let total = w as u64 * h as u64;
199 if total > max_pixels {
200 let scale = (max_pixels as f64 / total as f64).sqrt();
201 w = (w as f64 * scale) as u32;
202 h = (h as f64 * scale) as u32;
203 }
204 (w.max(1), h.max(1))
205}
206
207pub fn optimal_send_dimensions(width: u32, height: u32, model: VisionModel) -> (u32, u32) {
213 match model {
214 VisionModel::Claude => {
215 (
218 snap_to_tile_boundary(width, 256),
219 snap_to_tile_boundary(height, 256),
220 )
221 }
222 VisionModel::Gpt4o => optimal_for_prescaling_model(width, height, 2048, 512),
223 VisionModel::Gpt5 => {
224 let (fw, fh) = fit_within_pixels(width, height, 6000, 10_240_000);
225 (
226 snap_to_tile_boundary(fw, 512).max(512),
227 snap_to_tile_boundary(fh, 512).max(512),
228 )
229 }
230 VisionModel::Gemini15 => {
231 if width <= 384 && height <= 384 {
233 (width, height)
234 } else {
235 optimal_for_prescaling_model(width, height, 4096, 768)
236 }
237 }
238 }
239}
240
241fn optimal_for_prescaling_model(width: u32, height: u32, max_side: u32, tile: u32) -> (u32, u32) {
249 let (fw, fh) = fit_within(width, height, max_side);
250
251 let target_w = snap_to_tile_boundary(fw, tile).max(tile);
253 let target_h = snap_to_tile_boundary(fh, tile).max(tile);
254
255 if width > max_side || height > max_side {
257 let scale = width.max(height) as f64 / max_side as f64;
258 let opt_w = (target_w as f64 * scale).round() as u32;
259 let opt_h = (target_h as f64 * scale).round() as u32;
260 return (opt_w.max(1), opt_h.max(1));
261 }
262
263 (target_w, target_h)
264}
265
266pub struct TokenSavingsTable {
268 pub claude_before: TokenEstimate,
269 pub claude_after: TokenEstimate,
270 pub gpt4o_before: TokenEstimate,
271 pub gpt4o_after: TokenEstimate,
272 pub gpt5_before: TokenEstimate,
273 pub gpt5_after: TokenEstimate,
274 pub gemini_before: TokenEstimate,
275 pub gemini_after: TokenEstimate,
276}
277
278pub fn token_savings_table(orig_w: u32, orig_h: u32, opt_w: u32, opt_h: u32) -> TokenSavingsTable {
279 TokenSavingsTable {
280 claude_before: estimate_tokens(orig_w, orig_h, VisionModel::Claude),
281 claude_after: estimate_tokens(opt_w, opt_h, VisionModel::Claude),
282 gpt4o_before: estimate_tokens(orig_w, orig_h, VisionModel::Gpt4o),
283 gpt4o_after: estimate_tokens(opt_w, opt_h, VisionModel::Gpt4o),
284 gpt5_before: estimate_tokens(orig_w, orig_h, VisionModel::Gpt5),
285 gpt5_after: estimate_tokens(opt_w, opt_h, VisionModel::Gpt5),
286 gemini_before: estimate_tokens(orig_w, orig_h, VisionModel::Gemini15),
287 gemini_after: estimate_tokens(opt_w, opt_h, VisionModel::Gemini15),
288 }
289}
290
291impl TokenSavingsTable {
292 pub fn print(&self) {
293 println!(
294 "{:<12} {:>8} {:>8} {:>10}",
295 "Model", "Before", "After", "Saved"
296 );
297 println!("{}", "-".repeat(42));
298 self.print_row("Claude", &self.claude_before, &self.claude_after);
299 self.print_row("GPT-4o", &self.gpt4o_before, &self.gpt4o_after);
300 self.print_row("GPT-5", &self.gpt5_before, &self.gpt5_after);
301 self.print_row("Gemini", &self.gemini_before, &self.gemini_after);
302 }
303
304 fn print_row(&self, name: &str, before: &TokenEstimate, after: &TokenEstimate) {
305 let saved = before.tokens.saturating_sub(after.tokens);
306 let pct = if before.tokens > 0 {
307 saved as f64 / before.tokens as f64 * 100.0
308 } else {
309 0.0
310 };
311 println!(
312 "{:<12} {:>8} {:>8} {:>8} ({:.1}%)",
313 name, before.tokens, after.tokens, saved, pct
314 );
315 }
316}
317
318pub struct DimensionResult {
321 pub width: u32,
322 pub height: u32,
323 pub tiles_before: u32,
324 pub tiles_after: u32,
325}
326
327impl DimensionResult {
328 pub fn tokens_saved(&self) -> u32 {
329 self.tiles_before.saturating_sub(self.tiles_after)
330 }
331}
332
333#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
334pub enum ProcessMode {
335 Standard,
337 Ocr,
339 #[default]
341 Auto,
342}
343
344pub fn detect_ocr_mode(img: &DynamicImage) -> bool {
345 let rgb = img.to_rgb8();
346 let mut colorful_count = 0;
347 let mut total_count = 0;
348 for (x, y, p) in rgb.enumerate_pixels() {
350 if x % 4 == 0 && y % 4 == 0 {
351 total_count += 1;
352 let min = p[0].min(p[1]).min(p[2]);
353 let max = p[0].max(p[1]).max(p[2]);
354 if max.saturating_sub(min) > 25 {
355 colorful_count += 1;
356 }
357 }
358 }
359 let colorful_ratio = colorful_count as f64 / total_count.max(1) as f64;
360 colorful_ratio < 0.1 }
362
363pub struct SavingsReport {
364 pub tiles_before: u32,
365 pub tiles_after: u32,
366 pub tiles_saved: u32,
367 pub bytes_before: Option<u64>,
368 pub bytes_after: Option<u64>,
369}
370
371impl SavingsReport {
372 pub fn size_reduction_pct(&self) -> Option<f64> {
373 match (self.bytes_before, self.bytes_after) {
374 (Some(b), Some(a)) if b > 0 => Some((1.0 - a as f64 / b as f64) * 100.0),
375 _ => None,
376 }
377 }
378
379 pub fn token_reduction_pct(&self) -> f64 {
380 if self.tiles_before == 0 {
381 return 0.0;
382 }
383 self.tiles_saved as f64 / self.tiles_before as f64 * 100.0
384 }
385}
386
387pub struct ProcessResult {
388 pub image: DynamicImage,
389 pub width: u32,
390 pub height: u32,
391 pub report: SavingsReport,
392}
393
394impl ProcessResult {
395 pub fn tokens_saved(&self) -> u32 {
396 self.report.tiles_saved
397 }
398}
399
400pub fn process(
405 img: DynamicImage,
406 mode: ProcessMode,
407 input_bytes: u64,
408 cfg: &ProcessConfig,
409) -> ProcessResult {
410 let (orig_w, orig_h) = (img.width(), img.height());
411 let tiles_before = match cfg.target_model {
412 Some(model) => estimate_tokens(orig_w, orig_h, model).tiles,
413 None => tile_count(orig_w, cfg.tile_size) * tile_count(orig_h, cfg.tile_size),
414 };
415
416 let after_crop = if cfg.crop {
417 if cfg.smart_crop {
418 saliency_crop(&img, 16)
419 } else {
420 crop_padding(img, cfg.bg_tolerance)
421 }
422 } else {
423 img
424 };
425 let (mut opt_w, mut opt_h) = match cfg.target_model {
426 Some(model) => optimal_send_dimensions(after_crop.width(), after_crop.height(), model),
427 None => {
428 let d = calculate_optimal_dimensions_with(
429 after_crop.width(),
430 after_crop.height(),
431 cfg.tile_size,
432 );
433 (d.width, d.height)
434 }
435 };
436
437 if let Some(max_t) = cfg.max_tiles {
438 let (nw, nh) = enforce_max_tiles(opt_w, opt_h, max_t, cfg.tile_size, cfg.target_model);
439 opt_w = nw;
440 opt_h = nh;
441 }
442
443 let tiles_after = match cfg.target_model {
444 Some(model) => {
445 let est = estimate_tokens(opt_w, opt_h, model);
446 est.tiles
447 }
448 None => tile_count(opt_w, cfg.tile_size) * tile_count(opt_h, cfg.tile_size),
449 };
450 let resized = after_crop.resize_exact(opt_w, opt_h, FilterType::Lanczos3);
451
452 let actual_mode = match mode {
453 ProcessMode::Auto => {
454 if detect_ocr_mode(&after_crop) {
455 ProcessMode::Ocr
456 } else {
457 ProcessMode::Standard
458 }
459 }
460 m => m,
461 };
462
463 let final_image = match actual_mode {
464 ProcessMode::Standard | ProcessMode::Auto => resized,
465 ProcessMode::Ocr => binarize(resized),
466 };
467
468 ProcessResult {
469 width: final_image.width(),
470 height: final_image.height(),
471 image: final_image,
472 report: SavingsReport {
473 tiles_before,
474 tiles_after,
475 tiles_saved: tiles_before.saturating_sub(tiles_after),
476 bytes_before: if input_bytes > 0 {
477 Some(input_bytes)
478 } else {
479 None
480 },
481 bytes_after: None,
482 },
483 }
484}
485
486fn enforce_max_tiles(
487 mut width: u32,
488 mut height: u32,
489 max_tiles: u32,
490 default_tile_size: u32,
491 model: Option<VisionModel>,
492) -> (u32, u32) {
493 if max_tiles == 0 {
494 return (width, height);
495 }
496
497 let mut scale = 1.0;
498 let orig_w = width;
499 let orig_h = height;
500
501 loop {
502 let (snapped_w, snapped_h) = match model {
503 Some(m) => optimal_send_dimensions(width, height, m),
504 None => {
505 let d = calculate_optimal_dimensions_with(width, height, default_tile_size);
506 (d.width, d.height)
507 }
508 };
509
510 let tiles = match model {
511 Some(m) => estimate_tokens(snapped_w, snapped_h, m).tiles,
512 None => {
513 tile_count(snapped_w, default_tile_size) * tile_count(snapped_h, default_tile_size)
514 }
515 };
516
517 if tiles <= max_tiles || scale < 0.1 {
518 return (snapped_w, snapped_h);
519 }
520
521 scale *= 0.95;
522 width = (orig_w as f64 * scale) as u32;
523 height = (orig_h as f64 * scale) as u32;
524 width = width.max(1);
525 height = height.max(1);
526 }
527}
528
529pub fn calculate_optimal_dimensions(width: u32, height: u32) -> DimensionResult {
533 calculate_optimal_dimensions_with(width, height, 512)
534}
535
536pub fn calculate_optimal_dimensions_with(
538 width: u32,
539 height: u32,
540 tile_size: u32,
541) -> DimensionResult {
542 let opt_w = snap_to_tile_boundary(width, tile_size);
543 let opt_h = snap_to_tile_boundary(height, tile_size);
544
545 DimensionResult {
546 width: opt_w,
547 height: opt_h,
548 tiles_before: tile_count(width, tile_size) * tile_count(height, tile_size),
549 tiles_after: tile_count(opt_w, tile_size) * tile_count(opt_h, tile_size),
550 }
551}
552
553fn tile_count(dim: u32, tile_size: u32) -> u32 {
554 dim.div_ceil(tile_size)
555}
556
557fn snap_to_tile_boundary(dim: u32, tile_size: u32) -> u32 {
558 if dim.is_multiple_of(tile_size) {
559 return dim;
560 }
561 ((dim / tile_size) * tile_size).max(tile_size)
562}
563
564pub fn crop_padding(img: DynamicImage, bg_tolerance: u8) -> DynamicImage {
568 let rgba = img.to_rgba8();
569 let (w, h) = rgba.dimensions();
570
571 let corners = [
572 *rgba.get_pixel(0, 0),
573 *rgba.get_pixel(w - 1, 0),
574 *rgba.get_pixel(0, h - 1),
575 *rgba.get_pixel(w - 1, h - 1),
576 ];
577 let bg = corners[0]; let top = first_non_bg_row(&rgba, bg, bg_tolerance, true);
580 let bottom = first_non_bg_row(&rgba, bg, bg_tolerance, false);
581 let left = first_non_bg_col(&rgba, bg, bg_tolerance, true);
582 let right = first_non_bg_col(&rgba, bg, bg_tolerance, false);
583
584 if top >= bottom || left >= right {
585 return DynamicImage::ImageRgba8(rgba);
586 }
587
588 DynamicImage::ImageRgba8(
589 image::imageops::crop_imm(&rgba, left, top, right - left, bottom - top).to_image(),
590 )
591}
592
593fn is_bg(pixel: image::Rgba<u8>, bg: image::Rgba<u8>, tolerance: u8) -> bool {
594 pixel.0[3] < 10
595 || pixel.0[..3]
596 .iter()
597 .zip(bg.0[..3].iter())
598 .all(|(&a, &b)| a.abs_diff(b) <= tolerance)
599}
600
601fn first_non_bg_row(img: &image::RgbaImage, bg: image::Rgba<u8>, tol: u8, from_top: bool) -> u32 {
602 let (w, h) = img.dimensions();
603 let rows: Box<dyn Iterator<Item = u32>> = if from_top {
604 Box::new(0..h)
605 } else {
606 Box::new((0..h).rev())
607 };
608 for y in rows {
609 if (0..w).any(|x| !is_bg(*img.get_pixel(x, y), bg, tol)) {
610 return y;
611 }
612 }
613 0
614}
615
616fn first_non_bg_col(img: &image::RgbaImage, bg: image::Rgba<u8>, tol: u8, from_left: bool) -> u32 {
617 let (w, h) = img.dimensions();
618 let cols: Box<dyn Iterator<Item = u32>> = if from_left {
619 Box::new(0..w)
620 } else {
621 Box::new((0..w).rev())
622 };
623 for x in cols {
624 if (0..h).any(|y| !is_bg(*img.get_pixel(x, y), bg, tol)) {
625 return x;
626 }
627 }
628 0
629}
630
631pub fn saliency_crop(img: &DynamicImage, margin: u32) -> DynamicImage {
640 let gray = img.to_luma8();
641 let (w, h) = gray.dimensions();
642 if w < 3 || h < 3 {
643 return img.clone();
644 }
645
646 let mut energy = vec![0u32; (w * h) as usize];
647 let mut total: u64 = 0;
648 for y in 1..h - 1 {
649 for x in 1..w - 1 {
650 let l = gray.get_pixel(x - 1, y).0[0] as i32;
651 let r = gray.get_pixel(x + 1, y).0[0] as i32;
652 let t = gray.get_pixel(x, y - 1).0[0] as i32;
653 let b = gray.get_pixel(x, y + 1).0[0] as i32;
654 let e = ((r - l).abs() + (b - t).abs()) as u32;
655 energy[(y * w + x) as usize] = e;
656 total += e as u64;
657 }
658 }
659 let count = (w as u64) * (h as u64);
660 let mean = (total / count.max(1)) as u32;
661 let threshold = mean.saturating_mul(2).max(8);
662
663 let (mut min_x, mut min_y, mut max_x, mut max_y) = (w, h, 0u32, 0u32);
664 for y in 0..h {
665 for x in 0..w {
666 if energy[(y * w + x) as usize] > threshold {
667 if x < min_x {
668 min_x = x;
669 }
670 if y < min_y {
671 min_y = y;
672 }
673 if x > max_x {
674 max_x = x;
675 }
676 if y > max_y {
677 max_y = y;
678 }
679 }
680 }
681 }
682
683 if min_x >= max_x || min_y >= max_y {
684 return img.clone();
685 }
686
687 let x0 = min_x.saturating_sub(margin);
688 let y0 = min_y.saturating_sub(margin);
689 let x1 = (max_x + 1 + margin).min(w);
690 let y1 = (max_y + 1 + margin).min(h);
691 img.crop_imm(x0, y0, x1 - x0, y1 - y0)
692}
693
694pub fn ssim(a: &DynamicImage, b: &DynamicImage) -> f64 {
702 let (aw, ah) = (a.width(), a.height());
703 let (bw, bh) = (b.width(), b.height());
704 let (target_w, target_h) = (aw.max(bw), ah.max(bh));
705
706 let resize_if_needed = |img: &DynamicImage| -> image::GrayImage {
707 if img.width() == target_w && img.height() == target_h {
708 img.to_luma8()
709 } else {
710 img.resize_exact(target_w, target_h, FilterType::Lanczos3)
711 .to_luma8()
712 }
713 };
714
715 let a_luma = resize_if_needed(a);
716 let b_luma = resize_if_needed(b);
717
718 let n = (target_w as u64 * target_h as u64).max(1) as f64;
719 let (mut sum_a, mut sum_b) = (0f64, 0f64);
720 for (pa, pb) in a_luma.pixels().zip(b_luma.pixels()) {
721 sum_a += pa.0[0] as f64;
722 sum_b += pb.0[0] as f64;
723 }
724 let mean_a = sum_a / n;
725 let mean_b = sum_b / n;
726
727 let (mut var_a, mut var_b, mut cov) = (0f64, 0f64, 0f64);
728 for (pa, pb) in a_luma.pixels().zip(b_luma.pixels()) {
729 let da = pa.0[0] as f64 - mean_a;
730 let db = pb.0[0] as f64 - mean_b;
731 var_a += da * da;
732 var_b += db * db;
733 cov += da * db;
734 }
735 var_a /= n;
736 var_b /= n;
737 cov /= n;
738
739 let c1 = (0.01f64 * 255.0).powi(2);
740 let c2 = (0.03f64 * 255.0).powi(2);
741 let num = (2.0 * mean_a * mean_b + c1) * (2.0 * cov + c2);
742 let den = (mean_a.powi(2) + mean_b.powi(2) + c1) * (var_a + var_b + c2);
743 if den.abs() < f64::EPSILON {
744 1.0
745 } else {
746 num / den
747 }
748}
749
750pub fn encode_with_auto_quality(
756 original: &DynamicImage,
757 cfg: &ProcessConfig,
758 target_ssim: f64,
759 min_q: u8,
760 max_q: u8,
761) -> Result<(Vec<u8>, u8), String> {
762 let mut lo = min_q.max(1);
763 let mut hi = max_q.min(100).max(lo + 1);
764 let mut best: Option<(Vec<u8>, u8)> = None;
765
766 while hi.saturating_sub(lo) > 2 {
767 let mid = lo + (hi - lo) / 2;
768 let trial = ProcessConfig {
769 quality: mid,
770 ..cfg.clone()
771 };
772 let bytes = encode_to_bytes(original, &trial)?;
773 let decoded = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
774 let score = ssim(original, &decoded);
775 if score >= target_ssim {
776 best = Some((bytes, mid));
777 hi = mid;
778 } else {
779 lo = mid;
780 }
781 }
782
783 if let Some((b, q)) = best {
785 Ok((b, q))
786 } else {
787 let trial = ProcessConfig {
788 quality: hi,
789 ..cfg.clone()
790 };
791 let bytes = encode_to_bytes(original, &trial)?;
792 Ok((bytes, hi))
793 }
794}
795
796pub fn binarize(img: DynamicImage) -> DynamicImage {
799 let gray = img.to_luma8();
800 let (w, h) = gray.dimensions();
801 let threshold = otsu_threshold(&gray);
802 let binary: ImageBuffer<Luma<u8>, Vec<u8>> = ImageBuffer::from_fn(w, h, |x, y| {
803 let p = gray.get_pixel(x, y).0[0];
804 Luma([if p < threshold { 0u8 } else { 255u8 }])
805 });
806 DynamicImage::ImageLuma8(binary)
807}
808
809fn otsu_threshold(img: &image::GrayImage) -> u8 {
810 let mut histogram = [0u32; 256];
811 for p in img.pixels() {
812 histogram[p.0[0] as usize] += 1;
813 }
814 let total = img.width() * img.height();
815 let (mut sum, mut sum_bg, mut weight_bg) = (0f64, 0f64, 0f64);
816 for (i, &h) in histogram.iter().enumerate() {
817 sum += i as f64 * h as f64;
818 }
819 let (mut best_thresh, mut best_var) = (0u8, 0f64);
820 for (t, &h) in histogram.iter().enumerate() {
821 weight_bg += h as f64;
822 if weight_bg == 0.0 {
823 continue;
824 }
825 let weight_fg = total as f64 - weight_bg;
826 if weight_fg == 0.0 {
827 break;
828 }
829 sum_bg += t as f64 * h as f64;
830 let mean_bg = sum_bg / weight_bg;
831 let mean_fg = (sum - sum_bg) / weight_fg;
832 let var = weight_bg * weight_fg * (mean_bg - mean_fg).powi(2);
833 if var > best_var {
834 best_var = var;
835 best_thresh = t as u8;
836 }
837 }
838 best_thresh
839}
840
841pub fn decode_base64_image(input: &str) -> Result<DynamicImage, String> {
844 let data = if let Some(c) = input.find(',') {
845 &input[c + 1..]
846 } else {
847 input
848 };
849 let bytes = B64.decode(data.trim()).map_err(|e| e.to_string())?;
850 image::load_from_memory(&bytes).map_err(|e| e.to_string())
851}
852
853pub fn encode_image_base64(img: &DynamicImage, cfg: &ProcessConfig) -> Result<String, String> {
854 let bytes = encode_to_bytes(img, cfg)?;
855 Ok(B64.encode(bytes))
856}
857
858pub fn encode_to_bytes(img: &DynamicImage, cfg: &ProcessConfig) -> Result<Vec<u8>, String> {
860 match cfg.output_format {
861 OutputFormat::Jpeg => {
862 use image::codecs::jpeg::JpegEncoder;
863 let mut buf = Cursor::new(Vec::new());
864 let rgb = img.to_rgb8();
865 JpegEncoder::new_with_quality(&mut buf, cfg.quality)
866 .encode_image(&DynamicImage::ImageRgb8(rgb))
867 .map_err(|e| e.to_string())?;
868 Ok(buf.into_inner())
869 }
870 OutputFormat::WebP => {
871 let rgb = img.to_rgb8();
872 let enc = webp::Encoder::from_rgb(rgb.as_raw(), rgb.width(), rgb.height());
873 let mem = enc.encode(cfg.quality as f32);
874 Ok(mem.to_vec())
875 }
876 OutputFormat::Avif => {
877 use image::ImageEncoder;
878 use image::codecs::avif::AvifEncoder;
879 let mut buf = Cursor::new(Vec::new());
880 let rgba = img.to_rgba8();
881 AvifEncoder::new_with_speed_quality(&mut buf, 6, cfg.quality)
883 .write_image(
884 rgba.as_raw(),
885 rgba.width(),
886 rgba.height(),
887 image::ExtendedColorType::Rgba8,
888 )
889 .map_err(|e| e.to_string())?;
890 Ok(buf.into_inner())
891 }
892 }
893}
894
895pub struct OptimizeResult {
898 pub optimized_base64: String,
899 pub report: SavingsReport,
900 pub original_width: u32,
901 pub original_height: u32,
902 pub width: u32,
903 pub height: u32,
904 pub optimized_bytes: usize,
905}
906
907pub fn optimize_image(
909 input_base64: &str,
910 mode: ProcessMode,
911 cfg: &ProcessConfig,
912) -> Result<OptimizeResult, String> {
913 let img = decode_base64_image(input_base64)?;
914 let (orig_w, orig_h) = (img.width(), img.height());
915 let input_bytes = {
916 let data = if let Some(c) = input_base64.find(',') {
917 &input_base64[c + 1..]
918 } else {
919 input_base64
920 };
921 B64.decode(data.trim()).map_err(|e| e.to_string())?.len() as u64
922 };
923
924 let mut result = process(img, mode, input_bytes, cfg);
925 let bytes = encode_to_bytes(&result.image, cfg)?;
926 let encoded = B64.encode(&bytes);
927 result.report.bytes_after = Some(bytes.len() as u64);
928
929 Ok(OptimizeResult {
930 optimized_base64: encoded,
931 report: result.report,
932 original_width: orig_w,
933 original_height: orig_h,
934 width: result.width,
935 height: result.height,
936 optimized_bytes: bytes.len(),
937 })
938}
939
940#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
946#[serde(rename_all = "lowercase", tag = "op")]
947pub enum ImageOp {
948 Crop {
950 x: u32,
951 y: u32,
952 width: u32,
953 height: u32,
954 },
955 Grayscale,
957 Binarize { threshold: Option<u8> },
959 Resize { width: u32, height: u32 },
961 Contrast { amount: f32 },
963 Brightness { amount: f32 },
965}
966
967pub fn process_with_operations(mut img: DynamicImage, ops: Vec<ImageOp>) -> DynamicImage {
969 for op in ops {
970 img = match op {
971 ImageOp::Crop {
972 x,
973 y,
974 width,
975 height,
976 } => img.crop_imm(x, y, width, height),
977 ImageOp::Grayscale => DynamicImage::ImageLuma8(img.to_luma8()),
978 ImageOp::Binarize { threshold } => {
979 let gray = img.to_luma8();
980 let thr = threshold.unwrap_or(128);
981 let mut binarized = ImageBuffer::new(gray.width(), gray.height());
982 for (x, y, p) in gray.enumerate_pixels() {
983 let val = if p[0] > thr { 255 } else { 0 };
984 binarized.put_pixel(x, y, Luma([val]));
985 }
986 DynamicImage::ImageLuma8(binarized)
987 }
988 ImageOp::Resize { width, height } => {
989 img.resize_exact(width, height, FilterType::Lanczos3)
990 }
991 ImageOp::Contrast { amount } => img.adjust_contrast(amount),
992 ImageOp::Brightness { amount } => img.brighten(amount as i32),
993 };
994 }
995 img
996}
997
998#[cfg(test)]
999mod tests {
1000 use super::*;
1001
1002 fn cfg() -> ProcessConfig {
1003 ProcessConfig::default()
1004 }
1005
1006 #[test]
1007 fn exact_boundary_unchanged() {
1008 let r = calculate_optimal_dimensions(1024, 512);
1009 assert_eq!((r.width, r.height), (1024, 512));
1010 assert_eq!(r.tokens_saved(), 0);
1011 }
1012
1013 #[test]
1014 fn one_pixel_over_saves_full_tile_row() {
1015 let r = calculate_optimal_dimensions(1025, 1025);
1016 assert_eq!((r.width, r.height), (1024, 1024));
1017 assert_eq!(r.tiles_before, 9);
1018 assert_eq!(r.tiles_after, 4);
1019 assert_eq!(r.tokens_saved(), 5);
1020 }
1021
1022 #[test]
1023 fn small_image_never_below_one_tile() {
1024 let r = calculate_optimal_dimensions(100, 200);
1025 assert_eq!((r.width, r.height), (512, 512));
1026 }
1027
1028 #[test]
1029 fn mid_boundary_snaps_down() {
1030 let r = calculate_optimal_dimensions(768, 512);
1031 assert_eq!(r.width, 512);
1032 assert_eq!(r.tiles_after, 1);
1033 }
1034
1035 #[test]
1036 fn custom_tile_size_256() {
1037 let r = calculate_optimal_dimensions_with(257, 512, 256);
1038 assert_eq!(r.width, 256); assert_eq!(r.tiles_before, 2 * 2); assert_eq!(r.tiles_after, 1 * 2); }
1042
1043 #[test]
1044 fn full_pipeline_reduces_tiles() {
1045 use image::{DynamicImage, Rgba, RgbaImage};
1046 let mut img = RgbaImage::from_pixel(1025, 1025, Rgba([255, 255, 255, 255]));
1047 for x in 400..600 {
1048 for y in 400..600 {
1049 img.put_pixel(x, y, Rgba([0, 0, 0, 255]));
1050 }
1051 }
1052 let result = process(
1053 DynamicImage::ImageRgba8(img),
1054 ProcessMode::Standard,
1055 0,
1056 &cfg(),
1057 );
1058 assert!(result.report.tiles_after < result.report.tiles_before);
1059 }
1060
1061 #[test]
1062 fn crop_disabled_preserves_size() {
1063 use image::{DynamicImage, Rgba, RgbaImage};
1064 let img = RgbaImage::from_pixel(1024, 1024, Rgba([255, 255, 255, 255]));
1065 let no_crop = ProcessConfig::builder().crop(false).build();
1066 let result = process(
1067 DynamicImage::ImageRgba8(img),
1068 ProcessMode::Standard,
1069 0,
1070 &no_crop,
1071 );
1072 assert_eq!(result.width, 1024);
1073 }
1074
1075 #[test]
1076 fn crop_removes_white_border() {
1077 use image::{Rgba, RgbaImage};
1078 let mut img = RgbaImage::from_pixel(100, 100, Rgba([255, 255, 255, 255]));
1079 for x in 45..55 {
1080 for y in 45..55 {
1081 img.put_pixel(x, y, Rgba([255, 0, 0, 255]));
1082 }
1083 }
1084 let cropped = crop_padding(DynamicImage::ImageRgba8(img), 15);
1085 assert!(cropped.width() < 100 && cropped.height() < 100);
1086 }
1087
1088 #[test]
1089 fn binarize_produces_only_black_white() {
1090 use image::{DynamicImage, GrayImage, Luma};
1091 let img = GrayImage::from_fn(64, 64, |x, _| Luma([if x < 32 { 50u8 } else { 200u8 }]));
1092 let result = binarize(DynamicImage::ImageLuma8(img)).to_luma8();
1093 for p in result.pixels() {
1094 assert!(p.0[0] == 0 || p.0[0] == 255);
1095 }
1096 }
1097
1098 #[test]
1099 fn ssim_identical_images_is_one() {
1100 use image::{DynamicImage, Rgba, RgbaImage};
1101 let img =
1102 DynamicImage::ImageRgba8(RgbaImage::from_pixel(64, 64, Rgba([128, 128, 128, 255])));
1103 let s = ssim(&img, &img);
1104 assert!((s - 1.0).abs() < 1e-9);
1105 }
1106
1107 #[test]
1108 fn ssim_very_different_images_is_low() {
1109 use image::{DynamicImage, Rgba, RgbaImage};
1110 let black = DynamicImage::ImageRgba8(RgbaImage::from_pixel(64, 64, Rgba([0, 0, 0, 255])));
1111 let white =
1112 DynamicImage::ImageRgba8(RgbaImage::from_pixel(64, 64, Rgba([255, 255, 255, 255])));
1113 let s = ssim(&black, &white);
1114 assert!(s < 0.1, "expected low SSIM, got {s}");
1115 }
1116
1117 #[test]
1118 fn saliency_crop_tightens_around_high_energy_region() {
1119 use image::{DynamicImage, Rgba, RgbaImage};
1120 let mut img = RgbaImage::from_pixel(1000, 1000, Rgba([255, 255, 255, 255]));
1122 for x in 400..600 {
1123 for y in 400..600 {
1124 let v = if (x + y) % 2 == 0 { 0 } else { 255 };
1126 img.put_pixel(x, y, Rgba([v, v, v, 255]));
1127 }
1128 }
1129 let dyn_img = DynamicImage::ImageRgba8(img);
1130 let cropped = saliency_crop(&dyn_img, 8);
1131 assert!(cropped.width() < 1000);
1132 assert!(cropped.height() < 1000);
1133 assert!(cropped.width() < 400);
1135 assert!(cropped.height() < 400);
1136 }
1137
1138 #[test]
1139 fn auto_quality_returns_quality_in_range() {
1140 use image::{DynamicImage, Rgba, RgbaImage};
1141 let mut img = RgbaImage::from_pixel(256, 256, Rgba([100, 100, 100, 255]));
1142 for x in 0..256 {
1143 for y in 0..256 {
1144 img.put_pixel(x, y, Rgba([(x % 256) as u8, (y % 256) as u8, 128, 255]));
1145 }
1146 }
1147 let dyn_img = DynamicImage::ImageRgba8(img);
1148 let cfg = ProcessConfig::default();
1149 let (bytes, q) = encode_with_auto_quality(&dyn_img, &cfg, 0.95, 40, 95).expect("ok");
1150 assert!((40..=95).contains(&q));
1151 assert!(!bytes.is_empty());
1152 }
1153
1154 #[test]
1155 fn high_bg_tolerance_crops_more() {
1156 use image::{DynamicImage, Rgba, RgbaImage};
1157 let mut img = RgbaImage::from_pixel(100, 100, Rgba([240, 240, 240, 255]));
1161 for corner in [(0u32, 0u32), (99, 0), (0, 99), (99, 99)] {
1162 img.put_pixel(corner.0, corner.1, Rgba([255, 255, 255, 255]));
1163 }
1164 for x in 45..55 {
1165 for y in 45..55 {
1166 img.put_pixel(x, y, Rgba([0, 0, 0, 255]));
1167 }
1168 }
1169 let strict = crop_padding(DynamicImage::ImageRgba8(img.clone()), 5);
1170 let loose = crop_padding(DynamicImage::ImageRgba8(img), 20);
1171 assert!(loose.width() < strict.width());
1172 }
1173}
1174#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
1177pub struct OptimizationReport {
1178 pub timestamp: String,
1179 pub model: String,
1180 pub original_tokens: u32,
1181 pub optimized_tokens: u32,
1182 pub original_bytes: u64,
1183 pub optimized_bytes: u64,
1184 pub mode: String,
1185}
1186
1187#[derive(Debug, serde::Serialize, serde::Deserialize)]
1188pub struct SqueezerStats {
1189 pub total_optimizations: u64,
1190 pub total_original_tokens: u64,
1191 pub total_optimized_tokens: u64,
1192 pub total_original_bytes: u64,
1193 pub total_optimized_bytes: u64,
1194 pub history: Vec<OptimizationReport>,
1195}
1196
1197impl SqueezerStats {
1198 pub fn total_token_savings(&self) -> u64 {
1199 self.total_original_tokens
1200 .saturating_sub(self.total_optimized_tokens)
1201 }
1202
1203 pub fn total_byte_savings(&self) -> u64 {
1204 self.total_original_bytes
1205 .saturating_sub(self.total_optimized_bytes)
1206 }
1207
1208 pub fn estimated_usd_saved(&self) -> f64 {
1209 (self.total_token_savings() as f64 / 1_000_000.0) * 2.50
1211 }
1212}
1213
1214pub struct Persistence;
1215
1216impl Persistence {
1217 fn get_db_path() -> PathBuf {
1218 let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
1219 path.push(".vision-squeezer");
1220 let _ = std::fs::create_dir_all(&path);
1221 path.push("stats.db");
1222 path
1223 }
1224
1225 pub fn init_db() -> Result<(), String> {
1226 let conn = Connection::open(Self::get_db_path()).map_err(|e| e.to_string())?;
1227 conn.execute(
1228 "CREATE TABLE IF NOT EXISTS optimizations (
1229 id INTEGER PRIMARY KEY AUTOINCREMENT,
1230 timestamp TEXT NOT NULL,
1231 model TEXT NOT NULL,
1232 original_tokens INTEGER NOT NULL,
1233 optimized_tokens INTEGER NOT NULL,
1234 original_bytes INTEGER NOT NULL,
1235 optimized_bytes INTEGER NOT NULL,
1236 mode TEXT NOT NULL
1237 )",
1238 [],
1239 )
1240 .map_err(|e| e.to_string())?;
1241 Ok(())
1242 }
1243
1244 pub fn log_optimization(
1245 model: &str,
1246 orig_tokens: u32,
1247 opt_tokens: u32,
1248 orig_bytes: u64,
1249 opt_bytes: u64,
1250 mode: &str,
1251 ) -> Result<(), String> {
1252 let conn = Connection::open(Self::get_db_path()).map_err(|e| e.to_string())?;
1253 conn.execute(
1254 "INSERT INTO optimizations (timestamp, model, original_tokens, optimized_tokens, original_bytes, optimized_bytes, mode)
1255 VALUES (?, ?, ?, ?, ?, ?, ?)",
1256 params![
1257 Utc::now().to_rfc3339(),
1258 model,
1259 orig_tokens,
1260 opt_tokens,
1261 orig_bytes as i64,
1262 opt_bytes as i64,
1263 mode,
1264 ],
1265 ).map_err(|e| e.to_string())?;
1266 Ok(())
1267 }
1268
1269 pub fn get_stats() -> Result<SqueezerStats, String> {
1270 let conn = Connection::open(Self::get_db_path()).map_err(|e| e.to_string())?;
1271
1272 let mut stmt = conn
1273 .prepare(
1274 "SELECT
1275 COUNT(*),
1276 SUM(original_tokens),
1277 SUM(optimized_tokens),
1278 SUM(original_bytes),
1279 SUM(optimized_bytes)
1280 FROM optimizations",
1281 )
1282 .map_err(|e| e.to_string())?;
1283
1284 let (count, orig_t, opt_t, orig_b, opt_b) = stmt
1285 .query_row([], |row| {
1286 Ok((
1287 row.get::<_, Option<i64>>(0)?.unwrap_or(0) as u64,
1288 row.get::<_, Option<i64>>(1)?.unwrap_or(0) as u64,
1289 row.get::<_, Option<i64>>(2)?.unwrap_or(0) as u64,
1290 row.get::<_, Option<i64>>(3)?.unwrap_or(0) as u64,
1291 row.get::<_, Option<i64>>(4)?.unwrap_or(0) as u64,
1292 ))
1293 })
1294 .map_err(|e| e.to_string())?;
1295
1296 let mut stmt = conn.prepare(
1297 "SELECT timestamp, model, original_tokens, optimized_tokens, original_bytes, optimized_bytes, mode
1298 FROM optimizations ORDER BY timestamp DESC LIMIT 50"
1299 ).map_err(|e| e.to_string())?;
1300
1301 let history = stmt
1302 .query_map([], |row| {
1303 Ok(OptimizationReport {
1304 timestamp: row.get(0)?,
1305 model: row.get(1)?,
1306 original_tokens: row.get(2)?,
1307 optimized_tokens: row.get(3)?,
1308 original_bytes: row.get::<_, i64>(4)? as u64,
1309 optimized_bytes: row.get::<_, i64>(5)? as u64,
1310 mode: row.get(6)?,
1311 })
1312 })
1313 .map_err(|e| e.to_string())?
1314 .collect::<Result<Vec<_>, _>>()
1315 .map_err(|e| e.to_string())?;
1316
1317 Ok(SqueezerStats {
1318 total_optimizations: count,
1319 total_original_tokens: orig_t,
1320 total_optimized_tokens: opt_t,
1321 total_original_bytes: orig_b,
1322 total_optimized_bytes: opt_b,
1323 history,
1324 })
1325 }
1326
1327 pub fn get_all_history() -> Result<Vec<OptimizationReport>, String> {
1328 let conn = Connection::open(Self::get_db_path()).map_err(|e| e.to_string())?;
1329 let mut stmt = conn.prepare(
1330 "SELECT timestamp, model, original_tokens, optimized_tokens, original_bytes, optimized_bytes, mode
1331 FROM optimizations ORDER BY timestamp ASC"
1332 ).map_err(|e| e.to_string())?;
1333
1334 stmt.query_map([], |row| {
1335 Ok(OptimizationReport {
1336 timestamp: row.get(0)?,
1337 model: row.get(1)?,
1338 original_tokens: row.get(2)?,
1339 optimized_tokens: row.get(3)?,
1340 original_bytes: row.get::<_, i64>(4)? as u64,
1341 optimized_bytes: row.get::<_, i64>(5)? as u64,
1342 mode: row.get(6)?,
1343 })
1344 })
1345 .map_err(|e| e.to_string())?
1346 .collect::<Result<Vec<_>, _>>()
1347 .map_err(|e| e.to_string())
1348 }
1349}