使用Common-Lisp写一个png文本转换器

面码

注意 本文为非干货版本。其中有我从开始到最后的几乎全部开发时的思想,和走的一些弯路。如果你和我一样不愿意看这些,请直接看源码。

前言

要提到最初的想法,很疯狂。这要从我使用w3m在命令行里看网页开始。那时我十分喜爱没有css样式的网页。(ps:前端听到会气死的)渐渐我喜欢上了命令行的简单编辑方式。于是我使用emacs。也使用emacs上的eww进行网页浏览。那是本着使用emacs做一切的精神,于是思考:可以用emacs看视频吗?这个估计是肯定可以的。但是我不喜欢那种直接使用外部播放器的形式。我想要的是即使使用命令行也可以观看视频的网络服务。
哈哈。估计只有疯子想在如今这个时代做这样的事吧。现在网速也可以了,没有必要把图片压缩成这样的格式,在进行传输。但是仅仅是一个想法,仅仅是好玩。那是我使用的工具是java和c,这两语言当时对我来说一个有太多的细节要去讨论(java)另外一个太底层要做太多的工作。更恐怖的是,我当时对多媒体编码一窍不通,这里不是说我现在就通了,而是我知道了他的可怕,那时纯是出身牛犊不怕虎。
现在我学习Common lisp想着正好做点什么练练手。视频太复杂了。试试把一张图片转化成文本格式吧。

我的环境和工具

  • MacBook Pro
  • emacs 使用的是spacemacs的配置 添加了common lisp的layer
  • 使用SBLC作为lisp解释器的实现
  • 使用quicklisp 作为system构建工具

关于quicklisp使用的介绍我就不写了 别人写的很好的 传送门 献上。这个很有用,如果你学习这个教程一定要看。

开始顶层设计

首先我们知道一张图片是一个数字的概念,至少在计算机里。有许多的点(像素),每个点上有一个颜色,合起来就是一张图片。那么我操作这些点的值或者根据这些值进行一定的计算就可以得出我们想要的图了。

一些细节

哈哈。听上去很容易。但是有很多的细节要解决。首先图片有很多的格式,比如png,gif等。我怎么从这些文件中获得到对应的点阵(这里我叫他bitmap)呢?这个是其一。其二我怎么解决使用的文件的格式问题,这么多文件格式我只提供一个入口?
第二个问题好解决。文件格式很多我肯定不能一次性写完,虽然他们从bitmap到文本的算法是相同的。于是我觉得先写一个png的转换器在一步一步的做。然后又时间了把其他类型补上。这个问题解决的了。现在变成了怎么从png导出到bitmap的形式。
于是我去看了png格式的文档。这尼玛,官网文档是英文的啃下来我这肯定是要疯的。于是找了一些博客,发现上面写png格式的数据段格式不是按照顺序存的,而且还使用了我没有听说过的压缩算法。这不是要坑我吗~而且使用了CRC校验,这我是大学的时候学过,但是最讨厌了。怎么办?

这个有关于png压缩算法的 传送门 。有兴趣的朋友还是应该看看,特别是学编码的同学。

探索过程

有一句话说的好。你能想到的轮子别人都做过了。虽然这句话有毒,要是这么想就什么也不用写了。但是也有一定的道理。lisp风行怎么多年,不可能连一个解析png的小模块都没有。但是怎么找呢?百度?谷歌?答案是都不太好。肯定是我的搜索词有问题。(关于搜索我将单独写一个文章)百度就不说了没啥人查lisp的东西,文章也少。谷歌,相关度或许要高一些。但是呢~ 英文实在不好啃。于是我使用了github自带的搜索。于是一下子出来了好几个。有zpng,png-read。有的写了不错的文档,虽然是因为的但是还是要啃的。文档不好怎么办,读源码吧。于是在png-read中发现了他可以读取一个PNG文件返回一个CRC校验结果,并且返回一个png-state的对象。再打开这个文件看到这个对象中有一个槽(lisp中的对象属性)名字叫image-data。这个或许就是了。我靠,突然发现命名是如此的重要。
现在又有一个问题了,我怎么使用这个包里面的函数呢?我怎么构建自己的项目呢?毕竟我是连那个包管理都弄得很蒙圈的人。而且要处理依赖什么的。想想在php中人们使用composer在java中使用meavn,那么common lisp就没有什么吗?于是我找到了quicklisp。上面的传送门里有介绍哦。(如果你想读懂我下面的内容就要看)

开始编程吧

现在我们需要的东西都有了,可以开始了吧。先写一个最简单的,读取一个png文件到一个全局变量里面。

1
2
3
4
(defparameter *png-object* nil)
(defun set-png-object (file)
(setf *png-object* (png-read:read-png-file file)))

这个函数再简单不过了。现在我想这样先获取一个图片的宽和高以便于在后面可以方便的建立一个对应的bitmap进行接下来的运算。
还有我写的其他很多函数都要判断这个*png-boject*这个值存不存在以免出现不安全的调用。

1
2
3
4
5
6
7
8
(defun check-object-and-do-funcation (fn)
(if (eql nil *png-object*)
(format t "we never hava png-object.please use funcation (set-png-object *file*) to set frist")
(funcall fn)))
(defun get-size ()
(let ((fn #'(lambda () (list (png-read:width *png-object*) (png-read:height *png-object*)))))
(check-object-and-do-funcation fn)))

好了。回到一个重要的问题上面。我们怎么转换的算法问题了。

一点算法的讨论

说道这个算法,我就想到了我在大学的时候学的一门叫做《数字图像处理》的课程。想当初我因为经常逃课不去,还被取消了考试资格,无奈来年重修了。
说道这个算法我首先想到了就是简化。首先说道我们使用的颜色表示系统。RGB颜色。这个很简单用过PS的都知道,比如我们熟悉的66CCFF是洛天依的颜色。表示的就是这个系统—红绿蓝。值都是从0到255。也就是说这里每个点存的是一个向量。现在由于我使用文字符号作为颜色的表示于是就不可以考虑图片的颜色差别,只能估计到颜色的深浅(其实可以,一会有空我在讨论),于是乎我就要把原来的3维的向量映射到一个灰度表上。这里将有许多种思路。其中先说本程序使用的,也是最简单的方法。

RGB到灰度的算法

算数平局值法

这个方法估计一下子就可以想到了。就是算三个颜色的平局值然后在根据这个平均值进行映射。想一下原来时0-255每个,取完值还是255个。这个方法真的不算是最好,不过是最简明的。但是是我思考了其他方法最后选择的。因为这个方法的缺点很明显,比如两完全不同的颜色(0 255 0)和(255 0 0)。在这种算法下本来是一个正常的边界,现在灰度会变成一样,就糊到一起了。这个当然不是我希望看到的。

注意 下面的内容和专业知识有关,但是写的又很不专业,如果你不感兴趣我不建议你看,也许会对你造成迷惑。(我将用“选”字标记)

向量距离法(名字自取 不知道有没有这种算法)

这个算法没有结果测试,完全是我自己脑补的。首先一般的像素点周围有8个向量。正好是9宫格中,中心点周围的8个。那么他与每个向量有一个距离。(即向量间距离,这个高中就有学计算起来不是很难)这个距离可能是正的(+)也可能是负的(-)。这里的正和负正好反应了他和另外一个像素比是“更深了”还是“更浅了”。而数值的大小表示的就是灰度的差别。那么我们可以制作一个这样的一个九宫格的模板。中间正好是四周向量间距离的和。让这个模板在整个图片中走一遍就可以得到一个新的图像了。这里还可以进行一些小改进比如在模板中加入权值的方法。这个方法的好处是图片的边缘的部分被锐化了。(在强调一次,我没有试验过,我是理论上这样认为,如果有时间我将试验一次)

从灰度图映射到字符

这里我们得到了一个bitmap了,就是我说的灰度图。(这里是思想试验得到的,不是真的。从我读取数据到我得到灰度图,还是需要一些时间的)那么我们要做的就是写一个方法,将灰度对应到一个字符表上。这里有一个很大的问题,怎么定义一个字符表呢?这个问题我没有很好的答案。至少应该是根据字符位图中占的黑块数目的多少来确定的。那么这个怎么确定呢?对不起这里我没有很好的找到代码的实现,也没有自己的实现。我甚至都不知道在搜索引擎中使用那个关键字进行搜索,在qq问别人也很难描述。这个问题太奇葩了。当然这个还不是最奇葩的。可以看看下面写的“更加有趣的想法”。于是我根据自己经验定义了一个灰度0到19也就是二十个字符的对照表。这个对照表写的很有问题你可以修改或扩充它,或者使用其他算法生成它。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
(defun get-string (i)
(case i
(19 ".")
(18 ",")
(17 "_")
(16 "-")
(15 "~")
(14 ":")
(13 "!")
(12 "+")
(11 "=")
(10 "a")
(9 "0")
(8 "b")
(7 "%")
(6 "V")
(5 "H")
(4 "E")
(3 "&")
(2 "#")
(1 "M")
(0 "@")
(otherwise "@")))

好了我们现在要讨论的成了从算出来的0-255的灰度值到这20个符号的映射问题了。

简单分割法

你可能会想这个有什么好分的,不是就是255除以一个数得19余个几然后分个20个区吗!然后每个区对应一个字符。ok~我就是这样的方法。没有任何问题。这个方法简明好理解。或者如果出现最后一个区太大,可以取巧一点各个区匀一点。下面是实现的代码。

1
2
3
4
5
6
(defun get-string-bitmap (bitmap)
(let ((string-bitmap (make-array (get-size) :initial-element ".")))
(dotimes (i (png-read:width *png-object*))
(dotimes (j (png-read:height *png-object*))
(setf (aref string-bitmap i j) (get-string (aref bitmap i j)))))
string-bitmap))

但是这个并不是很好的方法。为什么呢?想一下如果一个图得出的灰度集中在某一个范围内,怎么办呢?比如都出现在20-99之间。结果这里就只有对应3个符号,本来有起伏的一张图片就变的一摸黑了。这个当然不是我们希望的。

直方图法

实际上这个也思想并不复杂。我就是想让图片中所有的像素的灰度分布的广义点。比如说原来有一个图片只有3个字符对应的(使用简单分割),现在会出这个图片灰度分布的直方图,然后重新定义灰度到符号的映射关系。让密的地方多分几个符号,让稀疏的地方少分几个符号。这个是可行的。这样将会时原来的图像层次丰富起来。这个思路完全是照搬《数字图像处理》这本书中的思想,如果有这本书的同学不妨可以拿来看看。这里实现过程和算法细节我就不说了。

更加有趣的想法

现在让我们忘了使用“深浅”定义灰度到字符的映射关系。忘掉之前所有说过的算法。先想一个问题:一个像素点对应一个字符合理吗?想想一个符号占的大小,绝对比一个像素点还要大吧。那么比如一个符号的宽和高占的大小是10个像素。当然具体多少我也不知道。那么一张原来是200X200的小图片就变成了2000X200的大图片,这个更不用说是1080X1920的大图了。在电脑屏幕下根本显示不过来。于是最优的方法是,根据一个字符占的空间的大小来匹配图片中对应大小的空间。这里就有一个相似度的问题。这样在我看来是最优的解,根本不需要定义什么映射表就可以达到。但是我对于字符所占像素大小和获取上面的像素点信息的方法没有头绪。而且使用预定义列表表示字符的方法很反感。这个有趣的想法只能日后来解决了。

实验结果

我从网络上找到了一张“面码的图片”一下是处理生成的文件:
面码

不足

  1. 由于开始把宽和高搞错了导致图片是旋转了90度的(已经修复)
  2. 由于对图片像素的读取顺序的未知,图片是镜子中的。(已经修复)
  3. 图片总是很大不方便看

git源码传送门 传送门

写在最后

使用lisp开发时一段开心的旅程。但是一些有用的lisp特性并没有使用成,可能是我使用的不熟,有可以是没有使用的必要。不过这个在3天的时间完成了,这个速度是我自己都没有想到的。我总结了一下是我对于搜索工具的使用上更加的高效,也归功于CL这门语言的独特魅力。这次写完其实很激动想总结一下方法论的东西但是写到这里也就罢了。

我将一直的迷惑与无知,我是黄油香蕉君,再见。

给作者买杯咖啡吧。喵~