【译文】将图片转为ASCII码字符画
在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