
DAMOYOLO-S与JavaScript结合打造浏览器端实时目标检测Web应用最近在做一个在线教育项目需要让学员直接在网页上就能体验实时目标检测比如识别摄像头里的文具、手势或者简单的实验器材。一开始想的是搞个后端服务器把视频流传过去处理再传回来但转念一想这延迟、这服务器成本对一个小型应用来说有点吃不消。后来琢磨着能不能把模型直接搬到浏览器里跑这样既没有网络延迟的烦恼又能保护用户隐私视频数据不出本地还省了服务器开销。试了一圈发现DAMOYOLO-S这个轻量级模型配合上JavaScript生态里的ONNX Runtime Web还真能跑起来。今天就来聊聊怎么把这两者结合起来从零开始搭建一个完全在浏览器里运行的实时目标检测应用。整个过程下来感觉就像给浏览器装上了一双“AI眼睛”任何有摄像头的设备打开网页就能用特别适合那些对实时性要求高、又希望部署简单的场景。1. 为什么要在浏览器里跑目标检测你可能要问现在云服务这么方便为啥非得把模型塞进浏览器这背后有几个实实在在的好处。首先实时性得到了质的提升。所有的计算都在你本地电脑或手机的浏览器里完成视频帧不需要上传到遥远的服务器处理完再下载回来。这意味着从你看到画面到画面里出现检测框几乎是瞬间完成的延迟极低。对于互动教学、体感游戏这类场景这种即时反馈的体验至关重要。其次用户隐私得到了更好的保护。摄像头捕捉的画面是相当敏感的个人数据。在浏览器端处理意味着视频流数据根本不会离开你的设备也就不存在数据在传输或服务器端存储时泄露的风险。这对于涉及人脸、手势或者特定工作环境的检测应用来说是个巨大的优势。再者它极大地简化了部署和运维。开发者只需要维护一个静态网页扔到任何Web服务器甚至GitHub Pages上就能用。没有后端API需要维护没有GPU服务器需要租赁和监控也就不用担心流量激增导致的服务崩溃。用户访问即用没有任何安装步骤。当然挑战也很明显。浏览器的计算资源尤其是CPU和内存远不如服务器强大所以模型必须足够轻量。DAMOYOLO-S就是为此而生的它在保持YOLO系列高检测精度的同时大幅减少了参数量和计算量让它能在主流设备的浏览器中达到实时运行的速度。2. 核心工具链DAMOYOLO-S与ONNX Runtime Web要把这件事做成我们需要两把关键的“钥匙”一个足够轻量的模型和一个能在浏览器里高效运行它的引擎。DAMOYOLO-S为边缘计算优化的检测器DAMOYOLO是YOLO家族的一个新成员它的核心设计思想就是“高效”。其中的SSmall版本在模型结构上做了大量精简比如采用更高效的网络模块、减少通道数等使得模型体积小巧推理速度快。但别因为它“小”就小看它在COCO这类通用数据集上它的检测精度依然很有竞争力足以应对很多常见的实时检测任务比如识别人、车、动物、日常物品等。ONNX Runtime Web浏览器中的AI推理引擎模型准备好了怎么在JavaScript环境里让它跑起来这里主角就是ONNX Runtime Web。你可以把它理解为一个专门为浏览器设计的、高度优化的“模型解释器”。它支持WebGL和WebAssembly后端能充分利用你设备的GPU或CPU进行加速计算。更重要的是它允许我们将PyTorch或TensorFlow训练好的模型转换成统一的ONNX格式然后直接导入浏览器使用整个过程非常顺畅。整个技术栈的流程很清晰在Python环境中用熟悉的框架如PyTorch训练或下载DAMOYOLO-S模型然后将其导出为ONNX格式。最后在网页中通过ONNX Runtime Web加载这个.onnx文件并编写JavaScript代码来处理视频流和进行推理。3. 从模型到网页一步步构建应用理论说完了我们动手搭一个最简单的原型。这个原型会完成从打开摄像头、抓取视频帧、用模型推理到在画面上画出检测框的全过程。3.1 第一步准备模型ONNX转换首先我们需要一个DAMOYOLO-S的ONNX模型。如果你有PyTorch的训练模型可以使用torch.onnx.export进行转换。这里假设我们已经有了一个转换好的模型文件damoyolo-s.onnx。关键点在于导出时要注意输入输出的设置。DAMOYOLO-S的输入通常是一个归一化后的图像张量形状为[1, 3, 640, 640]批次通道高宽。输出则是检测结果可能包含边界框、置信度和类别。3.2 第二步搭建基础HTML页面创建一个index.html文件这是应用的主入口。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title浏览器端实时目标检测/title style body { font-family: sans-serif; margin: 20px; text-align: center; } #container { display: flex; flex-direction: column; align-items: center; } #videoContainer { position: relative; display: inline-block; } #videoElement { display: block; border: 2px solid #ccc; max-width: 100%; } #canvasOverlay { position: absolute; top: 0; left: 0; pointer-events: none; } #status { margin-top: 10px; color: #666; } button { margin: 10px; padding: 10px 20px; font-size: 16px; cursor: pointer; } /style /head body div idcontainer h1DAMOYOLO-S 实时目标检测/h1 div idvideoContainer !-- 用于显示摄像头视频 -- video idvideoElement playsinline autoplay muted/video !-- 用于绘制检测框的Canvas覆盖层 -- canvas idcanvasOverlay/canvas /div p idstatus正在加载模型.../p div button idstartButton开启摄像头并开始检测/button button idstopButton disabled停止检测/button /div /div !-- 引入ONNX Runtime Web -- script srchttps://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js/script !-- 引入我们的主逻辑脚本 -- script srcmain.js/script /body /html页面结构很简单一个视频元素用于显示摄像头画面一个Canvas覆盖在视频上方用来画检测框几个按钮和状态提示。3.3 第三步编写核心JavaScript逻辑接下来是重头戏创建main.js文件。代码有点长我们分块来看。首先初始化变量和获取DOM元素// 获取页面元素 const videoElement document.getElementById(videoElement); const canvasOverlay document.getElementById(canvasOverlay); const ctx canvasOverlay.getContext(2d); const statusText document.getElementById(status); const startButton document.getElementById(startButton); const stopButton document.getElementById(stopButton); // 模型和推理相关变量 let session null; // ONNX Runtime会话 const modelInputSize 640; // DAMOYOLO-S输入尺寸 let isDetecting false; let animationFrameId null;然后编写加载模型的函数async function loadModel() { statusText.textContent 正在加载AI模型...; try { // 从服务器加载ONNX模型文件 session await ort.InferenceSession.create(./models/damoyolo-s.onnx); statusText.textContent 模型加载成功; console.log(模型加载完毕输入信息, session.inputNames, 输出信息, session.outputNames); } catch (error) { statusText.textContent 模型加载失败: error.message; console.error(模型加载错误:, error); } }接着是处理视频帧和图像预处理的函数function preprocessFrame(videoElement, inputSize) { // 1. 创建一个离屏Canvas用于绘图和缩放 const offscreenCanvas document.createElement(canvas); offscreenCanvas.width inputSize; offscreenCanvas.height inputSize; const offscreenCtx offscreenCanvas.getContext(2d); // 2. 将视频帧绘制到Canvas并进行等比例缩放和居中裁剪 // 确保输入模型的是正方形图像避免变形 const videoWidth videoElement.videoWidth; const videoHeight videoElement.videoHeight; const scale Math.max(inputSize / videoWidth, inputSize / videoHeight); const scaledWidth videoWidth * scale; const scaledHeight videoHeight * scale; const offsetX (inputSize - scaledWidth) / 2; const offsetY (inputSize - scaledHeight) / 2; offscreenCtx.fillStyle #114514; // 用灰色填充边缘裁剪区域 offscreenCtx.fillRect(0, 0, inputSize, inputSize); offscreenCtx.drawImage(videoElement, offsetX, offsetY, scaledWidth, scaledHeight); // 3. 获取图像数据并归一化 const imageData offscreenCtx.getImageData(0, 0, inputSize, inputSize); const data imageData.data; const inputTensorData new Float32Array(1 * 3 * inputSize * inputSize); // 将RGB数据从 [0, 255] 归一化到 [0, 1]并转换为CHW格式 let tensorIndex 0; for (let c 0; c 3; c) { // 通道循环 (R, G, B) for (let h 0; h inputSize; h) { for (let w 0; w inputSize; w) { const pixelIndex (h * inputSize w) * 4; // ImageData是RGBA格式 // 归一化并按照BGR顺序某些模型需要或RGB顺序放入张量 // 这里假设模型需要RGB顺序 const value data[pixelIndex c] / 255.0; // R: c0, G: c1, B: c2 inputTensorData[tensorIndex] value; } } } // 4. 创建ONNX Runtime需要的张量 const inputTensor new ort.Tensor(float32, inputTensorData, [1, 3, inputSize, inputSize]); return { inputTensor, offsetX, offsetY, scale }; }这个预处理函数做了几件关键事把任意尺寸的视频帧缩放并裁剪成640x640的正方形将像素值从0-255归一化到0-1并把数据格式从HWC高、宽、通道转换成模型需要的CHW通道、高、宽格式。核心的检测与绘制循环async function detectFrame() { if (!session || !isDetecting) return; // 1. 预处理当前视频帧 const { inputTensor, offsetX, offsetY, scale } preprocessFrame(videoElement, modelInputSize); // 2. 运行模型推理 const feeds { [session.inputNames[0]]: inputTensor }; // 构造输入feed const results await session.run(feeds); // 假设模型的第一个输出是检测结果形状为 [1, num_detections, 6] // 6维代表[x_center, y_center, width, height, confidence, class_id] const detections results[session.outputNames[0]].data; // 3. 清空上一帧的画布 ctx.clearRect(0, 0, canvasOverlay.width, canvasOverlay.height); // 4. 解析并绘制检测结果 const numDetections results[session.outputNames[0]].dims[1]; for (let i 0; i numDetections; i) { const baseIndex i * 6; const confidence detections[baseIndex 4]; // 设置一个置信度阈值过滤掉不可信的检测 if (confidence 0.5) { // 检测框坐标是相对于640x640输入图像的需要转换回原始视频坐标 let x_center detections[baseIndex]; let y_center detections[baseIndex 1]; let width detections[baseIndex 2]; let height detections[baseIndex 3]; // 坐标反变换去除填充缩放到原始视频尺寸 x_center (x_center - offsetX) / scale; y_center (y_center - offsetY) / scale; width width / scale; height height / scale; // 计算左上角坐标 const x x_center - width / 2; const y y_center - height / 2; // 绘制矩形框 ctx.strokeStyle #00FF00; ctx.lineWidth 2; ctx.strokeRect(x, y, width, height); // 绘制标签背景和文字 ctx.fillStyle #00FF00; const label 类别 ${detections[baseIndex 5]} (${(confidence * 100).toFixed(1)}%); const textWidth ctx.measureText(label).width; ctx.fillRect(x, y - 20, textWidth 10, 20); ctx.fillStyle #000; ctx.fillText(label, x 5, y - 5); } } // 5. 循环调用实现实时检测 if (isDetecting) { animationFrameId requestAnimationFrame(detectFrame); } }最后绑定按钮事件和初始化// 启动摄像头和检测 startButton.addEventListener(click, async () { if (!session) { alert(模型尚未加载完成请稍候。); return; } try { const stream await navigator.mediaDevices.getUserMedia({ video: true }); videoElement.srcObject stream; // 等待视频元数据加载以获取正确尺寸 videoElement.onloadedmetadata () { canvasOverlay.width videoElement.videoWidth; canvasOverlay.height videoElement.videoHeight; isDetecting true; startButton.disabled true; stopButton.disabled false; statusText.textContent 检测运行中...; detectFrame(); // 开始检测循环 }; } catch (err) { statusText.textContent 无法访问摄像头: err.message; console.error(摄像头错误:, err); } }); // 停止检测 stopButton.addEventListener(click, () { isDetecting false; if (animationFrameId) { cancelAnimationFrame(animationFrameId); } if (videoElement.srcObject) { videoElement.srcObject.getTracks().forEach(track track.stop()); videoElement.srcObject null; } ctx.clearRect(0, 0, canvasOverlay.width, canvasOverlay.height); startButton.disabled false; stopButton.disabled true; statusText.textContent 检测已停止。; }); // 页面加载时初始化模型 window.onload loadModel;把这些代码组合起来一个最基础的浏览器端实时检测应用就成型了。点击“开启摄像头”浏览器会请求权限然后你就能看到摄像头画面和上面实时绘制的绿色检测框了。4. 效果展示与优化方向跑起来之后效果怎么样在我的笔记本电脑集成显卡上测试使用ONNX Runtime Web的WebGL后端对于640x640的输入推理速度大概在每帧150-200毫秒左右折算下来就是5-7 FPS。这个帧率对于演示和一些非高速交互场景如物品识别讲解已经可以接受了。检测精度方面对于COCO数据集中的常见大类人、杯子、手机、椅子等在光线良好的情况下识别准确率不错。当然这只是个起点。要让这个应用真正好用还有不少可以优化的地方模型优化可以尝试对DAMOYOLO-S模型进行量化如INT8量化能显著减小模型体积并提升推理速度虽然可能会损失一点点精度。推理加速确保ONNX Runtime使用了正确的后端WebGPU如果可用会比WebGL更快。也可以尝试降低输入分辨率如从640降到320用速度换精度。前后处理优化图像预处理和后处理非极大值抑制NMS是纯JavaScript代码优化这些逻辑也能提升整体帧率。可以考虑使用Worker将预处理和推理放到后台线程避免阻塞UI。用户体验增加选择不同置信度阈值、选择检测类别的UI控件。添加FPS显示让用户感知性能。对于移动端要做好横竖屏适配和触摸交互。5. 还能用在哪儿这个技术组合的想象空间挺大的。除了开头提到的在线教育识别教具、手势互动还能玩出很多花样。比如在互动营销里可以做个网页小游戏让用户用摄像头寻找画面里隐藏的“品牌吉祥物”找到有奖。或者在电商场景让用户用手机摄像头扫描身边的物品自动识别并跳转到相似商品购买页面实现“即看即买”。对于内容审核的初级过滤可以在用户上传图片或视频时先在浏览器端快速跑一遍过滤掉明显违规的内容减轻服务器压力。在物联网领域给一些低功耗设备配个带浏览器的屏幕就能本地实现简单的安防监控或产线质检提示功能。总的来说把DAMOYOLO-S这类轻量模型通过ONNX Runtime Web搬到浏览器里相当于把AI能力“下沉”到了终端。它打开了一扇新的大门让我们能构建出更实时、更隐私安全、部署更简单的智能Web应用。虽然目前性能还有天花板但随着WebGPU的普及和模型压缩技术的进步这条路肯定会越走越宽。如果你正被服务器延迟和成本困扰或者特别看重用户隐私不妨试试这个纯前端的方案说不定就能解决你的痛点。获取更多AI镜像想探索更多AI镜像和应用场景访问 CSDN星图镜像广场提供丰富的预置镜像覆盖大模型推理、图像生成、视频生成、模型微调等多个领域支持一键部署。