ASCII艺术化的一个实现

最早的ASCII艺术起源于1966年,主要运用于无图形化环境下显示图片,因为通常这种情况下ASCII码是可以毫无障碍地显示。目前有相当多的网站提供在线的图片转换,只需要简单地在搜索引擎搜索“ASCII art”就可以了。

下面是本人写的一个小型的代码片段基于Python的用于图片的ascii艺术化,因为根据PEP484 加入了Type Hints,所以最小运行环境需要Python 3.5

UPDATE:目前已使用新的算法,文章在此

接下来介绍一下实现这么一个东西的基本思路

确定字符串的长度与宽度

如果只是输出文本,那么这一步其实只要知道字符串长度与宽度的比值就可以了。因为即使字号与字符像素尺寸是非线性的,但字号对字符像素尺寸长与宽的影响是等同的。因此输出的文本在不同的字号下的显示效果是一致的。这里唯一需要额外考虑的是多行字符串在显示时的行距,但是不同的文本编辑器倾向于不同的行距,这个问题考虑了也没什么用。

另一方面如果希望生成字符串图像,那么必须知道多行字符串长度与行数与其的尺寸变化关系。字符串的尺寸并不是单个字符尺寸的简单相加,即并非正比例关系,而是一个一次关系。

图像像素与字符的对应关系

通常情况下这种映射关系用的是字符的平均灰度与所替换区块内像素点的平均灰度进行映射。根据平均灰度选择具有相似灰度的字符。因为很显然不能指望字符去表达色彩(总不能用“R”去表示红色像素、“G”去表示绿色像素吧),那就只能去表达明度了。实际操作中有三个细节问题:

如何取平均灰度

直觉上会用crop方法(假设用的是Pillow的Image模块)取得一个区块,然后用getdata方法得到这个区块内所有像素点,最后计算算数平均值。那么与这一系列操作等价的方法就是resize方法,并且resize方法还能选用更多更优秀的resample方法(重采样)以避免简单的算术平均值可能造成的图像失真。因而实际代码中选用的就是resize方法确定某一区块的灰度

如何取字符的平均灰度

单纯取字符的平均灰度是很简单的,用Pillow的ImageDraw模块画出字符,然后再用上述方法计算一下灰度就行了。但令人遗憾的是这样算出来的灰度范围非常窄,通常只有五十多级的灰度,就这么点灰度是不能表现正常图片256级的灰度的,因此需要插值。使用最邻近插值就能将五十多级的灰度扩展到256级,但问题仍然没有解决,因为这么处理,就会有相当一大段的灰度用的是同一字符表达,实际生成的图形相当得ugly。因此需要用其他方法插值,在本人的代码中,用到的是 linear normalization后再做最邻近插值,当然要是愿意的话也可以用非线性的normalization,但在这里我们主要考虑的是字符分布密度不变。当然可能有人会考虑用直方图均衡化来扩展灰度范围,但是字符数量相对较少(与256比较)的情况下,效果有限。

如何更好地取得平均灰度

对于某些图片,但它被转化为灰度图像的时候会有某些灰度级别被大量使用,而有些灰度级别很少甚至没有使用。为了解决这个问题,实际代码中用到了直方图均衡化方法。当然这只是锦上添花,只能一定程度上提升效果,虽然这个效果提升地也非常有限。

性能如何

关于性能,通过cProfile可以观察到大量的时间用在了getsize与text这两个函数上,且不说text函数正是我们所需要的,耗时长一点可以接受,但getsize似乎需要对每一行字符执行一次,而在这个应用中getsize的返回值应当是不变的,那么这个时间就被无谓地浪费了。研读PIL的text函数之后,getsize的时间消耗无法避免,因此直接渲染的方案,性能不佳。因此,该方案可以用于验证目的,或者少量图片的转换工作。

如何提升性能

在本应用中不变的是用于填充的字符,不论何种待转换图像,需要渲染的字符种类总是不变的,因此我们可以考虑将每个字符预先渲染出来,然后拼接数据块。因此一个直觉上的做法就是PIL的crop和paste函数:先将字符渲染出来,然后crop成单个字符的图片,然后依平均灰度paste到目标位置即可。

不过,为了方便,在实际代码中采用的是numpy的reshape和concatenate函数:先用List Comprehension或者直接numpy array的index(这一步并不是决速步,cProfile里按total time都没有排上前20)生成字符图形序列,然后reshape序列到(行,列,字符高,字符宽),最后用concatenate沿着axis=1拼两次就行了。

整个过程如下图:

整个操作过程简单,不涉及循环(如果一定要把List Comprehension当循环,那就…)和复杂的paste定位问题。当然坏处是引入了numpy依赖(毕竟终极目标是依赖越少越好)。cProfile表明主要的耗时步骤在concatenate函数,推测是在这一步涉及了大量数据拷贝(从单一字符图形数据拷贝到一行字符的图形数据再拷贝到整个图形数据)。

留下评论