实时口罩检测-通用模型WebRTC视频流接入:浏览器端实时检测

1. 引言:从静态图片到实时视频流的跨越

想象一下,在一个公共场所的入口,摄像头需要实时判断每一位进入者是否佩戴了口罩。传统的做法可能是:拍一张照片,上传到服务器,等待模型分析,再把结果返回。这个过程可能需要几秒钟,甚至更长。在需要快速通行或实时监控的场景下,这种延迟是无法接受的。

今天,我们要解决的问题就是:如何让口罩检测模型“跑”在浏览器里,直接处理摄像头视频流,实现毫秒级的实时检测?

本文将带你一步步实现这个目标。我们将基于一个已经部署好的“实时口罩检测-通用”模型服务,利用WebRTC技术,在浏览器端直接捕获摄像头视频流,并调用模型进行实时分析。整个过程无需将视频帧上传到远程服务器,所有计算都在本地(或靠近用户的边缘)完成,从而实现真正的低延迟、高隐私性的实时检测应用。

你将学到什么:

  • 理解WebRTC如何捕获和处理浏览器摄像头视频流。
  • 学会将Gradio部署的模型服务与前端页面集成。
  • 掌握在浏览器中实现实时视频流目标检测的核心逻辑。
  • 获得一套完整的、可运行的代码,快速搭建你自己的实时检测应用。

前置条件:

  • 一个已经通过ModelScope和Gradio部署好的“实时口罩检测-通用”模型服务(访问地址假设为 http://your-model-service.com)。
  • 基础的HTML、JavaScript知识。
  • 一个支持WebRTC的现代浏览器(如Chrome, Edge, Firefox)。

2. 核心技术与原理简介

在开始动手之前,我们先花几分钟了解一下背后的“武器库”。这能帮助你在遇到问题时,知道该从哪里寻找答案。

2.1 模型基石:DAMO-YOLO

我们使用的“实时口罩检测-通用”模型,其核心是DAMO-YOLO-S。你可以把它理解为一个速度与精度平衡得非常好的“视觉侦察兵”。

  • 它厉害在哪? 相比大家熟悉的YOLOv5、YOLOv7等,DAMO-YOLO在保持极快推理速度的同时,检测精度更高。这意味着它既能“看得准”(准确区分戴口罩和没戴口罩的人脸),又能“反应快”(每秒能处理很多帧图像),这正是实时应用最需要的特性。
  • 设计巧思:大脖子,小脑袋。 它的网络结构主要分三部分:骨干网络(Backbone)、颈部网络(Neck)和检测头(Head)。DAMO-YOLO采用了一种“大脖子、小脑袋”的设计。简单说,就是花更多精力在“颈部”去充分融合图像浅层的细节信息(比如边缘、轮廓)和深层的语义信息(比如“这是一个人脸”),最后用一个轻量级的“头”做出判断。这种设计让模型在复杂场景下也能表现稳定。

2.2 实时传输引擎:WebRTC

WebRTC 是一套免费的、开放的、内置于浏览器的实时通信技术。我们主要利用它的两个超能力:

  1. getUserMedia API: 让你在网页中一行代码就能获取用户摄像头和麦克风的访问权限,并得到实时的视频/音频流。
  2. 低延迟传输: WebRTC专为实时性优化,能够将媒体流以极低的延迟进行处理和(如果需要)传输。

在我们的场景中,我们主要使用第一个能力来捕获摄像头视频流。

2.3 桥梁:Gradio模型接口

我们的模型已经通过 Gradio 部署成了Web服务。Gradio会为我们的模型生成一个友好的网页界面和一套标准的HTTP API。这意味着,我们可以通过向一个特定的URL发送图片数据,来获取模型的检测结果。这为我们从浏览器调用模型提供了可能。

技术栈全景图:

[浏览器摄像头] --(WebRTC)--> [视频流] --(Canvas截帧)--> [图片帧] --(HTTP请求)--> [Gradio模型服务] --(JSON响应)--> [检测结果] --(Canvas绘图)--> [浏览器标注显示]

整个过程形成了一个高效的闭环。

3. 实战:构建浏览器端实时检测系统

理论说得差不多了,现在我们来动手搭建。请跟随以下步骤,你将得到一个完整的实时检测页面。

3.1 创建基础HTML页面

首先,创建一个名为 realtime_mask_detection.html 的文件,并填入以下骨架代码。它定义了视频显示、画布(用于绘图)和控制按钮。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>浏览器实时口罩检测</title>
    <style>
        body {
            font-family: sans-serif;
            display: flex;
            flex-direction: column;
            align-items: center;
            padding: 20px;
            background-color: #f5f5f5;
        }
        .container {
            display: flex;
            flex-wrap: wrap;
            justify-content: center;
            gap: 20px;
            max-width: 1200px;
            margin-bottom: 20px;
        }
        .video-box, .result-box {
            border: 2px solid #ccc;
            border-radius: 8px;
            padding: 10px;
            background: white;
            text-align: center;
        }
        video, canvas {
            width: 640px;
            height: 480px;
            background-color: #000;
            border-radius: 4px;
        }
        .controls {
            margin: 20px 0;
        }
        button {
            padding: 12px 24px;
            margin: 0 10px;
            font-size: 16px;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            transition: background-color 0.3s;
        }
        #startBtn {
            background-color: #4CAF50;
            color: white;
        }
        #startBtn:hover {
            background-color: #45a049;
        }
        #stopBtn {
            background-color: #f44336;
            color: white;
        }
        #stopBtn:hover {
            background-color: #da190b;
        }
        #status {
            margin-top: 15px;
            padding: 10px;
            border-radius: 4px;
            min-height: 24px;
        }
        .status-ready {
            color: #2196F3;
        }
        .status-running {
            color: #4CAF50;
        }
        .status-error {
            color: #f44336;
        }
        .legend {
            display: flex;
            justify-content: center;
            gap: 20px;
            margin-top: 10px;
        }
        .legend-item {
            display: flex;
            align-items: center;
        }
        .color-box {
            width: 20px;
            height: 20px;
            margin-right: 8px;
            border: 1px solid #333;
        }
        .mask-color {
            background-color: rgba(0, 255, 0, 0.5); /* 绿色,半透明 */
        }
        .no-mask-color {
            background-color: rgba(255, 0, 0, 0.5); /* 红色,半透明 */
        }
    </style>
</head>
<body>
    <h1>🎥 浏览器实时口罩检测演示</h1>
    <p>基于 DAMO-YOLO 模型与 WebRTC 技术实现。请允许浏览器访问您的摄像头。</p>

    <div class="container">
        <div class="video-box">
            <h3>实时摄像头画面</h3>
            <video id="videoElement" playsinline autoplay muted></video>
            <!-- playsinline 用于移动端自动播放,muted 是Chrome等浏览器自动播放策略要求 -->
        </div>
        <div class="result-box">
            <h3>实时检测结果</h3>
            <canvas id="canvasElement"></canvas>
            <div class="legend">
                <div class="legend-item"><div class="color-box mask-color"></div> 已佩戴口罩</div>
                <div class="legend-item"><div class="color-box no-mask-color"></div> 未佩戴口罩</div>
            </div>
        </div>
    </div>

    <div class="controls">
        <button id="startBtn">开始实时检测</button>
        <button id="stopBtn" disabled>停止检测</button>
    </div>

    <div id="status" class="status-ready">状态:等待开始...</div>

    <!-- 引入主逻辑脚本 -->
    <script src="realtime_mask.js"></script>
</body>
</html>

3.2 编写核心JavaScript逻辑

接下来,创建 realtime_mask.js 文件。这是整个应用的大脑,负责控制视频流、调用模型和绘制结果。

// realtime_mask.js

// 配置:你的Gradio模型服务地址
const MODEL_API_URL = 'http://your-model-service.com/run/predict'; // 请替换为你的实际地址

// 获取DOM元素
const videoElement = document.getElementById('videoElement');
const canvasElement = document.getElementById('canvasElement');
const ctx = canvasElement.getContext('2d');
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const statusDiv = document.getElementById('status');

// 全局变量
let stream = null;
let animationId = null;
let isRunning = false;

// 1. 初始化:请求摄像头权限并设置视频流
async function initCamera() {
    updateStatus('正在请求摄像头权限...', 'status-ready');
    try {
        // 获取用户媒体(摄像头视频),约束条件指定视频分辨率等
        stream = await navigator.mediaDevices.getUserMedia({
            video: {
                width: { ideal: 640 },
                height: { ideal: 480 },
                facingMode: 'user' // 'user' 前置摄像头,'environment' 后置
            },
            audio: false // 我们不需要音频
        });
        videoElement.srcObject = stream;
        updateStatus('摄像头已就绪。点击“开始实时检测”。', 'status-ready');
        startBtn.disabled = false;
    } catch (err) {
        console.error('获取摄像头失败:', err);
        updateStatus(`错误:无法访问摄像头。请确保已授予权限。 (${err.name})`, 'status-error');
        startBtn.disabled = true;
    }
}

// 2. 调用模型API进行检测
async function detectMask(imageDataUrl) {
    // 将DataURL转换为Blob,以便通过FormData发送
    const blob = await (await fetch(imageDataUrl)).blob();
    const formData = new FormData();
    formData.append('data', blob, 'frame.jpg'); // 'data' 是Gradio接口常见的参数名,请根据你的接口调整

    try {
        const response = await fetch(MODEL_API_URL, {
            method: 'POST',
            body: formData,
            // 注意:如果Gradio服务开启了CORS,可能需要处理跨域。本地开发或同源部署可避免。
        });

        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }

        const result = await response.json();
        // Gradio的预测接口通常返回一个JSON,结构如 { data: [...] }
        // 具体结构需要查看你模型的实际返回。假设返回一个数组,每个元素是 [x1, y1, x2, y2, confidence, class_id]
        return result.data || [];
    } catch (error) {
        console.error('模型调用失败:', error);
        updateStatus(`检测请求失败: ${error.message}`, 'status-error');
        return [];
    }
}

// 3. 从视频帧中捕获图像并处理
function captureAndProcessFrame() {
    if (!isRunning || !stream) return;

    // 确保画布尺寸与视频一致
    if (canvasElement.width !== videoElement.videoWidth || canvasElement.height !== videoElement.videoHeight) {
        canvasElement.width = videoElement.videoWidth;
        canvasElement.height = videoElement.videoHeight;
    }

    // 1) 将当前视频帧绘制到画布上
    ctx.drawImage(videoElement, 0, 0, canvasElement.width, canvasElement.height);

    // 2) 从画布获取图像数据(DataURL格式)
    const imageDataUrl = canvasElement.toDataURL('image/jpeg', 0.8); // 压缩质量0.8,平衡速度与质量

    // 3) 调用检测函数
    detectMask(imageDataUrl).then(detections => {
        // 4) 在画布上绘制检测结果
        drawDetections(detections);
    }).catch(err => {
        console.error('处理帧时出错:', err);
    });

    // 5) 循环下一帧,实现“实时”
    animationId = requestAnimationFrame(captureAndProcessFrame);
}

// 4. 在画布上绘制检测框和标签
function drawDetections(detections) {
    // 先清除上一帧的绘制(除了原始图像)
    // 我们不清除整个画布,因为上面已经有视频帧了。我们只清除之前绘制的矩形和文字。
    // 更简单的方法是:在绘制新框之前,重新绘制一遍视频帧。但为了效率,我们采用“脏矩形”思想简化处理。
    // 这里为了简单,我们直接在新的视频帧上绘制,覆盖旧的。
    // 实际上,我们在 captureAndProcessFrame 的第一步已经用视频帧覆盖了整个画布。

    if (!detections || detections.length === 0) return;

    const labelMap = {
        1: { name: 'facemask', color: 'rgba(0, 255, 0, 0.5)', textColor: 'green' }, // 绿色,半透明
        2: { name: 'no facemask', color: 'rgba(255, 0, 0, 0.5)', textColor: 'red' }  // 红色,半透明
    };

    detections.forEach(det => {
        // 假设 det 是 [x1, y1, x2, y2, confidence, class_id] 格式
        // 具体格式需要根据你模型的实际输出调整
        const [x1, y1, x2, y2, conf, clsId] = det;
        const labelInfo = labelMap[clsId];

        if (!labelInfo) return;

        // 绘制矩形框
        ctx.strokeStyle = labelInfo.textColor;
        ctx.lineWidth = 3;
        ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);

        // 绘制半透明填充背景
        ctx.fillStyle = labelInfo.color;
        ctx.fillRect(x1, y1, x2 - x1, y2 - y1);

        // 绘制标签文本
        const label = `${labelInfo.name} ${(conf * 100).toFixed(1)}%`;
        ctx.fillStyle = 'white';
        ctx.font = 'bold 16px Arial';
        const textWidth = ctx.measureText(label).width;
        ctx.fillRect(x1, y1 - 20, textWidth + 10, 20); // 文本背景
        ctx.fillStyle = 'black';
        ctx.fillText(label, x1 + 5, y1 - 5);
    });
}

// 5. 更新状态显示
function updateStatus(message, className) {
    statusDiv.textContent = `状态:${message}`;
    statusDiv.className = className;
}

// 6. 开始检测流程
function startDetection() {
    if (isRunning) return;
    if (!stream) {
        updateStatus('错误:摄像头未就绪。', 'status-error');
        return;
    }
    isRunning = true;
    startBtn.disabled = true;
    stopBtn.disabled = false;
    updateStatus('实时检测运行中...', 'status-running');
    // 启动处理循环
    captureAndProcessFrame();
}

// 7. 停止检测流程
function stopDetection() {
    isRunning = false;
    if (animationId) {
        cancelAnimationFrame(animationId);
        animationId = null;
    }
    startBtn.disabled = false;
    stopBtn.disabled = true;
    updateStatus('检测已停止。', 'status-ready');
    // 可选:清除画布上的检测框
    ctx.clearRect(0, 0, canvasElement.width, canvasElement.height);
    ctx.drawImage(videoElement, 0, 0, canvasElement.width, canvasElement.height);
}

// 8. 绑定按钮事件
startBtn.addEventListener('click', startDetection);
stopBtn.addEventListener('click', stopDetection);

// 9. 页面加载时初始化
window.addEventListener('load', initCamera);

// 10. 页面关闭或刷新时,关闭摄像头流
window.addEventListener('beforeunload', () => {
    if (stream) {
        stream.getTracks().forEach(track => track.stop());
    }
    stopDetection();
});

3.3 关键步骤与代码解析

现在,让我们拆解一下上面代码中的几个关键点:

  1. initCamera 函数: 这是入口。它使用 navigator.mediaDevices.getUserMedia 这个WebRTC API来请求摄像头权限。成功后,将获取到的媒体流(stream)赋值给 <video> 元素的 srcObject,视频就开始播放了。
  2. captureAndProcessFrame 函数: 这是核心循环。
    • 使用 drawImage 将视频当前帧“快照”到 <canvas> 上。
    • 使用 toDataURL 将画布内容转换成一张JPEG图片的Base64编码字符串。
    • 调用 detectMask 函数,将图片数据发送给模型API。
    • 收到检测结果后,调用 drawDetections 在画布上绘制框和标签。
    • 最后,使用 requestAnimationFrame 递归调用自己,形成一个动画循环,从而实现逐帧处理。
  3. detectMask 函数: 负责与后端模型通信。它构造一个包含图片的 FormData,通过 fetch API 发送POST请求到Gradio服务。这里有个关键点:你需要将 MODEL_API_URL 替换成你实际部署的Gradio应用的预测接口地址。 通常Gradio应用的接口路径是 /run/predict
  4. drawDetections 函数: 根据模型返回的坐标和类别信息,在画布上绘制矩形框。我们用了两种颜色来区分“戴口罩”(绿色)和“未戴口罩”(红色)。
  5. 资源管理: 在页面关闭时,我们通过事件监听器关闭摄像头轨道并停止检测循环,这是一个好习惯,可以避免资源泄漏。

4. 运行与调试你的应用

  1. 准备模型服务: 确保你的“实时口罩检测-通用”Gradio应用正在运行,并记下它的访问地址(例如 http://localhost:7860 或一个公网地址)。
  2. 修改配置:realtime_mask.js 文件中,将 const MODEL_API_URL 的值改为你的模型服务地址加上 /run/predict。例如:const MODEL_API_URL = 'http://localhost:7860/run/predict';
  3. 启动页面: 由于涉及摄像头访问和可能的跨域请求,建议通过一个HTTP服务器来打开HTML文件,而不是直接双击。你可以使用Python快速启动一个:
    # 在HTML文件所在目录执行
    python3 -m http.server 8000
    
    然后在浏览器中访问 http://localhost:8000/realtime_mask_detection.html
  4. 允许摄像头: 浏览器会弹出权限请求,点击“允许”。
  5. 开始检测: 点击“开始实时检测”按钮。你应该能看到左侧是摄像头实时画面,右侧画布上会实时画出检测框。

可能遇到的问题与解决思路:

  • 跨域错误(CORS): 如果你的前端页面地址(如 http://localhost:8000)和模型API地址(如 http://localhost:7860)不同源,浏览器会因安全策略阻止请求。解决方法:
    • 最佳实践: 将前端页面和后端模型服务部署在同一个域名下。
    • 开发调试: 可以暂时使用浏览器插件禁用CORS(不推荐用于生产),或者为Gradio服务配置CORS头(如果Gradio支持)。
    • 代理: 使用Nginx等反向代理将前后端API代理到同域下。
  • 模型返回格式不匹配: 代码中 drawDetections 函数假设了特定的返回格式 [x1, y1, x2, y2, conf, clsId]。你需要根据你模型Gradio接口的实际返回JSON结构来调整解析逻辑。打开浏览器开发者工具的“网络”选项卡,查看预测请求的响应体,就能知道确切的格式。
  • 性能问题: 逐帧发送高清图片可能会对网络和服务器造成压力。你可以通过调整 toDataURL 的图片质量(如 0.7)、降低视频采集分辨率、或设置检测频率(例如每3帧处理一次)来优化。

5. 总结与展望

通过本文的实践,我们成功地将一个静态的图片口罩检测模型,升级为了一个基于浏览器和WebRTC的实时视频流检测应用。我们利用了DAMO-YOLO模型的高性能,结合WebRTC的实时捕获能力,在浏览器端完成了视频流的获取、帧的提取、结果的绘制,仅将图像推理任务交给了后端模型服务。

这种架构的优势非常明显:

  • 低延迟: 视频捕获和结果显示在本地,只有推理请求有网络往返延迟。
  • 隐私性好: 原始视频数据无需上传到远程服务器(除非你需要存储),仅在必要时发送单帧图片用于分析。
  • 部署灵活: 前端是纯静态网页,可以放在任何地方;后端模型服务可以独立部署和扩展。

你可以在此基础上进行更多探索:

  • 优化性能: 实现前端智能节流,只在画面有显著变化时才发送检测请求。
  • 增加功能: 添加检测统计(如当前画面未戴口罩人数)、声音告警、截图保存等功能。
  • 模型轻量化: 探索使用ONNX Runtime或TensorFlow.js等工具,尝试将模型直接部署到浏览器中运行,实现完全离线的实时检测,这将彻底消除网络延迟。

实时AI应用正在改变我们与计算机交互的方式。希望这个项目能成为你探索更广阔世界的一块敲门砖。


获取更多AI镜像

想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐