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:
6
datasets/dms/README.md
Normal file
6
datasets/dms/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# DMS 数据集目录
|
||||
|
||||
本目录为 **脚手架**(registry、训练脚本、manifests),不含原始图像。
|
||||
|
||||
- 完整数据:挂载外部 workspace 或 rsync `workspace/DMS/DATASET/packs` 等到此处
|
||||
- 配置入口:`datasets.registry.yaml`、`manifests/train_versions.yaml`
|
||||
3
datasets/dms/configs/README.md
Normal file
3
datasets/dms/configs/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# 旧版手写 yaml(已弃用)
|
||||
|
||||
请使用 `DMS_v1_AddBy_Luchengfang_20260520/yaml/*.yaml`(由 `scripts/refresh_yaml.py` 从 `datasets.registry.yaml` 生成)。
|
||||
7
datasets/dms/configs/addw-0523.yaml
Normal file
7
datasets/dms/configs/addw-0523.yaml
Normal 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"]
|
||||
7
datasets/dms/configs/dam_0417.yaml
Normal file
7
datasets/dms/configs/dam_0417.yaml
Normal 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"]
|
||||
7
datasets/dms/configs/ddaw.yaml
Normal file
7
datasets/dms/configs/ddaw.yaml
Normal 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"]
|
||||
7
datasets/dms/configs/isa-detect.yaml
Normal file
7
datasets/dms/configs/isa-detect.yaml
Normal 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"]
|
||||
5
datasets/dms/configs/isa_class_0116.yaml
Normal file
5
datasets/dms/configs/isa_class_0116.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
# ISA 交通标志分类 (Ultralytics classify)
|
||||
path: /home/chengfanglu/DATA/DMS/DATASET/gyp/isa_class_0116
|
||||
train: train
|
||||
val: val
|
||||
test: test
|
||||
9
datasets/dms/configs/yolov8n-face-0726.yaml
Normal file
9
datasets/dms/configs/yolov8n-face-0726.yaml
Normal 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
|
||||
16
datasets/dms/data_packs.yaml
Normal file
16
datasets/dms/data_packs.yaml
Normal 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
|
||||
101
datasets/dms/datasets.registry.yaml
Normal file
101
datasets/dms/datasets.registry.yaml
Normal 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: []
|
||||
87
datasets/dms/docs/ISA模型训练指导手册.md
Executable file
87
datasets/dms/docs/ISA模型训练指导手册.md
Executable 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模型,
|
||||
176
datasets/dms/docs/TODO_gyp_to_yolo26.md
Normal file
176
datasets/dms/docs/TODO_gyp_to_yolo26.md
Normal 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/ # 各任务 yaml,path 为相对路径
|
||||
├── 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`(20~50 张代表性图,覆盖场景)
|
||||
- [ ] **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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 阶段 5:ONNX → 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*
|
||||
207
datasets/dms/manifests/dataset_class_summary.txt
Normal file
207
datasets/dms/manifests/dataset_class_summary.txt
Normal 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 标签,待转换后按类划分)
|
||||
62
datasets/dms/manifests/stratified_split_report.txt
Normal file
62
datasets/dms/manifests/stratified_split_report.txt
Normal 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%
|
||||
53
datasets/dms/manifests/train_versions.yaml
Normal file
53
datasets/dms/manifests/train_versions.yaml
Normal 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
|
||||
7
datasets/dms/manifests/yaml_active/addw.yaml
Normal file
7
datasets/dms/manifests/yaml_active/addw.yaml
Normal 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"]
|
||||
5
datasets/dms/manifests/yaml_active/addw_face.yaml
Normal file
5
datasets/dms/manifests/yaml_active/addw_face.yaml
Normal 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]
|
||||
7
datasets/dms/manifests/yaml_active/dam.yaml
Normal file
7
datasets/dms/manifests/yaml_active/dam.yaml
Normal 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"]
|
||||
7
datasets/dms/manifests/yaml_active/dam_0417.yaml
Normal file
7
datasets/dms/manifests/yaml_active/dam_0417.yaml
Normal 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"]
|
||||
7
datasets/dms/manifests/yaml_active/ddaw.yaml
Normal file
7
datasets/dms/manifests/yaml_active/ddaw.yaml
Normal 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"]
|
||||
7
datasets/dms/manifests/yaml_active/isa.yaml
Normal file
7
datasets/dms/manifests/yaml_active/isa.yaml
Normal 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"]
|
||||
5
datasets/dms/manifests/yaml_active/isa_class.yaml
Normal file
5
datasets/dms/manifests/yaml_active/isa_class.yaml
Normal 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
|
||||
268
datasets/dms/scripts/convert_to_yolo.py
Normal file
268
datasets/dms/scripts/convert_to_yolo.py
Normal 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()
|
||||
538
datasets/dms/scripts/ingest_incremental.py
Normal file
538
datasets/dms/scripts/ingest_incremental.py
Normal 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()
|
||||
138
datasets/dms/scripts/isa_preprocess.py
Executable file
138
datasets/dms/scripts/isa_preprocess.py
Executable 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)
|
||||
51
datasets/dms/scripts/pack_registry.py
Normal file
51
datasets/dms/scripts/pack_registry.py
Normal 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", [])
|
||||
131
datasets/dms/scripts/refresh_yaml.py
Normal file
131
datasets/dms/scripts/refresh_yaml.py
Normal 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()
|
||||
504
datasets/dms/scripts/stratified_split.py
Normal file
504
datasets/dms/scripts/stratified_split.py
Normal 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
96
datasets/dms/scripts/train.sh
Executable 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"
|
||||
103
datasets/lane/DATASETS_LAYOUT.md
Normal file
103
datasets/lane/DATASETS_LAYOUT.md
Normal 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
6
datasets/lane/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Lane 数据集目录
|
||||
|
||||
本目录含列表与脚本脚手架;大图数据在 `DATASET/` 或外部 workspace。
|
||||
|
||||
- 完整数据:`export AS_WORKSPACE_ROOT=... && bash scripts/setup_links.sh`
|
||||
- 或 rsync `workspace/lane/DATASET/` 到本地
|
||||
18
datasets/lane/datasets_registry.json
Normal file
18
datasets/lane/datasets_registry.json
Normal 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": {}
|
||||
}
|
||||
144117
datasets/lane/lists_merged/train__DATASET.txt
Normal file
144117
datasets/lane/lists_merged/train__DATASET.txt
Normal file
File diff suppressed because it is too large
Load Diff
272
datasets/lane/scripts/build_ufld_dataset.py
Normal file
272
datasets/lane/scripts/build_ufld_dataset.py
Normal 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()
|
||||
96
datasets/lane/scripts/build_ufld_pack.py
Normal file
96
datasets/lane/scripts/build_ufld_pack.py
Normal 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()
|
||||
210
datasets/lane/scripts/merge_ufld_lists.py
Normal file
210
datasets/lane/scripts/merge_ufld_lists.py
Normal 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()
|
||||
325
datasets/lane/scripts/rename_ufld_dataset.py
Normal file
325
datasets/lane/scripts/rename_ufld_dataset.py
Normal 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()
|
||||
Reference in New Issue
Block a user