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

6
datasets/dms/README.md Normal file
View File

@@ -0,0 +1,6 @@
# DMS 数据集目录
本目录为 **脚手架**registry、训练脚本、manifests不含原始图像。
- 完整数据:挂载外部 workspace 或 rsync `workspace/DMS/DATASET/packs` 等到此处
- 配置入口:`datasets.registry.yaml``manifests/train_versions.yaml`

View File

@@ -0,0 +1,3 @@
# 旧版手写 yaml已弃用
请使用 `DMS_v1_AddBy_Luchengfang_20260520/yaml/*.yaml`(由 `scripts/refresh_yaml.py``datasets.registry.yaml` 生成)。

View File

@@ -0,0 +1,7 @@
# ADDW 分心检测
path: /home/chengfanglu/DATA/DMS/DATASET/gyp/addw_0523
train: images/train
val: images/val
nc: 4
names: ["face", "eye_open", "nod_face", "nod_eye"]

View File

@@ -0,0 +1,7 @@
# DAM 源数据 dam_src_0417 转换 (VOC -> YOLO)
path: /home/chengfanglu/DATA/DMS/DATASET/gyp/dam_0417
train: images/train
val: images/val
nc: 15
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"]

View File

@@ -0,0 +1,7 @@
# DDAW 疲劳检测(脸/眼/嘴状态)
path: /home/chengfanglu/DATA/DMS/DATASET/gyp/ddaw_1124
train: images/train
val: images/val
nc: 9
names: ["face", "eye_open", "eye_partially_open", "eye_close", "mouth_open", "mouth_partially_open", "mouth_close", "side_face", "nod_face"]

View File

@@ -0,0 +1,7 @@
# ISA 交通标志检测
path: /home/chengfanglu/DATA/DMS/DATASET/gyp/isa_detect
train: images/train
val: images/val
nc: 4
names: ["indicative", "prohibitory", "warning", "vehicle"]

View File

@@ -0,0 +1,5 @@
# ISA 交通标志分类 (Ultralytics classify)
path: /home/chengfanglu/DATA/DMS/DATASET/gyp/isa_class_0116
train: train
val: val
test: test

View File

@@ -0,0 +1,9 @@
# ADDW 人脸关键点 (pose)
path: /home/chengfanglu/DATA/DMS/DATASET/gyp/yoloface-0726
train: images/train
val: images/val
test:
kpt_shape: [37, 3]
names:
0: face

View File

@@ -0,0 +1,16 @@
# DMS 数据包登记(训练用哪些包由 ML/workflow.registry.yaml 的 active_packs 决定)
schema: dms-multi-pack-v1
packs:
- name: dms_v1
path: packs/dms_v1
role: baseline
frozen: true
# 新建增量包示例add/build 写入,需 enable 后才参与训练):
# - name: dms_v2
# path: packs/dms_v2
aliases:
DMS_v1_AddBy_Luchengfang_20260520: dms_v1

View File

@@ -0,0 +1,101 @@
# 任务定义 + 训练超参。数据包路径见 data_packs.yaml训练用哪些包见 ML/workflow.registry.yaml active_packs
version: 2
dataset_root: .
split:
val_ratio: 0.1
seed: 42
resplit_after_ingest: true
min_val_per_class: 1
min_train_per_class: 1
rare_class_train_floor: 5
ingest:
sources_subdir: sources
ingested_subdir: sources/_ingested
train:
mode: full
detect:
model: yolo26n.pt
epochs: 100
lr0: 0.01
imgsz: 640
warm_start: null
epochs_continue: 50
lr0_continue: 0.001
pose:
model: yolo26n-pose.pt
epochs: 100
lr0: 0.01
imgsz: 640
warm_start: null
epochs_continue: 50
lr0_continue: 0.001
classify:
model: yolo26n-cls.pt
epochs: 50
lr0: 0.01
imgsz: 224
warm_start: null
epochs_continue: 30
lr0_continue: 0.001
tasks:
ddaw:
type: detect
task_dir: ddaw
nc: 9
names: [face, eye_open, eye_partially_open, eye_close, mouth_open, mouth_partially_open, mouth_close, side_face, nod_face]
inbox: inbox/ddaw
extra_train: []
addw:
type: detect
task_dir: addw
nc: 4
names: [face, eye_open, nod_face, nod_eye]
inbox: inbox/addw
extra_train: []
addw_face:
type: pose
task_dir: addw_face
kpt_shape: [37, 3]
names: {0: face}
inbox: inbox/addw_face
extra_train: []
isa:
type: detect
task_dir: isa
nc: 4
names: [indicative, prohibitory, warning, vehicle]
inbox: inbox/isa
extra_train: []
dam:
type: detect
task_dir: dam
nc: 15
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]
inbox: inbox/dam
extra_train: []
dam_0417:
type: detect
task_dir: dam_0417
nc: 15
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]
inbox: inbox/dam_0417
extra_train: []
isa_class:
type: classify
task_dir: isa_class
inbox: inbox/isa_class
extra_train: []

View File

@@ -0,0 +1,87 @@
1,链接webserver
地址192.168.1.175
密码123
用户名xzl
2,remmina 远程传数据
/home/xzl/yolov5-6.2 训练代码
/mnt/sas/xzl13/datasets/isa_detect 检测数据集
/home/xzl/datasets 分类数据集(最新的是 isa_class_1120
3,vscode
/home/xzl/yolov5-6.2 训练工程代码文件夹
激活训练环境
conda activate /mnt/sas/xzl13/conda/yolov5s62
3.1 检测模型
3.1.1 数据处理
标注后的图片和xml标注文件整理到一个文件夹src_data下面。
所有.jpg图片放到src_data/images,所有xml文件放到src_data/labels
tar cvf isa_detect_1120.tar.gz src_data
/mnt/sas/xzl13/datasets 下新建文件夹 isa_detect_1120
remmina传输文件到下面
解压
cd /mnt/sas/xzl13/datasets/isa_detect_1120
tar xvf isa_detect_1120.tar.gz
执行脚本处理数据
python isa_detect_preprocess.py --src-data ./isa_detect_1124_test/src_data
拷贝检测数据到 /mnt/sas/xzl13/datasets/isa_detect
cd /mnt/sas/xzl13/datasets/isa_detect_1120
cp -r dst_data/* ../isa_detect/
3.1.2 服务器训练检测模型
检测训练脚本位置:/home/xzl/yolov5-6.2/train.py
cd /home/xzl/yolov5-6.2/
nohup python train.py --data isa-detect.yaml --weights '' --cfg yolov5s_isa.yaml --img 640 --batch-size 128 --device 1,2 > train_isa_detect_1124_log &
only modify train_isa_detect_1120_log for log
3.1.3 导出模型为onnx
训练完模型保存位置/home/xzl/yolov5-6.2/runs/train/exp6/weights/best.pt
执行导出转换脚本
cd /home/xzl/yolov5-6.2/
python export.py --weights runs/train/exp6/weights/best.pt --include onnx
导出文件位置:/home/xzl/yolov5-6.2/runs/train/exp6/weights/best.onnx
3.2 分类模型
3.2.1 从检测数据集roi截图
cd /mnt/sas/xzl13/datasets/
python isa_roi_img.py --train-label ./isa_detect_1120/dst_data/labels/train --val-label ./isa_detect_1120/dst_data/labels/val --save-dir ./isa_detect_1120/roi
roi截图后保存在/mnt/sas/xzl13/datasets/isa_detect_1120/roi下面。下载到本地用于下一步分类处理数据
3.2.2 数据处理
从webserver下载上次训练数据集src_data到本地
将欧洲回传的截图或检测数据集roi截图拷贝到src_data相应文件夹下
在webserser的/home/xzl/datasets/ 下新建文件夹 isa_class_1120 并将本地处理完的src_data数据上传到此目录下。
分类数据处理脚本:
cd home/xzl/datasets/
python isa_preprocess.py --src-data ./isa_class_1120/src_data
cp /home/xzl/datasets/isa_class_1120/dst_data/* /home/xzl/datasets/isa_class_1120
3.2.3 训练
/home/xzl/yolov5-6.2/classify/train.py 分类训练脚本
nohup python classify/train.py --model mobilenet_v3_large --data isa_class_1120 --epochs 80 --img 224 --device 3 > train_isa_clas_1120_log &
--data isa_class_1120 指定数据集到/home/xzl/datasets/isa_class_1120
训练完,生成文件在/home/xzl/yolov5-6.2/runs/train-cls/exp5下面。
模型文件在 /home/xzl/yolov5-6.2/runs/train-cls/exp5/weights/best.pt
3.2.4 导出onnx模型
cd /home/xzl/yolov5-6.2
python export.py --weights runs/train-cls/isa1120_classify_mobilenetv3_large/weights/best.pt --include onnx --imgsz 224
--weights 指定新生成的模型文件
转生成onnx模型

View File

@@ -0,0 +1,176 @@
# GYP 数据整理 → YOLO26 RKNN 迁移 Todo
> 数据根目录:`/home/chengfanglu/DATA/DMS/DATASET/gyp`(约 **45G**,已自 `gyp_org` / `BK2/archive/gyp` 迁入)
> 配置:`/home/chengfanglu/DATA/DMS/DATASET/configs/`
> 目标工程:`/home/chengfanglu/DATA/DMS/Code/yolo26_rknn_ultralytics-main`
---
## 阶段 0环境与目录先做
- [ ] **0.1** 确认 conda`clrnet_export` / 新建 `yolo26` 环境,能跑 `yolo train` / `yolo export format=rknn`
- [ ] **0.2** RKNN 量化环境单独建 venv与 ultralytics 依赖隔离,见 README-EN
- [ ] **0.3** 确定目标芯片平台(如 `rk3588`)写入后续 convert 命令
- [ ] **0.4** 统一数据根目录 `DMS/DATASET/dms_yolo/`(使用 `scripts/refresh_yaml.py`
```
dms_yolo/
├── yaml/ # 各任务 yamlpath 为相对路径
├── ddaw/ -> ../gyp/ddaw_1124
├── addw/, addw_face/, isa/, dam/, dam_0417/, isa_class/
├── manifests/
└── README.md
```
迁移服务器: `scripts/pack_dms_yolo.sh` 或 `rsync --copy-links`
---
## 阶段 1盘点源数据BK2/archive/gyp
| 业务 | 源路径 | 格式 | 体量 | 类别数 |
|------|--------|------|------|--------|
| DDAW | `DATASET/gyp/ddaw_1124` | YOLO images/labels | ~646M | 9 |
| ADDW | `DATASET/gyp/addw_0523` | YOLO | ~553M | 4 |
| ADDW 人脸 | `DATASET/gyp/yoloface-0726` | YOLO pose | ~1.6G | face + 37 kpts |
| ISA | `DATASET/gyp/isa_detect` | YOLO | ~40G | 4 |
| ISA 分类 | `DATASET/gyp/isa_class_0116` | 文件夹分类 | ~448M | 多类 |
| DAM | `DATASET/gyp/dam_src_0417` / `dam_0516` | jpg+xml / YOLO | ~540M + ~930M | 待确认 |
| DOWN | `down/yolov5-6.2` | 代码+runs数据在 yaml 指向服务器路径 | 待查本地 data |
- [ ] **1.1** 对每个子项目跑一遍统计train/val 图片数、标签数、空标签、坏图
- [x] **1.2** 唯一数据根:`DMS/DATASET/gyp/`(已从 archive 补齐 isa / yoloface / isa_class
- [x] **1.3** 已删除 `gyp_org` 及 `BK2/archive/gyp` 中重复数据目录(约释放 40G+
---
## 阶段 2转换为 Ultralytics/YOLO26 标准结构
### 划分原则(必守)
**不要按总量随机划分 train/val**,必须 **按类别分层**,使各类在 train/val 中的比例接近(默认 val≈10%)。
- **YOLO 检测**`scripts/stratified_split.py yolo`
合并现有 train+val 为池子后,按「图像所含最稀有类」优先依次划分,避免稀有类全进 train。
- **文件夹分类**`scripts/stratified_split.py classify`
**每个类别目录内独立**划分(与 `isa_preprocess.py` 思路一致),禁止全库 `random.sample`。
- 划分前用 `--dry-run` 查看各类 val 占比;满意后再去掉 `--dry-run` 执行。
```bash
cd DMS/DATASET/scripts
python stratified_split.py yolo --root ../gyp/ddaw_1124 --val-ratio 0.1 --dry-run
python stratified_split.py yolo --root ../gyp/ddaw_1124 --val-ratio 0.1 --seed 42
python stratified_split.py classify --root ../gyp/isa_class_0116 --src-split train --val-ratio 0.1 --dry-run
```
每个检测任务目标结构:
```
dms_yolo/<task>/
images/train/
images/val/
labels/train/ # 与 images 同名 .txt
labels/val/
<task>.yaml
```
- [ ] **2.1 DDAW**(优先,与 DMS 疲劳最相关)
- 源:`ddaw_1124`(已是 YOLO 布局)
- 复制或软链到 `dms_yolo/ddaw/`
- 编写 `ddaw.yaml``path`、`nc: 9`、`names`(与 `gyp_org/configs/ddaw.yaml` 一致)
- 校验 train/val 一一配对
- [ ] **2.2 ADDW 检测**
- 源:`addw_0523`
- 同上,生成 `addw.yaml`4 类)
- [ ] **2.3 ISA 检测**(体量大,可放后)
- 源:`isa/jiancexunlian/isa_detect`(约 5 万 train 图)
- 生成 `isa.yaml`4 类indicative / prohibitory / warning / vehicle
- **必须**用 `stratified_split.py yolo` 按类重划分(约 6 万图,先 `--dry-run`
- [ ] **2.4 DAM**
- 源:`dam/src_data_0417_pick`jpg + xml
- [ ] 编写 xml → YOLO txt 转换脚本(可参考原 yolov5 `dam-0516` 流程)
- [ ] 划分 train/val 后写入 `dms_yolo/dam/`
- [ ] **2.5 ADDW 人脸 Pose**(若上 RKNN
- 源:`yoloface-0726`
- 确认 yolo26 是否支持 `format=rknn` + pose若不支持单独保留 ultralytics820 链路
- 生成 `yoloface.yaml``kpt_shape: [37,3]`
- [ ] **2.6 DOWN / 其他**
- 清点 `down/` 下是否有本地 `images/labels`;若仅 yaml 指远程路径,从 archive 或备份补数据
---
## 阶段 3清单与量化校准集RKNN 必需)
`rknn_export/convert.py` 需要 **图片路径列表 txt**(默认 `coco_subset_20.txt`)。
- [ ] **3.1** 每个任务生成 `manifests/<task>_calib_20.txt`2050 张代表性图,覆盖场景)
- [ ] **3.2** 每个任务生成 `manifests/<task>_train.txt` / `val.txt`(可选,用于训练记录)
- [ ] **3.3** 图片尺寸统一策略:
- 检测默认 **640×640**(与 `yolo export format=rknn` 一致)
- 记录原图分辨率,训练 yaml 里可设 `imgsz`
---
## 阶段 4迁入 yolo26 工程并训练
路径:`DMS/Code/yolo26_rknn_ultralytics-main`
- [ ] **4.1** 在工程下建 `data/` 或软链:`data/dms_yolo -> ../../DATASET/dms_yolo`
- [ ] **4.2** 冒烟训练(每个任务先 1 epoch / 小 subset
```bash
yolo detect train data=../../DATASET/dms_yolo/ddaw/ddaw.yaml model=yolo26n.pt epochs=1 imgsz=640
```
- [ ] **4.3** 正式训练记录:`runs/`、best.pt、指标
- [ ] **4.4** 导出 RKNN 用 ONNX
```bash
yolo export model=runs/detect/train/weights/best.pt format=rknn imgsz=640
```
---
## 阶段 5ONNX → RKNN
- [ ] **5.1** 使用任务专属校准列表:
```bash
python rknn_export/convert.py \
--model-path <best.onnx> \
--platform rk3588 \
--data-path ../../DATASET/dms_yolo/manifests/ddaw_calib_20.txt
```
- [ ] **5.2** 板端验证:原始输出 6 tensor + CPU 后处理decode/NMS见 README-EN
- [ ] **5.3** 与旧 yolov5/yolov8 模型对比精度与延迟
---
## 阶段 6清理与文档
- [ ] **6.1** 确认 `BK2/archive/gyp` 无再用压缩包(**已完成删 16 个,约释 43G**
- [ ] **6.2** 更新 `gyp_org/README.md`:指向 `dms_yolo` 新路径
- [ ] **6.3** 在 `DMS/Code/yolo26_rknn_ultralytics-main` 增加 `docs/DMS_DATASETS.md`(类名、路径、训练命令)
---
## 建议优先级
1. **DDAW** → 冒烟训练 → RKNN 导出(验证整条链路)
2. **ADDW 检测** → 同上
3. **DAM**(需 xml 转换)
4. **ISA**40G训练成本高按需
5. **人脸 Pose**(依赖 RKNN 对 pose 的支持情况)
---
## 当前磁盘参考
| 路径 | 大小(删包后) |
|------|----------------|
| `BK2/archive/gyp` | **~51G** |
| `DMS/DATASET/gyp_org` | ~48G若与 archive 重复,合并后可再省) |
| 系统盘可用 | **~137G** |
---
*生成日期2026-05-20*

View File

@@ -0,0 +1,207 @@
======================================================================
【ddaw_1124】YOLO 检测 path: /home/chengfanglu/DATA/DMS/DATASET/gyp/ddaw_1124
======================================================================
当前划分: train=3824 val=424 test=0 合计=4248
类别数(nc): 9 类别ID: [0, 1, 2, 3, 4, 5, 6, 7, 8]
--- 当前:含该类别的图片数(按 split---
cls train val val% test 合计
0 3072 348 10.2% 0 3420
1 686 65 8.7% 0 751
2 1827 210 10.3% 0 2037
3 619 71 10.3% 0 690
4 279 29 9.4% 0 308
5 881 99 10.1% 0 980
6 2415 273 10.2% 0 2688
7 659 64 8.9% 0 723
8 138 15 9.8% 0 153
当前 val 占比: min=8.7% max=10.3% 极差=1.7% (目标≈10%)
✓ 当前各类 val 比例已较均衡
--- 分层重分后(模拟, val_ratio=0.1, seed=42) ---
train=3823 val=425
cls train val val%
0 3074 346 10.1%
1 674 77 10.3%
2 1834 203 10.0%
3 620 70 10.1%
4 277 31 10.1%
5 882 98 10.0%
6 2423 265 9.9%
7 651 72 10.0%
8 138 15 9.8%
重分后 val 占比: min=9.8% max=10.3% 极差=0.4%
✓ 分层后各类 val 比例均衡极差≤2%
======================================================================
【addw_0523】YOLO 检测 path: /home/chengfanglu/DATA/DMS/DATASET/gyp/addw_0523
======================================================================
当前划分: train=2375 val=594 test=0 合计=2969
类别数(nc): 4 类别ID: [0, 1, 2, 3]
--- 当前:含该类别的图片数(按 split---
cls train val val% test 合计
0 1367 370 21.3% 0 1737
1 415 120 22.4% 0 535
2 1018 229 18.4% 0 1247
3 1812 445 19.7% 0 2257
当前 val 占比: min=18.4% max=22.4% 极差=4.1% (目标≈10%)
⚠ 当前各类 val 比例不均衡
--- 分层重分后(模拟, val_ratio=0.1, seed=42) ---
train=2671 val=298
cls train val val%
0 1565 172 9.9%
1 481 54 10.1%
2 1121 126 10.1%
3 2029 228 10.1%
重分后 val 占比: min=9.9% max=10.1% 极差=0.2%
✓ 分层后各类 val 比例均衡极差≤2%
======================================================================
【isa_detect】YOLO 检测 path: /home/chengfanglu/DATA/DMS/DATASET/gyp/isa_detect
======================================================================
当前划分: train=50480 val=9584 test=0 合计=60064
类别数(nc): 4 类别ID: [0, 1, 2, 3]
--- 当前:含该类别的图片数(按 split---
cls train val val% test 合计
0 15871 2971 15.8% 0 18842
1 33355 7060 17.5% 0 40415
2 7348 1204 14.1% 0 8552
3 14715 2429 14.2% 0 17144
当前 val 占比: min=14.1% max=17.5% 极差=3.4% (目标≈10%)
⚠ 当前各类 val 比例不均衡
--- 分层重分后(模拟, val_ratio=0.1, seed=42) ---
train=54066 val=5940
cls train val val%
0 16883 1902 10.1%
1 36344 4013 9.9%
2 7693 855 10.0%
3 15435 1707 10.0%
重分后 val 占比: min=9.9% max=10.1% 极差=0.2%
✓ 分层后各类 val 比例均衡极差≤2%
======================================================================
【dam_0516】YOLO 检测 path: /home/chengfanglu/DATA/DMS/DATASET/gyp/dam_0516
======================================================================
当前划分: train=3780 val=1890 test=0 合计=5670
类别数(nc): 15 类别ID: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]
--- 当前:含该类别的图片数(按 split---
cls train val val% test 合计
0 2582 1337 34.1% 0 3919
1 726 373 33.9% 0 1099
2 1670 829 33.2% 0 2499
3 784 395 33.5% 0 1179
4 266 136 33.8% 0 402
5 1141 542 32.2% 0 1683
6 2066 1050 33.7% 0 3116
7 670 324 32.6% 0 994
8 445 217 32.8% 0 662
9 1088 528 32.7% 0 1616
10 306 164 34.9% 0 470
11 838 464 35.6% 0 1302
12 883 489 35.6% 0 1372
13 3700 1843 33.2% 0 5543
14 360 151 29.5% 0 511
当前 val 占比: min=29.5% max=35.6% 极差=6.1% (目标≈10%)
⚠ 当前各类 val 比例不均衡
--- 分层重分后(模拟, val_ratio=0.1, seed=42) ---
train=5104 val=566
cls train val val%
0 3525 394 10.1%
1 986 113 10.3%
2 2240 259 10.4%
3 1074 105 8.9%
4 362 40 10.0%
5 1497 186 11.1%
6 2826 290 9.3%
7 896 98 9.9%
8 595 67 10.1%
9 1473 143 8.8%
10 423 47 10.0%
11 1180 122 9.4%
12 1241 131 9.5%
13 4988 555 10.0%
14 459 52 10.2%
重分后 val 占比: min=8.8% max=11.1% 极差=2.2%
~ 分层后明显改善,少数类因样本极少会有小幅偏差
======================================================================
【yoloface-0726】YOLO 检测 path: /home/chengfanglu/DATA/DMS/DATASET/gyp/yoloface-0726
======================================================================
当前划分: train=4145 val=1464 test=0 合计=5609
类别数(nc): 1 类别ID: [0]
--- 当前:含该类别的图片数(按 split---
cls train val val% test 合计
0 4137 1462 26.1% 0 5599
当前 val 占比: min=26.1% max=26.1% 极差=0.0% (目标≈10%)
✓ 当前各类 val 比例已较均衡
--- 分层重分后(模拟, val_ratio=0.1, seed=42) ---
train=4970 val=551
cls train val val%
0 4960 551 10.0%
重分后 val 占比: min=10.0% max=10.0% 极差=0.0%
✓ 分层后各类 val 比例均衡极差≤2%
======================================================================
【isa_class_0116】分类(文件夹) path: /home/chengfanglu/DATA/DMS/DATASET/gyp/isa_class_0116
======================================================================
split: ['test', 'train'] 类别数: 92 总图片: 61747
--- 每类图片数前20类 + 汇总)---
class test train
005 43 393
010 55 501
015 47 424
020 67 604
030 214 1933
040 152 1369
050 279 2513
060 224 2024
070 179 1614
080 285 2572
090 116 1045
100 207 1866
110 88 794
120 156 1407
130 47 429
arrow_right 126 1138
arrow_up 38 350
bycycle 40 368
dis_005 30 279
dis_010 23 213
(仅列前20类共92类)
当前为 train/test 结构(无 val若需 val 请用 stratified_split.py classify --src-split train
若从 train 按类划 10% val: val% min=8.3% max=11.8% 极差=3.4%
test: 92类 min=1 max=310 avg=67 合计=6133
train: 92类 min=17 max=2792 avg=604 合计=55614
======================================================================
【dam_src_0417】原始 jpg+xml path: /home/chengfanglu/DATA/DMS/DATASET/gyp/dam_src_0417
======================================================================
src_data_0417_pick: jpg=2455 xml=2455 (无 YOLO 标签,待转换后按类划分)

View File

@@ -0,0 +1,62 @@
【ddaw_1124】 train=3823 val=425 各类val%: min=9.8% max=10.3% 极差=0.4%
cls0: train=3074 val=346 val%=10.1%
cls1: train=674 val=77 val%=10.3%
cls2: train=1834 val=203 val%=10.0%
cls3: train=620 val=70 val%=10.1%
cls4: train=277 val=31 val%=10.1%
cls5: train=882 val=98 val%=10.0%
cls6: train=2423 val=265 val%=9.9%
cls7: train=651 val=72 val%=10.0%
cls8: train=138 val=15 val%=9.8%
【addw_0523】 train=2671 val=298 各类val%: min=9.9% max=10.1% 极差=0.2%
cls0: train=1565 val=172 val%=9.9%
cls1: train=481 val=54 val%=10.1%
cls2: train=1121 val=126 val%=10.1%
cls3: train=2029 val=228 val%=10.1%
【dam_0516】 train=5104 val=566 各类val%: min=8.8% max=11.1% 极差=2.2%
cls0: train=3525 val=394 val%=10.1%
cls1: train=986 val=113 val%=10.3%
cls2: train=2240 val=259 val%=10.4%
cls3: train=1074 val=105 val%=8.9%
cls4: train=362 val=40 val%=10.0%
cls5: train=1497 val=186 val%=11.1%
cls6: train=2826 val=290 val%=9.3%
cls7: train=896 val=98 val%=9.9%
cls8: train=595 val=67 val%=10.1%
cls9: train=1473 val=143 val%=8.8%
cls10: train=423 val=47 val%=10.0%
cls11: train=1180 val=122 val%=9.4%
cls12: train=1241 val=131 val%=9.5%
cls13: train=4988 val=555 val%=10.0%
cls14: train=459 val=52 val%=10.2%
【dam_0417】 train=2210 val=245 各类val%: min=8.6% max=10.7% 极差=2.1%
cls0: train=1302 val=137 val%=9.5%
cls1: train=237 val=26 val%=9.9%
cls2: train=1117 val=125 val%=10.1%
cls3: train=607 val=68 val%=10.1%
cls4: train=220 val=24 val%=9.8%
cls5: train=749 val=77 val%=9.3%
cls6: train=1093 val=128 val%=10.5%
cls7: train=585 val=64 val%=9.9%
cls8: train=291 val=33 val%=10.2%
cls9: train=902 val=97 val%=9.7%
cls10: train=29 val=3 val%=9.4%
cls11: train=451 val=54 val%=10.7%
cls12: train=545 val=61 val%=10.1%
cls13: train=2172 val=243 val%=10.1%
cls14: train=297 val=28 val%=8.6%
【yoloface-0726】 train=4970 val=627 各类val%: min=11.2% max=11.2% 极差=0.0%
cls0: train=4960 val=627 val%=11.2%
【isa_detect】 train=54066 val=5990 各类val%: min=10.0% max=10.4% 极差=0.4%
cls0: train=16883 val=1951 val%=10.4%
cls1: train=36344 val=4063 val%=10.1%
cls2: train=7693 val=859 val%=10.0%
cls3: train=15435 val=1708 val%=10.0%
【isa_class_0116】 classify 各类val%: min=8.3% max=11.8% 极差=3.4%

View File

@@ -0,0 +1,53 @@
# 模型版本current=线上/基线candidate=待晋级train → eval → promote
# data_yaml 由 active_packs 生成,路径固定为 manifests/yaml_active/
schema: model-versions-v2
ddaw:
type: detect
data_yaml: manifests/yaml_active/ddaw.yaml
current: null
candidate: null
last_eval: null
addw:
type: detect
data_yaml: manifests/yaml_active/addw.yaml
current: null
candidate: null
last_eval: null
addw_face:
type: pose
data_yaml: manifests/yaml_active/addw_face.yaml
current: null
candidate: null
last_eval: null
isa:
type: detect
data_yaml: manifests/yaml_active/isa.yaml
current: null
candidate: null
last_eval: null
dam:
type: detect
data_yaml: manifests/yaml_active/dam.yaml
current: null
candidate: null
last_eval: null
dam_0417:
type: detect
data_yaml: manifests/yaml_active/dam_0417.yaml
current: null
candidate: null
last_eval: null
isa_class:
type: classify
data_yaml: manifests/yaml_active/isa_class.yaml
current: null
candidate: null
last_eval: null

View File

@@ -0,0 +1,7 @@
# addw — packs: dms_v1
path: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/addw
train: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/addw/images/train
val: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/addw/images/val
nc: 4
names: ["face", "eye_open", "nod_face", "nod_eye"]

View File

@@ -0,0 +1,5 @@
# addw_face — packs: dms_v1
path: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/addw_face
train: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/addw_face/images/train
val: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/addw_face/images/val
kpt_shape: [37, 3]

View File

@@ -0,0 +1,7 @@
# dam — packs: dms_v1
path: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/dam
train: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/dam/images/train
val: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/dam/images/val
nc: 15
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"]

View File

@@ -0,0 +1,7 @@
# dam_0417 — packs: dms_v1
path: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/dam_0417
train: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/dam_0417/images/train
val: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/dam_0417/images/val
nc: 15
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"]

View File

@@ -0,0 +1,7 @@
# ddaw — packs: dms_v1
path: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/ddaw
train: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/ddaw/images/train
val: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/ddaw/images/val
nc: 9
names: ["face", "eye_open", "eye_partially_open", "eye_close", "mouth_open", "mouth_partially_open", "mouth_close", "side_face", "nod_face"]

View File

@@ -0,0 +1,7 @@
# isa — packs: dms_v1
path: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/isa
train: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/isa/images/train
val: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/isa/images/val
nc: 4
names: ["indicative", "prohibitory", "warning", "vehicle"]

View File

@@ -0,0 +1,5 @@
# isa_class — pack: dms_v1
path: /home/chengfanglu/DATA/workspace/DMS/DATASET/packs/dms_v1/isa_class
train: train
val: val
test: test

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"

View File

@@ -0,0 +1,103 @@
# 多包数据集目录规范DATASET + DATASET-AddBy-*
## 目录约定
```
lane0_copy/
├── DATASET/ # 基线包 v1冻结不覆盖
│ ├── images/ ...
│ ├── annotations/segmentation_masks/ ...
│ ├── list/train_gt.txt # 仅本包内相对路径: images/... mask/...
│ └── manifest.json
├── DATASET-AddBy-zhangsan-20260615/ # 工程师增量包(独立目录)
│ ├── images/ ...
│ ├── annotations/segmentation_masks/ ...
│ ├── list/train_gt.txt
│ └── manifest.json
├── lists_merged/ # 跨包合并后的训练列表(不写回各包)
│ └── train_all_v2.txt # 行内带包名前缀,见下
└── datasets_registry.json # 登记所有包与合并列表版本
```
**命名规则:** `DATASET-AddBy-<工程师姓名>-<日期>`
- 日期建议 `YYYYMMDD`,例如 `20260615`
- 姓名用英文/拼音,避免空格(可用 `_`
## 列表文件格式(合并训练)
`data_root` 设为 **`lane0_copy`**(各包的父目录),合并列表每行两列,路径**带包名前缀**
```
DATASET/images/src_.../frame_000001.jpg DATASET/annotations/segmentation_masks/src_.../frame_000001.png
DATASET-AddBy-zhangsan-20260615/images/src_.../frame_000001.jpg DATASET-AddBy-zhangsan-20260615/annotations/...
```
UFLD 配置示例(**推荐:在 config 里写 train_packs**
```python
# configs/mufld_lane_multi_pack.py
data_root = '/home/chengfanglu/DATA/lane0_copy'
train_packs = ['DATASET', 'DATASET-A'] # 短名可在 datasets_registry.json 的 aliases 里映射
pack_list_name = 'list/train_gt.txt'
merged_list_dir = 'lists_merged'
```
`python train.py configs/mufld_lane_multi_pack.py` 会自动合并并缓存到 `lists_merged/train__DATASET__....txt`
别名示例 `datasets_registry.json`
```json
"aliases": {
"DATASET-A": "DATASET-AddBy-zhangsan-20260615"
}
```
## 工作流
### 1. 新建增量包(工程师提交 archive + train_val_gt.txt
```bash
conda activate lane_light
python scripts/build_ufld_pack.py \
--src /path/to/new_archive \
--parent /home/chengfanglu/DATA/lane0_copy \
--engineer zhangsan \
--date 20260615
```
生成:`DATASET-AddBy-zhangsan-20260615/`
### 2. 合并多包训练列表(不改动 DATASET v1
```bash
python scripts/merge_ufld_lists.py \
--data-root /home/chengfanglu/DATA/lane0_copy \
--out lists_merged/train_all_v2.txt \
--prefix-from-pack \
DATASET/list/train_gt.txt \
DATASET-AddBy-zhangsan-20260615/list/train_gt.txt
```
### 3. 训练
```bash
cd /home/chengfanglu/DATA/BK2/UFLD
# configs 里 data_root=lane0_copy, train_list=lists_merged/train_all_v2.txt
python train.py configs/mufld_lane_culane.py
```
### 4. 登记版本
合并脚本加 `--update-registry` 会写入 `datasets_registry.json`
## 原则
| 项 | 做法 |
|----|------|
| 基线复现 | 永远保留 `DATASET/list/train_gt.txt`,训练用副本 `lists_merged/*.txt` |
| 增量隔离 | 每个工程师一个 `DATASET-AddBy-*`,不往 DATASET 里混贴文件 |
| 磁盘 | 默认硬链接;跨盘用 `--copy` |
| 去重 | 合并时按**图像路径**去重,先出现的包优先(`--base` 指定主包) |

6
datasets/lane/README.md Normal file
View File

@@ -0,0 +1,6 @@
# Lane 数据集目录
本目录含列表与脚本脚手架;大图数据在 `DATASET/` 或外部 workspace。
- 完整数据:`export AS_WORKSPACE_ROOT=... && bash scripts/setup_links.sh`
- 或 rsync `workspace/lane/DATASET/` 到本地

View File

@@ -0,0 +1,18 @@
{
"schema": "ufld-multi-pack-v1",
"parent_root": "/home/chengfanglu/DATA/workspace/lane",
"base_pack": "lane_v1",
"aliases": {
"lane_v1": "DATASET",
"DATASET": "DATASET"
},
"packs": [
{
"name": "lane_v1",
"path": "DATASET",
"role": "baseline_v1",
"frozen": true
}
],
"merged_train_lists": {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""
Build UFLD-ready dataset under lane0_copy/DATASET from archive train_2025_03_13_mufld.
Layout:
DATASET/
images/<src_...>/...frame_XXXXXX.jpg|png
annotations/segmentation_masks/<src_...>/...frame_XXXXXX.png
list/train_gt.txt # 90% train (two columns)
list/val_gt.txt # 10% val
list/test_gt.txt # held-out labeled test
list/test.txt # image-only inference list
manifest.json
README.md
Uses hardlinks when possible (same filesystem, no extra disk for file data).
Usage:
conda activate lane_light
python build_ufld_dataset.py
python build_ufld_dataset.py --copy # physical copy instead of hardlink
"""
from __future__ import annotations
import argparse
import json
import os
import random
import shutil
import sys
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
# reuse naming rules
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
from rename_ufld_dataset import transform_dir_component, transform_filename # noqa: E402
DEFAULT_SRC = Path("/home/chengfanglu/DATA/lane0_copy/archive/train_2025_03_13_mufld")
DEFAULT_OUT = Path("/home/chengfanglu/DATA/lane0_copy/DATASET")
IMG_ROOT = "images"
LBL_ROOT = "annotations/segmentation_masks"
def transform_core_rel(rel: str) -> str:
"""Legacy path (no seg_label prefix) -> renamed relative path."""
rel = rel.lstrip("/").replace("\\", "/")
if rel.startswith("seg_label/"):
rel = rel[len("seg_label/") :]
parts = rel.split("/")
if not parts:
return rel
out = [transform_dir_component(parts[0])]
for i in range(1, len(parts)):
comp = parts[i]
out.append(
transform_filename(comp) if i == len(parts) - 1 else transform_dir_component(comp)
)
return "/".join(out)
def to_image_rel(legacy_img: str) -> str:
return f"{IMG_ROOT}/{transform_core_rel(legacy_img)}"
def to_mask_rel(legacy_mask: str) -> str:
return f"{LBL_ROOT}/{transform_core_rel(legacy_mask)}"
def parse_gt_line(line: str) -> tuple[str, str] | None:
parts = line.strip().split()
if len(parts) < 2:
return None
return parts[0].lstrip("/"), parts[1].lstrip("/")
def link_or_copy(src: Path, dst: Path, use_copy: bool) -> None:
dst.parent.mkdir(parents=True, exist_ok=True)
if dst.exists():
if dst.samefile(src):
return
raise FileExistsError(f"exists with different file: {dst}")
if use_copy:
shutil.copy2(src, dst)
else:
try:
os.link(src, dst)
except OSError:
shutil.copy2(src, dst)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--src", type=Path, default=DEFAULT_SRC)
ap.add_argument("--out", type=Path, default=DEFAULT_OUT)
ap.add_argument("--copy", action="store_true", help="Physical copy (uses ~2x disk)")
ap.add_argument("--val-ratio", type=float, default=0.1)
ap.add_argument("--seed", type=int, default=42)
args = ap.parse_args()
src_root = args.src.resolve()
out_root = args.out.resolve()
use_copy = args.copy
if not src_root.is_dir():
sys.exit(f"Source not found: {src_root}")
out_root.mkdir(parents=True, exist_ok=True)
list_dir = out_root / "list"
list_dir.mkdir(parents=True, exist_ok=True)
# --- collect pairs from manifests ---
train_val_path = src_root / "train_val_gt.txt"
test_gt_path = src_root / "test_gt.txt"
test_txt_path = src_root / "test.txt"
pairs: list[tuple[str, str]] = []
for line in train_val_path.read_text(encoding="utf-8", errors="replace").splitlines():
p = parse_gt_line(line)
if p:
pairs.append(p)
test_pairs: list[tuple[str, str]] = []
for line in test_gt_path.read_text(encoding="utf-8", errors="replace").splitlines():
p = parse_gt_line(line)
if p:
test_pairs.append(p)
test_images_only: list[str] = []
for line in test_txt_path.read_text(encoding="utf-8", errors="replace").splitlines():
p = line.strip().lstrip("/")
if p:
test_images_only.append(p)
# unique files to materialize
img_jobs: dict[str, str] = {} # legacy -> new rel
msk_jobs: dict[str, str] = {}
for img, msk in pairs + test_pairs:
img_jobs[img] = to_image_rel(img)
msk_jobs[msk] = to_mask_rel(msk)
for img in test_images_only:
img_jobs[img] = to_image_rel(img)
print(f"Link/copy {len(img_jobs)} images + {len(msk_jobs)} masks -> {out_root}", file=sys.stderr)
missing = []
linked_img = linked_msk = 0
for i, (legacy, new_rel) in enumerate(img_jobs.items()):
s, d = src_root / legacy, out_root / new_rel
if not s.is_file():
missing.append(("image", legacy))
continue
link_or_copy(s, d, use_copy)
linked_img += 1
if (i + 1) % 20000 == 0:
print(f" images {i+1}/{len(img_jobs)}", file=sys.stderr)
for i, (legacy, new_rel) in enumerate(msk_jobs.items()):
s, d = src_root / legacy, out_root / new_rel
if not s.is_file():
missing.append(("mask", legacy))
continue
link_or_copy(s, d, use_copy)
linked_msk += 1
if (i + 1) % 20000 == 0:
print(f" masks {i+1}/{len(msk_jobs)}", file=sys.stderr)
# --- train / val split (stratified by source) ---
by_src: dict[str, list[tuple[str, str]]] = defaultdict(list)
for img, msk in pairs:
by_src[img.split("/")[0]].append((to_image_rel(img), to_mask_rel(msk)))
rng = random.Random(args.seed)
train_lines: list[str] = []
val_lines: list[str] = []
for src_name in sorted(by_src.keys()):
items = by_src[src_name]
rng.shuffle(items)
n_val = max(1, int(len(items) * args.val_ratio)) if len(items) >= 10 else max(0, int(len(items) * args.val_ratio))
val_items = items[:n_val]
tr_items = items[n_val:]
for ir, mr in tr_items:
train_lines.append(f"{ir} {mr}")
for ir, mr in val_items:
val_lines.append(f"{ir} {mr}")
rng.shuffle(train_lines)
rng.shuffle(val_lines)
(list_dir / "train_gt.txt").write_text("\n".join(train_lines) + "\n", encoding="utf-8")
(list_dir / "val_gt.txt").write_text("\n".join(val_lines) + "\n", encoding="utf-8")
test_gt_lines = [f"{to_image_rel(i)} {to_mask_rel(m)}" for i, m in test_pairs]
(list_dir / "test_gt.txt").write_text("\n".join(test_gt_lines) + "\n", encoding="utf-8")
test_inf_lines = [to_image_rel(i) for i in test_images_only]
(list_dir / "test.txt").write_text("\n".join(test_inf_lines) + "\n", encoding="utf-8")
manifest = {
"created_utc": datetime.now(timezone.utc).isoformat(),
"source": str(src_root),
"output": str(out_root),
"link_mode": "copy" if use_copy else "hardlink",
"train_pairs": len(train_lines),
"val_pairs": len(val_lines),
"test_gt_pairs": len(test_gt_lines),
"test_inference_images": len(test_inf_lines),
"linked_images": linked_img,
"linked_masks": linked_msk,
"missing_files": missing[:50],
"missing_count": len(missing),
"val_ratio": args.val_ratio,
"seed": args.seed,
"ufld_data_root": str(out_root),
"ufld_train_list": "list/train_gt.txt",
}
(out_root / "manifest.json").write_text(
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
readme = f"""# lane0_copy/DATASET — UFLD 训练包
生成自: `{src_root}`
## 目录结构
```
DATASET/
├── images/ # 原图(清晰命名)
├── annotations/segmentation_masks/ # 分割标签(与 images 镜像路径)
├── list/
│ ├── train_gt.txt # 训练({len(train_lines)} 对)
│ ├── val_gt.txt # 验证({len(val_lines)} 对)
│ ├── test_gt.txt # 有标签测试({len(test_gt_lines)} 对)
│ └── test.txt # 仅图像推理({len(test_inf_lines)} 条)
├── manifest.json
└── README.md
```
## 命名规则
- 来源目录: `src_<类型>_<设备>_<日期>`,例如 `src_cam_zxc_20250628`
- 子目录: `clip_XX` / `scene_XX` / `unit_XX` / `video_*` 等
- 帧文件: `frame_XXXXXX.jpg` / `frame_cam_<id>.jpg`(去掉 `_new` 后缀)
## UFLD 训练
```bash
cd /home/chengfanglu/DATA/BK2/UFLD
# configs/mufld_lane_culane.py 中 data_root 指向本目录
python train.py configs/mufld_lane_culane.py
```
`LaneClsDataset` 读取 `list/train_gt.txt`两列图像相对路径、mask 相对路径)。
## 说明
- 文件通过 **{'物理复制' if use_copy else '硬链接'}** 生成,节省磁盘(硬链接与 archive 共享 inode
- 有标签评测用 `list/test_gt.txt`,勿与 `list/test.txt` 混用。
"""
(out_root / "README.md").write_text(readme, encoding="utf-8")
print(json.dumps(manifest, indent=2, ensure_ascii=False))
if missing:
print(f"WARNING: {len(missing)} missing files (see manifest)", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
Build one incremental UFLD pack: DATASET-AddBy-<engineer>-<date>
Wrapper around build_ufld_dataset layout logic; does not modify base DATASET/.
Example:
python build_ufld_pack.py \\
--src /path/to/archive \\
--parent /home/chengfanglu/DATA/lane0_copy \\
--engineer zhangsan \\
--date 20260615
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
def sanitize_engineer(name: str) -> str:
name = name.strip()
if not name:
raise ValueError("engineer name is empty")
if not re.match(r"^[A-Za-z0-9_\-]+$", name):
raise ValueError("engineer: use letters, digits, underscore, hyphen only")
return name
def pack_name(engineer: str, date: str) -> str:
date = re.sub(r"[^0-9]", "", date)
if len(date) != 8:
raise ValueError("date must be YYYYMMDD (8 digits)")
return f"DATASET-AddBy-{engineer}-{date}"
def main() -> None:
ap = argparse.ArgumentParser(description="Build DATASET-AddBy-<engineer>-<date> pack")
ap.add_argument("--src", type=Path, required=True, help="archive with train_val_gt.txt")
ap.add_argument("--parent", type=Path, default=Path("/home/chengfanglu/DATA/lane0_copy"))
ap.add_argument("--engineer", type=str, required=True)
ap.add_argument("--date", type=str, required=True, help="YYYYMMDD")
ap.add_argument("--copy", action="store_true")
ap.add_argument("--val-ratio", type=float, default=0.1)
ap.add_argument("--seed", type=int, default=42)
args = ap.parse_args()
engineer = sanitize_engineer(args.engineer)
out_name = pack_name(engineer, args.date)
out_root = args.parent.resolve() / out_name
if out_root.exists() and any(out_root.iterdir()):
sys.exit(f"Refusing to overwrite non-empty pack: {out_root}")
build_script = SCRIPT_DIR / "build_ufld_dataset.py"
cmd = [
sys.executable,
str(build_script),
"--src",
str(args.src.resolve()),
"--out",
str(out_root),
"--val-ratio",
str(args.val_ratio),
"--seed",
str(args.seed),
]
if args.copy:
cmd.append("--copy")
print(f"Building pack: {out_name}", file=sys.stderr)
subprocess.check_call(cmd)
# annotate manifest
manifest_path = out_root / "manifest.json"
if manifest_path.is_file():
import json
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["pack_name"] = out_name
manifest["engineer"] = engineer
manifest["pack_date"] = re.sub(r"[^0-9]", "", args.date)
manifest["layout"] = "DATASET-AddBy-<engineer>-<date>"
manifest_path.write_text(
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
print(f"Done: {out_root}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""
Merge UFLD list files across DATASET + DATASET-AddBy-<engineer>-<date> packs.
When --prefix-from-pack is set, data_root should be lane0_copy (parent of all packs).
Each input list path must live under <pack>/list/*.txt; lines get prefixed as <pack>/images/...
Example:
python merge_ufld_lists.py \\
--data-root /home/chengfanglu/DATA/lane0_copy \\
--prefix-from-pack \\
--out lists_merged/train_all_v2.txt \\
--update-registry \\
DATASET/list/train_gt.txt \\
DATASET-AddBy-zhangsan-20260615/list/train_gt.txt
"""
from __future__ import annotations
import argparse
import json
import sys
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
def parse_gt_line(line: str) -> tuple[str, str] | None:
parts = line.strip().split()
if len(parts) < 2:
return None
img, msk = parts[0].lstrip("/"), parts[1].lstrip("/")
return img, msk
def resolve_list_path(path: Path, data_root: Path) -> Path:
if path.is_file():
return path.resolve()
candidate = data_root / path
if candidate.is_file():
return candidate.resolve()
sys.exit(f"list not found: {path} (also tried {candidate})")
def pack_prefix_from_list(list_path: Path, data_root: Path) -> str:
"""DATASET/list/train_gt.txt -> DATASET/ ; DATASET-AddBy-x-20260615/list/... -> same."""
list_path = list_path.resolve()
data_root = data_root.resolve()
try:
rel = list_path.relative_to(data_root)
except ValueError:
if list_path.parent.name == "list":
return f"{list_path.parent.parent.name}/"
return ""
if len(rel.parts) >= 2 and rel.parts[1] == "list":
return f"{rel.parts[0]}/"
return ""
def apply_pack_prefix(img: str, msk: str, prefix: str) -> tuple[str, str]:
if not prefix:
return img, msk
if not img.startswith(prefix):
img = prefix + img
if not msk.startswith(prefix):
msk = prefix + msk
return img, msk
def load_pairs(path: Path, prefix: str) -> list[tuple[str, str]]:
pairs = []
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
p = parse_gt_line(line)
if p:
pairs.append(apply_pack_prefix(p[0], p[1], prefix))
return pairs
def validate_pairs(data_root: Path, pairs: list[tuple[str, str]]) -> tuple[list[str], list[str]]:
missing_img, missing_msk = [], []
for img, msk in pairs:
if not (data_root / img).is_file():
missing_img.append(img)
if not (data_root / msk).is_file():
missing_msk.append(msk)
return missing_img, missing_msk
def update_registry(registry_path: Path, data_root: Path, out_rel: str, input_paths: list[Path]) -> None:
if registry_path.is_file():
reg = json.loads(registry_path.read_text(encoding="utf-8"))
else:
reg = {
"schema": "ufld-multi-pack-v1",
"parent_root": str(data_root),
"base_pack": "DATASET",
"packs": [],
"merged_train_lists": {},
}
reg["parent_root"] = str(data_root)
known = {p["name"] for p in reg.get("packs", [])}
for lp in input_paths:
prefix = pack_prefix_from_list(lp, data_root)
name = prefix.rstrip("/") if prefix else lp.parent.parent.name
if name and name not in known:
reg.setdefault("packs", []).append(
{"name": name, "path": name, "role": "increment" if name != "DATASET" else "baseline_v1"}
)
known.add(name)
reg.setdefault("merged_train_lists", {})[Path(out_rel).name] = {
"path": out_rel.replace("\\", "/"),
"created_utc": datetime.now(timezone.utc).isoformat(),
"sources": [str(resolve_list_path(p, data_root)) for p in input_paths],
}
registry_path.write_text(json.dumps(reg, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def main() -> None:
ap = argparse.ArgumentParser(description="Merge UFLD lists across DATASET / DATASET-AddBy-* packs")
ap.add_argument(
"--data-root",
type=Path,
required=True,
help="parent dir containing DATASET and DATASET-AddBy-* (e.g. lane0_copy)",
)
ap.add_argument("--out", type=Path, required=True, help="output list, e.g. lists_merged/train_all_v2.txt")
ap.add_argument("inputs", nargs="+", type=Path, help="pack list files, e.g. DATASET/list/train_gt.txt")
ap.add_argument("--base", type=Path, default=None, help="processed first; duplicates skipped")
ap.add_argument(
"--prefix-from-pack",
action="store_true",
help="prefix each line with pack dir name inferred from input path",
)
ap.add_argument("--no-validate", action="store_true")
ap.add_argument("--report", type=Path, default=None)
ap.add_argument(
"--update-registry",
action="store_true",
help="update datasets_registry.json under data-root",
)
args = ap.parse_args()
data_root = args.data_root.resolve()
ordered: list[tuple[str, Path]] = []
if args.base:
ordered.append(("base", resolve_list_path(args.base, data_root)))
for i, p in enumerate(args.inputs):
ordered.append((f"input{i}", resolve_list_path(p, data_root)))
merged: list[tuple[str, str]] = []
seen: set[str] = set()
stats: dict = {"sources": {}}
for name, list_path in ordered:
prefix = pack_prefix_from_list(list_path, data_root) if args.prefix_from_pack else ""
added = skipped = 0
for img, msk in load_pairs(list_path, prefix):
if img in seen:
skipped += 1
continue
seen.add(img)
merged.append((img, msk))
added += 1
stats["sources"][str(list_path)] = {
"pack_prefix": prefix,
"added": added,
"skipped_duplicate": skipped,
}
if not args.no_validate:
missing_img, missing_msk = validate_pairs(data_root, merged)
stats["missing_images"] = len(missing_img)
stats["missing_masks"] = len(missing_msk)
if missing_img or missing_msk:
print(f"ERROR: missing {len(missing_img)} images, {len(missing_msk)} masks", file=sys.stderr)
for p in missing_img[:10]:
print(" img:", p, file=sys.stderr)
for p in missing_msk[:10]:
print(" msk:", p, file=sys.stderr)
sys.exit(1)
out_path = args.out if args.out.is_absolute() else data_root / args.out
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text("\n".join(f"{img} {msk}" for img, msk in merged) + "\n", encoding="utf-8")
stats["total_out"] = len(merged)
stats["data_root"] = str(data_root)
stats["output"] = str(out_path)
stats["prefix_from_pack"] = args.prefix_from_pack
stats["created_utc"] = datetime.now(timezone.utc).isoformat()
print(json.dumps(stats, indent=2, ensure_ascii=False))
print(f"Wrote {len(merged)} pairs -> {out_path}")
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(stats, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
if args.update_registry:
out_rel = str(out_path.relative_to(data_root)).replace("\\", "/")
update_registry(
data_root / "datasets_registry.json",
data_root,
out_rel,
[p for _, p in ordered],
)
print(f"Updated {data_root / 'datasets_registry.json'}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""
Rename lane0_copy/UFLD assets to a clearer layout and refresh index files.
Conventions
-----------
- Top-level sources: src_<type>_<device>_<YYYYMMDD> (seg_label/ mirrors the tree)
- Clips: clip_XX, scene_XX, unit_XX, driver_XXX_30fps, video_<id>
- Frames: frame_XXXXXX.jpg / .png (strip legacy _new suffix)
- Camera frames: frame_cam_<id>, frame_ts_<timestamp>
Usage:
python3 rename_ufld_dataset.py --dry-run
python3 rename_ufld_dataset.py --apply
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
from collections import defaultdict
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
UFLD_ROOT = SCRIPT_DIR.parent / "UFLD"
TOP_LEVEL_MAP: dict[str, str] = {
"100HF": "src_freeway_100hf_day",
"60HF_night": "src_freeway_60hf_night",
"crv_lane": "src_vehicle_crv_lane",
"culane_data": "src_culane",
"dvr_0422_zxc": "src_dvr_zxc_20250422",
"dvr_0424_zxc": "src_dvr_zxc_20250424",
"dvr_0425_buick": "src_dvr_buick_20250425",
"dvr_0503_buick": "src_dvr_buick_20250503",
"jiqing_highway": "src_road_jiqing",
"pic_0507_zk282": "src_cam_zk282_20250507",
"pic_0511_zk282": "src_cam_zk282_20250511",
"pic_0514_zk282": "src_cam_zk282_20250514",
"pic_0613_zk282": "src_cam_zk282_20250613",
"pic_0620_zxc": "src_cam_zxc_20250620",
"pic_0624_zxc": "src_cam_zxc_20250624",
"pic_0628_zxc": "src_cam_zxc_20250628",
"pic_1009_zk282_front30dig": "src_cam_zk282_20241009_front30deg",
"pic_1209_zk282": "src_cam_zk282_20241209",
"pic_250211_zk282": "src_cam_zk282_20250211",
"pic_250515_zk425": "src_cam_zk425_20250515",
"pic_250609_zk425": "src_cam_zk425_20250609",
"shaoyang_data": "src_road_shaoyang",
"vil": "src_vil",
}
INDEX_FILES = [
"train_val_gt.txt",
"test_gt.txt",
"test.txt",
"test.json",
"train_val.json",
"test_label.json",
]
SKIP_BASENAMES = {
"train_val_gt.txt",
"test_gt.txt",
"test.txt",
"test.json",
"train_val.json",
"test_label.json",
}
def transform_dir_component(name: str) -> str:
if name in TOP_LEVEL_MAP:
return TOP_LEVEL_MAP[name]
m = re.match(r"^scene(\d+)$", name, re.I)
if m:
return f"scene_{int(m.group(1)):02d}"
m = re.match(r"^dvr_(\d+)$", name, re.I)
if m:
return f"unit_{int(m.group(1)):02d}"
m = re.match(r"^(\d+)$", name)
if m:
n = int(m.group(1))
return f"clip_{n:02d}" if n < 1000 else f"clip_{n}"
m = re.match(r"^driver_(\d+)_30frame$", name, re.I)
if m:
return f"driver_{int(m.group(1)):03d}_30fps"
if name.upper().endswith(".MP4"):
return "video_" + name[: -len(".MP4")]
m = re.match(r"^(\d+)_Road(\d+)_Trim(\d+)_frames$", name, re.I)
if m:
return f"road_{m.group(2)}_trim_{int(m.group(3)):03d}_seq_{int(m.group(1)):02d}"
if name == "image_curve":
return "curve"
if re.match(r"^highway_\d+$", name):
return "highway"
m = re.match(r"^img_(\d+)_(\d+)_batch(\d+)$", name, re.I)
if m:
return f"batch_{int(m.group(3)):02d}_stream{int(m.group(2))}"
m = re.match(r"^pic_(\d+)_([a-z]+)_batch(\d+)$", name, re.I)
if m:
return f"batch_{int(m.group(3)):02d}_{m.group(2)}"
m = re.search(r"batch(\d+)", name, re.I)
if m and ("batch" in name.lower()):
return f"batch_{int(m.group(1)):02d}"
return name
def transform_filename(name: str) -> str:
if name in SKIP_BASENAMES:
return name
base, ext = os.path.splitext(name)
if ext == ".lines.txt":
stem = base
if stem.endswith("_new"):
stem = stem[: -len("_new")]
m = re.match(r"^(\d{5})$", stem)
if m:
return f"frame_{m.group(1)}.lines.txt"
return name
if base.endswith("_new"):
base = base[: -len("_new")]
m = re.match(r"^(\d+)$", base)
if m:
return f"frame_{int(m.group(1)):06d}{ext}"
m = re.match(r"^camera_msg_(\d+)$", base, re.I)
if m:
return f"frame_cam_{m.group(1)}{ext}"
m = re.match(r"^camera_front_6mm_(\d+)$", base, re.I)
if m:
return f"frame_cam_{m.group(1)}{ext}"
m = re.match(r"^camera_+(\d+)$", base, re.I)
if m:
return f"frame_ts_{m.group(1)}{ext}"
m = re.match(r"^frame_(\d+)_(\d+)$", base)
if m:
return f"frame_{m.group(1)}_{m.group(2)}{ext}"
m = re.match(r"^frame_(\d+)$", base, re.I)
if m:
return f"frame_{int(m.group(1)):06d}{ext}"
m = re.match(r"^(\d{5})$", base)
if m:
return f"frame_{m.group(1)}{ext}"
return f"{base}{ext}"
def transform_rel_path(rel: str) -> str:
rel = rel.lstrip("/").replace("\\", "/")
if not rel:
return rel
parts = rel.split("/")
out: list[str] = []
i = 0
if parts[0] == "seg_label":
out.append("seg_label")
i = 1
if i < len(parts):
out.append(transform_dir_component(parts[i]))
i += 1
while i < len(parts):
comp = parts[i]
if i == len(parts) - 1:
out.append(transform_filename(comp))
else:
out.append(transform_dir_component(comp))
i += 1
return "/".join(out)
def collect_file_mappings(root: Path) -> dict[str, str]:
mapping: dict[str, str] = {}
for dirpath, _, files in os.walk(root):
rel_dir = os.path.relpath(dirpath, root)
if rel_dir == ".":
rel_dir = ""
for fn in files:
if fn in SKIP_BASENAMES:
continue
old_rel = f"{rel_dir}/{fn}" if rel_dir else fn
old_rel = old_rel.replace("\\", "/")
new_rel = transform_rel_path(old_rel)
if new_rel != old_rel:
mapping[old_rel] = new_rel
return mapping
def apply_renames(root: Path, mapping: dict[str, str], dry_run: bool) -> tuple[int, int]:
ok = 0
err = 0
# longest old paths first so nested dirs still resolve
for old_rel in sorted(mapping.keys(), key=lambda p: (-p.count("/"), p)):
new_rel = mapping[old_rel]
old_abs = root / old_rel
new_abs = root / new_rel
if not old_abs.is_file():
continue
if new_abs.exists() and new_abs.resolve() != old_abs.resolve():
print(f"COLLISION: {old_rel} -> {new_rel} (target exists)")
err += 1
continue
if dry_run:
ok += 1
continue
new_abs.parent.mkdir(parents=True, exist_ok=True)
os.rename(old_abs, new_abs)
ok += 1
return ok, err
def prune_empty_dirs(root: Path, dry_run: bool) -> int:
removed = 0
for dirpath, dirs, files in os.walk(root, topdown=False):
if not dirs and not files:
p = Path(dirpath)
if p == root:
continue
if dry_run:
removed += 1
else:
try:
p.rmdir()
removed += 1
except OSError:
pass
return removed
def replace_in_line(line: str, mapping: dict[str, str]) -> str:
out = line
# Replace longest paths first
for old, new in sorted(mapping.items(), key=lambda kv: -len(kv[0])):
old_slash = "/" + old
new_slash = "/" + new
out = out.replace(old_slash, new_slash)
if out.startswith(old + " ") or out.startswith(old + "\t"):
out = new + out[len(old) :]
if out == old or out.startswith(old + "\n"):
out = new + out[len(old) :]
return out
def update_index_files(root: Path, mapping: dict[str, str], dry_run: bool) -> None:
slash_map = {"/" + k: "/" + v for k, v in mapping.items()}
slash_map.update(mapping)
for name in INDEX_FILES:
path = root / name
if not path.is_file():
continue
if name.endswith(".json"):
text = path.read_text(encoding="utf-8", errors="replace")
if dry_run:
continue
backup = path.with_suffix(path.suffix + ".bak")
if not backup.exists():
shutil.copy2(path, backup)
new_text = replace_in_line(text, slash_map)
path.write_text(new_text, encoding="utf-8")
else:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True)
new_lines = [replace_in_line(ln, slash_map) for ln in lines]
if dry_run:
continue
backup = path.with_suffix(path.suffix + ".bak")
if not backup.exists():
shutil.copy2(path, backup)
path.write_text("".join(new_lines), encoding="utf-8")
def check_collisions(mapping: dict[str, str]) -> list[str]:
rev: dict[str, list[str]] = defaultdict(list)
for old, new in mapping.items():
rev[new].append(old)
return [f"{new} <= {olds}" for new, olds in rev.items() if len(olds) > 1]
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--root", type=Path, default=UFLD_ROOT)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--apply", action="store_true")
args = ap.parse_args()
if not args.dry_run and not args.apply:
args.dry_run = True
root = args.root.resolve()
print(f"Root: {root}")
mapping = collect_file_mappings(root)
print(f"File path mappings: {len(mapping)}")
collisions = check_collisions(mapping)
if collisions:
print(f"WARNING: {len(collisions)} target collisions (showing 20)")
for c in collisions[:20]:
print(" ", c)
if not args.dry_run:
raise SystemExit("Abort: fix collisions before apply")
ok, err = apply_renames(root, mapping, dry_run=args.dry_run)
print(f"Renames: ok={ok} err={err} dry_run={args.dry_run}")
if args.apply:
empty = prune_empty_dirs(root, dry_run=False)
print(f"Removed {empty} empty directories")
update_index_files(root, mapping, dry_run=False)
meta = {
"root": str(root),
"files_renamed": ok,
"mapping_count": len(mapping),
"top_level_map": TOP_LEVEL_MAP,
}
(root / "rename_manifest.json").write_text(
json.dumps({"meta": meta, "sample": dict(list(mapping.items())[:50])}, indent=2, ensure_ascii=False),
encoding="utf-8",
)
print("Updated index files (backups: *.bak)")
else:
samples = list(mapping.items())[:8]
for a, b in samples:
print(f" {a}\n -> {b}")
if __name__ == "__main__":
main()