前阵子一个朋友想做自动抠图的小工具,问我要不要把模型部署到服务器上,顺便接个付费API。我直接摇头,告诉他现在有个更省事的做法:一个网页页面,塞一个40MB的离线模型,打开就能自动抠图,不用Python、不用服务器、不用API Key、也不用好显卡。他半信半疑,等我把demo丢过去,他当场就把这个页面挂到了自己的工作流里。
这篇内容就是把这个方案完整拆开聊。适合谁看?主要是前端开发者、独立开发者,或者想做一个临时抠图工具但不想折腾环境的普通用户。你会看到我从模型选型到浏览器推理,再到边缘白边处理、CORS、移动端兼容这些坑,基本是一路踩完攒下来的经验。如果你也准备在网页端做类似的AI能力,这篇能帮你省下不少试错时间。
1. 为什么我坚持把抠图做成纯网页端
1.1 先分清“不用服务器”和“不部署到服务器”
很多朋友一听“不用服务器”,第一反应是怀疑。这里我先说清楚:我说的不用服务器,是指不需要你维护一台运行着Python、FastAPI、图像处理依赖的后端机器,不需要处理并发、存储、购买GPU实例。但如果你想让别人也能访问,还是需要把静态文件放在某个托管上,比如GitHub Pages、对象存储、Netlify,这跟你理解的“服务器”是两码事。
这一类托管只要上传HTML、JS和onnx模型文件就能运行。好处是没有后端计算,所以不会有突发流量压垮进程的问题,也不存在“API Key过期”这种破事。模型推理发生在每个访问者的浏览器本地,你的服务器只负责把文件吐出去,连计算资源都不消耗。
如果你是给自己用,甚至可以直接把页面和模型放到本机目录,双击打开就能跑。虽然本地file://协议下会有CORS限制,后面我会讲怎么解决,但本质上这个方案可以做到完全不依赖任何在线服务。
1.2 和传统方案相比,这个模式到底赢在哪
我习惯做决定前先拉一张对比表,把不同路线摆在一起看:
| 维度 | 传统服务端/API | Python脚本 | 纯网页端离线 |
|---|---|---|---|
| 需要环境 | Python、GPU、依赖库 | Python环境 | 浏览器 |
| 部署成本 | 服务器、运维、监控 | 本机运行,不方便分享 | 静态托管或纯本地 |
| 调用费用 | 可能按次收费 | 无 | 无 |
| 图片隐私 | 上传到第三方 | 本地 | 本地 |
| 分享方式 | 开发接口文档 | 发脚本/代码 | 发链接就行 |
| 可扩展性 | 强,但重 | 中 | 轻量,适合小工具 |
最让我在意的其实是隐私。图片不用上传,直接在浏览器里算完,不会留底。对不想把照片交给第三方的人来说,这是一个很大卖点。另外一个好处是没有调用量限制,你批量跑几百张图也不用担心费用。成本为零,隐私有保障,部署几乎为零,这三个点一起出现,在很多场景下是很有吸引力的。
当然我也承认这种方案的局限。模型大小和复杂度受限于浏览器端,不能跑特别重的大模型,对极其复杂的边缘毛发、半透明物体,效果可能不如服务端的专属模型。但普通的人像抠图、电商图去底,已经足够用了。
2. 40MB离线模型的选型逻辑与实践
2.1 MODNet还是U²-Net,我是怎么定的
网页端一次性加载的模型,体积和推理速度是不能绕开的两个指标。我一开始试过U²-Net原版,效果确实不错,但ONNX模型快200MB,浏览器加载时间和内存占用直接劝退。所以我最后选了一款轻量级抠图模型,量化成ONNX之后差不多40MB。这里不严格指定具体名字,市面上常见的MODNet量化版、或者一些蒸馏过的RMBG变体都在这个量级。
如果你要做人像抠图,MODNet量化版基本够用;如果要做通用物体分割,可以看看U²-Net-small或者一些专门针对通用前景分割的小模型。我把两个方向放在一起对比一下:
| 模型 | 模型大小 | 浏览器友好度 | 通用性 | 推荐场景 |
|---|---|---|---|---|
| MODNet量化版 | 40MB左右 | 高 | 人像为主,通用物体一般 | 人像抠图、直播背景替换 |
| U²-Net-small | 5MB左右 | 很高 | 通用物体分割较好 | 物体抠图、素材处理 |
| U²-Net原版 | 约170MB以上 | 低 | 通用物体效果最好 | 不建议直接放网页端 |
我这里说的“40MB离线模型”,选的就是第一档。体积小的好处很明显:首次加载快,WASM实例化后内存占用可控。和U²-Net原版相比,MODNet牺牲了一些边缘精细度,但换来的是能在普通CPU上实时或准实时推理。抠图场景里,速度和稳定性往往比那一两个像素级的边缘更影响体验。
2.2 模型文件放哪与“离线”含义
“离线”并不是一个神秘状态。把onnx文件丢到静态目录里,浏览器通过fetch下载一次,之后用Cache API保存,再打开页面不联网也能继续抠图。真正需要理解的,是模型文件从网络请求到本地缓存的过程。
这里有个容易忽略的点:模型下载是一次性的,但浏览器为了安全会拦截跨域请求。如果你的页面和模型不在同一个域,会直接报CORS错误。我的做法是把模型放到和页面同目录,或者用对象存储设好跨域头。很多人在这里翻车,明明代码没问题,却总在加载模型时报错,别问我怎么知道的。
“不用服务器”不是说你可以完全无视静态资源托管规则,而是说你不需要写后端逻辑。模型文件本质上和一张图片、一个JS文件一样,只要能被浏览器取到就行。理解了这一点,后面的接入就很顺了。
3. 在浏览器里跑推理:ONNX Runtime Web接入全流程
3.1 产品形态与目录结构
我第一次做的时候以为要引入一堆npm包,实际上完全不用。用CDN挂一个onnxruntime-web,再加一个HTML、一个JS文件和一个onnx模型,就完成了。这是最简目录结构:
project/ ├─ index.html ├─ app.js └─ model.modnet.onnxindex.html里只需要一个文件选择控件、一个预览用的img,还有一个显示结果的canvas。为了不把样式写得太复杂,我直接用一个原始页面示范:
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>网页端离线抠图</title> <script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.18.0/dist/ort.js"></script> </head> <body> <div> <input type="file" id="file" accept="image/*"> </div> <div> <img id="origin" alt="原图"> </div> <canvas id="result"></canvas> <script src="app.js"></script> </body> </html>为什么我不用打包工具?因为这里只有一个推理逻辑页面,引入构建链路反而把问题复杂化。直接用一个全局的ort对象,简单直接。如果你想在项目里模块化管理,后面再用npm包也不迟,但最初验证阶段,越少依赖越好。
3.2 核心代码:从图片输入到Alpha通道输出
下面是我验证核心流程时写的代码。第一步先加载模型,并指定用WASM作为执行后端。这里不指定任何外部API,不需要Key。
const MODEL_PATH = './model.modnet.onnx'; const INPUT_SIZE = 224; let session; async function init() { session = await ort.InferenceSession.create(MODEL_PATH, { executionProviders: ['wasm'], }); }第二步是把用户选中的图片预处理成模型需要的格式。因为模型输入是224×224的RGB张量,我需要先用canvas缩放图片,再把像素从HWC格式转成CHW格式,并且做归一化。不同模型的归一化规则不一样,有的用ImageNet mean/std,有的只是映射到[-1,1]。这里我按常见做法演示:
function preprocess(img) { const canvas = document.createElement('canvas'); canvas.width = INPUT_SIZE; canvas.height = INPUT_SIZE; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, INPUT_SIZE, INPUT_SIZE); const data = ctx.getImageData(0, 0, INPUT_SIZE, INPUT_SIZE).data; const input = new Float32Array(3 * INPUT_SIZE * INPUT_SIZE); for (let i = 0; i < data.length; i += 4) { const idx = i / 4; input[idx] = (data[i] / 255 - 0.5) / 0.5; input[INPUT_SIZE * INPUT_SIZE + idx] = (data[i + 1] / 255 - 0.5) / 0.5; input[2 * INPUT_SIZE * INPUT_SIZE + idx] = (data[i + 2] / 255 - 0.5) / 0.5; } return new ort.Tensor('float32', input, [1, 3, INPUT_SIZE, INPUT_SIZE]); }第三步就是跑推理。模型输出通常是一个matte,也就是每个像素的透明度值。我拿到之后会把它画到一张224×224的临时canvas上,再用drawImage把matte缩放到原图尺寸,这样比手动写双线性插值简单很多,而且浏览器原生处理速度更快。
async function run(img) { const feeds = { input: preprocess(img) }; const output = await session.run(feeds); const key = Object.keys(output)[0]; const matte = output[key].data; // 把matte转成canvas const matteCanvas = document.createElement('canvas'); matteCanvas.width = INPUT_SIZE; matteCanvas.height = INPUT_SIZE; const mCtx = matteCanvas.getContext('2d'); const mImage = mCtx.createImageData(INPUT_SIZE, INPUT_SIZE); for (let i = 0; i < matte.length; i++) { let alpha = Math.round(matte[i] * 255); alpha = Math.max(0, Math.min(255, alpha)); mImage.data[i * 4 + 3] = alpha; } mCtx.putImageData(mImage, 0, 0); // 缩放到原图尺寸 const resized = document.createElement('canvas'); resized.width = img.naturalWidth; resized.height = img.naturalHeight; resized.getContext('2d').drawImage(matteCanvas, 0, 0, resized.width, resized.height); // 用matte作为alpha通道,合成最终透明PNG const resultCanvas = document.createElement('canvas'); resultCanvas.width = img.naturalWidth; resultCanvas.height = img.naturalHeight; const ctx = resultCanvas.getContext('2d'); ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, resultCanvas.width, resultCanvas.height); const resizedAlpha = resized.getContext('2d').getImageData(0, 0, resized.width, resized.height).data; for (let i = 0; i < imageData.data.length; i += 4) { imageData.data[i + 3] = resizedAlpha[i + 3]; } ctx.putImageData(imageData, 0, 0); return resultCanvas; }到这里核心流程已经通了。你把init和run接上,监听文件选择框的change事件,就能完成一次完整的自动抠图。我这里拿到的matte是Float32Array,数值在0~1之间。不同模型输出范围可能不一样,如果输出是0~255,你需要在后处理前判断一下最大值,做一个归一化。这个坑我踩过,有一次效果全黑,就是因为把0~255当成0~1用了。
4. 没有显卡也能稳:性能调优的关键点
4.1 为什么小模型在CPU上也能跑得动
很多人一听说AI抠图,就默认需要高端显卡。实际上,我们用的模型只有40MB,输入分辨率是224×224,WASM在普通PC的CPU上跑一次前向推理,大概只需要一两百毫秒。onnxruntime-web默认用WASM执行,相当于把C++推理引擎编译到浏览器里,不需要安装驱动,不需要CUDA,也不需要任何显卡相关的环境。
这也是“不用显卡”这句话的底气来源。模型小、输入小、计算量可控,所以CPU足以应付。我甚至在一台六七年前的笔记本上用Chrome跑过,单张图大概400毫秒左右,体验虽然不算秒出,但配合loading提示完全可接受。手机端会再慢一些,但现代手机的CPU也足够处理这种小模型。
如果你希望进一步压缩推理时间,第一个思路是降低输入尺寸。模型原始设计可能支持不同输入,如果能从256降到224,速度会有明显提升。当然,抠图效果也会随之变差一点,需要找一个平衡点。
4.2 多线程、WebGPU与内存占用
onnxruntime-web有一个更激进的多线程模式,依赖SharedArrayBuffer,而SharedArrayBuffer只能在HTTPS环境下开启,并且响应头必须设置COOP和COEP跨域隔离。如果条件满足,推理时间能再降一半。我实测过,在PC上单张推理可以从240毫秒降到110毫秒左右。
不过这里我要劝你一句:如果不是对性能特别敏感,默认的单线程WASM已经够用。多线程配置会引入跨域响应头、兼容性判断、CDN回源等一系列额外问题。我刚开始也折腾过COOP/COEP,后来发现对一个轻量抠图工具来说,省的那100毫秒远没有稳定性重要。不想折腾也没关系,默认wasm就能跑。
WebGPU是另一个加速选项。在较新的Chrome版本里,onnxruntime-web可以指定WebGPU作为执行后端。如果你的设备有独立显卡,可能获得更快的推理速度。但我在一台只有核显的旧笔记本上试过,wasm反而比webgpu稳定,所以默认wasm是我更推荐的做法。想在支持的设备上尝鲜,可以这样写:
await ort.InferenceSession.create(MODEL_PATH, { executionProviders: ['webgpu', 'wasm'], });这样WebGPU可用时会优先使用,不行就自动回退到wasm。内存方面,40MB模型的权重会加载进内存,推理中间张量也会占一些空间。PC上整体占用大概100到200MB,手机上也还能接受。但如果你连续处理几十张图,需要主动释放不再使用的中间变量,尤其不要把所有原图同时保存到内存里。处理完一张,就及时置空引用,让浏览器垃圾回收有机会工作。
5. 我踩过的坑:边缘发白、CORS失败与移动端崩溃
5.1 透明边缘的处理
用这类轻量模型抠图,最常被吐槽的是边缘发白或有一圈残留。原因有两层:一是模型的matte在边缘处本身就不是平滑过渡到0,二是在合成透明PNG时,RGB通道仍然保留着原来的浅色背景。
想要改善,可以在拿到matte后做一个sigmoid陡化,把中间值附近的过渡压得更狠一些。例如:
alpha = 1 / (1 + Math.exp(-(matte - 0.5) * 10));这样会让低于阈值的区域更快变透明,高于阈值的区域保留更清晰。更直接一点的做法是,在做alpha合成之前,对matte做一个3x3的中值滤波,把边缘的孤立噪点去掉。我一般会把这两个技巧结合起来,效果会比直接输出干净很多。
顺带提醒一下,如果你抠出来的图边缘发青或者发粉,多半是半透明像素混合了背景色导致。这种情况靠简单alpha处理不太够,建议在导出前对边缘像素做一次收缩和去色。不过对普通用户来说,sigmoid陡化已经能覆盖90%的日常场景。
5.2 模型文件与本地调试的CORS
第二个坑是CORS。我第一次做的时候,直接双击HTML,用file://协议打开页面,结果模型加载一直报错。原因很简单,浏览器把模型fetch请求当作跨域请求拦截了。file://下页面的来源是null,而模型文件也在本地,但浏览器就是不认。
解决办法有两种。本地调试的时候起一个静态服务,VS Code的Live Server插件就行,然后把页面和onnx模型放在同一目录,确保同源。发布的时候用任意静态托管,只要保证最终访问的URL里,页面和模型在同一个域名下,就不会有CORS问题。
这里再强调一次,“不用服务器”不是让你忽略HTTP协议,只是说你不需要写后端业务。静态文件请求本身还是走HTTP,浏览器做跨域检查也是基于HTTP头。你把模型放到对象存储,记得给文件配置跨域规则,否则浏览器依然会拦截。
5.3 移动端崩溃与纹理限制
第三个坑是移动端Canvas尺寸限制。某些安卓机的Canvas面积有上限,比如4096×4096或者更低,超过后浏览器可能直接静默失败,甚至崩溃。我第一次在手机上一测试,一张几千万像素的照片直接让页面白屏了。
解决方法是检查图片的宽高,超过上限就先等比缩放到上限再抠图。另一个问题是原始文件太大,比如相机拍出来十几MB的JPG,浏览器加载会比较吃力。我习惯先用createImageBitmap把图片降采样,再扔给canvas处理。这样既能保护内存,又能提升后续所有步骤的速度。
移动端还要注意:WebGL和WASM共存时,某些浏览器会互相抢资源。如果页面不复杂,尽量别在canvas上叠加太多滤镜和动画,给推理过程留足内存。
6. 从“能用”到“好用”的进阶改进
6.1 拖拽、粘贴、批量处理
单图选择控件已经能跑,但用户体验还可以更好。比如支持拖拽上传、粘贴截图的几行代码,能让用户顺心很多。拖拽主要监听drop事件,粘贴主要监听document的paste事件。
批量处理则要注意并发数量。onnxruntime-web的session在一个Tab里同时跑两三个推理没问题,但一口气提交20张,内存会飞。我一般写一个并发队列,每次最多2个任务,配合loading动画。这样就算用户拖进一百张图,页面也不会卡死,而是排队一张张处理。
下载结果的时候,我建议直接用canvas.toBlob导出PNG,然后用URL.createObjectURL生成下载链接。不要直接把canvas数据转base64塞到href里,那样图片太大时会卡住浏览器。用Blob方案更稳定,也更省内存。
6.2 用PWA做成真正的离线应用
如果想让这个网页端工具更像一个本地应用,还可以加一个很简单的Service Worker,把index.html、app.js和model.onnx都缓存起来。之后用户第一次打开页面后会完成全量缓存,第二次开始完全离线使用,还能“添加到主屏幕”。
这里有个操作细节:模型文件更新时,改名是最靠谱的缓存更新策略。不要指望浏览器自动判断文件是否有变化,因为缓存策略可能让旧文件一直生效。你更新了模型,用户还在用旧缓存,这种问题排查起来很头疼。所以我通常把模型文件命名为model.v1.onnx、model.v2.onnx,在代码里引用也同步改掉。
另外,Service Worker只在HTTPS或localhost下才能注册。如果你只是本地自用,没有HTTPS环境,可以不做PWA,直接依赖浏览器HTTP缓存也行。但一旦想发布给别人用,PWA这个版本真的会让人眼前一亮,因为用户感觉像是装了一个本地软件,但背后其实只有一个静态页面。
老实讲,这个方案不是万能的,它牺牲了模型的绝对精度,换来了零部署、零成本、高隐私和极低的门槛。在我的实际项目里,它帮我把一个本来要花几天搭服务的需求,压缩到了半小时内完成。如果你也有一堆图片需要离线批量处理,或者只是不想把照片上传到别人的服务器,这个40MB离线模型的网页端抠图方案值得照着抄一遍,先跑通单图流程,再慢慢加批量、PWA这些进阶功能。