我之前已经有一个 truecolor half-block 版本:把图片压到终端宽度后,用上半块和下半块分别保存两行像素的颜色。这个方法稳定、兼容性好,但每个字符只能表达上下两个采样点。想让图片在命令行里更细,就要把一个字符格继续拆开,改用 Unicode 的四象限块字符。
这篇文章说明新版转换器的思路。使用入口在 实用功能 页面:把图片拖进去,生成预览,下载 .ans 文件,然后在支持 24-bit truecolor 的终端里运行 cat 文件名.ans。
一、为什么不是简单地把四个像素直接塞进一个字符
ANSI 终端的一个字符格通常只能同时设置一种前景色和一种背景色。Unicode 四象限字符能决定这个字符格里哪些象限显示前景色,哪些象限显示背景色,但它不能让左上、右上、左下、右下四个象限分别拥有四种独立颜色。
所以“四象限 full color”实际面对一个约束:每个 2 x 2 像素块最多只能压缩成两组颜色。转换器要做的不是假装没有损失,而是在这个约束下找到误差最小的两色表示。
二、四象限字符怎样编码 2 x 2 像素
一个字符格被看作四个位置:左上、右上、左下、右下。每个位置要么属于前景色,要么属于背景色,因此一共有 16 种组合。常见字符包括空格、上半块、下半块、左半块、右半块、对角块和整块,例如 ▘ ▝ ▀ ▖ ▌ ▞ ▛ ▗ ▚ ▐ ▜ ▄ ▙ ▟ █。
转换器先把原图按目标终端宽度缩放到一个采样网格。假设输出宽度是 180 个终端字符,那么采样网格的宽度就是 360 个像素;每两个横向采样点和两个纵向采样点合成一个终端字符。高度会按终端字体比例自动计算,默认把一个终端字符当作高宽比约 2:1。
三、怎样选择前景色、背景色和字符
对每一个 2 x 2 像素块,转换器枚举 16 种象限遮罩。某一种遮罩会把四个像素分成两组:前景组和背景组。前景组取平均 RGB 作为前景色,背景组取平均 RGB 作为背景色,然后计算四个原始像素与两组代表色之间的平方误差。
最终选择误差最小的一组:
- 枚举 16 种象限遮罩。
- 根据遮罩把四个采样像素分成前景组和背景组。
- 分别计算两组 RGB 平均值。
- 计算四个像素的重建误差。
- 输出误差最小的 Unicode 字符和 ANSI truecolor 前景/背景色。
这比固定使用半块字符更细,因为它同时保留了左右方向的边缘信息。代价是文件会更大,某些终端字体对四象限块的显示也可能不如半块稳定。
四、生成的 .ans 文件里是什么
.ans 文件本质上是普通 UTF-8 文本,只是里面包含 ANSI 转义序列。每个字符前会按需要写入类似 ESC[38;2;R;G;B;48;2;R;G;Bm 的控制码,分别设置前景色和背景色,然后写入一个四象限 Unicode 字符。每行结束时再用 ESC[0m 重置样式,避免背景色污染后面的命令行。
在 macOS Terminal、iTerm2、Windows Terminal、GNOME Terminal 等支持 truecolor 和 Unicode block elements 的终端中,可以直接运行:
cat image-quadrant-180x120-truecolor.ans
如果图片太宽,终端自动换行会破坏画面;这时要么把终端窗口拉宽,要么在转换时降低输出宽度。
五、为什么工具页不把图片上传到服务器
图片转换不需要服务器参与。浏览器已经能解码常见图片格式,并能通过 Canvas 读取缩放后的像素。把转换放在前端有三个好处:
- 隐私更简单:图片不离开用户设备。
- 存储压力更小:服务器不保存上传文件,也不需要定时清理原图。
- 反馈更快:拖入图片后可以直接预览、调整宽度并重新生成。
页面仍然设置了 30 分钟失效逻辑:生成的 Blob 下载链接、预览 HTML 和当前图片引用会在本页中自动释放。用户已经下载到本机的 .ans 文件不受影响。
六、实现中的几个边界
第一,浏览器只能处理它自己能解码的格式。PNG、JPEG、WebP、GIF、AVIF 和 SVG 通常没有问题,但 HEIC、损坏文件或引用外部资源的 SVG 可能失败。
第二,四象限字符并不等于四倍无损分辨率。每个字符依然只有前景和背景两种颜色;复杂纹理、噪声和高频细节会被压缩成两组代表色。
第三,终端字体会影响观感。理想字体应该让块元素紧密贴合,没有明显缝隙;如果看到格线或字符宽度不齐,需要换等宽字体或调整终端行距。
七、为什么这版比 half-block 更适合细节图
Half-block 的优势是每个字符保存上下两行颜色,色彩表达稳定;四象限版本的优势是同一个字符里可以出现左右方向的结构,因此头发、轮廓线、小面积高光和斜边会更清楚。对于动漫头像、图标和边缘明显的插画,四象限通常能在相同终端宽度下保留更多形状信息。
如果目标是最大兼容性,half-block 仍然是很好的选择;如果目标是在支持 Unicode 和 truecolor 的现代终端里尽可能精细,四象限加最小误差两色聚类更合适。
八、ANSI 转换策略对照表
| 策略 | 每个字符表达的信息 | 适合图片 | 主要限制 |
|---|---|---|---|
| Plain ASCII | 只用字符密度近似明暗,不保留真实颜色。 | 高对比黑白图、终端兼容性要求极高的场景。 | 色彩和细小边缘损失最大。 |
| Half-block truecolor | 一个字符保存上下两个采样点,各自一组颜色。 | 照片、渐变、对兼容性要求较高的彩色图。 | 横向结构弱,斜线和小轮廓容易变粗。 |
| Quadrant truecolor | 一个字符覆盖 2 x 2 采样点,用两色最小误差近似。 | 头像、图标、插画、轮廓明显的图。 | 每格仍只有前景/背景两色,不是四象限四种独立颜色。 |
| 完整图片输出 | 保留原始像素,由图片查看器渲染。 | 需要精确保真、打印或后期处理的场景。 | 不再是纯终端文本,也不能直接嵌入命令行输出。 |
九、终端兼容性验证清单
生成 .ans 后,不建议只看浏览器预览就下结论。浏览器使用的是 Canvas 和页面字体,真实终端还会受字体、行高、色彩配置和自动换行影响。一个可复查的测试记录至少应包含终端名称、字体、字号、输出列宽、图片合成背景和实际运行命令。
| 检查项 | 通过标准 | 失败时优先调整 |
|---|---|---|
| Truecolor 支持 | 渐变色和照片色块没有退化成 256 色近似。 | 更换终端或确认 COLORTERM=truecolor。 |
| Unicode block elements | 四象限字符无缺字、无明显缝隙。 | 更换等宽字体,降低行距或改用 half-block。 |
| 输出宽度 | 一行 ANSI 内容不会被终端自动折行。 | 降低转换宽度,或把终端窗口拉宽后重新测试。 |
| 透明背景 | 透明区域与终端背景接近,不产生奇怪边框。 | 选择接近主题背景的合成色后重新导出。 |
终端字符不是正方形,图会被拉长
把 2×2 像素塞进一个字符格之后,还有一件必须处理的事:终端的字符格不是正方形。
绝大多数等宽字体的字符格宽高比在 1:2 附近——高度大约是宽度的两倍。四象限方案里,一个字符格横向放 2 个像素、纵向也放 2 个像素,于是每个像素在屏幕上占据的实际比例是「半格宽 × 一格高」,也就是高是宽的四倍。
直接转换的结果就是图像被纵向拉长到原来的两倍,人脸会变成长脸。这个失真很明显,但因为终端里没有参照物,容易被误认为是”分辨率不够”。
修法是在采样之前先按字符格的宽高比预压缩:
CELL_RATIO = 2.0 # 字符格高/宽,多数终端在 1.8 ~ 2.2
# 目标字符列数决定横向采样数(每格 2 像素)
cols = target_cols
src_w, src_h = img.size
# 纵向要多压一倍,抵消字符格本身的高宽比
rows = int(cols * (src_h / src_w) / CELL_RATIO)
img = img.resize((cols * 2, rows * 2))
关键是 / CELL_RATIO 这一步。少了它图就是拉长的;而这个比例无法从程序里查到——它取决于用户的终端和字体设置,只能给一个可调的默认值。2.0 对大多数环境合适,建议把它做成参数而不是写死。
顺带一个验证方法:转一张正方形网格图。如果输出的格子在终端里看起来是正方形,比例就对了;这比拿照片试直观得多,因为照片没有已知的参考形状。
真彩色不是每个终端都支持
前面用的 24 位真彩色转义序列(\033[38;2;R;G;Bm)表达能力最好,但它需要终端支持。在只支持 256 色的环境里,这串序列要么被忽略、要么直接以乱码形式打印出来,整个输出就毁了。
能力检测的惯例是看两个环境变量:
import os
truecolor = os.environ.get('COLORTERM', '') in ('truecolor', '24bit')
term256 = '256color' in os.environ.get('TERM', '')
降级到 256 色时,需要把 RGB 映射到 xterm 的调色板。这个调色板的结构是:0–15 是系统色,16–231 是一个 6×6×6 的色立方,232–255 是 24 级灰阶。映射时先把每个通道量化到 6 档:
LEVELS = [0, 95, 135, 175, 215, 255] # xterm 色立方的六档,不是等距的
def rgb_to_256(r, g, b):
if abs(r - g) < 8 and abs(g - b) < 8 and 8 <= r <= 238: # 接近灰色
return 232 + (r - 8) * 24 // 231
q = lambda v: min(range(6), key=lambda i: abs(LEVELS[i] - v))
return 16 + 36 * q(r) + 6 * q(g) + q(b)
这段代码有两个容易写错的地方,都会静默产生错误的颜色。
第一,色立方的六档不是等距的。它们是 0、95、135、175、215、255——第一档到第二档跨了 95,之后每档只跨 40。用 v * 5 // 256 这类线性公式量化会得到系统性偏移,而且很容易在 v = 255 时算出索引 6,越界撞进灰阶段。可以用上面那种「取最近档位」的写法,它不依赖任何公式推导,也不会越界。
验证方法很直接:纯红应该映射到 196、纯绿 46、纯蓝 21、纯白 231、纯黑 16,这些是 xterm 调色板的固定值,对不上就是量化写错了。
第二,灰色要单独走灰阶段。色立方里能表示的灰色只有那六档,而 232–255 这段专门的灰阶有 24 档。不做这个分支的话,灰度图会出现明显的色阶断层——这恰恰是转黑白照片时最先被看出来的问题。灰阶的两端要留出边界(8 <= r <= 238),因为纯黑和纯白在色立方里已经有更准确的表示。
十、使用建议
- 头像或竖图可以先用 160 到 200 列宽度试一次。
- 如果终端会自动换行,就降低宽度,而不是继续提高精度。
- 透明 PNG 建议选择接近终端背景的合成色。
- 复杂照片会生成很大的 ANSI 文件,插画、图标和人物头像通常效果更好。
完整使用入口在 实用功能页面。转换器本身不依赖账号,也不需要后端保存文件;它只是把图片采样、两色近似和 ANSI 输出这三步放到了同一个浏览器界面里。
本文讲的整套遮罩搜索和真彩色编码,做成了可以直接用的网页工具:图片转 ANSI 字符画。拖入图片即可生成,转换全程在你自己的浏览器里完成,文件不上传服务器。