#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ augment_external_datasets.py Integrate external COCO datasets (e.g., Keremberke / Roboflow / Kaggle) into your TACO→YOLOv8-seg workflow: - Class mapping via label_map.json - Polygons from COCO 'segmentation' (list). If only bounding boxes are available: generate rectangle polygons. - Write YOLOv8-seg labels, link/copy images into target structure - Extend manifests {train,val,test}.txt (informational; not needed for local prefetching) Example: python ml/scripts/augment_external_datasets.py \ --coco external/keremberke/annotations.json \ --images_dir external/keremberke/images \ --out_root ml/datasets/taco \ --manifest_root ml/datasets/taco/manifests \ --label_map ml/configs/label_map.json \ --split_policy train_only Split policies: - train_only (default): put all external material into 'train' - stratify: 80/10/10 (reproducible via --seed) - keep_existing: use 'images', 'annotations' and any existing 'train/val/test' keys like in COCO (if present) """ from __future__ import annotations import argparse, json, os, random, sys, shutil from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from collections import defaultdict, Counter # --- Utils --- def read_json(p: Path) -> Any: return json.loads(p.read_text(encoding="utf-8")) def write_text(p: Path, s: str) -> None: p.parent.mkdir(parents=True, exist_ok=True) p.write_text(s, encoding="utf-8") def safe_symlink_or_copy(src: Path, dst: Path) -> None: dst.parent.mkdir(parents=True, exist_ok=True) if os.name == "nt": if not dst.exists(): shutil.copy2(src, dst) else: try: if dst.exists(): dst.unlink() dst.symlink_to(src.resolve()) except Exception: if not dst.exists(): shutil.copy2(src, dst) # --- Label map --- def load_label_map(label_map_path: Path) -> Dict[str, Any]: lm = read_json(label_map_path) rules = [] for r in lm.get("rules", []): rules.append({ "to": r["to"], "match_any_substring": [s.lower() for s in r.get("match_any_substring", [])], "match_any_regex": r.get("match_any_regex", []), }) lm["rules"] = rules return lm import re def map_class(name: str, label_map: Dict[str, Any]) -> str: n = name.lower().strip() for r in label_map["rules"]: if any(sub in n for sub in r["match_any_substring"]): return r["to"] for pat in r["match_any_regex"]: if re.search(pat, n): return r["to"] return label_map.get("default", "other") # --- COCO helpers --- def index_coco(coco: Dict[str, Any]): imgs = {im["id"]: im for im in coco.get("images", [])} cats = {c["id"]: c for c in coco.get("categories", [])} by_img = defaultdict(list) for a in coco.get("annotations", []): by_img[a["image_id"]].append(a) return imgs, cats, by_img def coco_seg_to_yolo_polys(seg, img_w: int, img_h: int) -> List[List[float]]: polys = [] if isinstance(seg, list): for poly in seg: if not isinstance(poly, list) or len(poly) < 6: continue norm = [] for i, v in enumerate(poly): if i % 2 == 0: x = max(0.0, min(float(v) / img_w, 1.0)) norm.append(x) else: y = max(0.0, min(float(v) / img_h, 1.0)) norm.append(y) polys.append(norm) return polys def bbox_to_rect_poly_xyxy(x1,y1,x2,y2, img_w:int, img_h:int) -> List[float]: # clamp + normalize to [0,1], clockwise rectangle x1n = max(0.0, min(x1 / img_w, 1.0)); y1n = max(0.0, min(y1 / img_h, 1.0)) x2n = max(0.0, min(x2 / img_w, 1.0)); y2n = max(0.0, min(y2 / img_h, 1.0)) return [x1n,y1n, x1n,y2n, x2n,y2n, x2n,y1n] def write_yolo_seg_label(lbl_path: Path, anns: List[dict], cats_by_id: Dict[int, dict], label_map: Dict[str, Any], img_w: int, img_h: int, class_to_index: Dict[str, int]) -> int: lines = [] for a in anns: cat = cats_by_id.get(a.get("category_id")) if not cat: continue mapped = map_class(cat.get("name",""), label_map) if mapped not in class_to_index: # falls Mapping außerhalb eurer Zielklassen liegt -> skip continue cls_id = class_to_index[mapped] wrote = 0 # 1) bevorzugt echte Polygone seg = a.get("segmentation") polys = coco_seg_to_yolo_polys(seg, img_w, img_h) for poly in polys: if len(poly) >= 6: lines.append(" ".join([str(cls_id)] + [f"{p:.6f}" for p in poly])) wrote += 1 # 2) falls keine Polygone und BBox existiert -> Rechteck-Polygon if wrote == 0 and "bbox" in a and isinstance(a["bbox"], (list,tuple)) and len(a["bbox"]) >= 4: x,y,w,h = a["bbox"][:4] rect = bbox_to_rect_poly_xyxy(x, y, x+w, y+h, img_w, img_h) lines.append(" ".join([str(cls_id)] + [f"{p:.6f}" for p in rect])) wrote += 1 if lines: lbl_path.parent.mkdir(parents=True, exist_ok=True) lbl_path.write_text("\n".join(lines) + "\n", encoding="utf-8") return len(lines) # --- Split policies --- def stratified_split(items: List[dict], y: List[str], train_ratio=0.8, val_ratio=0.1, seed=42): rnd = random.Random(seed) by = defaultdict(list) for i,c in enumerate(y): by[c].append(i) train,val,test = [],[],[] for c, idxs in by.items(): rnd.shuffle(idxs) n=len(idxs); ntr=int(round(n*train_ratio)); nv=int(round(n*val_ratio)) train += idxs[:ntr] val += idxs[ntr:ntr+nv] test += idxs[ntr+nv:] for arr in (train,val,test): rnd.shuffle(arr) return train,val,test # --- Main augmentation --- def augment( coco_path: Path, images_dir: Path, out_root: Path, manifest_root: Path, label_map_path: Path, split_policy: str = "train_only", train_ratio: float = 0.8, val_ratio: float = 0.1, seed: int = 42, min_bbox_area_frac: float = 0.0, target_split: str = "auto" ): label_map = load_label_map(label_map_path) target_classes = label_map["target_classes"] class_to_index = {c:i for i,c in enumerate(target_classes)} coco = read_json(coco_path) images_by_id, cats_by_id, anns_by_image = index_coco(coco) # Build items (only those with at least one annotation) items = [] for img_id, im in images_by_id.items(): file_name = im["file_name"] w = im.get("width") or 0 h = im.get("height") or 0 anns = anns_by_image.get(img_id, []) if not anns: continue # optional: filter very small bounding boxes if min_bbox_area_frac > 0 and w>0 and h>0: keep = [] for a in anns: if "bbox" in a: bx,by,bw,bh = a["bbox"][:4] if (bw*bh)/(w*h + 1e-9) >= min_bbox_area_frac: keep.append(a) else: keep.append(a) anns = keep if not anns: continue # primary label mapped = [] for a in anns: cat = cats_by_id.get(a.get("category_id")) if cat: mapped.append(map_class(cat.get("name",""), label_map)) primary = None if mapped: c = Counter(mapped) primary = c.most_common(1)[0][0] items.append({"id": img_id, "file_name": file_name, "width": w, "height": h, "anns": anns, "primary": primary}) # Split splits = {"train": [], "val": [], "test": []} def _infer_split_from_paths() -> str: name = (str(coco_path).lower() + " " + str(images_dir).lower()) if "val" in name or "valid" in name or "validation" in name: return "val" if "test" in name: return "test" return "train" if target_split in ("train", "val", "test"): splits[target_split] = items else: if split_policy == "train_only": splits["train"] = items elif split_policy == "stratify": y = [it["primary"] or "other" for it in items] ti,vi,si = stratified_split(items, y, train_ratio, val_ratio, seed) splits["train"] = [items[i] for i in ti] splits["val"] = [items[i] for i in vi] splits["test"] = [items[i] for i in si] elif split_policy == "keep_existing": # Try to infer target split from file names/paths inferred = _infer_split_from_paths() splits[inferred] = items else: raise ValueError(f"unknown split_policy: {split_policy}") # Write total_written = 0 per_split_written = {} for split, arr in splits.items(): img_out = out_root / "images" / split lbl_out = out_root / "labels" / split mani = manifest_root / f"{split}.txt" mani.parent.mkdir(parents=True, exist_ok=True) # load existing manifest lines (keep) existing = [] if mani.exists(): for ln in mani.read_text(encoding="utf-8").splitlines(): if not ln.strip(): continue parts = ln.split("\t") if len(parts)>=2: existing.append((parts[0], parts[1])) new_lines = [] used = 0 for it in arr: src = images_dir / Path(it["file_name"]).name if not src.exists(): # exports often have subfolders -> fallback: try relative path from COCO alt = images_dir / it["file_name"] if alt.exists(): src = alt if not src.exists(): # image missing -> skip continue # link/copy image into target dst_img = img_out / Path(src).name safe_symlink_or_copy(src, dst_img) # write label lbl_path = lbl_out / (dst_img.stem + ".txt") n = write_yolo_seg_label(lbl_path, it["anns"], cats_by_id, label_map, it["width"], it["height"], class_to_index) if n == 0: # no usable segment -> optionally remove image again to keep clean try: if dst_img.exists(): dst_img.unlink() except Exception: pass continue # extend manifest (informational; local file) new_lines.append(f"{dst_img.name}\t{src.resolve().as_posix()}") used += 1 # append to manifest with mani.open("a", encoding="utf-8") as f: for ln in new_lines: f.write(ln + "\n") total_written += used per_split_written[split] = used print(json.dumps({"total_images_added": total_written, "per_split_added": per_split_written}, indent=2)) def parse_args(): ap = argparse.ArgumentParser(description="Augment an existing TACO-prepared dataset with an external COCO dataset.") ap.add_argument("--coco", required=True, type=Path, help="Path to the external COCO annotations file") ap.add_argument("--images_dir", required=True, type=Path, help="Folder with the corresponding images") ap.add_argument("--out_root", required=True, type=Path, help="Target dataset root, e.g., ml/datasets/taco") ap.add_argument("--manifest_root", required=True, type=Path, help="Manifest folder, e.g., ml/datasets/taco/manifests") ap.add_argument("--label_map", required=True, type=Path, help="Your ml/configs/label_map.json") ap.add_argument("--split_policy", choices=["train_only","stratify","keep_existing"], default="train_only") ap.add_argument("--train_ratio", type=float, default=0.8) ap.add_argument("--val_ratio", type=float, default=0.1) ap.add_argument("--seed", type=int, default=42) ap.add_argument("--min_bbox_area_frac", type=float, default=0.0, help="BBox area minimum relative to the image (0..1) to filter tiny objects") ap.add_argument("--target_split", choices=["auto","train","val","test"], default="auto", help="Force write items into a specific split; auto will infer for keep_existing or use split_policy") return ap.parse_args() if __name__ == "__main__": args = parse_args() augment(args.coco, args.images_dir, args.out_root, args.manifest_root, args.label_map, split_policy=args.split_policy, train_ratio=args.train_ratio, val_ratio=args.val_ratio, seed=args.seed, min_bbox_area_frac=args.min_bbox_area_frac, target_split=args.target_split)