在門禁機上,單靠一顆普通 RGB 鏡頭做人臉辨識,基本上就是把大門敞開給彩色列印照片、iPad 螢幕和 3D 面具隨便進。為了過 BCTC(銀行卡檢測中心)金融級支付認證,我們在 MSM8937 這塊破板子上,同時掛了 RGB 相機與 3D ToF 深度感測器(吐出 IR 灰階圖與 16-bit 點雲)。
鏡頭掛上去很簡單,但軟體的災難才剛開始:你怎麼保證送進 AI 模型的彩色人臉跟深度人臉,是同一瞬間的同一個人?
- RGB 相機:每秒 30 幀,跑得順暢,負責給人看預覽畫面和抓人臉位置。
- ToF 深度相機:要解算 16-bit 原始點雲,幀率較低且負擔重。
這會造成致命的 時空錯位
當有人邊走邊刷臉時,RGB 鏡頭抓到他在左邊(第 10 毫秒),ToF 深度鏡頭抓到的深度圖卻還停在右邊(第 25 毫秒)。兩張圖一疊合,RGB 人臉跟 3D 深度輪廓直接對不上——AI 活體模型一看深度資料凹凸不平且位置錯亂,直接判斷成攻擊,刷臉當場失敗。
我們必須在軟體層把這兩路影像強行鎖在同一個時間點。
軟體解法:RGB 當火車頭,ToF 隨叫隨到
我們沒有讓兩顆鏡頭各自亂跳 Callback,而是設計了一套以 RGB 為基準的「非阻塞抽幀(0ms)」與「丟舊保新(Drop-Oldest)」機制。
1. RGB 當 Master 時鐘,非阻塞拉取 ToF
在 CameraInstanceMK1.java 中,我們讓幀率穩定的 RGB 鏡頭當火車頭。
每當 RGB 吐出一幀畫面,我們不是傻傻等待 ToF 慢慢算完,而是直接以 0ms 逾時去 ToF 的緩衝區抓「當前最新」的一張深度幀:
// CameraInstanceMK1.java
mRgbCamera.setFrameCallback(
yuv -> {
if (yuv != null) {
System.arraycopy(yuv, 0, mYuvBuffer, 0, yuv.length);
// 關鍵點:RGB 到達瞬間,立刻去 ToF 隊列拉取最新深度資料(0ms 逾時非阻塞)
if (tofRaw == null || tofRaw.length != getTofRawFrameSize()) {
tofRaw = new byte[getTofRawFrameSize()];
}
mTof.getData(0, tofRaw);
// 將對齊好的 RGB (mYuvBuffer) 與 ToF (tofRaw) 打包丟進 Pipeline
mCameraFrameCallback.onFrameAvailable(mYuvBuffer, tofRaw, null);
}
}
);
2. ToF 的固定佇列與 Drop-Oldest 策略
ToF 的資料產生在背景執行緒跑。在 CameraTofHelpler.java 裡,我們維護了一個固定長度的 ArrayBlockingQueue。
如果 ToF 產生的速度比 RGB 拿取的速度快,或者隊列塞滿了,我們會主動丟棄最舊的那一幀,確保隊列最前端永遠是「距離現在最近」的資料:
// CameraTofHelpler.java
private ArrayBlockingQueue<byte[]> mRawQueue = new ArrayBlockingQueue<>(RAW_QUEUE_MAX_SIZE);
private ArrayBlockingQueue<byte[]> mFreeRawQueue = ...;
// 當 ToF 底層吐資料時
synchronized (CameraTofHelpler.this) {
if (mRawQueueSize >= RAW_QUEUE_MAX_SIZE) {
// 隊列滿了代表 RGB 還沒來拿,主動丟棄最舊的一幀(Drop-Oldest)
buffer = mRawQueue.poll(0, TimeUnit.MILLISECONDS);
}
mRawQueue.offer(buffer);
}
// 供 RGB 火車頭調用的非阻塞提取函數
public void getData(int timeoutMS, byte[] buffer) {
synchronized (CameraTofHelpler.this) {
byte[] tmp = mRawQueue.poll(timeoutMS, TimeUnit.MILLISECONDS);
if (tmp != null) {
System.arraycopy(tmp, 0, buffer, 0, buffer.length);
offerData(tmp); // 記憶體池回收重用 (0-GC)
}
}
}
透過這套機制,送進後面 Pipeline 的每一包 CameraData,RGB 與 ToF 都是在毫秒級誤差內組裝完成的。
進 Pipeline:ToF 點雲轉 IR/Depth 圖像
拿到湊好包的 CameraData 之後,第一步是在 TofRawToIrDepth 中將 ToF 吐出來的 Raw 資料轉成 OpenCV Mat:
package com.iam3d.sdk.service.detect.steps;
public class TofRawToIrDepth implements Step<CameraData, DetectInfo> {
@Override
public boolean process(PipelineContext<CameraData, DetectInfo> context) {
CameraData imageData = context.getInput();
DetectInfo detectInfo = context.getOutput();
if (imageData.getIrFrame() != null) {
// 用 OpenCV Mat 包裝 IR Raw Frame(單通道 8-bit)
Mat depMat = new Mat(
CameraProxy.getInstance().getIrFrameHeight(),
CameraProxy.getInstance().getIrFrameWidth(),
CvType.CV_8U
);
depMat.put(0, 0, imageData.getIrFrame());
// Debug 模式下可落盤驗證 IR 圖片
if (DebugFlags.saveIrCameraImages) {
MatOfByte imagetBytes = new MatOfByte();
Imgcodecs.imencode(".png", depMat, imagetBytes);
String path = Environment.getExternalStorageDirectory().getAbsolutePath()
+ "/rawir" + imageData.id() + ".png";
FileUtils.write(new File(path), imagetBytes.toArray());
}
}
return true;
}
}
原本在 Java 裡解析 4 通道點雲(640×480×4 = 122 萬次迭代)需要 18 毫秒,我們把這段座標轉換下沉到 C++ 用 NEON 指令集一次抽 4 個 float,耗時直接縮短到 2 毫秒以下。
3D 空間人臉對齊:抓鼻尖深度與提早短路
有了深度矩陣,接下來要從 3D 空間中把人臉「挖」出來。
在 AlignDepthFace 步驟中,我們透過 getNoseDepth 計算人臉離鏡頭最近的幾何特徵點——鼻尖深度:
package com.iam3d.sdk.service.detect.steps;
public class AlignDepthFace implements Step<CameraData, DetectInfo> {
@Override
public boolean process(PipelineContext<CameraData, DetectInfo> context) {
CameraData cameraData = context.getInput();
DetectInfo detectInfo = context.getOutput();
int mDepthFrameWidth = CameraProxy.getInstance().getDepthFrameWidth();
int mDepthFrameHeight = CameraProxy.getInstance().getDepthFrameHeight();
int xDepthCropSize = CameraProxy.getInstance().getWidthSizeToCropDepth();
int yDepthCropSize = CameraProxy.getInstance().getHeightSizeToCropDepth();
// 1. 檢查臉部 BBox 是否在畫面邊界內
if (!isBBoxInRange(detectInfo.faceDepthPos, mDepthFrameWidth, mDepthFrameHeight, xDepthCropSize, yDepthCropSize)) {
return false;
}
// 2. 計算鼻尖深度值(毫米)
int noseDepth = getNoseDepth(
cameraData.getShortDepthData(), mDepthFrameWidth, mDepthFrameHeight,
detectInfo.faceDepthPos, xDepthCropSize, yDepthCropSize
);
// 關鍵優化:距離不合法直接短路中斷,絕不浪費 CPU 跑後續模型
if (noseDepth < 0) {
return false;
}
detectInfo.setDepthFaceDistance(noseDepth);
// 3. 深度歸一化並裁切出 3D 人臉送進 Align 模型
Bitmap normalizedDepth = normalizeTofDepth(
cameraData.getShortDepthData(), mDepthFrameWidth, mDepthFrameHeight,
30, 90, noseDepth, xDepthCropSize, yDepthCropSize
);
alignInput.setBitmap(normalizedDepth);
alignInput.setFacePos(detectInfo.faceDepthPos);
inference.setInput(alignInput);
inference.setOutput(alignOutput);
Face3DAIModel.start().use(Model.FACE_ALIGN_MODEL).infer(inference);
detectInfo.alignDepthSuccess = alignOutput.isSuccess();
detectInfo.alignedDepthFace = alignOutput.getAlignedBitmap();
return detectInfo.alignDepthSuccess;
}
}
如果人在有效辨識範圍外(例如太遠或太近),noseDepth 回傳負值,整條 Pipeline 在這裡直接短路中斷,把珍貴的算力留給下一幀。
雙模態 IR + Depth 雙流活體防偽
到了最後一步,我們手上有了兩張對齊好的人臉:
- IR 人臉圖:看皮膚紅外反射特徵(辨識紙張、螢幕翻拍)。
- Depth 人臉圖:看五官 3D 凹凸幾何特徵(辨識平面照片、矽膠面具)。
在 CheckAlignedDepthLiveness 裡,我們發動雙 TFLite 模型並行推理,最後採用嚴格的 AND 判定:
package com.iam3d.sdk.service.detect.steps;
public class CheckAlignedDepthLiveness implements Step<CameraData, DetectInfo> {
private LivenssOutput irOutput = new LivenssOutput();
private Inference<Bitmap, LivenssOutput> irInference = new Inference<>();
private LivenssOutput depthOutput = new LivenssOutput();
private Inference<Bitmap, LivenssOutput> depthInference = new Inference<>();
@Override
public boolean process(PipelineContext<CameraData, DetectInfo> context) {
DetectInfo detectInfo = context.getOutput();
if (!detectInfo.alignDepthSuccess) return false;
// 1. IR 紅外活體推理
irInference.setInput(detectInfo.alignedIRFace);
irInference.setOutput(irOutput);
Face3DAIModel.start().use(Model.IR_LIVENESS_MODEL).infer(irInference);
// 2. 3D Depth 深度活體推理
depthInference.setInput(detectInfo.alignedDepthFace);
depthInference.setOutput(depthOutput);
Face3DAIModel.start().use(Model.DEPTH_LIVENESS_MODEL).infer(depthInference);
// 3. 雙防線 AND 判定:兩者都過才算真活人
detectInfo.liveness = (irInference.getOutput().isPass() && depthInference.getOutput().isPass());
// 4. 重置輸出容器供下一幀重用 (0-GC)
irOutput.reset();
depthOutput.reset();
return detectInfo.liveness;
}
}
這兩道防線是互補的:
- 拿一張高解析度彩色列印照片:IR 活體模型可能被特殊油墨騙過,但 3D 深度模型一測發現整張臉在同一個平面上,直接被 Depth 攔截。
- 拿一個 3D 列印的立體假面具:Depth 模型看到了凹凸鼻樑,但 IR 模型一測發現材質反射光譜根本不是活體皮膚,直接被 IR 攔截。
這就是 MK1 能通過 BCTC 金融級防偽認證的核心邏輯。
在 2GB RAM 的破裝置上搞雙鏡頭,最難的往往不是模型本身,而是怎麼在軟體層面把兩路不同幀率、不同格式的相機流伺候好:
- RGB 火車頭 + 0ms 非阻塞拉取:用穩定幀率帶動慢速幀,避免 UI 卡頓。
- 固定長度隊列 + Drop-Oldest:寧可丟掉過期影格,也絕不讓時間錯位的髒資料進 AI。
- 及早短路與 0-GC:無效距離直接中斷,容器重複利用。
把這套資料流打通,多鏡頭才能真正成為防偽的武器,而不是拖垮系統的包袱。
參考資料 (References)
- BCTC 金融人臉識別技術規範: 中國金融移動支付人臉識別技術安全規範 (
JR/T 0166-2018). - Android Camera2 API Reference: Multi-Camera synchronization & ImageReader buffer management.
https://developer.android.com/reference/android/hardware/camera2/package-summary - OpenCV Mat & Color Space Specifications: OpenCV Matrix manipulation and memory layout.
https://docs.opencv.org

發佈留言