js超大图片取色实践(分片+web worker)

10,576 阅读7分钟

简介

业务背景:用户上传图案,从图案上取色,然后将颜色及相关信息 传递给算法做图像分色,最终产生生产所需的文件。

取色图1.gif

前端图片取色大致流程:

  1. 图像预处理(转为pixel数据)=>
  2. 鼠标hover到图片上,计算相对原图的坐标点 =>
  3. 根据坐标,从pixels中取到相应的数据。

本文只讲流程 1 和 3

以下三种方案,基本原理都是将图片绘制到canvas上(aka 图像预处理,主要是解析为pixel数据),然后通过 2d context getImageData取到单个pixel的颜色。

然而我们的业务场景里,一张印花图尺寸可能大到20000 * 20000 px,面对超大图取色的场景,普通的预处理手段(直接绘制在单个canvas上面)会导致canvas崩溃,进而无法正确取色。

接下来展开讲解各方案的问题及优劣。

方案

1.传统方案

请先进入 demo1

这张图片大小 14410 × 19938,直接通过getImageData取色会发现取出来是 [0, 0, 0],如果将canvas绘制在document中(不用offscreenCanvas),你会发现canvas挂掉了(可通过 demo2 查看效果)

image.png

原因是浏览器会对单个canvas的尺寸做限制。参考链接

image.png

2.分片

既然单个canvas有限制,自然想到了分片。

大致的原理是:将图像分成若干片,每一片绘制局部的图像,在取色的时候,将坐标转换到对应的片取色。

可参考此图:

image.png 最重要的是构造如下数据结构,便于在取色的时候使用:

type Chunk = {
  x: number;
  y: number;
  width: number;
  height: number;
  ctx: OffscreenCanvasRenderingContext2D;
};

type TwoDimChunks = Chunk[][];

核心代码如下:

type ChunkWithoutCtx = Omit<Chunk, 'ctx'>;

// 算商和余
function calcRemainder(dividend, divisor) {
  const quotient = Math.floor(dividend / divisor);
  const remainder = dividend % divisor;

  return {
    quotient: remainder > 0 ? quotient + 1 : quotient,
    remainder: remainder === 0 ? divisor : remainder,
  };
}

// 分片算法
function make2DimChunks(props: {
  imageWidth: number,
  imageHeight: number,
  chunkWidth: number,
  chunkHeight: number,
}): ChunkWithoutCtx[][] {
  const chunks: ChunkWithoutCtx[][] = [];
  const {
    imageHeight, imageWidth, chunkWidth, chunkHeight,
  } = props;
  const { quotient: rowNum, remainder: rowRemainder } = calcRemainder(imageHeight, chunkHeight);
  const { quotient: colNum, remainder: colRemainder } = calcRemainder(imageWidth, chunkWidth);

  for (let i = 0; i < rowNum; i += 1) {
    chunks.push([]);

    for (let j = 0; j < colNum; j += 1) {
      const ctxObj: ChunkWithoutCtx = {
        x: j * chunkWidth,
        y: i * chunkHeight,
        width: j === colNum - 1 ? colRemainder : chunkWidth,
        height: i === rowNum - 1 ? rowRemainder : chunkHeight,
      };

      chunks[i].push(ctxObj);
    }
  }

  return chunks;
}

const chunksWithoutCtx = make2DimChunks({
  imageWidth: imageBitmap.width,
  imageHeight: imageBitmap.height,
  chunkWidth,
  chunkHeight,
});

// 拼上 canvas 2d context,便于之后取色
const twoDimChunks = chunksWithoutCtx.map((row) => row.map((chunk: Chunk) => {
  // 若存在兼容性问题,可以使用普通的dom canvas代替
  const canvas = new OffscreenCanvas(chunk.width, chunk.height);

  const tempCtx = canvas.getContext('2d');

  tempCtx!.drawImage(
    imageBitmap,
    chunk.x, chunk.y, chunk.width, chunk.height,
    0, 0, chunk.width, chunk.height,
  );

  return { ...chunk, ctx: tempCtx }
}));

// 获取pixel数据
function getPixel(x: number, y: number, options: { chunkWidth: number, chunkHeight: number}) {
  const rowIndex = Math.floor(y / options.chunkHeight);
  const colIndex = Math.floor(x / options.chunkWidth);

  // 找到对应的chunk
  const ctxObj = twoDimChunks?.[rowIndex]?.[colIndex];

  if (ctxObj) {
		// 通过getImageData取色
    return Array.from(ctxObj.ctx.getImageData(x - ctxObj.x, y - ctxObj.y, 1, 1).data).slice(0, 3);
  }

  return [0, 0, 0];
}

优劣

优势:解决了超大图绘制canvas崩溃的问题。

劣势:main thread在处理大图的时候会有几秒钟阻塞。

代码示例

demo3

3.web worker + 分片

分片虽然能解决问题,但是在点击取色后(做图像预处理),有明显的ui阻塞现象:

取色2.gif

所以尝试把耗时大的逻辑放到web worker处理。

经过性能分析,发现drawImage是耗时最多的环节,于是将绘制过程转移到web worker。界面效果可以看到明显的提升:

取色3.gif

在使用web worker的过程中,遇到了一些问题:

  1. worker中无法访问dom。main thread可以访问dom,drawImage(imageDom) 的时候是取的dom。
  2. worker受同源限制。
  3. postMessage不支持callback。
  4. 垃圾回收。

问题1:worker中无法访问dom

可通过 image url => blob => imageBitmap => drawImage(imageBitmap) 的方式来解决。

fetch(imageUrl).then((resp) => resp.blob()).then((blob) => createImageBitmap(blob)).then((imageBitmap) => {
    // ...
    ctx!.drawImage(imageBitmap, 0, 0);
});

问题2:worker受同源限制

new Worker(workerUrl) ,底层构建出来,会生成g.alicdn.com的资源,与本域名不同,浏览器会由于同源策略无法启用该worker。

一种绕过的方式是使用字符串,但是写起来体验不好,无法使用ts。在阮一峰老师的文章看到一种巧妙的写法:

function createWorker(f) {
  var blob = new Blob(['(' + f.toString() +')()']);
  var url = window.URL.createObjectURL(blob);
  var worker = new Worker(url);
  return worker;
}

createWorker(function workerWrapper(e) {
  // ... 这里写worker代码
});

如此一来可以按照正常的typescript来写。

但是也有一些局限,比如不能在 workerWrapper中使用一些高级es用法,比如 ...await

因为webpack会通过babel处理代码,有些方法会被转义。而上述创建worker的方法 是通过 function.toString()的方式,所以不会引入webpack module依赖,导致报错。

image.png

问题3:postMessage不支持callback

原理很简单,每次postMessage生成一个id,worker回传的时候,如果发现是相同id,则调用对应的callback。

main thread中:

type EventData = {
  action: string;
  payload: any[];
  id: number;
};

let i = 0;

function generateId() {
  i += 1;
  return i;
}

// 扩展worker postMessage方法
function wrapWorkerWithCallback(worker: Worker) {
  return {
    postMessage: (data: EventData, callback: (payload: any[]) => void) => {
      const messageId = generateId();
      worker.postMessage({
        ...data,
        id: messageId,
      });

      const onMessage = (ev: MessageEvent<EventData>) => {
        const { id, payload } = ev.data;
        if (id === messageId) {
          callback(payload);
          worker.removeEventListener('message', onMessage);
        }
      };

      worker.addEventListener('message', onMessage);
    },
    terminate: worker.terminate.bind(worker),
  };
}

this.worker = wrapWorkerWithCallback(worker);

// 扩展worker 的使用
this.worker.postMessage({
  action: 'getPixel',
  payload: [x, y],
}, (payload) => {
 	// 回调,接收到worker返回的pixel数据
  resolve(payload[0]);
});

worker中:

type EventData = {
  action: string;
  payload: any[];
  id: number;
};

type Callback = (payload: any[]) => void;

type MessageHandler = (
  ev: MessageEvent<EventData>,
  callback: Callback
) => void;

// 生成message callback,后续onmessage回调中会使用
function makeMessageCallback(ev: MessageEvent<EventData>) {
  const { action, id } = ev.data;

  return (payload: any[]) => {
    self.postMessage({
      action: `${action}Callback`,
      id,
      payload,
    });
  };
}

// 封装onmessage,提供回调支持
function onmessage(handler: MessageHandler) {
  self.onmessage = (ev: MessageEvent<EventData>) => {
    const callback = makeMessageCallback(ev);
    handler(ev, callback);
  };
}

onmessage((ev, callback) => {
  const {
    action, payload,
  } = ev.data;

  switch (action) {
    case 'drawImage':
      // 无法用...payload,webpack会引入外部包导致报错
      drawImage.apply(self, payload.concat([callback]));
      break;
    case 'getPixel':
      {
        const pixel = getPixel.apply(self, payload.concat([options]));

        // 回调给main thread
        callback([pixel]);
      }
      break;
    default:
      break;
  }
});

问题4:垃圾回收

切换路由的时候发现之前的内存没有回收,原因是没有释放worker。解决方式如下:

export function createWorker(f) {
  const blob = new Blob([`(${f.toString()})()`]);
  const url = window.URL.createObjectURL(blob);
  const worker = new Worker(url);

  return {
    worker,
    revoke: () => window.URL.revokeObjectURL(url),
  };
}

const {worker, revoke} = createWorker(workerWrapper);

// 释放worker
worker.terminate();
// 释放 blob url
revoke();

优劣

优势: 解决了方案2的ui渲染阻塞问题,用户体验好。

劣势:web worker有一定学习成本及实现成本;兼容性问题。

兼容情况如下:

image.png

image.png

web worker兼容性尚可,但是在worker里绘制需要用到offscreenCanvas,兼容性堪忧。

我们的业务场景比较特殊,可以限制用户浏览器,所以不是问题。这点需要根据业务场景酌情考虑。

代码示例

链接

性能分析

这里只对比方案2和3 图像预处理 的性能差异,包括时间和内存。下面分别称 无worker 和 有worker。

测试图片:大小7.1M,尺寸 14410 × 19938。

时长计算方式:测12次,去掉最高最低值,留10次测试值 取平均值:

function average(arr) { 
    const rest = arr.sort((a, b) => a-b);
    rest.pop();
    rest.shift();
    return rest.reduce((acc, cur) => acc + cur , 0) / rest.length;
}

无worker

时间

拉图片: 1520 ms

drawImage: 3395 ms

总时长:4915 ms

内存

预处理前

image.png

预处理后:内存不升反降,GPU内存升高1.5G

image.png

有worker

时间

拉图片: 97 ms

to blob: 693 ms

创建bitmap: 1985 ms

drawImage: 1536 ms

总时长:4311 ms

注意: 这里拉图片和无worker时的拉图片不一样。无worker是用 img.src,会包含完整的pixels data,这里仅仅是fetch,所以时间上有较大的差距。

内存

预处理前

image.png

预处理后:内存升高1.1G,GPU内存升高1.5G。

image.png

对比

时间上,两者差不多。

但是如果拆开来看,可以发现同是drawImage,有worker只用花费 1536 ms,而无worker则会花费 3359 ms,2倍时长。

为什么会这样?暂未找到答案。

内存上,worker会占用更多的内存。

原因是worker中存储了被绘制的canvas数据。

而为何无worker的内存会不增反降?这个问题也待探索。

其它思路

1.js decode image

即用js的方式直接decode图片,尝试了一下这个decode库,图稍微大一点就会导致浏览器崩溃。

2.压缩图片后,再取色

尝试用oss压缩,会有尺寸限制(4096 * 4096)。

可以沟通让后端压缩。

或者canvas绘制时 压缩图片,取色时做一下坐标转换,但缺乏通用性。

结论

  1. 超大图取色不能使用传统方案,会导致canvas crash。
  2. 分片(将一张大图拆到多个canvas上绘制,取色时定位到一个chunk上取)可以解决超大图crash的问题。
  3. 分片 加上 worker,可以避免ui阻塞,使用户体验更佳,不过整体等待时间无太大差别。

参考