Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| """ | |
| build_training_set.py — assemble a YOLO training set from external datasets, | |
| mapped onto Alami's 7 material buckets, plus background hard-negatives. | |
| GPU-FREE. Runs on a laptop / Colab CPU / CI. Produces the dataset that | |
| train_yolov8_seg.py (GPU) then consumes. | |
| Sources it understands: | |
| * TACO (COCO json) — recommended download: Kaggle mirror kneroma/tacotrashdataset | |
| (single batch, no Flickr rate limits; see docs/TRAINING_DATA.md) | |
| * any COCO-format dataset (category names are mapped via ml/serving/labels.to_bucket) | |
| * background images (no trash) -> emitted with EMPTY label files = hard negatives, | |
| the key lever against false positives (docs/TRAINING_DATA.md §4) | |
| Class mapping is deterministic and auditable: every source category name goes | |
| through the SAME bucket mapper the server uses (ml/serving/labels.py), so the | |
| training labels match what the API emits at inference. | |
| Usage: | |
| # convert a COCO dataset (e.g. downloaded TACO, UAVVaste) into our 7-class YOLO format | |
| python ml/scripts/build_training_set.py coco \ | |
| --ann /data/taco/annotations.json --images /data/taco/images \ | |
| --out ml/datasets/merged | |
| # ingest a folder-per-class CLASSIFICATION dataset (TrashNet, RealWaste, | |
| # e-waste/organic sets): each image gets ONE near-full-frame box of its | |
| # class — a weak label that works because these datasets show a single, | |
| # centered object. This is how we fill the dead organic/ewaste classes. | |
| python ml/scripts/build_training_set.py classification \ | |
| --images /data/realwaste --out ml/datasets/merged | |
| # add background hard-negatives (empty labels) at ~1:3 ratio | |
| python ml/scripts/build_training_set.py negatives \ | |
| --images /data/backgrounds --out ml/datasets/merged --split 0.85 | |
| # ingest an ALREADY-YOLO-format dataset (e.g. MRS Trash Detection) by | |
| # remapping its class ids onto our 7 buckets (works for bbox AND seg lines) | |
| python ml/scripts/build_training_set.py yolo \ | |
| --images /data/mrs/images/train --labels /data/mrs/labels/train \ | |
| --names /data/mrs/classes.json --out ml/datasets/merged --prefix mrs | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import shutil | |
| import sys | |
| from collections import Counter | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Optional, Tuple | |
| REPO = Path(__file__).resolve().parents[2] | |
| sys.path.insert(0, str(REPO)) | |
| from ml.serving.labels import to_bucket, BUCKETS # noqa: E402 | |
| # fixed class order for the model — index == YOLO class id | |
| CLASS_ORDER = list(BUCKETS) | |
| CLASS_ID = {name: i for i, name in enumerate(CLASS_ORDER)} | |
| def _clamp01(v: float) -> float: | |
| return min(max(v, 0.0), 1.0) | |
| def _fmt_line(cid: int, coords: List[float]) -> str: | |
| return str(cid) + " " + " ".join(f"{c:.6f}" for c in coords) | |
| def bbox_polygon(cx: float, cy: float, w: float, h: float) -> List[float]: | |
| """Normalized center-box -> 4-corner polygon (YOLO-seg segment format).""" | |
| x1, y1 = _clamp01(cx - w / 2.0), _clamp01(cy - h / 2.0) | |
| x2, y2 = _clamp01(cx + w / 2.0), _clamp01(cy + h / 2.0) | |
| return [x1, y1, x2, y1, x2, y2, x1, y2] | |
| def _largest_polygon(segmentation: Any) -> Optional[List[float]]: | |
| """Pick the largest polygon (shoelace area) from a COCO segmentation. | |
| Returns None for RLE/crowd/malformed segmentations (caller falls back to bbox).""" | |
| if not isinstance(segmentation, list) or not segmentation: | |
| return None | |
| best, best_area = None, -1.0 | |
| for poly in segmentation: | |
| if not isinstance(poly, list) or len(poly) < 6 or len(poly) % 2 != 0: | |
| continue | |
| try: | |
| xs, ys = poly[0::2], poly[1::2] | |
| area = abs(sum(xs[i] * ys[(i + 1) % len(ys)] - xs[(i + 1) % len(xs)] * ys[i] | |
| for i in range(len(xs)))) / 2.0 | |
| except TypeError: | |
| continue | |
| if area > best_area: | |
| best, best_area = poly, area | |
| return best | |
| def coco_to_yolo_records(coco: Dict[str, Any]) -> Tuple[Dict[int, List[str]], Counter]: | |
| """Pure transform: COCO dict -> {image_id: [yolo-SEG label lines]} + bucket stats. | |
| Each COCO category name is mapped to one of Alami's 7 buckets via to_bucket, | |
| so the label ids match the server's output classes. Emits SEGMENT polygons | |
| (required to train yolov8-seg): the real COCO segmentation polygon when | |
| present (TACO, MTA, Roboflow exports), otherwise the bbox as a 4-corner | |
| polygon — a valid degenerate mask. | |
| """ | |
| images = {img["id"]: img for img in coco.get("images", [])} | |
| cats = {c["id"]: c.get("name", "") for c in coco.get("categories", [])} | |
| labels: Dict[int, List[str]] = {} | |
| stats: Counter = Counter() | |
| for ann in coco.get("annotations", []): | |
| img = images.get(ann.get("image_id")) | |
| if not img: | |
| continue | |
| w0, h0 = float(img.get("width", 0)), float(img.get("height", 0)) | |
| if w0 <= 0 or h0 <= 0: | |
| continue | |
| bbox = ann.get("bbox") | |
| if not bbox or len(bbox) < 4: | |
| continue | |
| x, y, bw, bh = [float(v) for v in bbox[:4]] | |
| if bw <= 0 or bh <= 0: | |
| continue | |
| bucket = to_bucket(cats.get(ann.get("category_id"), "")) | |
| cid = CLASS_ID[bucket] | |
| poly = _largest_polygon(ann.get("segmentation")) | |
| if poly is not None: | |
| coords = [_clamp01(v / (w0 if i % 2 == 0 else h0)) for i, v in enumerate(poly)] | |
| else: | |
| cx = _clamp01((x + bw / 2.0) / w0) | |
| cy = _clamp01((y + bh / 2.0) / h0) | |
| coords = bbox_polygon(cx, cy, _clamp01(bw / w0), _clamp01(bh / h0)) | |
| labels.setdefault(ann["image_id"], []).append(_fmt_line(cid, coords)) | |
| stats[bucket] += 1 | |
| return labels, stats | |
| def _write_dataset_yaml(out: Path) -> None: | |
| import yaml | |
| ds = { | |
| "path": str(out.resolve()), | |
| "train": "images/train", "val": "images/val", "test": "images/val", | |
| "nc": len(CLASS_ORDER), "names": CLASS_ORDER, | |
| } | |
| (out / "dataset.yaml").write_text(yaml.safe_dump(ds, sort_keys=False), encoding="utf-8") | |
| (out / "names.json").write_text(json.dumps(CLASS_ORDER, ensure_ascii=False, indent=2), | |
| encoding="utf-8") | |
| def _ensure_dirs(out: Path) -> None: | |
| for sub in ["images/train", "images/val", "labels/train", "labels/val"]: | |
| (out / sub).mkdir(parents=True, exist_ok=True) | |
| def cmd_coco(args) -> int: | |
| coco = json.loads(Path(args.ann).read_text(encoding="utf-8")) | |
| labels, stats = coco_to_yolo_records(coco) | |
| images = {img["id"]: img for img in coco.get("images", [])} | |
| out = Path(args.out) | |
| _ensure_dirs(out) | |
| src_images = Path(args.images) | |
| ids = sorted(labels.keys()) | |
| cut = int(len(ids) * args.split) | |
| written = 0 | |
| for i, img_id in enumerate(ids): | |
| split = "train" if i < cut else "val" | |
| img = images[img_id] | |
| fname = img.get("file_name") | |
| if not fname: | |
| continue | |
| src = src_images / fname | |
| if not src.exists(): | |
| continue | |
| stem = Path(fname).name | |
| shutil.copy2(src, out / f"images/{split}" / stem) | |
| (out / f"labels/{split}" / (Path(stem).stem + ".txt")).write_text( | |
| "\n".join(labels[img_id]) + "\n", encoding="utf-8") | |
| written += 1 | |
| _write_dataset_yaml(out) | |
| print(f"COCO->YOLO: {written} labelled images written to {out}") | |
| print(f"bucket distribution: {dict(stats)}") | |
| missing = [b for b in CLASS_ORDER if stats.get(b, 0) == 0] | |
| if missing: | |
| print(f"NOTE: no boxes for {missing} in this source — add a targeted dataset " | |
| f"(see docs/TRAINING_DATA.md §3.2).") | |
| return 0 | |
| IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp") | |
| def cmd_classification(args) -> int: | |
| """Ingest a folder-per-class classification dataset as weak detection labels. | |
| Layout expected: <images>/<class_name>/*.jpg (e.g. RealWaste, TrashNet, | |
| Kaggle e-waste). Each image gets ONE near-full-frame box (margin trims the | |
| border) labelled with to_bucket(<class_name>). Class folders that map to | |
| 'other' can be skipped with --skip-other to avoid diluting the signal. | |
| """ | |
| src = Path(args.images) | |
| out = Path(args.out) | |
| _ensure_dirs(out) | |
| margin = float(args.margin) | |
| assert 0.0 <= margin < 0.5, "--margin must be in [0, 0.5)" | |
| stats: Counter = Counter() | |
| skipped_other = 0 | |
| written = 0 | |
| class_dirs = sorted(d for d in src.iterdir() if d.is_dir()) | |
| if not class_dirs: | |
| print(f"No class subfolders found under {src} — expected <class>/<images>.") | |
| return 1 | |
| for cdir in class_dirs: | |
| bucket = to_bucket(cdir.name) | |
| if bucket == "other" and args.skip_other: | |
| skipped_other += 1 | |
| continue | |
| cid = CLASS_ID[bucket] | |
| imgs = sorted(p for p in cdir.rglob("*") if p.suffix.lower() in IMG_EXTS) | |
| cut = int(len(imgs) * args.split) | |
| for i, p in enumerate(imgs): | |
| split = "train" if i < cut else "val" | |
| stem = f"cls_{cdir.name}_{p.stem}{p.suffix.lower()}" | |
| shutil.copy2(p, out / f"images/{split}" / stem) | |
| w = h = 1.0 - 2.0 * margin | |
| # weak near-full-frame box as 4-corner polygon (yolov8-seg format) | |
| (out / f"labels/{split}" / (Path(stem).stem + ".txt")).write_text( | |
| _fmt_line(cid, bbox_polygon(0.5, 0.5, w, h)) + "\n", encoding="utf-8") | |
| stats[bucket] += 1 | |
| written += 1 | |
| if not (out / "names.json").exists(): | |
| _write_dataset_yaml(out) | |
| print(f"classification->YOLO: {written} images written to {out} " | |
| f"(weak full-frame boxes, margin={margin})") | |
| print(f"bucket distribution: {dict(stats)}") | |
| if skipped_other: | |
| print(f"skipped {skipped_other} class folder(s) mapping to 'other' (--skip-other)") | |
| return 0 | |
| def load_class_names(path: Path) -> List[str]: | |
| """Load source class names from classes.json (list), dataset.yaml | |
| (names: list|{id: name}) or a plain txt (one name per line).""" | |
| text = path.read_text(encoding="utf-8") | |
| if path.suffix.lower() in (".yaml", ".yml"): | |
| import yaml | |
| names = (yaml.safe_load(text) or {}).get("names") | |
| if isinstance(names, dict): | |
| return [str(names[k]) for k in sorted(names, key=int)] | |
| return [str(n) for n in (names or [])] | |
| if path.suffix.lower() == ".json": | |
| data = json.loads(text) | |
| if isinstance(data, dict): # {"0": "Plastic", ...} or {"names": [...]} | |
| if "names" in data: | |
| data = data["names"] | |
| else: | |
| return [str(data[k]) for k in sorted(data, key=lambda x: int(x))] | |
| return [str(n) for n in data] | |
| return [ln.strip() for ln in text.splitlines() if ln.strip()] | |
| def remap_yolo_label_lines(lines: List[str], names: List[str], | |
| skip_other: bool = False) -> Tuple[List[str], Counter]: | |
| """Pure transform: YOLO label lines with SOURCE class ids -> yolo-SEG lines | |
| with Alami bucket ids. Polygon lines pass through with remapped id; bbox | |
| lines (cid cx cy w h) are converted to 4-corner polygons, because training | |
| yolov8-seg requires segment labels for every object.""" | |
| out_lines: List[str] = [] | |
| stats: Counter = Counter() | |
| for ln in lines: | |
| parts = ln.split() | |
| if len(parts) < 5: # need at least cid + 4 coords | |
| continue | |
| try: | |
| src_id = int(float(parts[0])) | |
| coords = [float(v) for v in parts[1:]] | |
| except ValueError: | |
| continue | |
| if not (0 <= src_id < len(names)): | |
| continue | |
| bucket = to_bucket(names[src_id]) | |
| if bucket == "other" and skip_other: | |
| continue | |
| cid = CLASS_ID[bucket] | |
| if len(coords) == 4: # bbox -> degenerate rectangle polygon | |
| cx, cy, w, h = coords | |
| out_lines.append(_fmt_line(cid, bbox_polygon(cx, cy, w, h))) | |
| else: # already a polygon | |
| out_lines.append(_fmt_line(cid, coords)) | |
| stats[bucket] += 1 | |
| return out_lines, stats | |
| def cmd_yolo(args) -> int: | |
| """Ingest a YOLO-format dataset (images + labels + class names), remapping | |
| source class ids onto our 7 buckets. Supports bbox and segmentation label | |
| lines. Images whose labels all drop out (invalid/skipped) are not copied.""" | |
| out = Path(args.out) | |
| _ensure_dirs(out) | |
| names = load_class_names(Path(args.names)) | |
| if not names: | |
| print(f"No class names loaded from {args.names}") | |
| return 1 | |
| img_dir, lbl_dir = Path(args.images), Path(args.labels) | |
| imgs = sorted(p for p in img_dir.rglob("*") if p.suffix.lower() in IMG_EXTS) | |
| prefix = args.prefix or img_dir.parent.name | |
| stats: Counter = Counter() | |
| written = skipped = 0 | |
| cut = int(len(imgs) * args.split) | |
| for i, p in enumerate(imgs): | |
| lbl = lbl_dir / (p.stem + ".txt") | |
| if not lbl.exists(): | |
| skipped += 1 | |
| continue | |
| lines, s = remap_yolo_label_lines( | |
| lbl.read_text(encoding="utf-8").splitlines(), names, args.skip_other) | |
| if not lines: | |
| skipped += 1 | |
| continue | |
| split = "train" if i < cut else "val" | |
| stem = f"yolo_{prefix}_{p.stem}{p.suffix.lower()}" | |
| shutil.copy2(p, out / f"images/{split}" / stem) | |
| (out / f"labels/{split}" / (Path(stem).stem + ".txt")).write_text( | |
| "\n".join(lines) + "\n", encoding="utf-8") | |
| stats.update(s) | |
| written += 1 | |
| if not (out / "names.json").exists(): | |
| _write_dataset_yaml(out) | |
| print(f"YOLO->YOLO: {written} images remapped to {out} ({skipped} skipped: " | |
| "no/empty/unmappable labels)") | |
| print(f"bucket distribution: {dict(stats)}") | |
| return 0 | |
| def cmd_negatives(args) -> int: | |
| """Add background images as hard negatives (empty label files).""" | |
| out = Path(args.out) | |
| _ensure_dirs(out) | |
| src = Path(args.images) | |
| imgs = [p for p in src.rglob("*") if p.suffix.lower() in (".jpg", ".jpeg", ".png", ".webp")] | |
| cut = int(len(imgs) * args.split) | |
| for i, p in enumerate(imgs): | |
| split = "train" if i < cut else "val" | |
| stem = f"neg__{p.stem}{p.suffix}" | |
| shutil.copy2(p, out / f"images/{split}" / stem) | |
| # EMPTY label file == "no object here" == hard negative | |
| (out / f"labels/{split}" / (Path(stem).stem + ".txt")).write_text("", encoding="utf-8") | |
| if not (out / "names.json").exists(): | |
| _write_dataset_yaml(out) | |
| print(f"hard-negatives: {len(imgs)} background images added (empty labels) to {out}") | |
| return 0 | |
| def main(argv=None) -> int: | |
| ap = argparse.ArgumentParser(description="Build a 7-class YOLO training set from external data.") | |
| sub = ap.add_subparsers(dest="cmd", required=True) | |
| p_coco = sub.add_parser("coco", help="convert a COCO dataset (e.g. TACO)") | |
| p_coco.add_argument("--ann", required=True, help="COCO annotations.json") | |
| p_coco.add_argument("--images", required=True, help="image directory") | |
| p_coco.add_argument("--out", default="ml/datasets/merged") | |
| p_coco.add_argument("--split", type=float, default=0.85) | |
| p_coco.set_defaults(func=cmd_coco) | |
| p_cls = sub.add_parser("classification", | |
| help="ingest folder-per-class dataset (weak full-frame boxes)") | |
| p_cls.add_argument("--images", required=True, help="root dir with <class>/<images> layout") | |
| p_cls.add_argument("--out", default="ml/datasets/merged") | |
| p_cls.add_argument("--split", type=float, default=0.85) | |
| p_cls.add_argument("--margin", type=float, default=0.02, | |
| help="border fraction trimmed off the full-frame box (default 0.02)") | |
| p_cls.add_argument("--skip-other", action="store_true", | |
| help="skip class folders that map to the 'other' bucket") | |
| p_cls.set_defaults(func=cmd_classification) | |
| p_yolo = sub.add_parser("yolo", help="ingest a YOLO-format dataset with class-id remap") | |
| p_yolo.add_argument("--images", required=True, help="image directory (searched recursively)") | |
| p_yolo.add_argument("--labels", required=True, help="label directory (matching <stem>.txt)") | |
| p_yolo.add_argument("--names", required=True, | |
| help="source class names: classes.json | dataset.yaml | names.txt") | |
| p_yolo.add_argument("--out", default="ml/datasets/merged") | |
| p_yolo.add_argument("--split", type=float, default=0.85) | |
| p_yolo.add_argument("--prefix", default="", help="filename prefix to avoid collisions") | |
| p_yolo.add_argument("--skip-other", action="store_true", | |
| help="drop boxes that map to the 'other' bucket") | |
| p_yolo.set_defaults(func=cmd_yolo) | |
| p_neg = sub.add_parser("negatives", help="add background hard-negatives") | |
| p_neg.add_argument("--images", required=True, help="directory of background (no-trash) images") | |
| p_neg.add_argument("--out", default="ml/datasets/merged") | |
| p_neg.add_argument("--split", type=float, default=0.85) | |
| p_neg.set_defaults(func=cmd_negatives) | |
| args = ap.parse_args(argv) | |
| return args.func(args) | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |