| |
| """Build 2-row collages for the top confusion pairs from conformal_v13.json. |
| |
| Usage: python confusion_pair_collage.py |
| Outputs: runs/h100/confusion_pairs/pair_{A}_vs_{B}.png and README.md |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import random |
| import re |
| from collections import defaultdict |
| from pathlib import Path |
|
|
| from PIL import Image, ImageDraw, ImageFont |
|
|
| ROOT = Path("/arf/scratch/stakan/hitit-proje") |
| CONFORMAL = ROOT / "hitit_ocr/runs/h100/conformal_v13.json" |
| MANIFEST = ROOT / "datasets/sources/hitit_local/manifest_v13_ultimate.jsonl" |
| OUT_DIR = ROOT / "hitit_ocr/runs/h100/confusion_pairs" |
|
|
| TOP_K = 15 |
| N_PER_ROW = 8 |
| MIN_SAMPLES = 4 |
| TILE = 128 |
| HEADER_H = 40 |
| ROW_LABEL_W = 80 |
| SEED = 42 |
|
|
|
|
| def safe_name(s: str) -> str: |
| return re.sub(r"[^A-Za-z0-9]+", "_", s).strip("_") or "X" |
|
|
|
|
| def load_pairs() -> list[tuple[str, str, int]]: |
| data = json.loads(CONFORMAL.read_text()) |
| pairs = sorted(data.get("top_confusion_pairs", []), key=lambda p: -int(p["count"])) |
| out, seen = [], set() |
| for p in pairs: |
| a, b = p["true"], p["pred"] |
| key = tuple(sorted([a, b])) |
| if key in seen: |
| continue |
| seen.add(key) |
| out.append((a, b, int(p["count"]))) |
| if len(out) >= TOP_K: |
| break |
| return out |
|
|
|
|
| def index_manifest(needed: set[str]) -> dict[str, list[str]]: |
| by_label: dict[str, list[str]] = defaultdict(list) |
| with MANIFEST.open("r") as fh: |
| for line in fh: |
| if not line.strip(): |
| continue |
| try: |
| rec = json.loads(line) |
| except json.JSONDecodeError: |
| continue |
| lbl = rec.get("unified_label") or rec.get("label_norm") |
| path = rec.get("path") |
| if lbl in needed and path: |
| by_label[lbl].append(path) |
| return by_label |
|
|
|
|
| def load_tile(p: str) -> Image.Image | None: |
| try: |
| im = Image.open(p).convert("RGB") |
| except Exception: |
| return None |
| im.thumbnail((TILE, TILE), Image.LANCZOS) |
| canvas = Image.new("RGB", (TILE, TILE), (255, 255, 255)) |
| canvas.paste(im, ((TILE - im.width) // 2, (TILE - im.height) // 2)) |
| return canvas |
|
|
|
|
| def get_font(size: int): |
| for c in ("/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"): |
| try: |
| return ImageFont.truetype(c, size) |
| except Exception: |
| pass |
| return ImageFont.load_default() |
|
|
|
|
| def make_row(paths: list[str]) -> list[Image.Image]: |
| tiles = [] |
| for p in paths: |
| t = load_tile(p) |
| if t is not None: |
| tiles.append(t) |
| if len(tiles) >= N_PER_ROW: |
| break |
| return tiles |
|
|
|
|
| def build_collage(a: str, b: str, count: int, ap: list[str], bp: list[str]) -> Image.Image: |
| w, h = ROW_LABEL_W + N_PER_ROW * TILE, HEADER_H + 2 * TILE |
| img = Image.new("RGB", (w, h), (245, 245, 245)) |
| draw = ImageDraw.Draw(img) |
| tf, lf = get_font(22), get_font(18) |
| draw.rectangle([0, 0, w, HEADER_H], fill=(30, 30, 30)) |
| draw.text((10, 8), f"Confusion pair: {a} vs {b} (count={count})", fill=(255, 255, 255), font=tf) |
| for ri, (lbl, tiles) in enumerate(((a, make_row(ap)), (b, make_row(bp)))): |
| y0 = HEADER_H + ri * TILE |
| draw.rectangle([0, y0, ROW_LABEL_W, y0 + TILE], fill=(60, 60, 60)) |
| draw.text((8, y0 + TILE // 2 - 10), lbl, fill=(255, 255, 255), font=lf) |
| for i, t in enumerate(tiles): |
| img.paste(t, (ROW_LABEL_W + i * TILE, y0)) |
| return img |
|
|
|
|
| def main() -> None: |
| random.seed(SEED) |
| OUT_DIR.mkdir(parents=True, exist_ok=True) |
| pairs = load_pairs() |
| needed = {x for a, b, _ in pairs for x in (a, b)} |
| print(f"[info] indexing manifest for {len(needed)} labels ...") |
| by_label = index_manifest(needed) |
| for lbl, lst in by_label.items(): |
| random.shuffle(lst) |
| produced, skipped = [], [] |
| for a, b, count in pairs: |
| ap = by_label.get(a, [])[:N_PER_ROW] |
| bp = by_label.get(b, [])[:N_PER_ROW] |
| if len(ap) < MIN_SAMPLES or len(bp) < MIN_SAMPLES: |
| skipped.append((a, b, count, len(ap), len(bp))) |
| continue |
| img = build_collage(a, b, count, ap, bp) |
| fname = f"pair_{safe_name(a)}_vs_{safe_name(b)}.png" |
| img.save(OUT_DIR / fname) |
| produced.append((a, b, count, fname)) |
| print(f"[ok] {a} vs {b} (n={count}) -> {fname}") |
| lines = ["# Confusion Pair Collages", "", f"Source: `{CONFORMAL}`", |
| f"Top-{TOP_K} unique pairs (by count).", "", |
| "| true | pred | count | collage |", "|---|---|---|---|"] |
| lines += [f"| {a} | {b} | {c} | `{fn}` |" for a, b, c, fn in produced] |
| if skipped: |
| lines += ["", "## Skipped (insufficient samples)", |
| "| true | pred | count | n_true | n_pred |", "|---|---|---|---|---|"] |
| lines += [f"| {a} | {b} | {c} | {na} | {nb} |" for a, b, c, na, nb in skipped] |
| (OUT_DIR / "README.md").write_text("\n".join(lines) + "\n") |
| print(f"[done] {len(produced)} collages, {len(skipped)} skipped -> {OUT_DIR}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|