文章目錄
- 前言
- 工具函數
- 數據處理工具 (`utils_for_data.py`)
- 訓練工具 (`utils_for_train.py`)
- 檢測相關工具 (`utils_for_detection.py`)
- 可視化工具 (`utils_for_huitu.py`)
- 模型
- 類別預測層
- 邊界框預測層
- 連接多尺度預測
- 高和寬減半塊
- 基礎網絡塊
- 完整的模型
- 訓練模型
- 讀取數據集和初始化
- 定義損失函數和評價函數
- 訓練模型
- 預測目標
- 圖像預處理
- 執行預測和后處理
- 可視化結果
- 總結
前言
大家好!歡迎來到“從代碼學習深度學習”系列博客。目標檢測是計算機視覺領域的核心任務之一,旨在識別圖像或視頻中特定類別的對象實例,并確定它們的位置和范圍。近年來,深度學習技術極大地推動了目標檢測的發展,涌現出許多優秀的算法,如 R-CNN 系列、YOLO 系列以及我們今天要重點介紹的單發多框檢測(Single Shot MultiBox Detector, SSD)。
SSD 是一種流行的單階段目標檢測器,以其在速度和精度之間的良好平衡而聞名。與兩階段檢測器(如 Faster R-CNN)先生成區域提議再進行分類和回歸不同,SSD 直接在不同尺度的特征圖上預測邊界框和類別,從而實現了更快的檢測速度。
本篇博客旨在通過一個具體的 PyTorch 實現(基于香蕉檢測數據集),帶領大家深入理解 SSD 的核心原理和代碼實現細節。我們將逐步剖析模型結構、損失函數、訓練過程以及預測可視化等關鍵環節,真正做到“從代碼中學習”。
完整代碼:下載鏈接
在深入 SSD 模型之前,我們先引入一些在整個項目中會用到的工具函數,它們主要負責數據處理、模型訓練輔助以及結果可視化。
工具函數
在實現和訓練 SSD 模型以及可視化結果的過程中,我們會用到一些輔助函數。這些函數分散在不同的工具文件中。
數據處理工具 (utils_for_data.py
)
這部分代碼負責讀取和加載香蕉檢測數據集。read_data_bananas
函數讀取圖像和對應的 CSV 標簽文件,并將它們轉換成 PyTorch 張量。BananasDataset
類繼承了 torch.utils.data.Dataset
,方便我們構建數據加載器。load_data_bananas
函數則利用 BananasDataset
創建了訓練和驗證數據的數據加載器(DataLoader)。
# --- START OF FILE utils_for_data.py ---import os
import pandas as pd
import torch
import torchvisiondef read_data_bananas(is_train=True):"""讀取香蕉檢測數據集中的圖像和標簽參數:is_train (bool): 是否讀取訓練集數據,True表示讀取訓練集,False表示讀取驗證集返回:tuple: (images, targets)- images: 圖像列表,每個元素是一個形狀為[C, H, W]的張量- targets: 標注信息張量,形狀為[N, 1, 5],每行包含[類別, 左上角x, 左上角y, 右下角x, 右下角y]"""# 設置數據目錄路徑data_dir = 'banana-detection'# 根據is_train確定使用訓練集還是驗證集路徑subset_name = 'bananas_train' if is_train else 'bananas_val'# 構建標簽CSV文件的完整路徑# csv_fname: 字符串,表示CSV文件的完整路徑csv_fname = os.path.join(data_dir, subset_name, 'label.csv')# 讀取CSV文件到pandas DataFrame# csv_data: DataFrame,包含圖像名稱和對應的標注信息csv_data = pd.read_csv(csv_fname)# 將img_name列設置為索引,便于后續訪問# csv_data: DataFrame,索引為圖像名稱,列為標注信息csv_data = csv_data.set_index('img_name')# 初始化存儲圖像和標注的列表# images: 列表,用于存儲讀取的圖像張量# targets: 列表,用于存儲對應的標注信息images, targets = [], []# 遍歷DataFrame中的每一行,讀取圖像和對應的標注信息for img_name, target in csv_data.iterrows():# 讀取圖像并添加到images列表中# img_name: 字符串,圖像文件名# 讀取的圖像: 張量,形狀為[C, H, W],C是通道數,H是高度,W是寬度images.append(torchvision.io.read_image(os.path.join(data_dir, subset_name, 'images', f'{img_name}')))# 添加標注信息到targets列表中# target: Series,包含類別和邊界框坐標信息# list(target): 列表,形狀為[5],包含[類別, 左上角x, 左上角y, 右下角x, 右下角y]targets.append(list(target))# 將targets列表轉換為張量,并添加一個維度,然后將值歸一化到0-1范圍# torch.tensor(targets): 張量,形狀為[N, 5],N是樣本數量# torch.tensor(targets).unsqueeze(1): 張量,形狀為[N, 1, 5]# 最終返回的targets: 張量,形狀為[N, 1, 5],值范圍在0-1之間targets_tensor = torch.tensor(targets).unsqueeze(1) / 256return images, targets_tensorclass BananasDataset(torch.utils.data.Dataset):"""一個用于加載香蕉檢測數據集的自定義數據集類繼承自torch.utils.data.Dataset基類,實現了必要的__init__、__getitem__和__len__方法用于提供數據加載器(DataLoader)訪問數據集的接口"""def __init__(self, is_train):"""初始化香蕉檢測數據集參數:is_train (bool): 是否加載訓練集數據,True表示加載訓練集,False表示加載驗證集屬性:self.features: 列表,包含所有圖像張量,每個張量形狀為[C, H, W]self.labels: 張量,形狀為[N, 1, 5],其中N是樣本數量,1是類別數量每個樣本包含[類別, 左上角x, 左上角y, 右下角x, 右下角y]"""# 調用read_data_bananas函數讀取數據集# self.features: 列表,包含N個形狀為[C, H, W]的圖像張量# self.labels: 張量,形狀為[N, 1, 5]self.features, self.labels = read_data_bananas(is_train)# 打印讀取的數據集信息dataset_type = '訓練樣本' if is_train else '驗證樣本'print(f'讀取了 {len(self.features)} 個{dataset_type}')def __getitem__(self, idx):"""獲取指定索引的樣本參數:idx (int): 樣本索引返回:tuple: (feature, label)- feature: 張量,形狀為[C, H, W],圖像數據,已轉換為float類型- label: 張量,形狀為[1, 5],對應的標注信息"""# 返回索引為idx的特征和標簽對# self.features[idx]: 張量,形狀為[C, H, W]# self.features[idx].float(): 將圖像張量轉換為float類型,形狀不變,仍為[C, H, W]# self.labels[idx]: 張量,形狀為[1, 5],包含一個目標的類別和邊界框信息return (self.features[idx].float(), self.labels[idx])def __len__(self):"""獲取數據集中樣本的數量返回:int: 數據集中的樣本數量"""# 返回數據集中的樣本數量# len(self.features): int,表示數據集中圖像的總數return len(self.features)def load_data_bananas(batch_size):"""加載香蕉檢測數據集,并創建數據加載器參數:batch_size (int): 批量大小,指定每次加載的樣本數量返回:tuple: (train_iter, val_iter)- train_iter: 訓練數據加載器,每次返回batch_size個訓練樣本每個批次包含:- 特征張量,形狀為[batch_size, C, H, W]- 標簽張量,形狀為[batch_size, 1, 5]- val_iter: 驗證數據加載器,每次返回batch_size個驗證樣本批次格式與train_iter相同"""# 創建訓練集數據加載器# BananasDataset(is_train=True): 實例化訓練集數據集對象# batch_size: 每個批次的樣本數量# shuffle=True: 打亂數據順序,增強模型的泛化能力# train_iter的每個批次包含:# - 特征張量,形狀為[batch_size, C, H, W],C是通道數,H是高度,W是寬度# - 標簽張量,形狀為[batch_size, 1, 5],每行包含[類別, 左上角x, 左上角y, 右下角x, 右下角y]train_iter = torch.utils.data.DataLoader(BananasDataset(is_train=True),batch_size=batch_size,shuffle=True)# 創建驗證集數據加載器# BananasDataset(is_train=False): 實例化驗證集數據集對象# batch_size: 每個批次的樣本數量# shuffle默認為False: 不打亂驗證數據的順序,保持一致性# val_iter的每個批次包含:# - 特征張量,形狀為[batch_size, C, H, W]# - 標簽張量,形狀為[batch_size, 1, 5]val_iter = torch.utils.data.DataLoader(BananasDataset(is_train=False),batch_size=batch_size)return train_iter, val_iter
# --- END OF FILE utils_for_data.py ---
訓練工具 (utils_for_train.py
)
這部分包含通用的訓練輔助類。Timer
類用于記錄和計算代碼塊的執行時間。Accumulator
類則方便我們在訓練過程中累加損失、準確率等多個指標。try_gpu
函數嘗試獲取可用的 GPU 設備,否則回退到 CPU。
# --- START OF FILE utils_for_train.py ---import torch
import math # 導入math包,用于計算指數
from torch import nn
import time
import numpy as np # 導入numpy 用于cumsum計算class Timer:"""記錄多次運行時間"""def __init__(self):"""Defined in :numref:`subsec_linear_model`"""self.times = []self.start()def start(self):"""啟動計時器"""self.tik = time.time()def stop(self):"""停止計時器并將時間記錄在列表中"""self.times.append(time.time() - self.tik)return self.times[-1]def avg(self):"""返回平均時間"""return sum(self.times) / len(self.times)def sum(self):"""返回時間總和"""return sum(self.times)def cumsum(self):"""返回累計時間"""return np.array(self.times).cumsum().tolist()class Accumulator:"""在 n 個變量上累加"""def __init__(self, n):"""初始化 Accumulator 類輸入:n: 需要累加的變量數量 # 輸入參數:變量數量輸出:無返回值 # 方法無顯式返回值"""self.data = [0.0] * n # 初始化一個長度為 n 的浮點數列表,初始值為 0.0def add(self, *args):"""向累加器中添加多個值輸入:*args: 可變數量的數值,用于累加 # 輸入參數:可變參數,表示要累加的值輸出:無返回值 # 方法無顯式返回值"""self.data = [a + float(b) for a, b in zip(self.data, args)] # 將輸入值累加到對應位置的數據上def reset(self):"""重置累加器中的所有值為 0輸入:無 # 方法無輸入參數輸出:無返回值 # 方法無顯式返回值"""self.data = [0.0] * len(self.data) # 重置數據列表,所有值設為 0.0def __getitem__(self, idx):"""獲取指定索引處的值輸入:idx: 索引值 # 輸入參數:要訪問的數據索引輸出:float: 指定索引處的值 # 返回指定位置的累加值"""return self.data[idx] # 返回指定索引處的數據值def try_gpu(i=0):"""如果存在,則返回gpu(i),否則返回cpu()Args:i (int, optional): GPU設備的編號,默認為0,表示嘗試使用第0號GPUReturns:torch.device: 返回可用的設備對象,如果指定編號的GPU可用則返回GPU,否則返回CPU"""# 檢查系統中可用的GPU數量是否大于等于i+1if torch.cuda.device_count() >= i + 1:# 如果條件滿足,返回指定編號i的GPU設備return torch.device(f'cuda:{i}')# 如果沒有足夠的GPU設備,返回CPU設備return torch.device('cpu')# --- END OF FILE utils_for_train.py ---
檢測相關工具 (utils_for_detection.py
)
這是 SSD 實現的核心工具集。包含了以下關鍵功能:
- 邊界框表示轉換:
box_corner_to_center
和box_center_to_corner
用于在 (左上角, 右下角) 和 (中心點, 寬高) 兩種坐標表示法之間轉換。 - 錨框生成:
multibox_prior
根據輸入的特征圖、尺寸比例 (sizes) 和寬高比 (ratios) 生成大量的錨框。 - IoU 計算:
box_iou
計算兩組邊界框之間的交并比 (Intersection over Union),這是目標檢測中的基本度量。 - 錨框分配:
assign_anchor_to_bbox
將真實邊界框 (ground truth) 分配給最匹配的錨框。 - 偏移量計算:
offset_boxes
計算預測邊界框相對于錨框的偏移量(中心點坐標和寬高),這是回歸任務的目標。offset_inverse
則根據錨框和預測的偏移量反算出預測的邊界框坐標。 - 目標生成:
multibox_target
是關鍵函數,它整合了錨框分配和偏移量計算,為每個錨框生成對應的類別標簽和邊界框回歸目標。 - 非極大值抑制 (NMS):
nms
用于在預測階段去除高度重疊的冗余檢測框,保留置信度最高的框。 - 多框檢測:
multibox_detection
結合類別概率預測、邊界框偏移量預測、錨框以及 NMS,生成最終的檢測結果。 - 可視化輔助:
bbox_to_rect
將邊界框轉換為 Matplotlib 繪圖格式,show_bboxes
則用于在圖像上繪制邊界框和標簽。
# --- START OF FILE utils_for_detection.py ---import torch
import matplotlib.pyplot as plt
torch.set_printoptions(2) # 精簡輸出精度def box_corner_to_center(boxes):"""將邊界框從(左上角,右下角)表示法轉換為(中心點,寬度,高度)表示法該函數接收以(x1, y1, x2, y2)格式表示的邊界框張量,其中:- (x1, y1):表示邊界框左上角的坐標- (x2, y2):表示邊界框右下角的坐標然后將其轉換為(cx, cy, w, h)格式,其中:- (cx, cy):表示邊界框中心點的坐標- w:表示邊界框的寬度- h:表示邊界框的高度參數:boxes (torch.Tensor): 形狀為(N, 4)的張量,包含N個邊界框的左上角和右下角坐標返回:torch.Tensor: 形狀為(N, 4)的張量,包含N個邊界框的中心點坐標、寬度和高度"""# 分別提取所有邊界框的左上角和右下角坐標x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]# 計算中心點坐標cx = (x1 + x2) / 2 # 中心點x坐標 = (左邊界x + 右邊界x) / 2cy = (y1 + y2) / 2 # 中心點y坐標 = (上邊界y + 下邊界y) / 2# 計算寬度和高度w = x2 - x1 # 寬度 = 右邊界x - 左邊界xh = y2 - y1 # 高度 = 下邊界y - 上邊界y# 將計算得到的中心點坐標、寬度和高度堆疊成新的張量boxes = torch.stack((cx, cy, w, h), axis=-1)return boxesdef box_center_to_corner(boxes):"""將邊界框從(中心點,寬度,高度)表示法轉換為(左上角,右下角)表示法該函數接收以(cx, cy, w, h)格式表示的邊界框張量,其中:- (cx, cy):表示邊界框中心點的坐標- w:表示邊界框的寬度- h:表示邊界框的高度然后將其轉換為(x1, y1, x2, y2)格式,其中:- (x1, y1):表示邊界框左上角的坐標- (x2, y2):表示邊界框右下角的坐標參數:boxes (torch.Tensor): 形狀為(N, 4)的張量,包含N個邊界框的中心點坐標、寬度和高度返回:torch.Tensor: 形狀為(N, 4)的張量,包含N個邊界框的左上角和右下角坐標"""# 分別提取所有邊界框的中心點坐標、寬度和高度cx, cy, w, h = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]# 計算左上角坐標x1 = cx - 0.5 * w # 左邊界x = 中心點x - 寬度/2y1 = cy - 0.5 * h # 上邊界y = 中心點y - 高度/2# 計算右下角坐標x2 = cx + 0.5 * w # 右邊界x = 中心點x + 寬度/2y2 = cy + 0.5 * h # 下邊界y = 中心點y + 高度/2# 將計算得到的左上角和右下角坐標堆疊成新的張量boxes = torch.stack((x1, y1, x2, y2), axis=-1)return boxesdef multibox_prior(data, sizes, ratios):"""生成以每個像素為中心具有不同形狀的錨框參數:data:輸入圖像張量,維度為(批量大小, 通道數, 高度, 寬度)sizes:錨框縮放比列表,元素個數為num_sizes,每個元素∈(0,1]ratios:錨框寬高比列表,元素個數為num_ratios,每個元素>0返回:輸出張量,維度為(1, 像素總數*每像素錨框數, 4),表示所有錨框的坐標"""# 獲取輸入數據的高度和寬度# in_height, in_width: 標量in_height, in_width = data.shape[-2:]# 獲取設備信息以及尺寸和比例的數量# device: 字符串; num_sizes, num_ratios: 標量device, num_sizes, num_ratios = data.device, len(sizes), len(ratios)# 計算每個像素點產生的錨框數量 = 尺寸數 + 寬高比數 - 1# boxes_per_pixel: 標量boxes_per_pixel = (num_sizes + num_ratios - 1)# 將尺寸和比例轉換為張量# size_tensor: 維度為(num_sizes,)# ratio_tensor: 維度為(num_ratios,)size_tensor = torch.tensor(sizes, device=device)ratio_tensor = torch.tensor(ratios, device=device)# 為了將錨點移動到像素的中心,需要設置偏移量# 因為一個像素的高為1且寬為1,我們選擇偏移中心0.5# offset_h, offset_w: 標量offset_h, offset_w = 0.5, 0.5# 計算高度和寬度方向上的步長(歸一化)# steps_h, steps_w: 標量steps_h = 1.0 / in_height # 在y軸上縮放步長steps_w = 1.0 / in_width # 在x軸上縮放步長# 生成錨框的所有中心點# center_h: 維度為(in_height,)# center_w: 維度為(in_width,)center_h = (torch.arange(in_height, device=device) + offset_h) * steps_hcenter_w = (torch.arange(in_width, device=device) + offset_w) * steps_w# 使用meshgrid生成網格坐標# shift_y, shift_x: 維度均為(in_height, in_width)shift_y, shift_x = torch.meshgrid(center_h, center_w, indexing='ij')# 將坐標展平為一維# shift_y, shift_x: 展平后維度均為(in_height*in_width,)shift_y, shift_x = shift_y.reshape(-1), shift_x.reshape(-1)# 生成"boxes_per_pixel"個高和寬,# 之后用于創建錨框的四角坐標(xmin,ymin,xmax,ymax)# 計算錨框寬度:先計算尺寸與第一個比例的組合,再計算第一個尺寸與其余比例的組合# w: 維度為(num_sizes + num_ratios - 1,)w = torch.cat((size_tensor * torch.sqrt(ratio_tensor[0]),sizes[0] * torch.sqrt(ratio_tensor[1:])))\* in_height / in_width # 處理矩形輸入,調整寬度# 計算錨框高度:對應于寬度的計算方式# h: 維度為(num_sizes + num_ratios - 1,)h = torch.cat((size_tensor / torch.sqrt(ratio_tensor[0]),sizes[0] / torch.sqrt(ratio_tensor[1:]</