前幾篇我們在跟 GC 搏鬥、跟記憶體算帳、跟 CPU 的指令週期斤斤計較。
今天換個戰場:在資料進 AI 之前,YUV_420_888 to RGB 是明顯可以拼看看加速的部分。
在我們的破板子上,它吃掉了將近一幀的預算。直到我花了點時間去理解為什麼相機要吐那種格式的資料,才知道這不是 Android 的鍋,
人眼對顏色是瞎子,所以 YUV 就這樣誕生了
YUV_420_888 這個格式,根源在於人眼的生理缺陷。
人眼感知亮度的細胞有 1.2 億個,感知顏色的只有 600 萬個。也就是說,你對「亮不亮」極度敏感,但對「什麼顏色」其實沒那麼在意。
YUV 就是靠這個設計的:
- Y(亮度):每個 pixel 都存,一個都不能少。
- U、V(色度):人眼感覺不太出來,那 2×22×2 的四個 pixel 共用一份就夠了。
這樣同樣 4 個 pixel,RGB 要 12 bytes,YUV 420 只要 6 bytes。省一半,人眼幾乎看不出差異。 這就是為什麼所有相機、視訊編碼都在用這個格式——它在畫質和頻寬之間找到了一個利用人體缺陷的甜蜜點。
UV 為什麼是交錯存的?(Semi-Planar 的由來)
你從相機拿到的 YUV 資料,UV 的部分長這樣:
[YYYYYYY...][U0 V0 U1 V1 U2 V2 ...]
U 和 V 交錯在一起,而不是分開存。這叫 Semi-Planar(NV21)。
為什麼不乾脆把 U 和 V 分開?(也就是 Planar 格式)
因為硬體工程師要省錢。
相機裡的 ISP 有一個 DMA 引擎負責把像素搬到記憶體,運作方式就像掃描機,從第 0 行掃到最後一行。掃完每兩行 Y,對應的 U 和 V 剛好也準備好了。
- 選 Semi-Planar:U 和 V 準備好了就直接交錯塞,邏輯超簡單,電路便宜。
- 選 Planar:要先把 U 和 V 暫存起來,等 Y 全部寫完再分批寫,需要額外 Buffer 和複雜排程,電路面積貴很多。
幾十年前的工程師在矽晶片成本的壓力下,選了最便宜能動的方案,然後這個選擇就這樣沿用至今。
為什麼還有多餘的垃圾 byte?(Row Stride 的物理根源)
你的 RGB 相機解析度是 800 × 600。但實際上每行在記憶體裡佔了 832 bytes,不是 800。多出來的 32 bytes 是垃圾。
這叫 Row Stride,是記憶體對齊造成的。
CPU 跟記憶體之間的匯流排一次搬 64 bytes,而且要求每次搬的起始位址必須是 64 的倍數。800 不是 64 的倍數,所以硬體把每行補到 832(剛好整除),確保每行的起頭永遠對齊。這讓 DMA 效率最高,代價是每行末尾留了一堆垃圾 byte 讓你自己清。
不同廠商的 ISP 對齊的倍數不同(16、32、64 都有),所以不同相機的 Stride 不一樣,你沒辦法事先預測,只能每幀問 getRowStride() 看看今天有沒有 Padding。
所以在送進 AI 之前,你得做兩件事
你收到的資料長這樣:
行 0: [800 bytes 有效 Y] [32 bytes 垃圾]
行 1: [800 bytes 有效 Y] [32 bytes 垃圾]
…(共 600 行)
UV: [U0 V0 U1 V1 U2 V2 …](交錯放在一起)
- 去 Padding:把每行末尾的垃圾挑掉。
- 拆 UV:把交錯的 UVUV 拆成分開的 UUU… 和 VVV…(Semi-Planar → Planar)。
原本怎麼做的:純 Java 苦工
換 FastCV 之前,備用路徑是這樣的:
// 有 Padding 就進這個地獄
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×2=24 萬次400×300×2=24 萬次陣列存取。Java 虛擬機每次 get() 都偷偷做邊界檢查,等於 CPU 被強迫做了 24 萬次無意義的安全確認。
代價:一張圖 5~15 毫秒。
換成 QFastCV:一行搞定
// Y 和 UV 先搬到預分配的工作台(只 new 一次,每幀重複用)
byteBuffer0.get(bufferY, 0, yuvPlane0Size);
byteBuffer1.get(bufferU, 0, yuvPlane1Size);
// 🚀 去 Padding + 拆 UV,一起搞定
result = QFastCV.yuv420SPToYuv420P(
bufferY, bufferU,
CAM_RGB_WIDTH, CAM_RGB_HEIGHT, // 800 × 600
image.getPlanes()[0].getRowStride(), // 有多少 Padding 自己看著辦
image.getPlanes()[1].getRowStride(),
dstY, dstU, dstV,
0, 0, 0
);
代價:< 1 毫秒。
它憑什麼這麼快?
① 沒有 JVM 邊界檢查:底層 C/C++ 直接操作記憶體指標,那 24 萬次安全確認直接歸零。
② VLD2 硬體指令:ARM NEON 有一個專門為拆交錯資料設計的指令叫 VLD2。一個指令從記憶體讀 16 bytes 的 UVUVUV...,硬體電路在一個時脈週期內直接拆成 8 個 U 和 8 個 V。Java 要跑 16 次迴圈,NEON 用一個指令收工。
③ Hexagon DSP:高通晶片裡有一顆專門處理「大量搬運 + 簡單變換」的數位訊號處理器。FastCV 是高通原廠寫的,可以直接叫它出來幹活,CPU 幾乎不用動。
三路相機,誰受益?
這套邏輯只對 RGB 相機有用,因為只有它是 YUV 格式:
| 相機 | 解析度 | 格式 | FastCV 有幫到 |
|---|---|---|---|
| RGB | 800×600 | YUV_420_888 | 受益 |
| TOF | 640×1920 | RAW_SENSOR(直接整包搬) | 無關 |
| IR | 480×640 | 灰階(從 TOF RAW 解析出來) | 無關 |
TOF 是深度原始資料,沒有顏色,沒有 UV,連 Padding 問題都沒有,一行 byteBuffer.get(buffer) 整包搬走就完事了。IR 是灰階,每個 pixel 只有 1 byte,同樣沒有這些破事。
結語
YUV_420_888、Semi-Planar、Row Stride——這三個東西追根究底,都是幾十年前的硬體工程師在有限的矽晶片面積和記憶體頻寬壓力下,做出的最務實選擇。
你不理解這些,就看不懂為什麼一個「格式轉換」能吃掉 15ms。搞懂整條因果鏈之後,才知道該在哪裡下刀——把 Java 苦工換成 FastCV,讓高通原廠的 DSP 用一個硬體指令做掉 Java 要跑 16 次迴圈的事。
在爛裝置上,沒有不重要的環節。每個你以為「應該很快」的地方,都有可能藏著一把刀。
發佈留言