CI / ledger-and-tools (push) Successful in 17s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
207 lines
8.3 KiB
Python
Executable File
207 lines
8.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Bring a studio export (``python -m src.pipeline.run export``) into this repository.
|
|
|
|
Images once, labels per dataset: every frame lands in ``data/pool/images/`` exactly
|
|
once (LFS), and the dataset directory holds symlinks into the pool in the layout
|
|
Ultralytics expects, so labels resolve next to them:
|
|
|
|
data/datasets/<name>/
|
|
├── images/{train,val}/tpi_<id>.jpg -> ../../../../pool/images/tpi_<id>.jpg
|
|
├── labels/{train,val}/tpi_<id>.txt
|
|
├── dataset.yaml (path: . ; resolved to an absolute path at train time)
|
|
├── manifest.csv (the studio's provenance: tpid, split, reason, beamline, ...)
|
|
└── source.json (where it came from, counts, classes, gold manifest hash)
|
|
|
|
A second dataset re-lists the frames it shares with earlier ones and adds only the
|
|
new images to the pool, so ``git diff dataset/A dataset/B`` shows what changed.
|
|
|
|
python tools/import_studio_export.py /path/to/dataset_20261002_1200 --tag alc \\
|
|
[--gold /path/to/studio/data/gold] [--commit] [--git-tag]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
IMG_EXT = (".jpg", ".jpeg", ".png")
|
|
|
|
|
|
def _sha256(p: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with p.open("rb") as fh:
|
|
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def read_names(dataset_yaml: Path) -> Dict[int, str]:
|
|
"""``names:`` block of a YOLO dataset.yaml, without PyYAML."""
|
|
names: Dict[int, str] = {}
|
|
in_names = False
|
|
for line in dataset_yaml.read_text(encoding="utf-8").splitlines():
|
|
if re.match(r"^names\s*:", line):
|
|
in_names = True
|
|
continue
|
|
if in_names:
|
|
m = re.match(r"^\s+(\d+)\s*:\s*(\S+)", line)
|
|
if m:
|
|
names[int(m.group(1))] = m.group(2)
|
|
elif line.strip() and not line.startswith(" "):
|
|
break
|
|
return names
|
|
|
|
|
|
def write_dataset_yaml(dest: Path, names: Dict[int, str]) -> Path:
|
|
body = "\n".join(f" {i}: {names[i]}" for i in sorted(names))
|
|
p = dest / "dataset.yaml"
|
|
p.write_text(
|
|
"# path is resolved to this directory's absolute path by tools/train_run.py\n"
|
|
f"path: .\ntrain: images/train\nval: images/val\n\nnames:\n{body}\n",
|
|
encoding="utf-8",
|
|
)
|
|
return p
|
|
|
|
|
|
def add_to_pool(image: Path, pool: Path) -> tuple[Path, bool]:
|
|
"""Copy ``image`` into the pool unless an identical file is already there."""
|
|
pool.mkdir(parents=True, exist_ok=True)
|
|
dest = pool / image.name
|
|
if dest.exists():
|
|
if dest.stat().st_size == image.stat().st_size:
|
|
return dest, False
|
|
raise SystemExit(f"{dest} exists with a different size than {image}; a pool frame never changes")
|
|
shutil.copy2(image, dest)
|
|
return dest, True
|
|
|
|
|
|
def link_into(dataset_images: Path, pool_file: Path) -> Path:
|
|
dataset_images.mkdir(parents=True, exist_ok=True)
|
|
link = dataset_images / pool_file.name
|
|
rel = os.path.relpath(pool_file, dataset_images)
|
|
if link.is_symlink() or link.exists():
|
|
link.unlink()
|
|
link.symlink_to(rel)
|
|
return link
|
|
|
|
|
|
def import_export(export_dir: Path, name: str, *, root: Path = ROOT, gold_dir: Optional[Path] = None) -> Dict:
|
|
export_dir = Path(export_dir)
|
|
if not (export_dir / "dataset.yaml").exists():
|
|
raise SystemExit(f"{export_dir} is not a studio export (no dataset.yaml)")
|
|
names = read_names(export_dir / "dataset.yaml")
|
|
pool = root / "data" / "pool" / "images"
|
|
dest = root / "data" / "datasets" / name
|
|
if dest.exists():
|
|
raise SystemExit(f"{dest} exists; datasets are immutable, pick another name")
|
|
counts: Dict[str, Dict[str, int]] = {}
|
|
added = 0
|
|
for split in ("train", "val"):
|
|
src_images = export_dir / split / "images"
|
|
src_labels = export_dir / split / "labels"
|
|
n_img = n_lbl = n_empty = 0
|
|
for img in sorted(p for p in src_images.glob("*") if p.suffix.lower() in IMG_EXT):
|
|
pool_file, new = add_to_pool(img, pool)
|
|
added += int(new)
|
|
link_into(dest / "images" / split, pool_file)
|
|
lbl = src_labels / f"{img.stem}.txt"
|
|
out_lbl = dest / "labels" / split / f"{img.stem}.txt"
|
|
out_lbl.parent.mkdir(parents=True, exist_ok=True)
|
|
if lbl.exists():
|
|
shutil.copy2(lbl, out_lbl)
|
|
n_lbl += 1
|
|
if not lbl.read_text(encoding="utf-8").strip():
|
|
n_empty += 1
|
|
else:
|
|
out_lbl.write_text("", encoding="utf-8") # background frame
|
|
n_empty += 1
|
|
n_img += 1
|
|
counts[split] = {"images": n_img, "labels": n_lbl, "empty": n_empty}
|
|
write_dataset_yaml(dest, names)
|
|
if (export_dir / "manifest.csv").exists():
|
|
shutil.copy2(export_dir / "manifest.csv", dest / "manifest.csv")
|
|
gold_info = None
|
|
if gold_dir:
|
|
gold_info = import_gold(Path(gold_dir), root=root)
|
|
source = {
|
|
"name": name,
|
|
"imported_at": datetime.now().isoformat(timespec="seconds"),
|
|
"export_dir": str(export_dir.resolve()),
|
|
"classes": names,
|
|
"counts": counts,
|
|
"new_pool_images": added,
|
|
"manifest_sha256": _sha256(dest / "manifest.csv") if (dest / "manifest.csv").exists() else None,
|
|
"gold": gold_info,
|
|
}
|
|
(dest / "source.json").write_text(json.dumps(source, indent=2) + "\n", encoding="utf-8")
|
|
return source
|
|
|
|
|
|
def import_gold(gold_dir: Path, *, root: Path = ROOT) -> Dict:
|
|
"""The studio's frozen gold set: images into the pool, symlinks + labels + manifest under data/gold."""
|
|
pool = root / "data" / "pool" / "images"
|
|
dest = root / "data" / "gold"
|
|
n = 0
|
|
for img in sorted(p for p in (gold_dir / "images").glob("*") if p.suffix.lower() in IMG_EXT):
|
|
pool_file, _ = add_to_pool(img, pool)
|
|
link_into(dest / "images", pool_file)
|
|
lbl = gold_dir / "labels" / f"{img.stem}.txt"
|
|
(dest / "labels").mkdir(parents=True, exist_ok=True)
|
|
if lbl.exists():
|
|
shutil.copy2(lbl, dest / "labels" / lbl.name)
|
|
n += 1
|
|
manifest = gold_dir / "gold_manifest.json"
|
|
info = {"images": n, "manifest_sha256": None}
|
|
if manifest.exists():
|
|
shutil.copy2(manifest, dest / "gold_manifest.json")
|
|
info["manifest_sha256"] = _sha256(manifest)
|
|
return info
|
|
|
|
|
|
def git(root: Path, *args: str) -> str:
|
|
return subprocess.check_output(["git", "-C", str(root), *args], text=True).strip()
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("export_dir")
|
|
ap.add_argument("--tag", required=True, help="short dataset name; directory = <date>_<tag>")
|
|
ap.add_argument("--name", default=None, help="full dataset name instead of <date>_<tag>")
|
|
ap.add_argument("--gold", default=None, help="studio gold directory (images/, labels/, gold_manifest.json)")
|
|
ap.add_argument("--root", default=str(ROOT))
|
|
ap.add_argument("--commit", action="store_true", help="git add + commit the dataset (and pool additions)")
|
|
ap.add_argument("--git-tag", action="store_true", help="also tag the commit dataset/<name>")
|
|
args = ap.parse_args(argv)
|
|
root = Path(args.root)
|
|
name = args.name or f"{datetime.now().strftime('%Y-%m-%d')}_{args.tag}"
|
|
source = import_export(Path(args.export_dir), name, root=root, gold_dir=Path(args.gold) if args.gold else None)
|
|
c = source["counts"]
|
|
print(f"dataset {name}: train {c['train']['images']} / val {c['val']['images']} images, "
|
|
f"{source['new_pool_images']} new in the pool")
|
|
if args.commit:
|
|
git(root, "add", "data/pool/images", f"data/datasets/{name}")
|
|
if args.gold:
|
|
git(root, "add", "data/gold")
|
|
msg = (f"dataset: {name} ({c['train']['images']} train / {c['val']['images']} val, "
|
|
f"{source['new_pool_images']} new pool images)")
|
|
git(root, "commit", "-q", "-m", msg)
|
|
print(f"committed: {msg}")
|
|
if args.git_tag:
|
|
git(root, "tag", f"dataset/{name}")
|
|
print(f"tagged dataset/{name}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|