清晨的超市里,一位老练的果品分拣员站在传送带前,眼睛快速扫过每一个经过的苹果——不仅要判断“这是不是苹果”(分类),还要在瞬间喊出“这个苹果在左侧第三个格子,有点碰伤”(定位检测)。他不需要把每个水果拿起来仔细端详,靠的是多年训练出的条件反射:眼睛捕捉、大脑识别、手指挥动,一气呵成。 浏览器端的图像分类与目标检测,干的正是这档子事。只不过“分拣员”换成了浏览器里跑着的 JavaScript 代码,“传送带”换成了摄像头实时采集的视频流,“喊话”换成了在画布上画出一个个检测框。传统上,这种“视觉识别”被认为是后端服务器的专利——毕竟训练一个模型动辄需要 GPU 集群,推理一次也要消耗不少算力。但 TensorFlow.js 和 ONNX.js 等工具的出现,让浏览器这个“轻量级选手”也能扛起视觉识别的大旗。 这就像分拣员不是非要坐在中央控制室里操作超级计算机,而是直接在传送带旁支起一个小摊位,现场分拣、现场贴标。省去了“把水果送到中央仓库、等结果传回来”的漫长往返,代价则是摊位的空间有限、人手有限——你需要精打细算地安排每一步,才能在保持流畅的同时不把摊位挤爆。 这篇文章,就是带你走进这个“浏览器分拣台”,看看端侧的图像分类与目标检测究竟如何运转:如何从摄像头取流、如何批量处理推理请求、如何把检测结果画到屏幕上、又如何在一个低端手机上依然保住 30 帧的流畅体验。你会发现,这些问题的答案,本质上是一个“如何在有限资源下做最优调度”的故事。

岗位关联

掌握浏览器端图像分类与目标检测,意味着你能将 AI 能力直接嵌入 Web 产品,无需后端即可实现实时视觉交互——这是前端工程师迈向 AI 应用开发的关键一步,也是边缘计算浪潮中极具价值的技能储备。

目录
  1. 可视化演示
  2. 摄像头取流
  3. 推理批处理
  4. 检测框渲染
  5. 帧率优化
  6. 低端设备适配
  7. 方案选型对比
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示端侧推理流水线、帧率优化策略对比及目标检测框渲染后处理流程。切换模式查看不同环节。

本节配有交互动效(进入视口后自动加载,约 3KB)。

摄像头取流

分拣员上岗的第一件事是什么?不是练识别能力,而是先找到“眼睛”——架好摄像头,对准传送带。在浏览器里,这个“架摄像头”的动作就是 getUserMedia API。它像一把钥匙,打开设备的摄像头权限门,然后源源不断地把视频帧“传送”到浏览器里。但这里有个细节:摄像头给的是“视频流”,而 AI 模型吃的是“图片帧”——这就好比传送带上的水果是一个接一个连成线的,分拣员必须一张张“定格”来看。

于是,开发者需要从视频流中“抽帧”。最常见的做法是把视频帧绘制到一个隐藏的 <canvas> 上,再从画布里读出像素数据(ImageData),转成 TensorFlow.js 的张量。这中间有一个微妙的权衡:分辨率越高,模型看得越清楚,但推理耗时越长、内存占用越大。你不可能把 4K 的每一帧都塞给模型——就像分拣员不可能把传送带上的每个水果都拿起来翻个面端详。

不妨定义取流到画面帧的转换过程为一个简单映射。设摄像头输入帧的原始分辨率宽度为 \( W_0 \)、高度为 \( H_0 \),模型期望输入宽度为 \( W_m \)、高度为 \( H_m \),则缩放系数为:

除了分辨率,还有权限处理这个“看门”环节。用户拒绝授权、设备没有摄像头、浏览器不支持 getUserMedia……每一种异常都需要优雅降级,而不是让页面白屏。这就像分拣台前拉了根警戒线,你得先确认分拣员有资格站到这个位置。

回扣思路:取流是整个流程的“眼睛”,但眼睛也不是越亮越好——你要在“看得清”和“看得快”之间找到平衡点,这正是后续所有优化的起点。

s=min⁡(WmW0,  HmH0)\displaystyle s = \min\left(\frac{W_m}{W_0},\; \frac{H_m}{H_0}\right)
这个公式计算的是“把摄像头的大图等比缩小到模型能接受的小图”的比例。取两个比值中的较小者,保证缩放后不会拉伸变形——就像把一张大合照缩小到钱包照片尺寸,要按较短边等比缩放,才不会把脸拉变形。
  1. 模型输入宽度比计算宽度方向上的缩放需求
    WmW0\displaystyle \frac{W_m}{W_0}
  2. 模型输入高度比计算高度方向上的缩放需求
    HmH0\displaystyle \frac{H_m}{H_0}
  3. 取最小值选择两个比值中较小的,确保画面完全纳入且不变形
    s=min⁡(a,b)\displaystyle s = \min(a, b)

推理批处理

分拣员的瓶颈不在眼睛,而在大脑——每识别一个水果都需要几十毫秒的思考时间。如果传送带上的水果一个接一个地来,一个一个地思考,很快就会堆积如山。聪明的分拣员会怎么做?他会攒几个再一起处理:这一眼扫过去,同时看清三个苹果、两个梨、一个橙子。这就是“批处理”(batch processing)。

在浏览器推理中,批处理的典型做法是把多帧图像堆叠成一个高维张量,一次性交给模型推理。假设每一帧被缩放为 \( 224 \times 224 \times 3 \) 的 RGB 张量,现在把 \( b \) 帧合并为一个批次,那么这个批次张量的形状就是:

批处理能大幅提升吞吐量,但并非越大越好。批大小增加,单次推理的延迟会上升——分拣员一次看 100 个水果,思考时间可能比看 10 个水果长好几倍。在实时交互场景中,你需要找到“吞吐量最大但单帧延迟仍可接受”的平衡点。这就是批大小权衡的本质。

异步队列是批处理的另一半。浏览器里,模型的推理是异步的:你发出一帧,等待结果返回,期间并不阻塞主线程。就像一个好脾气的分拣员,把看不完的水果先放到一边,等手头这批处理完了再继续——tf.nextFrame() 和 Promise 机制保证了主流程不会因为等待推理而卡死。

回扣思路:批处理是“摊位的批量接单智慧”——不是一次接待一个客人,而是把相近时间到达的订单攒起来一起做,用略微增加的单单等待时间,换来整体接待能力的跃升。

batch_tensor=[b,  224,  224,  3]⏟帧数×高×宽×通道\displaystyle \text{batch\_tensor} = \underbrace{[b, \;224,\; 224,\; 3]}_{\text{帧数} \times \text{高} \times \text{宽} \times \text{通道}}
这里的 [b, 224, 224, 3] 可以理解为把 b 个“水果照片”摞成一叠,变成一个四维数据块。模型一次处理这一叠,相当于分拣员一眼扫过 b 个水果。
  1. 帧数维度批大小 \( b \),表示一次处理多少帧
    b\displaystyle b
  2. 空间维度每帧的高度和宽度
    224×224\displaystyle 224 \times 224
  3. 通道维度RGB 三个颜色通道
    3\displaystyle 3

检测框渲染

分拣员识别出水果之后,不能只在脑子里记着,得喊出来、贴上标签、甚至指给你看。在浏览器端,模型输出的“指给你看”就是检测框(bounding box)——一组 [x, y, width, height] 坐标加上类别和置信度。渲染这些框的工作,落到了 Canvas 2D API 身上。

渲染不是简单地画个方框。模型可能输出几百个候选框,其中大部分是重复的或者低置信度的噪声。你需要先把置信度低于阈值的框丢弃,再用非极大值抑制(NMS)去掉重叠的框。所谓阈值筛选,就好比分拣员只报告“90% 确定是苹果”的结果,对“50% 可能是苹果”的模糊判断保持沉默。

假设模型对某个候选框输出的原始置信度为 \( c \),阈值设定为 \( \theta \; (0 < \theta < 1) \),则该框被保留的条件为:

渲染与推理解耦是保持流畅的关键。推理在后台进行,渲染在主线程绘制,两者不该互相阻塞。常见的做法是:推理结果放入一个“结果缓冲区”,渲染循环每次从缓冲区取最新结果绘制,即使推理掉了帧,画面也不会卡顿——就像分拣员的大脑在思考时,嘴巴已经在说上一个结果了,两者各干各的,井水不犯河水。

回扣思路:检测框是分拣员“说出口的话”。话说得太多(阈值太低)会吵死人,话说得太少(阈值太高)又会漏报。恰到好处的阈值 + 流畅的“嘴皮子”(渲染解耦),才是好的表达。

c≥θ\displaystyle c \ge \theta
置信度阈值就是“分拣员的自信心门槛”。只有超过这个门槛的结果才被渲染出来,否则直接丢弃——避免满屏都是“好像有东西”的方框。
  1. 模型置信度模型输出对该框内物体属于某类别的概率
    c\displaystyle c
  2. 判定阈值设定的最低置信度门槛
    θ\displaystyle \theta
  3. 保留条件置信度不低于阈值时,候选框被保留并渲染
    c≥θ\displaystyle c \ge \theta

帧率优化

分拣员最怕什么?传送带跑得比他的思考速度快。一旦来不及处理,水果就会堆积、掉落、甚至堵住传送带。浏览器的“传送带”就是摄像头的帧率——假设摄像头以 30 FPS 输出帧,而模型推理一次需要 100ms(即 10 FPS),那么推理速度跟不上了,必须想办法。

第一个办法是“跳帧”。就像分拣员不会对每一个经过的水果都伸手去拿,而是有选择性地每隔几帧取一个来识别。定义推理频率 \( f_{\text{infer}} \) 和摄像头帧率 \( f_{\text{cam}} \),则跳帧间隔 \( k \) 为:

第二个办法是“降采样”。把送入模型的图像尺寸从 \( 640 \times 640 \) 降到 \( 320 \times 320 \),计算量大约会降到原来的四分之一(因为卷积操作的参数量与输入像素数成正比)。代价是精度下降——小图上小物体会更难被检测到。这就像分拣员离传送带远一点看,能看清大西瓜,但可能看不清小草莓。

第三个办法是“频率分离”。渲染保持全帧率(比如 30 FPS),而推理则按较低频率运行(比如 10 FPS),用最新一次的推理结果反复渲染多帧。这种“渲染快、推理慢”的策略,保证了画面流畅感和检测框的连续性——就像分拣员的眼睛持续盯着传送带(渲染),大脑每隔一小会才重新评估一次当前的局面(推理),中间用“上一轮判断”来撑着。

回扣思路:帧率优化是分拣台上的节拍器——不是让每个动作都快,而是让“看”和“说”合拍,用最小的算力代价换来视觉上最流畅的体验。

k=⌈fcamfinfer⌉\displaystyle k = \left\lceil \frac{f_{\text{cam}}}{f_{\text{infer}}} \right\rceil
这个公式算的是“每隔多少帧做一次推理”。如果摄像头 30 帧/秒,推理能力只有 10 次/秒,那就每 3 帧取 1 帧来推理,保证推理节奏跟得上。
  1. 摄像头帧率视频源每秒输出的帧数
    fcam\displaystyle f_{\text{cam}}
  2. 推理频率模型每秒能完成的推理次数
    finfer\displaystyle f_{\text{infer}}
  3. 向上取整保证跳帧间隔为整数,且推理量不超过能力上限
    k=⌈fcamfinfer⌉\displaystyle k = \left\lceil \frac{f_{\text{cam}}}{f_{\text{infer}}} \right\rceil

低端设备适配

不是每个分拣员都站在最新款的传送带前。很多用户手里的设备是几年前的千元机,浏览器的性能捉襟见肘。这时候,你不能让应用直接卡死或闪退,而要像一位经验丰富的分拣班长,给每个摊位配置适合的方案。

第一个适配手段是“模型量化”。把模型权重从 32 位浮点数压缩到 8 位整数,模型体积大约缩小到原来的四分之一,推理速度提升明显,但精度会有轻微下降。量化后的模型就像一个“速记版”的分拣手册——字迹潦草了一些,但关键信息都在,查起信息来反而更快。

假设原始浮点权重的数值范围为 \( [r_{\min}, r_{\max}] \),将其均匀映射到 8 位整数范围 \( [0, 255] \),则量化缩放因子为:

第二个适配手段是“WebGL 回退”。TensorFlow.js 默认用 WebGL 做 GPU 加速,但低端设备的 GPU 可能不支持某些特性,或者驱动有 bug。这时候需要检测环境,回退到 CPU(WASM)后端,虽然慢一些,但稳定。这就像分拣台上准备了手动的弹簧秤,电子秤坏了也能称重,只是慢点。

第三个适配手段是“动态降级”。通过监控推理耗时,动态调整输入分辨率和推理频率:如果检测到推理时间超过阈值,自动降低分辨率或增大跳帧间隔。这是“自适应节拍器”——设备性能好,就调高节奏;设备卡顿,就自动放慢。用户不会看到明显的卡死,只会发现精度稍微下降了一点。

第四个是“内存监控”。浏览器环境下,模型推理的中间张量可能占用大量内存,低端设备尤其容易 OOM(内存溢出)。定期调用 tf.memory() 检查张量数量,及时释放不再使用的中间结果,就像分拣台边有个垃圾桶,果皮果核随手丢,保持台面整洁。

回扣思路:低端设备适配的哲学是“小灶台也能做大锅饭”——通过量化瘦身、环境回退、动态降级和内存管理,让每一份算力都花在刀刃上。

Δ=rmax⁡−rmin⁡255\displaystyle \Delta = \frac{r_{\max} - r_{\min}}{255}
缩放因子 \( \Delta \) 就是把浮点数范围“等分”成 256 份的刻度大小。刻度越小,量化越精细,精度损失越小;刻度越大,信息丢失越多。
  1. 权重范围原始浮点权重的最小值和最大值
    rmin⁡,rmax⁡\displaystyle r_{\min}, \quad r_{\max}
  2. 量化级数8 位整数可以表示的离散级数
    255\displaystyle 255
  3. 缩放因子每一级对应的浮点数值跨度
    Δ=rmax⁡−rmin⁡255\displaystyle \Delta = \frac{r_{\max} - r_{\min}}{255}

方案选型对比

当你要为项目选择“浏览器分拣台”的搭建方案时,市场上主要有两个主流的“装备包”:TensorFlow.js 和 ONNX.js。它们都能在浏览器里跑神经网络,但是侧重点不同。

TensorFlow.js 是 TensorFlow 生态的浏览器延伸。它的最大优势是“亲儿子”待遇:你可以直接加载 Keras 训练的模型,也可以使用它提供的各种预训练模型(如 COCO-SSD、MobileNet)。如果你已经在用 TensorFlow 生态,TensorFlow.js 是最顺手的方案——就像你一直用某个品牌的厨具,新出的便携款自然上手最快。

ONNX.js 则是一个“翻译官”式的方案。ONNX(Open Neural Network Exchange)是一个开放的模型交换格式,PyTorch、TensorFlow、Keras 等框架训练出来的模型都能导出成 ONNX,然后由 ONNX.js 在浏览器里加载运行。它的核心价值是“跨框架互操作”——你可以在 PyTorch 里训练,在浏览器里部署,而不必拘泥于某个特定生态。

两个方案都对模型大小敏感。以 MobileNetV2 为例,原始浮点模型约 14MB,量化为 8 位后约 3.5MB。是选择体积大、精度高的模型,还是体积小、精度略降的模型?要考虑用户首次加载的等待时间。定义加载时间预算 \( T_{\text{budget}} \) 和模型大小 \( M \),在带宽为 \( B \) 的网络下,可加载的最大模型为:

场景适配是选型的最终判据。如果你的应用是“快速原型、要在浏览器里做实验、团队熟悉 JS”——TensorFlow.js 是首选。如果你的应用是“已经在 PyTorch 里训练好了模型、想直接搬到浏览器且不想重新训练”——ONNX.js 更合适。另外还有一个隐形的选型标准:模型是否需要在服务端复用。如果有服务端推理需求,ONNX 的“一次导出、多处运行”优势会更加明显。

回扣思路:选型没有银弹,就像分拣员选工具——有人喜欢弹簧秤(TensorFlow.js),因为熟悉顺手;有人喜欢电子秤(ONNX.js),因为通用性强。关键是搞清楚自己的传送带有多宽、水果种类有多少、分拣精度要求多高,再决定买哪套装备。

Mmax⁡=B×Tbudget\displaystyle M_{\max} = B \times T_{\text{budget}}
这个公式是“带宽 × 时间 = 能下载的数据量”的现实应用。如果网络带宽是每秒 1MB,加载预算 3 秒,那模型就不该超过 3MB——超过的话用户等太久,很可能直接关掉页面。
  1. 带宽网络每秒能传输的数据量
    B\displaystyle B
  2. 时间预算可接受的模型加载时间上限
    Tbudget\displaystyle T_{\text{budget}}
  3. 最大模型体积两者相乘得到模型大小上限
    Mmax⁡=B×Tbudget\displaystyle M_{\max} = B \times T_{\text{budget}}

AI 应用场景

场景一:实时口罩检测的 Web 应用

阶段:浏览器端目标检测全流程(取流 → 推理 → 渲染 → 优化)

解决的问题:传统做法是把摄像头视频流上传到服务器进行口罩识别,存在两大痛点:一是视频上传消耗大量带宽,延迟高;二是涉及用户隐私,很多用户不愿意把摄像头画面传到云端。浏览器端推理把整个流程放在本地,画面不需要离开设备,既保护隐私又降低延迟。

具体案例:某办公楼的访客系统在浏览器中运行 TensorFlow.js + COCO-SSD 模型,访客在平板上打开网页,摄像头自动取流,模型实时检测人脸区域并判断是否佩戴口罩。检测到未佩戴口罩时会显示红色检测框并发出提醒。由于采用了跳帧策略(每 3 帧推理一次)和 WebGL 加速,普通 Android 平板上也能达到 20 FPS 的流畅体验。

场景二:商品瑕疵检测的在线演示系统

阶段:图像分类 + 检测框渲染

解决的问题:电商平台需要向卖家展示 AI 质检的能力,但部署一套完整的服务端推理系统成本高、周期长。浏览器端推理让销售团队可以在任何电脑上打开网页,直接用本地摄像头演示商品瑕疵检测,无需后端支持。

具体案例:某服装电商平台用 ONNX.js 加载 PyTorch 训练的布料瑕疵检测模型,销售人员在客户电脑上打开演示网页,对准一块布料,模型实时标出破洞、污渍、色差等瑕疵位置。模型经过 INT8 量化后体积从 25MB 压缩到 6MB,加载时间从 8 秒降到 2 秒。

Python 演示

python90 行
# -*- coding: utf-8 -*-"""浏览器端目标检测核心逻辑演示(批处理+NMS)"""import numpy as np  class FrameBatcher:    """模拟浏览器端帧批处理器"""     def __init__(self, batch_size=4, conf_thres=0.6, iou_thres=0.5):        self.batch_size = batch_size        self.conf_thres = conf_thres        self.iou_thres = iou_thres        self.frame_queue = []        self.result_cache = []     def add_frame(self, frame_id, boxes, scores):        """模拟加入一帧的原始检测结果"""        self.frame_queue.append((frame_id, boxes, scores))        if len(self.frame_queue) >= self.batch_size:            return self.flush()        return None     def flush(self):        """批量处理队列中的帧"""        if not self.frame_queue:            return None        batch = self.frame_queue[:]        self.frame_queue = []        return self._batch_infer(batch)     def _batch_infer(self, batch):        """对一批帧执行推理和NMS"""        all_results = []        for frame_id, boxes, scores in batch:            keep = self._nms(boxes, scores)            for idx in keep:                all_results.append({'frame': frame_id, 'box': boxes[idx], 'score': scores[idx]})        return all_results     def _nms(self, boxes, scores):        """非极大值抑制:去除重叠候选框"""        order = np.argsort(scores)[::-1]        keep = []        while order.size > 0:            i = order[0]            keep.append(i)            if order.size == 1:                break            ious = self._compute_iou(boxes[i], boxes[order[1:]])            order = order[1:][ious <= self.iou_thres]        return keep     def _compute_iou(self, box_a, box_b_list):        """计算两个框的IoU重叠度"""        x1 = np.maximum(box_a[0], box_b_list[:, 0])        y1 = np.maximum(box_a[1], box_b_list[:, 1])        x2 = np.minimum(box_a[2], box_b_list[:, 2])        y2 = np.minimum(box_a[3], box_b_list[:, 3])        inter_area = np.maximum(0, x2 - x1) * np.maximum(0, y2 - y1)        box_a_area = (box_a[2] - box_a[0]) * (box_a[3] - box_a[1])        box_b_area = (box_b_list[:, 2] - box_b_list[:, 0]) * (box_b_list[:, 3] - box_b_list[:, 1])        union_area = box_a_area + box_b_area - inter_area        return inter_area / np.maximum(union_area, 1e-6)  if __name__ == '__main__':    batcher = FrameBatcher(batch_size=4, conf_thres=0.6, iou_thres=0.5)     # 模拟4帧图像的原始检测输出    frames = [        (1, np.array([[10, 10, 60, 60], [12, 12, 58, 58], [100, 100, 150, 150]]),         np.array([0.9, 0.7, 0.8])),        (2, np.array([[20, 20, 80, 80], [100, 100, 160, 160]]),         np.array([0.85, 0.75])),        (3, np.array([[30, 30, 70, 70], [200, 200, 300, 300]]),         np.array([0.65, 0.95])),        (4, np.array([[15, 15, 65, 65], [50, 50, 120, 120]]),         np.array([0.55, 0.88])),    ]     batcher.result_cache = []    for frame in frames:        res = batcher.add_frame(frame[0], frame[1], frame[2])        if res:            batcher.result_cache.extend(res)     print('批处理推理结果(已过滤低置信度并做NMS):')    for r in batcher.result_cache:        print('  帧%d: 框=%s 置信度=%.2f'              % (r['frame'], r['box'].tolist(), r['score']))
目的:演示浏览器端目标检测中的两个核心算法——批处理合并多帧数据、非极大值抑制去掉重叠框。这段逻辑在 TensorFlow.js 的端侧推理中同样存在,只是发生在浏览器内部。
  1. class FrameBatcher帧批处理器类,对应浏览器端把多帧攒成一批的核心逻辑
  2. self.frame_queue = []帧队列实例属性,存放等待批处理的帧
  3. add_frame()加入一帧原始检测结果,积累到 batch_size 时触发批处理
  4. _batch_infer()对一批帧执行推理后处理,对应 TensorFlow.js 的一次批量推理
  5. _nms()非极大值抑制,把重叠的候选框合并成最终结果
  6. order = np.argsort(scores)[::-1]按置信度从高到低排序,优先保留最有信心的框
  7. _compute_iou()计算两个框的交并比,衡量框的重叠程度
  8. ious <= self.iou_thres只保留与当前框重叠较小的框,丢弃几乎重复的框
  9. np.maximum(union_area, 1e-6)防止除零,保证数值稳定性

常见误区

误区我以为浏览器端 AI 就是把 Python 的模型推理代码用 JS 重写一遍,逻辑一模一样,只是换个语言。结果发现连“模型文件怎么加载”都搞不定,更别提 GPU 加速了。

正解浏览器端推理的复杂之处不在于“换语言”,而在于运行环境的天壤之别。Python 版模型跑在服务器上,有无限的 CPU/内存/GPU 资源;浏览器端跑在用户设备上,需要处理 WebGL 加速、内存限制、模型加载延迟、主线程阻塞等一系列问题。这就像同样是做饭,在中央厨房跟在小推车上完全不是一回事——前者可以随意摆开阵势,后者得想着怎么在方寸之间周转。

为何常见:因为大多数人接触 AI 都是从 Python 开始的,天然会认为“换个语言就行”,忽略了浏览器这个“边缘设备”本身的资源约束。

误区我觉得取流帧率拉到 60 FPS 肯定体验最好,结果页面卡成幻灯片。后来才发现,取流帧率只是“传送带速度”,真正决定体验的是推理帧率和渲染帧率的配合。

正解摄像头帧率高,意味着每一帧都要绘制到 Canvas、要做预处理,消耗的是 CPU/GPU 资源。如果模型推理只能跑 10 FPS,你喂给它 60 FPS 的帧也只会浪费计算资源,还可能因为过载导致推理延迟反而增大。好的做法是:渲染保持流畅(比如 30 FPS),推理按需降低频率(比如 10 FPS),并用跳帧策略让两者协调。

为何常见:人们直观地认为“更高 = 更好”,忽略了系统是一个整体,瓶颈在推理而不是取流。就像传送带跑得再快,分拣员来不及处理,只会造成堆积。

误区我以为阈值设低一点就能“捕捉”到所有目标,结果画面里全是密密麻麻的方框,连墙上的一团影子都被框出来了,根本没法用。

正解目标检测模型的原始输出包含大量候选框,其中很多是高度重叠或低置信度的噪声。阈值的作用是“宁缺毋滥”——设得低,召回率上去了但精确率惨不忍睹;设得高,精确率上去了但可能漏检。实际应用中需要根据场景调参:安全监控场景宁可误报也怕漏报,阈值可以低一些;商品计数场景则要求精确,阈值需要调高。

为何常见:不熟悉检测模型输出机制的人,会把原始输出当成“最终结果”,不知道候选框只是模型的“草稿”,还需要筛选和去重。

误区我看它们都是“浏览器跑 AI 的库”,以为差不多,随便选一个就行。结果按 TensorFlow.js 的教程做了半天,才发现同事给的模型是 ONNX 格式,根本加载不了。

正解TensorFlow.js 是“TensorFlow 生态的浏览器延伸”,它擅长加载 TensorFlow / Keras 生态的模型;ONNX.js 则是“跨框架的翻译官”,接收的是开放格式 ONNX 模型。两者在模型来源、加载方式、支持的算子层面都有差异。选型时应该看你的模型从哪来、要部署到哪去,而不是“哪个热门用哪个”。

为何常见:初学者看到它们都能“在浏览器里跑模型”,就自然地画上了等号,忽略了模型格式这个关键差异。就像看到两个人都能“切菜”,就以为他们用的是同一把刀。

误区我一开始就强制 WebGL 后端,觉得 GPU 肯定比 CPU 快。结果在用户的老款电脑上一测试,反而比 CPU 模式还慢,甚至直接崩溃。

正解WebGL 加速的实际效果高度依赖设备 GPU 的能力和驱动质量。对于小型模型(如 MobileNet),CPU(WASM)后端可能已经足够快,而且更稳定;WebGL 的优势主要体现在大型模型的并行计算上。更合理的做法是运行时检测设备能力,动态选择后端,而不是写死一种。就像不是所有路都适合开跑车,山路可能还骑摩托更快。

为何常见:人们容易迷信“GPU 一定快”的直觉,忽略了 WebGL 本身的调用开销、纹理上传成本和低端 GPU 的兼容性问题。

面试题

Q1. 在浏览器中,哪个 API 用于获取摄像头视频流?

单选题 · easy

  • A.getUserMedia← 正确答案
  • B.getVideoStream
  • C.openCamera
  • D.mediaCapture

答案:A。getUserMedia 是 WebRTC 体系中用于获取摄像头和麦克风流的标准 API。它返回一个 Promise,解析为 MediaStream 对象。其他选项是干扰项——getVideoStream、openCamera、mediaCapture 都不是浏览器标准 API。这就像打开摄像头权限门只有一把官方配的钥匙,其他都是仿造的。

Q2. 把摄像头视频帧送入 TensorFlow.js 模型前,通常需要做什么?

单选题 · easy

  • A.把视频帧绘制到 Canvas 再提取像素,转换成张量← 正确答案
  • B.直接传入 HTMLVideoElement 对象
  • C.将视频流压缩为 JPEG 后传入
  • D.等待摄像头自动转换格式

答案:A。tf.browser.fromPixels 可以从 Canvas 或 Video 元素直接提取像素并转换为张量,但 Video 元素内部也需要经过绘制逻辑。最标准和可控的做法是先把帧绘制到 Canvas,再提取像素数据转成张量。模型吃的是“图片帧数据”,不是视频流本身,就像分拣员需要“定格”的水果照片,而不是一整段录像。

Q3. 批量推理的批大小从 1 增大到 4,下列哪种说法正确?

单选题 · mid

  • A.单帧延迟一定降低,吞吐量一定提升
  • B.吞吐量提升,但单帧延迟可能上升← 正确答案
  • C.吞吐量和单帧延迟都一定提升
  • D.吞吐量和单帧延迟都不变

答案:B。批处理的核心权衡是“用单帧延迟的小幅增加换取吞吐量的大幅提升”。批大小为 1 时每帧单独推理;批大小为 4 时需要等 4 帧凑齐,单帧延迟可能因排队而增加,但总体吞吐量(每秒处理的帧数)通常会提升。就像分拣员攒 4 个水果一起看,单个水果的等待时间略长,但整体效率更高。

Q4. 模型输出 200 个候选框,置信度阈值 0.7,大约保留了 30 个,接下来应该做什么?

单选题 · mid

  • A.直接把这 30 个框全部画出来
  • B.用非极大值抑制(NMS)去除重叠框← 正确答案
  • C.把置信度阈值再调低到 0.3
  • D.把 30 个框平均合并成一个

答案:B。阈值筛选之后,剩下的候选框往往包含大量高度重叠的重复框——同一个物体可能被框了 5 次。NMS 的作用是:按置信度排序,保留最高分的框,去掉与它重叠度(IoU)超过阈值的其他框,然后重复这个过程。直接画出来会看到一堆重叠方框,调低阈值更糟糕,平均合并则没有依据。

Q5. 摄像头输出 30 FPS,模型推理能力为 8 FPS,合适的跳帧间隔是多少?

单选题 · mid

  • A.2 帧
  • B.3 帧
  • C.4 帧← 正确答案
  • D.5 帧

答案:C。跳帧间隔 \( k = \lceil 30 / 8 \rceil = \lceil 3.75 \rceil = 4 \)。意思是每 4 帧取 1 帧送入模型,实际推理频率约为 \( 30/4 = 7.5 \) FPS,略低于模型能力上限,留有余量。取 3 帧则为 10 FPS,可能超出模型能力;取 5 帧则为 6 FPS,精度浪费。这个计算正是“分拣员跟不上传送带时,有节奏地跳过一部分水果”的数学表达。

Q6. 以下哪些是低端设备适配的常见手段?

多选题 · mid

  • A.将模型从 FP32 量化为 INT8← 正确答案
  • B.检测到 WebGL 不可用或异常时回退到 CPU← 正确答案
  • C.动态降低输入图像分辨率← 正确答案
  • D.将摄像头帧率调高到 60 FPS

答案:ABC。模型量化(A)能直接减少模型体积和计算量;WebGL 回退(B)保证老设备上也能稳定运行;动态降分辨率(C)是“性能自适应”的常见策略。而调高摄像头帧率(D)不仅不帮助低端设备,反而会加重 CPU/GPU 负担。D 选项的分拣员“传送带加速”方向完全反了。

Q7. TensorFlow.js 使用 WebGL 后端做 GPU 加速时,下列哪个问题可能导致它比 CPU 后端还慢?

单选题 · hard

  • A.模型参数量超过 100 万
  • B.多次 GPU 和 CPU 之间的张量拷贝← 正确答案
  • C.浏览器窗口尺寸不是整数
  • D.Canvas 的宽度大于高度

答案:B。WebGL 加速需要把数据从 CPU 内存上传到 GPU 纹理,推理结果还要拷回 CPU 层。如果代码中频繁在 GPU 和 CPU 之间交换数据(比如每帧都做 dataSync() 或反复转换张量),传输开销会超过 GPU 并行计算带来的收益,导致整体反而更慢。这就像跑车每次提速都要先花时间从车库挪到赛道,来回折腾反而没小电驴快。

Q8. 关于 TensorFlow.js 和 ONNX.js 的对比,下列说法正确的有?

多选题 · hard

  • A.TensorFlow.js 可以直接加载 Keras 训练的模型← 正确答案
  • B.ONNX.js 可以加载 PyTorch 导出的 ONNX 模型← 正确答案
  • C.两者都支持 GPU 加速← 正确答案
  • D.ONNX.js 必须搭配 TensorFlow.js 才能运行

答案:ABC。TensorFlow.js 是 TensorFlow 生态的浏览器端运行时,能加载 Keras 的 .h5 或 TF SavedModel 转出的模型(A 对)。ONNX.js 加载开放格式 ONNX 模型,PyTorch 可以通过 torch.onnx.export 导出(B 对)。两者都支持 WebGL 作为后端(C 对)。ONNX.js 独立运行,不依赖 TensorFlow.js(D 错)。它们的关系更像是“两种不同菜系”,而不是“主菜和配菜”。

Q9. 在帧率优化中,“渲染与推理解耦”的含义是什么?

单选题 · hard

  • A.渲染和推理同时进行,互不阻塞← 正确答案
  • B.只渲染当前推理的帧,其他帧丢弃
  • C.渲染由 GPU 完成,推理由 CPU 完成
  • D.推理结果只保存一帧就立即覆盖

答案:A。渲染与推理解耦的核心是:推理在异步后台进行,结果写入缓冲区;渲染循环以全帧率(如 30 FPS)持续绘制,每次从缓冲区取“最新可用”的检测框。即使推理只完成了最近一次(如 8 FPS),渲染也不会等待推理结果,所以画面依然流畅。这是“分拣员嘴巴不停、大脑偶尔重新评估”的工程化实现。

Q10. 某浏览器端检测应用的模型大小为 30MB,网络带宽平均 1MB/s,加载预算 5 秒。下列哪些措施可能让它在预算内加载?

多选题 · extreme

  • A.量化模型到 INT8,使体积降到 8MB← 正确答案
  • B.使用 HTTP 缓存减少二次访问加载← 正确答案
  • C.将带宽预估改为理论峰值 30MB/s
  • D.模型分片懒加载,先加载首屏所需部分← 正确答案

答案:ABD。量化(A)直接把 30MB 压到 8MB 左右,在 5 秒内绰绰有余。HTTP 缓存(B)对首次加载无效,但能大幅加速二次访问,属于合理优化。分片懒加载(D)把模型按需加载,首屏只需加载部分权重。C 选项是自欺欺人——理论峰值带宽不等于实际可用带宽,就像传送带标称速度不等于实际分拣速度,不能用理想值当设计依据。