养狗的人对“禁止上沙发”这条规矩的执行难度都有体会:出门一趟回来,坐垫上那个凹下去的坑就是证据。与其反复做无效训练,不如让机器帮你盯着。这篇就讲怎么在浏览器里跑一个实时目标检测,摄像头对着客厅,一旦狗跳上沙发就打提示音、留现场截图。整套东西完全跑在浏览器里,用 TensorFlow.js 加载 COCO-SSD 这类现成的检测模型,不需要显卡服务器、不需要把家里的画面传出去、也不需要装 App,一个静态页面丢在局域网里就能开。
它同时也是一个很典型的端侧视觉小项目范本:采集、推理、几何判定、状态机防抖、告警联动,五个环节一个不少。把这套骨架记住,换成“识别快递有没有放门口”“盯着灶台有没有人”“看猫有没有扒垃圾桶”,逻辑几乎原样复用,只换类别名和判定区域。
适合谁看?写过一点 JavaScript、能看懂 canvas 和 Promise 的前端;想入门目标检测但被训练流程劝退的算法初学者;以及单纯想把家里摄像头用起来、不想折腾后端的人。下面我会从方案取舍讲到判定逻辑,再给一版能直接跑的代码,最后把踩过的坑摊开讲。
1. 方案选型:为什么把推理放在浏览器里
1.1 三种落地形态的取舍
做这类“家里的小监控”需求,方案无非三条路:后端推理、云端 API、浏览器端推理。我三条都试过,最后留在了浏览器端,原因不是它最强,而是它的成本结构最合适。
后端方案是本地起一个 Python 服务,OpenCV 抓帧、PyTorch 或 ONNX Runtime 推理。优点是能上大模型、精度上限高、可以做长时间录像归档;缺点是要维护一套运行环境,家里那台常年开机的旧机器一旦驱动或依赖出问题,排查成本不小,而且每次改个阈值都要重启服务。
云端方案是把帧推给在线接口做识别。优点是本地零负担;缺点很致命——家里的客厅画面要持续往外传,带宽和隐私都是问题,而且断网就废,长期调用的费用也不低。
浏览器方案则是页面打开摄像头,推理在本地 WebGL 后端完成。数据不出机器,断网也能用,部署就是几个静态文件。它的代价是模型必须够小、精度不如大模型、页面关掉就没了。但对“防狗上沙发”这种目标类别固定、场景固定、只需要粗略判定的需求来说,这个代价完全可以接受。
| 方案 | 首次搭建成本 | 长期维护 | 隐私 | 精度上限 | 断网可用 |
|---|---|---|---|---|---|
| 本地后端推理 | 高 | 中 | 好 | 高 | 是 |
| 云端接口识别 | 低 | 低 | 差 | 中高 | 否 |
| 浏览器端推理 | 低 | 极低 | 最好 | 中 | 是 |
还有一点很实际:浏览器方案天然支持“随手打开看看”。你人在外面,用手机连回家里的局域网或者内网穿透访问那个页面,实时画面和告警记录都在,不需要额外做一套客户端。
1.2 预训练模型横向对比
浏览器里能跑的检测模型就那么几类,我把主流的几个列出来对比。注意下面的耗时是按普通轻薄本核显、输入降到 320×240 的量级估的,不同机器差异很大,只看相对关系。
| 模型 | 类别集合 | 小目标表现 | 单帧耗时量级 | 接入难度 |
|---|---|---|---|---|
| COCO-SSD(lite_mobilenet_v2) | COCO 80 类 | 一般 | 20–35 ms | 极低 |
| COCO-SSD(mobilenet_v2) | COCO 80 类 | 较好 | 50–80 ms | 极低 |
| COCO-SSD(resnet50) | COCO 80 类 | 好 | 150 ms 以上 | 极低 |
| YOLO 系列转 ONNX + onnxruntime-web | COCO 80 类 | 好 | 40–90 ms | 中 |
| MediaPipe Object Detector | 可换自定义模型 | 中 | 30–60 ms | 中 |
| 通用视觉 Transformer 类模型 | 视权重而定 | 好 | 200 ms 以上 | 中高 |
选 COCO-SSD 的决定性理由只有一个:COCO 那 80 个类别里,恰好同时有dog和couch。这意味着我一行训练代码都不用写,加载完模型就能直接拿到这两个框。换成 YOLO 也能拿到同样的类别,但要做模型转换、算子兼容性验证、前后处理对齐,多出来的工作量对这个需求来说不值。
后处理这块值得多说一句。COCO-SSD 的detect()返回的数组结构非常干净,每个元素是{ bbox: [x, y, width, height], class: 'dog', score: 0.87 },坐标原点是输入元素的左上角。没有 anchor、没有 NMS 手动步骤,拿来就能算几何关系。对新手来说,这一层封装省掉的时间够你把整个告警逻辑调三轮。
base 档位怎么选?我的建议是先用lite_mobilenet_v2把全流程跑通,看误报漏报能不能接受;如果发现狗在画面里稍远一点就检测不到,再往上升一档。resnet50我基本不推荐,它在浏览器里吃掉的时间和显存换来的精度提升,在低分辨率输入下体现不出来,属于典型的“参数好看但用不上”。
1.3 这个方案的能力边界要先说清楚
不吹不黑,COCO-SSD 在这个场景里有几处硬伤,提前知道能少走弯路。
第一,它的类别是固定的 80 类,couch这个类别在真实客厅里的识别率明显低于dog。因为训练数据里的 couch 多是标准沙发照片,而你家的沙发可能罩着毯子、堆着抱枕、被猫抓得起球,模型不一定认。这就是为什么后面我要引入手绘区域作为主判据。
第二,小目标和远距离是弱项。狗在画面里小于大约 60×60 像素时,漏检率会明显上升。这决定了摄像头不能装得太远。
第三,它对姿态不敏感但对形状敏感。狗正常站立侧身时最容易被检出,蜷成一团趴着时分数经常掉到 0.3 以下。而狗上沙发这个动作的终态,恰恰就是蜷着。
第四,它会混淆近缘类别。cat和dog互相误判很常见,dog和teddy bear也会混,尤其是在光线差、画面糊的时候。
知道这些,后面所有的判定阈值和防抖设计才有依据——不是拍脑袋,是被模型的短板逼出来的。
2. 判定逻辑:从“看见狗”到“狗在沙发上”
2.1 双框几何关系:为什么不能只看 IoU
拿到dog框和couch框之后,最直觉的做法是算两者的 IoU(交并比),超过某个值就判定狗在沙发上。我第一版就是这么写的,结果误报率高得没法用。
原因在于 IoU 的分母是“并集面积”。沙发框通常很大,狗框相对小,两者重叠时并集仍然很大,IoU 天然偏低。一只狗真的趴在沙发上,IoU 可能只有 0.2 到 0.35;而一只狗站在沙发正前方,因为检测框有抖动、沙发框也会往下延伸,IoU 偶尔能冲到 0.15 以上。两个区间的差距被压缩得太小,阈值怎么调都不舒服。
更麻烦的是场景变化。同一只狗,趴在沙发正中间和趴在沙发边缘,IoU 能差三倍。同一张沙发,白天光线下框得紧,晚上红外画面下框得松,都影响分母。所以 IoU 这个指标在这里不是不能用,是不能单独用。
我的改法是换成“覆盖率”:以狗框为基准,算它有多大比例落在沙发区域内。分母是狗框面积,不受沙发框大小影响;分子是交集面积。这个指标衡量的是“这只狗有多少身子在沙发上”,语义上比 IoU 更贴近我们要判断的东西。
// 覆盖率:inner 有多少比例落在 outer 里 function overlapRatio(inner, outer) { const [ix, iy, iw, ih] = inner; const [ox, oy, ow, oh] = outer; const x1 = Math.max(ix, ox); const y1 = Math.max(iy, oy); const x2 = Math.min(ix + iw, ox + ow); const y2 = Math.min(iy + ih, oy + oh); if (x2 <= x1 || y2 <= y1) return 0; const inter = (x2 - x1) * (y2 - y1); return inter / (iw * ih); }覆盖率有个副作用要留心:如果某个检测框异常大,把狗和周围地板一起框进去了,覆盖率会被稀释。所以我还会额外加一道“尺寸合理性”过滤——狗框面积超过画面面积 40% 的基本可以认为框爆了,直接丢弃这一帧的该目标。
2.2 覆盖率加中心点的组合判据
只靠覆盖率还是会被一种情况骗到:狗站在沙发前面,身体下半部在地板上,但因为透视关系,它的框和沙发框在图像上有重叠,覆盖率算出来可能有 0.4 左右。
真正能区分“站在前面”和“躺在上面”的信号,是框的垂直位置关系。狗躺在沙发上时,它的框底边通常高于沙发框底边一定距离;站在前面的狗,框底边会接近甚至低于沙发框底边。不过这个信号依赖摄像头角度,俯视和侧视差别很大,硬编码阈值容易翻车。
我最后用的是三重条件的组合:覆盖率超过阈值、狗框中心点落在沙发区域内部、狗框底边不低于沙发框底边太多。三条同时满足才算一次命中。
function isOnSofa(dog, sofa, opts) { const { minCover = 0.55, bottomTolerance = 0.15 } = opts; const [dx, dy, dw, dh] = dog.bbox; const [sx, sy, sw, sh] = sofa.bbox; const cover = overlapRatio(dog.bbox, sofa.bbox); if (cover < minCover) return false; const cx = dx + dw / 2; const cy = dy + dh / 2; if (cx < sx || cx > sx + sw || cy < sy || cy > sy + sh) return false; const dogBottom = dy + dh; const sofaBottom = sy + sh; if (dogBottom > sofaBottom + sh * bottomTolerance) return false; return true; }bottomTolerance设成沙发高度的 15%,意思是允许狗框底边略微超出沙发框底边一点,因为模型对沙发底部的定位经常不准。这个参数别设得太死,宁可放宽一点靠状态机去防抖。
2.3 手绘区域:绕开 couch 检测不稳定的更稳做法
用一个动作触发误报的证明是:你调了半天阈值,某天沙发换了布套,couch框整个消失了,系统直接失效。因为couch是模型给的,模型认不出来就没有区域了。
所以我在第二版里把主判据换成了手绘区域。首次打开页面时,在一个静止的画面上用鼠标点几个点,围出沙发的多边形,存进 localStorage。之后每一帧都用这个多边形做区域判定,完全不依赖couch的检测结果。
好处有三个。一是稳定,沙发换布套、堆抱枕、光线变化,区域都不会跑。二是精确,多边形能贴合 L 型沙发、转角沙发这种不规则形状,而矩形框只能给个包围盒,边角会误纳地板。三是省算力,couch那条检测分支可以直接免掉,只跑dog一类,过滤更简单。
代价是位置固定。摄像头一旦被碰歪或者重装,区域要重新画。为此我加了个小功能:页面顶部固定显示上一次的画面缩略图,你调整完摄像头可以对照着看区域有没有偏。这个细节听起来琐碎,但实际用起来很救命,尤其是摄像头装在柜子顶上、你搬个凳子上去拧螺丝的场景。
点在多边形内的判定用射线法,代码不长,性能足够(多边形顶点一般不超过十个):
function pointInPolygon(pt, poly) { let inside = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const xi = poly[i].x, yi = poly[i].y; const xj = poly[j].x, yj = poly[j].y; const cross = (yi > pt.y) !== (yj > pt.y); if (cross && pt.x < ((xj - xi) * (pt.y - yi)) / (yj - yi) + xi) { inside = !inside; } } return inside; }和覆盖率结合的时候,我改成了“狗框四角加中心点,共五个采样点,至少三个落在多边形内”作为命中条件。这比只测中心点稳,因为狗趴着的时候中心点可能正好落在沙发扶手的空白处,但四个角还在垫子上。
2.4 状态机与防抖:连续帧确认、退出条件和冷却窗口
即使判定函数写对了,单帧结果也不能直接用。检测器输出是有抖动的,同一只狗在不同帧之间的分数、框位置都会跳变,某一帧命中、下一帧不命中是常态。如果直接按单帧触发告警,你会收到一串高频提示音。
我用了三个参数来管这件事:连续命中帧数、连续未命中帧数、冷却时间。
连续命中帧数(HIT_FRAMES)的作用是抗瞬时误报。设成 3,意味着要连续三帧都判定为在沙发上才触发。按 10 FPS 算,大约是 300 毫秒的持续确认。这个时间短于狗跳上沙发的动作时长,所以不会漏掉真实事件;又能过滤掉狗跑过镜头前面那种一闪而过的重叠。
连续未命中帧数(MISS_FRAMES)管的是退出。设成 12,大约 1.2 秒。为什么不设成 3 就退出?因为狗在沙发上换姿势、被自己尾巴挡住、或者短暂跳到扶手外侧,都会造成中间几帧不命中。如果退出太快,状态会在 alert 和 idle 之间反复横跳,告警音跟着断断续续,非常吵。
冷却时间(COOLDOWN_MS)管的是重复告警。狗在沙发上待十分钟,你不需要每分钟收到六十次提示。设成 60 秒,触发一次之后一分钟内不再响,但状态仍然保持 alert,画面上的红框也还在。
const state = { mode: 'idle', hitStreak: 0, missStreak: 0, lastAlertAt: 0 }; const HIT_FRAMES = 3; const MISS_FRAMES = 12; const COOLDOWN_MS = 60000; function tickState(hit) { if (hit) { state.hitStreak++; state.missStreak = 0; } else { state.missStreak++; state.hitStreak = 0; } if (state.mode === 'idle' && state.hitStreak >= HIT_FRAMES) { const now = Date.now(); if (now - state.lastAlertAt >= COOLDOWN_MS) { state.mode = 'alert'; state.lastAlertAt = now; fireAlert(); } } else if (state.mode === 'alert' && state.missStreak >= MISS_FRAMES) { state.mode = 'idle'; } }这里有个容易写错的细节:冷却期内如果hitStreak不断累加,冷却一结束会不会立刻又触发?在这个写法里不会,因为触发条件是mode === 'idle',冷却期内 mode 一直是 alert,被挡住了。等狗真下去了、判定不再命中、退出 alert,下一轮再上来才重新计。这个行为是对的。
想加“离开后多久允许再次告警”的话,可以在退出 alert 的时候重置lastAlertAt或者单独搞一个canAlertAfter时间戳,别把冷却逻辑和状态退出逻辑揉在一起,会乱。
2.5 阈值怎么定:一套能复现的标定流程
所有阈值都不该拍脑袋。我的做法是攒素材再标定,具体分四步。
第一步是采集。用手机在真实机位、真实光线下录三段视频:狗在沙发上的、狗在沙发附近但不上的、画面里没狗的。每段一到两分钟,覆盖白天和晚上开灯两种情况。
第二步是回放。把视频文件喂给同一个页面(用<video loop muted>替代摄像头流),让程序按固定间隔输出每一帧的检测结果和判定中间值,写进一个数组,最后导出成 CSV。
第三步是统计。把“狗在沙发上”那段的所有帧覆盖率拉出来,看分布——真实命中的帧覆盖率大多落在 0.62 到 0.95 之间;再看“狗在附近但不上的”那段,覆盖率大多低于 0.35。这两段之间的空档就是你的阈值可用区间,取中间偏下的位置,比如 0.5,留出余量。
第四步是验证。拿那两段视频回放跑完整状态机,看误报次数和漏报次数。误报多就往小调HIT_FRAMES或者往大调覆盖率阈值;漏报多就反向调。改一次跑一次,两三轮回合基本就能定下来。
这套流程听起来麻烦,但比“打开摄像头让狗配合演示”高效太多——狗不会配合,而视频可以无限重播。
3. 代码实现:一个能直接跑的检测页
3.1 页面骨架与摄像头接入
页面结构极简:一个<video>负责采集,两个 canvas 叠在上面,一个画检测框和区域,一个用来截帧存档。视频用playsinline和muted修饰,避免在移动端被系统接管播放器或者自动播放被拦。
<div class="stage"> <video id="cam" playsinline muted autoplay></video> <canvas id="overlay"></canvas> </div> <div class="bar"> <button id="btnStart">启动</button> <button id="btnDraw">画沙发区域</button> <span id="stat">未启动</span> </div>摄像头接入要注意两点。第一,getUserMedia必须在安全上下文里调用,也就是https或者localhost。用局域网 IP 直接访问http://192.168.x.x是不行的,浏览器会直接拒绝。解决办法要么起一个带本地证书的静态服务,要么在本机用localhost开着,想看远端画面再另想办法。
第二,分辨率别贪。getUserMedia的width和height我建议设成{ ideal: 640 }和{ ideal: 480 },让浏览器自己降采样。用 1080p 采集再缩小给模型,中间的拷贝成本白花了,后面 4.1 节会算这笔账。
async function startCamera() { const stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 480 }, frameRate: { ideal: 15 } }, audio: false }); const video = document.getElementById('cam'); video.srcObject = stream; await video.play(); syncCanvasSize(); return stream; }syncCanvasSize()这一步是后面所有坐标能对上的前提。canvas 的width和height属性要设成video.videoWidth和video.videoHeight,而不是 CSS 显示尺寸,然后用 CSSwidth: 100%让它铺满容器。这样模型返回的 bbox 坐标可以直接画到 canvas 上,不用做任何缩放换算。反过来,如果 canvas 属性设成 CSS 尺寸,画面会被拉伸,框也跟着歪。
3.2 模型加载与串行推理循环
模型加载是异步的,第一次会下载权重文件,之后走缓存。加载的时候给个状态提示,别让用户对着黑屏猜。
let model = null; async function loadModel() { setStatus('加载模型中…'); model = await cocoSsd.load({ base: 'lite_mobilenet_v2' }); setStatus('模型就绪'); }推理循环这里有个坑,我第一版是用setInterval每 100 毫秒调一次detect(),结果内存曲线一路往上爬,页面十分钟就卡死。原因是detect()返回 Promise,如果上一帧还没算完就开始了下一帧,任务会堆叠,中间产生的张量来不及释放。
正确做法是串行自调度:算完一帧,看花了多少时间,再从剩余时间里补一个setTimeout开始下一帧。这样无论推理快慢,同一时刻永远只有一个任务在跑。
const FRAME_INTERVAL = 100; // 目标 10 FPS let loopTimer = null; let running = false; async function loop() { if (!running) return; const t0 = performance.now(); if (video.readyState >= 2 && model) { try { const preds = await model.detect(video, 20, 0.4); handleFrame(preds); } catch (err) { console.warn('推理异常', err); } } const cost = performance.now() - t0; lastCost = cost; loopTimer = setTimeout(loop, Math.max(0, FRAME_INTERVAL - cost)); }model.detect()的第二个参数是最大检测框数,第三个是置信度下限。最大框数设 20 绰绰有余,家里画面里同时出现的东西不会太多,设大了反而拖慢后处理。置信度下限 0.4 是个起点,狗这个类别在正常光线下分数普遍较高,设 0.4 能过滤掉大部分噪声,后面可以按实测调。
3.3 推理调度:别用 setInterval(补充实操)
这里再强调一下调度问题,因为它是最容易踩的坑。setInterval(fn, 100)只保证“每 100 毫秒尝试调用一次”,不保证上一次执行完了。当推理耗时超过 100 毫秒(老机器上很常见),任务就会排队,队列只会越来越长,表现为画面上的框越来越滞后,最终浏览器标签页卡死。
用上面那种串行自调度,实际帧率会自动降到机器能承受的水平。老机器可能只有 5 FPS,但画面是实时的、不卡顿的。对这种监控类需求,5 FPS 完全够用——狗跳上沙发的动作持续一两秒,不会漏。
判断实际帧率,可以维护一个滑动平均:
const fpsWindow = []; function updateFps(cost) { fpsWindow.push(cost); if (fpsWindow.length > 20) fpsWindow.shift(); const avg = fpsWindow.reduce((a, b) => a + b, 0) / fpsWindow.length; document.getElementById('stat').textContent = `推理 ${avg.toFixed(0)} ms / 约 ${(1000 / Math.max(avg, FRAME_INTERVAL)).toFixed(1)} FPS`; }3.4 判定与告警动作
告警动作我配了四样:提示音、页面上闪红框、存一张截图、写一条日志。四样各有用处——提示音管即时提醒,红框让你一眼看到是哪只狗,截图是事后证据,日志用来统计频率(比如发现它每天下午三点准时上去)。
提示音用 Web Audio API 现场合成,不引外部音频文件,省一个请求也不怕加载失败。这里有个浏览器策略要处理:AudioContext在用户没有交互之前是 suspended 状态,直接播会静音。所以启动按钮的点击事件里顺手resume()一下,把状态解开。
let ac = null; function initAudio() { if (!ac) ac = new (window.AudioContext || window.webkitAudioContext)(); if (ac.state === 'suspended') ac.resume(); } function fireAlert() { // 双音提示 if (ac && ac.state === 'running') { [880, 1180].forEach((freq, i) => { const osc = ac.createOscillator(); const gain = ac.createGain(); const t = ac.currentTime + i * 0.25; osc.type = 'square'; osc.frequency.value = freq; gain.gain.setValueAtTime(0.18, t); gain.gain.exponentialRampToValueAtTime(0.001, t + 0.22); osc.connect(gain).connect(ac.destination); osc.start(t); osc.stop(t + 0.25); }); } captureSnapshot(); pushLog(); }截图别一直往数组里塞,内存会爆。我保留最近 20 张,用环形缓冲,超过就丢掉最老的。toDataURL('image/jpeg', 0.7)的压缩率设 0.7,一张 640×480 的图大约 40KB 到 60KB,20 张也就一兆多,可以接受。
const snapshots = []; function captureSnapshot() { const c = document.createElement('canvas'); c.width = video.videoWidth; c.height = video.videoHeight; c.getContext('2d').drawImage(video, 0, 0); snapshots.push({ url: c.toDataURL('image/jpeg', 0.7), at: Date.now() }); if (snapshots.length > 20) snapshots.shift(); }3.5 可视化调试层与 ROI 绘制
调试期一定要把框和判定中间值画出来,不然你根本不知道是模型没检到,还是检到了但判定没通过。这两件事的排查方向完全相反。
画框的时候,不同状态用不同颜色区分:狗框用绿色,命中时改成红色;沙发区域多边形用半透明蓝填充加描边。旁边顺带把当前帧的覆盖率、命中帧数打在角落,方便对照录像回放调参。
ROI 绘制做成一个模式:点“画沙发区域”按钮进入绘制态,视频暂停在某一帧,鼠标点击依次落点,双击闭合。画完把点集归一化成 0 到 1 的相对坐标再存 localStorage——存相对坐标的好处是以后换了分辨率或者摄像头,区域还能按比例还原。
function saveRoi(points, w, h) { const norm = points.map(p => ({ x: +(p.x / w).toFixed(4), y: +(p.y / h).toFixed(4) })); localStorage.setItem('sofaRoi', JSON.stringify(norm)); } function loadRoi(w, h) { const raw = localStorage.getItem('sofaRoi'); if (!raw) return null; try { return JSON.parse(raw).map(p => ({ x: p.x * w, y: p.y * h })); } catch (e) { return null; } }归一化这一步值得单独讲,因为它解决的是一类通用问题:任何跟你“当时的分辨率”绑定的配置,在设备变更后都会失效。统一转成相对坐标,是代价最低的兼容方案。
4. 性能调优:让轻薄本也能跑到 10 FPS
4.1 分辨率、帧率、模型档位的三角平衡
这三个参数互相牵制,改一个另外两个都会受影响。先把账算清楚,调整起来才有方向。
像素量级对比:320×240 是 7.68 万像素,640×480 是 30.7 万,1280×720 是 92.2 万,1920×1080 是 207 万。从 480p 到 1080p,像素涨了 6.75 倍。GPU 纹理上传和 canvas 绘制的成本大致跟像素数成正比,所以这部分开销会翻将近七倍。
但模型内部的输入尺寸是固定的。lite_mobilenet_v2那一档把输入缩放到的分辨率远小于 1080p,也就是说,你把 1080p 喂进去,模型看到的还是缩放后的小图,精度不会因为源分辨率高而提升(除非狗在画面里占比很小,缩放后彻底糊了)。多出来的计算全是白花的。
结论很明确:源分辨率给 640×480 就够,画面里狗的高度占比最好不低于画面高度的 15%。如果摄像头装得远、狗太小,正确的做法是把摄像头挪近或者换个视角,而不是加大采集分辨率。
帧率同理。狗上沙发这个事件的时间尺度是一两秒,你不需要 30 FPS 去捕捉。8 到 10 FPS 足够,每帧留出 100 毫秒的预算,机器压力小很多。真正需要高帧率的场景(比如检测快速移动、做轨迹追踪),这个方案本来也不适合。
4.2 WebGL 后端与显存释放
TensorFlow.js 会自动选择后端,优先 WebGL,退到 WASM,最后 CPU。你可以在启动时打一行日志确认走的是哪个:
console.log('backend:', tf.getBackend());如果是cpu,那基本别指望实时了,先检查浏览器有没有禁用硬件加速。核显也够用,集显跑这个量级的模型没问题,问题往往出在浏览器设置上。
显存泄漏的常见来源是自己做预处理时创建了张量却忘了释放。用model.detect(video)直接传 video 元素的话,内部会自己管理,一般不会漏。但如果你自己写tf.browser.fromPixels()做裁剪、缩放,那每一个中间张量都要.dispose(),或者整段包在tf.tidy()里。
// 需要自己做预处理时,务必用 tidy 包住 const input = tf.tidy(() => { const raw = tf.browser.fromPixels(video); const resized = tf.image.resizeBilinear(raw, [300, 300]); return resized.toFloat().div(255); }); // 用完手动释放返回的那个 input.dispose();一个判断有没有泄漏的土办法:打开开发者工具的性能面板,录三分钟,看内存曲线。正常的曲线应该是锯齿状上下波动但整体平的;如果是一条斜向上的直线,那就是漏了。
4.3 后台标签页、定时器与长时间运行
浏览器为了省电,会对后台标签页的定时器做节流,setTimeout最短可能被拉到 1 秒一次,requestAnimationFrame在页面隐藏时干脆不执行。这意味你把页面切到别的标签,监控就基本停了。
如果你的用法是一直开着这个页面(挂在一个旧显示器或者平板上),这不是问题。如果需要后台运行,可以在页面可见性变化时打个标记,回到前台时重新对齐状态,避免用积压的时间戳做判断。
document.addEventListener('visibilitychange', () => { if (document.hidden) { pausedByHide = true; } else if (pausedByHide) { pausedByHide = false; state.hitStreak = 0; state.missStreak = 0; state.lastAlertAt = Date.now(); // 给个冷却,避免回来立刻响 } });长时间运行还有两个实际问题。一是摄像头断流,USB 摄像头被碰松、或者驱动休眠,video流的 track 会 ended,画面卡在最后一帧但代码还在跑,你会以为一切正常。加个监听自动重连:
stream.getVideoTracks()[0].addEventListener('ended', async () => { console.warn('摄像头断开,准备重连'); await new Promise(r => setTimeout(r, 2000)); if (running) startCamera(); });二是内存缓慢增长。哪怕代码写得再干净,长时间跑也会有碎片。我设了一个定时刷新:每天凌晨重新加载一次页面,或者在检测到内存占用超过某个值时主动location.reload()。这个做法很土,但对家用长期运行的小工具来说,土办法往往最可靠。
5. 踩坑实录与排查手册
5.1 漏检的六种典型场景
逆光与夜间。晚上只开一盏暖光灯,摄像头自动增益拉高,画面噪点重、狗的边缘糊,检测分数会掉到 0.2 以下。最有效的办法是补光,加一盏常亮的小夜灯在摄像头附近,别指望调算法解决物理问题。
蜷成一团。这是最顽固的漏检源。狗趴着的时候,模型看不到头、腿、尾巴这些判别特征,只剩一团毛。分数经常在 0.25 到 0.35 之间飘。应对是把置信度下限降下来(比如降到 0.25),然后靠区域判定和状态机去过滤噪声——反正它已经在沙发上了,误报的代价也就是多响一次。
只露头或者露尾巴。狗钻在靠垫缝里,只露出半个头。检测器基本无解。这个只能靠区域判定兜底:既然它已经在沙发区域内,露不露头其实不影响结论。
毛色与沙发接近。灰狗配灰沙发,对比度极低。我的处理是把沙发区域画得稍微往里收一点,宁可让它踩到边缘漏判一次,也别天天误报。
距离太远。狗在画面里占比太小。前面说过,挪摄像头是唯一正解。
模型档位太低。lite_mobilenet_v2在复杂背景下小目标弱。如果你发现白天也经常检不到,先换mobilenet_v2试一版,别急着改判定逻辑。
5.2 误报的四大来源
猫被认成狗。COCO 里cat和dog的混淆率不低。最直接的解法是显式过滤——如果同一帧里既有cat又有dog,而且框的位置高度重叠,倾向于认为那是同一只动物,按cat处理。如果家里只养猫没养狗,那就更简单了,只信cat,把dog的置信度下限调到很高。
靠垫、毛毯、玩偶被认成狗。COCO 里有个teddy bear类,被误判成dog很常见。加一道亮度或纹理检查不现实,实际做法是提高区域判定的门槛——沙发上的抱枕长期位于沙发区域内,如果光靠“有狗框且在区域内”就会经常误报,所以必须叠加“框的宽高比合理”这个条件。狗的标准框宽高比大致在 0.5 到 2.5 之间,太方或者太扁的框直接丢。
狗从沙发前走过。前面 2.2 节那三重条件就是为了解决这个。如果还是偶尔误报,把HIT_FRAMES从 3 提到 5。
框爆。某个检测框把狗和周围地板一起框进去了,覆盖率被稀释反而通过了判定。加一道面积上限过滤,超过画面面积 40% 的框直接丢弃。
5.3 排查速查表
遇到问题先别改代码,按这张表定位。
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| 页面上完全不画框 | 模型没加载完 / 后端是 cpu | 控制台看tf.getBackend(),看是否有加载报错 |
| 有框但和画面错位 | canvas 尺寸取了 CSS 尺寸 | 检查 canvas 的 width/height 属性是否等于 videoWidth/videoHeight |
| 画面流畅但检测结果严重滞后 | 用了 setInterval 导致任务堆叠 | 改成串行自调度,看队列是否还在积压 |
| 一定时间后页面卡死 | 张量泄漏 / 截图数组无限增长 | 性能面板录内存曲线,检查是否有未释放的 tf 张量 |
| 一直不告警 | 区域没画 / 阈值太严 / 冷却没结束 | 把覆盖率实时打在屏幕上,看数值卡在哪一步 |
| 告警音不响 | AudioContext 处于 suspended | 在用户点击事件里调用resume() |
| 切开标签页就失效 | 浏览器对后台定时器节流 | 见 4.3 节,或改为前台常驻 |
| 摄像头突然黑屏 | track ended | 监听ended事件并重连 |
| 手机打不开摄像头 | 不是安全上下文 | 检查协议是不是 https 或 localhost |
5.4 用录像回放替代真人演示
这条是我觉得最值得单独拎出来讲的。调这个系统最痛苦的部分不是写代码,是测试——每次改一个阈值,你得让狗配合着上沙发、下来、再上去。狗不配合,你就只能在那儿干等。
我的做法是在页面里加一个“演示模式”:用<video loop muted playsinline>替换摄像头流,直接把手机录的视频当成输入源。同一个检测循环不改一行代码,model.detect()对 video 元素一视同仁,不管它是摄像头流还是文件。
这个改动带来三个好处。一是可重复,同一段视频跑一百次,每次的输入完全一致,你能精确比较改阈值前后的差异。二是可量化,让程序把每帧的检测结果和判定中间值导出成 JSON,用脚本统计误报帧数和漏报帧数,改参数的时候有数据支撑而不是凭感觉。三是可存档,建立一个小素材库,把各种难例(逆光、蜷缩、猫路过)都录下来,以后调整逻辑时当回归测试跑一遍。
素材库我建议至少覆盖这几类:正常白天、夜间开灯、狗在沙发上、狗在沙发旁、只有猫、画面空。每段一到两分钟,够用了。
我个人在实际操作中的体会是,这类小项目真正耗时间的从来不是模型和代码,而是判定逻辑的调参和边界情况的兜底。模型给你的是“这一帧里有什么”,但“算不算数”这件事,得你自己用几何关系、状态机、区域约束一层层筛出来。所以别急着上更重的模型,先把判定层写扎实——同样的 COCO-SSD,判定层写得好和写得糙,可用性差着十倍。