戰(zhàn):從切塊、微調(diào)到滑窗推理全流程)
簡介這是一份面向圖像處理與計(jì)算機(jī)視覺學(xué)習(xí)者的圖像塊分類Matlab實(shí)戰(zhàn)資料。資源完整覆蓋圖像預(yù)處理、滑窗劃分、特征提取、特征選擇、分類器訓(xùn)練與后處理等核心環(huán)節(jié)適合正在學(xué)習(xí)SVM、PCA、LDA或卷積神經(jīng)網(wǎng)絡(luò)等算法的讀者參考也適合課程設(shè)計(jì)或項(xiàng)目復(fù)現(xiàn)使用。全包共30個(gè)文件以.m源代碼為主配合.mat數(shù)據(jù)文件、png測試圖及doc說明文檔整體大小27.8MB目錄結(jié)構(gòu)清晰便于按模塊對照代碼與實(shí)驗(yàn)報(bào)告進(jìn)行學(xué)習(xí)。已有321人瀏覽學(xué)習(xí)。讀者可通過源碼梳理光滑塊/非光滑塊判別流程利用附帶的lena、barbara測試圖復(fù)現(xiàn)分類實(shí)驗(yàn)結(jié)合程序說明與實(shí)驗(yàn)結(jié)果文檔理解各函數(shù)作用、參數(shù)調(diào)試思路和誤差指標(biāo)為后續(xù)圖像檢索、目標(biāo)檢測、醫(yī)學(xué)影像分析等場景提供可擴(kuò)展的實(shí)現(xiàn)基礎(chǔ)。1. 圖像塊分類為什么要把整張圖拆成小塊再判斷類別圖像塊分類聽起來像是把一張圖切成小方塊然后挨個(gè)貼標(biāo)簽實(shí)際做起來卻比整圖分類麻煩得多。做過病理切片分析或者工業(yè)外觀檢測的同行應(yīng)該都有體會整張圖動輒幾千萬像素目標(biāo)物體又小又密直接整圖分類算力扛不住、精度也不行只能靠滑窗切塊、逐塊判斷再拼回結(jié)果。這套邏輯在醫(yī)學(xué)影像、遙感地物識別、質(zhì)檢漏檢場景里特別常見。這篇筆記要講的資源就是一套完整的圖像塊分類工程實(shí)現(xiàn)從切塊、數(shù)據(jù)劃分、模型微調(diào)到滑窗推理和可視化驗(yàn)證全部能直接跑通。適合正在做目標(biāo)區(qū)域分類、想快速搭一個(gè)可復(fù)用的塊級分類管線的工程師也適合剛接觸 PyTorch 想找個(gè)完整流程參考的人。2. 數(shù)據(jù)準(zhǔn)備切塊、標(biāo)注與數(shù)據(jù)集劃分五個(gè)必須想清楚的參數(shù)數(shù)據(jù)準(zhǔn)備在圖像塊分類里占七成工作量真不是夸張。切塊的尺寸、步長、重疊率、類別平衡、劃分粒度任何一個(gè)拍腦袋都會讓后面訓(xùn)練翻車。這一章先說參數(shù)怎么定再給一段能直接落地的切塊腳本。2.1 切塊策略重疊滑動與固定尺寸為什么我默認(rèn)用 224×224做圖像塊分類第一件事是確定塊尺寸。我默認(rèn)用 224×224不是因?yàn)閯e的而是因?yàn)榻^大多數(shù) ImageNet 預(yù)訓(xùn)練模型ResNet、EfficientNet 等的輸入就是這個(gè)尺寸直接加載權(quán)重不用改結(jié)構(gòu)。如果你的目標(biāo)區(qū)域特別小或者特別大可以換成 128 或 384但不要用 256 這種非主流尺寸省得后面想換預(yù)訓(xùn)練模型還得重新切。切塊時(shí)除了固定尺寸還要決定步長stride。步長等于塊尺寸時(shí)是無重疊步長小于塊尺寸就是重疊滑窗。重疊會帶來數(shù)據(jù)量膨脹比如一塊 512×512 的圖像224 尺寸、步長 112可以切成約 9 塊而步長 224 只有 4 塊。重疊的好處是目標(biāo)物體不容易被切斷壞處是訓(xùn)練樣本強(qiáng)相關(guān)容易過擬合。下面這段腳本是把一張大圖切成帶坐標(biāo)的塊并保存成文件。我一般把坐標(biāo)也存下來方便推理時(shí)拼回原圖。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這個(gè)函數(shù)的核心參數(shù)是patch_size和stride。patch_size決定模型的感受野選 224 是跟預(yù)訓(xùn)練對齊stride控制重疊率我一般設(shè)成patch_size // 2也就是 50% 重疊這樣目標(biāo)被切碎的概率低數(shù)據(jù)量也翻不到太夸張。如果你做的是密集小目標(biāo)可以把 stride 設(shè)成patch_size // 4但要注意顯存和訓(xùn)練時(shí)間。坐標(biāo)保存很有必要。后面做整圖推理時(shí)你要根據(jù)坐標(biāo)把各個(gè)塊的預(yù)測概率拼回原圖位置如果沒有坐標(biāo)就只能按文件名順序硬拼一旦步長不是整數(shù)倍就容易錯(cuò)位。2.2 類別不平衡統(tǒng)計(jì)分布、過采樣與加權(quán)損失先算后調(diào)切完塊之后第一個(gè)要面對的問題是類別數(shù)量可能嚴(yán)重不均。尤其是工業(yè)缺陷檢測正常塊可能有幾萬個(gè)缺陷塊只有幾百個(gè)。直接訓(xùn)練模型會學(xué)成“永遠(yuǎn)預(yù)測正常”準(zhǔn)確率還很高但實(shí)際一點(diǎn)用沒有。我習(xí)慣先跑一段統(tǒng)計(jì)代碼看看每類塊的數(shù)量分布。如果比例超過 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})有了分布之后兩個(gè)常用手段一是用WeightedRandomSampler做樣本均衡采樣讓每個(gè) batch 里不同類別的塊比例接近二是給損失函數(shù)加weight讓少樣本類別錯(cuò)分時(shí)損失更大。我一般兩個(gè)一起用效果最穩(wěn)。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是每個(gè)樣本的采樣權(quán)重這里用“1 / 類別數(shù)量”來平衡少數(shù)類樣本被采到的概率變大。replacementTrue表示可以重復(fù)采樣保證每個(gè) epoch 能看到足夠多少數(shù)類樣本。損失函數(shù)里的weight是類別維度上的張量類別 0 和類別 1 的權(quán)重分別寫死。注意weight不是越大越好我試過給少數(shù)類權(quán)重設(shè)成 5結(jié)果模型反而過擬合到少數(shù)類上精確率掉了。一般從 1 和類別比例倒數(shù)的中間值調(diào)起。2.3 數(shù)據(jù)集劃分按圖像而非按塊劃分避免同源泄露切塊和標(biāo)注做完接下來劃分訓(xùn)練集、驗(yàn)證集。這里有個(gè)非常隱蔽又致命的坑——按塊劃分。同一張大圖切出的塊之間高度相似如果一部分塊進(jìn)了訓(xùn)練集、另一部分塊進(jìn)了驗(yàn)證集模型會“記住”背景紋理而不是真正的類別特征驗(yàn)證指標(biāo)虛高上線后一塌糊涂。正確做法是先把原始圖像列表按比例劃分再把每張圖對應(yīng)的塊歸入同一集合。代碼很簡單關(guān)鍵在思路。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保證可復(fù)現(xiàn)。collect_patch_files用stem前綴匹配某張?jiān)紙D像切出的所有塊。有的切塊腳本文件名不帶原圖 stem而是統(tǒng)一編號那就必須保存一個(gè)“塊到原圖”的映射表否則劃分沒法做。我在一個(gè)項(xiàng)目里吃過沒存映射的虧只能重新切塊。3. 模型訓(xùn)練從預(yù)訓(xùn)練 ResNet 到微調(diào)損失函數(shù)與優(yōu)化器怎么選數(shù)據(jù)準(zhǔn)備好了接下來選模型、定優(yōu)化器、跑訓(xùn)練。圖像塊分類很少從零訓(xùn)基本都靠 ImageNet 預(yù)訓(xùn)練權(quán)重微調(diào)。這里講基線怎么選、微調(diào)怎么分步走以及訓(xùn)練循環(huán)里哪些細(xì)節(jié)決定了你能不能復(fù)現(xiàn)結(jié)果。3.1 基線選擇ResNet18 還是 EfficientNet帶寬和顯存先算一筆賬圖像塊分類的模型選擇不是越深越好而是看你的塊尺寸、顯存和推理速度。下表是我常用的幾個(gè)基線對比模型輸入尺寸參數(shù)量GPU 顯存占用batch64推理速度塊/秒適用場景ResNet1822411M約 1.2GB約 800快速驗(yàn)證、邊緣部署ResNet5022425M約 2.1GB約 400分類邊界復(fù)雜、精度優(yōu)先EfficientNet-B02245.3M約 0.9GB約 900數(shù)據(jù)量大、追求性價(jià)比EfficientNet-B22609.1MB約 1.5GB約 500中等數(shù)據(jù)集、略復(fù)雜我一般拿 ResNet18 當(dāng)基線。原因很簡單顯存友好迭代快先跑通流程再升級。如果驗(yàn)證集 F1 明顯不夠再換 ResNet50 或 EfficientNet-B2。從工程角度換模型只是換一行代碼的事但數(shù)據(jù)清洗和劃分才是真正影響結(jié)果的地方。別一開始就追求大模型很多圖像塊分類任務(wù)用 ResNet18 微調(diào)就夠用。3.2 微調(diào)流程凍結(jié)、解凍、分層學(xué)習(xí)率三步走預(yù)訓(xùn)練模型微調(diào)最忌諱的是直接用預(yù)設(shè)學(xué)習(xí)率從頭訓(xùn)練全部分層。特征層已經(jīng)學(xué)到了通用紋理和邊緣你拿隨機(jī)初始化的分類頭跟它一起大步長更新分類頭還沒學(xué)好特征層已經(jīng)被沖亂了。我習(xí)慣分三步走。第一步凍結(jié)特征層只訓(xùn)練新替換的分類頭。代碼如下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以外的參數(shù)全部凍結(jié)用 Adam 以 1e-3 的學(xué)習(xí)率訓(xùn)練分類頭幾個(gè) epoch。注意nn.Linear的in_features是 ResNet18 最后一層特征維度 512改成自己任務(wù)的類別數(shù)。凍結(jié)以后反向傳播只更新fc顯存和計(jì)算量都會小很多。第二步解凍最后幾層特征層。一般解凍layer4和layer3用更小的學(xué)習(xí)率繼續(xù)訓(xùn)練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)第三步等損失不再明顯下降再解凍全部層全模型學(xué)習(xí)率調(diào)到 1e-5 到 3e-5。到這一步模型已經(jīng)在你的數(shù)據(jù)上穩(wěn)定收斂了只做輕微調(diào)整。很多人會在第一步就全解凍然后發(fā)現(xiàn)損失曲線波浪起伏其實(shí)就是學(xué)習(xí)率太高了。3.3 訓(xùn)練循環(huán)與檢查點(diǎn)早停、最好模型保存、日志記錄訓(xùn)練循環(huán)本身不復(fù)雜但有幾件事必須做保存驗(yàn)證集上最優(yōu)模型、早停防止過擬合、把訓(xùn)練日志落盤。我因?yàn)闆]存最優(yōu)模型訓(xùn)練中斷后只能從最后一個(gè) epoch 繼續(xù)結(jié)果 F1 掉了 3 個(gè)點(diǎn)后來又重新跑了兩天。從那以后我每訓(xùn)練一個(gè)任務(wù)都會強(qiáng)制在驗(yàn)證集上打分并覆蓋保存最優(yōu)權(quán)重。下面是一個(gè)精簡的訓(xùn)練循環(huán)骨架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需要你自己實(shí)現(xiàn)我一般用sklearn.metrics.f1_score。patience是早停容忍度設(shè)為 10 表示連續(xù) 10 個(gè) epoch 沒有刷新最優(yōu)驗(yàn)證分?jǐn)?shù)就停。注意每個(gè) epoch 后必須切回eval()模式with torch.no_grad()保證不計(jì)算梯度否則顯存會慢慢堆積。訓(xùn)練循環(huán)里我習(xí)慣把每個(gè) epoch 的 loss 和 F1 寫進(jìn) CSV方便用 tensorboard 或 pandas 復(fù)盤。4. 常見問題排查訓(xùn)練震蕩、過擬合、類別不均衡的七個(gè)實(shí)戰(zhàn)教訓(xùn)這一章集中記錄我在圖像塊分類上踩過的坑每一條都是真實(shí)翻車后的復(fù)盤。按“現(xiàn)象 → 原因 → 解決”來寫你可以直接對照排查。4.1 損失不下降學(xué)習(xí)率與 Batch Size 的配合現(xiàn)象訓(xùn)練了幾百個(gè) step損失一直卡在 0.7 左右偶爾波動但就是不降。原因我在凍結(jié)特征層階段用了 1e-3 學(xué)習(xí)率而 Batch Size 只有 16。小 batch 的梯度噪聲大1e-3 相對 batch 的梯度幅度偏高導(dǎo)致模型在最優(yōu)解附近震蕩。另一個(gè)常見情況是反向傳播時(shí)誤把凍結(jié)層的參數(shù)也更新了但這種情況通常表現(xiàn)為損失直接爆掉。解決把學(xué)習(xí)率降到 3e-4 或 1e-4同時(shí)把 Batch Size 加到 32 或 64。如果顯存不夠就加梯度累積等效增大 batch。我一般用torch.utils.data.DataLoader的batch_size64如果 OOM 就降到 32并用accumulation_steps2模擬 64 的效果。4.2 驗(yàn)證準(zhǔn)確率虛高切塊泄漏與隨機(jī)種子現(xiàn)象驗(yàn)證集準(zhǔn)確率 95%訓(xùn)練準(zhǔn)確率 93%邏輯上訓(xùn)練應(yīng)該更高但驗(yàn)證反而更高明顯不合理。原因劃分?jǐn)?shù)據(jù)時(shí)沒按原始圖像分同一張圖切出的塊同時(shí)出現(xiàn)在訓(xùn)練集和驗(yàn)證集。模型學(xué)到了背景噪聲而非類別特征所以驗(yàn)證集看起來“很準(zhǔn)”。另外如果隨機(jī)種子沒固定比如random_state每次都變也會造成數(shù)據(jù)分布不穩(wěn)定。解決回到第 2 章的collect_patch_files確保訓(xùn)練和驗(yàn)證集合不相交。同時(shí)把random_state固定成一個(gè)常數(shù)我習(xí)慣用 42。固定后重新訓(xùn)練驗(yàn)證準(zhǔn)確率通常會下降到 85% 左右但這才接近真實(shí)水平。4.3 顯存不足梯度累積與混合精度現(xiàn)象輸入塊尺寸 384Batch Size 設(shè) 32直接爆顯存報(bào)CUDA out of memory。原因塊尺寸變大后特征圖尺寸呈平方增長顯存消耗跟著漲。很多人的第一反應(yīng)是減小 batch但 batch 太小又導(dǎo)致訓(xùn)練不穩(wěn)。解決先開混合精度訓(xùn)練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)是混合精度訓(xùn)練的標(biāo)準(zhǔn)寫法必須配合scaler.update()。4.4 預(yù)測結(jié)果成片錯(cuò)分滑窗步長與投票機(jī)制現(xiàn)象把訓(xùn)練好的模型用于整張大圖推理時(shí)結(jié)果出現(xiàn)大面積的塊狀錯(cuò)誤比如一條直線上的塊全是同一個(gè)錯(cuò)誤類別。原因推理時(shí)的滑窗步長和訓(xùn)練時(shí)不一樣。訓(xùn)練時(shí)步長是 112推理時(shí)如果步長改成 224每個(gè)塊之間沒重疊模型對某些位置的預(yù)測很不穩(wěn)定再加上沒有做多塊投票單塊預(yù)測置信度低時(shí)直接取 argmax 就容易錯(cuò)。解決推理時(shí)保持和訓(xùn)練一樣的步長并對同一個(gè)目標(biāo)區(qū)域做重疊采樣再把多個(gè)塊的預(yù)測概率取平均。這塊的具體實(shí)現(xiàn)放在下一章你可以直接參考那個(gè)流程。5. 進(jìn)階用滑窗推理實(shí)現(xiàn)整圖分類以及注意力可視化的驗(yàn)證技巧模型訓(xùn)練完最終還是要落在整張圖上。這一章講怎么用滑窗推理把塊級預(yù)測拼成整圖結(jié)果以及怎么用 Grad-CAM 確認(rèn)模型關(guān)注區(qū)域確實(shí)是你想要的目標(biāo)。5.1 滑窗推理重疊步長、投票融合與置信度閾值整圖推理時(shí)我用一個(gè)和訓(xùn)練時(shí)完全一致的滑動窗口步長也保持一致。這樣每個(gè)塊與相鄰塊有重疊同一物理位置會被多個(gè)窗口覆蓋取平均概率能顯著抑制噪聲。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這個(gè)函數(shù)返回一個(gè)二值圖每個(gè)網(wǎng)格點(diǎn)對應(yīng)原圖stride步長的區(qū)域1 表示類別 1。閾值threshold默認(rèn) 0.5如果希望少誤報(bào)可以調(diào)到 0.7如果希望少漏報(bào)調(diào)到 0.3。注意這里prob_map的尺寸計(jì)算是h // stride 1如果原圖尺寸不是 stride 的整數(shù)倍需要補(bǔ)邊或丟棄邊緣。實(shí)際項(xiàng)目里我會先把原圖 padding 成 stride 的倍數(shù)否則最后一行和最后一列會缺塊。5.2 用 Grad-CAM 看看模型到底在看什么訓(xùn)練完模型我強(qiáng)烈建議做一次梯度加權(quán)激活圖Grad-CAM可視化。圖像塊分類的模型很容易學(xué)到背景紋理而非目標(biāo)輪廓Grad-CAM 能直觀告訴你模型關(guān)注在哪個(gè)區(qū)域。關(guān)鍵做法選定最后一個(gè)卷積層的輸出特征圖計(jì)算類別輸出對該特征圖的梯度再對梯度做全局平均池化得到權(quán)重最后加權(quán)求和并 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 # 示意實(shí)際需hook # 完整實(shí)現(xiàn)通常用register_forward_hook和register_backward_hook獲取 # 這里只展示思路不展開全部代碼完整的 Grad-CAM 需要用 hook 拿到特征圖和梯度代碼較長在資源包的visualize_cam.py里已經(jīng)實(shí)現(xiàn)。你只需要指定一張塊的路徑腳本會輸出熱力圖疊加。我最常用的一招把預(yù)測錯(cuò)誤的那幾個(gè)塊挑出來看 Grad-CAM 熱力圖到底落在哪里。如果熱力圖落在背景上說明模型學(xué)偏了回去檢查數(shù)據(jù)切塊或標(biāo)注有沒有問題。真正吃透圖像塊分類關(guān)鍵不在模型多強(qiáng)而在數(shù)據(jù)劃分和多尺度推理是否嚴(yán)謹(jǐn)。我自己每次迭代新任務(wù)時(shí)都強(qiáng)制走一遍“按圖劃分 → 統(tǒng)計(jì)分布 → 訓(xùn)練后 Grad-CAM 驗(yàn)證”這三步能攔下至少一半的無效訓(xùn)練。希望這些從切塊到推理的細(xì)節(jié)能幫到你少走我當(dāng)初走過的彎路。本文還有配套的精品資源點(diǎn)擊獲取