简介
业务背景:用户上传图案,从图案上取色,然后将颜色及相关信息 传递给算法做图像分色,最终产生生产所需的文件。
前端图片取色大致流程:
- 图像预处理(转为pixel数据)=>
- 鼠标hover到图片上,计算相对原图的坐标点 =>
- 根据坐标,从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 查看效果)
原因是浏览器会对单个canvas的尺寸做限制。参考链接
2.分片
既然单个canvas有限制,自然想到了分片。
大致的原理是:将图像分成若干片,每一片绘制局部的图像,在取色的时候,将坐标转换到对应的片取色。
可参考此图:
最重要的是构造如下数据结构,便于在取色的时候使用:
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在处理大图的时候会有几秒钟阻塞。
代码示例
3.web worker + 分片
分片虽然能解决问题,但是在点击取色后(做图像预处理),有明显的ui阻塞现象:
所以尝试把耗时大的逻辑放到web worker处理。
经过性能分析,发现drawImage是耗时最多的环节,于是将绘制过程转移到web worker。界面效果可以看到明显的提升:
在使用web worker的过程中,遇到了一些问题:
- worker中无法访问dom。main thread可以访问dom,
drawImage(imageDom)
的时候是取的dom。 - worker受同源限制。
- postMessage不支持callback。
- 垃圾回收。
问题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依赖,导致报错。
问题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有一定学习成本及实现成本;兼容性问题。
兼容情况如下:
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
内存
预处理前
预处理后:内存不升反降,GPU内存升高1.5G
有worker
时间
拉图片: 97 ms
to blob: 693 ms
创建bitmap: 1985 ms
drawImage: 1536 ms
总时长:4311 ms
注意: 这里拉图片和无worker时的拉图片不一样。无worker是用 img.src,会包含完整的pixels data,这里仅仅是fetch,所以时间上有较大的差距。
内存
预处理前
预处理后:内存升高1.1G,GPU内存升高1.5G。
对比
时间上,两者差不多。
但是如果拆开来看,可以发现同是drawImage,有worker只用花费 1536 ms,而无worker则会花费 3359 ms,2倍时长。
为什么会这样?暂未找到答案。
内存上,worker会占用更多的内存。
原因是worker中存储了被绘制的canvas数据。
而为何无worker的内存会不增反降?这个问题也待探索。
其它思路
1.js decode image
即用js的方式直接decode图片,尝试了一下这个decode库,图稍微大一点就会导致浏览器崩溃。
2.压缩图片后,再取色
尝试用oss压缩,会有尺寸限制(4096 * 4096)。
可以沟通让后端压缩。
或者canvas绘制时 压缩图片,取色时做一下坐标转换,但缺乏通用性。
结论
- 超大图取色不能使用传统方案,会导致canvas crash。
- 分片(将一张大图拆到多个canvas上绘制,取色时定位到一个chunk上取)可以解决超大图crash的问题。
- 分片 加上 worker,可以避免ui阻塞,使用户体验更佳,不过整体等待时间无太大差别。