Files
ModelTrainEval/scripts/export_clusters.py
T
Shaun ZhouandClaude Opus 5 6a8941c18a docs: 修正 clusters/ 是软链的说法
默认 link_mode 是 hard(scripts/cluster_unlabeled.py 与 configs/cluster/film.yaml
都是),README 里却有三处说成软链,与同一节上文"放的是硬链接"自相矛盾。

- 产出清单与导出小节改为硬链接。
- "导出的是真实文件(不是软链)"在硬链接下不成立——条目本身就是原图数据,
  复制的意义是脱离 runs/ 路径、去掉排序前缀、避免删改牵连原图。
- 跨机器那条注记原本只讲失效软链,补成 SourceResolver 实际处理的三种条目
  (硬链接走 inode 反查 assignments.csv / 有效软链 / 失效软链重定位)。
- scripts/export_clusters.py 的 docstring 是同一处错误,一并修正。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 16:04:01 +08:00

311 lines
13 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/` 默认是**硬链接**(`link_mode: hard`),方便人工翻看、调整——
可以把图在子目录之间挪动、删掉分错的、把 `cluster_03` 改名成 `grease`。这个脚本以整理后的
目录为准,把每张图**复制**成独立的副本:
```
data/orgnized_from_r101_k40/
├── grease/ ← 子目录名沿用 clusters/ 里的名字(人工整理过的类别名)
├── mosquito/
├── normal/
└── manifest.json # 每张图的原始路径与所属类别,便于追溯
```
**硬链接条目本身就是原图**,只是路径在 runs/ 下、文件名带着排序前缀。脚本按 inode
反查 assignments.csv 认出它对应哪张原图,好让文件名和 `--prefix-parent` 拿到真正的来源。
**软链失效也没关系**(`link_mode: sym` 时)。从服务器拷回来的 clusters/ 目录,软链指向的
是服务器路径(`/home/deep/ModelTrainEval/data/raw/...`),在本机全部失效。脚本会读出链接
目标,按其中的 `data/` 段重新落到本机 `--source-root` 上;万一还找不到,再用文件名在
源目录里兜底索引一次。
导出的文件名用**原图的名字**,不带 clusters/ 里的 `0125_` 排序前缀。
加 `--prefix-parent` 则再拼上原父目录名(`反面纵黑点/abc.png` -> `反面纵黑点_abc.png`),
便于导出后仍能看出图片来自哪个原始目录。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
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")
RANK_PREFIX = re.compile(r"^\d{4}_")
class SourceResolver:
"""把 clusters/ 里的条目解析成本机上的**原图**路径。
条目可能是三种东西,依次处理:
1. **硬链接**(link_mode: hard 的默认产物)——它就是原图本身,但路径在 runs/ 下、
文件名还带着 `0125_` 排序前缀。按 inode 反查 assignments.csv 里的原始路径,
这样导出的文件名和 `--prefix-parent` 才能拿到真正的来源目录;查不到就退而用
条目本身、并剥掉排序前缀。
2. **有效软链**——直接 resolve。
3. **失效软链**(从服务器拷回来的常态)——读出链接目标,按其中的 `data/` 段
重新落到本机 `source_root` 上;再不行用文件名查索引。
"""
def __init__(
self, source_root: Path, marker: str = "data", assignments: Path | None = None
) -> None:
self.source_root = source_root.resolve()
self.marker = marker
self._index: dict[str, Path] | None = None
self._by_inode = self._build_inode_index(assignments)
@staticmethod
def _build_inode_index(assignments: Path | None) -> dict[tuple[int, int], Path]:
"""assignments.csv 里列出的原图 -> inode,用来认出硬链接指向谁。"""
if not assignments or not assignments.exists():
return {}
index: dict[tuple[int, int], Path] = {}
with assignments.open(encoding="utf-8") as handle:
for row in csv.DictReader(handle):
path = Path(row.get("path", ""))
try:
stat = path.stat()
except OSError:
continue
index[(stat.st_dev, stat.st_ino)] = path.resolve()
return index
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():
stat = entry.stat()
original = self._by_inode.get((stat.st_dev, stat.st_ino))
if original is not None: # 硬链接:认出它指向的原图
return original
return self._lookup(RANK_PREFIX.sub("", entry.name)) or 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), assignments=(work_dir or clusters_dir.parent) / "assignments.csv"
)
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())