玩具项目计划

最近觉得有必要在日常工作之余写一些娱乐为主的toy project,一来是给无聊的生活找找乐子,二来也保持一下自己的编程手感。

选择要写什么项目主要有两点原则:第一,既然是“toy”,那么就必须得好玩;第二,最好能锻炼或者学习一些之前不熟或者根本不会的新技能。

目前我已经写完了第一个项目(虽然还有一些附加功能没有加上),正在进行第二个项目。这篇博客的目的就是记录下这些项目的思路、实现过程、技术难点等。希望可以写非常长,但目前为止我立的flag好像从未实现过。

ANSI-art

这个项目的代码已经开源在Github上,使用的编程语言是我最近很喜欢的Go,地址在这里

它的主要功能是让一张这样的图片:

messi.png

变成这样:

messi_output

或者这样:

messi_ansi

或者这样:

messi_block

要知道以上这些画面都出现在你黑漆漆的命令行!

更棒的是,如果你能对图片做这样的事,很自然地你会想到,我们为什么不能对视频这么做呢?显然我犯懒了,目前尚未支持视频(也许永远不会),但我们已经有了Gif版本:

在命令行里播放动画是多么酷的一件事啊(希望你也这么觉得)。

为了让它更酷一点,我甚至通过beep添加了背景音乐功能,于是你的命令行不仅可以播放动画,还能播放音乐。

这就是这个项目的大概内容。我还有几个想加的功能,但不对加上它们的时间做任何保证,比如把生成的图片和动画保存成文件形式;支持mp4等格式;以及把这个项目放在Web上。(这些功能并没有什么难度,只是继续为这个项目增加功能带给我技术上的收益以及情绪价值已经不足了。)

接下来谈谈这个项目的实现。这个项目最有趣的部分应该是ASCII版本,因为用字符去绘制一张图片看起来真的挺酷的——它酷的地方在于,你第一眼看到是真的会想:这是怎么做到的?相比之下,ANSI版本看上去就有些显然了,提取每个像素的RGB值,给对应位置的字符通过转义字符设定颜色。当然,很多人可能不知道如何在命令行给字符设定颜色,但如果你看过这个Wikipedia主页,你就会知道做到这件事是多么简单,因此我不会详细介绍ANSI版本的实现,对它们来说也许Code is the best documentation。

不过尽管也许你第一眼无法看出ASCII版本是如何实现的,它的原理实际上也并不复杂。在这里我们依然用字符来表示每一个像素,ASCII字符中printable character是从32到126的95个字符,因为表达能力有限,我们用它们来代表不同的灰度值(共128个),所以在ASCII模式下,每张输入的图片首先要通过grayscale转化为黑白图片。接下来要做的就是定义映射规则,如何将0-127的灰度值映射到26-126这些ASCII字符上。

这个问题的答案也很简单,我们来举一个例子。在一个等宽字体中,我们对比M.,显然M.更接近黑色,这是因为在同样一个单元格内,M的黑色像素数目要比.多。于是很自然地,我们会想到,如果可以按黑色像素数量给ASCII字符排序,那么这个映射规则就很容易得到了。

这个排序可以做得简单粗暴。我们将每个printable的ASCII字符画在白底图片上,去数所有RGB值非(255,255,255)的像素数量就可以了。这部分代码在rank文件夹下。值得一提的是,虽然大多数字符在不同字体下看起来差不多,但这个排序的确是font-specific的。我terminal的字体是monaco,因此在这里我的排序也是基于monaco字体的,如果使用其他字体,最好下载其对应的tff文件重新跑一遍rank。绘制字符并计算非白像素的代码如下:

    awidth, _ := face.GlyphAdvance(text)

    iwidthf := int(float64(awidth) / 64)
    pt := freetype.Pt(125-iwidthf/2, 128)
    _, _ = c.DrawString(string(text), pt)

    size := rgba.Bounds().Size()
    blackCnt := 0
    for i := 0; i < size.X; i++ {
        for j := 0; j < size.Y; j++ {
            pixel := rgba.At(i, j)
            r, g, b, a := pixel.RGBA()
            r >>= 8
            g >>= 8
            b >>= 8
            a >>= 8
            if r != 255 || g != 255 || b != 255 {
                blackCnt++
            }
        }
    }

这样排序得到的值实际上是黑色像素的个数,为了映射,我们通过(value - min_value)/(max_value - min_value) * 128的方式把数值的range控制在[0,128]。这样,当我们拿到一个像素的灰度值后,只需要在这个有序序列里找到最接近的值就行了,这个显然可以通过二分查找做到。这部分代码如下:

func findClosestK(value int, arr []float64) (id int) {
    x := float64(value)
    low, high, mid := 0, len(arr)-1, 0
    for low < high {
        mid = low + (high-low)/2
        if arr[mid] == x {
            // this is very unlikely for floats, but we still keep this
            return mid
        }

        if x < arr[mid] {
            if mid > 0 && x > arr[mid-1] {
                d1 := math.Abs(arr[mid] - x)
                d2 := math.Abs(arr[mid-1] - x)
                if d1 < d2 {
                    return mid
                } else {
                    return mid - 1
                }
            }
            high = mid

        } else {
            if mid < len(arr)-1 && x < arr[mid+1] {
                d1 := math.Abs(arr[mid] - x)
                d2 := math.Abs(arr[mid+1] - x)
                if d1 < d2 {
                    return mid
                } else {
                    return mid + 1
                }
            }
            low = mid + 1
        }

    }
    return low
}

就这样我们完成了由灰度值到ASCII字符的映射。

当然还有其他的一些事情可以做,比如对于一些颜色上不太适合的图片可能需要调节contrast或者进行sharpen后才能获得更好的效果,我们把具体的设置放在命令行参数里由用户自行决定。

在实现过程中,其实也遇到了一些其他问题。其中一个问题在于文件路径。在排序中,我是将排序结果输出到文件;在转化为ASCII/ANSI版本的时候,通过读文件的方式来获得排序。那么这个文件路径就是一个有一点麻烦的地方:对我的main.go文件来说,自然是很容易设置这个相对路径;但如果有人要import我的包,这个路径就不对了。目前在go module开启的情况下,引用的包的下载路径是$GOPATH/pkg/mod。但这样我并不能区分是在内部的main.go调用还是以module的形式调用,因此我决定通过runtime来确定目前的具体路径,一劳永逸:

var (
    _, b, _, _ = runtime.Caller(0)

    // Root folder of this project
    Root = filepath.Join(filepath.Dir(b), "../")
)

    // the intensity/rank files are predefined
    intensity, _ := readFloatLines(Root + "/rank/intensity.txt")
    rank, _ := readIntLines(Root + "/rank/rank.txt")

这个项目的一大遗憾是,写它的时候我对tcell一无所知,是直接将结果输出到终端的。当ANSI转义字符(需要渲染)很多的时候,由于有时会很明显地在动画中观察到闪烁。我通过调慢帧率一定程度上缓解了这个问题,但并没有解决。而tcell这样的终端模拟器因为自己维护了intermediate buffer,在这方面可以自由优化(比如只重新渲染相邻帧不同的部分,当然这只是我的猜测,我并没有真的读过它的源码);而直接输出到真正的终端,显然是无法对它的buffer做任何操作的。为了弥补这个遗憾(如前所述,我已经懒得继续修改这个项目),我在下一个ASCII-Live中使用了tcell。

ASCII-Live

ASCII-Live是我花了半天时间写完的一个demo project,效果是将Webcam捕捉的视频实时以ASCII-art的形式呈现在终端:

这个项目显然是基于ANSI-art的(这也是为什么刚才我要花时间去解决路径的问题),算是它的一个小应用,灵感来源于ascii-fluid。项目的思路非常简单,前端每0.1s捕捉一帧画面传到后端,再在后端调用ANSI-art的函数,实时输出到终端。

这里简单介绍一下这个项目的技术栈:

  • 前端使用React完成,主要依赖React-webcam。开始的时候考虑过不用浏览器,直接通过Go打开webcam,但Google了一下没有找到什么很好的方案,大部分都要依赖Go版本的OpenCV。因为这实在是一个沉重的依赖,并且很有点杀鸡用牛刀的感觉,我放弃了这个想法。必须承认,React我是临时学的,确实很好上手。
  • 前后端通信我使用了WebSocket。WebSocket的特点是可以维护一个长时间的双向全双工固定连接,相对于HTTP的长轮询,WebSocket能减少很多开销。在Client端,我们只需要一行const socket = new WebSocket("ws://localhost:8080/ws");即可;而在Server端,使用Gorilla/WebSocket,也可以很容易地建立起WebSocket连接。
  • 这次画在终端上使用了tcell。

其他的部分就乏善可陈了,毕竟这个项目本身也只是ANSI-art的一个demo。但在写这个项目的过程中,有一件比较有意思的事情值得聊聊。

原本前端部分我并不打算用React。看到ascii-fluid使用的Go-WebAssembly之后,我原本打算对他的代码照(直)猫(接)画(复)虎(用),顺便了解一下wasm。而原作者自己看上去非常喜欢Go的WebAssembly方案(尽管性能堪忧),在他维护的数个repo中都使用了同一段go-WebAssembly代码来调用Webcam,而他也一直没有意识到这之间藏着的巨大隐患。

Go的wasm学起来就很不舒服,因为写起来几乎就像是在写js的go binding,没有我想象中更高抽象度的封装,因此写起来的体验也许甚至不如纯js。

而更可怕的是,我很快就发现了原作者的一个致命bug:打开webcam后大概一分钟,浏览器的内存狂飙几个G,然后网页直接crash。

采用了经典的注释Debug法后,我把问题定位到了syscall/js提供的CopyBytesToGo上。在实现里我们以几十FPS的频率将webcam的帧数据从前端传到后端,在这个过程中我们用CopyBytesToGo将js的uint8array转换为Go的slices。

和原作者讨论后,我们觉得问题出在GC上。像Go这样的自动GC语言让编程者不用亲自处理内存的申请和释放,但另一方面也让编程者失去了对内存的直接控制。就这个特定场景而言,似乎是因为我们无法控制内存释放的时间。

如果我们以每秒60帧从webcam抽取640x480的图片,那么一秒钟我们会有70多MB数据,如果这些内存不被及时释放,一分钟时间内将需要4GB以上的开销。这也和我观察的一致:运行期间我通过activity monitor看到在程序崩掉的一分钟内,浏览器内存占用从2GB暴增到6GB多。——显然这个“如果”已经成真,但由于Go是自动GC,我们甚至无法手动释放这部分内存。

刚才提到那位原作者维护了数个重用了这部分代码的repo,其中有一个甚至有3k+ star,但在我发现这个问题之前他从未意识到存在这样一个bug。尝试优化了几天之后,他意识到自己无法解决它,因此这个问题成为了Golang官方repo下的一个issue,而我转头学React去了。

自动GC语言的弊端并不罕见。去年二月,Discord宣布全面从Go语言转为Rust(如图一)。因为他们发现由于Go每两分钟的GC,他们服务的延迟和CPU的使用率每两分钟都要经历一次spike;而切换了无GC性能又好的Rust之后,问题解决了(如图二,蓝色的是Rust,紫色的是Go)。无独有偶,LinkedIn也在2016年发博客表示因为JVM的GC logging被后台IO阻塞,他们许多在线服务的线程全都被迫停止(如图三)。

351630091419_.pic_hd.jpg

331630091370_.pic.jpg

361630091474_.pic_hd.jpg

C语言的内存泄露问题由来已久。C++试图通过RAII(智能指针,STL)解决,但被迫向前兼容,积重难返;Go和Java试图通过自动垃圾回收来规避问题,但在对性能要求高的场景下往往因为额外的runtime以及难以对内存精细控制而捉襟见肘;也许最佳的方案来自Rust:把RAII的思想推广到语言层面(ownership和borrow),在编译期最大程度上杜绝原本在运行时才会暴露的内存错误。出于这个原因,我决定下一个项目使用Rust来写,尽管更新这篇博客的此时此刻,我仍然处在Rust学习的初级阶段。

This blog is under a CC BY-NC-SA 3.0 Unported License
Link to this article: https://huangweiran.club/2021/08/05/玩具项目计划/