Skip to main content

anno_eval/eval/
visual.rs

1//! Visual NER evaluation metrics.
2//!
3//! # Overview
4//!
5//! Visual NER (VisualNER) extracts entities from images and documents,
6//! producing entities with both text spans and bounding box locations.
7//!
8//! # Evaluation Metrics
9//!
10//! From VisualNER benchmarks (FUNSD, SROIE, CORD):
11//!
12//! | Metric | Description |
13//! |--------|-------------|
14//! | Text F1 | Standard NER F1 on extracted text |
15//! | Box IoU | Intersection-over-Union of bounding boxes |
16//! | End-to-End F1 | Correct text AND box (>50% IoU) |
17//!
18//! # Benchmark Datasets
19//!
20//! Common benchmarks include FUNSD, SROIE, CORD, and DocVQA.
21//!
22//! # Research Alignment
23//!
24//! LayoutLMv3 (arXiv:2204.08387) is a representative reference for multimodal
25//! document understanding (text + layout + pixels).
26
27use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
29
30// =============================================================================
31// BOUNDING BOX TYPES
32// =============================================================================
33
34/// A bounding box in normalized coordinates (0.0-1.0).
35///
36/// Using normalized coordinates allows consistent evaluation
37/// regardless of image resolution.
38#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
39pub struct BoundingBox {
40    /// Left coordinate (0.0-1.0)
41    pub x1: f32,
42    /// Top coordinate (0.0-1.0)
43    pub y1: f32,
44    /// Right coordinate (0.0-1.0)
45    pub x2: f32,
46    /// Bottom coordinate (0.0-1.0)
47    pub y2: f32,
48}
49
50impl BoundingBox {
51    /// Create a new bounding box.
52    pub fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
53        Self { x1, y1, x2, y2 }
54    }
55
56    /// Calculate area of the bounding box.
57    pub fn area(&self) -> f32 {
58        (self.x2 - self.x1).max(0.0) * (self.y2 - self.y1).max(0.0)
59    }
60
61    /// Calculate Intersection-over-Union (IoU) with another box.
62    ///
63    /// IoU = intersection_area / union_area
64    /// Range: 0.0 (no overlap) to 1.0 (identical boxes)
65    pub fn iou(&self, other: &BoundingBox) -> f32 {
66        let x1 = self.x1.max(other.x1);
67        let y1 = self.y1.max(other.y1);
68        let x2 = self.x2.min(other.x2);
69        let y2 = self.y2.min(other.y2);
70
71        let intersection = (x2 - x1).max(0.0) * (y2 - y1).max(0.0);
72        let union = self.area() + other.area() - intersection;
73
74        if union > 0.0 {
75            intersection / union
76        } else {
77            0.0
78        }
79    }
80
81    /// Check if this box substantially overlaps with another (IoU >= threshold).
82    pub fn overlaps(&self, other: &BoundingBox, threshold: f32) -> bool {
83        self.iou(other) >= threshold
84    }
85}
86
87// =============================================================================
88// VISUAL ENTITY TYPES
89// =============================================================================
90
91/// A gold standard visual entity with text and bounding box.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct VisualGold {
94    /// Entity text
95    pub text: String,
96    /// Entity type
97    pub entity_type: String,
98    /// Bounding box (normalized coordinates)
99    pub bbox: BoundingBox,
100}
101
102impl VisualGold {
103    /// Create a new visual gold entity.
104    pub fn new(text: impl Into<String>, entity_type: impl Into<String>, bbox: BoundingBox) -> Self {
105        Self {
106            text: text.into(),
107            entity_type: entity_type.into(),
108            bbox,
109        }
110    }
111}
112
113/// A predicted visual entity.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct VisualPrediction {
116    /// Extracted text
117    pub text: String,
118    /// Predicted entity type
119    pub entity_type: String,
120    /// Predicted bounding box
121    pub bbox: BoundingBox,
122    /// Confidence score
123    pub confidence: f32,
124}
125
126// =============================================================================
127// EVALUATION CONFIG
128// =============================================================================
129
130/// Configuration for visual NER evaluation.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct VisualEvalConfig {
133    /// IoU threshold for box matching (default: 0.5)
134    pub iou_threshold: f32,
135    /// Whether text match should be case-insensitive
136    pub case_insensitive: bool,
137    /// Whether to normalize whitespace in text comparison
138    pub normalize_whitespace: bool,
139    /// Whether entity type must match for credit
140    pub require_type_match: bool,
141}
142
143impl Default for VisualEvalConfig {
144    fn default() -> Self {
145        Self {
146            iou_threshold: 0.5,
147            case_insensitive: false,
148            normalize_whitespace: true,
149            require_type_match: true,
150        }
151    }
152}
153
154// =============================================================================
155// METRICS
156// =============================================================================
157
158/// Visual NER evaluation metrics.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct VisualNERMetrics {
161    // Text-only metrics (ignoring boxes)
162    /// Text-only precision
163    pub text_precision: f64,
164    /// Text-only recall
165    pub text_recall: f64,
166    /// Text-only F1
167    pub text_f1: f64,
168
169    // Box-only metrics (ignoring text)
170    /// Mean IoU across matched boxes
171    pub mean_iou: f64,
172    /// Box precision (boxes above IoU threshold)
173    pub box_precision: f64,
174    /// Box recall
175    pub box_recall: f64,
176    /// Box F1
177    pub box_f1: f64,
178
179    // End-to-end metrics (text AND box must match)
180    /// End-to-end precision
181    pub e2e_precision: f64,
182    /// End-to-end recall
183    pub e2e_recall: f64,
184    /// End-to-end F1
185    pub e2e_f1: f64,
186
187    // Per-type breakdown
188    /// Metrics per entity type
189    pub per_type: HashMap<String, VisualTypeMetrics>,
190
191    // Counts
192    /// Number of predicted entities
193    pub num_predicted: usize,
194    /// Number of gold entities
195    pub num_gold: usize,
196    /// Text matches
197    pub text_matches: usize,
198    /// Box matches (IoU >= threshold)
199    pub box_matches: usize,
200    /// End-to-end matches (text AND box)
201    pub e2e_matches: usize,
202}
203
204/// Per-type visual metrics.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct VisualTypeMetrics {
207    /// Entity type
208    pub entity_type: String,
209    /// Text F1
210    pub text_f1: f64,
211    /// Box F1
212    pub box_f1: f64,
213    /// End-to-end F1
214    pub e2e_f1: f64,
215    /// Support (gold count)
216    pub support: usize,
217}
218
219// =============================================================================
220// EVALUATION FUNCTION
221// =============================================================================
222
223/// Evaluate visual NER predictions against gold standard.
224///
225/// # Arguments
226///
227/// * `gold` - Gold standard entities
228/// * `pred` - Predicted entities
229/// * `config` - Evaluation configuration
230///
231/// # Returns
232///
233/// Comprehensive visual NER metrics.
234pub fn evaluate_visual_ner(
235    gold: &[VisualGold],
236    pred: &[VisualPrediction],
237    config: &VisualEvalConfig,
238) -> VisualNERMetrics {
239    let mut text_matches = 0;
240    let mut box_matches = 0;
241    let mut e2e_matches = 0;
242    let mut iou_sum = 0.0f64;
243    let mut iou_count = 0;
244
245    let mut type_stats: HashMap<String, (usize, usize, usize, usize, usize)> = HashMap::new();
246
247    // Track which gold entities have been matched
248    let mut gold_text_matched = vec![false; gold.len()];
249    let mut gold_box_matched = vec![false; gold.len()];
250    let mut gold_e2e_matched = vec![false; gold.len()];
251
252    // Initialize type stats
253    for g in gold {
254        type_stats
255            .entry(g.entity_type.clone())
256            .or_insert((0, 0, 0, 0, 0))
257            .0 += 1;
258    }
259    for p in pred {
260        type_stats
261            .entry(p.entity_type.clone())
262            .or_insert((0, 0, 0, 0, 0))
263            .1 += 1;
264    }
265
266    // Match predictions to gold
267    for p in pred {
268        let pred_text = normalize_text(&p.text, config);
269
270        for (g_idx, g) in gold.iter().enumerate() {
271            // Check type match if required
272            if config.require_type_match && p.entity_type != g.entity_type {
273                continue;
274            }
275
276            let gold_text = normalize_text(&g.text, config);
277            let text_match = pred_text == gold_text;
278            let iou = p.bbox.iou(&g.bbox);
279            let box_match = iou >= config.iou_threshold;
280
281            // Update IoU stats for any overlapping boxes
282            if iou > 0.0 {
283                iou_sum += iou as f64;
284                iou_count += 1;
285            }
286
287            // Text match
288            if text_match && !gold_text_matched[g_idx] {
289                gold_text_matched[g_idx] = true;
290                text_matches += 1;
291                if let Some(stats) = type_stats.get_mut(&g.entity_type) {
292                    stats.2 += 1;
293                }
294            }
295
296            // Box match
297            if box_match && !gold_box_matched[g_idx] {
298                gold_box_matched[g_idx] = true;
299                box_matches += 1;
300                if let Some(stats) = type_stats.get_mut(&g.entity_type) {
301                    stats.3 += 1;
302                }
303            }
304
305            // End-to-end match (both text AND box)
306            if text_match && box_match && !gold_e2e_matched[g_idx] {
307                gold_e2e_matched[g_idx] = true;
308                e2e_matches += 1;
309                if let Some(stats) = type_stats.get_mut(&g.entity_type) {
310                    stats.4 += 1;
311                }
312                break; // Found a complete match, move to next prediction
313            }
314        }
315    }
316
317    // Calculate metrics
318    let num_gold = gold.len();
319    let num_pred = pred.len();
320
321    let text_precision = if num_pred > 0 {
322        text_matches as f64 / num_pred as f64
323    } else {
324        0.0
325    };
326    let text_recall = if num_gold > 0 {
327        text_matches as f64 / num_gold as f64
328    } else {
329        0.0
330    };
331    let text_f1 = f1(text_precision, text_recall);
332
333    let box_precision = if num_pred > 0 {
334        box_matches as f64 / num_pred as f64
335    } else {
336        0.0
337    };
338    let box_recall = if num_gold > 0 {
339        box_matches as f64 / num_gold as f64
340    } else {
341        0.0
342    };
343    let box_f1 = f1(box_precision, box_recall);
344
345    let e2e_precision = if num_pred > 0 {
346        e2e_matches as f64 / num_pred as f64
347    } else {
348        0.0
349    };
350    let e2e_recall = if num_gold > 0 {
351        e2e_matches as f64 / num_gold as f64
352    } else {
353        0.0
354    };
355    let e2e_f1 = f1(e2e_precision, e2e_recall);
356
357    let mean_iou = if iou_count > 0 {
358        iou_sum / iou_count as f64
359    } else {
360        0.0
361    };
362
363    // Per-type metrics
364    let per_type: HashMap<_, _> = type_stats
365        .into_iter()
366        .map(|(et, (gold_count, pred_count, text_tp, box_tp, e2e_tp))| {
367            let text_f1 = if gold_count > 0 && pred_count > 0 {
368                let p = text_tp as f64 / pred_count as f64;
369                let r = text_tp as f64 / gold_count as f64;
370                f1(p, r)
371            } else {
372                0.0
373            };
374            let box_f1 = if gold_count > 0 && pred_count > 0 {
375                let p = box_tp as f64 / pred_count as f64;
376                let r = box_tp as f64 / gold_count as f64;
377                f1(p, r)
378            } else {
379                0.0
380            };
381            let e2e_f1 = if gold_count > 0 && pred_count > 0 {
382                let p = e2e_tp as f64 / pred_count as f64;
383                let r = e2e_tp as f64 / gold_count as f64;
384                f1(p, r)
385            } else {
386                0.0
387            };
388            (
389                et.clone(),
390                VisualTypeMetrics {
391                    entity_type: et,
392                    text_f1,
393                    box_f1,
394                    e2e_f1,
395                    support: gold_count,
396                },
397            )
398        })
399        .collect();
400
401    VisualNERMetrics {
402        text_precision,
403        text_recall,
404        text_f1,
405        mean_iou,
406        box_precision,
407        box_recall,
408        box_f1,
409        e2e_precision,
410        e2e_recall,
411        e2e_f1,
412        per_type,
413        num_predicted: num_pred,
414        num_gold,
415        text_matches,
416        box_matches,
417        e2e_matches,
418    }
419}
420
421// =============================================================================
422// HELPERS
423// =============================================================================
424
425fn normalize_text(text: &str, config: &VisualEvalConfig) -> String {
426    let mut s = text.to_string();
427    if config.case_insensitive {
428        s = s.to_lowercase();
429    }
430    if config.normalize_whitespace {
431        s = s.split_whitespace().collect::<Vec<_>>().join(" ");
432    }
433    s
434}
435
436fn f1(precision: f64, recall: f64) -> f64 {
437    if precision + recall > 0.0 {
438        2.0 * precision * recall / (precision + recall)
439    } else {
440        0.0
441    }
442}
443
444// =============================================================================
445// SYNTHETIC DATA
446// =============================================================================
447
448/// Generate synthetic visual NER examples for testing.
449///
450/// Returns examples with made-up bounding boxes for unit testing.
451pub fn synthetic_visual_examples() -> Vec<(String, Vec<VisualGold>)> {
452    vec![
453        (
454            "Invoice #12345".to_string(),
455            vec![VisualGold::new(
456                "Invoice #12345",
457                "DOCUMENT_ID",
458                BoundingBox::new(0.1, 0.05, 0.4, 0.1),
459            )],
460        ),
461        (
462            "Total: $1,234.56\nDate: 2024-01-15".to_string(),
463            vec![
464                VisualGold::new("$1,234.56", "MONEY", BoundingBox::new(0.5, 0.8, 0.7, 0.85)),
465                VisualGold::new("2024-01-15", "DATE", BoundingBox::new(0.5, 0.7, 0.7, 0.75)),
466            ],
467        ),
468        (
469            "Acme Corp\n123 Main St, City".to_string(),
470            vec![
471                VisualGold::new("Acme Corp", "ORG", BoundingBox::new(0.1, 0.1, 0.35, 0.15)),
472                VisualGold::new(
473                    "123 Main St, City",
474                    "ADDRESS",
475                    BoundingBox::new(0.1, 0.16, 0.5, 0.21),
476                ),
477            ],
478        ),
479    ]
480}
481
482// =============================================================================
483// TESTS
484// =============================================================================
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    #[test]
491    fn test_bounding_box_area() {
492        let bbox = BoundingBox::new(0.0, 0.0, 0.5, 0.5);
493        assert!((bbox.area() - 0.25).abs() < 0.001);
494    }
495
496    #[test]
497    fn test_bounding_box_iou_identical() {
498        let bbox1 = BoundingBox::new(0.1, 0.1, 0.5, 0.5);
499        let bbox2 = BoundingBox::new(0.1, 0.1, 0.5, 0.5);
500        assert!((bbox1.iou(&bbox2) - 1.0).abs() < 0.001);
501    }
502
503    #[test]
504    fn test_bounding_box_iou_no_overlap() {
505        let bbox1 = BoundingBox::new(0.0, 0.0, 0.2, 0.2);
506        let bbox2 = BoundingBox::new(0.5, 0.5, 0.7, 0.7);
507        assert!(bbox1.iou(&bbox2) < 0.001);
508    }
509
510    #[test]
511    fn test_bounding_box_iou_partial() {
512        let bbox1 = BoundingBox::new(0.0, 0.0, 0.5, 0.5);
513        let bbox2 = BoundingBox::new(0.25, 0.25, 0.75, 0.75);
514        let iou = bbox1.iou(&bbox2);
515        // Intersection: 0.25x0.25 = 0.0625
516        // Union: 0.25 + 0.25 - 0.0625 = 0.4375
517        // IoU: 0.0625 / 0.4375 ≈ 0.143
518        assert!(iou > 0.1 && iou < 0.2);
519    }
520
521    #[test]
522    fn test_evaluate_perfect_match() {
523        let gold = vec![VisualGold::new(
524            "Invoice",
525            "DOC",
526            BoundingBox::new(0.1, 0.1, 0.3, 0.15),
527        )];
528        let pred = vec![VisualPrediction {
529            text: "Invoice".to_string(),
530            entity_type: "DOC".to_string(),
531            bbox: BoundingBox::new(0.1, 0.1, 0.3, 0.15),
532            confidence: 0.95,
533        }];
534
535        let config = VisualEvalConfig::default();
536        let metrics = evaluate_visual_ner(&gold, &pred, &config);
537
538        assert!((metrics.text_f1 - 1.0).abs() < 0.001);
539        assert!((metrics.e2e_f1 - 1.0).abs() < 0.001);
540    }
541
542    #[test]
543    fn test_evaluate_text_only_match() {
544        let gold = vec![VisualGold::new(
545            "Invoice",
546            "DOC",
547            BoundingBox::new(0.1, 0.1, 0.3, 0.15),
548        )];
549        let pred = vec![VisualPrediction {
550            text: "Invoice".to_string(),
551            entity_type: "DOC".to_string(),
552            bbox: BoundingBox::new(0.5, 0.5, 0.7, 0.6), // Different box
553            confidence: 0.95,
554        }];
555
556        let config = VisualEvalConfig::default();
557        let metrics = evaluate_visual_ner(&gold, &pred, &config);
558
559        assert!((metrics.text_f1 - 1.0).abs() < 0.001);
560        assert!(metrics.e2e_f1 < 0.5); // E2E should fail due to box mismatch
561    }
562
563    #[test]
564    fn test_synthetic_examples_valid() {
565        let examples = synthetic_visual_examples();
566        assert!(!examples.is_empty());
567
568        for (text, entities) in &examples {
569            assert!(!text.is_empty());
570            for entity in entities {
571                // Valid bounding box coordinates
572                assert!(entity.bbox.x1 >= 0.0 && entity.bbox.x1 <= 1.0);
573                assert!(entity.bbox.y1 >= 0.0 && entity.bbox.y1 <= 1.0);
574                assert!(entity.bbox.x2 >= entity.bbox.x1 && entity.bbox.x2 <= 1.0);
575                assert!(entity.bbox.y2 >= entity.bbox.y1 && entity.bbox.y2 <= 1.0);
576            }
577        }
578    }
579}