refactor: 按实际的五步工作流裁掉无关代码

实际工作流是:simclr 训练 -> 聚类 -> 人工归类 -> 分类训练 -> GradCAM/SHAP 生成
热力图与 box。据此移除不参与其中的检测/定位链路,共 31 个文件、约 2600 行:

- 模型:defect_net、postprocess、necks/、heads/center_head
- 数据:film_defect(YOLO 读取端)、target_encoder
- 引擎:trainer、evaluator、losses
- 入口:train.py、eval.py
- 脚本:convert_annotations、split_dataset、export_onnx、profile_inference
- 配置:configs/{train,eval,model,dataset}
- utils/labels(多类/单类标签模式,仅检测分类头使用)

共享模块按实际用量瘦身:engine/metrics 只留 AverageMeter(检测指标随之移除);
datasets/yolo 只留写出侧(IMAGE_SUFFIXES 与 write_data_yaml)——第 5 步产出的
YOLO 标签是给外部检测训练用的交换格式,读取端不再需要。

裁剪过程中修掉一个真 bug:generate_boxes 的 --stage 默认 6(layer3)对 tiny_conv
这类非 nn.Sequential 骨架直接抛异常。现在自动退回骨架最终输出并警告,
另加 --stage -1 显式选择最终输出。

五步工作流已端到端实跑验证(simclr 1 轮 -> 聚类 -> 导出 -> 分类 1 轮 ->
gradcam 与 shap 两种方法各生成一遍)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-11 10:27:20 +08:00
co-authored by Claude Opus 5
parent 57446c2f10
commit 86c86b105c
45 changed files with 186 additions and 3218 deletions
+4 -9
View File
@@ -1,22 +1,17 @@
"""模型骨架:backbone -> neck -> heads。
"""模型骨架:backbone + head。
约束:部署在实时设备上,只使用基于 convolution 的结构(不含 attention / transformer)。
按项目约定只用 convolution,不含 attention / transformer。
"""
from models import backbones, heads, necks # noqa: F401 触发注册
from models import backbones, heads # noqa: F401 触发注册
from models.build import build_model
from models.classifier import DefectClassifier
from models.defect_net import DefectNet
from models.postprocess import decode_heatmap
from models.registry import BACKBONES, HEADS, MODELS, NECKS
from models.registry import BACKBONES, HEADS, MODELS
__all__ = [
"build_model",
"DefectNet",
"DefectClassifier",
"decode_heatmap",
"BACKBONES",
"NECKS",
"HEADS",
"MODELS",
]
-83
View File
@@ -1,83 +0,0 @@
"""缺陷分类 + 定位主模型。"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
import torch
from torch import nn
from models.heads.center_head import CenterHead
from models.heads.classification import ClassificationHead
from models.registry import BACKBONES, MODELS, NECKS
from utils.labels import LABEL_MODES, MULTILABEL, check_label_mode
__all__ = ["DefectNet", "classification_outputs", "LABEL_MODES"]
def classification_outputs(num_classes: int, label_mode: str) -> int:
"""分类头的输出维度;multiclass 需要额外一维表示 "无缺陷"。"""
check_label_mode(label_mode)
return num_classes if label_mode == MULTILABEL else num_classes + 1
@MODELS.register("defect_net")
class DefectNet(nn.Module):
"""backbone -> neck -> {分类头, 中心点定位头}。
Args:
num_classes: 缺陷类别数(不含背景)。
backbone: backbone 注册表的配置,如 `{"name": "tiny_conv", ...}`。
neck: neck 配置,`None` 表示直接在骨架特征上出图。
head: CenterHead 的额外参数。
with_classification: 是否输出图像级分类 logits。
label_mode: 图像级标签形式,由 yaml 配置决定。
"multilabel" —— 多类:一张图可同时含多种缺陷,逐类 sigmoid + BCE;
"multiclass" —— 单类:一张图只归一个类别(含 "无缺陷"),softmax + CE。
仅影响分类头与分类损失;定位头始终按 num_classes 个类别输出 heatmap。
"""
def __init__(
self,
num_classes: int,
backbone: Mapping[str, Any] | None = None,
neck: Mapping[str, Any] | None = None,
head: Mapping[str, Any] | None = None,
with_classification: bool = True,
classification: Mapping[str, Any] | None = None,
label_mode: str = "multilabel",
) -> None:
super().__init__()
self.num_classes = num_classes
self.label_mode = check_label_mode(label_mode)
self.backbone = BACKBONES.build(dict(backbone or {"name": "tiny_conv"}))
stride = self.backbone.out_stride
channels = self.backbone.out_channels
if neck:
self.neck: nn.Module = NECKS.build(dict(neck), in_channels=channels)
channels = self.neck.out_channels
stride = stride // self.neck.scale_factor
else:
self.neck = nn.Identity()
self.out_stride = stride
self.center_head = CenterHead(channels, num_classes, **dict(head or {}))
self.cls_head = (
ClassificationHead(
channels,
classification_outputs(num_classes, label_mode),
**dict(classification or {}),
)
if with_classification
else None
)
def forward(self, images: torch.Tensor) -> dict[str, torch.Tensor]:
feat = self.neck(self.backbone(images))
outputs = self.center_head(feat)
if self.cls_head is not None:
outputs["logits"] = self.cls_head(feat)
return outputs
+2 -2
View File
@@ -1,4 +1,4 @@
from models.heads.center_head import CenterHead
from models.heads.classification import ClassificationHead
from models.heads.projection import ProjectionHead
__all__ = ["CenterHead", "ClassificationHead"]
__all__ = ["ClassificationHead", "ProjectionHead"]
-51
View File
@@ -1,51 +0,0 @@
"""Anchor-free 中心点定位头(CenterNet 风格,纯卷积)。
输出:
heatmap: (B, num_classes, H, W) 各类别缺陷中心的置信度(logits)
wh: (B, 2, H, W) 中心点处的框宽高(特征图尺度)
offset: (B, 2, H, W) 下采样量化误差补偿
"""
from __future__ import annotations
import math
import torch
from torch import nn
from models.layers import ConvBNAct
from models.registry import HEADS
def _branch(in_channels: int, hidden: int, out_channels: int) -> nn.Sequential:
return nn.Sequential(
ConvBNAct(in_channels, hidden, kernel_size=3),
nn.Conv2d(hidden, out_channels, kernel_size=1),
)
@HEADS.register("center")
class CenterHead(nn.Module):
def __init__(
self,
in_channels: int,
num_classes: int,
hidden_channels: int = 64,
prior_prob: float = 0.01,
) -> None:
super().__init__()
self.num_classes = num_classes
self.heatmap = _branch(in_channels, hidden_channels, num_classes)
self.wh = _branch(in_channels, hidden_channels, 2)
self.offset = _branch(in_channels, hidden_channels, 2)
# heatmap 正样本极稀疏,用先验概率初始化最后一层 bias,稳定早期训练。
bias = -math.log((1 - prior_prob) / prior_prob)
nn.init.constant_(self.heatmap[-1].bias, bias)
def forward(self, feat: torch.Tensor) -> dict[str, torch.Tensor]:
return {
"heatmap": self.heatmap(feat),
"wh": self.wh(feat),
"offset": self.offset(feat),
}
-3
View File
@@ -1,3 +0,0 @@
from models.necks.upsample import UpsampleNeck
__all__ = ["UpsampleNeck"]
-33
View File
@@ -1,33 +0,0 @@
"""上采样 neck:把骨架的低分辨率特征恢复到定位所需的 stride。"""
from __future__ import annotations
import torch
from torch import nn
from torch.nn import functional as F
from models.layers import ConvBNAct
from models.registry import NECKS
@NECKS.register("upsample")
class UpsampleNeck(nn.Module):
"""`num_upsamples` 次 2 倍上采样,每次接一个 3x3 卷积平滑。
使用 nearest + conv 而非转置卷积:避免棋盘伪影,且在推理引擎上更快。
"""
def __init__(self, in_channels: int, channels: int = 64, num_upsamples: int = 2) -> None:
super().__init__()
self.lateral = ConvBNAct(in_channels, channels, kernel_size=1)
self.blocks = nn.ModuleList(
[ConvBNAct(channels, channels, kernel_size=3) for _ in range(num_upsamples)]
)
self.out_channels = channels
self.scale_factor = 2**num_upsamples
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = self.lateral(x)
for block in self.blocks:
x = block(F.interpolate(x, scale_factor=2.0, mode="nearest"))
return x
-68
View File
@@ -1,68 +0,0 @@
"""推理后处理:从 heatmap 解码出缺陷框。"""
from __future__ import annotations
import torch
from torch.nn import functional as F
def heatmap_nms(heatmap: torch.Tensor, kernel: int = 3) -> torch.Tensor:
"""用 max-pool 做峰值抑制,替代 NMS(无循环,可导出)。"""
pooled = F.max_pool2d(heatmap, kernel, stride=1, padding=kernel // 2)
return heatmap * (pooled == heatmap).float()
@torch.no_grad()
def decode_heatmap(
heatmap: torch.Tensor,
wh: torch.Tensor,
offset: torch.Tensor,
stride: int,
topk: int = 50,
score_threshold: float = 0.3,
) -> list[dict[str, torch.Tensor]]:
"""解码为原图坐标下的检测结果。
Args:
heatmap: (B, C, H, W) logits。
wh / offset: (B, 2, H, W),单位为特征图像素。
stride: 特征图相对原图的下采样倍数。
Returns:
每张图一个 dict:`boxes` (N, 4) xyxy、`scores` (N,)、`labels` (N,)。
"""
scores_map = heatmap_nms(heatmap.sigmoid())
batch, _num_classes, height, width = scores_map.shape
flat = scores_map.view(batch, -1)
topk = min(topk, flat.shape[1])
topk_scores, topk_idx = flat.topk(topk, dim=1)
labels = torch.div(topk_idx, height * width, rounding_mode="floor")
pixel_idx = topk_idx % (height * width)
ys = torch.div(pixel_idx, width, rounding_mode="floor").float()
xs = (pixel_idx % width).float()
wh_flat = wh.view(batch, 2, -1)
offset_flat = offset.view(batch, 2, -1)
gather_idx = pixel_idx.unsqueeze(1).expand(-1, 2, -1)
pred_wh = wh_flat.gather(2, gather_idx)
pred_offset = offset_flat.gather(2, gather_idx)
cx = (xs + pred_offset[:, 0]) * stride
cy = (ys + pred_offset[:, 1]) * stride
half_w = pred_wh[:, 0].clamp(min=0) * stride / 2
half_h = pred_wh[:, 1].clamp(min=0) * stride / 2
boxes = torch.stack([cx - half_w, cy - half_h, cx + half_w, cy + half_h], dim=-1)
results = []
for i in range(batch):
keep = topk_scores[i] >= score_threshold
results.append(
{
"boxes": boxes[i][keep],
"scores": topk_scores[i][keep],
"labels": labels[i][keep],
}
)
return results
+1 -2
View File
@@ -3,8 +3,7 @@
from utils.registry import Registry
BACKBONES = Registry("backbones")
NECKS = Registry("necks")
HEADS = Registry("heads")
MODELS = Registry("models")
__all__ = ["BACKBONES", "NECKS", "HEADS", "MODELS"]
__all__ = ["BACKBONES", "HEADS", "MODELS"]