#!/usr/bin/env python3 """v12 ULTIMATE cross-source manifest. Integrates EVERY valid source: 1. Hitit original (17k) + aug (5k) 2. Pseudo from unsup cluster (36k) 3. Pseudo iter2 from ensemble (3k) 4. eBL classification overlap (43k, cap 500/cls) 5. OB zip after extraction (55k → capped) 6. DeepScribe classification overlap (3k) 7. eBL LMU 158k metadata (new!) — filter for Hitit-label overlap 8. compvis bbox extracted — mzl→abz mapping where possible """ import json, argparse, os, re from pathlib import Path from collections import Counter ROOT = Path("/arf/scratch/stakan/hitit-proje") def load_mzl_abz_map(path=None): """Load MZL→ABZ mapping JSON built from Oracc OSL. Returns (mzl_to_sign, mzl_to_abz) where: - mzl_to_sign: "839" → "KUR" (canonical sign name, Hitit-compatible) - mzl_to_abz: "839" → "470" (ABZ/ABZL number) """ if path is None: path = ROOT / 'hitit_ocr/data/mzl_abz_map.json' if not Path(path).exists(): return {}, {} with open(path) as f: d = json.load(f) return d.get('mzl_to_sign', {}), d.get('mzl_to_abz', {}) def extract_compvis_bboxes(label_to_idx, cap_per_label=200, mzl_map=None): """Extract sign bboxes from compvis manifest to per-bbox classification records. Uses the MZL→ABZ sign mapping built from Oracc OSL to translate compvis `mzl_label` numerical codes (e.g. "839") into canonical sign names (e.g. "KUR"). Only records whose mapped sign name exists in `label_to_idx` are kept. """ mzl_to_sign = mzl_map or {} src_path = ROOT / 'datasets/sources/compvis/manifest.jsonl' if not src_path.exists(): return [] out = [] per_label = Counter() n_mapped = 0; n_unmapped = 0 for line in open(src_path): r = json.loads(line) if r.get('task') != 'detection': continue extra = r.get('extra') or {} bboxes = extra.get('bboxes', []) # Find image path. compvis images live in cdli/.jpg; # manifest stores image_name like "P334926_Obv" (strip _Obv/_Rev). img_path = r.get('path') if img_path is None or not os.path.exists(img_path): iname = r.get('image_name') or '' tid = r.get('tablet_id') or '' base = re.sub(r'_(Obv|Rev|obverse|reverse)$', '', iname) cand_names = [base, tid, iname] img_path = None for name in cand_names: if not name: continue for sub in ('cdli', 'saa05', 'test'): c = ROOT / f'datasets/sources/compvis/cuneiform-sign-detection-dataset/images/{sub}/{name}.jpg' if c.exists(): img_path = str(c); break if img_path: break if not img_path or not os.path.exists(img_path): continue for b in bboxes: mzl = str(b.get('mzl_label', '')).strip() if not mzl: continue # Try (1) direct match, (2) MZL-map translation to sign name if mzl in label_to_idx: lab = mzl else: lab = mzl_to_sign.get(mzl) or mzl_to_sign.get(mzl.lstrip('0')) if not lab or lab not in label_to_idx: n_unmapped += 1; continue n_mapped += 1 if per_label[lab] >= cap_per_label: continue bb = b.get('bbox') or b.get('relative_bbox') if not bb or len(bb) != 4: continue out.append({ 'task': 'classification', 'storage': 'fs', 'path': img_path, 'bbox': bb, 'unified_label': lab, 'cross_source': True, 'cross_source_origin': 'compvis_bbox', 'tablet_view_fold': 1, 'integrity_ok': True, }) per_label[lab] += 1 print(f" [compvis] bbox records: {len(out)} (mzl-mapped: {n_mapped}, unmapped: {n_unmapped})") return out def extract_yeni_veri_bboxes(label_to_idx, cap_per_label=500): """yeni_veri: 38 Hittite tablets with YOLO bboxes + class_name (ABZ-compatible). Each bbox becomes a classification record (native Hittite).""" src_path = ROOT / 'datasets/sources/yeni_veri/manifest.jsonl' if not src_path.exists(): return [] out = []; per_label = Counter() for line in open(src_path): try: r = json.loads(line) except: continue if r.get('task') != 'detection': continue img_path = r.get('path') or '' if img_path and not os.path.isabs(img_path): img_path = str(ROOT / img_path) if not os.path.exists(img_path): continue extra = r.get('extra') or {} for b in extra.get('bboxes', []): lab = b.get('class_name') if not lab or lab not in label_to_idx: continue if per_label[lab] >= cap_per_label: continue yb = b.get('yolo_bbox') if not yb or len(yb) != 4: continue out.append({ 'task': 'classification', 'storage': 'fs', 'path': img_path, 'bbox': yb, 'bbox_format': 'yolo', 'unified_label': lab, 'cross_source': True, 'cross_source_origin': 'yeni_veri', 'tablet_view_fold': 1, 'integrity_ok': True, 'period': 'Hittite', }) per_label[lab] += 1 print(f" [yeni_veri] bbox records: {len(out)}") return out def extract_maicubeda(label_to_idx, cap_per_label=300): """maicubeda: 27.5k classification crops (char-level). Filter by label overlap, keep fs storage only.""" src_path = ROOT / 'datasets/sources/maicubeda/manifest_classification.jsonl' if not src_path.exists(): src_path = ROOT / 'datasets/sources/maicubeda/manifest.jsonl' if not src_path.exists(): return [] out = []; per_label = Counter() for line in open(src_path): try: r = json.loads(line) except: continue if r.get('task') != 'classification': continue if r.get('storage') != 'fs': continue if r.get('integrity_ok') is False: continue lab = r.get('unified_label') if not lab or lab not in label_to_idx: continue if per_label[lab] >= cap_per_label: continue p = r.get('path') if not p or not os.path.exists(p): continue out.append({ 'task': 'classification', 'storage': 'fs', 'path': p, 'unified_label': lab, 'cross_source': True, 'cross_source_origin': 'maicubeda', 'tablet_view_fold': 1, 'integrity_ok': True, }) per_label[lab] += 1 print(f" [maicubeda] records: {len(out)}") return out def extract_ebl_lmu(label_to_idx, cap_per_label=200, max_take=80000): """Extract Hitit-overlapping records from eBL LMU 158k dataset. After tar extraction the jpeg files sit directly in datasets/sources/ebl_lmu/<_id>.jpeg (no snippets/ subdir). We also normalize signName: eBL uses 'ŠU₂' with Unicode subscripts while Hittite labels are plain ASCII ('ŠU2'), so we try both forms. """ meta_path = ROOT / 'datasets/sources/ebl_lmu/metadata.json' if not meta_path.exists(): return [] archive_dirs = [ ROOT / 'datasets/sources/ebl_lmu', ROOT / 'datasets/sources/ebl_lmu/snippets', ROOT / 'datasets/sources/ebl_ocr/v2_20251219/snippets', ] archive_dir = next((d for d in archive_dirs if d.exists()), None) if archive_dir is None: return [] # Unicode subscript → ASCII digit map for signName normalization sub_map = str.maketrans('₀₁₂₃₄₅₆₇₈₉', '0123456789') with open(meta_path) as f: meta = json.load(f) if not isinstance(meta, list): return [] out = []; per_label = Counter() n_label_miss = 0; n_file_miss = 0 for r in meta: raw_sn = r.get('signName') or '' # Try (1) raw, (2) ASCII-subscript form, (3) uppercase for cand in (raw_sn, raw_sn.translate(sub_map), raw_sn.upper().translate(sub_map)): if cand in label_to_idx: sn = cand; break else: n_label_miss += 1; continue if per_label[sn] >= cap_per_label: continue if len(out) >= max_take: break sid = r.get('_id', '') # Expected file: {archive_dir}/{_id}.jpeg (tar stored them flat) p_candidates = [archive_dir / f'{sid}.jpeg', archive_dir / f'{sid}.jpg'] p = next((x for x in p_candidates if x.exists()), None) if p is None: n_file_miss += 1; continue out.append({ 'task': 'classification', 'storage': 'fs', 'path': str(p), 'unified_label': sn, 'cross_source': True, 'cross_source_origin': 'ebl_lmu', 'tablet_view_fold': 1, 'integrity_ok': True, 'period': r.get('script') or 'Mesopotamian', }) per_label[sn] += 1 print(f" [ebl_lmu] records: {len(out)} (label_miss={n_label_miss}, file_miss={n_file_miss})") return out def main(): ap = argparse.ArgumentParser() ap.add_argument('--base-manifest', required=True, help='Base Hitit manifest to extend') ap.add_argument('--label-to-idx', required=True, help='v4 ckpt for label set') ap.add_argument('--output', required=True) ap.add_argument('--include-pseudo', action='store_true', default=True) ap.add_argument('--cap-per-label', type=int, default=500) ap.add_argument('--mzl-map', default=str(ROOT / 'hitit_ocr/data/mzl_abz_map.json')) args = ap.parse_args() import torch ck = torch.load(args.label_to_idx, map_location='cpu', weights_only=False) l2i = ck['label_to_idx'] print(f"Hitit classes: {len(l2i)}") mzl_to_sign, _ = load_mzl_abz_map(args.mzl_map) print(f"MZL→sign mappings: {len(mzl_to_sign)}") added = Counter() with open(args.output, 'w') as out: # Base with open(args.base_manifest) as f: for line in f: out.write(line); added['base'] += 1 # Already integrated pseudo manifests (check they exist and not already in base) for src_name, src_path in [ ('unsup_cluster', ROOT / 'hitit_ocr/runs/h100/unsup_cluster_v4/pseudo_labels.jsonl'), ('pseudo_iter2_ebl', ROOT / 'datasets/sources/hitit_local/manifest_pseudo_iter2_ebl.jsonl'), ]: if not src_path.exists(): continue with open(src_path) as f: for line in f: try: r = json.loads(line) except: continue if r.get('unified_label') not in l2i: continue if r.get('tablet_view_fold', 0) == 0: r['tablet_view_fold'] = 1 out.write(json.dumps(r) + '\n'); added[src_name] += 1 # Cross-source from previously-built v11 (Hitit + eBL + OB + DS) # Reuse build logic: read each source, filter by overlap for src in ['ebl_ocr', 'old_babylonian_signs', 'deepscribe']: # Check both regular and fs-rewritten manifest src_mf = ROOT / f'datasets/sources/{src}/manifest_fs.jsonl' if not src_mf.exists(): src_mf = ROOT / f'datasets/sources/{src}/manifest.jsonl' if not src_mf.exists(): continue per_label = Counter() with open(src_mf) as f: for line in f: try: r = json.loads(line) except: continue if r.get('task') != 'classification': continue lab = r.get('unified_label') if not lab or lab not in l2i: continue if r.get('storage') != 'fs' or not r.get('path'): continue if r.get('integrity_ok') is False: continue if per_label[lab] >= args.cap_per_label: continue r['cross_source'] = True; r['cross_source_origin'] = src r['tablet_view_fold'] = 1 out.write(json.dumps(r) + '\n'); added[src] += 1 per_label[lab] += 1 # eBL LMU 158k ebl_rows = extract_ebl_lmu(l2i, cap_per_label=args.cap_per_label, max_take=30000) for r in ebl_rows: out.write(json.dumps(r) + '\n') added['ebl_lmu'] = len(ebl_rows) # compvis bbox with MZL→ABZ map cv_rows = extract_compvis_bboxes(l2i, cap_per_label=args.cap_per_label, mzl_map=mzl_to_sign) for r in cv_rows: out.write(json.dumps(r) + '\n') added['compvis_bbox'] = len(cv_rows) # yeni_veri: Hitit tablet bboxes with native ABZ class_name yv_rows = extract_yeni_veri_bboxes(l2i, cap_per_label=args.cap_per_label) for r in yv_rows: out.write(json.dumps(r) + '\n') added['yeni_veri'] = len(yv_rows) # maicubeda: char-level classification crops mai_rows = extract_maicubeda(l2i, cap_per_label=args.cap_per_label) for r in mai_rows: out.write(json.dumps(r) + '\n') added['maicubeda'] = len(mai_rows) print(f"\nManifest v12 breakdown:") total = 0 for k, v in added.items(): print(f" {k}: {v}") total += v print(f" TOTAL: {total}") print(f"Output: {args.output}") if __name__ == '__main__': main()