Files
ModelTrainEval/scripts/export_clusters.py
T
Shaun ZhouandClaude Opus 5 f8654f447b feat(export): 改为按 clusters/ 目录导出,并重定位失效的软链
原来按 assignments.csv 导出,反映的是聚类当时的划分;而 clusters/ 目录会被人工
整理——挪动分错的图、把 cluster_03 改名成 grease。现在以整理后的目录为准。

- 扫描 clusters/ 下的子目录,目录名即类别名并原样保留(不再是数字编号)
- 文件名用原图的名字,去掉 clusters/ 里的 NNNN_ 排序前缀
- 失效软链的重定位:读出链接目标,按其中的 data/ 段落到本机 --source-root 上;
  再不行用文件名在源目录里兜底索引(惰性构建,用不到就不扫盘)。
  从服务器拷回来的 clusters/ 软链全部指向 /home/deep/... 在本机失效,这是常态
- 解析不到的条目列出并中止,--skip-missing 可忽略
- manifest 记录类别、每张图的原始路径与被跳过的条目

在真实目录上验证:11,212 张、15 个类别,软链 100% 失效但全部重定位成功,
导出后抽查 200 张与原图逐字节一致。跨数据集的数字文件名(blue/113.bmp 与
pearlescent/113.bmp)会被重名检查拦下,--prefix-parent 可化解。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 11:26:02 +08:00

275 lines
11 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
"""按 clusters/ 目录里的分组把原图复制成一个新数据集。
uv run python scripts/export_clusters.py --work-dir runs/orgnized_from_r101_k40
聚类脚本产出的 `clusters/` 里是**软链**,方便人工翻看、调整——可以把图在子目录之间
挪动、删掉分错的、把 `cluster_03` 改名成 `grease`。这个脚本以整理后的目录为准,
把每张图的**真实文件**复制出来:
```
data/orgnized_from_r101_k40/
├── grease/ ← 子目录名沿用 clusters/ 里的名字(人工整理过的类别名)
├── mosquito/
├── normal/
└── manifest.json # 每张图的原始路径与所属类别,便于追溯
```
**软链失效也没关系。** 从服务器拷回来的 clusters/ 目录,软链指向的是服务器路径
(`/home/deep/ModelTrainEval/data/raw/...`),在本机全部失效。脚本会读出链接目标,
按其中的 `data/` 段重新落到本机 `--source-root` 上;万一还找不到,再用文件名在
源目录里兜底索引一次。
导出的文件名用**原图的名字**,不带 clusters/ 里的 `0125_` 排序前缀。
加 `--prefix-parent` 则再拼上原父目录名(`反面纵黑点/abc.png` -> `反面纵黑点_abc.png`),
便于导出后仍能看出图片来自哪个原始目录。
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
from collections import Counter, defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from utils import setup_logging # noqa: E402
logger = None
IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp")
class SourceResolver:
"""把 clusters/ 里的条目解析成本机上的真实图片路径。
三种情况依次尝试:
1. 条目本身就是真实文件(有人直接把图拷进去了);
2. 软链有效,直接 resolve;
3. 软链失效——读出链接目标,按 `data/` 段重新落到本机 `source_root` 上。
再不行就用链接目标的文件名在源目录里查索引(索引惰性构建,用不到就不扫盘)。
"""
def __init__(self, source_root: Path, marker: str = "data") -> None:
self.source_root = source_root.resolve()
self.marker = marker
self._index: dict[str, Path] | None = None
def _rebase(self, target: Path) -> Path | None:
"""把 /home/deep/ModelTrainEval/data/raw/film/x.png 落到本机同名的 data/ 下。"""
parts = target.parts
if self.marker not in parts:
return None
candidate = self.source_root.joinpath(*parts[parts.index(self.marker) :])
return candidate if candidate.exists() else None
def _lookup(self, name: str) -> Path | None:
if self._index is None:
logger.info("建立文件名索引以兜底解析(扫描 %s)…", self.source_root / self.marker)
self._index = {
path.name: path
for path in (self.source_root / self.marker).rglob("*")
if path.suffix.lower() in IMAGE_SUFFIXES
}
logger.info("索引 %d 个文件", len(self._index))
return self._index.get(name)
def resolve(self, entry: Path) -> Path | None:
if entry.is_symlink():
target = Path(os.readlink(entry))
if entry.exists(): # 软链有效
return entry.resolve()
return self._rebase(target) or self._lookup(target.name)
if entry.is_file():
return entry.resolve()
return None
def read_clusters(clusters_dir: Path) -> dict[str, list[Path]]:
"""扫描 clusters/ 下的每个子目录,子目录名即类别名。"""
if not clusters_dir.is_dir():
raise FileNotFoundError(f"找不到目录 {clusters_dir},先跑一次 scripts/cluster_unlabeled.py")
groups: dict[str, list[Path]] = {}
for category in sorted(p for p in clusters_dir.iterdir() if p.is_dir()):
entries = sorted(
p
for p in category.rglob("*")
if (p.is_symlink() or p.is_file()) and p.suffix.lower() in IMAGE_SUFFIXES
)
if entries:
groups[category.name] = entries
if not groups:
raise ValueError(f"{clusters_dir} 下没有找到任何图片条目")
return groups
def destination_name(source: Path, prefix_parent: bool = False) -> str:
"""目标文件名,用**原图**的名字(不带 clusters/ 里的 NNNN_ 排序前缀)。
`prefix_parent` 时拼上原父目录名:导出后只剩类别目录,来源信息本来会丢失。
"""
return f"{source.parent.name}_{source.name}" if prefix_parent else source.name
def check_conflicts(resolved: dict[str, list[Path]], prefix_parent: bool = False) -> None:
"""同一个类别里出现同名文件时直接报错——不改名就不能悄悄覆盖。"""
problems = []
for category, files in sorted(resolved.items()):
names = Counter(destination_name(f, prefix_parent) for f in files)
for name, count in names.items():
if count > 1:
sources = [str(f) for f in files if destination_name(f, prefix_parent) == name][:3]
problems.append(f" {category} 里 {name!r} 出现 {count} 次: {', '.join(sources)} …")
if problems:
hint = "" if prefix_parent else "可以试试 --prefix-parent 用父目录名区分,或"
raise ValueError(
f"以下文件重名,同名会互相覆盖;{hint}先重命名源文件:\n" + "\n".join(problems[:20])
)
def main() -> int:
global logger
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--work-dir", default=None, help="聚类产出目录(取其中的 clusters/)")
parser.add_argument("--clusters", default=None, help="直接指定 clusters 目录")
parser.add_argument("--out", default=None, help="输出目录;缺省自动推导到 data/ 下")
parser.add_argument("--data-root", default="data", help="自动推导时的输出根目录,默认 data")
parser.add_argument(
"--source-root",
default=".",
help="本机存放原图的仓库根目录,用来重定位失效的软链,默认当前目录",
)
parser.add_argument("--overwrite", action="store_true", help="输出目录已存在时先清空")
parser.add_argument("--dry-run", action="store_true", help="只打印计划,不真的复制")
parser.add_argument("--no-manifest", action="store_true", help="不写 manifest.json")
parser.add_argument(
"--prefix-parent",
action="store_true",
help="复制时在文件名前拼上原父目录名(反面纵黑点/abc.png -> 反面纵黑点_abc.png)",
)
parser.add_argument(
"--skip-missing", action="store_true", help="解析不到的条目跳过而不是中止"
)
args = parser.parse_args()
logger = setup_logging()
if not args.work_dir and not args.clusters:
logger.error("必须给 --work-dir 或 --clusters 之一")
return 1
work_dir = Path(args.work_dir) if args.work_dir else None
clusters_dir = Path(args.clusters) if args.clusters else work_dir / "clusters"
try:
groups = read_clusters(clusters_dir)
except (FileNotFoundError, ValueError) as exc:
logger.error("%s", exc)
return 1
out_dir = (
Path(args.out) if args.out else Path(args.data_root) / clusters_dir.resolve().parent.name
)
total = sum(len(v) for v in groups.values())
logger.info("来源: %s(%d 张,%d 个类别)", clusters_dir, total, len(groups))
logger.info("输出目录: %s", out_dir)
resolver = SourceResolver(Path(args.source_root))
resolved: dict[str, list[Path]] = defaultdict(list)
unresolved: list[Path] = []
for category, entries in groups.items():
for entry in entries:
source = resolver.resolve(entry)
if source is None:
unresolved.append(entry)
else:
resolved[category].append(source)
if unresolved:
logger.error("有 %d 个条目在本机找不到对应的原图,例如:", len(unresolved))
for entry in unresolved[:5]:
target = os.readlink(entry) if entry.is_symlink() else "(不是软链)"
logger.error(" %s -> %s", entry, target)
if not args.skip_missing:
logger.error(
"确认 --source-root 指向存放 data/ 的目录(当前 %s);"
"确实要忽略这些条目请加 --skip-missing",
Path(args.source_root).resolve(),
)
return 1
logger.warning("--skip-missing:跳过上述 %d 个条目", len(unresolved))
try:
check_conflicts(resolved, args.prefix_parent)
except ValueError as exc:
logger.error("%s", exc)
return 1
for category in sorted(resolved):
logger.info(" %-14s -> %s/%s/ 共 %d 张", category, out_dir.name, category,
len(resolved[category]))
if args.prefix_parent:
example = next(iter(next(iter(resolved.values()))))
logger.info("文件名加父目录前缀:%s -> %s", example.name, destination_name(example, True))
if args.dry_run:
logger.info("--dry-run:未写入任何文件")
return 0
if out_dir.exists() and any(out_dir.iterdir()):
if not args.overwrite:
logger.error("%s 已存在且非空;确认要覆盖请加 --overwrite", out_dir)
return 1
shutil.rmtree(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
copied = 0
total_bytes = 0
manifest: dict[str, list[dict]] = {}
for category in sorted(resolved):
target_dir = out_dir / category
target_dir.mkdir(parents=True, exist_ok=True)
entries = []
for source in sorted(resolved[category]):
name = destination_name(source, args.prefix_parent)
destination = target_dir / name
shutil.copy2(source, destination) # copy2 连同修改时间一起复制
total_bytes += destination.stat().st_size
copied += 1
entries.append({"file": name, "source": str(source)})
manifest[category] = entries
logger.info("%-14s 完成:%d 张", category, len(entries))
if not args.no_manifest:
summary_path = (work_dir or clusters_dir.parent) / "summary.json"
summary = json.loads(summary_path.read_text(encoding="utf-8")) if summary_path.exists() else {}
(out_dir / "manifest.json").write_text(
json.dumps(
{
"clusters_dir": str(clusters_dir.resolve()),
"source_root": str(Path(args.source_root).resolve()),
"categories": sorted(manifest),
"prefix_parent": args.prefix_parent,
"skipped": [str(p) for p in unresolved],
"feature_source": summary.get("feature_source"),
"category_sizes": {k: len(v) for k, v in sorted(manifest.items())},
"files": manifest,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
logger.info("完成:复制 %d 张,共 %.1f MB -> %s", copied, total_bytes / 1024**2, out_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main())