Unity 双屏拍照系统:摄像头采集 + OpenCV DNN 人脸检测 + 自动倒计时抓拍

前言
最近在做的一个 Unity 留言墙项目,需要一个双屏拍照功能 —— 操作屏负责交互和引导,大屏负责展示。用户站在摄像头前,系统自动检测到人脸后开始倒计时,倒计时结束自动抓拍。

核心需求拆解:

WebCamTexture 摄像头画面采集
OpenCvSharp DNN(Caffe SSD)人脸检测,Haar 级联作为降级方案
人脸确认后 5 秒倒计时自动拍照
双屏同步:操作屏 + 大屏通过事件总线联动
重拍、确认、页面跳转完整流程
下面按模块梳理实现。

一、整体架构

┌──────────────────────┐ EventBus ┌───────────────────────┐
│ PhotoOperationPage │ ◄──────────────► │ PhotoLargeScreenPage │
│ (操作触屏) │ PhotoCaptured │ (大屏展示) │
│ │ PhotoRetake │ │
│ - 摄像头预览 │ │ - 摄像头预览 │
│ - 人脸检测 │ │ - 倒计时数字 │
│ - 倒计时/自动抓拍 │ │ - 抓拍结果展示 │
│ - 确认/重拍按钮 │ │ │
└──────────┬───────────┘ └───────────────────────┘
│
▼
┌──────────────┐ ┌─────────────────┐
│ SharedState │ │ FaceDetector │
│ - WebCam │ │ - DNN (Caffe) │
│ - Photo │ │ - Haar Cascade │
│ - Countdown │ │ - 降级策略 │
└──────────────┘ └─────────────────┘
SharedState:静态类,跨页面共享 WebCamTexture、抓拍结果、倒计时状态,避免重复开启摄像头
EventBus:轻量泛型事件总线,Subscribe / Publish / Unsubscribe,操作屏发布事件、大屏订阅响应
FaceDetector:MonoBehaviour 组件,挂在操作页 Prefab 上,封装 OpenCvSharp 的 DNN 和 Haar 两种检测方式
二、WebCamTexture 摄像头采集
Unity 自带 WebCamTexture,使用简单但有几个坑需要注意。

2.1 摄像头预热与共享

// 在应用启动时(SceneSetup)预先创建 WebCamTexture,存入 SharedState
var webCam = new WebCamTexture();
webCam.Play();
SharedState.CurrentWebCam = webCam;
为什么预热?WebCamTexture.Play() 不是瞬间完成的。刚创建时 width 可能只有 16(默认占位尺寸),需要等几帧画面才真正就绪。所以各页面在赋值给 RawImage 时需要判断:

if (_cameraPreview != null && webCam != null)
{
_cameraPreview.texture = webCam;
// 摄像头未就绪时显示黑底,避免拉伸的色块
_cameraPreview.color = webCam.width > 16 ? Color.white : Color.black;
}
2.2 双屏共用同一个 WebCamTexture
操作屏和大屏的 RawImage.texture 都指向 SharedState.CurrentWebCam,同一个纹理实例渲染到两个 RawImage 上,不存在额外性能开销。

三、OpenCvSharp DNN 人脸检测
3.1 为什么选 DNN 而非纯 Haar
Haar 级联分类器轻量但只对正脸敏感,稍微偏头、遮挡、光线变化就容易漏检。DNN SSD(Caffe 模型 res10_300x300_ssd_iter_140000_fp16)对多角度、遮挡、小脸的鲁棒性好很多。

模型文件放在 Assets/Resources/ 下:

deploy.prototxt — 网络结构定义
res10_300x300_ssd_iter_140000_fp16.caffemodel — 预训练权重(约 5.3MB)
3.2 初始化:DNN 优先,失败降级 Haar

public void Initialize()
{
if (Initialized) return;

if (_useDnn)
{
InitializeDnn();
if (_dnnNet != null)
{
Initialized = true;
return;
}
// DNN 加载失败 → 自动降级
_useDnn = false;
}

// 加载 Haar cascade 作为兜底
var asset = Resources.Load(_cascadeFileName);
string tempPath = Application.temporaryCachePath + "/" + _cascadeFileName + ".xml";
File.WriteAllBytes(tempPath, asset.bytes);
_cascade = new CascadeClassifier(tempPath);
Initialized = true;
}
DNN 模型通过 CvDnn.ReadNetFromCaffe 加载,proto 和 model 文件同样是 Resources.Load 后写入临时路径。

3.3 检测流程

public bool Detect(Color32[] pixels, int width, int height)
{
if (_useDnn && _dnnNet != null)
return DetectDnn(pixels, width, height);

if (_cascade == null) return false;
return DetectHaar(pixels, width, height);
}
DNN 推理核心:

private bool DetectDnn(Color32[] pixels, int width, int height)
{
// Unity Color32 是 RGBA,OpenCV DNN 需要 BGR
byte[] bgr = new byte[pixels.Length * 3];
for (int i = 0; i < pixels.Length; i++)
{
bgr[i * 3] = pixels[i].b;
bgr[i * 3 + 1] = pixels[i].g;
bgr[i * 3 + 2] = pixels[i].r;
}

using var src = Mat.FromPixelData(height, width, MatType.CV_8UC3, ...);
// BlobFromImage: 缩放到 300x300,减去均值 [104,117,123]
using var blob = CvDnn.BlobFromImage(src, 1.0, new Size(300, 300),
new Scalar(104, 117, 123), true, false);
_dnnNet.SetInput(blob);
using var detection = _dnnNet.Forward();

// 遍历检测结果,任意一个超过置信度阈值即返回 true
for (int i = 0; i < detectionRows; i++)
{
float conf = detMat.At(i, 2);
if (conf > _dnnConfidence) return true;
}
return false;
}
关键点:Unity 的 Color32 通道顺序是 RGBA,而 OpenCV DNN 模型预处理需要 BGR 且不需要 Alpha 通道,做一次通道重排即可。

四、GPU 加速:Blit 替代 GetPixels32 全帧读取
4.1 问题
最初 RunDetection() 直接对 1280×720 的 WebCamTexture 调 GetPixels32(),每次从 GPU 读回 921,600 个像素到 CPU,每 0.3 秒跑一次。Game 视图放大后,渲染管线压力增加,主线程卡顿明显。

4.2 方案
用 Graphics.Blit 在 GPU 侧缩放到 320×240 的 RenderTexture,再 ReadPixels 读取仅 76,800 个像素(数据量降为 1/12):

private RenderTexture _detectRT; // 缓存复用
private Texture2D _detectTex;

private bool RunDetection()
{
var webCam = SharedState.CurrentWebCam;
if (webCam == null) return false;

int detectW = 320, detectH = 240;

// 缓存 RenderTexture,避免每帧新建
if (_detectRT == null)
_detectRT = new RenderTexture(detectW, detectH, 0, RenderTextureFormat.ARGB32);
if (_detectTex == null)
_detectTex = new Texture2D(detectW, detectH, TextureFormat.RGBA32, false);

// GPU 侧缩放
Graphics.Blit(webCam, _detectRT);

var prevRT = RenderTexture.active;
RenderTexture.active = _detectRT;
_detectTex.ReadPixels(new Rect(0, 0, detectW, detectH), 0, 0);
_detectTex.Apply();
RenderTexture.active = prevRT;

return _faceDetector.Detect(_detectTex.GetPixels32(), detectW, detectH);
}
注意 RenderTexture 和 Texture2D 缓存复用,避免 GC 分配。页面销毁时记得释放:

public override void OnDispose()
{
if (_capturedTexture != null) Destroy(_capturedTexture);
if (_detectTex != null) Destroy(_detectTex);
if (_detectRT != null) Destroy(_detectRT);
}
五、倒计时逻辑:防抖 + 断续保护
人脸检测每 0.3 秒跑一次,但单帧检测结果不可靠(可能误检或短暂丢失)。引入连续帧确认机制:

[SerializeField] private int _detectStreakThreshold = 2; // 连续检测到 2 帧 → 开始倒计时
[SerializeField] private int _lostStreakThreshold = 3; // 连续丢失 3 帧 → 取消倒计时
核心 Update 逻辑:

_detectionTimer -= Time.deltaTime;
if (_detectionTimer <= 0f)
{
_detectionTimer = _detectionInterval; // 0.3s
bool personDetected = RunDetection();

if (personDetected)
{
_lostStreak = 0;
_detectStreak++;
if (!_isCountingDown && _detectStreak >= _detectStreakThreshold)
StartCountdown(); // 连续 N 帧有人 → 触发倒计时
}
else
{
_detectStreak = 0;
_lostStreak++;
if (_isCountingDown && _lostStreak >= _lostStreakThreshold)
CancelCountdown(); // 连续 N 帧无人 → 取消倒计时
}
}

if (_isCountingDown)
{
_countdownValue -= Time.deltaTime;
int display = Mathf.CeilToInt(_countdownValue);
SharedState.CountdownValue = display; // 同步给大屏
if (_countdownValue <= 0f)
AutoCapture();
}
这样避免了:

画面闪过一个路人就误触发倒计时
检测对象短暂离开画面就立即中断(给用户调整站位的时间)
六、双屏事件同步
6.1 事件定义

// PhotoCapturedEvent.cs
public struct PhotoCapturedEvent { public Texture2D Texture; }

// PhotoRetakeEvent.cs
public struct PhotoRetakeEvent { }
用 struct 而非 class,避免 GC 分配。

6.2 EventBus 实现

public static class EventBus
{
private static readonly Dictionary<Type, Delegate> _handlers = new();

public static void Subscribe(Action handler)
{
var type = typeof(T);
_handlers[type] = _handlers.ContainsKey(type)
? Delegate.Combine(_handlers[type], handler)
: handler;
}

public static void Publish(T eventData)
{
if (_handlers.TryGetValue(typeof(T), out var del))
(del as Action)?.Invoke(eventData);
}
}
简洁的泛型实现,不需要第三方消息框架。在 OnShow 订阅、OnHide 取消订阅,避免页面隐藏后还响应事件。

6.3 大屏响应事件

// PhotoLargeScreenPage 订阅抓拍事件
private void OnPhotoCaptured(PhotoCapturedEvent e)
{
_cameraPreview.gameObject.SetActive(false);
_capturedPhotoDisplay.texture = e.Texture; // 展示抓拍结果
_capturedPhotoDisplay.gameObject.SetActive(true);
}

// 订阅重拍事件 — 恢复摄像头预览
private void OnPhotoRetake(PhotoRetakeEvent e)
{
_cameraPreview.gameObject.SetActive(true);
_cameraPreview.texture = SharedState.CurrentWebCam;
_capturedPhotoDisplay.gameObject.SetActive(false);
}
6.4 倒计时同步
倒计时数字通过 SharedState.IsCountingDown 和 SharedState.CountdownValue 共享。大屏的 Update 直接读取并更新 UI:

private void Update()
{
if (SharedState.IsCountingDown)
{
_countdownText.gameObject.SetActive(true);
_countdownText.text = SharedState.CountdownValue.ToString();
}
else
{
_countdownText.gameObject.SetActive(false);
}
}
七、完整拍照流程

用户进入拍照页
│
▼
摄像头预览开始 (操作屏 + 大屏)
│
▼
检测到人脸 (连续2帧) ──→ 5秒倒计时开始
│ │
│ (人脸短暂丢失<3帧不中断)
│ │
▼ ▼
倒计时归零 ──→ 自动抓拍
│
▼
发布 PhotoCapturedEvent
│
▼
操作屏:显示"确认"/"重拍"按钮
大屏:显示抓拍照片
│
├── 确认 → 跳转签名页
│
└── 重拍 → 发布 PhotoRetakeEvent → 回到预览状态
八、小结
几个值得注意的点:

1.WebCamTexture 预热:不要一进页面就 new WebCamTexture,在应用启动时创建,页面间共享同一实例。判断 width > 16 确定摄像头是否就绪。

2.OpenCV DNN 模型部署:proto 和 caffemodel 放 Resources 目录,运行时写入 Application.temporaryCachePath,注意 Unity Color32 是 RGBA,DNN 输入需要 BGR。参考( https://www.cnblogs.com/mingupupu/p/19363129 )

3.GPU→CPU 传输优化:用 Graphics.Blit 先在 GPU 上缩放,再 ReadPixels 读小尺寸的 RenderTexture,数据量可以从 1280×720×4 降到 320×240×4。

4.检测稳定性:用连续帧计数器(streak counter)做防抖,避免单帧误检/漏检导致的倒计时抖动。

5.双屏解耦:操作屏和大屏不直接引用对方,通过 EventBus + SharedState 通信,方便后续扩展更多页面。

完整代码就不全贴了,核心逻辑都在上面的片段里。有问题欢迎交流。

posted @ 2026-05-08 11:27  带一蓑烟雨  阅读(40)  评论(0)    收藏  举报