RISHIVEL's picture
space deploy: fix zerogpu pickling
087643a
Raw History Blame Contribute Delete
3.9 kB
# 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,
)