狗一听见门响就蹿上沙发,回来一看坐垫上留个坑——这事儿我盯着摄像头画面折腾了大概两周,最后落地的东西比想象中简单:一个纯前端页面,打开摄像头,浏览器实时目标检测在本地跑,识别到狗压进沙发那块区域就响一声。全程不上传任何画面,老笔记本上也跑得动。这套方案适合两类人:一类是家里有狗、想低成本做个"电子狗哨"的养宠人;另一类是刚接触前端视觉、想找个有真实约束条件的项目练手的人——它涉及模型加载、视频流接入、坐标换算、状态机去抖、长时间运行稳定性,麻雀虽小五脏俱全。下面我把整个拆解过程、踩过的坑和能直接抄的代码都摊开讲。
1. 需求拆解:这件事到底难在哪
1.1 把"防狗上沙发"翻译成技术语言
很多人一听这需求,第一反应是"不就是个目标检测吗,找个模型框出来不完了"。真做起来你会发现,"检测到狗"和"狗上了沙发"是两个完全不同难度的问题。前者是通用的单帧分类定位,后者是一个带空间关系、时间连续性、并且需要控制误报的业务判定。我把需求拆成四层,每一层都有独立的失败模式,必须分开处理,不然出了问题根本不知道该改哪。
第一层是画面获取,得在浏览器里拿到稳定的摄像头流,解决权限、分辨率协商、前后摄像头切换这些问题。第二层是单帧推理,把视频帧喂给模型,拿到"哪些位置有什么物体、有多确信"的结果。第三层是空间判定,判断检测框落没落在你划的沙发范围内——这是纯几何计算,跟模型没关系,但错得最多的往往是它。第四层是时间决策,单帧结果抖动极大,可能这帧 0.62 下帧 0.41,需要一个状态机把零散信号攒成"事件",再触发提示音。
注意:不要指望模型直接输出"狗在沙发上"这种结论。通用检测模型只知道"这团像素像狗",它不懂什么叫沙发,也不懂你家客厅的布局。沙发这个概念必须由你在业务层用人划的区域来定义。
这四层里,第三层和第四层是我花时间最多的地方。第一版我图省事,只要检测框中心点落进矩形区域就报警,结果狗从沙发前走过、只露出一半身子的时候疯狂误报;后来改成判断框的底部中点(近似狗脚着地的位置),误报立刻掉了大半。这个细节后面会详细讲,它背后其实是一个朴素的物理直觉:判定"站上去"应该看接触面,而不是看整体重心。
1.2 正确率、延迟、隐私三者怎么取舍
任何实时检测系统都在三个指标之间拉扯:准确率、响应延迟、资源占用。家用场景的诉求和工业质检完全不同,判断标准只有一条——误报和漏报哪个更不能忍。狗上沙发这件事,漏报的代价是坐垫上多个坑,误报的代价是半夜突然响一声把人吓醒。所以我的默认策略是宁可慢半秒,不要乱响:宁可检测频率低一点、确认帧数多一点,也要把误报压到接近零。
响应延迟方面,我做了一个粗糙的体验测试:从狗屁股挨上坐垫到提示音响起,2 秒以内人不会有"这玩意儿卡了"的感知,3 秒以上就会觉得迟钝。这个预算分配下来就是:推理间隔 200 毫秒左右,连续 3 帧确认,理论最快反应时间约 600 毫秒,留足了余量。
隐私是这套方案最硬的优势。所有像素在整个生命周期里都没离开过浏览器进程——视频流只存在于<video>元素和 Canvas 里,推理结果只有几个坐标和分数。如果你换成把视频推到远端服务去分析,那客厅画面等于天天往外传,我个人是完全不能接受的。这也是我坚持把它做成纯前端方案的核心原因,而不是"为了炫技"。
1.3 整体链路长什么样
先把全貌画出来,后面每块单独展开。数据从摄像头进来,经节流控制后送进模型,模型吐出一堆检测框;判定模块筛出"狗"这一类,和预先标定好的沙发区域做几何比对;命中后交给状态机累积,达到稳定条件才触发提示音,同时往日志里写一条记录。
整个链路里唯一"重"的环节是模型推理,其余全是很轻的几何计算和状态维护。这也决定了优化方向应该优先砸在推理上:降低输入尺寸、拉开推理间隔、选轻量模型。我见过有同学花大量时间优化判定逻辑的性能,其实那部分开销小到可以忽略,方向一开始就偏了。
还有一条容易被忽略的约束:CPU 和显卡占用不能拉满。这页面是要 7×24 挂着跑的,如果推理把笔记本风扇吹得呼呼响,用户三天就把它关了。所以后面我会专门讲"主动降速"的取舍。
2. 技术选型:为什么把模型塞进浏览器
2.1 浏览器端推理的三种可行路线
摆在我面前的选择大概有三个方向,我实际都试过或评估过。第一种是纯前端跑轻量检测模型,用 WebGL 加速,模型文件几 MB,首屏加载几秒,之后完全离线。第二种是前端只负责采集,把画面编码后送到后端或边缘设备推理,准确率上限更高,可以换更重的模型,但要维护服务、要处理带宽、要面对画面外传的隐私问题。第三种是趁设备厂商的本地视觉能力,但这通常是封闭能力,可定制性差,也不好跨设备复用。
我最后选第一种,理由是匹配度:家用场景的类别极其有限(只需要区分"狗"和"不是狗"),空间范围固定(就一个沙发),对绝对精度的要求远低于对"跑得动、不出事、不联网"的要求。在这种约束下,用一个大模型把 80 类都识别到很高的 mAP,属于过度设计。
| 方案 | 首屏/接入成本 | 准确率上限 | 隐私 | 长期运维 | 适合场景 |
|---|---|---|---|---|---|
| 浏览器本地推理 | 低,加载即用 | 中 | 画面不出设备 | 无 | 家用、单场景、类别少 |
| 推流到服务端推理 | 高,要部署 | 高 | 画面外传 | 有 | 多点位、需要统一管理 |
| 厂商封闭能力 | 中 | 不可控 | 一般较好 | 依赖厂商 | 快速验证 |
表格里"准确率上限"那一栏是相对的。浏览器本地方案的准确率天花板确实低一些,但在我这个场景里,真正拉低准确率的不是模型规模,而是光照、遮挡和画面质量——这三样换成多大的模型都救不回来。与其纠结模型,不如先把摄像头位置摆好、把补光做上,收益大得多。
2.2 轻量检测模型的选型逻辑
浏览器里能用的现成检测模型不算多,主流是两类:一类是通用目标检测,比如基于 MobileNet 骨干的 SSD 系列,开箱即用,能识别常见类别,其中就包含狗;另一类是姿态/关键点模型,输出骨架而非框,判断"是否站上去"更精细,但类别覆盖窄,需要额外训练。我选了前者,因为它的类别表里天然带"狗"这一项,不需要自己准备数据集就能跑通全流程。
选型时我主要看三个参数:输入分辨率、骨干网络、权重体积。输入分辨率直接决定推理耗时,300×300 左右的输入在移动端芯片上单帧大约几十毫秒,够用;骨干网络越深越准也越慢,家用场景用最轻的那档即可,因为目标(一只成年犬)在画面里占比通常不小,不需要强特征提取能力。权重体积决定首屏体验,几 MB 的量级在普通宽带上 2 到 3 秒能下完,可以接受,配合浏览器缓存,第二次打开几乎瞬开。
提示:模型加载一定要做失败兜底。我遇到过网络策略较严的环境里模型文件下不下来的情况,页面直接白屏。后来加了超时和错误提示,至少能告诉用户"模型没加载成功",而不是让人对着空页面猜。
另外一个我认为必须提前想清楚的问题是:要不要自己训练模型。如果你的狗是深色长毛、经常缩成一团、或者沙发本身是毛绒材质导致边界模糊,通用模型的召回率会明显下降。这时候的解法不是换更大的模型,而是用迁移学习在通用模型基础上补几百张自家场景的图微调。这个工作量不小,我建议先用通用模型跑一周,统计漏报都发生在什么条件下,再决定要不要投入。
2.3 一句话说清架构
这套东西的架构可以压缩成一句话:一个页面、两条循环、一个区域。页面就是单个 HTML,包含视频元素、用于可视化的 Canvas、和几个控制按钮;两条循环分别是视频播放(浏览器内部驱动)和检测循环(我用时间戳节流主动控制);一个区域是用户手动标定的沙发多边形。没有服务端,没有数据库,没有消息队列,配置全部存在本地。
这种"极简形态"不是偷懒,而是刻意为之。家用工具的失败模式通常是"复杂度导致的不可维护",加一个后端就多一个可能挂掉的组件。我宁可把能力边界缩到最小,也要保证它能长期无人值守地跑。
3. 核心原理:从检测框到"狗在沙发上"
3.1 模型输出的到底是什么
先说清楚模型吐出来的东西长什么样,不然后面所有判定都是空中楼阁。一次检测调用返回的是一个数组,每个元素包含三部分:边界框、类别名、置信度。边界框的格式是一个四元组,代表左上角横纵坐标加上宽高——注意是宽高,不是右下角坐标,这个格式差异是新手最容易写错的地方,写错了框会整体偏移一个身位。
置信度是一个 0 到 1 之间的数,表示"模型有多确信这个位置有这个东西"。它不是概率意义上的绝对准确率,只是一个相对排序的分数。实践中你会发现同一只狗在不同帧之间的分数波动很大,起身时 0.7,趴着不动时掉到 0.4,这不是模型坏了,而是目标姿态和遮挡变化导致的正常现象。
// 一次调用的返回结构(示意) [ { bbox: [312, 118, 186, 240], class: 'dog', score: 0.71 }, { bbox: [40, 300, 90, 120], class: 'couch', score: 0.55 } ]有意思的是,部分通用检测模型的类别表里确实包含沙发类。我一开始很兴奋,想着能不能直接用检测框和沙发框求交集,省掉手动标定。实测下来放弃了这个念头:沙发这类大件家具的检测稳定性远不如狗,稍微被抱枕遮一下就检不出来,而且只要有一帧丢了沙发框,整个判定就断了。手动划一次区域,稳定性高出一个数量级。
3.2 沙发布防区怎么划才靠谱
区域标定这件事,看起来只是"点几下鼠标",实际上决定了后面所有判定的准确率上限。我试过三种划法,最后留下的是多边形。矩形最简单,但沙发通常有扶手和靠背,矩形会把沙发前方的地板也圈进去,狗路过就误报。多边形可以贴着扶手内沿描一圈,把"坐垫可站立面"精确框出来。
标定的操作流程我做成:页面先截取一帧画面当底图,用户在上面依次点击若干顶点,最后闭合,点位存进本地存储。这里有个细节必须处理——标定用的底图和实际推理的坐标系必须一致。如果视频元素被 CSS 拉伸过,而底图是按原始分辨率截的,那画出来的区域和真实画面对不上,判定会全错。我的做法是统一用视频的原始内部分辨率作为坐标系,显示时再按比例缩放。
区域数据我建议存成归一化坐标(0 到 1 之间的小数),而不是像素值。原因很实在:换了摄像头、改了分辨率,像素坐标就废了,归一化坐标乘以当前宽高就能自动适配,同一个区域配置可以跨设备复用。这个习惯在图像类项目里通用,早建立早省事。
3.3 从单帧到"事件":去抖与状态机
单帧判定必须加状态机,这是整个项目里我觉得最有"工程味"的一段。原始信号是这样的:帧 1 命中、帧 2 未命中、帧 3 命中、帧 4 命中……如果每帧命中就响,你会被提示音淹没。所以需要一个类似硬件的"去抖"逻辑:连续命中 N 帧才认为状态切换为"上沙发",连续未命中 M 帧才认为"离开"。
我把 N 设成 3,M 设成 8。为什么离开的阈值要比进入的大?因为狗趴着不动的时候,模型偶尔会掉一两帧,如果 M 也设成 3,状态会疯狂在"在"和"不在"之间翻转,触发一堆无意义的日志。离开判定松一点,宁可晚点认为它走了,也不要中途抖动。
再叠加一层冷却时间:一次触发之后,30 秒内不再重复触发。这一层防的是"狗赖在沙发上不走"的情况——状态一直保持在命中态,但从业务上讲,你不需要它每 3 帧就提醒你一次。冷却时间我做成可配置,实际用下来 30 秒到 1 分钟比较舒服。
// 状态机核心逻辑(去掉无关分支) const state = { hits: 0, misses: 0, inside: false, lastAlert: 0 }; const ENTER_N = 3, EXIT_M = 8, COOLDOWN_MS = 30000; function tick(hit, now) { if (hit) { state.hits++; state.misses = 0; if (!state.inside && state.hits >= ENTER_N) { state.inside = true; if (now - state.lastAlert > COOLDOWN_MS) { state.lastAlert = now; fireAlert(); } } } else { state.misses++; state.hits = 0; if (state.inside && state.misses >= EXIT_M) { state.inside = false; } } }这段代码短,但每一行都有存在的理由。state.hits和state.misses互斥清零,保证计数语义只有一种解释;inside是唯一的业务状态,报警只发生在它的上升沿;lastAlert做冷却。很多同类项目写成"每帧独立判断 + 时间窗口过滤",逻辑散在各处,加一个新条件就要改三处,很难维护。
3.4 判定"站上去"用哪个参考点
这一节单独拎出来讲,因为我在这上面栽过跟头。最初我用检测框中心点判断是否在区域内,问题很明显:狗站在沙发前的地板上,头探过来闻坐垫,框的中心点可能已经越过了沙发边界,于是误报。反过来,狗趴在沙发边缘,只有前半身压在坐垫上,中心点还在外面,于是漏报。
第二种尝试是框与区域的重叠面积占比,即检测框有多大比例落在区域内。这个比中心点好,但阈值很难定:狗整个趴在沙发上时占比接近 1,只探个头时占比 0.05,中间一大段是模糊地带。
最终我用的是框底部边线中点,也就是取检测框左右两边的中间、下边线的那个点。理由很朴素:这个点近似对应目标与地面(或家具面)的接触位置。狗从沙发前走过,接触点在沙发前方的地板上,判定不命中;狗跳上去,接触点进入坐垫区域,命中。实测这个方案的误报率比中心点低了一个量级,代码还更简单。
// 接触点判定 + 面积过滤 function isOnCouch(det, roiPoly, minAreaRatio) { const [x, y, w, h] = det.bbox; // 过滤过小的框,压掉远处背景里的误检 if ((w * h) / (frameW * frameH) < minAreaRatio) return false; const footPoint = { x: x + w / 2, y: y + h }; return pointInPolygon(footPoint, roiPoly); }minAreaRatio这个过滤项也很有用。画面里偶尔会出现远处电视里一只狗、墙上挂画上的狗、或者一个小摆件被误检,它们的共同特征是框很小。设一个面积下限(我用的 1.5% 画面面积),一股脑全过滤掉,副作用极小,因为要真的上沙发,狗在画面里不可能太小。
4. 动手实现:一个能跑的最小闭环
4.1 页面骨架与依赖引入
页面结构我保持到最简:一个视频元素(不显示,只作为数据源)、一个叠加在上面的 Canvas(用来画框和区域)、一排按钮(启动、标定、静音)。刻意不在视频元素上做任何 CSS 缩放之外的装饰,就是为了减少坐标换算的心智负担。
依赖通过模块打包引入,不用全局脚本标签,避免版本混乱。核心就两个包:张量运算框架和目标检测模型。
import * as tf from '@tensorflow/tfjs'; import * as cocoSsd from '@tensorflow-models/coco-ssd'; // 优先使用 WebGL 后端,失败则回落到 CPU await tf.ready(); console.log('backend:', tf.getBackend()); const model = await cocoSsd.load({ base: 'lite_mobilenet_v2' });lite_mobilenet_v2这一档是我反复对比后定下来的。换成更重的档位,准确率提升在肉眼可辨的范围内很小,但单帧耗时几乎翻倍,在老旧设备上直接掉到个位数帧率。家用场景的目标足够大,轻量档完全够。
关于后端,tf.ready()之后打印一下当前后端是个好习惯。如果拿到的是 CPU 后端,说明 WebGL 没起来,页面会慢到不可用,这时候应该在界面上明确提示,而不是让用户以为"这东西就是这么卡"。我遇到过某些远程桌面环境里 WebGL 被禁用,就是靠这个日志定位的。
4.2 摄像头接入的坑都在约束参数里
取流本身几行代码,但参数写不好会带来一堆后续麻烦。我用的约束是:分辨率给一个理想值 640×480,帧率不强制,面向环境摄像头。640×480 是个甜点,再高对检测精度帮助有限(模型内部还会缩放),但会明显增加解码和绘制的开销。
const stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 480 }, frameRate: { ideal: 15, max: 20 }, facingMode: 'environment' }, audio: false }); video.srcObject = stream; await video.play();这里有几个坑值得单独说。第一,ideal不是强约束,设备可能给你一个完全不同的分辨率,所以后续所有坐标计算都必须读video.videoWidth和videoHeight,不能写死 640 和 480。我第一版就是写死的,换了个摄像头之后框整体偏移,查了半天。第二,play()要等,某些浏览器上srcObject赋值后立刻调用检测,会拿到宽度为 0 的帧,模型直接报错。加一个等待元数据就绪的逻辑更稳。第三,如果用了前置摄像头并且做了镜像显示,画面是左右翻转的,但模型推理用的是原始帧,这时候你画的框和看到的画面是对不上的,需要在绘制阶段单独处理翻转,别在判定阶段混着改。
注意:检测循环启动前一定要检查
video.readyState >= 2,否则会频繁触发"帧数据无效"的异常。这不是理论风险,我在低端机上稳定复现过。
4.3 推理循环的节流写法
循环这块,我最开始用requestAnimationFrame直接驱动,结果在 60Hz 屏幕上每秒调用 60 次推理,机器瞬间烫手,帧率还被拖垮。正确做法是用时间戳节流:动画帧照常跑(用于流畅绘制),但推理按固定间隔触发。
let lastInfer = 0; const INFER_INTERVAL = 200; // 毫秒 async function loop(now) { requestAnimationFrame(loop); if (now - lastInfer < INFER_INTERVAL) return; lastInfer = now; if (video.readyState < 2) return; const preds = await model.detect(video, 20, 0.35); const dogs = preds.filter(p => p.class === 'dog' && p.score >= 0.45); const hit = dogs.some(d => isOnCouch(d, roiPoly, 0.015)); tick(hit, performance.now()); draw(preds, dogs, hit); } requestAnimationFrame(loop);几个参数值得解释。model.detect的第二个参数是最大检测数量,我设 20,因为家里画面里不可能有更多目标,设小一点能省一点后处理时间。第三个参数是内部置信度阈值,设 0.35 是为了让模型多吐一些低分框给我自己筛,因为模型的阈值和我的业务阈值不是一回事——我要在"狗"这个类别上单独用一个更高的阈值(0.45)。这个双层阈值的设计很实用:模型的内部阈值宁可松一点,业务阈值严格把关,比反过来灵活。
INFER_INTERVAL设 200 毫秒,也就是每秒 5 次推理。这个频率听起来很低,但配合 3 帧确认,最慢 600 毫秒就能触发,人对这个延迟的感知是可以接受的。我把这个值做成可调,实测在性能好的机器上降到 120 毫秒体验更好,在老旧设备上提到 300 毫秒也不影响可用性。
还有一个隐藏问题:await model.detect是异步的,如果某次推理特别慢(比如系统突然卡了一下),可能出现两次推理交叠。虽然实际影响不大,但为了保险,我加了一个busy标志位,正在推理时直接跳过这一轮。
4.4 提示音触发与自动播放限制
报警用什么方式,我试过三种:页面弹提示、往手机推送、播声音。前两个都需要额外的服务端或通知权限,只有声音是纯前端的,所以选了声音。但浏览器有自动播放策略——页面没被用户交互过之前,音频播放会被拦截。这意味着你不能指望页面一打开就能响。
解法是在用户点击"启动"按钮的那一刻解锁音频上下文。这个动作本身就是一次用户手势,正好满足策略要求。我用的是音频接口实时合成一段短促的提示音,不需要额外的音频文件,省掉一次网络请求,也不会出现"音频加载失败导致静默"的情况。
let audioCtx; function unlockAudio() { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); if (audioCtx.state === 'suspended') audioCtx.resume(); } function fireAlert() { if (muted || !audioCtx) return; const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(880, audioCtx.currentTime); gain.gain.setValueAtTime(0.15, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.35); osc.connect(gain).connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime + 0.36); }音量我控制在 0.15 左右,实测这个音量在客厅里能听见,但不会吓人。频率用 880 Hz 的短音,比低频的嗡嗡声更容易被注意到。时长 0.35 秒,太短容易被环境噪声盖住,太长会烦。
4.5 可视化调试层的价值
我强烈建议做一个可视化层,把模型的原始检测框、筛选后的狗框、以及沙发区域全部画出来,颜色区分。理由不是好看,而是没有它你根本没法排查。误报的时候你要一眼看出是模型把什么当成了狗、那个框在哪、接触点落在区域的哪一侧。这些信息全靠日志输出根本看不明白。
绘制的坐标换算要小心。Canvas 的显示尺寸受 CSS 控制,内部尺寸应该设成视频的原始分辨率,然后用 CSS 缩放,这样drawImage和绘制坐标都在同一个坐标系里,不需要做任何缩放计算。如果反过来,按显示尺寸设内部尺寸,那每一帧都要做一次比例换算,容易出错,也浪费性能。
还有一个实用技巧:给命中的框和未命中的框用不同颜色,并且在画布角落实时显示当前状态机的计数(命中帧数、是否处于 inside 状态)。调试期间我几乎全靠这两个数字判断阈值调得合不合适。上线之后可以一键关掉,不影响正常使用。
5. 调优与长期运行:让它在客厅里活过一个星期
5.1 性能预算怎么分配
先说结论:在这个项目里,模型推理占了 90% 以上的计算开销,其余所有逻辑加起来不到 10%。所以优化必须瞄准推理,而不是去抠几何计算的性能。这个判断很重要,因为它决定了你该往哪个方向使劲。
我实际测过的一组数据(老笔记本集显,640×480 输入):轻量档模型单帧推理在 40 到 70 毫秒之间波动,绘制一帧在 2 毫秒以内,区域判定和状态机加起来不到 0.1 毫秒。按每秒 5 次推理算,CPU 占用大约在 20% 到 25% 之间,风扇会有轻微声音但不会持续高转。
| 优化手段 | 效果 | 代价 | 我的取舍 |
|---|---|---|---|
| 降低推理频率到 5fps | 开销降约 60% | 响应慢约 200ms | 采用 |
| 使用最轻骨干 | 开销降约 40% | 小目标召回略降 | 采用 |
| 缩小输入分辨率 | 开销降约 30% | 远处目标易漏 | 视情况 |
| 关闭可视化绘制 | 开销降约 2% | 没法调试 | 仅上线后关 |
| 后台标签页暂停 | 开销降 100% | 无 | 必须采用 |
最后一条特别重要。浏览器对后台标签页有节流策略,但不同浏览器的策略不一样,有的会把定时器压到每分钟一次,有的干脆不停。我加了一个基于页面可见性事件的显式暂停:页面切到后台就停掉检测循环,切回来再恢复。这一条既省电,也避免了"页面在后台偷偷跑了通宵"这种事。
5.2 长期运行的两个真实故障
第一个故障是日志数组无限增长。我最早把每次检测结果都推进一个数组,想着方便事后分析,跑了大概六个小时页面就明显变卡,内存占用一路往上走。修法很简单:给日志数组设一个上限(我用的 200 条),超出就从头覆盖。这类"环形缓冲"的思路在任何需要长时间记录的场景里都适用,成本极低,收益极大。
第二个故障是视频流意外中断。可能是摄像头被其他程序占用,可能是 USB 松动,也可能是系统休眠唤醒。表现是video.readyState一直是 0 或者画面卡住不动,但检测循环还在空转。我加了一个简易的健康检查:连续若干次推理失败或者连续若干秒画面无变化,就重建整个流并提示用户。这个逻辑有点土,但确实管用,尤其适合无人值守的场景。
// 简易健康检查(每 30 秒跑一次) setInterval(() => { if (video.readyState < 2 || video.currentTime === lastVideoTime) { stallCount++; if (stallCount >= 2) restartStream(); } else { stallCount = 0; } lastVideoTime = video.currentTime; }, 30000);5.3 光照、遮挡与摄像头位置
这三件事对准确率的影响,远比换模型大。我在同一个模型下做过对比:白天靠窗自然光,检测稳定在 0.7 以上;傍晚只开一盏暖色灯,分数掉到 0.45 上下,还经常漏检。这不是模型的问题,是画面信噪比下降导致的必然结果。
具体建议:摄像头架在能看到坐垫正面的位置,略微俯角,不要平视正对沙发——平视时狗趴下后被靠背挡住,只剩一撮毛露在外面,什么模型都检不出来。避开窗户逆光,否则狗会变成剪影。夜间要么补一盏小夜灯,要么接受夜间基本不可用这个现实(普通摄像头在无光环境下拍出来的画面,任何模型都无能为力)。
遮挡方面还有一个容易忽略的细节:天气热的时候狗喜欢趴在沙发靠背上,只露一个头。这种姿态在画面里占比很小,经常被面积过滤误杀。我的处理是把面积下限从 1.5% 调到 1%,代价是误检略微上升,但靠去抖逻辑兜住了。这种参数的调法没有标准答案,只能根据自己的实际画面反复试。
5.4 提示策略的人性化设计
技术上响一声就完事了,但真用起来你会发现提示策略本身也需要设计。我家的情况是,狗白天大部分时间都在沙发上趴着,如果一直响,我三天就想把这东西砸了。所以我加了两个机制:冷却时间和分时段静音。
冷却时间前面说过,解决的是"持续命中"的重复触发。分时段静音则是按小时设定不响的区间,比如夜里十一点到早上七点直接静音,只记录不播报。这两个机制加起来,实测一周的误报和打扰次数能压到个位数。
另外我建议把触发记录展示出来,哪怕只是一个列表,显示"几点几分触发了一次"。这玩意儿除了排查问题,还有个意外用途:你能大概知道狗一天什么时候爱上去。纯粹好玩。
6. 常见问题与排查实录
下面这些是我和自己、和几个朋友实际遇到过的问题,按现象整理成速查表,遇到问题先对照一下,能省不少时间。
| 现象 | 大概率原因 | 排查动作 |
|---|---|---|
| 页面打开一直空白 | 后端没起来或模型下载失败 | 打印当前后端和加载错误,检查网络 |
| 画面正常但从不报警 | 区域没标定好或坐标系错位 | 打开可视化层,看框和区域是否对得上 |
| 框位置整体偏移 | 用了写死分辨率或 CSS 缩放未换算 | 统一以 videoWidth/Height 为基准 |
| 画面左右和框相反 | 前置摄像头镜像了显示但没镜像坐标 | 在绘制阶段单独处理翻转 |
| 频繁误报 | 置信度阈值过低或用了中心点判定 | 阈值提到 0.45,改用底部中点 |
| 狗趴着不动就不报了 | 连续帧确认把静态目标滤掉了 | 降低进入阈值或提高推理频率 |
| 跑几小时后变卡 | 日志或数组无限增长 | 加环形缓冲上限 |
| 夜里完全不工作 | 无光环境画面不可用 | 补光或接受夜间不启用 |
| 声音不响 | 音频上下文未解锁 | 在用户点击回调里 resume |
| 页面切后台后不恢复 | 可见性事件未正确恢复循环 | 检查事件绑定与状态复位 |
讲几个表里没展开但比较典型的。关于"狗趴着不动就不报了",这个问题的成因挺有意思:狗刚跳上去的时候动作大、姿态舒展,模型容易检出;趴稳之后缩成一团,加上长时间静止,模型反而可能连续几帧都没检出。如果你的进入阈值设得太高(比如要连续 5 帧),很可能永远攒不够。我的做法是把进入阈值压到 3 帧,并且推理频率不要太低。
关于误报的具体来源,我统计过一周的误报,大致分布是:电视或手机屏幕里的狗(占一半以上)、墙上的挂画和玩偶、以及狗在沙发前转身时框的形变。前两类用面积过滤能挡掉一部分,挡不掉的只能靠位置过滤——屏幕和挂画的位置通常是固定的,把它们从区域里排除就行。第三类靠底部中点判定基本解决了。
提示:如果误报集中在某个固定位置,最快的解法不是调模型,而是在区域标定时把那个位置直接抠掉。多边形标定的好处就在这里,可以画得很细。
还有一个我认为值得单独提醒的坑:不要用检测框的重叠率(IoU)去和沙发区域做匹配。我一开始想当然地用了这个方法,结果发现沙发是大目标、狗是小目标,两者框的尺度差太远,IoU 天然就上不去,阈值怎么调都不对。正确的做法是用"检测框落在区域内的比例"或者干脆用点判定,语义上才对得上。
最后分享一个我在实际使用中觉得最有价值的小改动:给判定结果加一个置信度平滑。不是直接用单帧的分数,而是维护一个最近 5 帧的分数滑动平均,用平均值和阈值比较。这个改动只有几行代码,但能明显削掉那些"闪一下就没了"的抖动,让状态切换更稳。尤其是画面有轻微反光或者狗在动的时候,效果比单纯调阈值好得多。这个思路可以推广到所有需要把连续检测结果转成离散状态的场景里,比如人多的地方统计进出、停车位占用检测,本质都是同一个问题。