
前阵子一个朋友想做自动抠图的小工具问我要不要把模型部署到服务器上顺便接个付费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 和传统方案相比这个模式到底赢在哪我习惯做决定前先拉一张对比表把不同路线摆在一起看维度传统服务端/APIPython脚本纯网页端离线需要环境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-small5MB左右很高通用物体分割较好物体抠图、素材处理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 charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 title网页端离线抠图/title script srchttps://cdn.jsdelivr.net/npm/onnxruntime-web1.18.0/dist/ort.js/script /head body div input typefile idfile acceptimage/* /div div img idorigin alt原图 /div canvas idresult/canvas script srcapp.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×224WASM在普通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这些进阶功能。