征程6 NV12 理論與程式碼詳解

地平线智能驾驶开发者發表於2024-10-04

1.引言

使用地平線 征程 6 演算法工具鏈進行模型部署時,如果你的模型輸入是影像格式,通常會遇到如下資訊。

對於初學者,可能會存在一些疑問,比如:

  1. nv12 是什麼?
  2. 明明演算法模型是一個輸入,為什麼看 hbm 模型,有 y 和 uv 兩個輸入?
  3. 為什麼 uv 的 valid shape 不是 (1,224,224,2) ,而是(1,112,112,2)
  4. stride 中為什麼有 -1?如果需要自己計算,計算公式是什麼?
  5. 為什麼 aligned byte size 是 -1,而不是一個具體的值?如果需要自己計算,計算公式是什麼?

相信閱讀完本文,可以幫助大家理解上面 5 個問題,下面來一起看一下。

NV12 是一種廣泛應用的影像格式,特別在影片編解碼領域,自動駕駛領域,嵌入式端影像輸入一般都是 NV12,例如英偉達和地平線。

NV12 屬於 YUV 顏色空間中的一種,採用 YUV 4:2:0 的取樣方式。主要特點是將亮度(Y)與色度(UV)資料分開儲存,地平線使用的 NV12,U 和 V 色度分量交替儲存。

在深入理解 NV12 之前,我們首先需要對 YUV 顏色空間有基本的瞭解,YUV 理論介紹參考地平線社群文章:常見影像格式 中的部分章節。

2.YUV

YUV 是一種彩色影像格式,其中 Y 表示亮度(Luminance),用於指定一個畫素的亮度(可以理解為是黑白程度),U 和 V 表示色度(Chrominance 或 Chroma),用於指定畫素的顏色,每個數值都採用 UINT8 表示,如下圖所示。YUV 格式採用亮度-色度分離的方式,也就是說只有 U、V 參與顏色的表示,這一點與 RGB 是不同的。

不難發現,即使沒有 U、V 分量,僅憑 Y 分量我們也能 “識別” 出一幅影像的基本內容,只不過此時呈現的是一張黑白影像。而 U、V 分量為這些基本內容賦予了色彩,黑白影像演變為了彩色影像。這意味著,我們可以在保留 Y 分量資訊的情況下,儘可能地減少 U、V 兩個分量的取樣,以實現最大限度地減少資料量,這對於影片資料的儲存和傳輸是有極大裨益的。這也是為什麼,YUV 相比於 RGB 更適合影片處理領域。

2.1 YUV 常見格式

據研究表明,人眼對亮度資訊比色彩資訊更加敏感。YUV 下采樣就是根據人眼的特點,將人眼相對不敏感的色彩資訊進行壓縮取樣,得到相對小的檔案進行播放和傳輸。根據 Y 和 UV 的佔比,常用的 YUV 格式有:YUV444,YUV422,YUV420 三種。

用三個圖來直觀地表示不同採集方式下 Y 和 UV 的佔比。

YUV444:每一個 Y 分量對應一對 UV 分量,每畫素佔用 3 位元組(Y + U + V = 8 + 8 + 8 = 24bits);

YUV422:每兩個 Y 分量共用一對 UV 分量,每畫素佔用 2 位元組(Y + 0.5U + 0.5V = 8 + 4 + 4 = 16bits);

YUV420:每四個 Y 分量共用一對 UV 分量,每畫素佔用 1.5 位元組(Y + 0.25U + 0.25V = 8 + 2 + 2 = 12bits);

此時來理解 YUV4xx 中的 4,這個 4,實際上表達了最大的共享單位!也就是最多 4 個 Y 共享一對 UV。

2.2 YUV420 詳解

在 YUV420 中,一個畫素點對應一個 Y,一個 4X4 的小方塊對應一個 U 和 V,每個畫素佔用 1.5 個位元組。依據不同的 UV 分量排列方式,還可以將 YUV420 分為 YUV420P 和 YUV420SP 兩種格式。

YUV420P 是先把 U 存放完,再存放 V,排列方式如下圖:

YUV420SP 是 UV、UV 交替存放的,排列方式如下圖:

此時 ,相信大家就可以理解 YUV420 資料在記憶體中的長度應該是:width * height * 3 / 2 。

3.NV12 程式碼示例

地平線使用的 NV12 影像格式屬於 YUV 顏色空間中的 YUV420SP 格式,每四個 Y 分量共用一組 U 分量和 V 分量,Y 連續存放,U 與 V 交叉存放,下面介紹兩種常見庫將影像轉為 nv12 的程式碼。

3.1 PIL 將影像轉為 nv12

import sys
import numpy as np
from PIL import Image

def generate_nv12(input_path, output_path='./'):
    img = Image.open(input_path)
    w,h = img.size
    # 將圖片轉換為YUV格式
    yuv_img = img.convert('YCbCr')
    y_data, u_data, v_data = yuv_img.split()
    
    # 將Y、U、V通道資料轉換為位元組流
    y_data_bytes = y_data.tobytes()
    u_data_bytes = u_data.resize((u_data.width // 2, u_data.height // 2)).tobytes()
    v_data_bytes = v_data.resize((v_data.width // 2, v_data.height // 2)).tobytes()
    
    # 將UV資料按UVUVUV...的形式排列
    uvuvuv_data = bytearray()
    for u_byte, v_byte in zip(u_data_bytes, v_data_bytes):
        uvuvuv_data.extend([u_byte, v_byte])
    
    # y data
    y_path = output_path + "_y.bin"
    with open(y_path, 'wb') as f:
        f.write(y_data_bytes)
   
    # uv data
    uv_path = output_path + "_uv.bin"
    with open(uv_path, 'wb') as f:
        f.write(uvuvuv_data)
   
    nv12_data = y_data_bytes + uvuvuv_data
    # 儲存為NV12格式檔案
    nv12_path = output_path + "_nv12.bin"
    with open(nv12_path, 'wb') as f:
        f.write(nv12_data)
   
    # 用於bc模型的輸入
    y = np.frombuffer(y_data_bytes, dtype=np.uint8).reshape(1, h, w, 1).astype(np.uint8)
    uv = np.frombuffer(uvuvuv_data, dtype=np.uint8).reshape(1, h//2, w//2, 2).astype(np.uint8)
    return y, uv

if _name_ == "__main__":
    if len(sys.argv) < 3:
        print("Usage: python resize_image.py <input_path> <output_path>")
        sys.exit(1)

    input_path = sys.argv[1]
    output_path = sys.argv[2]

    y, uv = generate_nv12(input_path, output_path)

3.2 cv2 將影像轉為 nv12

import cv2
import numpy as np

def image2nv12(image):
    image = image.astype(np.uint8)
    height, width = image.shape[0], image.shape[1]
    yuv420p = cv2.cvtColor(image, cv2.COLOR_BGR2YUV_I420).reshape((height * width * 3 // 2, ))
    y = yuv420p[:height * width]
    uv_planar = yuv420p[height * width:].reshape((2, height * width // 4))
    uv_packed = uv_planar.transpose((1, 0)).reshape((height * width // 2, ))
    nv12 = np.zeros_like(yuv420p)
    nv12[:height * width] = y         # y分量
    nv12[height * width:] = uv_packed # uv分量,uvuv交替儲存,征程6拆開就是這種
    # return y, uv_packed        # 分開返回
    return nv12                  # 合在一起返回nv12,看大家需要

image = cv2.imread("./image.jpg")
nv12 = image2nv12(image)

閱讀到這兒,相信前 3 個疑問,已經介紹清楚了,下面再來看剩下 2 個問題。

4.對齊規則

對於 NV12 輸入,地平線 BPU 要求模型輸入 HW 都是偶數,主要是為了滿足 UV 是 Y 的一半的要求。

有效資料排布和對齊資料排布用 validShape 和 stride 表示。

  • validShape 是有效資料的 shape。
  • stride 表示 validShape 各維度的步長,描述跨越張量各個維度所需要經過的位元組數。當資料型別為 NV12(Y、UV)時比較特殊,只要求 W 方向 32 對齊。

BPU 對模型輸入輸出記憶體首地址有對齊限制,要求輸入與輸出記憶體的首地址 32 對齊。

使用 hbUCPMalloc 與 hbUCPMallocCached 介面申請的記憶體首地址預設 32 對齊。 當使用者申請一塊記憶體,並使用偏移地址作為模型的輸入或輸出時,請檢查偏移後的首地址是否 32 對齊。

完了,沒看懂,什麼有效資料?步長?W 方向 32 對齊?首地址 32 對齊?沒看懂?舉個例子:

為了直觀展示,假設對齊前的 NV12 有效資料的 shape:H=4,W=8,步長 Stride=12,Y 分量和 UV 分量分別儲存在兩塊不同的記憶體空間中,記憶體首地址分別用 mem[0]和 mem[1]表示,Y 分量佔用 412=48 位元組,UV 分量共佔用 212=24 位元組。

相信到這兒,你懂了。

5.動態輸入 -1 介紹

當模型輸入張量屬性 stride 中含有 -1 時,代表該模型的輸入是動態的,需要根據實際輸入對動態維度進行填寫。此時需要大家想起來:

  • W 方向保證 32 對齊。
  • stride[idx] >= stride[idx+1] ∗ validShape.dimensionSize[idx+1],其中 idx 代表當前維度。

舉個例子,如文章最上方的截圖:

  • input_y : validShape = [1,224,224,1], stride = [-1,-1,1,1]
  • input_uv : validShape = [1,112,112,2], stride = [-1,-1,2,1]

stride 計算如下所示,保證動態維度 32 對齊,其中 ALIGN_32 代表 32 位元組對齊:

input_y :

  • stride[3] = 1,結合 tensor type 看,每個元素 8bit 也就是 1byte 大小;
  • stride[2] = 1;
  • stride[1] = ALIGN_32(stride[2] * validShape.dimensionSize[2]) = ALIGN_32(1 * 224) = 224;
  • stride[0] = ALIGN_32(stride[1] * validShape.dimensionSize[1]) = ALIGN_32(224 * 224) = 50176;

input_uv :

  • stride[3] = 1,結合 tensor type 看,每個元素 8bit 也就是 1byte 大小;
  • stride[2] = 2;
  • stride[1] = ALIGN_32(stride[2] * validShape.dimensionSize[2]) = ALIGN_32(2 * 112) = 224;
  • stride[0] = ALIGN_32(stride[1] * validShape.dimensionSize[1]) = ALIGN_32(224 * 112) = 25088;

在準備輸入時,就需要按照上面的 stride 和 validshape 準備資料了。

但此時,無法解釋為什麼 nv12 輸入時,這裡的 stride 為什麼必須是 -1,畢竟可以透過公式計算得到啊,為什麼工具不計算好直接提供出來呢?別問,問就是還沒理解透徹,這是甲魚的臀部——“規定”。

看到這兒,第 4 個問題也解決了。

6.aligned byte size 如何計算

NV12 輸入時,alignedByteSize = stride[0]。

在別的輸入格式時,可能會遇到 alignedByteSize > stride[0] 的情況,這就是另外的故事了,下次再聊~

相關文章