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:
co-authored by
Claude Opus 5
parent
f8654f447b
commit
33d28dcc7c
@@ -249,6 +249,19 @@ scatter:
|
|||||||
翻图时注意 `clusters/` 里文件名前缀是排序号:`0000_` 最靠近质心(最典型),
|
翻图时注意 `clusters/` 里文件名前缀是排序号:`0000_` 最靠近质心(最典型),
|
||||||
末尾 20 张是离质心最远的离群样本——最能暴露这个簇是不是混了东西。
|
末尾 20 张是离质心最远的离群样本——最能暴露这个簇是不是混了东西。
|
||||||
|
|
||||||
|
`clusters/` 里放的是**硬链接**(`link_mode: hard`,默认):不占额外磁盘,但表现得和真实
|
||||||
|
文件一样——看图软件能开、可以直接在 `runs/` 下把图挪来挪去、建子目录继续分子类,
|
||||||
|
整个目录拷到别的机器也不会断链。要求与源图同一文件系统,跨盘会自动退回软链并警告。
|
||||||
|
|
||||||
|
| `link_mode` | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `hard`(默认) | 硬链接。零额外磁盘,行为同真实文件,可跨机器拷贝 |
|
||||||
|
| `sym` | 软链接。跨盘可用,但记绝对路径,换机器就断(`export_clusters.py` 能重定位) |
|
||||||
|
| `copy` | 直接复制。跨盘可用且绝不断链,代价是占一份磁盘 |
|
||||||
|
|
||||||
|
> `hard` 与原图是**同一份数据**:在 `clusters/` 里改图会改到原图;只挪动、删除条目
|
||||||
|
> 不影响原图。
|
||||||
|
|
||||||
**尺寸不统一的处理**:这批图是已裁好的缺陷小图,中位 152×113,宽高比从 0.24 到 39.9。
|
**尺寸不统一的处理**:这批图是已裁好的缺陷小图,中位 152×113,宽高比从 0.24 到 39.9。
|
||||||
尺寸和宽高比是上游 ROI 裁剪的产物、不是缺陷属性,所以预处理的目标是把它抹掉:
|
尺寸和宽高比是上游 ROI 裁剪的产物、不是缺陷属性,所以预处理的目标是把它抹掉:
|
||||||
直接拉伸到 128×128(不用 letterbox——填充比例等价于把宽高比明着喂给网络),
|
直接拉伸到 128×128(不用 letterbox——填充比例等价于把宽高比明着喂给网络),
|
||||||
|
|||||||
@@ -122,7 +122,19 @@ simclr:
|
|||||||
optimizer: {name: adamw, lr: 0.001, weight_decay: 0.0001}
|
optimizer: {name: adamw, lr: 0.001, weight_decay: 0.0001}
|
||||||
scheduler: {name: cosine}
|
scheduler: {name: cosine}
|
||||||
|
|
||||||
# 输出:每簇软链多少张图 / 预览拼图放多少张
|
# 输出:每簇放多少张图 / 预览拼图放多少张
|
||||||
|
#
|
||||||
|
# link_mode 决定 clusters/ 里放什么:
|
||||||
|
# hard(默认)——硬链接。不占额外磁盘,但表现得和真实文件一样:看图软件、
|
||||||
|
# 在 runs/ 下直接继续分子类的工具都能用,整个目录拷到别的机器
|
||||||
|
# 也不会断链。要求与源图同一文件系统,跨盘会自动退回软链并警告。
|
||||||
|
# sym ——软链接。跨盘可用,但记的是绝对路径,换机器就断
|
||||||
|
# (export_clusters.py 能重定位回来)。
|
||||||
|
# copy ——直接复制。跨盘可用且绝不断链,代价是占一份磁盘。
|
||||||
|
#
|
||||||
|
# 注意 hard 与原图是同一份数据:在 clusters/ 里**改图会改到原图**;
|
||||||
|
# 只挪动、删除条目不影响原图。
|
||||||
|
link_mode: hard
|
||||||
link_limit: 300
|
link_limit: 300
|
||||||
preview_count: 100
|
preview_count: 100
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import csv
|
import csv
|
||||||
|
import errno
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from collections import defaultdict
|
from collections import Counter, defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
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))
|
link_limit = int(cfg.get("link_limit", 300))
|
||||||
preview_count = int(cfg.get("preview_count", 25))
|
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"
|
clusters_dir = work_dir / "clusters"
|
||||||
if clusters_dir.exists():
|
if clusters_dir.exists():
|
||||||
shutil.rmtree(clusters_dir)
|
shutil.rmtree(clusters_dir)
|
||||||
|
|
||||||
|
used_modes: Counter[str] = Counter()
|
||||||
order = sorted(range(len(metas)), key=lambda i: distances[i]) # 离质心近的排前面
|
order = sorted(range(len(metas)), key=lambda i: distances[i]) # 离质心近的排前面
|
||||||
per_cluster: dict[int, list[int]] = defaultdict(list)
|
per_cluster: dict[int, list[int]] = defaultdict(list)
|
||||||
for index in order:
|
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()
|
source = Path(metas[index]["path"]).resolve()
|
||||||
link = target / f"{rank:04d}_{source.stem}{source.suffix}"
|
link = target / f"{rank:04d}_{source.stem}{source.suffix}"
|
||||||
if not link.exists():
|
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])
|
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:
|
if cfg.get("scatter", {}).get("enabled", True) and result.points is not None:
|
||||||
scatter_path = work_dir / "cluster_scatter.png"
|
scatter_path = work_dir / "cluster_scatter.png"
|
||||||
save_scatter(scatter_path, result, cfg)
|
save_scatter(scatter_path, result, cfg)
|
||||||
@@ -380,6 +396,38 @@ def warn_misplaced_keys(cfg, method: str) -> None:
|
|||||||
logger.info("当前 method=%s,`simclr:` 段下的配置不会生效", method)
|
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:
|
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")
|
torch.save({"embeddings": embeddings, "metas": metas}, work_dir / "features.pt")
|
||||||
|
|
||||||
write_outputs(cfg, work_dir, metas, result, num_clusters, stability)
|
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):
|
for index, size in enumerate(result.sizes):
|
||||||
mean_distance = float(result.distances[result.labels == index].mean()) if size else float("nan")
|
mean_distance = float(result.distances[result.labels == index].mean()) if size else float("nan")
|
||||||
logger.info(" cluster_%02d %6d 张 平均距质心 %.3f", index, size, mean_distance)
|
logger.info(" cluster_%02d %6d 张 平均距质心 %.3f", index, size, mean_distance)
|
||||||
|
|||||||
+46
-10
@@ -20,6 +20,9 @@ data/orgnized_from_r101_k40/
|
|||||||
按其中的 `data/` 段重新落到本机 `--source-root` 上;万一还找不到,再用文件名在
|
按其中的 `data/` 段重新落到本机 `--source-root` 上;万一还找不到,再用文件名在
|
||||||
源目录里兜底索引一次。
|
源目录里兜底索引一次。
|
||||||
|
|
||||||
|
clusters/ 里若是**硬链接**(`link_mode: hard`,默认),条目本身就是原图;脚本按 inode
|
||||||
|
反查 assignments.csv 认出它对应哪张原图,好让文件名和 `--prefix-parent` 拿到真正的来源。
|
||||||
|
|
||||||
导出的文件名用**原图的名字**,不带 clusters/ 里的 `0125_` 排序前缀。
|
导出的文件名用**原图的名字**,不带 clusters/ 里的 `0125_` 排序前缀。
|
||||||
加 `--prefix-parent` 则再拼上原父目录名(`反面纵黑点/abc.png` -> `反面纵黑点_abc.png`),
|
加 `--prefix-parent` 则再拼上原父目录名(`反面纵黑点/abc.png` -> `反面纵黑点_abc.png`),
|
||||||
便于导出后仍能看出图片来自哪个原始目录。
|
便于导出后仍能看出图片来自哪个原始目录。
|
||||||
@@ -28,8 +31,10 @@ data/orgnized_from_r101_k40/
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import csv
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
@@ -44,20 +49,45 @@ logger = None
|
|||||||
IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp")
|
IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp")
|
||||||
|
|
||||||
|
|
||||||
class SourceResolver:
|
RANK_PREFIX = re.compile(r"^\d{4}_")
|
||||||
"""把 clusters/ 里的条目解析成本机上的真实图片路径。
|
|
||||||
|
|
||||||
三种情况依次尝试:
|
|
||||||
1. 条目本身就是真实文件(有人直接把图拷进去了);
|
class SourceResolver:
|
||||||
2. 软链有效,直接 resolve;
|
"""把 clusters/ 里的条目解析成本机上的**原图**路径。
|
||||||
3. 软链失效——读出链接目标,按 `data/` 段重新落到本机 `source_root` 上。
|
|
||||||
再不行就用链接目标的文件名在源目录里查索引(索引惰性构建,用不到就不扫盘)。
|
条目可能是三种东西,依次处理:
|
||||||
|
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.source_root = source_root.resolve()
|
||||||
self.marker = marker
|
self.marker = marker
|
||||||
self._index: dict[str, Path] | None = None
|
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:
|
def _rebase(self, target: Path) -> Path | None:
|
||||||
"""把 /home/deep/ModelTrainEval/data/raw/film/x.png 落到本机同名的 data/ 下。"""
|
"""把 /home/deep/ModelTrainEval/data/raw/film/x.png 落到本机同名的 data/ 下。"""
|
||||||
@@ -85,7 +115,11 @@ class SourceResolver:
|
|||||||
return entry.resolve()
|
return entry.resolve()
|
||||||
return self._rebase(target) or self._lookup(target.name)
|
return self._rebase(target) or self._lookup(target.name)
|
||||||
if entry.is_file():
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -180,7 +214,9 @@ def main() -> int:
|
|||||||
logger.info("来源: %s(%d 张,%d 个类别)", clusters_dir, total, len(groups))
|
logger.info("来源: %s(%d 张,%d 个类别)", clusters_dir, total, len(groups))
|
||||||
logger.info("输出目录: %s", out_dir)
|
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)
|
resolved: dict[str, list[Path]] = defaultdict(list)
|
||||||
unresolved: list[Path] = []
|
unresolved: list[Path] = []
|
||||||
for category, entries in groups.items():
|
for category, entries in groups.items():
|
||||||
|
|||||||
@@ -115,6 +115,40 @@ def test_prefix_parent_resolves_cross_dataset_collisions(curated, tmp_path, monk
|
|||||||
assert {"blue_7.png", "pearlescent_7.png"} <= names
|
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):
|
def test_real_files_in_clusters_are_used_directly(curated, tmp_path, monkeypatch):
|
||||||
"""有人直接把真实图片拷进 clusters/ 时也要能用。"""
|
"""有人直接把真实图片拷进 clusters/ 时也要能用。"""
|
||||||
root, clusters, _ = curated
|
root, clusters, _ = curated
|
||||||
|
|||||||
@@ -657,6 +657,68 @@ def test_train_only_conflicts_with_sweep_k(tiny_image_dir, tmp_path, monkeypatch
|
|||||||
assert cu.main() == 1
|
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):
|
def test_scatter_plot_is_written_next_to_previews(tiny_image_dir, tmp_path, monkeypatch):
|
||||||
"""cluster_scatter.png 要和 preview 拼图落在同一级目录。"""
|
"""cluster_scatter.png 要和 preview 拼图落在同一级目录。"""
|
||||||
sys.path.insert(0, "scripts")
|
sys.path.insert(0, "scripts")
|
||||||
|
|||||||
Reference in New Issue
Block a user