1use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
29
30#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
39pub struct BoundingBox {
40 pub x1: f32,
42 pub y1: f32,
44 pub x2: f32,
46 pub y2: f32,
48}
49
50impl BoundingBox {
51 pub fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
53 Self { x1, y1, x2, y2 }
54 }
55
56 pub fn area(&self) -> f32 {
58 (self.x2 - self.x1).max(0.0) * (self.y2 - self.y1).max(0.0)
59 }
60
61 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 pub fn overlaps(&self, other: &BoundingBox, threshold: f32) -> bool {
83 self.iou(other) >= threshold
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct VisualGold {
94 pub text: String,
96 pub entity_type: String,
98 pub bbox: BoundingBox,
100}
101
102impl VisualGold {
103 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#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct VisualPrediction {
116 pub text: String,
118 pub entity_type: String,
120 pub bbox: BoundingBox,
122 pub confidence: f32,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct VisualEvalConfig {
133 pub iou_threshold: f32,
135 pub case_insensitive: bool,
137 pub normalize_whitespace: bool,
139 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#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct VisualNERMetrics {
161 pub text_precision: f64,
164 pub text_recall: f64,
166 pub text_f1: f64,
168
169 pub mean_iou: f64,
172 pub box_precision: f64,
174 pub box_recall: f64,
176 pub box_f1: f64,
178
179 pub e2e_precision: f64,
182 pub e2e_recall: f64,
184 pub e2e_f1: f64,
186
187 pub per_type: HashMap<String, VisualTypeMetrics>,
190
191 pub num_predicted: usize,
194 pub num_gold: usize,
196 pub text_matches: usize,
198 pub box_matches: usize,
200 pub e2e_matches: usize,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct VisualTypeMetrics {
207 pub entity_type: String,
209 pub text_f1: f64,
211 pub box_f1: f64,
213 pub e2e_f1: f64,
215 pub support: usize,
217}
218
219pub 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 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 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 for p in pred {
268 let pred_text = normalize_text(&p.text, config);
269
270 for (g_idx, g) in gold.iter().enumerate() {
271 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 if iou > 0.0 {
283 iou_sum += iou as f64;
284 iou_count += 1;
285 }
286
287 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 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 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; }
314 }
315 }
316
317 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 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
421fn 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
444pub 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#[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 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), 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); }
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 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}