字符画工具并不新鲜,但把它做成一个可调参数、支持真彩色、能跨终端运行的命令行程序,涉及的问题比想象中多:终端字符不是正方形、灰度映射需要符合人眼感知、ANSI 转义序列要正确拼接、Windows 下还存在编码兼容问题。本文围绕一个名为 cua 的 Python 小工具,拆解它从图片读取到终端输出的完整代码逻辑,并给出常见故障的排查方向。
一、cua 的定位与设计取舍
cua 全称 Character Unicode Art,目标很直接:输入一张图片,输出一段在终端里看起来像原图的文本。它提供灰度模式和彩色模式,支持通过参数调整输出宽度、反转灰度映射、修正终端字符宽高比。
技术路线选择 Python + Pillow,原因有两个:Pillow 对图片打开、缩放、灰度转换、像素遍历的支持成熟,几十行代码就能完成核心流程;Python 脚本跨平台,Windows、macOS、Linux 终端都能运行。相比纯前端 Canvas 方案,它更符合命令行工具定位;相比 C/C++ 或 Rust,开发调试成本更低。整个核心逻辑约一百行,适合作为图像处理和命令行开发的练手项目。
设计上有两个关键决策。第一,字符集不只使用传统 ASCII,还加入 Unicode 块状字符,例如 █、▓、▒、░,让灰度层次更丰富。第二,彩色模式不丢弃灰度信息:用像素亮度决定字符密度,用像素 RGB 决定字符颜色。这样做的好处是,即使终端降级到 256 色或 16 色,字符的明暗轮廓仍然保留,不会变成一团模糊色块。
二、核心实现原理
1. 图片缩放与终端宽高比校正
图片不能直接逐像素输出,必须先缩放到终端可展示的尺寸。宽度由 -w 参数控制,默认 120 列。高度不能简单按图片比例计算,因为终端字符的显示区域通常是竖长的,常见字体下字符宽度约为高度的 0.4 到 0.6 倍。如果直接把 1920x1080 的图缩到 120 列宽,高度会变成 67.5 行,但每个字符实际占位偏高,画面会被纵向拉伸。
cua 的做法是在计算目标高度时乘一个补偿系数,默认 0.5:
target_height = int(target_width * height / width * 0.5)
这个系数是经验值,取决于终端字体和行距。如果输出被压扁,把 -r 调大,例如 0.55;如果输出显得瘦高,把 -r 调小,例如 0.45。缩放算法默认使用 LANCZOS,原文认为它在图片尺寸远大于目标尺寸时效果更好,计算量略高于 BICUBIC。
2. 灰度转换公式
把彩色像素转成灰度,不能简单使用 (R+G+B)/3。人眼对绿色更敏感,对蓝色较不敏感,工业界常用 ITU-R BT.601 权重:
Gray = 0.299 * R + 0.587 * G + 0.114 * B
Pillow 的 convert("L") 内部使用的就是这类权重,因此代码里可以直接调用库函数,也可以按上述公式手写 rgb_to_gray。
3. 灰度到字符的密度映射
拿到 0 到 255 的灰度值后,需要把它映射到字符密度序列。代码中定义 CHAR_DENSITY 字符串,gray_to_char 用灰度值计算索引:
max_index = len(CHAR_DENSITY) - 1
index = int(gray / 255 * max_index)
设计意图是灰度 0 对应高密度字符,灰度 255 对应空格,从而让暗部更密、亮部更疏。实际视觉方向取决于 CHAR_DENSITY 的排列;如果发现明暗反了,可以使用 -i 参数翻转索引,代码逻辑是 index = max_index - index。
4. ANSI 真彩色输出
彩色模式依赖 ANSI 转义序列设置前景色。现代终端支持 24 位真彩色,格式为:
\x1b[38;2;R;G;Bm内容\x1b[0m
其中 \x1b 是 ESC 字符,38;2 表示按 RGB 设置前景色,后面依次是红、绿、蓝通道值,m 结束控制序列。每个字符输出后要用 \x1b[0m 重置样式,否则后续文本会继续带颜色。
cua 的策略是先用亮度选字符,再用原始 RGB 上色。这样颜色和轮廓都能保留。需要注意透明像素:如果图片是 RGBA 模式,且某个像素的 alpha 小于 128,代码会把它的 RGB 统一改成白色,避免透明 PNG 的圆角区域输出成黑块。如果希望透明区域显示为纯黑,可以把代码中的 255 改成 0。
三、代码实现拆解
运行环境要求 Python 3.7 以上,安装 Pillow:
pip install pillow
项目可以保持单文件结构,例如 cua/cua.py、cua/demo.jpg、cua/README.md。核心函数分为缩放、灰度计算、字符映射、图像转字符画和命令行入口。
缩放与高度校正函数:
- def resize_image(img, target_width, aspect_ratio):
- width, height = img.size
- target_height = int(target_width * height / width * aspect_ratio)
- target_height = max(1, target_height)
- return img.resize((target_width, target_height), Image.LANCZOS)
复制代码
灰度计算与字符映射函数:
- def rgb_to_gray(r, g, b):
- return int(0.299 * r + 0.587 * g + 0.114 * b)
- def gray_to_char(gray, invert):
- max_index = len(CHAR_DENSITY) - 1
- index = int(gray / 255 * max_index)
- if invert:
- index = max_index - index
- index = max(0, min(index, max_index))
- return CHAR_DENSITY[index]
复制代码
图像转字符画的核心循环:
- img = resize_image(img, target_width, aspect_ratio)
- img = img.convert("RGBA")
- for y in range(img.height):
- for x in range(img.width):
- r, g, b, a = img.getpixel((x, y))
- if a < 128:
- r, g, b = 255, 255, 255
- gray = rgb_to_gray(r, g, b)
- ch = gray_to_char(gray, invert)
- if color:
- line_parts.append(f"\x1b[38;2;{r};{g};{b}m{ch}\x1b[0m")
- else:
- line_parts.append(ch)
复制代码
命令行参数包括:image 为输入图片路径;-w/--width 指定输出宽度,默认 120;-c/--color 启用 ANSI 真彩色;-i/--invert 反转灰度映射;-r/--ratio 设置字符宽高比补偿系数,默认 0.5。
为避免超大图导致卡顿,代码在打开图片后检查像素总量。如果宽乘高超过 2000 万,会先按比例缩到 2000 万像素以内:
- width, height = img.size
- if width * height > 20_000_000:
- ratio = (20_000_000 / (width * height)) ** 0.5
- img = img.resize((int(width * ratio), int(height * ratio)), Image.LANCZOS)
复制代码
四、实际运行示例
以 800x600 的图片为例,灰度模式、输出宽度 100:
python cua.py demo.jpg -w 100
默认高度约为 100 * 600 / 800 * 0.5 = 37 行,普通终端可以完整显示。彩色模式加 -c:
python cua.py demo.jpg -w 100 -c
如果终端背景是白色,建议加 -i 反转映射,否则亮部可能和背景混在一起:
python cua.py demo.jpg -w 100 -i
实际效果与字体、终端背景色、字符密度序列、宽高比系数都有关系,因此这些参数保留为可调项,而不是写死一套配置。
五、常见问题与排查
1. Windows 终端乱码或中文变问号
Windows 命令行默认编码可能是 GBK,遇到 ANSI 转义序列时容易出现解析异常。可以在终端先切换 UTF-8:
chcp 65001
也可以在 Python 代码里包装标准输出:
- import sys, io
- sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
复制代码
Windows Terminal 默认对 UTF-8 支持更好,遇到乱码时可以优先换终端测试。
2. 字符画被压扁或拉长
这是宽高比补偿系数不匹配导致的。判断方法是输出一个包含 @ 字符的 20x10 网格,观察行高和列宽。画面偏扁说明高度偏小,调大 -r;画面瘦高说明高度偏大,调小 -r。竖构图图片压缩后行数可能很多,终端高度不够时会被截断,可以降低 -w,或增大压缩系数。
3. 彩色模式颜色不对或一片灰
先确认终端是否支持真彩色。老终端可能只支持 16 色或 256 色,遇到 38;2;R;G;B 序列会忽略样式。可以换 Windows Terminal、iTerm2、GNOME Terminal、Konsole 或 Alacritty。若只能使用 256 色,需要把 RGB 映射到最近调色板,再用 \x1b[38;5;Nm 输出,这属于扩展实现,不在默认代码中。还要检查管道或日志工具是否过滤了转义序列,例如输出经过 less 时可能吞掉颜色控制码。
4. 输出全是同样密度的字符,画面模糊
优先检查灰度分布。如果图片整体偏亮或偏暗,灰度值可能集中在字符序列的一端,导致层次不足。可以用 Pillow 做自动对比度拉伸:
- from PIL import ImageOps
- img = ImageOps.autocontrast(img.convert("L"))
复制代码
另一个原因是输出宽度过大,例如超过 180 列后,每个字符对应的原图区域太小,灰度变化缓慢,画面会像很淡的马赛克。把 -w 调回 80 到 120 之间通常能改善。
5. 透明 PNG 图标输出成黑块
透明像素的 RGB 存储方式不统一,有的文件里是 0,有的保留背景色预乘结果。直接读取原 RGB 可能输出黑色块。cua 在 alpha 小于 128 时把像素当成白色处理,避免黑块;如果需要透明区域显示为纯黑,把代码里的 255 改成 0 即可。
六、适用场景与限制
cua 适合在终端里做轻量视觉输出,也适合作为 Python 图像处理和命令行参数解析的练习项目。它的限制也很明显:字符画本身会舍弃细节,只保留轮廓和明暗关系;最终效果依赖终端字体、背景色和真彩色支持;默认代码没有实现 256 色降级和对比度增强,需要按需扩展。理解缩放公式、灰度权重、字符索引和 ANSI 序列之后,就可以继续增加自定义密度串、自动对比度、竖图适配等参数。 |