File size: 3,903 Bytes
087643a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# Detections -> structured summary. Pure functions only, no I/O, no model
# calls, nothing that touches pixels - this is what makes it true that the
# LLM downstream can't hallucinate what's on the page: it never sees it,
# only this summary.
from __future__ import annotations

from dataclasses import dataclass, field

from app.schemas import Detection


@dataclass
class ConfStats:
    max: float
    mean: float
    min: float


@dataclass
class Relation:
    """A spatial fact between two regions, e.g. Caption inside a Table.
    Own type instead of a dict so a typo'd key fails loudly, not silently."""

    kind: str
    subject: str
    object: str


@dataclass
class Evidence:
    class_counts: dict[str, int]
    confidence_stats: dict[str, ConfStats]
    total_detections: int
    reading_order: list[str]
    relations: list[Relation] = field(default_factory=list)
    # Raw scores per class - guardrail's ambiguous-band rule needs the full
    # distribution, not just max/mean/min.
    confidences_by_class: dict[str, list[float]] = field(default_factory=dict)


def _box_area(detection: Detection) -> float:
    b = detection.bbox
    return (b.x2 - b.x1) * (b.y2 - b.y1)


def _contains(outer: Detection, inner: Detection, threshold: float = 0.85) -> bool:
    """True if inner sits mostly (85%+) inside outer. Not 100% - annotators
    don't draw pixel-perfect boxes, a caption box a few px outside its
    table shouldn't make the relationship disappear."""
    ox1, oy1, ox2, oy2 = outer.bbox.x1, outer.bbox.y1, outer.bbox.x2, outer.bbox.y2
    ix1, iy1, ix2, iy2 = inner.bbox.x1, inner.bbox.y1, inner.bbox.x2, inner.bbox.y2

    overlap_x1, overlap_y1 = max(ox1, ix1), max(oy1, iy1)
    overlap_x2, overlap_y2 = min(ox2, ix2), min(oy2, iy2)
    if overlap_x2 <= overlap_x1 or overlap_y2 <= overlap_y1:
        return False

    overlap_area = (overlap_x2 - overlap_x1) * (overlap_y2 - overlap_y1)
    inner_area = _box_area(inner)
    return inner_area > 0 and (overlap_area / inner_area) >= threshold


def build_evidence(detections: list[Detection], image_width: int, image_height: int) -> Evidence:
    """Main entry point: detections -> Evidence.

    image_width/height aren't used yet - kept for when a relation needs
    page-edge position (e.g. "is this near the top" for Page-header)."""
    class_counts: dict[str, int] = {}
    confidences_by_class: dict[str, list[float]] = {}

    for detection in detections:
        class_counts[detection.class_name] = class_counts.get(detection.class_name, 0) + 1
        confidences_by_class.setdefault(detection.class_name, []).append(detection.confidence)

    confidence_stats = {
        class_name: ConfStats(
            max=max(values), mean=sum(values) / len(values), min=min(values)
        )
        for class_name, values in confidences_by_class.items()
    }

    # Top-to-bottom, then left-to-right. Not real reading-order logic - a
    # two-column page would interleave columns wrong under this sort. Good
    # enough for DocLayNet's mostly-single-column pages, noted in the memo.
    ordered = sorted(detections, key=lambda det: (det.bbox.y1, det.bbox.x1))
    reading_order = [det.class_name for det in ordered]

    relations: list[Relation] = []
    for outer in detections:
        for inner in detections:
            if outer is inner:
                continue
            if _box_area(inner) >= _box_area(outer):
                continue  # can't "contain" something the same size or bigger
            if _contains(outer, inner):
                relations.append(Relation(kind="contains", subject=outer.class_name, object=inner.class_name))

    return Evidence(
        class_counts=class_counts,
        confidence_stats=confidence_stats,
        total_detections=len(detections),
        reading_order=reading_order,
        relations=relations,
        confidences_by_class=confidences_by_class,
    )