使用 canvas 制作魔方墙
故事起因
我是一個(gè)魔方愛好者(只是愛好,但技術(shù)并不強(qiáng)),在大學(xué)期間擔(dān)任過(guò)魔方社社長(zhǎng),每到招新的時(shí)候,一般都會(huì)用上千個(gè)魔方拼出招新二維碼,顯得比較有逼格。二維碼本身也是一個(gè)一個(gè)的小格子組成,并且只有兩種顏色,把二維碼下載下來(lái),然后畫一些輔助線用魔方照著拼出來(lái)就好了。
有一年女朋友過(guò)生日,我想用魔方拼出他的照片人像,肯定比較有意義。但是有一個(gè)棘手的問(wèn)題,如何將一張圖片轉(zhuǎn)換為6種顏色的小格子呢,當(dāng)時(shí)在網(wǎng)上始終都沒有找到符合的工具,于是這個(gè)想法也就破滅了。
幾年過(guò)去了,忽然又回想起這件事,想著是不是可以用JavaScript自己做個(gè)這個(gè)功能,說(shuō)干就干。
思路
毫無(wú)疑問(wèn),肯定是使用 canvas,使用 drawImage 方法將圖片繪制到 canvas 上,然后通過(guò) getImageData 方法獲取到每個(gè)像素點(diǎn)的顏色值,修改顏色值,重新繪制圖片,最后將圖片下載下來(lái)。其中有幾個(gè)問(wèn)題:
- 圖片本身的顏色有很多,但是魔方只有6種顏色,如何將整個(gè)圖片轉(zhuǎn)為只有6中顏色的圖片。
- 一張圖片的像素點(diǎn)很多,不可能每個(gè)像素點(diǎn)都轉(zhuǎn)換為魔方的一個(gè)塊,不然不切實(shí)際。比如一張1000 * 1000 像素的圖片,應(yīng)該轉(zhuǎn)為 100 * 100個(gè)魔方格才比較符合實(shí)際,我把這個(gè)操作稱之為 “降低精度”。
正式開始
注:下面的代碼使用的是jsx語(yǔ)法
第一步:將圖片繪制到 canvas 上
... const ImgRef = useRef<any>(null); const [imgUrl, setImgUrl] = useState<string>(''); ...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); }...<canvas id='canvas'></canvas> <img src={imgUrl} ref={ImgRef}/>這里不能將 cnavas 的寬高定死,需要根據(jù)上傳的圖片大小進(jìn)行動(dòng)態(tài)設(shè)置
第二步:上傳圖片
使用 antd 的上傳組件進(jìn)行圖片上傳,將圖片轉(zhuǎn)為base64的形式進(jìn)行顯示。
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);}); } ...<UploadonChange={onFileChange} ><Button type="primary" icon={<UploadOutlined />}>上傳圖片</Button> </Upload>第三步:獲取圖片數(shù)據(jù),對(duì)數(shù)據(jù)進(jìn)行處理
const data = ctx.getImageData(0, 0, width, height).data;說(shuō)明:獲取到的數(shù)據(jù)是一個(gè)數(shù)組,每 4個(gè)數(shù)據(jù)就是一個(gè)像素點(diǎn),分別代表 紅色(r),綠色(g),紅色(b),透明度(a),如果有1000個(gè)像素,就有 4000個(gè)數(shù)據(jù)。像素?cái)?shù)據(jù)是按照?qǐng)D片的從左到右從上至下依次排列的。
問(wèn)題一:如何將不同的顏色轉(zhuǎn)換為6種目標(biāo)色?
魔方的6種顏色為:#e41e3a、#ff5800、#ffd500、#009e60、#0051ba、#ffffff
方案一:將HEX色值轉(zhuǎn)為色相,色相為一個(gè) 360 度的圓環(huán),6種顏色在色相環(huán)上對(duì)應(yīng)6個(gè)不同的角度,目標(biāo)色的色相也會(huì)對(duì)應(yīng)一個(gè)角度,計(jì)算距離哪種顏色的角度最小,就將其轉(zhuǎn)換為相應(yīng)的顏色。經(jīng)過(guò)測(cè)試這種方式轉(zhuǎn)換出來(lái)的圖片與原圖的顏色分布差距較大。
方案二:將rgb看做是三維坐標(biāo),對(duì)應(yīng)三維坐標(biāo)系中的一個(gè)點(diǎn),通過(guò)求兩個(gè)點(diǎn)之間的距離來(lái)計(jì)算相似度,距離越小,相似度越高。把目標(biāo)顏色轉(zhuǎn)換為相似度最高的顏色。
// 求兩個(gè)顏色的相似度 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)) }問(wèn)題二:如何”降低精度“?
假如上面這張圖片,我們要轉(zhuǎn)換成10 * 7個(gè)小格子,每個(gè)格子只能填充一種顏色,我們只需要取每個(gè)小格子中的其中一個(gè)像素點(diǎn)的顏色即可,可以取左上角第一個(gè),也可以取中間的,沒有特殊的要求。當(dāng)然每個(gè)格子的取值點(diǎn)最好一致。
經(jīng)過(guò)處理后處理后就可以得到下面這張圖。
這貌似什么都看不出來(lái),這是因?yàn)椤敖档途取边^(guò)渡,我們可以嘗試調(diào)整參數(shù)值,將5*5個(gè)像素轉(zhuǎn)為一個(gè)方塊。
是不是已經(jīng)可以看到輪廓樣子了,畢竟只有6種顏色,所以對(duì)于細(xì)節(jié)較多的圖片在效果圖中無(wú)法體現(xiàn)出來(lái)。我們換一張單調(diào)點(diǎn)的圖片看看。
第四步:重新效果圖
? 在上面我們得到了原始圖片的數(shù)據(jù) data,對(duì)數(shù)據(jù)處理后需要重新繪制效果圖。這里只是一些邏輯上的計(jì)算。
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(',');const similarity = getSimilarity({r, g, b}, {r: Number(r2), g: Number(g2), b: Number(b2)});if (similarity < maxSimilarity) {maxSimilarity = similaritysimilarityColor = item;}})return similarityColor.color; } - 首先我們來(lái)定義一個(gè)常量 `gap` ,表示方塊的寬高 - 兩層嵌套循環(huán),`position` 表示獲取的像素點(diǎn)在數(shù)組中的位置:`width * h` 表示行數(shù);`+ w` 表示某行的第幾個(gè)像素; `* 4` 是因?yàn)橐粋€(gè)像素點(diǎn)在數(shù)組中需要占4個(gè)位置;`* gap` 是獲取第n個(gè)小方塊的左上角的那個(gè)像素點(diǎn)位置 - position,position+1,position+2,position+3分別對(duì)應(yīng)了一個(gè)像素點(diǎn)的 rgba 信息 - MosaicImage 方法為轉(zhuǎn)換后的目標(biāo)顏色 - 使用 `fillStyle` 設(shè)置繪制顏色,使用 `fillRect` 方法繪制小方塊擴(kuò)展功能
通過(guò)對(duì)圖片每個(gè)像素點(diǎn)的操作,可以做出很多有意思的東西,比如說(shuō)圖片馬賽克、顏色反轉(zhuǎn)、簡(jiǎn)單的摳圖等功能。
圖片馬賽克
與上面制作魔方圖的原理相同,去掉顏色轉(zhuǎn)換的步驟,可以直接取每個(gè)小方塊的左上角或中間的像素顏色作為小方塊的顏色。
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);} }顏色反轉(zhuǎn)
將 rgb 的各自的值都用 255 減一下
function ReversalColor(r: number, g: number, b: number): string {return `rgb(${255-r},${255-g},${255-b})`; }摳圖
這里只能做一些簡(jiǎn)單的摳圖,如果要實(shí)現(xiàn)一些復(fù)雜的摳圖,需要配合很好的算法。
可以設(shè)置一些目標(biāo)顏色,將匹配的與目標(biāo)色相同的像素點(diǎn)的透明度設(shè)置為 0 即可。主要要值得注意的是,不能使用上面重新繪制的方式,重新繪制是在原來(lái)的圖片上面覆蓋一層,得到的結(jié)果并不是透明的png圖片。這里需要使用修改原數(shù)據(jù)的方式實(shí)現(xiàn),后面會(huì)講到。
換顏色
將指定顏色換為目標(biāo)色,可用于更換頭像背景色。
下載圖片
將canvas內(nèi)容轉(zhuǎn)為圖片鏈接,然后進(jìn)行下載。當(dāng)然也可以鼠標(biāo)右鍵直接下載。
function downloadImage() { const canvas: any = document.getElementById('canvas'); const imgUrl = canvas.toDataURL("image/png"); console.log(imgUrl); const a = document.createElement('a'); a.download = '圖片.jpg'; a.href = imgUrl; a.setAttribute('download', 'chart-download'); a.click();}優(yōu)化
為了更加方便的處理,我把這幾個(gè)功能做成了一個(gè)小項(xiàng)目,可以點(diǎn)擊這里進(jìn)行體驗(yàn)。
現(xiàn)在可以很方便的切換不同的模式,并且可以設(shè)置像素大小,目標(biāo)色也可以自定義(目前還沒有做,近期會(huì)加上去)。
當(dāng)我把像素大小設(shè)置為1時(shí),相當(dāng)于對(duì)每個(gè)像素點(diǎn)都需要進(jìn)行處理,有10000個(gè)像素的話就需要畫10000個(gè)小方塊,導(dǎo)致頁(yè)面出現(xiàn)卡頓現(xiàn)象。
優(yōu)化一下之前方案,之前是采用重新繪制的方式,其實(shí)我們也可以修改原數(shù)據(jù)的方式。通過(guò) getImageData 方法可以得到你一個(gè) ImageData 對(duì)象。
其中 data 是一個(gè) Uint8ClampedArray (8位無(wú)符號(hào)整型固定數(shù)組) 類型化數(shù)組表示一個(gè)由值固定在0-255區(qū)間的8位無(wú)符號(hào)整型組成的數(shù)組;如果你指定一個(gè)在 [0,255] 區(qū)間外的值,它將被替換為0或255;如果你指定一個(gè)非整數(shù),那么它將被設(shè)置為最接近它的整數(shù)。
通過(guò)處理數(shù)據(jù)的方式比重繪的方式要復(fù)雜一些,涉及到數(shù)據(jù)的計(jì)算,比如我們現(xiàn)在要將下面這個(gè)小方塊的區(qū)域全部設(shè)置為一種顏色:
首先我們知道方塊左上角第一個(gè)像素的起始索引值 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 為目標(biāo)像素點(diǎn)的索引值,這里要注意一點(diǎn),只能通過(guò)設(shè)置每一位方式去設(shè)置值,不能使用數(shù)組的 splice 方法批量處理。Uint8ClampedArray 上不存在這個(gè)方法。處理數(shù)據(jù)后,使用 putImageData 方法繪制圖片,完整代碼如下:
function handleImageData() { setCanDownload(false); 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); 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);}但是處理后的效果圖第一列看起來(lái)有些問(wèn)題,第一列的寬度并不是設(shè)置的寬度,并且顏色也有點(diǎn)問(wèn)題。
當(dāng)時(shí)想了很久才找到原因,如果是第一種方案,是在一張畫布上根據(jù)左上角的坐標(biāo)進(jìn)行繪制一個(gè)小方塊,如果方塊部分區(qū)域超出了畫布區(qū)域,則會(huì)隱藏,看到的效果會(huì)是最后一行和最后一列可能出現(xiàn)非完整小方塊的現(xiàn)象,這屬于正常的。
但是通過(guò)處理數(shù)據(jù)的方式就有所不同,當(dāng)計(jì)算出的索引值大于了某一行最后一個(gè)像素的索引值時(shí),則會(huì)自動(dòng)換到下一行的起始位置去,得到的結(jié)果就是上圖,第一列其實(shí)是最后一列缺失的部分。
因此這需要增加一個(gè)判斷:
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) { // 增加判斷 imageObj.data[point] = r; imageObj.data[point + 1] = g; imageObj.data[point + 2] = b; imageObj.data[point + 3] = a; } }}分析:(h + y + 1) * width * 4 表示當(dāng)前行的最后一個(gè)點(diǎn)的位置,如果 point 大于了這個(gè)值,則表示在畫布之外。
最后來(lái)看一下處理人像效果吧!
示例代碼是使用 JSX 寫的,可以點(diǎn)擊 下載源碼 自行下載。
個(gè)人網(wǎng)站:www.dengzhanyong.com
個(gè)人網(wǎng)站及公眾號(hào)一般會(huì)提前兩天發(fā)布新內(nèi)容
總結(jié)
以上是生活随笔為你收集整理的使用 canvas 制作魔方墙的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 【电子书制作软件哪个好】云展网教程 |
- 下一篇: 现在需要在input框输入年月yyyym