故事起因 我是一个魔方爱好者(只是爱好,但技术并不强),在大学期间担任过魔方社社长,每到招新的时候,一般都会用上千个魔方拼出招新二维码,显得比较有逼格。二维码本身也是一个一个的小格子组成,并且只有两种颜色,把二维码下载下来,然后画一些辅助线用魔方照着拼出来就好了。
有一年女朋友过生日,我想用魔方拼出他的照片人像,肯定比较有意义。但是有一个棘手的问题,如何将一张图片转换为6种颜色的小格子呢,当时在网上始终都没有找到符合的工具,于是这个想法也就破灭了。
几年过去了,忽然又回想起这件事,想着是不是可以用Javascript自己做个这个功能,说干就干。
思路 毫无疑问,肯定是使用 canvas
,使用 drawImage
方法将图片绘制到 canvas 上,然后通过 getImageData
方法获取到每个像素点的颜色值,修改颜色值,重新绘制图片,最后将图片下载下来。其中有几个问题:
图片本身的颜色有很多,但是魔方只有6种颜色,如何将整个图片转为只有6中颜色的图片。 一张图片的像素点很多,不可能每个像素点都转换为魔方的一个块,不然不切实际。比如一张1000 * 1000 像素的图片,应该转为 100 * 100个魔方格才比较符合实际,我把这个操作称之为 “降低精度”。 正式开始 注:下面的代码使用的是jsx语法
第一步:将图片绘制到 canvas
上
... const ImgRef = useRef(null); const [imgUrl, setImgUrl] = useState(''); ...function getImageData() {const canvas: any = document.getElementById('canvas');const ctx = canvas.getContext('2d');const { width, height } = ImgRef.current;canvas.width = width;canvas.height = height;ctx.drawImage(ImgRef.current, 0, 0, width, height); }...
这里不能将 cnavas 的宽高定死,需要根据上传的图片大小进行动态设置
第二步:上传图片
使用 antd
的上传组件进行图片上传,将图片转为base64的形式进行显示。
import { Button, Upload } from 'antd';... const file2base64 = function (file: File, callback: (base64: any) => void) {const reader = new FileReader();reader.addEventListener('load', () => callback(reader.result));reader.readAsDataURL(file); }function onFileChange(file: any) {const len = file.fileList.length;file2base64(file.fileList[len - 1].originFileObj, imageUrl => {setImgUrl(imageUrl);}); } ...> }>上传图片
第三步:获取图片数据,对数据进行处理
const data = ctx.getImageData(0, 0, width, height).data;
说明:获取到的数据是一个数组,每 4个数据就是一个像素点,分别代表 红色(r),绿色(g),红色(b),透明度(a),如果有1000个像素,就有 4000个数据。像素数据是按照图片的从左到右从上至下依次排列的。
问题一:如何将不同的颜色转换为6种目标色?
魔方的6种颜色为:#e41e3a、#ff5800、#ffd500、#009e60、#0051ba、#ffffff
方案一:将HEX色值转为色相,色相为一个 360 度的圆环,6种颜色在色相环上对应6个不同的角度,目标色的色相也会对应一个角度,计算距离哪种颜色的角度最小,就将其转换为相应的颜色。经过测试这种方式转换出来的图片与原图的颜色分布差距较大。
方案二:将rgb看做是三维坐标,对应三维坐标系中的一个点,通过求两个点之间的距离来计算相似度,距离越小,相似度越高。把目标颜色转换为相似度最高的颜色。
function getSimilarity ( color1: any, color2: any ) : number { const { r: r1, g: g1, b: b1 } = color1; const { r: r2, g: g2, b: b2 } = color2; return Math. sqrt ( Math. pow ( r1 - r2, 2 ) + Math. pow ( g1 - g2, 2 ) + Math. pow ( b1 - b2, 2 ) ) }
问题二:如何”降低精度“?
假如上面这张图片,我们要转换成10 * 7个小格子,每个格子只能填充一种颜色,我们只需要取每个小格子中的其中一个像素点的颜色即可,可以取左上角第一个,也可以取中间的,没有特殊的要求。当然每个格子的取值点最好一致。
经过处理后处理后就可以得到下面这张图。
这貌似什么都看不出来,这是因为“降低精度”过渡,我们可以尝试调整参数值,将5*5个像素转为一个方块。
是不是已经可以看到轮廓样子了,毕竟只有6种颜色,所以对于细节较多的图片在效果图中无法体现出来。我们换一张单调点的图片看看。
第四步:重新效果图
在上面我们得到了原始图片的数据 data
,对数据处理后需要重新绘制效果图。这里只是一些逻辑上的计算。
const { width, height } = ImgRef. current; const gap = 10 ; for ( var h = 0 ; h < height; h+= gap) { for ( var w = 0 ; w < width; w+= gap) { var position = ( width * h + w) * 4 * gap; var r = imageData[ position] , g = imageData[ position + 1 ] , b = imageData[ position + 2 ] ; let color = MosaicImage ( r, g, b) ; ctx. fillStyle = color; ctx. fillRect ( w, h, gap, gap) ; } } function MosaicImage ( r: number, g: number, b: number ) { let similarityColor: any = { } ; let maxSimilarity = Infinity ; cubeColors. forEach ( ( item ) => { const [ r2, g2, b2] = item. rgb. split ( &#39;,&#39; ) ; const similarity = getSimilarity ( { r, g, b} , { r: Number ( r2) , g: Number ( g2) , b: Number ( b2) } ) ; if ( similarity < maxSimilarity) { maxSimilarity = similaritysimilarityColor = item; } } ) return similarityColor. color; }
- 首先我们来定义一个常量 `gap` ,表示方块的宽高 - 两层嵌套循环,`position` 表示获取的像素点在数组中的位置:`width * h` 表示行数;`+ w` 表示某行的第几个像素; `* 4` 是因为一个像素点在数组中需要占4个位置;`* gap` 是获取第n个小方块的左上角的那个像素点位置 - position,position+1,position+2,position+3分别对应了一个像素点的 rgba 信息 - MosaicImage 方法为转换后的目标颜色 - 使用 `fillStyle` 设置绘制颜色,使用 `fillRect` 方法绘制小方块
通过对图片每个像素点的操作,可以做出很多有意思的东西,比如说图片马赛克、颜色反转、简单的抠图等功能。
图片马赛克 与上面制作魔方图的原理相同,去掉颜色转换的步骤,可以直接取每个小方块的左上角或中间的像素颜色作为小方块的颜色。
const { width, height } = ImgRef . current; const gap = 10 ; for ( var h = 0 ; h < height; h+= gap) { for ( var w = 0 ; w < width; w+= gap) { var position = ( width * h + w) * 4 * gap; var r = imageData[ position] , g = imageData[ position + 1 ] , b = imageData[ position + 2 ] ; let color = `rgb ( ${ r} , ${ g} , ${ b} ) `; ctx. fillStyle = color; ctx. fillRect ( w, h, gap, gap) ; } }
颜色反转 将 rgb 的各自的值都用 255 减一下
function ReversalColor ( r: number, g: number, b: number ) : string { return ` rgb( ${ 255 - r} , ${ 255 - g} , ${ 255 - b} ) ` ; }
抠图 这里只能做一些简单的抠图,如果要实现一些复杂的抠图,需要配合很好的算法。
可以设置一些目标颜色,将匹配的与目标色相同的像素点的透明度设置为 0 即可。主要要值得注意的是,不能使用上面重新绘制的方式,重新绘制是在原来的图片上面覆盖一层,得到的结果并不是透明的png图片。这里需要使用修改原数据的方式实现,后面会讲到。
换颜色 将指定颜色换为目标色,可用于更换头像背景色。
下载图片 将canvas内容转为图片链接,然后进行下载。当然也可以鼠标右键直接下载。
function downloadImage ( ) { const canvas: any = document. getElementById ( &#39;canvas&#39; ) ; const imgUrl = canvas. toDataURL ( "image/png" ) ; console. log ( imgUrl) ; const a = document. createElement ( &#39;a&#39; ) ; a. download = &#39;图片.jpg&#39; ; a. href = imgUrl; a. setAttribute ( &#39;download&#39; , &#39;chart-download&#39; ) ; a. click ( ) ; }
优化 为了更加方便的处理,我把这几个功能做成了一个小项目,可以点击这里进行体验。
现在可以很方便的切换不同的模式,并且可以设置像素大小,目标色也可以自定义(目前还没有做,近期会加上去)。
当我把像素大小设置为1时,相当于对每个像素点都需要进行处理,有10000个像素的话就需要画10000个小方块,导致页面出现卡顿现象。
优化一下之前方案,之前是采用重新绘制的方式,其实我们也可以修改原数据的方式。通过 getImageData
方法可以得到你一个 ImageData
对象。
其中 data
是一个 Uint8ClampedArray
(8位无符号整型固定数组) 类型化数组表示一个由值固定在0-255区间的8位无符号整型组成的数组;如果你指定一个在 [0,255] 区间外的值,它将被替换为0或255;如果你指定一个非整数,那么它将被设置为最接近它的整数。
通过处理数据的方式比重绘的方式要复杂一些,涉及到数据的计算,比如我们现在要将下面这个小方块的区域全部设置为一种颜色:
首先我们知道方块左上角第一个像素的起始索引值 positon
,小方块的宽高 gap
,图片的宽度 width
。
for ( let y = 0 ; y < gap; y++ ) { for ( let x = 0 ; x < gap; x++ ) { const point = position + ( x + width * y) * 4 ; imageObj. data[ point] = r; imageObj. data[ point + 1 ] = g; imageObj. data[ point + 2 ] = b; imageObj. data[ point + 3 ] = a; } }
point
为目标像素点的索引值,这里要注意一点,只能通过设置每一位方式去设置值,不能使用数组的 splice
方法批量处理。Uint8ClampedArray
上不存在这个方法。处理数据后,使用 putImageData
方法绘制图片,完整代码如下:
function handleImageData ( ) { setCanDownload ( false ) ; const canvas: any = document. getElementById ( &#39;canvas&#39; ) ; const ctx = canvas. getContext ( &#39;2d&#39; ) ; const { width, height } = ImgRef. current; canvas. width = width; canvas. height = height; ctx. drawImage ( ImgRef. current, 0 , 0 , width, height) ; const imageObj = ctx. getImageData ( 0 , 0 , width, height) ; const { data } = imageObj; for ( var h = 0 ; h < height; h+= gap) { for ( var w = 0 ; w < width; w+= gap) { var position = ( width * h + w) * 4 ; var r = data[ position] , g = data[ position + 1 ] , b = data[ position + 2 ] , a = data[ position + 3 ] ; for ( let y = 0 ; y < gap; y++ ) { for ( let x = 0 ; x < gap; x++ ) { const point = position + ( x + width * y) * 4 ; imageObj. data[ point] = r; imageObj. data[ point + 1 ] = g; imageObj. data[ point + 2 ] = b; imageObj. data[ point + 3 ] = a; } } } } ctx. putImageData ( imageObj, 0 , 0 , 0 , 0 , width, height) ; }
但是处理后的效果图第一列看起来有些问题,第一列的宽度并不是设置的宽度,并且颜色也有点问题。
当时想了很久才找到原因,如果是第一种方案,是在一张画布上根据左上角的坐标进行绘制一个小方块,如果方块部分区域超出了画布区域,则会隐藏,看到的效果会是最后一行和最后一列可能出现非完整小方块的现象,这属于正常的。
但是通过处理数据的方式就有所不同,当计算出的索引值大于了某一行最后一个像素的索引值时,则会自动换到下一行的起始位置去,得到的结果就是上图,第一列其实是最后一列缺失的部分。
因此这需要增加一个判断:
for ( let y = 0 ; y < gap; y++ ) { for ( let x = 0 ; x < gap; x++ ) { const point = position + ( x + width * y) * 4 ; if ( point < ( h + y + 1 ) * width * 4 ) {
分析:(h + y + 1) * width * 4
表示当前行的最后一个点的位置,如果 point
大于了这个值,则表示在画布之外。
最后来看一下处理人像效果吧!
示例代码是使用 JSX
写的,可以点击 下载源码 自行下载。
个人网站:www.dengzhanyong.com