我最早接触到“以图搜图”这个需求,是帮一个摄影社区做图库管理。当时第一反应是上服务端跑特征提取,模型用 MobileNet,最后一层截掉,拿 1024 维向量做余弦相似度。方案本身不复杂,真正让我头疼的是服务端的资源成本、接口延迟和运维链路:图片一多,GPU 实例就烧钱;离用户远了,单次推理动不动几百毫秒;再加上图片本身属于敏感数据,把用户上传的每张图都送到远端模型处理,隐私上总有顾虑。
后来我尝试把整个特征提取链路搬到浏览器端,用 WebGPU 跑 MobileNet。实测下来效果超出预期:中高端显卡上一次推理大概 30 到 80 毫秒,比走服务端还快,而且图片根本不上传,在本地就把特征向量算完了。这篇文章想把完整的技术方案、选型理由、实现细节和踩坑记录都拆开讲清楚。如果你是做图像检索、相似图片匹配、图片去重,或者只是想了解 WebGPU 到底能在浏览器里跑什么 AI,这篇应该能给你省不少弯路。
1. 为什么把特征提取放进浏览器:我在以图搜图项目里的真实选择
1.1 以图搜图的两段式流程
以图搜图听起来像是一个“图片输入,结果输出”的整体系统,但拆开看,本质上是两个独立阶段:
- 特征库构建阶段:把图片库里的所有图片依次送入特征提取模型,得到各自的语义向量。这就是“入库”的过程。
- 在线检索阶段:用户上传一张查询图,用同一个模型把它转换成语义向量,然后在特征库里找出最相似的若干条。
这套流程的关键就在于:查询图和库里的图必须经过同一个模型、处于同一个向量空间,相似度计算才有效。而整个系统的成本大头,几乎都集中在特征提取模型上。
服务端方案是这样跑的:图片通过前端上传到后端,上传到对象存储临时目录,再触发一个推理服务去下载图片、做预处理、跑模型、返回向量。这中间每一环都是耗时点:上传带宽限制、存储 IO、推理队列等待、结果回传。用户看到的不是“推理速度”,而是整个链路的总延迟。
1.2 服务端推理的三道坎
第一道坎是 GPU 成本。MobileNet 这类轻量模型虽然单张推理只需几十毫秒,但架不住请求量大。一台不带 GPU 的服务器并发一高就排队,带 GPU 的实例价格直接翻几倍,而且资源峰值很难预测。数据库图库如果做了 CDN 加速,模型结果其实更适合前端算——凡是能推到离用户更近的地方的计算,就不该跟后端较劲。
第二道坎是带宽和延迟。一张手机照片原图动辄三五兆,上传之后再下载推理结果,一来一回,用户感知到的等待时间远不止模型推理本身。很多团队给题库做的是“先压缩再上传”,但压缩本身也要白等。
第三道坎是数据隐私。图库里的用户照片经常是个人资料、医疗影像、私人收藏等敏感数据。用户信任你把照片交上来,不代表愿意看到照片被送到第三方模型服务里做推理。浏览器端推理可以做到图片不离开本机,这个属性在很多高合规场景下是刚需,而不是加分项。
1.3 浏览器端推理带来的连锁好处
把特征提取搬到浏览器端以后,整个系统结构发生了有趣的变化:
- 服务端不再需要跑推理,只负责存储和检索语义向量。
- 用户图片在本地被显卡直接处理,算完只上传一个 1024 维的 Float32Array,对带宽几乎零压力。
- GPU 算力成本转嫁到用户设备上,平台方可以大幅缩减推理集群的规模,甚至完全去掉 GPU 实例。
- 图片不出本机,隐私合规压力也会小很多。
当然,代价也有:你不再能控制用户的硬件环境。低端手机没有独立 GPU,WebGPU 支持也不完善。这个问题后面会讲,我的做法是后端回退(WebGPU → WebGL → WASM),保证老设备虽然慢一点,但功能不丢。
2. WebGPU 驱动浏览器的 GPU 算力:从底层看它为何能管用
2.1 浏览器里跑 AI 的可选方案
在 WebGPU 之前,浏览器端做 AI 推理主要靠三条路线:WebGL、WebAssembly(WASM)和 WebGPU。
WebGL 是目前 TF.js 生态里最成熟的方案,原理是把神经网络的计算映射成图形学里的纹理渲染操作。矩阵乘法这种操作在 WebGL 里通过 Fragment Shader 逐像素计算,理论上确实能调用 GPU 并行算力,但有两个大问题:一是它没有通用的计算抽象,矩阵搬运和中间结果存放都要借助纹理坐标系换算,代码维护痛苦;二是 WebGL 的精度支持有限,很多移动端 GPU 对浮点精度的处理不一致,导致不同机器跑出不同结果。
WASM 方案不依赖 GPU,它把 C++ 写的算子编译成二进制在 CPU 上跑。好处是兼容性极好,几乎任何现代浏览器都能跑;坏处是它本质上还是 CPU 计算,和 GPU 并行加速没有关系。
WebGPU 是三者中最新的图形与计算接口标准。它提供了真正意义上的通用计算管线,可以像写 CUDA 内核一样编写计算着色器(Compute Shader),让浏览器直接调度 GPU 做并行数学运算。这相当于把浏览器的定位从“显示网页”升级成“运行计算程序”。
2.2 计算着色器:WebGPU 能处理通用计算的关键
要理解 WebGPU 为什么适合跑 AI,不需要去记一整套 API,只需要搞清楚一个问题:神经网络推理过程是大量矩阵乘法和卷积运算,这类运算极度适合 GPU 的 SIMD(单指令多数据)并行模式。
GPU 里有成千上万个核心,CPU 可以一次性处理一个或几个线程的复杂指令,GPU 则强调同一时刻上万线程执行同一种简单操作。矩阵乘法恰好可以拆成“对每个输出元素,累加对应输入乘积”的独立并行任务,这正是计算着色器最擅长处理的场景。
WebGPU 在底层把数据放进 GPUBuffer,再让计算着色器对这些 buffer 做读写。和 WebGL 最大的差异是,WebGPU 的数据都是通用 buffer,不需要伪装成纹理,数学算子可以直接在 GPU 内存里反复迭代,避免了纹理与数据之间的格式转换开销。
在现代显卡上,用 WebGPU 跑 MobileNet 的 2D 卷积算子,比用 WebGL 跑同样的层平均能快一到两倍;对比 WASM 更是拉开数量级差距。
2.3 我在写代码前确认的兼容条件
WebGPU 的浏览器兼容性是项目启动前必须确认的底线。2024 年之后,Chrome 和 Edge 的主流版本默认开启,Firefox 和 Safari 的原生实现也基本到位,但不是所有浏览器版本都是完整可用状态。
我在代码里对 WebGPU 的检测方式非常简单:
if (navigator.gpu) { console.log('当前浏览器支持 WebGPU'); } else { console.log('当前浏览器不支持 WebGPU,需要降级到 WebGL'); }在 Chrome 上还要注意一个细节:即使navigator.gpu存在,requestAdapter()也可能返回null,这通常说明浏览器虽然支持 WebGPU 标准,但当前设备没有可用的 GPU 设备,例如部分虚拟机、远程桌面环境。这种情况也要算作“不可用”。
实际项目中我的态度是:能 WebGPU 就 WebGPU,不支持就 WebGL,再不行就 WASM,同时给用户一个明确的提示。后面实战环节我会给出具体的降级策略。
3. 为什么是 MobileNet,以及 1024 维向量从哪里来
3.1 移动端时代的遗留架构,反而是浏览器端当下的最优解
MobileNet 是 2017 年 Google 为移动端和嵌入式设备设计的轻量卷积网络。它最核心的贡献是用深度可分离卷积(Depthwise Separable Convolution)替代普通卷积。
理解深度可分离卷积不需要看公式,用类比就好:普通卷积是让一个厨师同时负责切菜、配菜、炒菜,所有工序混合在一个步骤里完成;深度可分离卷积则是把任务拆成两步——先每个食材单独处理(深度卷积),再由一个厨师统一调配(逐点卷积)。计算量因此大幅下降,在同样精度目标下,模型体积和计算量都能减少八成以上。
这些年出现了不少更轻量的新模型,比如 EfficientNet-Lite、GhostNet、以及各类蒸馏版移动模型。但在浏览器端做特征提取,我依然优先选 MobileNet,原因有三:
- 模型体积小。MobileNetV1 转换后的 tfjs 模型加上权重文件大概在 16MB 左右,加载负担可控。
- 推理速度快。深度可分离卷积本身就能在 GPU 上高效并行,WebGPU 对这类结构算子的支持度也最好。
- 生态成熟。不管是 Keras、TF.js 还是 ONNX Runtime,MobileNet 的权重转换和部署都有非常成熟的方案,踩坑成本低。
我不否认 2024-2025 年出现的新模型在 ImageNet 精度上可能比 MobileNet 高,但在浏览器端场景,精度提升带来收益远远抵不过体积和推理耗时的增加。做以图搜图的核心诉求是“特征向量表达能力强 + 检索稳定可复现”,MobileNet 在生产环境里已经验证过无数次,这是稳健选型。
3.2 倒数第二个输出:1024 维向量的真实来源
关于“1024 维向量”需要先澄清一点:MobileNetV1 和 MobileNetV2 的标准输出维度是不同的。
MobileNetV1 的主体在最后的 1x1 卷积层输出 1024 个通道的特征图,经过 Global Average Pooling 之后,得到 1024 维向量。MobileNetV2 则把最后一个卷积层的通道数设置成了 1280,所以全连接层之前是 1280 维。
如果你看到别人写的代码输出是 1280 维,那大概率用的是 MobileNetV2;如果输出的是 1024 维,那就是 MobileNetV1,或者有人手动裁剪了 V2 的最后一层。
我在本项目中选的是 MobileNetV1,原因很直接:项目标题里要求 1024 维向量,而 V1 天然就是 1024 维,不需要做任何额外加工。
这里有个非常重要的技术点:我们拿向量和最终分类结果无关。MobileNet 原本的最后一层是 1000 类分类器,输出的是“这张图属于哪个类别”的概率分布,这个输出不能直接用来做以图搜图。我们要的是倒数第二层的“图像语义嵌入向量”,这个向量包含了模型对图片内容的抽象理解。
在 Keras 中,获取这个向量的写法非常简洁:
from tensorflow.keras.applications import MobileNet model = MobileNet( weights='imagenet', include_top=False, pooling='avg' ) print(model.output.shape) # (None, 1024)关键参数解释一下:
include_top=False,去掉最后的分类全连接层。pooling='avg',将最后的二维特征图做全局平均池化,变成一个 1024 维向量。weights='imagenet',直接加载 ImageNet 预训练权重。
这样得到的模型,输入任意一张 224x224 的图片,输出就是一个 1024 维的浮点向量。
3.3 模型去掉分类层,导出到浏览器
用 Keras 拿到模型之后,需要把它转成浏览器能识别的 tfjs 格式。这里推荐使用 TensorFlow.js 官方的转换器。
pip install tensorflowjs转换命令非常简单:
tensorflowjs_converter \ --input_format=keras \ --output_format=tfjs_graph_model \ mobilenet_v1_1024.h5 \ web_model命令执行完之后,web_model目录下会生成一个model.json和若干.bin权重文件。这两个文件就是浏览器要加载的全部东西。
需要注意的一个点:如果你在 Keras 里保存的是完整模型(包含分类头),转换之后加载到浏览器再手动截取最后层,也是一条可行路径,但会增加额外的前端张量操作,容易出兼容问题。我在项目里推荐的做法是,在导出前就把模型结构裁剪好,浏览器端拿到的直接就是“图片进、1024 维向量出”的端口,省心很多。
如果不想自己转,也可以直接使用 TF.js 官方模型仓库里提供的预转换 MobileNet 模型。但要注意版本,有些预转换模型是用于图像分类的完整版,需要手动截层;为了不引入额外变量,我更建议按上面的命令自己转一次,整个流程五分鐘就能结束。
4. 实战:从一张图片到 1024 维特征向量
4.1 搭建最小前端骨架
先建一个最小可运行的 HTML 页面,用来选择图片并展示特征提取结果:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>WebGPU + MobileNet 特征提取</title> </head> <body> <h2>浏览器端以图搜图:特征提取示例</h2> <input type="file" id="fileInput" accept="image/*"> <div id="status">请选择图片</div> <canvas id="preview"></canvas> <pre id="output"></pre> <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.20.0/dist/tf.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-webgpu@4.20.0/dist/tf-backend-webgpu.min.js"></script> <script src="app.js"></script> </body> </html>注意 script 的加载顺序:先加载 tfjs 核心库,再加载 webgpu 后端。如果顺序反了,tf.setBackend('webgpu')会报未注册后端的错误。
4.2 初始化:把后端切换到 WebGPU
之后的app.js里,第一步是初始化后端和模型:
let model; async function initBackend() { if (navigator.gpu) { try { await tf.setBackend('webgpu'); await tf.ready(); console.log('已启用 WebGPU 后端'); } catch (e) { console.warn('WebGPU 初始化失败,回退到 WebGL:', e); await tf.setBackend('webgl'); await tf.ready(); } } else { await tf.setBackend('webgl'); await tf.ready(); } } async function loadModel() { // 这里换成你部署的 model.json 地址 model = await tf.loadGraphModel('./web_model/model.json'); console.log('模型加载完成'); } async function init() { await initBackend(); await loadModel(); document.getElementById('status').textContent = '模型已就绪,可以上传图片'; } init();这部分的重点在于降级逻辑。实测下来,WebGPU 在很多浏览器上都会因为各种原因初始化失败,比如设备不支持、显卡驱动过旧、浏览器沙箱限制等。写代码时不要假设只要有navigator.gpu就一定能成功,一定要用try...catch包裹,失败后自动回退到 WebGL。
4.3 图片预处理的细节把控
MobileNetV1 的标准输入是 224x224 的 RGB 图片,同时要求像素值归一化到 [-1, 1] 区间。
预处理主要分三步:
- 读取图片像素。
- 等比缩放并居中裁剪到 224x224。
- 归一化像素值。
很多人在这里会犯一个错误:直接把图片拉伸到 224x224。如果原图不是正方形,拉伸会导致图像畸变,特征向量的质量下降非常明显。以图搜图的场景里,查询图的构图信息非常宝贵,拉伸会让“横构图”直接变成“竖向挤压”,语义特征严重失真。
我的处理方式是先等比缩放,让图片长边变成 224,然后居中裁剪中心区域:
async function loadImageToCanvas(file) { const img = new Image(); const objectUrl = URL.createObjectURL(file); await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = objectUrl; }); URL.revokeObjectURL(objectUrl); const targetSize = 224; const canvas = document.getElementById('preview'); canvas.width = targetSize; canvas.height = targetSize; const ctx = canvas.getContext('2d'); // 计算等比缩放和居中裁剪区域 let sourceX = 0; let sourceY = 0; let sourceWidth = img.width; let sourceHeight = img.height; if (img.width / img.height > 1) { // 宽图:裁剪左右两侧 sourceWidth = img.height; sourceX = (img.width - sourceWidth) / 2; } else { // 高图:裁剪上下两侧 sourceHeight = img.width; sourceY = (img.height - sourceHeight) / 2; } ctx.drawImage( img, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, targetSize, targetSize ); return canvas; }这里把裁剪逻辑写清楚了:如果图片是宽图,就保留中间正方形区域;如果图片是高图,就保留垂直中间区域。因为tf.browser.fromPixels可以直接读取 canvas 数据,所以 canvas 里画成什么样,后面得到的张量就是什么样。
4.4 推理与向量归一化的代码拆解
图片处理好之后,接下来的代码把 canvas 转成 tensor、做归一化、推理、取向量:
async function extractFeature(file) { const canvas = await loadImageToCanvas(file); const featureTensor = tf.tidy(() => { // 从 canvas 读取像素张量:[H,W,3] let tensor = tf.browser.fromPixels(canvas); // 转成 float 类型并扩展到 batch 维度:[1,224,224,3] tensor = tensor.toFloat().expandDims(0); // MobileNet 标准归一化:像素值从 [0,255] 映射到 [-1,1] tensor = tensor.div(127.5).sub(1); // 推理,得到 [1,1024] 的特征向量 const feature = model.predict(tensor); // 去掉 batch 维度,变成 [1024] // 这里用 squeeze 而不是 reshape,避免硬编码维度号 const embedding = feature.squeeze(); // 计算 L2 范数,用于归一化 const norm = embedding.norm(); // 归一化:让向量长度变成 1 return embedding.div(norm); }); const vector = await featureTensor.data(); return Array.from(vector); }代码里用到的tf.tidy()非常重要。TF.js 里面创建的 tensor 如果不用.dispose()手动释放,会一直占用显存。tf.tidy会在回调函数执行完之后自动清理所有临时 tensor,只保留返回值。
还有个细节:使用embedding.squeeze()而不是reshape([1024]),是为了避免假设模型的 batch 维度一定为 1。如果你换了一个 batch 维度不是 1 的模型,squeeze依然能正确工作。
向量归一化这一步很多人都省略了,但在以图搜图里不应该省。归一化之后向量模长为 1,两个向量之间的欧几里得距离和余弦相似度就变成单调对应的关系。这意味着在检索阶段,直接用点积就能计算余弦相似度,省掉一次除法开销,而且能避免不同图片因为亮度、对比度带来的向量模长差异干扰检索结果。
4.5 完整可运行代码
把上面几个部分合起来,再加上文件输入事件监听:
document.getElementById('fileInput').addEventListener('change', async (event) => { const file = event.target.files[0]; if (!file) return; const status = document.getElementById('status'); const output = document.getElementById('output'); status.textContent = '正在提取特征...'; try { const vector = await extractFeature(file); status.textContent = '特征提取完成'; output.textContent = '向量长度(维度):' + vector.length + '\n' + '前 20 维:' + JSON.stringify(vector.slice(0, 20)) + '\n' + '向量 L2 范数(应约为 1):' + Math.sqrt(vector.reduce((sum, v) => sum + v * v, 0)).toFixed(6); } catch (e) { status.textContent = '特征提取失败:' + e.message; console.error(e); } });这段代码跑通后,你可以先验证一个关键点:上传同一张图片两次,得到的两组向量应该完全一致。这个稳定性的验证非常重要,如果同一张图前后两次算出的向量都不一样,那么整条检索链路都没有意义。
5. 用 1024 维向量搭建以图搜图
5.1 入库:为图片批量生成特征索引
特征提取只是第一步,以图搜图还需要把整个图片库批量处理,生成索引。这一阶段可以在浏览器里离线路由,也可以写一个离线脚本批量跑完,再把索引结果存储下来。
我准备的离线入库脚本大致逻辑是这样:
const libraryImages = [ { id: 1, url: './images/cat_01.jpg' }, { id: 2, url: './images/dog_01.jpg' }, { id: 3, url: './images/building_01.jpg' }, // ... ]; const libraryVectors = []; for (const item of libraryImages) { const img = await loadImageFromUrl(item.url); const vector = await extractFeatureFromImage(img); libraryVectors.push({ id: item.id, url: item.url, vector: vector }); } // 存储到 localStorage 或者 IndexedDB saveToStorage(libraryVectors);有一个实际中的优化思路值得提一下:如果图片库本身就在网页端展示,很多图片其实已经被浏览器缓存或渲染过了。这种情况下可以直接拿 DOM 里现有的<img>元素传入extractFeature,不需要额外请求一遍原图,能节省不少 IO 时间。
5.2 相似度计算:为什么选择余弦相似度
检索阶段,用户上传一张查询图,我们得到它的 1024 维归一化向量,然后要在特征库里找出“语义上最相似”的图片。
在向量空间里,衡量两个向量相似度的方式主要有三种:欧几里得距离、曼哈顿距离、余弦相似度。
- 欧几里得距离衡量的是空间中的直线距离。
- 余弦相似度衡量的是两个向量方向的夹角度数。
以图搜图场景里,用余弦相似度更合理。原因是图像特征向量会受到亮度、对比度、缩放等因素影响,而这些因素改变的是向量的“长短”,也就是模长,并不是方向。余弦相似度天生忽略模长,只看方向,因此对这类亮度、尺寸变化更鲁棒。
这两张图虽然所有数字都不一样,但方向接近,余弦相似度很高。这在图像语义上对应的是“内容相似,亮度色调有差异”的情况。这正是我们希望得到的结果。
余弦相似度的计算公式是:
cosine(A, B) = (A · B) / (|A| * |B|)因为我们在特征提取阶段已经做了向量归一化,|A|和|B|都等于 1,所以公式简化为:
cosine(A, B) = A · B在 JavaScript 里求两个归一化向量的点积:
function cosineSimilarity(vecA, vecB) { let dot = 0; for (let i = 0; i < vecA.length; i++) { dot += vecA[i] * vecB[i]; } return dot; }这个函数的返回值范围在 [-1, 1] 之间,越接近 1 表示语义越相似。如果你对归一化有顾虑,也可以保留原始向量,在计算时手动除以两个向量的模长,结果是一样的。
5.3 一个能跑起来的最小演示
下面给一个极简的以图搜图演示逻辑:假设库里已经有 1000 张图片的特征向量,用户上传查询图后,逐条计算相似度并排序,返回 Top 5。
async function searchSimilar(file, topK = 5) { const queryVector = await extractFeature(file); const library = loadFromStorage(); // 从本地存储读取入库向量列表 const results = []; for (const item of library) { const score = cosineSimilarity(queryVector, item.vector); results.push({ id: item.id, url: item.url, score: score }); } results.sort((a, b) => b.score - a.score); return results.slice(0, topK); }这段代码就是整条检索链路的核心。当图片库比较小(几千张以内)时,全量遍历完全没问题;但图片库达到几十万上百万张时,全量遍历就慢了,需要引入近似最近邻(ANN)索引。ANN 索引在浏览器端的实现方案我会在最后一节展开。
6. 性能实测与踩坑手记
6.1 我在真机上的实际测量
为了给你一个真实参照,我记录了在几台设备上的实测数据。测试模型是导出的 MobileNetV1(1024 维特征向量),输入图片经过等比缩放和居中裁剪到 224x224,统计的是从拿到 canvas 到特征向量输出出的完整时间。
| 设备 | 浏览器 | 推理后端 | 单次推理耗时 |
|---|---|---|---|
| 桌面 RTX 3060 | Chrome 126 | WebGPU | 35ms |
| 桌面 RTX 3060 | Chrome 126 | WebGL | 73ms |
| 桌面 i7 无独显 | Chrome 126 | WebGPU | 95ms |
| 桌面 i7 无独显 | Chrome 126 | WebGL | 180ms |
| 笔记本集显 | Edge 126 | WebGPU | 110ms |
| 中端安卓手机 | Chrome 124 | WebGL | 260ms |
这个表格不代表所有设备,但是有几个规律很明确:WebGPU 在支持良好的设备上通常比 WebGL 快 40% 到 60%;移动端 WebGPU 的普及度还不够高,很多手机最后还是走了 WebGL。
顺便说个有意思的现象:就算后端是 WebGPU,首次推理的耗时往往显著高于后续推理,可能达到几百毫秒甚至一秒以上。主要是因为 WebGPU 管线需要把模型算子编译成底层着色器程序,第一次推理等于做了一次预编译。解决方案很朴素:在页面初始化时或用户点击上传前,先跑一次哑推理(比如推理一张全黑图片),让管线预热完成。
// 预热:初始化完成后,立刻跑一次推理,避免首次正式检索卡顿 async function warmup() { const dummyTensor = tf.zeros([1, 224, 224, 3]); await model.predict(dummyTensor); dummyTensor.dispose(); }6.2 踩过的坑与解法清单
以下这些坑是我在实际开发中遇到的,很多都不在官方文档的显眼位置,特意记下来分享:
坑一:WebGPU 后端下model.predict返回的 Promise 不是张量
TF.js 中 WebGPU 后端的推理行为和其他后端略有差异。WebGL 和 WASM 后端下model.predict通常是同步返回张量,而 WebGPU 后端下某些算子会触发异步计算,要求你await结果。如果漏了await,拿到的可能是一个 Promise 对象,后续调用.squeeze()或.data()就会报错。
解决方案是在写代码时统一用const feature = await model.predict(tensor),不要依赖后端差异。这个习惯能让代码在多个后端间无缝切换。
坑二:tf.browser.fromPixels只能读取HTMLImageElement、HTMLCanvasElement或ImageData
tf.browser.fromPixels不能直接读取 blob 数据或 File 对象。你不能这样做:
tf.browser.fromPixels(file); // 报错必须先通过FileReader或URL.createObjectURL把文件转成图片,再绘制到 canvas 上,才能交给 TF.js。这个流程我在 4.3 小节里已经写好了完整版本。
坑三:模型的通道顺序必须是 NCHW 还是 NHWC
Keras 和 TF.js 默认使用 NHWC(宽高在最后,通道数是最后一个维度),绝大多数预训练 MobileNet 权重也是按 NHWC 格式训练和导出的。但如果你切换框架,比如用 PyTorch 导出的 ONNX 模型,默认可能是 NCHW(通道在第二个维度),到了 ONNX Runtime Web 里需要手动设置输入数据的维度顺序,很多人会在这里翻车。
坑四:WebGPU 兼容性检查不能用navigator.gpu一锤定音
前面提到过,navigator.gpu存在不等于 WebGPU 一定能用。我碰到过navigator.gpu存在但requestAdapter()返回null的情况,也碰到过能拿到 adapter 但创建 device 失败的情况。所以初始化逻辑里必须是多层try...catch,只要任何一层失败就往下降级。
6.3 后续可以继续扩展的方向
如果你想把这套浏览器端以图搜图做成真正的生产级系统,有以下几个方向值得继续投入:
第一个方向是海量向量索引。图片库超过十万张以后,全量遍历就撑不住了。浏览器端可以考虑用近似最近邻索引方案,目前比较成熟的方案是在 WASM 上运行usearch或hnswlib的编译版本,配合 IndexedDB 存储索引数据。有了 ANN 索引,百万级向量也能在几十毫秒内完成检索。
第二个方向是模型升级。MobileNetV1 的 1024 维向量确实够用,但如果你遇到检索精度不足的问题,可以换 MobileNetV3-Large 或者 EfficientNet-Lite,换成 1280 维甚至更高维的特征向量。维度变高,检索效果不一定是坏事,但有向量维度和存储量之间的权衡。
第三个方向是把特征向量存储在 IndexedDB 中,实现完全离线的图库检索。这样用户可以把所有本地图片建成一个本地图库,查询图直接在其中检索,完全不依赖网络。这是浏览器端以图搜图最理想的应用形态之一。
最后说一个我的个人经验:WebGPU 这套方案目前的稳定性确实不如 WebGL,尤其是要在多种型号的 GPU 上做兼容时,需要投入不少测试时间。但只要你的核心用户集中在 Chrome 和 Edge 上,且设备不算太老,WebGPU 带来的性能提升是值得的。我在把生产环境的默认后端切换到 WebGPU 之后,用户反馈的“检索慢”问题明显减少了。更重要的是,这套架构把 GPU 推理成本从服务端彻底转移到了用户端,同时把用户图片的隐私安全等级提高了几个层次,这在以图搜图这类涉及图像数据的产品里,可以说是最划算的一笔技术投入。