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
+51 -3
View File
@@ -20,10 +20,12 @@ from __future__ import annotations
import argparse
import csv
import errno
import json
import os
import shutil
import sys
from collections import defaultdict
from collections import Counter, defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
@@ -159,10 +161,14 @@ def write_outputs(cfg, work_dir, metas, result, num_clusters, stability):
link_limit = int(cfg.get("link_limit", 300))
preview_count = int(cfg.get("preview_count", 25))
link_mode = str(cfg.get("link_mode", "hard")).lower()
if link_mode not in LINK_MODES:
raise ValueError(f"未知 link_mode: {link_mode},可选 {LINK_MODES}")
clusters_dir = work_dir / "clusters"
if clusters_dir.exists():
shutil.rmtree(clusters_dir)
used_modes: Counter[str] = Counter()
order = sorted(range(len(metas)), key=lambda i: distances[i]) # 离质心近的排前面
per_cluster: dict[int, list[int]] = defaultdict(list)
for index in order:
@@ -177,9 +183,19 @@ def write_outputs(cfg, work_dir, metas, result, num_clusters, stability):
source = Path(metas[index]["path"]).resolve()
link = target / f"{rank:04d}_{source.stem}{source.suffix}"
if not link.exists():
link.symlink_to(source)
used_modes[make_link(source, link, link_mode)] += 1
save_preview(work_dir / f"cluster_{cluster:02d}_preview.png", metas, indices[:preview_count])
if used_modes:
detail = "、".join(f"{mode} {count}" for mode, count in sorted(used_modes.items()))
logger.info("clusters/ 下的条目:%s", detail)
if link_mode == "hard" and used_modes.get("sym"):
logger.warning(
"有 %d 个条目跨文件系统、退回了软链——这些在别的机器上会断链,"
"可改用 link_mode: copy,或让 runs/ 与数据同盘",
used_modes["sym"],
)
if cfg.get("scatter", {}).get("enabled", True) and result.points is not None:
scatter_path = work_dir / "cluster_scatter.png"
save_scatter(scatter_path, result, cfg)
@@ -380,6 +396,38 @@ def warn_misplaced_keys(cfg, method: str) -> None:
logger.info("当前 method=%s,`simclr:` 段下的配置不会生效", method)
LINK_MODES = ("hard", "sym", "copy")
def make_link(source: Path, destination: Path, mode: str) -> str:
"""在 clusters/ 里放一份指向原图的条目,返回实际用的方式。
默认硬链接:不占额外磁盘,但表现得和真实文件一模一样——看图软件、文件管理器、
以及在 runs/ 下直接继续分子类的工具都能正常处理,把整个目录拷到别的机器上
也不会变成断链(软链会,因为它记的是绝对路径)。
代价是硬链接必须与源文件同一个文件系统;跨盘时自动降级并说明原因。
另外硬链接和原图是同一份数据,**在 clusters/ 里改图会改到原图**,只挪动、
删除条目则不影响原图。
"""
if mode == "sym":
destination.symlink_to(source)
return "sym"
if mode == "copy":
shutil.copy2(source, destination)
return "copy"
try:
os.link(source, destination)
return "hard"
except OSError as exc:
if exc.errno != errno.EXDEV:
raise
# 跨文件系统只能退回软链;这时拷到别的机器上会断链,用 export_clusters.py 重定位
destination.symlink_to(source)
return "sym"
def sweep_clusters(embeddings, spec: str, cluster_kwargs: dict) -> None:
"""扫一遍簇数,同时看轮廓系数和换种子的稳定性。
@@ -527,7 +575,7 @@ def main() -> int:
torch.save({"embeddings": embeddings, "metas": metas}, work_dir / "features.pt")
write_outputs(cfg, work_dir, metas, result, num_clusters, stability)
logger.info("结果已写入 %s(clusters/ 下是按簇建的软链目录,直接翻图即可核对)", work_dir)
logger.info("结果已写入 %s(clusters/ 下按簇分好,直接翻图即可核对与调整)", work_dir)
for index, size in enumerate(result.sizes):
mean_distance = float(result.distances[result.labels == index].mean()) if size else float("nan")
logger.info(" cluster_%02d %6d 张 平均距质心 %.3f", index, size, mean_distance)
+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():