feat: initial HSAP platform

Huaxu Sentinel Active Safety Platform with embedded algorithm code,
Docker Compose setup, and vendored dataset scaffolds for clone-and-run.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-25 16:59:59 +08:00
commit 7c43b44c57
1619 changed files with 373355 additions and 0 deletions

View File

@@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""将非标准数据转为 Ultralytics/YOLO 可用格式。"""
from __future__ import annotations
import argparse
import os
import shutil
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
from stratified_split import collect_yolo_samples, stratified_assign, stratified_assign_classify
IMG_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".JPG", ".JPEG", ".PNG"}
DAM_NAMES = [
"face", "eye_open", "eye_partially_open", "eye_close", "mouth_open",
"mouth_partially_open", "mouth_close", "side_face", "nod_face", "glasses",
"sunglasses", "smoke", "phone", "driver", "rise_face",
]
DAM_NAME_TO_ID = {n: i for i, n in enumerate(DAM_NAMES)}
def voc_to_yolo_box(xmin: float, ymin: float, xmax: float, ymax: float, w: int, h: int) -> tuple[float, float, float, float]:
xc = (xmin + xmax) / 2.0 / w
yc = (ymin + ymax) / 2.0 / h
bw = (xmax - xmin) / w
bh = (ymax - ymin) / h
return xc, yc, bw, bh
def _classes_from_lines(lines: list[str]) -> set[int]:
out: set[int] = set()
for line in lines:
if line.strip():
out.add(int(line.split()[0]))
return out
def convert_dam_voc(
src_root: Path,
dst_root: Path,
val_ratio: float = 0.1,
seed: int = 42,
link_images: bool = True,
) -> dict:
"""VOC(xml) -> YOLO一次写入 train/val避免二次 move。"""
src_images = src_root / "images"
src_labels = src_root / "labels"
if not src_images.is_dir() or not src_labels.is_dir():
raise FileNotFoundError(f"expect {src_images} and {src_labels}")
records: list[tuple[str, Path, list[str]]] = []
unknown: dict[str, int] = {}
n_skip, n_empty = 0, 0
xml_files = sorted(src_labels.glob("*.xml"))
total = len(xml_files)
print(f" 解析 xml: {total}")
for i, xml_path in enumerate(xml_files, 1):
if i % 500 == 0 or i == total:
print(f" xml {i}/{total}", flush=True)
stem = xml_path.stem
img_src = None
for ext in IMG_EXTS:
p = src_images / f"{stem}{ext}"
if p.is_file():
img_src = p
break
if img_src is None:
n_skip += 1
continue
tree = ET.parse(xml_path)
root = tree.getroot()
size = root.find("size")
w = int(size.findtext("width", "0"))
h = int(size.findtext("height", "0"))
if w <= 0 or h <= 0:
n_skip += 1
continue
lines: list[str] = []
for obj in root.findall("object"):
name = (obj.findtext("name") or "").strip()
if name not in DAM_NAME_TO_ID:
unknown[name] = unknown.get(name, 0) + 1
continue
box = obj.find("bndbox")
xmin = float(box.findtext("xmin", "0"))
ymin = float(box.findtext("ymin", "0"))
xmax = float(box.findtext("xmax", "0"))
ymax = float(box.findtext("ymax", "0"))
xc, yc, bw, bh = voc_to_yolo_box(xmin, ymin, xmax, ymax, w, h)
lines.append(f"{DAM_NAME_TO_ID[name]} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}")
if not lines:
n_empty += 1
records.append((stem, img_src, lines))
samples = [(stem, _classes_from_lines(lines)) for stem, _, lines in records]
assignment = stratified_assign(samples, val_ratio=val_ratio, seed=seed, min_val_per_class=1)
print(f" 写入 YOLO: {len(records)}", flush=True)
img_abs_cache: dict[Path, str] = {}
for i, (stem, img_src, lines) in enumerate(records, 1):
if i % 500 == 0 or i == len(records):
print(f" write {i}/{len(records)}", flush=True)
split = assignment.get(stem, "train")
lab_dst = dst_root / "labels" / split / f"{stem}.txt"
img_dst = dst_root / "images" / split / img_src.name
lab_dst.parent.mkdir(parents=True, exist_ok=True)
img_dst.parent.mkdir(parents=True, exist_ok=True)
lab_dst.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
if img_dst.exists() or img_dst.is_symlink():
img_dst.unlink()
if link_images:
if img_src not in img_abs_cache:
img_abs_cache[img_src] = str(img_src.resolve())
img_dst.symlink_to(img_abs_cache[img_src])
else:
shutil.copy2(img_src, img_dst)
n_val = sum(1 for v in assignment.values() if v == "val")
yaml_path = dst_root.parent.parent / "configs" / "dam_0417.yaml"
yaml_path.parent.mkdir(parents=True, exist_ok=True)
names_yaml = ", ".join(f'"{n}"' for n in DAM_NAMES)
yaml_path.write_text(
f"""# DAM 源数据 dam_src_0417 转换 (VOC -> YOLO)
path: {dst_root.resolve()}
train: images/train
val: images/val
nc: {len(DAM_NAMES)}
names: [{names_yaml}]
""",
encoding="utf-8",
)
return {
"total": len(records),
"train": len(records) - n_val,
"val": n_val,
"empty_labels": n_empty,
"skipped_no_image": n_skip,
"unknown_names": unknown,
"yaml": str(yaml_path),
"dst": str(dst_root),
}
def convert_classify_layout(root: Path, val_ratio: float = 0.1, seed: int = 42) -> dict:
"""从 train 按类划出 val硬链优先同盘更快"""
train_dir = root / "train"
if not train_dir.is_dir():
raise FileNotFoundError(f"missing train/: {train_dir}")
val_dir = root / "val"
if val_dir.is_dir() and any(val_dir.iterdir()):
print(f" val/ 已存在,跳过: {root}")
return {"skipped": "val exists"}
class_dirs = sorted(d for d in train_dir.iterdir() if d.is_dir())
print(f" 类别数: {len(class_dirs)}", flush=True)
assignment = stratified_assign_classify(class_dirs, val_ratio, seed, min_val_per_class=1)
val_items = [(p, sp) for p, sp in assignment.items() if sp == "val"]
print(f" 划出 val: {len(val_items)}", flush=True)
val_dir.mkdir(parents=True, exist_ok=True)
moved, linked = 0, 0
for i, (src_path, _) in enumerate(val_items, 1):
if i % 2000 == 0 or i == len(val_items):
print(f" val {i}/{len(val_items)}", flush=True)
dst = val_dir / src_path.parent.name / src_path.name
dst.parent.mkdir(parents=True, exist_ok=True)
if dst.exists():
continue
try:
os.link(src_path, dst)
src_path.unlink()
linked += 1
except OSError:
shutil.move(str(src_path), str(dst))
moved += 1
yaml_path = root.parent.parent / "configs" / "isa_class_0116.yaml"
yaml_path.write_text(
f"""# ISA 交通标志分类 (Ultralytics classify)
path: {root.resolve()}
train: train
val: val
test: test
""",
encoding="utf-8",
)
return {"val_total": len(val_items), "hardlink": linked, "move": moved, "yaml": str(yaml_path)}
def verify_yolo_detect(root: Path) -> str:
for sp in ("train", "val"):
img_d = root / "images" / sp
lab_d = root / "labels" / sp
if not img_d.is_dir() or not lab_d.is_dir():
return f"missing images|labels/{sp}"
if not any(img_d.iterdir()):
return f"empty images/{sp}"
if not any(lab_d.glob("*.txt")):
return f"empty labels/{sp}"
return "ok"
def verify_yolo_pose(root: Path) -> str:
msg = verify_yolo_detect(root)
if msg != "ok":
return msg
sample = next((root / "labels" / "train").glob("*.txt"), None)
if sample and len(sample.read_text().split()) < 6:
return "pose label fields < 6"
return "ok"
def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("--gyp", type=Path, default=Path(__file__).resolve().parents[1] / "gyp")
p.add_argument("--val-ratio", type=float, default=0.1)
p.add_argument("--seed", type=int, default=42)
p.add_argument("--copy-images", action="store_true")
p.add_argument("--only", choices=("dam", "classify", "verify", "all"), default="all")
args = p.parse_args()
gyp = args.gyp.resolve()
if args.only in ("dam", "all"):
print("=" * 60)
print("1) dam_src_0417 VOC -> YOLO => gyp/dam_0417/")
src = gyp / "dam_src_0417" / "src_data_0417_pick"
dst = gyp / "dam_0417"
if dst.exists():
shutil.rmtree(dst)
r = convert_dam_voc(src, dst, args.val_ratio, args.seed, link_images=not args.copy_images)
print(r)
if args.only in ("classify", "all"):
print("\n" + "=" * 60)
print("2) isa_class_0116 分类 -> train/val/test")
r2 = convert_classify_layout(gyp / "isa_class_0116", args.val_ratio, args.seed)
print(r2)
if args.only in ("verify", "all"):
print("\n" + "=" * 60)
print("3) 校验")
for name in ["ddaw_1124", "addw_0523", "isa_detect", "dam_0516", "dam_0417"]:
root = gyp / name
if root.is_dir():
print(f" {name}: {verify_yolo_detect(root)}")
print(f" yoloface-0726: {verify_yolo_pose(gyp / 'yoloface-0726')}")
ic = gyp / "isa_class_0116"
if (ic / "val").is_dir():
print(f" isa_class_0116: train/val/test ok")
print("\n完成")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,538 @@
#!/usr/bin/env python3
"""
接入新数据:合并进 dataset_bundle/<task>/,默认按类分层重划 train/val。
叠放新批次(推荐):
.../dam/sources/20260520_line2/ # images+labels 或 images/train+labels/train
python ml.py build dms dam --all-sources
inbox 方式仍可用:
python ml.py add dms dam --src /path/to/batch
python ml.py build dms dam --batch <name>
"""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
import yaml
SCRIPT_DIR = Path(__file__).resolve().parent
DATASET_ROOT = SCRIPT_DIR.parent
sys.path.insert(0, str(SCRIPT_DIR))
from pack_registry import task_data_root as pack_task_data_root # noqa: E402
from convert_to_yolo import convert_dam_voc # noqa: E402
from stratified_split import ( # noqa: E402
apply_yolo_split,
collect_yolo_samples,
print_yolo_stats,
resplit_classify_root,
stratified_assign,
)
IMG_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".JPG", ".JPEG", ".PNG"}
def load_registry(root: Path) -> dict:
return yaml.safe_load((root / "datasets.registry.yaml").read_text(encoding="utf-8"))
def split_kwargs(reg: dict, args: argparse.Namespace) -> dict:
s = reg.get("split") or {}
return {
"val_ratio": args.val_ratio if args.val_ratio is not None else float(s.get("val_ratio", 0.1)),
"seed": args.seed if args.seed is not None else int(s.get("seed", 42)),
"min_val_per_class": int(s.get("min_val_per_class", 1)),
"min_train_per_class": int(s.get("min_train_per_class", 1)),
"rare_class_train_floor": int(s.get("rare_class_train_floor", 5)),
}
def sources_dir(data_root: Path, reg: dict) -> Path:
sub = (reg.get("ingest") or {}).get("sources_subdir", "sources")
return data_root / sub
def ingested_dir(data_root: Path, reg: dict) -> Path:
rel = (reg.get("ingest") or {}).get("ingested_subdir", "sources/_ingested")
return (data_root / rel).resolve()
def list_pending_sources(data_root: Path, reg: dict) -> list[Path]:
src_root = sources_dir(data_root, reg)
if not src_root.is_dir():
return []
ing = ingested_dir(data_root, reg)
skip = {ing.name, "_ingested", "_merged"}
return sorted(
p
for p in src_root.iterdir()
if p.is_dir() and p.name not in skip and not p.name.startswith(".")
)
def archive_source_batch(src: Path, data_root: Path, reg: dict, dry_run: bool) -> str | None:
"""若 src 在 sources/ 下,合并后移到 sources/_ingested/。"""
src_root = sources_dir(data_root, reg).resolve()
try:
src.resolve().relative_to(src_root)
except ValueError:
return None
dst_base = ingested_dir(data_root, reg)
dst = dst_base / src.name
if dry_run:
return str(dst)
dst_base.mkdir(parents=True, exist_ok=True)
if dst.exists():
dst = dst_base / f"{src.name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
shutil.move(str(src), str(dst))
return str(dst)
def run_stratified_resplit(
data_root: Path,
tcfg: dict,
sk: dict,
dry_run: bool = False,
) -> dict | None:
if dry_run:
return None
if tcfg["type"] in ("detect", "pose"):
samples = collect_yolo_samples(data_root, ("train", "val"))
assign = stratified_assign(samples, **sk)
apply_yolo_split(data_root, assign, pool_splits=("train", "val"), dry_run=False)
print_yolo_stats(data_root, "after stratified resplit (per-class)")
return {
"train": sum(1 for v in assign.values() if v == "train"),
"val": sum(1 for v in assign.values() if v == "val"),
}
if tcfg["type"] == "classify":
return resplit_classify_root(data_root, dry_run=False, **sk)
return None
def find_image(images_dirs: list[Path], stem: str) -> Path | None:
for d in images_dirs:
if not d.is_dir():
continue
for ext in IMG_EXTS:
p = d / f"{stem}{ext}"
if p.is_file():
return p
return None
def resolve_yolo_layout(src: Path) -> tuple[list[Path], list[Path], bool]:
if (src / "images" / "train").is_dir():
return [src / "images" / "train"], [src / "labels" / "train"], False
if (src / "images").is_dir() and (src / "labels").is_dir():
voc = any((src / "labels").glob("*.xml"))
return [src / "images"], [src / "labels"], voc
raise SystemExit(
f"无法识别 YOLO 目录: {src}\n"
"需要 images/train+labels/train 或 images+labels"
)
def resolve_classify_layout(src: Path) -> Path:
if (src / "train").is_dir() and any((src / "train").iterdir()):
return src / "train"
if any(d.is_dir() for d in src.iterdir()):
return src
raise SystemExit(f"无法识别分类目录: {src}\n需要 train/类名/*.jpg 或 类名/*.jpg")
def file_md5(p: Path) -> str:
h = hashlib.md5()
with p.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def existing_yolo_index(gyp_root: Path) -> tuple[set[str], dict[str, str]]:
"""stem -> md5 of label file用于去重"""
stems: set[str] = set()
md5s: dict[str, str] = {}
for sp in ("train", "val"):
lab_d = gyp_root / "labels" / sp
if not lab_d.is_dir():
continue
for lab in lab_d.glob("*.txt"):
stems.add(lab.stem)
md5s[lab.stem] = file_md5(lab)
return stems, md5s
def validate_detect_label(text: str, nc: int) -> str | None:
for i, line in enumerate(text.splitlines(), 1):
if not line.strip():
continue
parts = line.split()
if len(parts) < 5:
return f"line {i}: fields={len(parts)} < 5"
cid = int(parts[0])
if cid < 0 or cid >= nc:
return f"line {i}: class {cid} not in [0,{nc - 1}]"
return None
def validate_pose_label(text: str, kpt_shape: list[int]) -> str | None:
nk, nd = kpt_shape
min_f = 5 + nk * nd
for i, line in enumerate(text.splitlines(), 1):
if not line.strip():
continue
n = len(line.split())
if n < min_f:
return f"line {i}: pose fields={n} < {min_f} (kpt_shape={kpt_shape})"
return None
def validate_label(path: Path, tcfg: dict) -> str | None:
text = path.read_text(encoding="utf-8", errors="ignore")
typ = tcfg["type"]
if typ == "detect":
return validate_detect_label(text, int(tcfg["nc"]))
if typ == "pose":
return validate_pose_label(text, tcfg.get("kpt_shape", [37, 3]))
return None
def copy_pair(lab: Path, img: Path, dst_lab: Path, dst_img: Path, copy: bool) -> None:
dst_lab.parent.mkdir(parents=True, exist_ok=True)
dst_img.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(lab, dst_lab)
if copy:
shutil.copy2(img, dst_img)
else:
if dst_img.exists() or dst_img.is_symlink():
dst_img.unlink()
dst_img.symlink_to(img.resolve())
def ingest_yolo(
task: str,
tcfg: dict,
data_root: Path,
src: Path,
sk: dict,
to_split: str = "train",
resplit: bool = False,
dry_run: bool = False,
copy: bool = False,
dedup: str = "stem",
) -> dict:
img_dirs, lab_dirs, is_voc = resolve_yolo_layout(src)
staging_parent = None
if is_voc:
if tcfg["type"] != "detect":
raise SystemExit("VOC xml 仅支持 detect 任务dam / dam_0417")
staging = data_root.parent / "_staging_voc" / task
staging_parent = staging.parent
if staging.exists() and not dry_run:
shutil.rmtree(staging)
if not dry_run:
convert_dam_voc(src, staging, val_ratio=0.0, seed=sk["seed"], link_images=not copy)
img_dirs = [staging / "images" / "train"]
lab_dirs = [staging / "labels" / "train"]
known_stems, known_md5 = existing_yolo_index(data_root)
dst_img = data_root / "images" / to_split
dst_lab = data_root / "labels" / to_split
added, skipped_dup, skipped_bad, skipped_no_img = 0, 0, 0, 0
bad_samples: list[str] = []
for lab in sorted(lab_dirs[0].glob("*.txt")):
err = validate_label(lab, tcfg)
if err:
skipped_bad += 1
if len(bad_samples) < 5:
bad_samples.append(f"{lab.name}: {err}")
continue
stem = lab.stem
lab_md5 = file_md5(lab) if dedup == "md5" else None
if stem in known_stems:
if dedup == "md5" and known_md5.get(stem) != lab_md5:
pass # 同名不同内容,仍跳过并计 dup可改为告警
skipped_dup += 1
continue
img = find_image(img_dirs, stem)
if img is None:
skipped_no_img += 1
continue
if not dry_run:
copy_pair(lab, img, dst_lab / f"{stem}.txt", dst_img / img.name, copy)
added += 1
known_stems.add(stem)
if lab_md5:
known_md5[stem] = lab_md5
resplit_info = None
if resplit and not dry_run and tcfg["type"] in ("detect", "pose"):
resplit_info = run_stratified_resplit(data_root, tcfg, sk, dry_run=False)
if staging_parent and staging_parent.exists() and not dry_run:
shutil.rmtree(staging_parent, ignore_errors=True)
return {
"task": task,
"type": tcfg["type"],
"added": added,
"skipped_dup": skipped_dup,
"skipped_bad_label": skipped_bad,
"skipped_no_img": skipped_no_img,
"bad_samples": bad_samples,
"to_split": to_split,
"resplit": resplit_info,
"dry_run": dry_run,
}
def existing_classify_names(gyp_root: Path, split: str) -> set[str]:
d = gyp_root / split
if not d.is_dir():
return set()
return {x.name for x in d.iterdir() if x.is_dir()}
def ingest_classify(
task: str,
tcfg: dict,
data_root: Path,
src: Path,
sk: dict,
to_split: str = "train",
resplit: bool = False,
dry_run: bool = False,
copy: bool = True,
) -> dict:
src_root = resolve_classify_layout(src)
dst_root = data_root / to_split
added, skipped_dup, new_classes = 0, 0, []
for cls_dir in sorted(d for d in src_root.iterdir() if d.is_dir()):
dst_cls = dst_root / cls_dir.name
if not dst_cls.exists() and not dry_run:
new_classes.append(cls_dir.name)
for img in cls_dir.iterdir():
if not img.is_file() or img.suffix not in IMG_EXTS:
continue
dst = dst_cls / img.name
if dst.exists():
skipped_dup += 1
continue
if dry_run:
added += 1
continue
dst_cls.mkdir(parents=True, exist_ok=True)
if copy:
shutil.copy2(img, dst)
else:
dst.symlink_to(img.resolve())
added += 1
resplit_info = None
if resplit and not dry_run:
resplit_info = run_stratified_resplit(data_root, tcfg, sk, dry_run=False)
return {
"task": task,
"type": "classify",
"added": added,
"skipped_dup": skipped_dup,
"new_classes": new_classes[:20],
"new_class_count": len(new_classes),
"to_split": to_split,
"resplit": resplit_info,
"dry_run": dry_run,
}
def append_log(root: Path, record: dict) -> None:
log = root / "manifests" / "ingest_log.jsonl"
log.parent.mkdir(parents=True, exist_ok=True)
record["ts"] = datetime.now(timezone.utc).isoformat()
with log.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def run_refresh(root: Path) -> None:
subprocess.run(
[sys.executable, str(SCRIPT_DIR / "refresh_yaml.py"), "--root", str(root)],
check=True,
)
def ingest_one(
root: Path,
reg: dict,
task: str,
src: Path,
args: argparse.Namespace,
) -> dict:
tcfg = reg["tasks"][task]
pack = getattr(args, "pack", None) or "dms_v1"
data_root = pack_task_data_root(root, pack, tcfg["task_dir"])
sk = split_kwargs(reg, args)
print(f"\n=== pack={pack} task={task} type={tcfg['type']} src={src} ===")
print(
f" split: val_ratio={sk['val_ratio']} min_train={sk['min_train_per_class']} "
f"rare_floor={sk['rare_class_train_floor']} resplit={args.resplit}"
)
if tcfg["type"] == "classify":
result = ingest_classify(
task, tcfg, data_root, src,
sk=sk,
to_split=args.to, resplit=args.resplit,
dry_run=args.dry_run, copy=args.copy,
)
else:
result = ingest_yolo(
task, tcfg, data_root, src,
sk=sk,
to_split=args.to, resplit=args.resplit,
dry_run=args.dry_run, copy=args.copy, dedup=args.dedup,
)
if not args.dry_run:
archived = archive_source_batch(src, data_root, reg, dry_run=False)
if archived:
result["archived_to"] = archived
print(f" archived source -> {archived}")
print(result)
return result
def ingest_extra_train(root: Path, reg: dict, task: str, args: argparse.Namespace) -> list[dict]:
tcfg = reg["tasks"][task]
results = []
for ep in tcfg.get("extra_train") or []:
src = Path(ep)
if not src.is_absolute():
src = (root / ep).resolve()
if not src.is_dir():
print(f" skip extra_train (missing): {src}")
continue
results.append(ingest_one(root, reg, task, src, args))
return results
def ingest_all_sources(root: Path, reg: dict, task: str, args: argparse.Namespace) -> None:
tcfg = reg["tasks"][task]
pack = getattr(args, "pack", None) or "dms_v1"
data_root = pack_task_data_root(root, pack, tcfg["task_dir"])
batches = list_pending_sources(data_root, reg)
if not batches:
print(f" sources 为空: {sources_dir(data_root, reg)}")
return
print(f"\n>>> sources {task}: {len(batches)} batch(es)")
for batch in batches:
ingest_one(root, reg, task, batch, args)
if not args.dry_run:
append_log(root, {"src": str(batch), "task": task, "pack": pack, "via": "sources"})
def ingest_all_inbox(root: Path, reg: dict, args: argparse.Namespace) -> None:
for task, tcfg in reg["tasks"].items():
inbox = root / tcfg.get("inbox", f"inbox/{task}")
if not inbox.is_dir():
continue
batches = sorted(d for d in inbox.iterdir() if d.is_dir())
if not batches:
continue
print(f"\n>>> inbox {task}: {len(batches)} batch(es)")
for batch in batches:
ingest_one(root, reg, task, batch, args)
if not args.dry_run:
append_log(root, {"src": str(batch), "task": task, "pack": pack, "via": "inbox"})
def main() -> None:
p = argparse.ArgumentParser(description="DMS 全任务增量接入")
p.add_argument("--task", help="registry 任务名;与 --all-inbox 二选一")
p.add_argument("--src", type=Path, help="新数据目录")
p.add_argument("--all-inbox", action="store_true", help="处理所有 inbox/<task>/* 批次")
p.add_argument("--all-sources", action="store_true", help="处理任务 data/sources/* 下所有待合并批次")
p.add_argument("--sync-extra", action="store_true", help="合并 registry.extra_train 中所有路径")
p.add_argument("--to", default="train", choices=("train", "val"))
p.add_argument("--no-resplit", action="store_true", help="跳过重划分(默认按 registry.split.resplit_after_ingest")
p.add_argument("--val-ratio", type=float, default=None)
p.add_argument("--seed", type=int, default=None)
p.add_argument("--copy", action="store_true")
p.add_argument("--dedup", choices=("stem", "md5"), default="stem")
p.add_argument("--dry-run", action="store_true")
p.add_argument("--refresh", action="store_true", help="完成后运行 refresh_yaml.py")
p.add_argument("--pack", default="dms_v1", help="写入的数据包名(见 data_packs.yaml")
p.add_argument("--root", type=Path, default=DATASET_ROOT)
args = p.parse_args()
root = args.root.resolve()
reg = load_registry(root)
split_cfg = reg.get("split") or {}
if args.val_ratio is None:
args.val_ratio = float(split_cfg.get("val_ratio", 0.1))
if args.seed is None:
args.seed = int(split_cfg.get("seed", 42))
if args.no_resplit:
args.resplit = False
else:
args.resplit = bool(split_cfg.get("resplit_after_ingest", True))
if args.all_sources:
if not args.task:
raise SystemExit("--all-sources 需要 --task")
ingest_all_sources(root, reg, args.task, args)
if args.refresh and not args.dry_run:
run_refresh(root)
return
if args.all_inbox:
ingest_all_inbox(root, reg, args)
if args.refresh and not args.dry_run:
run_refresh(root)
return
if args.sync_extra:
for task in reg["tasks"]:
ingest_extra_train(root, reg, task, args)
if args.refresh and not args.dry_run:
run_refresh(root)
return
if not args.task or not args.src:
raise SystemExit("需要 --task + --src或 --all-inbox / --all-sources或 --sync-extra")
if args.task not in reg["tasks"]:
raise SystemExit(f"未知 task: {args.task},可选: {list(reg['tasks'])}")
src = args.src.resolve()
if not src.is_dir():
raise SystemExit(f"源目录不存在: {src}")
result = ingest_one(root, reg, args.task, src, args)
if not args.dry_run:
append_log(root, {"src": str(src), "pack": pack, **result})
if args.refresh:
run_refresh(root)
else:
print("提示: 可运行 python scripts/refresh_yaml.py")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,138 @@
import os
import random
import glob
import argparse
from pathlib import Path
import shutil
import sys
from PIL import Image
import cv2
import csv
from itertools import islice
FILE = Path(__file__).resolve()
ROOT = FILE.parents[0] # YOLOv5 root directory
print("-------------------ROOT=",ROOT)
# test_rate=0.05
# src_data_path = "./isa_class/src_data"
def process_img(src_data_path, test_rate):
dst_data_path = src_data_path.replace('src_data','dst_data')
# if not os.path.exists(dst_data_path):
# print("create dst_data = ", dst_data_path)
# os.mkdir(dst_data_path)
list = glob.glob(os.path.join(src_data_path,"*/*.jpg"))
print("len list = ",len(list))
test_cout = int(test_rate*len(list))
print("test_cout = ",test_cout)
test_list=random.sample(list, test_cout)
print("test_list len = ",len(test_list))
# print("test_list = ",test_list)
dst_train_path=os.path.join(dst_data_path,'train')
dst_test_path=os.path.join(dst_data_path,'test')
print("dst_train_path={} , dst_test_path={}".format(dst_train_path,dst_test_path))
if not os.path.exists(dst_train_path):
os.makedirs(dst_train_path)
if not os.path.exists(dst_test_path):
os.makedirs(dst_test_path)
class_name_list = []
for x in glob.glob(os.path.join(src_data_path, '*')):
class_name_list.append(os.path.split(x)[-1])
print("class_name_list = ",class_name_list)
data_set_type = ['train','test']
for class_name in class_name_list:
for set in data_set_type:
dst_class_dir = os.path.join(dst_data_path,set,class_name)
if not os.path.exists(dst_class_dir):
os.makedirs(dst_class_dir)
print("create dst_class_dir = ",dst_class_dir)
for class_name in class_name_list:
sub_src_path = os.path.join(src_data_path, class_name)
img_list = glob.glob(os.path.join(sub_src_path,'*.jpg'))
total_len = len(img_list)
test_cout = int(test_rate*len(img_list))
if test_cout < 1:
test_cout = 1
print("test_cout = ",test_cout)
print("class_name = ",class_name)
test_list=random.sample(img_list, test_cout)
print("test_list len = ",len(test_list))
for src_img in img_list:
img_name = os.path.split(src_img)[-1]
# for date_set in data_set_type:
dst_train_img_path=os.path.join(dst_train_path, class_name, img_name)
dst_test_img_path=os.path.join(dst_test_path, class_name, img_name)
# if src_img in test_list:
# else:
# dst_img_path=os.path.join(dst_train_path, class_name, img_name)
print("src_img={},dst_train_img_path={},dst__test_img_path={}".format(src_img, dst_train_img_path, dst_test_img_path))
if src_img in test_list:
shutil.copy(src_img, dst_test_img_path)
if total_len < 2:
shutil.copy(src_img, dst_train_img_path)
else:
shutil.copy(src_img, dst_train_img_path)
def parse_opt():
parser=argparse.ArgumentParser()
parser.add_argument('--src-data',type=str,default=ROOT / "src_data",help='src dir')
parser.add_argument('--test-rate',type=float,default=0.1,help='test_rate')
# parser.add_argument('--src-test',type=str,default=ROOT / "src_test",help='src dir')
parser.add_argument('--dst-data',type=str,default=ROOT / "dst_data",help='dst dir')
# parser.add_argument('--prefix',type=str,default="dst",help='prefix name ')
opt=parser.parse_args()
print("src_data=%s" % (opt.src_data))
print("test_rate=%s" % (opt.test_rate))
# print("src_test=%s" % (opt.src_test))
print("dst_data=%s" % (opt.dst_data))
# print("prefix=%s" % (opt.prefix))
return opt
# python isa_preprocess.py --src-data ./isa_class_tsrd_gtsrb_eureg_cctsdb_tt100k/src_data
# python isa_preprocess.py --src-data ./isa_class_tsrd_gtsrb_eureg_cctsdb_tt100k_speed/src_data
# python isa_preprocess.py --src-data ./isa_class_1020/src_data
# python isa_preprocess.py --src-data ./isa_class_1023/src_data
# python isa_preprocess.py --src-data ./isa_class_1025/src_data
# python isa_preprocess.py --src-data ./isa_class_1026/src_data
# python isa_preprocess.py --src-data ./isa_class_1102/src_data
# python isa_preprocess.py --src-data ./isa_class_1120/src_data
# python isa_preprocess.py --src-data ./isa_class_1212/src_data
# python isa_preprocess.py --src-data ./isa_class_1221/src_data
# python isa_preprocess.py --src-data ./isa_class_1222/src_data
# python isa_preprocess.py --src-data ./isa_class_1224/src_data
# python isa_preprocess.py --src-data ./isa_class_1229/src_data
# python isa_preprocess.py --src-data ./isa_class_0103/src_data
# python isa_preprocess.py --src-data ./isa_class_0104/src_data
# python isa_preprocess.py --src-data ./isa_class_0108/src_data
# python isa_preprocess.py --src-data ./isa_class_0112/src_data
# python isa_preprocess.py --src-data ./isa_class_0116/src_data
def main(opt):
if opt.src_data and opt.dst_data:
# train_ppm_to_jpg(opt.src_train, opt.dst_data)
process_img(opt.src_data, opt.test_rate)
if __name__ == "__main__":
opt = parse_opt()
main(opt)

View File

@@ -0,0 +1,51 @@
"""DMS 多包data_packs.yaml + ML/workflow.registry.yaml active_packs。"""
from __future__ import annotations
from pathlib import Path
import yaml
def load_packs_registry(root: Path) -> dict:
p = root / "data_packs.yaml"
if not p.is_file():
raise SystemExit(f"缺少 {p}")
return yaml.safe_load(p.read_text(encoding="utf-8"))
def ml_workflow_path(dataset_root: Path) -> Path:
# workspace/DMS/DATASET -> DATA/HSAP/workflow.registry.yaml
return dataset_root.resolve().parent.parent.parent / "ML" / "workflow.registry.yaml"
def load_active_pack_names(dataset_root: Path, cli_packs: list[str] | None = None) -> list[str]:
if cli_packs:
return cli_packs
wf_path = ml_workflow_path(dataset_root)
if wf_path.is_file():
wf = yaml.safe_load(wf_path.read_text(encoding="utf-8"))
active = wf.get("projects", {}).get("dms", {}).get("active_packs")
if active:
return list(active)
reg = load_packs_registry(dataset_root)
return [reg["packs"][0]["name"]] if reg.get("packs") else []
def resolve_pack_dir(root: Path, pack_name: str) -> Path:
reg = load_packs_registry(root)
name = reg.get("aliases", {}).get(pack_name, pack_name)
for item in reg.get("packs", []):
if item.get("name") == name:
return (root / item["path"]).resolve()
candidate = root / name
if candidate.is_dir():
return candidate.resolve()
raise SystemExit(f"未知数据包: {pack_name},已登记: {[p['name'] for p in reg.get('packs', [])]}")
def task_data_root(dataset_root: Path, pack_name: str, task_dir: str) -> Path:
return resolve_pack_dir(dataset_root, pack_name) / task_dir
def list_registered_packs(root: Path) -> list[dict]:
return load_packs_registry(root).get("packs", [])

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""按 workflow active_packs 生成 manifests/yaml_active/*.yaml可多包合并 train/val"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
import yaml
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
from pack_registry import ( # noqa: E402
load_active_pack_names,
resolve_pack_dir,
)
def fmt_names(names) -> str:
if isinstance(names, dict):
lines = ["names:"]
for k, v in sorted(names.items(), key=lambda x: int(x[0])):
lines.append(f" {k}: {v}")
return "\n".join(lines)
inner = ", ".join(f'"{n}"' for n in names)
return f"names: [{inner}]"
def yaml_list(key: str, paths: list[str]) -> str:
if len(paths) == 1:
return f"{key}: {paths[0]}"
lines = [f"{key}:"] + [f" - {p}" for p in paths]
return "\n".join(lines)
def pack_task_root(root: Path, pack_name: str, task_dir: str) -> Path:
return resolve_pack_dir(root, pack_name) / task_dir
def build_detect_pose_yaml(
task: str,
tcfg: dict,
root: Path,
pack_names: list[str],
typ: str,
) -> str:
task_dir = tcfg["task_dir"]
bases = []
train_paths = []
val_paths = []
for pack in pack_names:
base = pack_task_root(root, pack, task_dir)
if not base.is_dir():
print(f" skip pack {pack}: missing {base}")
continue
bases.append(base)
train_paths.append(str((base / "images" / "train").resolve()))
val_paths.append(str((base / "images" / "val").resolve()))
if not bases:
raise SystemExit(f"{task}: 无可用数据包目录")
lines = [
f"# {task} — packs: {', '.join(pack_names)}",
f"path: {bases[0]}",
yaml_list("train", train_paths),
yaml_list("val", val_paths),
"",
]
if typ == "pose":
lines.insert(4, f"kpt_shape: {tcfg.get('kpt_shape', [37, 3])}")
else:
lines.extend([f"nc: {tcfg['nc']}", fmt_names(tcfg["names"]), ""])
return "\n".join(lines)
def build_classify_yaml(task: str, tcfg: dict, root: Path, pack_names: list[str]) -> str:
task_dir = tcfg["task_dir"]
if len(pack_names) > 1:
print(f" warn {task}: classify 暂用首个包 {pack_names[0]}(多包请先合并目录)")
base = pack_task_root(root, pack_names[0], task_dir)
return f"""# {task} — pack: {pack_names[0]}
path: {base.resolve()}
train: train
val: val
test: test
"""
def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("--root", type=Path, default=SCRIPT_DIR.parent)
p.add_argument("--packs", help="逗号分隔,覆盖 workflow active_packs")
p.add_argument("--task", help="只生成某一任务")
args = p.parse_args()
root = args.root.resolve()
reg = yaml.safe_load((root / "datasets.registry.yaml").read_text(encoding="utf-8"))
cli = [x.strip() for x in args.packs.split(",")] if args.packs else None
pack_names = load_active_pack_names(root, cli)
if not pack_names:
raise SystemExit("active_packs 为空,请编辑 ML/workflow.registry.yaml 或 --packs")
out_dir = root / "manifests" / "yaml_active"
out_dir.mkdir(parents=True, exist_ok=True)
print(f"active_packs: {pack_names}")
tasks = reg["tasks"]
if args.task:
if args.task not in tasks:
raise SystemExit(f"未知 task: {args.task}")
tasks = {args.task: tasks[args.task]}
for task, tcfg in tasks.items():
typ = tcfg["type"]
if typ in ("detect", "pose"):
content = build_detect_pose_yaml(task, tcfg, root, pack_names, typ)
elif typ == "classify":
content = build_classify_yaml(task, tcfg, root, pack_names)
else:
print(f" skip {task}: type {typ}")
continue
out = out_dir / f"{task}.yaml"
out.write_text(content, encoding="utf-8")
print(f" wrote {out.relative_to(root)}")
print("完成。")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,504 @@
#!/usr/bin/env python3
"""
按类别分层划分数据集,避免仅按总量随机切分导致 train/val 类别比例失衡。
YOLO 检测:先按「图像所含类别中最稀有类」决定归属,再对各类别依次划分 val。
分类(文件夹按类):每个类别目录内独立划分 train/val或 train/test
用法示例:
# 预览 DDAW 重划分效果(合并现有 train+val 后重分)
python stratified_split.py yolo --root ../gyp/ddaw_1124 --val-ratio 0.1 --dry-run
# 执行划分(会移动 images/labels 下文件)
python stratified_split.py yolo --root ../gyp/ddaw_1124 --val-ratio 0.1 --seed 42
# 分类数据:从 train 按类划出 val
python stratified_split.py classify --root ../gyp/isa_class_0116 --val-ratio 0.1 --dry-run
"""
from __future__ import annotations
import argparse
import random
import shutil
from collections import Counter, defaultdict
from pathlib import Path
IMG_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".JPG", ".JPEG", ".PNG"}
def _read_yolo_classes(label_path: Path) -> set[int]:
if not label_path.is_file():
return set()
classes: set[int] = set()
for line in label_path.read_text(encoding="utf-8", errors="ignore").splitlines():
line = line.strip()
if not line:
continue
try:
classes.add(int(line.split()[0]))
except (ValueError, IndexError):
continue
return classes
def _find_image(images_dir: Path, stem: str) -> Path | None:
for ext in IMG_EXTS:
p = images_dir / f"{stem}{ext}"
if p.is_file():
return p
return None
def collect_yolo_samples(root: Path, splits: tuple[str, ...]) -> list[tuple[str, set[int]]]:
samples: list[tuple[str, set[int]]] = []
seen: set[str] = set()
for split in splits:
labels_dir = root / "labels" / split
if not labels_dir.is_dir():
continue
for label_path in labels_dir.glob("*.txt"):
stem = label_path.stem
if stem in seen:
continue
seen.add(stem)
classes = _read_yolo_classes(label_path)
samples.append((stem, classes))
return samples
def val_count_for_class(
n: int,
val_ratio: float,
min_val_per_class: int,
min_train_per_class: int,
rare_class_train_floor: int,
) -> int:
"""该类未分配样本数为 n 时,划入 val 的数量(其余进 train"""
if n <= 0:
return 0
if n <= rare_class_train_floor:
if n <= min_train_per_class:
return 0
return min(min_val_per_class, n - min_train_per_class)
n_val = int(round(n * val_ratio))
if min_val_per_class > 0:
n_val = max(min_val_per_class, n_val)
if min_train_per_class > 0 and n > min_train_per_class:
n_val = min(n_val, n - min_train_per_class)
return max(0, min(n_val, n))
def stratified_assign(
samples: list[tuple[str, set[int]]],
val_ratio: float,
seed: int,
min_val_per_class: int = 1,
min_train_per_class: int = 1,
rare_class_train_floor: int = 5,
) -> dict[str, str]:
"""按类别分层:从稀有类到常见类,为含该类的未分配图像划分 train/val。"""
rng = random.Random(seed)
class_to_stems: dict[int, list[str]] = defaultdict(list)
stem_to_classes: dict[str, set[int]] = {}
for stem, classes in samples:
stem_to_classes[stem] = classes
for c in classes:
class_to_stems[c].append(stem)
no_label = [s for s, c in samples if not c]
assignment: dict[str, str] = {}
classes_sorted = sorted(class_to_stems.keys(), key=lambda c: len(set(class_to_stems[c])))
for c in classes_sorted:
stems = list(dict.fromkeys(class_to_stems[c]))
unassigned = [s for s in stems if s not in assignment]
if not unassigned:
continue
rng.shuffle(unassigned)
n = len(unassigned)
n_val = val_count_for_class(
n, val_ratio, min_val_per_class, min_train_per_class, rare_class_train_floor,
)
for s in unassigned[:n_val]:
assignment[s] = "val"
for s in unassigned[n_val:]:
assignment[s] = "train"
for stem in no_label:
assignment.setdefault(stem, "train")
for stem, _ in samples:
assignment.setdefault(stem, "train")
return assignment
def yolo_class_stats(root: Path, split: str) -> tuple[int, Counter, Counter]:
labels_dir = root / "labels" / split
if not labels_dir.is_dir():
return 0, Counter(), Counter()
inst = Counter()
imgs = Counter()
n_img = 0
for label_path in labels_dir.glob("*.txt"):
n_img += 1
cls_in_img: set[int] = set()
for line in label_path.read_text(encoding="utf-8", errors="ignore").splitlines():
if not line.strip():
continue
try:
c = int(line.split()[0])
except (ValueError, IndexError):
continue
inst[c] += 1
cls_in_img.add(c)
for c in cls_in_img:
imgs[c] += 1
return n_img, inst, imgs
def print_yolo_stats(root: Path, title: str) -> None:
print(f"\n=== {title} ===")
for split in ("train", "val"):
n_img, inst, imgs = yolo_class_stats(root, split)
if n_img == 0:
continue
print(f" [{split}] {n_img} images")
all_cls = sorted(set(inst) | set(imgs))
for c in all_cls:
ratio = imgs[c] / n_img * 100 if n_img else 0
print(
f" cls {c}: instances={inst[c]}, images={imgs[c]} "
f"({imgs[c]}/{n_img}={ratio:.1f}% of split images)"
)
def apply_yolo_split(
root: Path,
assignment: dict[str, str],
pool_splits: tuple[str, ...] = ("train", "val"),
dry_run: bool = False,
) -> None:
"""根据 assignment 将图像与标签移动到 images/{train,val}、labels/{train,val}。"""
for split in ("train", "val"):
(root / "images" / split).mkdir(parents=True, exist_ok=True)
(root / "labels" / split).mkdir(parents=True, exist_ok=True)
# stem -> (image_path, label_path)
located: dict[str, tuple[Path | None, Path | None]] = {}
for split in pool_splits:
labels_dir = root / "labels" / split
images_dir = root / "images" / split
if not labels_dir.is_dir():
continue
for label_path in labels_dir.glob("*.txt"):
stem = label_path.stem
if stem in located:
continue
img = _find_image(images_dir, stem) if images_dir.is_dir() else None
located[stem] = (img, label_path)
moves: list[tuple[Path, Path]] = []
for stem, target_split in assignment.items():
img_src, lab_src = located.get(stem, (None, None))
if lab_src is None:
continue
lab_dst = root / "labels" / target_split / lab_src.name
if lab_src.resolve() != lab_dst.resolve():
moves.append((lab_src, lab_dst))
if img_src is not None:
img_dst = root / "images" / target_split / img_src.name
if img_src.resolve() != img_dst.resolve():
moves.append((img_src, img_dst))
print(f" planned moves: {len(moves)}")
if dry_run:
return
for src, dst in moves:
dst.parent.mkdir(parents=True, exist_ok=True)
if dst.exists():
dst.unlink()
shutil.move(str(src), str(dst))
def cmd_yolo(args: argparse.Namespace) -> None:
root = Path(args.root).resolve()
if not (root / "images").is_dir():
raise SystemExit(f"not a YOLO dataset root (missing images/): {root}")
pool_splits = tuple(s.strip() for s in args.pool_splits.split(","))
samples = collect_yolo_samples(root, pool_splits)
print(f"pool: {root} samples={len(samples)} val_ratio={args.val_ratio} seed={args.seed}")
print_yolo_stats(root, "before")
assignment = stratified_assign(
samples,
val_ratio=args.val_ratio,
seed=args.seed,
min_val_per_class=args.min_val_per_class,
min_train_per_class=args.min_train_per_class,
rare_class_train_floor=args.rare_class_train_floor,
)
n_val = sum(1 for v in assignment.values() if v == "val")
print(f"\nplanned: train={len(assignment) - n_val} val={n_val}")
# 模拟统计(不写盘)
if args.dry_run:
tmp_counts: dict[str, Counter] = {"train": Counter(), "val": Counter()}
tmp_imgs: dict[str, Counter] = {"train": Counter(), "val": Counter()}
for stem, split in assignment.items():
for split_name in pool_splits:
lab = root / "labels" / split_name / f"{stem}.txt"
if lab.is_file():
classes = _read_yolo_classes(lab)
break
else:
classes = set()
for c in classes:
tmp_imgs[split][c] += 1
for split_name in pool_splits:
lab = root / "labels" / split_name / f"{stem}.txt"
if not lab.is_file():
continue
for line in lab.read_text(encoding="utf-8", errors="ignore").splitlines():
if line.strip():
try:
tmp_counts[split][int(line.split()[0])] += 1
except (ValueError, IndexError):
pass
break
print("\n=== after (simulated) ===")
for sp in ("train", "val"):
n = sum(1 for v in assignment.values() if v == sp)
print(f" [{sp}] {n} images")
for c in sorted(set(tmp_counts[sp]) | set(tmp_imgs[sp])):
print(f" cls {c}: instances={tmp_counts[sp][c]}, images={tmp_imgs[sp][c]}")
print("\n=== per-class val ratio (images with class / all images with class) ===")
print(f" {'cls':>4} {'before':>8} {'after':>8} {'target':>8}")
before_val: Counter[int] = Counter()
before_tot: Counter[int] = Counter()
for split in pool_splits:
_, _, imgs = yolo_class_stats(root, split)
if split == "val":
before_val.update(imgs)
before_tot.update(imgs)
after_tot = Counter()
after_val = Counter()
for stem, split in assignment.items():
for split_name in pool_splits:
lab = root / "labels" / split_name / f"{stem}.txt"
if lab.is_file():
classes = _read_yolo_classes(lab)
break
else:
classes = set()
for c in classes:
after_tot[c] += 1
if split == "val":
after_val[c] += 1
for c in sorted(set(before_tot) | set(after_tot)):
b = before_val[c] / before_tot[c] * 100 if before_tot[c] else 0
a = after_val[c] / after_tot[c] * 100 if after_tot[c] else 0
print(f" {c:4d} {b:7.1f}% {a:7.1f}% {args.val_ratio * 100:7.1f}%")
return
apply_yolo_split(root, assignment, pool_splits=pool_splits, dry_run=False)
print_yolo_stats(root, "after")
def stratified_assign_classify(
class_dirs: list[Path],
val_ratio: float,
seed: int,
min_val_per_class: int,
min_train_per_class: int,
rare_class_train_floor: int,
) -> dict[Path, str]:
"""每个类别目录内独立划分。"""
rng = random.Random(seed)
assignment: dict[Path, str] = {}
for class_dir in sorted(class_dirs):
files = [p for p in class_dir.iterdir() if p.is_file() and p.suffix in IMG_EXTS]
rng.shuffle(files)
n = len(files)
if n == 0:
continue
n_val = val_count_for_class(
n, val_ratio, min_val_per_class, min_train_per_class, rare_class_train_floor,
)
for p in files[:n_val]:
assignment[p] = "val"
for p in files[n_val:]:
assignment[p] = "train"
return assignment
def resplit_classify_root(
root: Path,
val_ratio: float = 0.1,
seed: int = 42,
min_val_per_class: int = 1,
min_train_per_class: int = 1,
rare_class_train_floor: int = 5,
dry_run: bool = False,
) -> dict[str, int]:
"""合并 train+val 按类重分 val保留 test 不动。"""
pooled: dict[str, list[Path]] = defaultdict(list)
for split in ("train", "val"):
sp = root / split
if not sp.is_dir():
continue
for cls_dir in sp.iterdir():
if not cls_dir.is_dir():
continue
for f in cls_dir.iterdir():
if f.is_file() and f.suffix in IMG_EXTS:
pooled[cls_dir.name].append(f)
staging = root / "_resplit_staging"
if staging.exists() and not dry_run:
shutil.rmtree(staging)
staged_dirs: list[Path] = []
for cls, files in sorted(pooled.items()):
seen: dict[str, Path] = {}
for f in files:
seen[f.name] = f
if not seen:
continue
cls_staging = staging / cls
if not dry_run:
cls_staging.mkdir(parents=True, exist_ok=True)
for name, f in seen.items():
dst = cls_staging / name
if dry_run:
staged_dirs.append(cls_staging)
continue
if f.resolve() != dst.resolve():
shutil.move(str(f), str(dst))
if not dry_run:
staged_dirs.append(cls_staging)
if dry_run:
n_tr = n_va = 0
for cls, files in pooled.items():
n = len({f.name for f in files})
n_val = val_count_for_class(
n, val_ratio, min_val_per_class, min_train_per_class, rare_class_train_floor,
)
n_va += n_val
n_tr += n - n_val
return {"train": n_tr, "val": n_va, "dry_run": True}
assignment = stratified_assign_classify(
staged_dirs, val_ratio, seed, min_val_per_class, min_train_per_class, rare_class_train_floor,
)
(root / "train").mkdir(exist_ok=True)
(root / "val").mkdir(exist_ok=True)
n_val = 0
for src_path, sp in assignment.items():
dst = root / sp / src_path.parent.name / src_path.name
dst.parent.mkdir(parents=True, exist_ok=True)
if dst.exists():
dst.unlink()
shutil.move(str(src_path), str(dst))
if sp == "val":
n_val += 1
if staging.exists():
shutil.rmtree(staging, ignore_errors=True)
n_train = sum(len(list((root / "train" / c).iterdir())) for c in pooled if (root / "train" / c).is_dir())
return {"train": n_train, "val": n_val}
def cmd_classify(args: argparse.Namespace) -> None:
root = Path(args.root).resolve()
src_split = args.src_split
src_dir = root / src_split
if not src_dir.is_dir():
raise SystemExit(f"missing source split dir: {src_dir}")
class_dirs = [d for d in src_dir.iterdir() if d.is_dir()]
files_all = [p for d in class_dirs for p in d.iterdir() if p.is_file() and p.suffix in IMG_EXTS]
print(f"classify: {root} classes={len(class_dirs)} images={len(files_all)}")
assignment = stratified_assign_classify(
class_dirs,
args.val_ratio,
args.seed,
args.min_val_per_class,
args.min_train_per_class,
args.rare_class_train_floor,
)
n_val = sum(1 for v in assignment.values() if v == "val")
print(f"planned: train={len(assignment) - n_val} val={n_val}")
if args.dry_run:
per_cls: dict[str, Counter] = {"train": Counter(), "val": Counter()}
for path, sp in assignment.items():
per_cls[sp][path.parent.name] += 1
print("\n=== per-class counts (simulated) ===")
for cls_name in sorted({p.parent.name for p in assignment}):
tr = per_cls["train"][cls_name]
va = per_cls["val"][cls_name]
tot = tr + va
pct = va / tot * 100 if tot else 0
print(f" {cls_name}: train={tr} val={va} (val%={pct:.1f})")
return
for target in ("train", "val"):
(root / target).mkdir(parents=True, exist_ok=True)
moves = 0
for src_path, target_split in assignment.items():
cls_name = src_path.parent.name
dst_dir = root / target_split / cls_name
dst_dir.mkdir(parents=True, exist_ok=True)
dst = dst_dir / src_path.name
if src_path.resolve() == dst.resolve():
continue
if dst.exists():
dst.unlink()
shutil.move(str(src_path), str(dst))
moves += 1
print(f"done, moved {moves} files into train/val")
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="按类别分层划分 DMS 数据集")
sub = p.add_subparsers(dest="mode", required=True)
py = sub.add_parser("yolo", help="YOLO 检测images/labels 的 train+val")
py.add_argument("--root", required=True, help="数据集根目录,含 images/ labels/")
py.add_argument("--val-ratio", type=float, default=0.1)
py.add_argument("--seed", type=int, default=42)
py.add_argument("--pool-splits", default="train,val", help="合并哪些 split 后重分")
py.add_argument("--min-val-per-class", type=int, default=1)
py.add_argument("--min-train-per-class", type=int, default=1)
py.add_argument("--rare-class-train-floor", type=int, default=5)
py.add_argument("--dry-run", action="store_true")
py.set_defaults(func=cmd_yolo)
pc = sub.add_parser("classify", help="分类:每类文件夹内独立划分")
pc.add_argument("--root", required=True)
pc.add_argument("--src-split", default="train", help="从哪个目录按类采样(如 train")
pc.add_argument("--val-ratio", type=float, default=0.1)
pc.add_argument("--seed", type=int, default=42)
pc.add_argument("--min-val-per-class", type=int, default=1)
pc.add_argument("--min-train-per-class", type=int, default=1)
pc.add_argument("--rare-class-train-floor", type=int, default=5)
pc.add_argument("--dry-run", action="store_true")
pc.set_defaults(func=cmd_classify)
return p
def main() -> None:
args = build_parser().parse_args()
args.func(args)
if __name__ == "__main__":
main()

96
datasets/dms/scripts/train.sh Executable file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# train.sh <task> [full|continue] — 读 datasets.registry.yaml
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DATASET_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
YOLO26_ROOT="${YOLO26_ROOT:-$(cd "$DATASET_ROOT/../Code/yolo26_rknn_ultralytics-main" 2>/dev/null && pwd || echo "")}"
# 优先使用 dms_yolo26 环境
if [[ -z "${CONDA_DEFAULT_ENV:-}" || "${CONDA_DEFAULT_ENV}" != "dms_yolo26" ]]; then
if [[ -f "${HOME}/miniconda3/etc/profile.d/conda.sh" ]]; then
source "${HOME}/miniconda3/etc/profile.d/conda.sh"
conda activate dms_yolo26 2>/dev/null || true
fi
fi
TASK="${1:?用法: $0 <task> [full|continue]}"
TRAIN_MODE="${2:-full}"
REG="$DATASET_ROOT/datasets.registry.yaml"
YAML="$DATASET_ROOT/manifests/yaml_active/${TASK}.yaml"
VERSIONS="$DATASET_ROOT/manifests/train_versions.yaml"
if [[ ! -f "$YAML" ]]; then
echo "找不到 yaml: $YAML"
exit 1
fi
read -r TYPE MODE MODEL EPOCHS LR0 IMGSZ RUN_SUFFIX <<< "$(python3 - <<PY
import yaml
from pathlib import Path
reg = yaml.safe_load(Path("$REG").read_text())
tcfg = reg["tasks"]["$TASK"]
typ = tcfg["type"]
train_mode = "$TRAIN_MODE" if "$TRAIN_MODE" in ("full", "continue") else reg.get("train", {}).get("mode", "full")
t = reg.get("train", {}).get(typ, reg.get("train_defaults", {}).get(typ, {}))
if train_mode == "continue":
model = t.get("warm_start") or "null"
epochs = t.get("epochs_continue", t.get("epochs_increment", 50))
lr0 = t.get("lr0_continue", t.get("lr0", 0.001))
suffix = "continue"
else:
model = t.get("model", "yolo26n.pt")
epochs = t.get("epochs", 100)
lr0 = t.get("lr0", 0.01)
suffix = "full"
imgsz = t.get("imgsz", 224 if typ == "classify" else 640)
mode = {"detect": "detect", "pose": "pose", "classify": "classify"}.get(typ, "detect")
print(typ, mode, model, epochs, lr0, imgsz, suffix)
PY
)"
# continue 模式warm_start 为空则读 train_versions.yaml
if [[ "$TRAIN_MODE" == "continue" && ( "$MODEL" == "null" || "$MODEL" == "None" || -z "$MODEL" ) ]]; then
MODEL=$(python3 - <<PY 2>/dev/null || true
import yaml
from pathlib import Path
p = Path("$VERSIONS")
if p.is_file():
v = yaml.safe_load(p.read_text()) or {}
c = v.get("$TASK", {}).get("current")
if c: print(c)
PY
)
fi
if [[ "$TRAIN_MODE" == "continue" && ( -z "$MODEL" || "$MODEL" == "null" ) ]]; then
echo "continue 模式需要 registry.train.<type>.warm_start 或 manifests/train_versions.yaml 中的 current"
exit 1
fi
RUN_NAME="${TASK}_${RUN_SUFFIX}_$(date +%Y%m%d)"
echo "task=$TASK type=$TYPE yolo_mode=$MODE train_mode=$TRAIN_MODE"
echo "data=$YAML"
echo "model=$MODEL epochs=$EPOCHS lr0=$LR0 imgsz=$IMGSZ name=$RUN_NAME"
if [[ -z "$YOLO26_ROOT" || ! -d "$YOLO26_ROOT" ]]; then
echo "请设置 YOLO26_ROOT 或安装到 ../Code/yolo26_rknn_ultralytics-main"
echo " cd \$YOLO26_ROOT"
echo " yolo $MODE train data=$YAML model=$MODEL epochs=$EPOCHS lr0=$LR0 imgsz=$IMGSZ project=runs/${MODE} name=$RUN_NAME"
exit 0
fi
cd "$YOLO26_ROOT"
yolo "$MODE" train \
data="$YAML" \
model="$MODEL" \
epochs="$EPOCHS" \
lr0="$LR0" \
imgsz="$IMGSZ" \
project="runs/${MODE}" \
name="$RUN_NAME"
BEST="runs/${MODE}/${RUN_NAME}/weights/best.pt"
echo "完成: $BEST"
echo "请更新 manifests/train_versions.yaml 中 $TASK.current = $BEST"