refactor: prepare_label_studio.py 的内容改动(补上 a3e08a9 漏掉的部分)
a3e08a9 只提交了 git mv 的改名,内容修改因 git add 的路径报错没有进入暂存区。
本提交补上:
- 缺省输出 runs/<source>_label_studio/,不再写到 data/;该目录同时是 Label Studio
的 DOCUMENT_ROOT,打印的导入步骤里提醒复核期间不要删除或 --overwrite。
- 总是生成 tasks.json 与 labeling config,去掉 --label-studio 开关。
- 不再写 data.yaml:复核材料不是训练集,没有 data.yaml 就不会被误拿去训练,
原先"train 与 val 相同"的告警也随之移除(detect.py 里的同类告警保留)。
- detect.py、configs/detect/film.yaml、README 里对旧脚本的引用改为新流程。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a3e08a9bbc
commit
891f930de2
@@ -1,45 +1,39 @@
|
||||
#!/usr/bin/env python
|
||||
"""把 generate_boxes.py 的产出整理成扁平的 YOLO 数据集,放到 data/ 下。
|
||||
"""把 generate_boxes.py 的产出整理成 Label Studio 复核材料,输出放在 runs/ 下。
|
||||
|
||||
uv run python scripts/prepare_yolo_dataset.py --source runs/boxes_gradcam
|
||||
uv run python scripts/prepare_label_studio.py --source runs/boxes_shap
|
||||
|
||||
generate_boxes.py 的产出是**按类别**分目录的(便于人工核对):
|
||||
generate_boxes.py 生成的是弱监督框,需要人工在 Label Studio 里修正之后才能拿去训练 YOLO。
|
||||
它的产出按**类别**分目录(便于人工核对),而 label-studio-converter 的 YOLO 导入只认扁平的
|
||||
`<input>/{images,labels,classes.txt}`,且 os.listdir 不递归。这一步做三件事:
|
||||
|
||||
```
|
||||
runs/boxes_gradcam/
|
||||
├── images/<类别>/<图名>.png
|
||||
└── labels/<类别>/<图名>.txt
|
||||
```
|
||||
|
||||
而 YOLO 的类别信息在标签文件的第一列里,不靠目录名。这一步做三件事:
|
||||
|
||||
1. 把按类别分的目录**拍平**,不划分 train/val——这份数据集先导入 Label Studio 复核,
|
||||
划分留到复核之后;
|
||||
1. 把按类别分的目录**拍平**;
|
||||
2. 图片**统一成 PNG**:本来就是 PNG 的硬链接过去,不占额外磁盘;BMP 等其他格式
|
||||
(pearlescent / blue / uv 原始数据都是 BMP)无损转成 PNG。不缩放像素,
|
||||
归一化的框坐标原样有效。
|
||||
|
||||
归一化的框坐标原样有效;
|
||||
3. 文件**按类别重新编号**:`<类别>_<序号>.png`,序号在类内按源路径排序,重跑结果一致。
|
||||
原文件名里的正反面、位置、时间戳等信息记在 `name_map.csv` 里,不会丢。
|
||||
想保留原名加 `--keep-names`。
|
||||
|
||||
然后在输出目录上调用 label-studio-converter,生成可直接导入的任务:
|
||||
|
||||
```
|
||||
data/<name>/
|
||||
runs/<source>_label_studio/ # 同时也是 Label Studio 的 DOCUMENT_ROOT
|
||||
├── images/<类别>_<序号>.png
|
||||
├── labels/<类别>_<序号>.txt
|
||||
├── classes.txt # 每行一个类别名,行号即 class_id
|
||||
├── name_map.csv # 新名 ↔ 源文件对照
|
||||
├── data.yaml # train 与 val 都指向 images/,不能直接用于评估
|
||||
├── classes.txt # 每行一个类别名,行号即 class_id
|
||||
├── name_map.csv # 新名 ↔ 源文件对照
|
||||
├── summary.json
|
||||
├── tasks.json # 仅 --label-studio:框作为 predictions
|
||||
└── tasks.label_config.xml # 仅 --label-studio
|
||||
├── tasks.json # 每张图一个任务,框作为 predictions
|
||||
└── tasks.label_config.xml
|
||||
```
|
||||
|
||||
这个结构正是 label-studio-converter 的 YOLO 导入要求的 `<input>/{images,labels,classes.txt}`,
|
||||
所以 `--label-studio` 直接在数据集根目录上转换,DOCUMENT_ROOT 就是数据集根目录。
|
||||
这里**不写 data.yaml、不划分 train/val**:这份材料是给人复核的,不是训练集。
|
||||
修好标签后,再从 Label Studio 导出、划分到 data/ 下给 YOLO 训练。
|
||||
复核期间 Label Studio 直接从这个目录读图,**不要删除或 --overwrite 它**。
|
||||
|
||||
没有框的图(标签为空)默认保留:YOLO 训练里它们是负样本,有助于压低误检。但**空标签的
|
||||
语义是"这张图什么都没有"**,不是"有缺陷但没定位出来"。来自缺陷类别的空标签如果照搬,
|
||||
没有框的图(标签为空)默认保留,导入后是一个没有预标注框的任务。但**空标签在 YOLO 里的语义是
|
||||
"这张图什么都没有"**,不是"有缺陷但没定位出来"。来自缺陷类别的空标签如果原样走到训练,
|
||||
等于在教模型抑制真实缺陷,所以可以用 `--empty-policy` 按来源类别分别处置:
|
||||
|
||||
--empty-policy drop normal=keep black=full
|
||||
@@ -67,7 +61,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import yaml # noqa: E402
|
||||
|
||||
from datasets.yolo import IMAGE_SUFFIXES, write_classes_txt, write_data_yaml # noqa: E402
|
||||
from datasets.yolo import IMAGE_SUFFIXES, write_classes_txt # noqa: E402
|
||||
from utils import setup_logging # noqa: E402
|
||||
|
||||
logger = None
|
||||
@@ -210,7 +204,7 @@ def assign_names(samples: Sequence[Sample], names: Sequence[str]) -> dict[Path,
|
||||
|
||||
|
||||
def build_label_studio_tasks(out_dir: Path, num_images: int) -> dict:
|
||||
"""在数据集根目录上调用 label-studio-converter,生成 tasks.json 与 labeling config。
|
||||
"""在输出目录上调用 label-studio-converter,生成 tasks.json 与 labeling config。
|
||||
|
||||
converter 的 YOLO 导入只认 `<input>/{images,labels,classes.txt}`,且 os.listdir 不递归——
|
||||
这正是本脚本产出的扁平结构。图片是硬链接而不是软链接也有讲究:Label Studio 本地存储
|
||||
@@ -222,7 +216,7 @@ def build_label_studio_tasks(out_dir: Path, num_images: int) -> dict:
|
||||
try:
|
||||
from label_studio_sdk.converter.imports.yolo import convert_yolo_to_ls
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("--label-studio 需要 label-studio(uv sync 后可用)") from exc
|
||||
raise RuntimeError("需要 label-studio(uv sync 后可用)") from exc
|
||||
|
||||
tasks_file = out_dir / "tasks.json"
|
||||
# converter 会往 stdout 打一份缺了环境变量的导入说明,还会刷一串 root logger 的 INFO;
|
||||
@@ -323,15 +317,16 @@ def main() -> int:
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
parser.add_argument("--source", required=True, help="generate_boxes.py 的产出目录")
|
||||
parser.add_argument("--out", default=None, help="输出目录;缺省 data/<source 目录名>")
|
||||
parser.add_argument("--data-root", default="data", help="自动推导时的根目录,默认 data")
|
||||
parser.add_argument(
|
||||
"--out", default=None, help="输出目录;缺省 runs/<source 目录名>_label_studio",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--empty-policy", nargs="*", default=[], metavar="POLICY|类别=POLICY",
|
||||
help=(
|
||||
"怎么处置没有框的图(标签为空)。可写一个全局默认,再按类别覆盖,如 "
|
||||
"`--empty-policy drop normal=keep black=full`。"
|
||||
"keep=当负样本保留(默认)、drop=丢弃、full=退化成整图框。"
|
||||
"注意空标签在 YOLO 里的意思是『此图无缺陷』,缺陷类的空标签照搬会教模型抑制真实缺陷"
|
||||
"注意空标签在 YOLO 里的意思是『此图无缺陷』,缺陷类的空标签走到训练会教模型抑制真实缺陷"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -339,15 +334,14 @@ def main() -> int:
|
||||
help="保留源文件名,不按 <类别>_<序号> 重新编号(源文件名需去掉后缀后全局唯一)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--label-studio", action="store_true",
|
||||
help="额外在数据集根目录生成 tasks.json 与 labeling config,可直接导入 Label Studio 复核",
|
||||
"--overwrite", action="store_true",
|
||||
help="输出目录已存在时先清空。Label Studio 复核期间从这里读图,复核中不要用",
|
||||
)
|
||||
parser.add_argument("--overwrite", action="store_true", help="输出目录已存在时先清空")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只打印计划")
|
||||
args = parser.parse_args()
|
||||
|
||||
source = Path(args.source)
|
||||
out_dir = Path(args.out) if args.out else Path(args.data_root) / source.name
|
||||
out_dir = Path(args.out) if args.out else Path("runs") / f"{source.name}_label_studio"
|
||||
logger = setup_logging()
|
||||
|
||||
try:
|
||||
@@ -383,7 +377,7 @@ def main() -> int:
|
||||
)
|
||||
if as_negative:
|
||||
logger.warning(
|
||||
"以上 %s 的空标签会以『此图无缺陷』的身份进入训练。"
|
||||
"以上 %s 的空标签会以『此图无缺陷』的身份导入,复核时不补框的话会这样进入训练。"
|
||||
"如果其中某些类其实是有缺陷、只是没能定位出来,它们会教模型抑制真实缺陷——"
|
||||
"用 --empty-policy 逐类改成 drop 或 full。",
|
||||
"/".join(as_negative),
|
||||
@@ -408,8 +402,7 @@ def main() -> int:
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
if args.label_studio:
|
||||
logger.info("--label-studio:会在 %s 下生成 tasks.json", out_dir)
|
||||
logger.info("输出: %s", out_dir)
|
||||
logger.info("--dry-run:未写入任何文件")
|
||||
return 0
|
||||
|
||||
@@ -452,17 +445,12 @@ def main() -> int:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(f"{sample.full_box} 0.5 0.5 1.0 1.0\n", encoding="utf-8")
|
||||
|
||||
# 不划分时 train 与 val 只能指向同一目录:ultralytics 能读,但验证指标算的是训练集本身
|
||||
write_data_yaml(out_dir / "data.yaml", names, root=out_dir.resolve(), train="images", val="images")
|
||||
write_classes_txt(out_dir / "classes.txt", names)
|
||||
|
||||
label_studio = None
|
||||
if args.label_studio:
|
||||
try:
|
||||
label_studio = build_label_studio_tasks(out_dir, len(samples))
|
||||
except RuntimeError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 1
|
||||
try:
|
||||
label_studio = build_label_studio_tasks(out_dir, len(samples))
|
||||
except RuntimeError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 1
|
||||
# utf-8-sig:带 BOM,Excel / Numbers 直接打开中文源路径不乱码
|
||||
with (out_dir / "name_map.csv").open("w", encoding="utf-8-sig", newline="") as handle:
|
||||
writer = csv.writer(handle)
|
||||
@@ -480,7 +468,6 @@ def main() -> int:
|
||||
"empty_policy": {"default": default_policy, **per_class_policy},
|
||||
"renamed": renamed is not None,
|
||||
"label_studio": label_studio,
|
||||
"split": False,
|
||||
"per_class": dict(counts),
|
||||
"link_modes": dict(modes),
|
||||
},
|
||||
@@ -491,25 +478,21 @@ def main() -> int:
|
||||
)
|
||||
logger.info("图片落盘方式: %s(hard=硬链接, copy=跨盘复制, convert=转成 PNG)", dict(modes))
|
||||
logger.info("完成 -> %s", out_dir)
|
||||
logger.warning(
|
||||
"数据集未划分 train/val,data.yaml 的 train 与 val 都指向 images/。"
|
||||
"直接拿去训练的话验证指标算的是训练集本身,会虚高;训练前需要另行划分。"
|
||||
# label-studio-converter 自带的提示漏了环境变量(它在循环里改写了 image_root_url,
|
||||
# 与默认值比较时就不相等了),这里给出带实际路径的完整步骤
|
||||
logger.info(
|
||||
"Label Studio 任务 %d 个,导入步骤:\n"
|
||||
" 1) export LABEL_STUDIO_LOCAL_FILES_SERVING_ENABLED=true\n"
|
||||
" export LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT=%s\n"
|
||||
" uv run label-studio start\n"
|
||||
" 2) 新建项目 -> Labeling Setup -> Code,粘贴 %s\n"
|
||||
" 3) Settings -> Cloud Storage -> Add Source Storage -> Local files,\n"
|
||||
" 绝对路径填 %s/images。只保存,不要点 Sync(会给每张图再建一个空任务)\n"
|
||||
" 4) 导入 %s\n"
|
||||
"复核期间 Label Studio 从 %s 读图,不要删除或 --overwrite 这个目录",
|
||||
label_studio["tasks"], label_studio["document_root"], label_studio["label_config"],
|
||||
label_studio["document_root"], label_studio["tasks_file"], label_studio["document_root"],
|
||||
)
|
||||
if label_studio:
|
||||
# label-studio-converter 自带的提示漏了环境变量(它在循环里改写了 image_root_url,
|
||||
# 与默认值比较时就不相等了),这里给出带实际路径的完整步骤
|
||||
logger.info(
|
||||
"Label Studio 任务 %d 个,导入步骤:\n"
|
||||
" 1) export LABEL_STUDIO_LOCAL_FILES_SERVING_ENABLED=true\n"
|
||||
" export LABEL_STUDIO_LOCAL_FILES_DOCUMENT_ROOT=%s\n"
|
||||
" uv run label-studio start\n"
|
||||
" 2) 新建项目 -> Labeling Setup -> Code,粘贴 %s\n"
|
||||
" 3) Settings -> Cloud Storage -> Add Source Storage -> Local files,\n"
|
||||
" 绝对路径填 %s/images。只保存,不要点 Sync(会给每张图再建一个空任务)\n"
|
||||
" 4) 导入 %s",
|
||||
label_studio["tasks"], label_studio["document_root"], label_studio["label_config"],
|
||||
label_studio["document_root"], label_studio["tasks_file"],
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user