默认 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>
311 lines
13 KiB
Python
Executable File
311 lines
13 KiB
Python
Executable File
#!/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())
|