feat(cluster): clusters/ 默认改用硬链接,便于直接在 runs/ 下继续分子类

软链接记的是绝对路径,从服务器拷到本机后全部失效,也不便于直接在 runs/ 下整理。
硬链接不占额外磁盘却表现得和真实文件一样:看图软件能开、可以随意挪动、建子目录
继续分子类,整个目录拷到别的机器也不会断链。

- 新增 link_mode: hard(默认)/ sym / copy;跨文件系统时 hard 自动退回软链并警告
- 落盘后打印各方式的条目数,便于确认实际用的是哪种
- export_clusters.py 适配硬链接:条目本身就是原图,但路径在 runs/ 下、名字还带
  0125_ 排序前缀。按 inode 反查 assignments.csv 还原原图路径,这样导出的文件名与
  --prefix-parent 才能拿到真正的来源目录;查不到则剥掉前缀后用文件名索引兜底

实测:14,579 张跑聚类后 clusters/ 全为硬链接(nlink=2、可直接打开);
把簇改名、建子目录挪图后再导出,排序前缀已剥除、来源目录正确还原为「反面纵黑点」,
120 张内容与原图逐字节一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-10 12:04:08 +08:00
co-authored by Claude Opus 5
parent f8654f447b
commit 33d28dcc7c
6 changed files with 219 additions and 14 deletions
+46 -10
View File
@@ -20,6 +20,9 @@ data/orgnized_from_r101_k40/
按其中的 `data/` 段重新落到本机 `--source-root` 上;万一还找不到,再用文件名在
源目录里兜底索引一次。
clusters/ 里若是**硬链接**(`link_mode: hard`,默认),条目本身就是原图;脚本按 inode
反查 assignments.csv 认出它对应哪张原图,好让文件名和 `--prefix-parent` 拿到真正的来源。
导出的文件名用**原图的名字**,不带 clusters/ 里的 `0125_` 排序前缀。
加 `--prefix-parent` 则再拼上原父目录名(`反面纵黑点/abc.png` -> `反面纵黑点_abc.png`),
便于导出后仍能看出图片来自哪个原始目录。
@@ -28,8 +31,10 @@ data/orgnized_from_r101_k40/
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import shutil
import sys
from collections import Counter, defaultdict
@@ -44,20 +49,45 @@ logger = None
IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp")
class SourceResolver:
"""把 clusters/ 里的条目解析成本机上的真实图片路径。
RANK_PREFIX = re.compile(r"^\d{4}_")
三种情况依次尝试:
1. 条目本身就是真实文件(有人直接把图拷进去了);
2. 软链有效,直接 resolve;
3. 软链失效——读出链接目标,按 `data/` 段重新落到本机 `source_root` 上。
再不行就用链接目标的文件名在源目录里查索引(索引惰性构建,用不到就不扫盘)。
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") -> None:
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/ 下。"""
@@ -85,7 +115,11 @@ class SourceResolver:
return entry.resolve()
return self._rebase(target) or self._lookup(target.name)
if entry.is_file():
return entry.resolve()
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
@@ -180,7 +214,9 @@ def main() -> int:
logger.info("来源: %s(%d 张,%d 个类别)", clusters_dir, total, len(groups))
logger.info("输出目录: %s", out_dir)
resolver = SourceResolver(Path(args.source_root))
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():