#!/usr/bin/env python3 """ Restore missing hitit_ocr/data/classification/all//_.png crops. Legacy prepare_data.py (deleted runs) produced crops from datasets/sources/hitit_local//{image}+mark.txt using enumerate(info["signs"]) with info["signs"] = deduplicate(parse_annotations_format(data)). The manifest_v13_ultimate.jsonl records reference those exact filenames. We parse mark.txt the same way, recompute bbox list, and write crops back to hitit_ocr/data/classification/all//_.png. Usage: python restore_hitit_crops.py --check TID # dry-run one tablet python restore_hitit_crops.py --verify # compare parsed bbox list vs manifest indices python restore_hitit_crops.py # full restore """ import argparse import json import re import sys from collections import Counter, defaultdict from pathlib import Path from PIL import Image, ImageFile ImageFile.LOAD_TRUNCATED_IMAGES = True MIN_CROP_SIZE = 8 CONTEXT_PAD_RATIO = 0.15 TINY_BBOX_THRESHOLD = 0.005 PROJECT = Path("/arf/scratch/stakan/hitit-proje") SOURCES = PROJECT / "datasets/sources/hitit_local" OUT_ALL = PROJECT / "hitit_ocr/data/classification/all" MANIFEST = SOURCES / "manifest_v13_ultimate.jsonl" def normalize_sign(s): """Mirror of legacy prepare_data.py normalize_sign (ham: sadece trim + sayı/rare filtresi).""" if s is None: return None s = s.strip() s = s.rstrip(".,;: ") s = s.strip() if not s or s in ["/", ".", ",", "-", "(X)", "(x)", "X", "x", "?", ""]: return None if s.isdigit(): return None if len(s) == 1 and not s.isalpha(): return None return s def parse_annotations(data): results = [] for ann in data.get("annotations", []): sign = normalize_sign(ann.get("comment", "")) if not sign: continue m = ann.get("mark", {}) x, y, w, h = m.get("x", 0), m.get("y", 0), m.get("width", 0), m.get("height", 0) if w < 0: x += w w = abs(w) if h < 0: y += h h = abs(h) if w > MIN_CROP_SIZE and h > MIN_CROP_SIZE: results.append((x, y, w, h, sign)) return dedup(results) def parse_spots(data, img_w, img_h): results = [] for sp in data.get("spots", []): title = sp.get("title", "") if "_" not in title: continue sign = normalize_sign(title.split("_", 1)[1]) if not sign: continue x = sp.get("x", 0) / 100 * img_w y = sp.get("y", 0) / 100 * img_h w = sp.get("width", 0) / 100 * img_w h = sp.get("height", 0) / 100 * img_h if w > MIN_CROP_SIZE and h > MIN_CROP_SIZE: results.append((x, y, w, h, sign)) return dedup(results) def dedup(signs): seen = set() out = [] for e in signs: k = (round(e[0], 2), round(e[1], 2), round(e[2], 2), round(e[3], 2), e[4]) if k not in seen: seen.add(k) out.append(e) return out def load_tablet_signs(tid): """Return (image_path, width, height, signs[]) for tablet dir .""" tdir = SOURCES / tid if not tdir.is_dir(): return None mark = tdir / "mark.txt" if not mark.exists() or mark.stat().st_size == 0: return None img_path = None for f in sorted(tdir.iterdir()): if f.suffix.lower() in (".jpg", ".jpeg", ".png") and not f.is_symlink(): img_path = f break if f.suffix.lower() in (".jpg", ".jpeg", ".png"): img_path = f.resolve() break if not img_path or not img_path.exists(): return None try: with Image.open(img_path) as im: w, h = im.size except Exception: return None try: data = json.loads(mark.read_text()) except Exception: return None if "annotations" in data: signs = parse_annotations(data) elif "spots" in data: signs = parse_spots(data, w, h) else: return None return str(img_path), w, h, signs def safe_name(s): for a, b in [("/", "_SLASH_"), ("\\", "_BSLASH_"), (":", "_COLON_"), ("*", "_STAR_"), ("?", "_QMARK_"), ('"', "_DQUOTE_"), ("<", "_LT_"), (">", "_GT_"), ("|", "_PIPE_"), (" ", "_")]: s = s.replace(a, b) return s if s else "_EMPTY_" def manifest_records_by_tablet(): """Group missing hitit manifest entries by tablet_id. Only include those under classification/all/.""" by_tid = defaultdict(list) with open(MANIFEST) as f: for line in f: r = json.loads(line) if r.get("source") != "hitit": continue p = r.get("path", "") if "/classification/all/" not in p: continue # Derive tid from filename (more reliable than r["tablet_id"] # which has known mislabels, e.g. path=116.2_26.png with tablet_id="4"). stem = Path(p).stem # e.g. 116.2_26 parts = stem.rsplit("_", 1) if len(parts) == 2 and parts[1].isdigit(): tid = parts[0] else: tid = r.get("tablet_id") or stem by_tid[tid].append(r) return by_tid def restore_tablet(tid, records, verify_only=False): """For a tablet, parse bbox list, then write each record's crop. Returns dict of stats.""" t = load_tablet_signs(tid) if t is None: return {"tid": tid, "status": "no_tablet", "n_records": len(records)} img_path, w, h, signs = t # Build filename → idx map from records # File name pattern: _.png stats = {"tid": tid, "n_signs": len(signs), "n_records": len(records), "written": 0, "skipped_label_mismatch": 0, "idx_out_of_range": 0, "already_exists": 0, "save_failed": 0} if verify_only: # Check idx vs manifest label/size by_idx = {} for r in records: fn = Path(r["path"]).stem # e.g. 100.2_116 parts = fn.rsplit("_", 1) if len(parts) != 2 or not parts[1].isdigit(): continue idx = int(parts[1]) by_idx[idx] = r matched = 0 sz_mismatch = 0 label_mismatch_examples = [] for idx, r in by_idx.items(): if idx >= len(signs): stats["idx_out_of_range"] += 1 continue x, y, bw, bh, sign = signs[idx] # Expected pad px = bw * CONTEXT_PAD_RATIO py = bh * CONTEXT_PAD_RATIO x1 = max(0, int(x - px)); y1 = max(0, int(y - py)) x2 = min(w, int(x + bw + px)); y2 = min(h, int(y + bh + py)) expect_w = x2 - x1; expect_h = y2 - y1 man_w = r.get("width"); man_h = r.get("height") if man_w != expect_w or man_h != expect_h: sz_mismatch += 1 matched += 1 stats["verify_matched"] = matched stats["verify_size_mismatch"] = sz_mismatch return stats # Open image once try: img = Image.open(img_path).convert("RGB") except Exception: return {**stats, "status": "img_open_failed"} for r in records: fn = Path(r["path"]).stem parts = fn.rsplit("_", 1) if len(parts) != 2 or not parts[1].isdigit(): continue idx = int(parts[1]) if idx >= len(signs): stats["idx_out_of_range"] += 1 continue x, y, bw, bh, sign = signs[idx] px = bw * CONTEXT_PAD_RATIO py = bh * CONTEXT_PAD_RATIO x1 = max(0, int(x - px)) y1 = max(0, int(y - py)) x2 = min(w, int(x + bw + px)) y2 = min(h, int(y + bh + py)) if x2 - x1 < MIN_CROP_SIZE or y2 - y1 < MIN_CROP_SIZE: continue out = Path(r["path"]) if out.exists(): stats["already_exists"] += 1 continue out.parent.mkdir(parents=True, exist_ok=True) try: img.crop((x1, y1, x2, y2)).save(str(out)) stats["written"] += 1 except Exception as e: stats["save_failed"] += 1 img.close() return stats def main(): ap = argparse.ArgumentParser() ap.add_argument("--check", help="dry-run one tablet") ap.add_argument("--verify", action="store_true", help="parse all tablets, compare bbox sizes vs manifest") ap.add_argument("--limit", type=int, default=0, help="process at most N tablets") ap.add_argument("--workers", type=int, default=8) args = ap.parse_args() by_tid = manifest_records_by_tablet() tids = sorted(by_tid.keys()) print(f"[plan] {len(tids)} tablets, {sum(len(v) for v in by_tid.values())} crops to restore") if args.check: tid = args.check t = load_tablet_signs(tid) if t is None: print(f"tablet {tid} not loadable"); return img_path, w, h, signs = t print(f"tablet {tid}: img={img_path} size={w}x{h} bbox_count={len(signs)}") print("first 3 bboxes:", signs[:3]) print("records for this tablet:", len(by_tid.get(tid, []))) if by_tid.get(tid): print("first record:", json.dumps(by_tid[tid][0], ensure_ascii=False)[:200]) return if args.verify: agg = Counter() for i, tid in enumerate(tids): st = restore_tablet(tid, by_tid[tid], verify_only=True) agg["tablets"] += 1 for k in ("n_records", "verify_matched", "verify_size_mismatch", "idx_out_of_range"): agg[k] += st.get(k, 0) if args.limit and i + 1 >= args.limit: break print("VERIFY:", dict(agg)) return # Full restore import multiprocessing as mp work = [(tid, by_tid[tid]) for tid in tids] if args.limit: work = work[:args.limit] agg = Counter() if args.workers <= 1: for i, (tid, recs) in enumerate(work): st = restore_tablet(tid, recs) for k, v in st.items(): if isinstance(v, int): agg[k] += v if (i + 1) % 20 == 0: print(f" [{i+1}/{len(work)}] written={agg['written']} skipped_idx={agg['idx_out_of_range']}") else: with mp.Pool(args.workers) as pool: for i, st in enumerate(pool.starmap(restore_tablet, work)): for k, v in st.items(): if isinstance(v, int): agg[k] += v if (i + 1) % 20 == 0: print(f" [{i+1}/{len(work)}] written={agg['written']} skipped_idx={agg['idx_out_of_range']}") print("DONE:", dict(agg)) if __name__ == "__main__": main()