Skip to main content

vil_vision/
analyzer.rs

1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3
4/// Result of analyzing an image.
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct ImageAnalysis {
7    /// Human-readable description of the image content.
8    pub description: String,
9    /// Detected objects in the image.
10    pub objects: Vec<DetectedObject>,
11    /// Text extracted from the image via OCR, if any.
12    pub text_content: Option<String>,
13}
14
15/// A detected object within an image.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct DetectedObject {
18    /// Label/class of the detected object.
19    pub label: String,
20    /// Detection confidence (0.0 to 1.0).
21    pub confidence: f32,
22    /// Bounding box as [x_min, y_min, x_max, y_max] in normalized coordinates (0.0 to 1.0).
23    pub bounding_box: [f32; 4],
24}
25
26impl DetectedObject {
27    /// Compute bounding box area (normalized, 0.0 to 1.0).
28    pub fn area(&self) -> f32 {
29        let width = (self.bounding_box[2] - self.bounding_box[0]).max(0.0);
30        let height = (self.bounding_box[3] - self.bounding_box[1]).max(0.0);
31        width * height
32    }
33}
34
35/// Error type for image analysis operations.
36#[derive(Debug, Clone)]
37pub enum VisionError {
38    UnsupportedFormat(String),
39    EmptyImage,
40    AnalysisFailed(String),
41}
42
43impl std::fmt::Display for VisionError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            VisionError::UnsupportedFormat(fmt) => write!(f, "unsupported image format: {}", fmt),
47            VisionError::EmptyImage => write!(f, "image data is empty"),
48            VisionError::AnalysisFailed(e) => write!(f, "analysis failed: {}", e),
49        }
50    }
51}
52
53impl std::error::Error for VisionError {}
54
55/// Core trait for image analysis backends.
56#[async_trait]
57pub trait ImageAnalyzer: Send + Sync {
58    /// Analyze an image and return structured results.
59    async fn analyze(&self, image: &[u8]) -> Result<ImageAnalysis, VisionError>;
60
61    /// Name of this analyzer backend.
62    fn name(&self) -> &str;
63}
64
65/// A no-op analyzer that returns an error — extend with real backend (OpenAI Vision, Tesseract, etc.).
66pub struct NoopAnalyzer;
67
68#[async_trait]
69impl ImageAnalyzer for NoopAnalyzer {
70    async fn analyze(&self, image: &[u8]) -> Result<ImageAnalysis, VisionError> {
71        if image.is_empty() {
72            return Err(VisionError::EmptyImage);
73        }
74        Err(VisionError::AnalysisFailed(
75            "no analysis backend configured".into(),
76        ))
77    }
78
79    fn name(&self) -> &str {
80        "noop"
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn test_detected_object_area() {
90        let obj = DetectedObject {
91            label: "cat".into(),
92            confidence: 0.9,
93            bounding_box: [0.1, 0.2, 0.5, 0.8],
94        };
95        let area = obj.area();
96        // (0.5 - 0.1) * (0.8 - 0.2) = 0.4 * 0.6 = 0.24
97        assert!((area - 0.24).abs() < 0.001);
98    }
99
100    #[test]
101    fn test_analysis_types() {
102        let analysis = ImageAnalysis {
103            description: "A cat sitting on a mat".into(),
104            objects: vec![DetectedObject {
105                label: "cat".into(),
106                confidence: 0.95,
107                bounding_box: [0.1, 0.1, 0.9, 0.9],
108            }],
109            text_content: None,
110        };
111        assert_eq!(analysis.objects.len(), 1);
112        assert_eq!(analysis.objects[0].label, "cat");
113    }
114
115    #[tokio::test]
116    async fn test_noop_analyzer_empty() {
117        let a = NoopAnalyzer;
118        let result = a.analyze(b"").await;
119        assert!(matches!(result, Err(VisionError::EmptyImage)));
120    }
121
122    #[tokio::test]
123    async fn test_noop_analyzer_no_backend() {
124        let a = NoopAnalyzer;
125        let result = a.analyze(b"fake image data").await;
126        assert!(matches!(result, Err(VisionError::AnalysisFailed(_))));
127    }
128}