OCR-with-JEV / visualize.py
Tonic's picture
Upload 11 files
db9f6ee verified
Raw History Blame Contribute Delete
5.4 kB
"""Draw Jev correctness overlays and review-routing summaries."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Sequence
from PIL import Image, ImageDraw, ImageFont
@dataclass
class ScoredRegion:
index: int
label: str
category: str
box: tuple[int, int, int, int]
text: str
formatted: str
score: float | None
verdict: str
detail: str = ""
def _font(size: int = 16) -> ImageFont.ImageFont:
for name in (
"DejaVuSans-Bold.ttf",
"Arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"C:\\Windows\\Fonts\\arialbd.ttf",
"C:\\Windows\\Fonts\\arial.ttf",
):
try:
return ImageFont.truetype(name, size=size)
except OSError:
continue
return ImageFont.load_default()
def route(score: float | None, verdict: str, threshold: float) -> str:
if verdict != "scored" or score is None:
return "error"
if score >= threshold:
return "accept"
if score >= max(0.0, threshold - 0.15):
return "borderline"
return "review"
def route_color(route_name: str) -> tuple[int, int, int]:
return {
"accept": (22, 163, 74),
"borderline": (202, 138, 4),
"review": (220, 38, 38),
"error": (107, 114, 128),
"skip": (156, 163, 175),
}.get(route_name, (107, 114, 128))
def draw_overlay(
page: Image.Image,
regions: Sequence[ScoredRegion],
threshold: float,
) -> Image.Image:
canvas = page.convert("RGB").copy()
draw = ImageDraw.Draw(canvas, "RGBA")
label_font = _font(15)
for region in regions:
x1, y1, x2, y2 = region.box
if region.score is None and region.verdict == "skipped":
color = route_color("skip")
tag = "skip"
else:
name = route(region.score, region.verdict, threshold)
color = route_color(name)
if region.verdict == "scored" and region.score is not None:
tag = f"{region.score:.2f}"
else:
tag = region.verdict
outline = (*color, 230)
fill = (*color, 36)
draw.rectangle([x1, y1, x2, y2], outline=outline, width=3, fill=fill)
badge = f"#{region.index} {tag}"
tw = max(48, int(draw.textlength(badge, font=label_font)) + 10)
th = 20
by1 = max(0, y1 - th - 2)
draw.rectangle([x1, by1, x1 + tw, by1 + th], fill=(*color, 230))
draw.text((x1 + 5, by1 + 2), badge, fill=(255, 255, 255), font=label_font)
return canvas
def summary_html(regions: Sequence[ScoredRegion], threshold: float) -> str:
scored = [r for r in regions if r.verdict == "scored" and r.score is not None]
accepted = [r for r in scored if r.score >= threshold]
review = [r for r in scored if r.score < threshold]
errors = [r for r in regions if r.verdict not in {"scored", "skipped"}]
skipped = [r for r in regions if r.verdict == "skipped"]
avg = sum(r.score for r in scored) / len(scored) if scored else 0.0
return f"""
<div class="jev-summary">
<div class="jev-stat"><b>{len(scored)}</b><span>scored</span></div>
<div class="jev-stat accept"><b>{len(accepted)}</b><span>auto-accept ≥ {threshold:.2f}</span></div>
<div class="jev-stat review"><b>{len(review)}</b><span>human review</span></div>
<div class="jev-stat"><b>{avg:.3f}</b><span>mean score</span></div>
<div class="jev-stat"><b>{len(errors)}</b><span>errors</span></div>
<div class="jev-stat"><b>{len(skipped)}</b><span>not scored</span></div>
</div>
""".strip()
def regions_table(regions: Sequence[ScoredRegion], threshold: float) -> list[list[str]]:
rows: list[list[str]] = []
for region in regions:
decision = (
"skipped"
if region.verdict == "skipped"
else route(region.score, region.verdict, threshold)
)
score = f"{region.score:.4f}" if region.score is not None else "—"
preview = region.text.replace("\n", " ")
if len(preview) > 120:
preview = preview[:117] + "…"
rows.append(
[
str(region.index),
region.label,
region.category,
score,
region.verdict,
decision,
preview,
]
)
return rows
def split_markdown(regions: Sequence[ScoredRegion], threshold: float) -> tuple[str, str]:
accepted: list[str] = []
review: list[str] = []
for region in regions:
if not region.formatted.strip():
continue
block = f"<!-- #{region.index} {region.label} score={region.score} -->\n{region.formatted}"
if region.verdict == "scored" and region.score is not None and region.score >= threshold:
accepted.append(block)
elif region.verdict == "skipped":
accepted.append(block)
else:
review.append(block)
return "\n\n".join(accepted) or "_No auto-accepted regions._", "\n\n".join(review) or "_Nothing queued for review._"
def legend_html() -> str:
return """
<div class="jev-legend">
<span class="pill accept">Auto-accept</span>
<span class="pill borderline">Borderline</span>
<span class="pill review">Needs review</span>
<span class="pill skip">Not scored</span>
</div>
""".strip()