在Stack Overflow網站閑逛時,我通常會點擊側欄“網絡熱門問題”中的一個或兩個鏈接看看。它帶給我一些有趣的話題,不一定與平時開發有關。這次,我發現了一個有趣的帖子:how do ASCII art image conversion algorithms work?
ASCII碼藝術圖像的轉換主要包括兩個步驟:將我們的圖片轉換為灰色,並根據灰度值將每個像素映射到給定的字符。比如,@
比+
更黑,比...還黑。。。因此,讓我們嘗試在純js中實現這種算法。
對於那些着急的人,你可通過 實例 直接去測試轉換,或者直接在 github倉庫閱讀源代碼。
長傳圖片到canvas
第一個步驟是允許用戶上傳圖片。因此,我們需要一個input標簽。此外,我們要操作圖像像素時,我們還需要一個canvas標簽。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Ascii Art Converter</title>
</head>
<body>
<h1>Ascii Art Converter</h1>
<p>
<input type="file" name="picture" />
</p>
<canvas id="preview"></canvas>
</body>
</html>
在這一步,我們可以將圖片發送給input,然而什么都不會發生。當然,我們還需要將文件插入到canvas元素。我們使用 FileReader
api來完成。
const canvas = document.getElementById('preview');
const fileInput = document.querySelector('input[type="file"');
const context = canvas.getContext('2d');
fileInput.onchange = (e) => {
// just handling single file upload
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (event) => {
const image = new Image();
image.onload = () => {
canvas.width = image.width;
canvas.height = image.height;
context.drawImage(image, 0, 0);
}
image.src = event.target.result;
};
reader.readAsDataURL(file);
};
在input的change事件,我們實例化一個 FileReader 來讀取文件,一旦讀取完成,會將文件加載到canvas上。請注意,我們將畫布大小調整為圖像的大小,而不是將其截斷。drawImage 的最后兩個參數是圖片邊緣的上和左:我們要從左上角開始繪制圖像(坐標[0, 0])。
如果我們插入上邊的script到HTML中,並且上傳Homer圖片。我們可以在我們的canvas元素中顯示。
注意:如果你想從網絡攝像頭拍攝照片,請查看 Taking Picture From Webcam Using Canvas
將圖像轉換為灰色
現在圖片已經被上傳,我們需要將圖片轉為灰色。每個像素都可以被拆分為3個不同的部分:紅色,綠色,藍色,就像css中16進制的顏色值(#rrggbb)。計算像素的灰度只是將這三個值平均在一起。
但是,人類的眼睛對着三種顏色並不一樣敏感。比如,我們的眼睛對綠色非常敏感,而對藍色只有一點點的敏感。因此,我們需要使用不同的權重來考慮每一種顏色。Grayscale Wikipedia Page講的非常詳細,我們計算灰度值用下面的公式:
GrayScale = 0.21 R + 0.72 G + 0.07 B
因此我們需要遍歷我們的圖片的每一個像素,提取出其中的rgb值,然后用對應的灰度值來替換每一個部分。幸運的是,canvas允許我們操作每一個像素通過getImageData 函數。
const toGrayScale = (r, g, b) => 0.21 * r + 0.72 * g + 0.07 * b;
const convertToGrayScales = (context, width, height) => {
const imageData = context.getImageData(0, 0, width, height);
const grayScales = [];
for (let i = 0 ; i < imageData.data.length ; i += 4) {
const r = imageData.data[i];
const g = imageData.data[i + 1];
const b = imageData.data[i + 2];
const grayScale = toGrayScale(r, g, b);
imageData.data[i] = imageData.data[i + 1] = imageData.data[i + 2] = grayScale;
grayScales.push(grayScale);
}
context.putImageData(imageData, 0, 0);
return grayScales;
};
for循環需要一些解釋。我們在imageData.data對象中檢索每一個像素。然而,他是一堆數組,每一像素被分成4個部分:紅,綠,藍和透明度。我們從前3個值中檢索rgb值,計算灰度值,然后繼續移動4個索引來處理下一個像素的開始處。
在上邊的代碼片段,我們修改了圖像的原始數據,導致我們的函數不是很純。實際上,我找不到使用imageData 副本變量來更新圖片數據的方法。
在我們在 image.onload 事件監聽中調用 convertToGrayScales 函數,我們可以將上上傳的圖片顯示為灰色:
將像素映射為灰度值
現在對於每一像素我們有一組灰度值,我們可以將每個值映射為對應的字符。映射背后的原因很簡單:一些字符比其他更黑。比如,@
比.
更黑,.
在屏幕上占用的空間更少。
我們一般用下面的字符來轉換:
$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\|()1{}[]?-_+~<>i!lI;:,"^`'
因此,可以通過以下方式將灰度值映射到其等效的字符上:
const grayRamp = '$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/|()1{}[]?-_+~<>i!lI;:,"^`\'. ';
const rampLength = grayRamp.length;
const getCharacterForGrayScale = grayScale => grayRamp[Math.ceil((rampLength - 1) * grayScale / 255)];
我們使用如下的方式檢索字符:灰度值是0(黑色)應該為 $,白色像素(灰度值為255)應該用空格來替換。當數組從0開始索引我們應該將rampLength 減去 1(grayRamp的索引為[0, rampLength - 1])。
讓我們轉換我們的圖片為純字符:
const asciiImage = document.querySelector('pre#ascii');
const drawAscii = (grayScales) => {
const ascii = grayScales.reduce((asciiImage, grayScale) => {
return asciiImage + getCharacterForGrayScale(grayScale);
}, '');
asciiImage.textContent = ascii;
};
我們使用pre標簽來保持圖片的長寬比,因為它使用的是等寬字體。
在 image.onload 回調函數中調用 drawAscii 函數,我們得到下邊的結果:
猛地一看似乎無效。如果我們水平滾動,我們注意到一些字符串在屏幕上滾動。我們的圖片似乎在一行上。的確如此:我們的所有值都在一個數組里。因此,我們需要加入一個width值來斷行。
const drawAscii = (grayScales, width) => {
const ascii = grayScales.reduce((asciiImage, grayScale, index) => {
let nextChars = getCharacterForGrayScale(grayScale);
if ((index + 1) % width === 0) {
nextChars += '\n';
}
return asciiImage + nextChars;
}, '');
asciiImage.textContent = ascii;
};
結果比之前好了些,除了一些細節。。。
我們的圖片ASCII表示形式非常龐大。實際上,我們將任何單個像素映射到一個分布在許多像素的字符上。繪制一個10*10的小圖片將會占用10行10列字符。太大了。我們當然可以保持這個巨大的文本圖片通過減小字體大小來顯示之前的圖片。但是這並不是最佳選擇,如果你想通過email共享它(字太小的看不清字符)。
降低ASCII圖像比例
瀏覽web時檢查其如何實現這樣的分辨率降低,我們經常發現如下方法:
該技術在域獲取像素矩陣,並計算其平均灰度值。我們比繪制上邊紅色部分的9個白像素,而是繪制一個完全白色的像素。
我首先研究代碼,嘗試在一個數組上計算該平均值。然而,經過了一個小時的束縛,我想起了drawImage 函數的下兩個參數:輸出的寬度和高度。他們的主要作用是在繪制圖片之前先調整其大小。這正是我們需要做的!
讓我們調整圖像的尺寸:
const MAXIMUM_WIDTH = 80;
const MAXIMUM_HEIGHT = 50;
const clampDimensions = (width, height) => {
if (height > MAXIMUM_HEIGHT) {
const reducedWidth = Math.floor(width * MAXIMUM_HEIGHT / height);
return [reducedWidth, MAXIMUM_HEIGHT];
}
if (width > MAXIMUM_WIDTH) {
const reducedHeight = Math.floor(height * MAXIMUM_WIDTH / width);
return [MAXIMUM_WIDTH, reducedHeight];
}
return [width, height];
};
我們首先注意到height。實際上,更好的欣賞轉換后的圖片,我們不需要滾動來查看他們的全部。另外請注意,我們會保持圖片的長寬比,以防出現一些怪異的失真。我們需要在 image.onload 回調中使用 調整過的值:
image.onload = () => {
const [width, height] = clampDimensions(image.width, image.height);
canvas.width = width;
canvas.height = height;
context.drawImage(image, 0, 0, width, height);
const grayScales = convertToGrayScales(context, width, height);
drawAscii(grayScales, width);
};
如果我們上傳我們喜歡的辛普森角色,效果如下:
U88f
mr kzB C'
8 f @ t
^ 8@m-!l!{o%
w c#1)i!!!!!!!!B
B@1L)[!!!!!!!!!IW
@)1Y)!!!!!!!!!!!,B
@o)))[!!!!!!!!!!!!"J
"1))))!!!!!!!!!!!!!l|
@)))))!!!!!!!!!!!!!!"|
u)))))!!!!!!!!!!!!!l,@
<1)))))!!!!!!!!!!!!!!lf
Y1)))))!!!!!!!!!!!!!!!I
C))))))!!!!!!!!!!!!!!!"X
())))))i!!!!l!!!!!!l!ll"X
`1)))))?!&] }&!!)q p]?
t)))))1| pU j
a)))))0 @ f
#))))q ' ^
i))))@ a8 ! <@ l
t)1)W li ! . :
8)d1W "`t@XfC % %11x]
~*@1)@) @^;ll,|j %))[!M)LI
'&zo! ^:fx)X)* O!!!!!l~^" cc/!!J)]~x
j)!llO B*))f)Q{ 'B!!!!!!]@;x B{{i*W1]!!!q
"MUB1}!!l{ ' Z))))<!>(?!!!))){0*<@n b1{!!!<f!!c@@
j!!!Z1*d))@ q))))-!!>#WwLCm0ft??]!t*.@cw U)!!!!!ol@1))*
%1!!!!@+!!!iB 8)%)))-!@/t/}}11]???????]W-?f :1}Cl!!l,B)1!!!X
p))!!f{!!!!!+ W!i!))){&f]??????????????????Y Q)>!1!!!:1}!!!l8
@~jB)<*!!!,f!;k xvoh)))@t?????????????????]B?B %)!lZ","%)!!!@!W
L!!!!<Q|!!!ll!!q k)L))))t)?????????????????t@)* Y))!!!kBaM~!xCxIx
B!!!!!>c!!,8!!!"B IX11Y)#t??????????????????]f]8 81))!!xl!!!MI_#!u
B)!!!!!%?!@!!!!!">b ?#))%t????????????????????-0 ~h)))_!!h!!!!!i!i^Y
W)@|!!l@!!lx!!!!!!"Y( 8))af???]????????????|B{{@ M)))){!!!!!!!!!!i"@
'ff/|)xt1!!O!!!!!!!!"w! @))Wf?????????????????% -*))))?!!!!!!!!!!,"8
m11kb1))!!!!!!!!!!!!!"*; @))8t????????????????% ;@11)))!!!!!!!!!!!"xf
o1))))))!!!!!!!!!!!!!I"@ @)))t???????????????@ l@1)))){!!!!!!!!!!!"@
/m)))))]!!!!!!!!!!!!!!I,@ B)))&/???????????]]q JM1)))))>!!!!!!!!!!!"%
bq))))1!!!!!!!!!!!!!!!I,& W))))W)??????????W: ` IBY))))))-!!!!!!!!!!!!,B
@1))))!!!!!!!!!!!!!!!!I;& d. Z)))))+@}?????}@-< nJuB1)))))){!!!!!!!!!!!!!"8
xc)))){!!!!!!!!!!!!!!!!l)h]@ ())11)>ilrh&k/l!^" a!lll81)))}!!!!!!!!!!!!!!"&
B)))))-!!!!!!!!!!!!!!!!!(l*#@#X+l X<!!!!,qQmqlllllC1[!!!!!!!!!!!!!!!"@
h()))))!!!!!!!!!!!!!!!] lLilll' ..}i!!!:Il [lllll:L!!!!!!!!!!!!!!!"@
,*)))))}!!!!!!!!!!!!l% lklll W q!!!!I? ~ll" 8!!!!!!!!!!!!!"8
&))))))!!!!!!!!!!!!W l$l, p $!!lq J^ b!!!!!!!!!!:"k
>d)))))[!!!!!!!!!+ I} ' x!@; o :l!!!!!!!!"+]
@1)))))!!!!!!!!B `o | ]U B M B!!!!!!I"o.
Uc)))))<!!!!!i I|JbooB ^. o .>!!!!",a
.B)))))}!!!!a B @' @!!:"M`
bf)))))!!O. t 1 >I"1Y
^&)))))i' . _ _8
@)))1Z B 0
1Z)@l C `;
.;$lll` . Q>
@llllll ? {a
zrlllll^ * +%x
Zh;llll. fB@J
./MBW8z %
分辨率降低了,我們看不到像以前那么多的細節了,但是這是獲得可共享的ASCII藝術的強制缺點。
與往常一樣,以下是相關連接:
注意我們只是實現了靜態圖片的轉換,一些人處理了實時攝像頭視頻,比如 the ASCII camera。
原文:Converting an Image into ASCII Art Masterpiece
作者:Jonathan Petitcolas