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
+13
View File
@@ -249,6 +249,19 @@ scatter:
翻图时注意 `clusters/` 里文件名前缀是排序号:`0000_` 最靠近质心(最典型),
末尾 20 张是离质心最远的离群样本——最能暴露这个簇是不是混了东西。
`clusters/` 里放的是**硬链接**(`link_mode: hard`,默认):不占额外磁盘,但表现得和真实
文件一样——看图软件能开、可以直接在 `runs/` 下把图挪来挪去、建子目录继续分子类,
整个目录拷到别的机器也不会断链。要求与源图同一文件系统,跨盘会自动退回软链并警告。
| `link_mode` | 说明 |
| --- | --- |
| `hard`(默认) | 硬链接。零额外磁盘,行为同真实文件,可跨机器拷贝 |
| `sym` | 软链接。跨盘可用,但记绝对路径,换机器就断(`export_clusters.py` 能重定位) |
| `copy` | 直接复制。跨盘可用且绝不断链,代价是占一份磁盘 |
> `hard` 与原图是**同一份数据**:在 `clusters/` 里改图会改到原图;只挪动、删除条目
> 不影响原图。
**尺寸不统一的处理**:这批图是已裁好的缺陷小图,中位 152×113,宽高比从 0.24 到 39.9。
尺寸和宽高比是上游 ROI 裁剪的产物、不是缺陷属性,所以预处理的目标是把它抹掉:
直接拉伸到 128×128(不用 letterbox——填充比例等价于把宽高比明着喂给网络),
+13 -1
View File
@@ -122,7 +122,19 @@ simclr:
optimizer: {name: adamw, lr: 0.001, weight_decay: 0.0001}
scheduler: {name: cosine}
# 输出:每簇软链多少张图 / 预览拼图放多少张
# 输出:每簇放多少张图 / 预览拼图放多少张
#
# link_mode 决定 clusters/ 里放什么:
# hard(默认)——硬链接。不占额外磁盘,但表现得和真实文件一样:看图软件、
# 在 runs/ 下直接继续分子类的工具都能用,整个目录拷到别的机器
# 也不会断链。要求与源图同一文件系统,跨盘会自动退回软链并警告。
# sym ——软链接。跨盘可用,但记的是绝对路径,换机器就断
# (export_clusters.py 能重定位回来)。
# copy ——直接复制。跨盘可用且绝不断链,代价是占一份磁盘。
#
# 注意 hard 与原图是同一份数据:在 clusters/ 里**改图会改到原图**;
# 只挪动、删除条目不影响原图。
link_mode: hard
link_limit: 300
preview_count: 100
+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():
+34
View File
@@ -115,6 +115,40 @@ def test_prefix_parent_resolves_cross_dataset_collisions(curated, tmp_path, monk
assert {"blue_7.png", "pearlescent_7.png"} <= names
def test_hard_links_are_traced_back_to_the_original(curated, tmp_path, monkeypatch):
"""硬链接条目本身就是原图,但路径在 runs/ 下、名字还带排序前缀。
应当按 inode 反查 assignments.csv 还原出原图路径,
这样文件名和 --prefix-parent 才能拿到真正的来源目录。"""
import csv as csv_mod
import os as os_mod
root, clusters, categories = curated
# 新建一张原图(不能复用已在该类别里的,否则和已有条目重名)
original = root / "data" / "raw" / "film" / "grease" / "extra.png"
Image.fromarray(np.full((12, 16, 3), 3, dtype=np.uint8)).save(original)
# 造一个硬链接条目 + 对应的 assignments.csv
hard = clusters / "grease" / f"0500_{original.name}"
os_mod.link(original, hard)
with (clusters.parent / "assignments.csv").open("w", newline="", encoding="utf-8") as handle:
writer = csv_mod.writer(handle)
writer.writerow(["path", "cluster"])
for sources in list(categories.values()) + [[original]]:
for source in sources:
writer.writerow([str(source), 0])
out = tmp_path / "out"
assert run(["--work-dir", str(clusters.parent), "--out", str(out),
"--source-root", str(root), "--prefix-parent"], monkeypatch) == 0
manifest = json.loads((out / "manifest.json").read_text("utf-8"))
entries = {e["file"]: e["source"] for e in manifest["files"]["grease"]}
expected = f"{original.parent.name}_{original.name}"
assert expected in entries, f"应还原成 {expected},实际有 {sorted(entries)}"
assert entries[expected] == str(original.resolve()) # 指向原图而不是 runs/ 下的条目
assert not any(name[:4].isdigit() and name[4] == "_" for name in entries)
def test_real_files_in_clusters_are_used_directly(curated, tmp_path, monkeypatch):
"""有人直接把真实图片拷进 clusters/ 时也要能用。"""
root, clusters, _ = curated
+62
View File
@@ -657,6 +657,68 @@ def test_train_only_conflicts_with_sweep_k(tiny_image_dir, tmp_path, monkeypatch
assert cu.main() == 1
def test_clusters_use_hard_links_by_default(tiny_image_dir, tmp_path, monkeypatch):
"""clusters/ 里默认放硬链接:表现得像真实文件,拷到别的机器也不会断链。"""
sys.path.insert(0, "scripts")
import cluster_unlabeled as cu
work_dir = tmp_path / "run"
monkeypatch.setattr(sys, "argv", _cluster_argv(work_dir, tiny_image_dir))
assert cu.main() == 0
entries = [p for p in (work_dir / "clusters").rglob("*") if p.is_file() or p.is_symlink()]
assert entries
assert not any(p.is_symlink() for p in entries), "默认不该是软链"
assert all(p.stat().st_nlink > 1 for p in entries), "应当与原图共享 inode"
def test_link_mode_sym_and_copy(tiny_image_dir, tmp_path, monkeypatch):
sys.path.insert(0, "scripts")
import cluster_unlabeled as cu
for mode, expect_symlink in (("sym", True), ("copy", False)):
work_dir = tmp_path / f"run_{mode}"
monkeypatch.setattr(
sys, "argv", _cluster_argv(work_dir, tiny_image_dir, f"link_mode={mode}")
)
assert cu.main() == 0
entries = [p for p in (work_dir / "clusters").rglob("*") if p.is_file() or p.is_symlink()]
assert all(p.is_symlink() == expect_symlink for p in entries), mode
if mode == "copy":
assert all(p.stat().st_nlink == 1 for p in entries), "复制出来的不该共享 inode"
def test_unknown_link_mode_is_rejected(tiny_image_dir, tmp_path, monkeypatch):
sys.path.insert(0, "scripts")
import cluster_unlabeled as cu
monkeypatch.setattr(
sys, "argv", _cluster_argv(tmp_path / "bad", tiny_image_dir, "link_mode=hardlink")
)
with pytest.raises(ValueError, match="未知 link_mode"):
cu.main()
def test_make_link_falls_back_to_symlink_across_filesystems(tmp_path, monkeypatch):
"""跨文件系统时硬链接会失败(EXDEV),应当退回软链而不是崩掉。"""
import errno
import os as os_mod
sys.path.insert(0, "scripts")
import cluster_unlabeled as cu
source = tmp_path / "src.png"
source.write_bytes(b"data")
def fake_link(*_args, **_kwargs):
raise OSError(errno.EXDEV, "Cross-device link")
monkeypatch.setattr(os_mod, "link", fake_link)
destination = tmp_path / "dst.png"
assert cu.make_link(source, destination, "hard") == "sym"
assert destination.is_symlink()
def test_scatter_plot_is_written_next_to_previews(tiny_image_dir, tmp_path, monkeypatch):
"""cluster_scatter.png 要和 preview 拼图落在同一级目录。"""
sys.path.insert(0, "scripts")