前幾篇我們在跟 GC 搏鬥、跟記憶體算帳、跟 CPU 的指令週期斤斤計較。
今天換個戰場:在影像資料送進 AI 模型之前,將相機吐出來的 YUV_420_888 轉換為模型所需的格式,是整個流水線中最容易被忽視、卻能拼出極大加速空間的關鍵環節。
在我們這台 1.3GHz 弱 CPU 的破板子上,光是做一次 YUV 格式轉換與去 Padding,純 Java 寫法就吃掉了將近 15ms(相當於半幀的預算)。直到我深入研究相機硬體為什麼要吐出這種怪異格式,才發現這不是 Android 的鍋,而是幾十年前矽晶片設計師留下的歷史業障。
人眼對顏色是瞎子:YUV420 的生理學甜蜜點
YUV_420_888 這個格式的誕生,根源在於人類眼睛的生理缺陷。
人眼中負責感知亮度的視桿細胞有 1.2 億個,而負責感知顏色的視錐細胞只有 600 萬個。也就是說:人類大腦對「明暗邊界」極度敏感,但對「色彩細節」其實是個半瞎子。
工程師抓住這個生理缺陷設計了 YUV:
- Y(Luma 亮度):每個像素都完整保留,一個都不能少。
- U、V(Chroma 色度):人眼看不出細節,那就讓 2×2 的四個像素共用一組 U 和 V 即可。
同樣是 4 個像素:
- RGB 格式需要 4 × 3 = 12 bytes。
- YUV420 格式只需要 4 (Y) + 1 (U) + 1 (V) = 6 bytes。
記憶體頻寬與儲存空間直接砍半(省下 50%),而人眼幾乎看不出畫質差別。這就是為什麼全球所有相機感測器與視訊編解碼器,全部預設使用 YUV420。
UV 為什麼是交錯存的?(Semi-Planar NV21 的硬體歷史)
當你從 Android Camera2 取得 YUV 資料時,UV 記憶體佈局長成這樣:
Y 平面: [Y0 Y1 Y2 Y3 Y4 Y5 ...]
UV 平面: [U0 V0 U1 V1 U2 V2 ...] <-- U 和 V 交錯擠在一起
U 和 V 交錯存放在同一個 Buffer 中,這叫 Semi-Planar(NV21 / NV12)。
為什麼不乾脆把 U 和 V 完全分開存放(即 Planar 格式,如 I420)?因為相機 ISP 硬體工程師要省成本。
相機內部的 ISP(影像訊號處理器)透過 DMA 引擎將像素搬移到記憶體,其運作方式像掃描機一樣從第 0 行逐行掃到最後一行。當 ISP 掃完每兩行 Y 時,對應的一組 U 和 V 剛好計算完成:
- 選 Semi-Planar:U 和 V 算出來就直接交錯寫入記憶體,電路設計最簡單、晶片面積最小、成本最低。
- 選 Planar:必須在晶片內增加額外的 SRAM 快取,把 U 全部暫存起來,等整張圖的 Y 寫完後再分批寫入 U 與 V。這需要更複雜的硬體排程器,晶片成本直線上升。
幾十年前工程師在矽晶片成本壓力下的妥協,就這樣變成了我們今天必須面對的標準。
為什麼每行末尾有多餘的垃圾?(Row Stride 的 64-byte 對齊)
在我們的門禁機中,當 RGB 相機切換到 800×600 解析度時(見 CameraRgbHelpler.java),你會發現一個詭異現象:每行有效像素明明只有 800 bytes,但底層 Buffer 的每行跨度(Row Stride)卻是 832 bytes。
多出來的 32 bytes 全是無意義的 Padding 垃圾。
行 0: [800 bytes 有效 Y 數據] [32 bytes 垃圾 Padding]
行 1: [800 bytes 有效 Y 數據] [32 bytes 垃圾 Padding]
... 共 600 行
這是因為高通 SoC 的 CPU 與記憶體匯流排(System Bus)以 64 bytes 為一個傳輸區塊(Burst Transfer),且要求每次 DMA 傳輸的起始位址必須是 64 的整數倍。
800 不是 64 的倍數(800 / 64 = 12.5)。為了讓 DMA 傳輸效率達到硬體最高峰值,ISP 會自動在每行末尾補齊 32 bytes 垃圾,湊成 832(832 / 64 = 13,完美整除)。
硬體爽了,代價是軟體工程師必須在資料餵進 AI 模型前,手動把每行末尾的 32 bytes 垃圾挑出來扔掉。
原本怎麼做的:純 Java 苦工(24 萬次邊界檢查)
在導入硬體加速前,我們在 CameraRgbHelpler.java 中的備用路徑寫了標準的 Java 轉換迴圈:
// CameraRgbHelpler.java
private void yuv420888toNV21(Image image, byte[] nv21) {
// ... 處理 Y 平面 Padding ...
// 處理 UV 交錯提取
for (int row = 0; row < height / 2; row++) {
for (int col = 0; col < width / 2; col++) {
int vuPos = col * pixelStride + row * rowStride;
nv21[pos++] = vBuffer.get(vuPos); // 撿一個 V
nv21[pos++] = uBuffer.get(vuPos); // 撿一個 U
}
}
}
在 800×600 解析度下,UV 平面的尺寸是 400×300:
- 雙重迴圈總共要執行 400 × 300 = 120,000 次。
- 總計執行 24 萬次陣列存取與
ByteBuffer.get()。
Java 虛擬機在每次呼叫 get() 時,底層都會強制做一次記憶體邊界檢查(Bounds Checking)。在 1.3GHz 的弱 CPU 上,這 24 萬次安全確認直接吃掉了 5 ~ 15ms!在 30fps(每幀僅 33.3ms)的流水線中,光格式轉換就吃掉將近一半的算力預算。
換成 QFastCV:一行搞定(< 1ms)
為了解決這個瓶頸,我們在相機流水線中掛載高通原生 QFastCV 庫:
// CameraRgbHelpler.java
if (true == useFastCv) {
// 將 Y 與 UV 拷貝到預先分配好的鐵碗 Buffer
byteBuffer0.get(bufferY, 0, yuvPlane0Size);
byteBuffer1.get(bufferU, 0, yuvPlane1Size);
// 調用高通 Native 函式庫:去 Padding + 拆 UV 一步到位
result = QFastCV.yuv420SPToYuv420P(
bufferY, bufferU,
CAM_RGB_WIDTH, CAM_RGB_HEIGHT,
image.getPlanes()[0].getRowStride(), // 自動處理 Row Stride Padding
image.getPlanes()[1].getRowStride(),
dstY, dstU, dstV,
0, 0, 0
);
}
轉換耗時直接從原本的 15ms 暴跌到 < 1ms。
QFastCV 憑什麼這麼快?(ARM NEON 向量指令解密)
為什麼 C++ 呼叫 QFastCV 能產生超過 15 倍的效能差距?核心秘密在於 ARM NEON SIMD(單指令多資料流)的專屬硬體指令:
- 零 JVM 邊界檢查:
C++ 直接操作記憶體原生指標,那 24 萬次 Java 虛擬機的安全邊界檢查直接歸零。 - ARM NEON
VLD2專屬向量指令:
ARM 架構專門為拆解交錯資料設計了VLD2(Vector Load 2-element structure) 指令。
傳統 CPU 讀取U0 V0 U1 V1 ...需要進行 16 次讀取與位移操作;而 NEON 指令vld2.8 {d0, d1}, [r0]!可以在 1 個時脈週期內,直接將記憶體中連續的 16 bytes 拆解成獨立的 8 個 U 暫存器(d0)與 8 個 V 暫存器(d1)。Java 要跑 16 次迴圈的事,NEON 靠硬體電路一個指令搞定。 - 記憶體連續寫入(Cache Line Friendly):
拆解後的 U 與 V 透過VST1向量指令連續寫入獨立的 Planar 緩衝區,完美契合 CPU L1/L2 快取行,最大化記憶體寫入吞吐量。
三路相機資料流,誰真正受益?
這套 QFastCV 加速只對 RGB 相機生效,因為只有它牽涉到複雜的色彩空間轉換:
- RGB 鏡頭(800×600 / 640×480 YUV_420_888):
最大受益者。徹底解決 Semi-Planar 拆分與 Row Stride 去 Padding 的計算瓶頸,單幀耗時節省 14ms。 - Sony IMX516 ToF 深度鏡頭(640×1920 RAW12):
無關。ToF 輸出的是純 3D 深度原始 Raw 訊號,沒有顏色與 UV 分量,直接以byteBuffer.get(buffer)整包搬入記憶體。 - IR 紅外鏡頭(640×480 灰階):
無關。IR 影像是由 ToF Raw 訊號經RawToDepthSDK解算出的單通道 8-bit 灰階圖,每個像素僅佔 1 byte,完全沒有 UV 交錯問題。
結語
YUV_420_888、Semi-Planar、Row Stride——這三個概念追根究底,都是幾十年前晶片設計師在有限的矽晶圓面積與記憶體頻寬壓力下,做出的最務實硬體妥協。
你不理解底層硬體原理,就看不懂為什麼一個看似簡單的「格式轉換」能吃掉 15ms。搞懂整條因果鏈之後,才能精準下刀——用高通 QFastCV 與 ARM NEON 向量指令,在 1 個時脈週期內做掉 Java 要跑 16 次迴圈的事。
在低配邊緣設備上,每一個你以為「應該很快」的細節,都可能藏著拖垮整個系統的暗箭。
參考資料 (References)
- ARM NEON Technology: Vector Load and Store Instructions (VLD2/VST2).
https://developer.arm.com/architectures/instruction-sets/simd-isas/neon - Qualcomm FastCV Computer Vision SDK: Developer Guide and API Reference.
https://developer.qualcomm.com/software/fastcv-sdk - Android Camera2 YUV_420_888 Image Format Specification.
https://developer.android.com/reference/android/graphics/ImageFormat#YUV_420_888

發佈留言