Files
ModelTrainEval/scripts/cluster_unlabeled.py
T
Shaun ZhouandClaude Opus 5 f433e5cf59 feat: 未分类样本的无监督聚类(多 stage 特征 + KMeans,可选 SimCLR)
把 data/film 下所有图片拉平做聚类,不使用任何目录名,簇数由 num_clusters 决定。

预处理针对该批数据的实际情况(14.5k 张已裁好的缺陷小图,中位 152x113,
宽高比 0.24~39.9):尺寸与亮度都是 ROI 裁剪和光照的产物、不是缺陷属性,
因此拉伸到 128x128(不用 letterbox,其填充比例等价于编码了宽高比)并逐图 z-score。

- cluster: torch 实现的 kmeans++/Lloyd(含空簇处理与 n_init)、PCA、轮廓系数、
  NMI 稳定性、多 stage 特征提取;不引 scikit-learn
- 白化默认关闭:CNN 特征谱极陡(λ1/λ32≈165x),白化会把噪声方向抬到与信号同权,
  实测最大簇一度吃掉 96% 的样本
- simclr: NT-Xent 自监督预训练,用项目自己的卷积骨架,权重可直接初始化 DefectNet;
  属于训练,走 require_cuda() 守卫
- 产出 assignments.csv、summary.json、按簇的软链目录与代表样本拼图,供人工核对

全量 14,579 张:轮廓系数 0.326、换种子稳定性 0.964,CPU 约 100 秒。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:55:47 +08:00

297 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
"""把未分类的样本做无监督聚类,产出可人工核对的分组结果。
# 默认 4 类,ImageNet 预训练特征(不训练,CPU 可跑)
uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml
# 改簇数 / 换成在本地数据上自监督训练的特征(需要 Nvidia GPU)
uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml \
--set num_clusters=6 method=simclr
产出(work_dir 下):
assignments.csv 每张图的簇号、到质心距离、原目录
summary.json 簇大小、轮廓系数、簇 × 原目录交叉表
clusters/cluster_00/... 按簇建的软链目录,直接用看图软件翻
cluster_00_preview.png 每簇代表样本(离质心最近)的拼图
"""
from __future__ import annotations
import argparse
import csv
import json
import shutil
import sys
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import torch # noqa: E402
from datasets.build import build_dataset # noqa: E402
from datasets.unlabeled import unlabeled_collate # noqa: E402
from engine.cluster import ( # noqa: E402
StageFeatureExtractor,
cluster_features,
cluster_stability,
extract_features,
random_fit_index,
)
from models.registry import BACKBONES # noqa: E402
from utils import load_config, seed_everything, setup_logging # noqa: E402
from utils.device import NoNvidiaGpuError, require_cuda, resolve_device # noqa: E402
logger = None
def build_encoder(cfg, device, work_dir):
"""按 `method` 拿一个特征提取器。
pretrained: ImageNet 预训练卷积骨架,纯推理,CPU 也能跑。用顶层的 `backbone`。
simclr: 在本目录数据上自监督训练一个项目自己的卷积骨架——属于训练,先过 GPU 守卫。
用 `simclr.backbone`,与 pretrained 的骨架配置各自独立:两者的字段完全不同
(arch/pretrained vs widths/depths),共用一个块会在 `_base_` 合并时串味。
"""
method = str(cfg.get("method", "pretrained"))
if method == "pretrained":
backbone = BACKBONES.build(dict(cfg.get("backbone", {"name": "torchvision"})))
return StageFeatureExtractor(backbone, cfg.get("feature_stages")).to(device).eval()
if method != "simclr":
raise ValueError(f"未知 method: {method},可选 pretrained / simclr")
require_cuda() # 自监督预训练是训练,禁止在无 Nvidia GPU 的设备上跑
from torch.utils.data import DataLoader
from engine.simclr import SimCLRTrainer
simclr_cfg = dict(cfg.get("simclr", {}))
backbone_cfg = dict(simclr_cfg.pop("backbone", {"name": "tiny_conv"}))
# 自研骨架不是 nn.Sequential,默认取最终输出而不是中间 stage
stages = simclr_cfg.pop("feature_stages", None)
backbone = BACKBONES.build(backbone_cfg).to(device)
checkpoint = simclr_cfg.pop("checkpoint", None)
if checkpoint and Path(checkpoint).exists():
state = torch.load(checkpoint, map_location=device, weights_only=False)
backbone.load_state_dict(state["model"])
logger.info("复用已有的自监督骨架: %s", checkpoint)
return StageFeatureExtractor(backbone, stages).to(device).eval()
view_dataset = build_dataset(
{**dict(cfg["dataset"]), "views": 2, "view_transforms": cfg.get("view_transforms", [])}
)
loader = DataLoader(
view_dataset,
batch_size=int(simclr_cfg.pop("batch_size", 128)),
shuffle=True,
drop_last=True,
num_workers=int(cfg.get("loader", {}).get("num_workers", 4)),
collate_fn=unlabeled_collate,
)
SimCLRTrainer(backbone, loader, simclr_cfg, device, work_dir).fit()
return StageFeatureExtractor(backbone, stages).to(device).eval()
def write_outputs(cfg, work_dir, metas, result, num_clusters, stability):
"""CSV + JSON + 软链目录 + 预览拼图。人工只需要翻软链目录就能确认分组是否合理。"""
labels = result.labels.tolist()
distances = result.distances.tolist()
with (work_dir / "assignments.csv").open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["path", "cluster", "distance_to_centroid", "source_dir", "width", "height"])
for meta, label, distance in zip(metas, labels, distances, strict=True):
width, height = meta["size"]
writer.writerow([meta["path"], label, f"{distance:.6f}", meta["source_dir"], width, height])
summary = {
"num_clusters": num_clusters,
"num_images": len(metas),
"method": cfg.get("method", "pretrained"),
"image_size": cfg.get("image_size"),
"cluster_sizes": result.sizes,
"silhouette": result.silhouette,
"stability": stability,
"inertia": result.inertia,
"mean_distance_per_cluster": [
float(result.distances[result.labels == index].mean()) if size else None
for index, size in enumerate(result.sizes)
],
}
(work_dir / "summary.json").write_text(
json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8"
)
link_limit = int(cfg.get("link_limit", 300))
preview_count = int(cfg.get("preview_count", 25))
clusters_dir = work_dir / "clusters"
if clusters_dir.exists():
shutil.rmtree(clusters_dir)
order = sorted(range(len(metas)), key=lambda i: distances[i]) # 离质心近的排前面
per_cluster: dict[int, list[int]] = defaultdict(list)
for index in order:
per_cluster[labels[index]].append(index)
for cluster, indices in sorted(per_cluster.items()):
target = clusters_dir / f"cluster_{cluster:02d}"
target.mkdir(parents=True, exist_ok=True)
# 近的取大头,末尾补几张最远的——离群样本最能暴露这个簇是不是混了东西
chosen = indices[: max(link_limit - 20, 1)] + indices[-20:] if len(indices) > link_limit else indices
for rank, index in enumerate(chosen):
source = Path(metas[index]["path"]).resolve()
link = target / f"{rank:04d}_{source.stem}{source.suffix}"
if not link.exists():
link.symlink_to(source)
save_preview(work_dir / f"cluster_{cluster:02d}_preview.png", metas, indices[:preview_count])
return summary
def save_preview(path: Path, metas: list[dict], indices: list[int], cell: int = 96, columns: int = 5):
"""把代表样本拼成一张图,方便一眼看出这个簇是什么缺陷。"""
if not indices:
return
from PIL import Image
rows = (len(indices) + columns - 1) // columns
canvas = Image.new("RGB", (columns * cell, rows * cell), (32, 32, 32))
for position, index in enumerate(indices):
with Image.open(metas[index]["path"]) as raw:
image = raw.convert("RGB")
scale = min(cell / image.width, cell / image.height)
image = image.resize((max(1, round(image.width * scale)), max(1, round(image.height * scale))))
x = (position % columns) * cell + (cell - image.width) // 2
y = (position // columns) * cell + (cell - image.height) // 2
canvas.paste(image, (x, y))
canvas.save(path)
def sweep_clusters(embeddings, spec: str, cluster_kwargs: dict) -> None:
"""扫一遍簇数,同时看轮廓系数和换种子的稳定性。
无标签时没有"正确答案",只能看:簇是否紧致(轮廓),以及换初始化后划分是否还一样
(稳定性)。稳定性低说明那个 k 是在硬切一团连续的数据。
"""
start, end = (int(v) for v in spec.split("-"))
logger.info("%3s %9s %10s %12s 簇大小", "k", "轮廓", "稳定性", "最大簇占比")
for k in range(start, end + 1):
result = cluster_features(embeddings, k, seed=42, **cluster_kwargs)
stability = cluster_stability(embeddings, k, seeds=(0, 7), **cluster_kwargs)
logger.info(
"%3d %9.3f %10.3f %12.2f %s",
k,
result.silhouette,
stability,
max(result.sizes) / sum(result.sizes),
result.sizes,
)
def main() -> int:
global logger
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--config", default="configs/cluster/film.yaml")
parser.add_argument("--root", default=None, help="覆盖配置里的图片根目录")
parser.add_argument("--num-clusters", type=int, default=None, help="覆盖配置里的簇数")
parser.add_argument("--work-dir", default=None)
parser.add_argument("--set", nargs="*", default=[], metavar="KEY=VALUE")
parser.add_argument("--save-features", action="store_true", help="额外保存 embedding 供复用")
parser.add_argument(
"--sweep-k", default=None, metavar="A-B", help="只扫簇数(如 2-8)打印指标表,不产出分组"
)
args = parser.parse_args()
cfg = load_config(args.config, args.set)
if args.root:
cfg["dataset"]["root"] = args.root
if args.num_clusters:
cfg["num_clusters"] = args.num_clusters
work_dir = Path(args.work_dir or cfg.get("work_dir", "runs/cluster"))
work_dir.mkdir(parents=True, exist_ok=True)
logger = setup_logging(work_dir / "cluster.log")
seed_everything(int(cfg.get("seed", 42)))
num_clusters = int(cfg.get("num_clusters", 4))
try:
device = resolve_device(str(cfg.get("device", "cuda")))
except NoNvidiaGpuError as exc:
logger.error("%s", exc)
return 1
dataset = build_dataset(dict(cfg["dataset"]))
logger.info(
"扫描到 %d 张图,来自 %d 个目录;簇数 %d,method=%s,device=%s",
len(dataset),
len({dataset.source_dir(path) for path in dataset.images}),
num_clusters,
cfg.get("method", "pretrained"),
device,
)
try:
encoder = build_encoder(cfg, device, work_dir)
except NoNvidiaGpuError as exc:
logger.error("%s", exc)
return 1
from torch.utils.data import DataLoader
loader = DataLoader(
dataset,
batch_size=int(cfg.get("loader", {}).get("batch_size", 64)),
shuffle=False,
num_workers=int(cfg.get("loader", {}).get("num_workers", 4)),
collate_fn=unlabeled_collate,
)
embeddings, metas = extract_features(
encoder, loader, device, use_shape_features=bool(cfg.get("use_shape_features", False))
)
logger.info("embedding: %s", tuple(embeddings.shape))
cluster_kwargs = {
"pca_dim": int(cfg.get("pca_dim", 32)),
"whiten": bool(cfg.get("whiten", False)),
"n_init": int(cfg.get("n_init", 10)),
}
fit_index = random_fit_index(
len(metas), int(cfg.get("fit_max_samples", 0)), seed=int(cfg.get("seed", 42))
)
if fit_index is not None:
logger.info("随机抽 %d 个样本拟合质心(拟合后仍分配全部样本)", len(fit_index))
if args.sweep_k:
sweep_clusters(embeddings, args.sweep_k, cluster_kwargs)
return 0
result = cluster_features(
embeddings, num_clusters, seed=int(cfg.get("seed", 42)), fit_index=fit_index, **cluster_kwargs
)
stability = cluster_stability(embeddings, num_clusters, seeds=(0, 7), **cluster_kwargs)
logger.info(
"簇大小: %s | 轮廓系数: %.4f | 换种子稳定性: %.4f",
result.sizes,
result.silhouette,
stability,
)
if args.save_features:
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)
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)
return 0
if __name__ == "__main__":
raise SystemExit(main())