把整条图像处理链路放进浏览器:省下的不只是 CPU
把整条图像处理链路放进浏览器:省下的不只是 CPU
站内搜索
直接问 AI

把整条图像处理链路放进浏览器:省下的不只是 CPU

我给站点做了一个工具:上传角色的几张不同角度图片,自动抠图、分层、转成 SVG 矢量资产,存进资产库供以后做动画用。

约束只有一条:不上传服务器,算力和存储全部在用户设备上。

这条约束听起来像隐私考量,实际上最初的动机是成本——源站是台单核机器,前面挂着页面缓存,正常情况下每个请求的 CPU 开销接近零。加一条服务端图像处理链路会彻底破坏这个前提。

但做完之后发现,真正省下的不是 CPU。

省下的其实是四样东西

存储。用户上传的图片要存在哪里、存多久、谁来清理——这些问题一旦有服务端上传就必须回答。全在客户端就不存在。

审核。任何允许用户上传图片并保存的功能,都会成为一个内容审核面。个人站点没有能力承担这件事。

隐私责任。不接收数据,就不必声明如何保管数据、保留多久、如何删除。

并发。服务端图像处理最难的不是单张耗时,是同时来五个人。单核机器上这等于服务不可用。客户端处理天然按人分摊——每个人用自己的 CPU。

回头看,CPU 只是这四样里最不重要的一样。

整条链路怎么在浏览器里跑

输入是一张图片文件,输出是一组带路径数据的 SVG 图层。中间要走五步,每一步都有现成的浏览器 API 或纯 JS 实现。

解码。URL.createObjectURL<img>,等 onload,再画到 canvas 上 getImageData 拿到 RGBA 像素数组。这一步顺便做了超分:绘制时给定目标尺寸,浏览器的图像缩放比手写插值快得多也好得多。

抠图。两条路。经典路径是从图像四边取种子做泛洪填充,把与边缘颜色相近的连通区域判为背景——对干净背景的素材很有效,即时出结果。复杂背景则需要分割模型,走 ONNX Runtime Web 在 WASM 上推理。

分层。对前景像素做 k-means 颜色聚类,把角色分成若干色块。聚类数就是用户看到的”分层数量”。

连通域。同一个颜色簇在画面上可能分散成好几块(比如左右两只袖子),需要用连通域标记把它们分开,并丢弃面积过小的碎块。

矢量化。对每个连通域做轮廓追踪得到点列,再用 Douglas-Peucker 简化,最后拼成 SVG 的 path 数据。

全程没有任何网络请求,除了 AI 抠图那条路要下载一次模型。

存储:IndexedDB 和它的代价

资产库要能跨会话保留,所以需要持久化。localStorage 不行——它只存字符串且通常限制在几 MB。IndexedDB 可以直接存结构化对象,容量按磁盘可用空间给,是唯一合适的选择。

但它有一个必须向用户说明的性质:清除浏览器数据会一并删掉它。用户不会预期”清缓存”会删掉自己的作品。

所以导出功能不是锦上添花,是这个架构的必需组件。选择把数据放在用户设备上,就必须同时给用户把数据拿走的能力,否则一次误操作就是永久丢失。

另外隐私模式下 IndexedDB 可能被禁用。这种情况要显式提示,而不是让保存操作静默失败。

导出 ZIP 不需要压缩库

导出多个文件时自然想打包成 ZIP。引入一个压缩库要几十 KB,而且对已经压缩过的 PNG 几乎没有收益。

ZIP 格式支持存储模式(stored,压缩方法 0):文件原样写入,不做任何压缩。这样一来整个写入器只需要处理三种结构——本地文件头、中央目录项、中央目录结尾记录——加一个 CRC32 实现。一百多行,零依赖。

CRC32 用查表法,256 项的表在首次调用时生成:

var TABLE = null;
function crcTable() {
  if (TABLE) return TABLE;
  TABLE = new Uint32Array(256);
  for (var n = 0; n < 256; n++) {
    var c = n;
    for (var k = 0; k < 8; k++) {
      c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
    }
    TABLE[n] = c >>> 0;
  }
  return TABLE;
}

这是”用格式本身的能力替代依赖”的典型例子。ZIP 支持不压缩,而我们本来也不需要压缩——引入压缩库解决的是一个不存在的问题。

模型下载:唯一无法回避的代价

AI 抠图这条路要在用户设备上跑分割模型,模型必须先下载。这是整个架构里唯一一处用户能明确感知的成本。

几个必须做对的地方:

按需下载。模型只在用户主动选择 AI 模式时才拉取。绝大多数访客只是路过页面,不该为一个他们不用的功能付流量。

显示进度。几十 MB 的下载如果没有反馈,看起来就是卡死。用 fetch 的流式读取累加已收字节,实时更新:

var reader = res.body.getReader();
var received = 0;
(function pump() {
  return reader.read().then(function (r) {
    if (r.done) return assemble();
    received += r.value.length;
    onProgress(received, total);      // total 来自 content-length
    return pump();
  });
}());

让浏览器缓存它。模型是不可变的静态文件,配上长期缓存头,第二次使用就不再下载。

精度选型要按最坏输入决定,不是按体积。我第一次上线用的是 int8 量化版,体积最小,在测试样本上精度损失可忽略——但遇到模型不确定的图片时输出崩坏。换成 fp16 后体积翻倍,精度基本无损。省下的那点流量不值得换一个有时不可用的功能。

主线程做图像处理,页面会整个卡死

把计算搬到客户端之后会撞上一个服务端没有的约束:浏览器的主线程同时负责渲染界面和响应交互。在这个线程上跑一个几百毫秒的循环,页面就会有几百毫秒完全不响应——按钮点不动,滚动卡住,连”处理中”的动画都停住。

而图像处理天然就是长循环。对一张 1024×1024 的图做逐像素操作是一百万次迭代,配合模型推理很容易到秒级。

解决办法是把重活挪进 Web Worker——一个独立线程,它跑多久都不影响界面。代价是 Worker 和主线程之间只能传消息,不能共享普通对象。

这里有个决定性能的细节:传大块数据时要用可转移对象,不要让它被结构化克隆。默认情况下 postMessage 会把数据完整复制一份,一张 4MB 的位图传过去就多占 4MB 并耗时拷贝。把底层的 ArrayBuffer 列进转移列表,则是把所有权移交过去,零拷贝:

// 第二个参数是转移列表,buffer 的所有权直接交给 worker
worker.postMessage({ pixels: imageData.data.buffer, w, h },
                   [imageData.data.buffer]);
// 注意:转移之后主线程这边的 buffer 会变成长度 0,不能再用

转移之后原线程不能再访问那块内存——这不是限制,正是它零拷贝的原因。如果后面还要用,就先复制一份再转移,那样至少复制是你主动控制的。

另外 OffscreenCanvas 可以把 canvas 本身交给 Worker,让绘制和像素读写全部离开主线程。它在移动端 Safari 上支持较晚,需要做能力检测和回退。

移动端有一个不会报错的内存上限

还有一个只在真机上才会暴露的边界:移动浏览器对单个标签页的内存有硬上限,超过之后系统直接杀掉这个页面。

它的表现不是异常、不是报错,而是页面白屏或者直接重新加载。你的 try/catch 捕获不到,错误上报也发不出去——因为执行环境已经没了。在桌面上测一切正常,用户在手机上就是”一处理就闪退”。

图像处理特别容易触碰这条线,因为中间产物远比想象的大。一张 4000×3000 的照片,JPEG 文件可能只有 3MB,但解码成 ImageData 之后是 4000 × 3000 × 4 ≈ 46 MiB。管线里如果同时存在原图、缩放后的副本、掩码和输出,四份就接近 200MB——已经在危险区。

几条实际有效的约束:

  • 先按最长边限制到一个上限(比如 2048)再处理,而不是处理完再缩小。绝大多数场景下用户看不出差别,内存占用却降到四分之一。
  • 用完的大对象立刻断引用,尤其是 ImageData 和临时 canvas。把临时 canvas 的宽高设成 0 能更快释放它的后备存储。
  • 不要同时持有一条链路上的所有中间结果。逐步覆盖,只保留当前这一步需要的输入和输出。

验证只能在真机上做。桌面浏览器有几个 G 的余量,永远测不出这个问题——这一点和前面那些能在控制台里复现的问题完全不同,必须拿手机跑一遍大图。

什么时候不该这么做

这套架构不是万能的,有几个明确的边界。

如果处理结果需要在用户之间共享——比如生成的资产要给团队其他人看——那数据终究要到服务器上,客户端处理只能省下计算,省不掉存储和审核。

如果模型大到几百 MB,或者推理需要几分钟,用户体验会差到不如上传。这里有个粗略的判断:下载模型的时间如果超过上传原图的时间很多倍,客户端方案就不划算了。

如果需要跨设备访问同一份数据,IndexedDB 是每个浏览器独立的,做不到。这时候要么接受手动导入导出,要么就得有服务端。

而如果你的场景是”单次处理、结果归用户自己、不需要共享”——图像转换、格式处理、本地分析这一类——那客户端方案省下的东西远比省下的 CPU 多。

发表回复

向下探索