纯浏览器AI修图的部署与兼容性实战
文章围绕单HTML文件修图工具,讨论端侧AI、WASM线程、目标体积压缩、图像修复与3D LUT的实现问题。重点案例是静态托管缺少COOP/COEP响应头时,ONNX Runtime Web在手机上可能挂起。
AI 深度解读
一款照片不上传、没有后端、用单个 HTML 文件托管在 GitHub Pages 上的编辑器,难点在哪里?APIC Web 作者给出的答案是:模型跑起来只是开始,手机上会不会卡死、压缩能否达到指定体积、导出会不会丢掉透明背景,才决定工具能不能交付。
原文把开发过程整理成了 7 道测试。值得读的不是「浏览器专家」这个称号,而是一条具体思路:先认清运行环境和输出格式的限制,再减少计算、设计降级,并把做不到的事情明确告诉用户。
浏览器能跑模型,不代表手机上能稳定跑
APIC 使用 ONNX Runtime Web,通过 WebAssembly 执行端侧 AI。多线程 WASM 依赖 SharedArrayBuffer,后者需要 COOP/COEP 响应头建立跨源隔离。作者使用的 GitHub Pages 托管方式没有这些响应头,因此 crossOriginIsolated 为 false。
作者遇到的实际故障是:桌面端通常能够降级,手机上的 AI 步骤却可能一直挂着,也没有错误。于是,他们主动固定运行配置:
// 静态托管环境缺少 COOP/COEP,因此不启用线程。
// 显式固定配置,避免依赖运行时自动降级。
ort.env.wasm.numThreads = 1;
ort.env.wasm.proxy = false;
const session = await ort.InferenceSession.create(modelBytes, {
executionProviders: ['wasm'],
enableCpuMemArena: false,
enableMemPattern: false,
});
最关键的是 ort.env.wasm.numThreads = 1。其余参数也是作者采用的配置,但原文没有逐项说明它们分别解决了什么问题,不能据此推断全部都是手机卡死的原因。有条件提供响应头时,APIC 会先检查 self.crossOriginIsolated,再请求多个线程。
下载模型同样不能照搬普通接口的超时策略。背景移除模型分为 Lite、Fast、Maximum,分别为 5 MB、44 MB、176 MB;最高质量的物体移除模型约 208 MB。固定 30 秒超时,很容易误杀仍在正常下载的慢连接。
APIC 改成连续 40 秒没有收到字节才中止。下载完成后,用 Cache API 写入 apic-models-v1:通过 caches.open 打开缓存,cache.match(url) 命中后读取 arrayBuffer(),否则下载,再用 cache.put(url, new Response(bytes)) 保存。写缓存需要 try/catch,因为隐私模式和配额都可能导致失败。
缓存省掉的是重复下载,原文所谓第二次「即时」运行,没有提供启动耗时数据。模型加载失败时,物体移除还会沿着 Ultra(LaMa)→ MI-GAN → 快速补丁填充降级。
可靠性来自明确处理失败条件,而不是默认运行时总能替你兜底。
图片处理的质量,藏在体积、透明度和缩放顺序里
「压到 200 KB 以下」是明确约束,质量滑块却只是一个间接参数。APIC 用二分搜索连接两者,在 JPEG 质量区间 0.05 到 0.98 内最多编码 7 次:
async function findQualityForTarget(canvas, targetBytes) {
let lo = 0.05, hi = 0.98, best = null;
for (let i = 0; i < 7; i++) {
const mid = (lo + hi) / 2;
const blob = await toBlob(canvas, 'image/jpeg', mid);
if (blob.size <= targetBytes) { best = { blob, q: mid }; lo = mid; } // 满足限制后尝试更高质量
else { hi = mid; } // 超出限制则降低质量
}
return best;
}
要看的是 blob.size <= targetBytes:每次判断都依据实际编码结果。7 次搜索得到的是有限精度下的可行质量,不宜把它理解成数学意义上的最高画质保证。
如果降低质量仍无法满足目标,APIC 会把图片尺寸缩小约 20%,再搜索,最多缩小 8 次。仍然达不到,就显示实际体积。这里有个容易被忽略的产品选择:系统已经开始牺牲分辨率,界面应让用户知道发生了什么。
透明度则属于格式规则。JPEG 没有 alpha 通道,作者遇到了透明区域导出后变黑的问题,尤其容易出现在移除背景之后。解决办法是在新 canvas 上先用 #fff 填满背景,再 drawImage 叠加原图。转换面板同时说明 PNG、WebP 支持保留透明度,JPEG 不支持。白底是 APIC 的处理选择,不是 JPEG 唯一允许的底色。
缩小图片时,作者也没有直接用一次 ctx.drawImage(img, 0, 0, smallW, smallH) 完成大跨度缩放。他们逐次把宽高减半,直到当前尺寸进入目标尺寸的 2 倍范围,再做最后一次精确缩放;中间过程开启 imageSmoothingEnabled = true 和 imageSmoothingQuality = 'high'。
这套顺序值得试,但原文没有给出不同浏览器、不同图片的对照结果。「一步缩放一定模糊」应当视为作者的经验判断,具体收益仍要用自己的素材验证。
减少计算范围,比把整张照片塞给模型更重要
物体移除使用图像修补(inpainting),也就是根据周围内容填补被涂抹的区域。APIC 使用 LaMa 或更轻的 MI-GAN;其中 LaMa 是采用傅里叶卷积的网络,处理固定的 512×512 窗口。
对于一张 1200 万像素照片,把整图缩进这个窗口,小物体附近的信息也会被压缩。APIC 围绕用户笔刷的遮罩裁剪区域,在原图允许时至少保留 512 个原生像素,再把局部送入模型。
模型输出之后还有收尾工作:用方框模糊羽化边缘,让填充区域与周围颜色匹配,再补回颗粒感,避免修补区显得过于干净。剩余照片保持不动。原文没有提供速度和画质对照数据,但计算范围与编辑范围一致,这个取舍很清楚。
调色导出则是另一种拆分。APIC 提供曲线、8 段 HSL 混色和三向调色,用户希望把效果带到 DaVinci Resolve 或 Premiere。作者没有重新推导调色公式,而是生成一个 33×33×33 的颜色网格,用 Uint8ClampedArray 存储,让红色通道变化最快,再交给同一个像素处理函数:
processPixels(buf, gradeWithoutSpatialEffects)
输入是规则采样的颜色,输出就是颜色映射表。写成 .cube 时,声明 LUT_3D_SIZE 33、DOMAIN_MIN 0.0 0.0 0.0、DOMAIN_MAX 1.0 1.0 1.0,各通道除以 255 后用 toFixed(6) 输出。
这里的边界是:3D LUT 记录输入颜色到输出颜色的映射。清晰度、锐化、暗角、颗粒还依赖邻域、位置或其他信息,不能完整塞进这种映射,因此 APIC 将它们排除,并在界面说明。
为保留单 HTML 文件的交付方式,作者把 Worker 源码存为字符串,通过 new Blob([SRC], { type: 'application/javascript' })、URL.createObjectURL 和 new Worker 启动,让繁重的修补计算离开主线程。单文件描述的是应用打包方式,模型仍然需要另外下载。
我的判断
我更认可这篇文章对失败路径的处理:慢下载与死连接分开判断,模型失败后继续降级,压缩失败后报告真实体积,LUT 导出明确排除不支持的效果。这些做法直接影响用户能否完成任务。
但它不是性能评测。手机挂起没有设备与版本清单,逐级缩放没有画质对照,局部修补也没有耗时数据。它提供了一组可复用的工程经验,尚不足以证明这些配置对所有团队都最优。
「无后端」也有范围。按作者描述,照片处理不上传,但模型来自 Hugging Face;编辑无需账号,导出需要 Google 登录,网页还有小型横幅广告。因此,不能把它理解成首次打开即可完全离线、所有环节都没有外部依赖。
与服务端处理相比,端侧方案把模型下载和计算压力交给用户设备。是否合适,要看目标手机、网络条件和图片规模。单 HTML 文件方便分发,但原文没有讨论应用扩大之后的维护成本,也没有证明它优于常规构建方案。
能拿来做什么
- 做浏览器端 AI 功能时,先在真实手机上验证模型初始化、单线程运行和失败降级,把
self.crossOriginIsolated检查放进运行条件判断。 - 给前端图片工具增加目标体积输入,依据实际 Blob 大小搜索质量;需要缩小分辨率时,同时展示最终尺寸和文件体积。
- 处理局部修补时,先计算遮罩周围的裁剪范围,再评估模型输入和边缘融合,避免把整张照片都纳入计算。
- 如果调色流程已经共用一个像素函数,可以评估通过颜色网格导出
.cube,并明确列出不会进入 LUT 的空间效果。