# 【DL】YOLOv8 Deploy Using NCNN 筆記
## Brief 簡介
專案使用 YOLOv8 物件檢測模型部屬在邊緣裝置上,在了解模型與測試後發現有可以優化地方存在,而優化的關鍵在於 YOLOv8 在 Decode bounding box 的過程中是將每一個特徵網格 (Grid) 的每一個 Bounding Box 都進行解碼,而這部分能夠將其移除,並且移動到邊緣端進行實作,進而減少 decode Bounding Box 時間,而減少時間關鍵在於可以先判斷信心分數,把分數較低的 Bounding Box 移除,再進行積分操作,進而優化程式碼執行時間
## Model Structure

## YOLOv8 Post Process
在將影像輸入模型並進行後續操作後,針對不同部署模型格式 (如 ONNX、TensorRT、TFLite 等) 進行 export 時,後處理操作可能會影響計算圖 (computational graph) 的生成,而 decode bbox 過程也包含在裡面,因此我們需要將其拔除並另外實現在邊緣裝置上
我們從 YOLOv8 (現行為 YOLOv11 了) 來進行部分程式碼的解讀,參考 `ultralytics` 的 github 開源程式碼 [ultralytics/nn/modules/head.py](https://github.com/ultralytics/ultralytics/blob/main/ultralytics/nn/modules/head.py)
### Detect Head 功能
在 `Detect` 類別中負責了以下的任務
1. 不同尺度的特徵影像轉換成 類別 (classes) 與預測框 (bounding box) 預測
2. 將預測框解碼成實際預測框的操作
:::info
雖然 YOLOv8 是 anchor-free 的模型,但實際上訓練與 inference 也都需要 anchor,但意義上是不同的
* YOLOv5 anchor-based 表示需要預先定義好一組 anchor box,其代表寬和高
* YOLOv8 anchor-free 表示在 forward 過程自動生成 anchor,而 anchor 則是代表特徵圖的中心點位置座標
:::
* `self.cv2`
負責輸出 BBox 的四個位置數值, YOLOv8 中藉由 DFL 來回歸出這四個數值,為積分操作做準備
* `self.cv3`
負責輸出類別的機率分布
* `self.reg_max`
由 DFL 回歸出的 bbox 積分點樹的數量,通常設置為 16,公式以 $r^m$
```python!
self.cv2 = nn.ModuleList(
nn.Sequential(
Conv(x, c2, 3),
Conv(c2, c2, 3),
nn.Conv2d(c2, 4 * self.reg_max, 1)
) for x in ch
)
self.cv3 = nn.ModuleList(
nn.Sequential(
nn.Sequential(DWConv(x, x, 3), Conv(x, c3, 1)),
nn.Sequential(DWConv(c3, c3, 3), Conv(c3, c3, 1)),
nn.Conv2d(c3, self.nc, 1),
) for x in ch
)
```
### Detect Head 向前傳播流程
在 YOLOv8 的架構中,`forward` 函數需要處理來自不同尺度的特徵圖,而這些特徵圖被儲存在一個列表 `x` 中。這些特徵圖分過 `self.cv2` 和 `self.cv3` 層進行向前傳播,可以看出 YOLOv8 在輸出預測上進行了 **解偶 (decoupling)** 的
特徵圖 `x[i]` 經過 `self.cv2[i]` 和 `self.cv3[i]` 的處理後,tensor 的形狀如下:
- **`self.cv2[i](x[i])`** 的 tensor shape 為 $(b,\;4r_{p},\;h,\;w)$,其中:
- $b$ : batch 批次資料的大小
- $4 \cdot r^p$ : 為要回歸預測框的總維度
- $h, w$ : 特徵圖的高度與寬度
- **`self.cv3[i](x[i])`** 的 tensor shape 為 $(b,\;n_c,\;h,\;w)$,其中:
- $n_c$ : 類別的數量
最終,特徵圖 `x[i]` 的 tensor shape 會是 $(b,\;4r_{p} + n_c,\;h,\;w)$,即將回歸框和分類信息合併到同一個 tensor 中。
這樣,我們就完成了第一階段的 tensor 處理。由於這過程涉及許多 tensor 操作,對初學者來說可能較難理解,但基本上是將不同的預測任務(如邊界框和分類)進行分離後,再合併回輸出中。
```python!
def forward(self, x):
"""Concatenates and returns predicted bounding boxes and class probabilities."""
for i in range(self.nl):
x[i] = torch.cat((self.cv2[i](x[i]), self.cv3[i](x[i])), 1)
if self.training: # Training path
return x
y = self._inference(x)
return y if self.export else (y, x)
```
### `_inference`
在 `_inference` 函數中,負責處理 decode bbox與 anchor 處理,我們將核心的程式碼來進行解讀
* `x_cat`
* 將列表中的所有tensor串接在一起,注意這裡是將不同尺度下特徵圖串接在一起處理
* tensor shape : $(b,\;4r_{p} + n_c,\;\frac{h}{8}\frac{w}{8} + \frac{h}{16}\frac{w}{16} + ...)$
* `make_anchors(feats, strides, grid_cell_offset)`
* 建構anchor函數,根據傳入的特徵圖大小,在每一個特徵圖上的 pixel 生成一個 anchor ,如果在 `feat[0][0]` 生成的話就會是在特徵點的中心有一個 anchor,`strides` 則是相對於原圖其特徵圖縮小的倍率,通常就是 $(8,\;16,\;32)$ 就是輸出到 `Detect` 的特徵圖相對於原圖的縮放比例
* `self.dlf(x)`
進行 bbox 的積分進而回歸出 bbox 的$(x,\;y,\;w,\;h)$
* $(b,\;4\cdot r_{p},\; hw) \rightarrow (B,\;4,\;r_{p},\; hw)\rightarrow(b,\;r_{p},\;4,\; hw)$
操作順序為 `reshape() -> permute()`
:::success
積分操作藉由 `Conv 1 x 1` 來完成,等價於積分操作
:::
```python!
def _inference(self, x):
x_cat = torch.cat([xi.view(shape[0], self.no, -1) for xi in x], 2)
self.anchors, self.strides = (
x.transpose(0, 1) for x in make_anchors(x, self.stride, 0.5)
)
self.shape = shape
box, cls = x_cat.split((self.reg_max * 4, self.nc), 1)
dbox = self.decode_bboxes(self.dfl(box), self.anchors.unsqueeze(0)) * self.strides
return torch.cat((dbox, cls.sigmoid()), 1)
```
## Deploy using NCNN
### Export
藉由 NCNN 將模型轉換,並且部屬到樹梅派上
1. 將整個模型轉換並在邊緣裝置上執行 NMS
* 直接藉由 ```model.export(format="ncnn", dynamic=True, simplify=True)``` 將模型轉換
2. 將 Decode Bounding Box 操作分離開來,並將其實現在樹梅派上
* 修改原始程式碼
[ultralytics/nn/modules/head.py](https://github.com/ultralytics/ultralytics/blob/main/ultralytics/nn/modules/head.py)
```python
## ultralytics/nn/modules/head.py 58-68 lines
def forward(self, x):
"""Concatenates and returns predicted bounding boxes and class probabilities."""
batch_size = x[0].shape[0]
for i in range(self.nl):
# x[i] = torch.cat((self.cv2[i](x[i]), self.cv3[i](x[i])), 1)
box = self.cv2[i](x[i]).view(batch_size, self.reg_max * 4, -1)
cls = self.cv3[i](x[i]).view(batch_size, self.nc, -1)
x[i] = torch.cat((box, cls), 1)
if self.training: # Training path
return x
# y = self._inference(x)
y = torch.cat(x, dim=2)
return y if self.export else (y, x)
```
在export後會得到4個檔案,我們需要的是`.bin`與`.param`

### Visualization
我們透過 [Netron](https://netron.app/) 工具來協助進行網路視覺化的操作,可以看見圖片左下角的部分為進行 bounding box 回歸預測的部分,我們將這一部份進行抽離,並實現在 C++ 語言上,目的是為了降低模型執行時間,不必把所有 Bounding box 都回歸出來才進行 NMS,我們可以先透過分數過濾後再行 NMS 來加速時間


## Anchor Boxes
預定義的框,用於協助模型學習,讓模型學習的目標是如何調整預定義的框,以去符合標籤所標記的框
### YOLOv5 Anchor Box
* 模型預測偏移量
- $t_x, t_y$ : 中心位置的偏移量
- $t_w, t_h$ : 寬高的縮放係數
* Anchor Box 寬高
- $a_w$ : Anchor box 寬度
- $a_h$ : Anchor box 高度
* Bounding Box
- $b_x = (\sigma(t_x) + c_x)\times s$
- $b_y = (\sigma(t_y) + c_y) \times s$
- $b_w = (a_w \cdot e^{t_w}) \times s$
- $b_h = (a_h \cdot e^{t_h}) \times s$
## YOLOv8 Anchor-Free 轉換公式
YOLOv8 Anchor-Free 模型預測偏移量 $(d_l, d_t, d_r, d_b)$ 和網格中心點 $(x_a, y_a)$,通過縮放因子 $s$ 轉換為最終邊界框座標 $(b_{x_{1}}, b_{y_{1}}, b_{x_{2}}, b_{y_{2}})$
模型輸出參數 :
- $d_l$:左邊偏移量
- $d_t$:上邊偏移量
- $d_r$:右邊偏移量
- $d_b$:下邊偏移量
- $(x_a, y_a)$:特徵網格中心點
- $s$:縮放係數
邊界框計算公式:
1. $b_{x1} = (x_a - d_l) \cdot s$
2. $b_{y1} = (y_a - d_t) \cdot s$
3. $b_{x2} = (x_a + d_r) \cdot s$
4. $b_{y2} = (y_a + d_b) \cdot s$
## Non-Maximum Suppression Brief
由於 YOLOv8 是密集地在一張影像上進行 bbox 的預測,因此需要透過 NMS 來消除分數較低以及重複預測的bbox
1. 先過濾掉分數較低的框
2. 根據分數進行排序
3. 將分數最大當作候選框
4. 與其他的框計算IOU
* 如果IOU大於閥值則排除掉
* 閥值越大則Overlapping有可能比較多,反之毅然
* 重複直到本來bbox set為空為止

## Export Patch
在不更改原始程式碼的情況下,我們可以透過 `export.py` 來將 `forward` 函數進行改寫後,替換掉原生 `Detect` 的類別,這樣我們便可以在匯出模型權重檔案時,將 Bounding Box Decode 部分移除
### export.py
```pyth!
class PatchDetect(nn.Module):
def forward(self, x):
"""Concatenates and returns predicted bounding boxes and class probabilities."""
print("Use Patch Forward!")
if self.end2end:
return self.forward_end2end(x)
for i in range(self.nl):
x[i] = torch.cat((self.cv2[i](x[i]), self.cv3[i](x[i])), 1)
if self.training: # Training path
return x
y = torch.cat([xi.view(x[0].shape[0], self.no, -1) for xi in x], 2)
# y = self._inference(x)
return y # i self.export else (y, x)
# Replace original Package Detect head class
ultralytics.nn.modules.head.Detect = PatchDetect
```
### main.py
```python!
from ultralytics import YOLO
import argparse
def run(args):
if args.patch:
import export_patch
model = YOLO(args.path)
model.export(format="ncnn", dynamic=True, simplify=True)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run YOLO model with optional patch.")
parser.add_argument('--path', type=str, help="Export Model Path")
parser.add_argument('--patch', action='store_true', help='Enable export patch')
args = parser.parse_args()
run(args)
```
Export model時不包含decode box的功能
`python .\export_ncnn.py --path best.pt --patch`
Export model時包含decode box的功能
`python .\export_ncnn.py --path best.pt`