ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

图像块分类工程实战:从切块、微调到滑窗推理全流程

图像块分类工程实战:从切块、微调到滑窗推理全流程 简介这是一份面向图像处理与计算机视觉学习者的图像块分类Matlab实战资料。资源完整覆盖图像预处理、滑窗划分、特征提取、特征选择、分类器训练与后处理等核心环节适合正在学习SVM、PCA、LDA或卷积神经网络等算法的读者参考也适合课程设计或项目复现使用。全包共30个文件以.m源代码为主配合.mat数据文件、png测试图及doc说明文档整体大小27.8MB目录结构清晰便于按模块对照代码与实验报告进行学习。已有321人浏览学习。读者可通过源码梳理光滑块/非光滑块判别流程利用附带的lena、barbara测试图复现分类实验结合程序说明与实验结果文档理解各函数作用、参数调试思路和误差指标为后续图像检索、目标检测、医学影像分析等场景提供可扩展的实现基础。1. 图像块分类为什么要把整张图拆成小块再判断类别图像块分类听起来像是把一张图切成小方块然后挨个贴标签实际做起来却比整图分类麻烦得多。做过病理切片分析或者工业外观检测的同行应该都有体会整张图动辄几千万像素目标物体又小又密直接整图分类算力扛不住、精度也不行只能靠滑窗切块、逐块判断再拼回结果。这套逻辑在医学影像、遥感地物识别、质检漏检场景里特别常见。这篇笔记要讲的资源就是一套完整的图像块分类工程实现从切块、数据划分、模型微调到滑窗推理和可视化验证全部能直接跑通。适合正在做目标区域分类、想快速搭一个可复用的块级分类管线的工程师也适合刚接触 PyTorch 想找个完整流程参考的人。2. 数据准备切块、标注与数据集划分五个必须想清楚的参数数据准备在图像块分类里占七成工作量真不是夸张。切块的尺寸、步长、重叠率、类别平衡、划分粒度任何一个拍脑袋都会让后面训练翻车。这一章先说参数怎么定再给一段能直接落地的切块脚本。2.1 切块策略重叠滑动与固定尺寸为什么我默认用 224×224做图像块分类第一件事是确定块尺寸。我默认用 224×224不是因为别的而是因为绝大多数 ImageNet 预训练模型ResNet、EfficientNet 等的输入就是这个尺寸直接加载权重不用改结构。如果你的目标区域特别小或者特别大可以换成 128 或 384但不要用 256 这种非主流尺寸省得后面想换预训练模型还得重新切。切块时除了固定尺寸还要决定步长stride。步长等于块尺寸时是无重叠步长小于块尺寸就是重叠滑窗。重叠会带来数据量膨胀比如一块 512×512 的图像224 尺寸、步长 112可以切成约 9 块而步长 224 只有 4 块。重叠的好处是目标物体不容易被切断坏处是训练样本强相关容易过拟合。下面这段脚本是把一张大图切成带坐标的块并保存成文件。我一般把坐标也存下来方便推理时拼回原图。import numpy as np import cv2 from pathlib import Path def extract_patches(image_path, save_dir, patch_size224, stride112): img cv2.imread(image_path) h, w img.shape[:2] save_dir Path(save_dir) save_dir.mkdir(parentsTrue, exist_okTrue) patches [] coords [] for y in range(0, h - patch_size 1, stride): for x in range(0, w - patch_size 1, stride): patch img[y:ypatch_size, x:xpatch_size] if patch.shape[0] ! patch_size or patch.shape[1] ! patch_size: continue fname f{Path(image_path).stem}_{y}_{x}.png cv2.imwrite(str(save_dir / fname), patch) patches.append(fname) coords.append((y, x)) return patches, coords这个函数的核心参数是patch_size和stride。patch_size决定模型的感受野选 224 是跟预训练对齐stride控制重叠率我一般设成patch_size // 2也就是 50% 重叠这样目标被切碎的概率低数据量也翻不到太夸张。如果你做的是密集小目标可以把 stride 设成patch_size // 4但要注意显存和训练时间。坐标保存很有必要。后面做整图推理时你要根据坐标把各个块的预测概率拼回原图位置如果没有坐标就只能按文件名顺序硬拼一旦步长不是整数倍就容易错位。2.2 类别不平衡统计分布、过采样与加权损失先算后调切完块之后第一个要面对的问题是类别数量可能严重不均。尤其是工业缺陷检测正常块可能有几万个缺陷块只有几百个。直接训练模型会学成“永远预测正常”准确率还很高但实际一点用没有。我习惯先跑一段统计代码看看每类块的数量分布。如果比例超过 10:1就得动手处理。from collections import Counter import glob def count_class_distribution(labeled_dir): dist Counter() for label in [defect, normal]: files glob.glob(f{labeled_dir}/{label}/*.png) dist[label] len(files) return dist dist count_class_distribution(patches) print(dist) # 输出示例: Counter({normal: 4821, defect: 324})有了分布之后两个常用手段一是用WeightedRandomSampler做样本均衡采样让每个 batch 里不同类别的块比例接近二是给损失函数加weight让少样本类别错分时损失更大。我一般两个一起用效果最稳。from torch.utils.data import WeightedRandomSampler from torch import nn def make_weights_labels(labels): counts Counter(labels.tolist()) weights [1.0 / counts[i] for i in labels.tolist()] return torch.tensor(weights) sampler WeightedRandomSampler(weights, num_sampleslen(weights), replacementTrue) criterion nn.CrossEntropyLoss(weighttorch.tensor([0.2, 1.0]).cuda())WeightedRandomSampler的weights是每个样本的采样权重这里用“1 / 类别数量”来平衡少数类样本被采到的概率变大。replacementTrue表示可以重复采样保证每个 epoch 能看到足够多少数类样本。损失函数里的weight是类别维度上的张量类别 0 和类别 1 的权重分别写死。注意weight不是越大越好我试过给少数类权重设成 5结果模型反而过拟合到少数类上精确率掉了。一般从 1 和类别比例倒数的中间值调起。2.3 数据集划分按图像而非按块划分避免同源泄露切块和标注做完接下来划分训练集、验证集。这里有个非常隐蔽又致命的坑——按块划分。同一张大图切出的块之间高度相似如果一部分块进了训练集、另一部分块进了验证集模型会“记住”背景纹理而不是真正的类别特征验证指标虚高上线后一塌糊涂。正确做法是先把原始图像列表按比例划分再把每张图对应的块归入同一集合。代码很简单关键在思路。from sklearn.model_selection import train_test_split image_files [str(p) for p in Path(images).glob(*.png)] train_imgs, val_imgs train_test_split(image_files, test_size0.2, random_state42) def collect_patch_files(image_list, patch_root): files [] for img in image_list: stem Path(img).stem files.extend(Path(patch_root).glob(f{stem}_*.png)) return files train_patches collect_patch_files(train_imgs, patches) val_patches collect_patch_files(val_imgs, patches)注意这里train_test_split的random_state固定为 42保证可复现。collect_patch_files用stem前缀匹配某张原始图像切出的所有块。有的切块脚本文件名不带原图 stem而是统一编号那就必须保存一个“块到原图”的映射表否则划分没法做。我在一个项目里吃过没存映射的亏只能重新切块。3. 模型训练从预训练 ResNet 到微调损失函数与优化器怎么选数据准备好了接下来选模型、定优化器、跑训练。图像块分类很少从零训基本都靠 ImageNet 预训练权重微调。这里讲基线怎么选、微调怎么分步走以及训练循环里哪些细节决定了你能不能复现结果。3.1 基线选择ResNet18 还是 EfficientNet带宽和显存先算一笔账图像块分类的模型选择不是越深越好而是看你的块尺寸、显存和推理速度。下表是我常用的几个基线对比模型输入尺寸参数量GPU 显存占用batch64推理速度块/秒适用场景ResNet1822411M约 1.2GB约 800快速验证、边缘部署ResNet5022425M约 2.1GB约 400分类边界复杂、精度优先EfficientNet-B02245.3M约 0.9GB约 900数据量大、追求性价比EfficientNet-B22609.1MB约 1.5GB约 500中等数据集、略复杂我一般拿 ResNet18 当基线。原因很简单显存友好迭代快先跑通流程再升级。如果验证集 F1 明显不够再换 ResNet50 或 EfficientNet-B2。从工程角度换模型只是换一行代码的事但数据清洗和划分才是真正影响结果的地方。别一开始就追求大模型很多图像块分类任务用 ResNet18 微调就够用。3.2 微调流程冻结、解冻、分层学习率三步走预训练模型微调最忌讳的是直接用预设学习率从头训练全部分层。特征层已经学到了通用纹理和边缘你拿随机初始化的分类头跟它一起大步长更新分类头还没学好特征层已经被冲乱了。我习惯分三步走。第一步冻结特征层只训练新替换的分类头。代码如下from torchvision import models import torch.nn as nn model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) model.fc nn.Linear(model.fc.in_features, num_classes) for name, param in model.named_parameters(): if fc not in name: param.requires_grad False optimizer torch.optim.Adam(model.fc.parameters(), lr1e-3)这里先把除fc以外的参数全部冻结用 Adam 以 1e-3 的学习率训练分类头几个 epoch。注意nn.Linear的in_features是 ResNet18 最后一层特征维度 512改成自己任务的类别数。冻结以后反向传播只更新fc显存和计算量都会小很多。第二步解冻最后几层特征层。一般解冻layer4和layer3用更小的学习率继续训练for name, param in model.named_parameters(): if name.startswith(layer4): param.requires_grad True optimizer torch.optim.Adam([ {params: model.fc.parameters(), lr: 1e-4}, {params: model.layer4.parameters(), lr: 1e-5}, ], lr1e-5)第三步等损失不再明显下降再解冻全部层全模型学习率调到 1e-5 到 3e-5。到这一步模型已经在你的数据上稳定收敛了只做轻微调整。很多人会在第一步就全解冻然后发现损失曲线波浪起伏其实就是学习率太高了。3.3 训练循环与检查点早停、最好模型保存、日志记录训练循环本身不复杂但有几件事必须做保存验证集上最优模型、早停防止过拟合、把训练日志落盘。我因为没存最优模型训练中断后只能从最后一个 epoch 继续结果 F1 掉了 3 个点后来又重新跑了两天。从那以后我每训练一个任务都会强制在验证集上打分并覆盖保存最优权重。下面是一个精简的训练循环骨架import torch best_f1 0.0 patience 10 wait 0 for epoch in range(50): model.train() total_loss 0 for images, labels in train_loader: images, labels images.cuda(), labels.cuda() optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() model.eval() all_preds, all_labels [], [] with torch.no_grad(): for images, labels in val_loader: images, labels images.cuda(), labels.cuda() outputs model(images) preds outputs.argmax(dim1) all_preds.extend(preds.cpu().tolist()) all_labels.extend(labels.cpu().tolist()) f1 compute_f1(all_labels, all_preds) if f1 best_f1: best_f1 f1 torch.save(model.state_dict(), best_model.pth) wait 0 else: wait 1 if wait patience: print(fEarly stop at epoch {epoch}) break这段代码里的compute_f1需要你自己实现我一般用sklearn.metrics.f1_score。patience是早停容忍度设为 10 表示连续 10 个 epoch 没有刷新最优验证分数就停。注意每个 epoch 后必须切回eval()模式with torch.no_grad()保证不计算梯度否则显存会慢慢堆积。训练循环里我习惯把每个 epoch 的 loss 和 F1 写进 CSV方便用 tensorboard 或 pandas 复盘。4. 常见问题排查训练震荡、过拟合、类别不均衡的七个实战教训这一章集中记录我在图像块分类上踩过的坑每一条都是真实翻车后的复盘。按“现象 → 原因 → 解决”来写你可以直接对照排查。4.1 损失不下降学习率与 Batch Size 的配合现象训练了几百个 step损失一直卡在 0.7 左右偶尔波动但就是不降。原因我在冻结特征层阶段用了 1e-3 学习率而 Batch Size 只有 16。小 batch 的梯度噪声大1e-3 相对 batch 的梯度幅度偏高导致模型在最优解附近震荡。另一个常见情况是反向传播时误把冻结层的参数也更新了但这种情况通常表现为损失直接爆掉。解决把学习率降到 3e-4 或 1e-4同时把 Batch Size 加到 32 或 64。如果显存不够就加梯度累积等效增大 batch。我一般用torch.utils.data.DataLoader的batch_size64如果 OOM 就降到 32并用accumulation_steps2模拟 64 的效果。4.2 验证准确率虚高切块泄漏与随机种子现象验证集准确率 95%训练准确率 93%逻辑上训练应该更高但验证反而更高明显不合理。原因划分数据时没按原始图像分同一张图切出的块同时出现在训练集和验证集。模型学到了背景噪声而非类别特征所以验证集看起来“很准”。另外如果随机种子没固定比如random_state每次都变也会造成数据分布不稳定。解决回到第 2 章的collect_patch_files确保训练和验证集合不相交。同时把random_state固定成一个常数我习惯用 42。固定后重新训练验证准确率通常会下降到 85% 左右但这才接近真实水平。4.3 显存不足梯度累积与混合精度现象输入块尺寸 384Batch Size 设 32直接爆显存报CUDA out of memory。原因块尺寸变大后特征图尺寸呈平方增长显存消耗跟着涨。很多人的第一反应是减小 batch但 batch 太小又导致训练不稳。解决先开混合精度训练PyTorch 自带torch.cuda.amp几乎不损失精度。如果还爆就加梯度累积。示例from torch.cuda.amp import autocast, GradScaler scaler GradScaler() accumulation_steps 2 optimizer.zero_grad() for i, (images, labels) in enumerate(train_loader): with autocast(): outputs model(images) loss criterion(outputs, labels) / accumulation_steps scaler.scale(loss).backward() if (i 1) % accumulation_steps 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()注意这里loss除了accumulation_steps避免累积梯度过大。scaler.step(optimizer)是混合精度训练的标准写法必须配合scaler.update()。4.4 预测结果成片错分滑窗步长与投票机制现象把训练好的模型用于整张大图推理时结果出现大面积的块状错误比如一条直线上的块全是同一个错误类别。原因推理时的滑窗步长和训练时不一样。训练时步长是 112推理时如果步长改成 224每个块之间没重叠模型对某些位置的预测很不稳定再加上没有做多块投票单块预测置信度低时直接取 argmax 就容易错。解决推理时保持和训练一样的步长并对同一个目标区域做重叠采样再把多个块的预测概率取平均。这块的具体实现放在下一章你可以直接参考那个流程。5. 进阶用滑窗推理实现整图分类以及注意力可视化的验证技巧模型训练完最终还是要落在整张图上。这一章讲怎么用滑窗推理把块级预测拼成整图结果以及怎么用 Grad-CAM 确认模型关注区域确实是你想要的目标。5.1 滑窗推理重叠步长、投票融合与置信度阈值整图推理时我用一个和训练时完全一致的滑动窗口步长也保持一致。这样每个块与相邻块有重叠同一物理位置会被多个窗口覆盖取平均概率能显著抑制噪声。def infer_full_image(model, image_path, patch_size224, stride112, num_classes2, threshold0.5): img cv2.imread(image_path) h, w img.shape[:2] prob_map np.zeros((h // stride 1, w // stride 1, num_classes), dtypenp.float32) count_map np.zeros((h // stride 1, w // stride 1, 1), dtypenp.float32) model.eval() with torch.no_grad(): for y in range(0, h - patch_size 1, stride): for x in range(0, w - patch_size 1, stride): patch img[y:ypatch_size, x:xpatch_size] patch_tensor torch.from_numpy(patch.transpose(2,0,1)).unsqueeze(0).float().cuda() / 255.0 outputs torch.softmax(model(patch_tensor), dim1) py, px y // stride, x // stride prob_map[py, px] outputs.squeeze().cpu().numpy() count_map[py, px] 1.0 avg_prob prob_map / np.maximum(count_map, 1) pred (avg_prob[..., 1] threshold).astype(np.uint8) # 二分类示例 return pred这个函数返回一个二值图每个网格点对应原图stride步长的区域1 表示类别 1。阈值threshold默认 0.5如果希望少误报可以调到 0.7如果希望少漏报调到 0.3。注意这里prob_map的尺寸计算是h // stride 1如果原图尺寸不是 stride 的整数倍需要补边或丢弃边缘。实际项目里我会先把原图 padding 成 stride 的倍数否则最后一行和最后一列会缺块。5.2 用 Grad-CAM 看看模型到底在看什么训练完模型我强烈建议做一次梯度加权激活图Grad-CAM可视化。图像块分类的模型很容易学到背景纹理而非目标轮廓Grad-CAM 能直观告诉你模型关注在哪个区域。关键做法选定最后一个卷积层的输出特征图计算类别输出对该特征图的梯度再对梯度做全局平均池化得到权重最后加权求和并 normalize。from torch import autograd def grad_cam(model, image_tensor, target_class): model.eval() image_tensor image_tensor.cuda().requires_grad_(True) output model(image_tensor) one_hot torch.zeros_like(output) one_hot[0, target_class] 1 output.backward(gradientone_hot) gradients model.layer4[2].bn2.weight.grad # 示意实际需hook # 完整实现通常用register_forward_hook和register_backward_hook获取 # 这里只展示思路不展开全部代码完整的 Grad-CAM 需要用 hook 拿到特征图和梯度代码较长在资源包的visualize_cam.py里已经实现。你只需要指定一张块的路径脚本会输出热力图叠加。我最常用的一招把预测错误的那几个块挑出来看 Grad-CAM 热力图到底落在哪里。如果热力图落在背景上说明模型学偏了回去检查数据切块或标注有没有问题。真正吃透图像块分类关键不在模型多强而在数据划分和多尺度推理是否严谨。我自己每次迭代新任务时都强制走一遍“按图划分 → 统计分布 → 训练后 Grad-CAM 验证”这三步能拦下至少一半的无效训练。希望这些从切块到推理的细节能帮到你少走我当初走过的弯路。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进