序言

在旧文《如何写一个命令行的秒表》中,借助命令tput,我实现了“原地更新”所输出的时分秒的效果

其中用到的是ASCII转义序列\x1b[8D\x1b[0K。除此之外,ASCII转义序列还有许多其它功能。例如,可以用来定制输出内容的前景色

将转义序列中的参数38改为48,可以定制输出内容的背景色

将打印内容改为两个空格,看起来就像是在一块黑色的画布上涂了一个红色的方块

既然如此,只要尺寸合适,就可以在终端打印出一张图片,只需要将每一个像素的颜色作为背景色,在坐标对应的行列上输出两个空格即可。如果能抹掉输出的内容并在同样的位置上打印一张不同的图片,甚至可以实现动画的效果。

百闻不如一见,下面我用Python演示一番。

把GIF装进终端

要想用前文的思路在终端中显示一张GIF图片,必须先得到GIF图片每一帧的每个像素的颜色才行。在Python中使用名为Pillow的库可以轻松地解析GIF文件,先安装这个库

➜  /tmp rmdir show_gif
➜  /tmp mkdir show_gif
➜  /tmp cd show_gif
➜  show_gif python3 -m venv ./venv
➜  show_gif . ./venv/bin/activate
(venv) ➜  show_gif pip install Pillow
Collecting Pillow
  Using cached Pillow-8.1.0-cp39-cp39-macosx_10_10_x86_64.whl (2.2 MB)
Installing collected packages: Pillow
Successfully installed Pillow-8.1.0
WARNING: You are using pip version 20.2.3; however, version 21.0.1 is available.
You should consider upgrading via the '/private/tmp/show_gif/venv/bin/python3 -m pip install --upgrade pip' command.

接着便可以让它读入并解析一张GIF图片

import sys

from PIL import Image, ImageSequence

if __name__ == '__main__':
    path = sys.argv[1]
    im = Image.open(path)
    for frame in ImageSequence.Iterator(im):
        pass

然后将每一帧都转换为RGB模式再遍历其每一个像素

import sys

from PIL import Image, ImageSequence

if __name__ == '__main__':
    path = sys.argv[1]
    im = Image.open(path)
    for frame in ImageSequence.Iterator(im):
        rgb_frame = frame.convert('RGB')
        pixels = rgb_frame.load()
        for y in range(0, rgb_frame.height):
            for x in range(0, rgb_frame.width):
                pass

调用Image类的实例方法load得到的是一个PixelAccess类的实例,它可以像二维数组一般用坐标获取每一个像素的颜色值,颜色值则是一个长度为3的tuple类型的值,其中依次是像素的三原色的分量。

ANSI escape code词条的24-bit小节中得知,使用参数为48;2;的转义序列,再接上以分号分隔的三原色分量即可设置24位的背景色

import sys

from PIL import Image, ImageSequence

if __name__ == '__main__':
    path = sys.argv[1]
    im = Image.open(path)
    for frame in ImageSequence.Iterator(im):
        rgb_frame = frame.convert('RGB')
        pixels = rgb_frame.load()
        for y in range(0, rgb_frame.height):
            for x in range(0, rgb_frame.width):
                colors = pixels[x, y]
                print('\x1b[48;2;{};{};{}m  \x1b[0m'.format(*colors), end='')
            print('')

在每次二重循环遍历了所有像素后,还必须清除输出的内容,并将光标重置到左上角才能再次打印,这可以用ASCII转义序列来实现。查阅VT100 User Guide可以知道,用ED命令可以擦除显示的字符,对应的转义序列为\x1b[2J;用CUP命令可以移动光标的位置到左上角,对应的转义序列为\x1b[0;0H。在每次开始打印一帧图像前输出这两个转义序列即可

import sys

from PIL import Image, ImageSequence

if __name__ == '__main__':
    path = sys.argv[1]
    im = Image.open(path)
    for frame in ImageSequence.Iterator(im):
        rgb_frame = frame.convert('RGB')
        pixels = rgb_frame.load()
        print('\x1b[2J\x1b[0;0H', end='')
        for y in range(0, rgb_frame.height):
            for x in range(0, rgb_frame.width):
                colors = pixels[x, y]
                print('\x1b[48;2;{};{};{}m  \x1b[0m'.format(*colors), end='')
            print('')

最后,只需要在每次打印完一帧后,按GIF文件的要求睡眠一段时间即可。每一帧的展示时长可以从info属性的键duration中得到,单位是毫秒

import sys
import time

from PIL import Image, ImageSequence

if __name__ == '__main__':
    path = sys.argv[1]
    im = Image.open(path)
    for frame in ImageSequence.Iterator(im):
        rgb_frame = frame.convert('RGB')
        pixels = rgb_frame.load()
        print('\x1b[2J\x1b[0;0H', end='')
        for y in range(0, rgb_frame.height):
            for x in range(0, rgb_frame.width):
                colors = pixels[x, y]
                print('\x1b[48;2;{};{};{}m  \x1b[0m'.format(*colors), end='')
            print('')
        time.sleep(rgb_frame.info['duration'] / 1000)

现在可以看看效果了。我准备了一张测试用的GIF图片,宽度和高度均为47像素,共34帧

让它在终端中显示出来吧

一点微小的改进

你可能留意到了,前文的演示效果中有明显的闪烁,这是因为打印ASCII转义序列的速度不够快导致的。既然如此,可以将一整行的转义序列先生成出来,再一次性输出到终端。改动不复杂

import sys
import time

from PIL import Image, ImageSequence

if __name__ == '__main__':
    path = sys.argv[1]
    im = Image.open(path)
    for frame in ImageSequence.Iterator(im):
        rgb_frame = frame.convert('RGB')
        pixels = rgb_frame.load()
        print('\x1b[2J\x1b[0;0H', end='')
        for y in range(0, rgb_frame.height):
            last_colors = None
            line = ''
            for x in range(0, rgb_frame.width):
                colors = pixels[x, y]
                if colors != last_colors:
                    line += '\x1b[0m\x1b[48;2;{};{};{}m  '.format(*colors)
                else:
                    line += '  '
                last_colors = colors
            print('{}\x1b[0m'.format(line))
        time.sleep(rgb_frame.info['duration'] / 1000)

但效果却很显著

全文完

阅读原文


用户bPGfS
169 声望3.7k 粉丝