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

js超大图片取色实践(分片+webworker)
最新回答
星雾月凌

2022-08-20 23:50:37

简介

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

前端图片取色大致流程:

图像预处理(转为pixel数据)=>

鼠标hover到图片上,计算相对原图的坐标点=>

根据坐标,从pixels中取到相应的数据。

本文只讲流程1和3。

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

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

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

方案1.传统方案

请先进入demo1。

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

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

2.分片

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

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

可参考此图:

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

typeChunk={x:number;y:number;width:number;height:number;ctx:OffscreenCanvasRenderingContext2D;};typeTwoDimChunks=Chunk[][];

核心代码如下:

typeChunkWithoutCtx=Omit<Chunk,'ctx'>;//算商和余functioncalcRemainder(dividend,divisor){constquotient=Math.floor(dividend/divisor);constremainder=dividend%divisor;return{quotient:remainder>0?quotient+1:quotient,remainder:remainder===0?divisor:remainder,};}//分片算法functionmake2DimChunks(props:{imageWidth:number,imageHeight:number,chunkWidth:number,chunkHeight:number,}):ChunkWithoutCtx[][]{constchunks:ChunkWithoutCtx[][]=[];const{imageHeight,imageWidth,chunkWidth,chunkHeight,}=props;const{quotient:rowNum,remainder:rowRemainder}=calcRemainder(imageHeight,chunkHeight);const{quotient:colNum,remainder:colRemainder}=calcRemainder(imageWidth,chunkWidth);for(leti=0;i<rowNum;i+=1){chunks.push([]);for(letj=0;j<colNum;j+=1){constctxObj:ChunkWithoutCtx={x:j*chunkWidth,y:i*chunkHeight,width:j===colNum-1?colRemainder:chunkWidth,height:i===rowNum-1?rowRemainder:chunkHeight,};chunks[i].push(ctxObj);}}returnchunks;}constchunksWithoutCtx=make2DimChunks({imageWidth:imageBitmap.width,imageHeight:imageBitmap.height,chunkWidth,chunkHeight,});//拼上canvas2dcontext,便于之后取色consttwoDimChunks=chunksWithoutCtx.map((row)=>row.map((chunk:Chunk)=>{//若存在兼容性问题,可以使用普通的domcanvas代替constcanvas=newOffscreenCanvas(chunk.width,chunk.height);consttempCtx=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数据functiongetPixel(x:number,y:number,options:{chunkWidth:number,chunkHeight:number}){constrowIndex=Math.floor(y/options.chunkHeight);constcolIndex=Math.floor(x/options.chunkWidth);//找到对应的chunkconstctxObj=twoDimChunks?.[rowIndex]?.[colIndex];if(ctxObj){//通过getImageData取色returnArray.from(ctxObj.ctx.getImageData(x-ctxObj.x,y-ctxObj.y,1,1).data).slice(0,3);}return[0,0,0];}

优劣

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

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

代码示例

见demo3

3.webworker+分片

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

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

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

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

worker中无法访问dom。mainthread可以访问dom,drawImage(imageDom)的时候是取的dom。

worker受同源限制。

postMessage不支持callback。

垃圾回收。

问题1:worker中无法访问dom

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

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

问题2:worker受同源限制

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

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

functioncreateWorker(f){varblob=newBlob(['('+f.toString()+')()']);varurl=window.URL.createObjectURL(blob);varworker=newWorker(url);returnworker;}createWorker(functionworkerWrapper(e){//...这里写worker代码});

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

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

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

问题3:postMessage不支持callback

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

mainthread中:

typeEventData={action:string;payload:any[];id:number;};leti=0;functiongenerateId(){i+=1;returni;}//扩展workerpostMessage方法functionwrapWorkerWithCallback(worker:Worker){return{postMessage:(data:EventData,callback:(payload:any[])=>void)=>{constmessageId=generateId();worker.postMessage({...data,id:messageId,});constonMessage=(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中:

typeEventData={action:string;payload:any[];id:number;};typeCallback=(payload:any[])=>void;typeMessageHandler=(ev:MessageEvent<EventData>,callback:Callback)=>void;//生成messagecallback,后续onmessage回调中会使用functionmakeMessageCallback(ev:MessageEvent<EventData>){const{action,id}=ev.data;return(payload:any[])=>{self.postMessage({action:`${action}Callback`,id,payload,});};}//封装onmessage,提供回调支持functiononmessage(handler:MessageHandler){self.onmessage=(ev:MessageEvent<EventData>)=>{constcallback=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':{constpixel=getPixel.apply(self,payload.concat([options]));//回调给mainthreadcallback([pixel]);}break;default:break;}});

问题4:垃圾回收

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

exportfunctioncreateWorker(f){constblob=newBlob([`(${f.toString()})()`]);consturl=window.URL.createObjectURL(blob);constworker=newWorker(url);return{worker,revoke:()=>window.URL.revokeObjectURL(url),};}const{worker,revoke}=createWorker(workerWrapper);//释放workerworker.terminate();//释放bloburlrevoke();

优劣

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

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

兼容情况如下:

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

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

代码示例

见链接

性能分析

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

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

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

functionaverage(arr){constrest=arr.sort((a,b)=>a-b);rest.pop();rest.shift();returnrest.reduce((acc,cur)=>acc+cur,0)/rest.length;}

无worker时间

拉图片:1520ms

drawImage:3395ms

总时长:4915ms

内存

预处理前

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

有worker时间

拉图片:97ms

toblob:693ms

创建bitmap:1985ms

drawImage:1536ms

总时长:4311ms

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

内存

预处理前

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

对比

时间上,两者差不多。

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

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

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

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

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

其它思路

1.jsdecodeimage

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

2.压缩图片后,再取色

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

可以沟通让后端压缩。

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

结论

超大图取色不能使用传统方案,会导致canvascrash。

分片(将一张大图拆到多个canvas上绘制,取色时定位到一个chunk上取)可以解决超大图crash的问题。

分片加上worker,可以避免ui阻塞,使用户体验更佳,不过整体等待时间无太大差别。

参考

MDNimageBitmap

MDNusingwebworkers

MDNCanvas

LoadingImageswithWebWorkers

阮一峰webworker介绍

whenuseworkers--google开发者

howfastarewebworkers

GPUmemory

原文:https://juejin.cn/post/7098318817474363423