查看: 176|回复: 3

Python+Pillow实现命令行字符画cua与ANSI真彩输出

[复制链接]
发表于 1 小时前 | 显示全部楼层 |阅读模式
字符画工具并不新鲜,但把它做成一个可调参数、支持真彩色、能跨终端运行的命令行程序,涉及的问题比想象中多:终端字符不是正方形、灰度映射需要符合人眼感知、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。核心函数分为缩放、灰度计算、字符映射、图像转字符画和命令行入口。

缩放与高度校正函数:
  1. def resize_image(img, target_width, aspect_ratio):
  2.     width, height = img.size
  3.     target_height = int(target_width * height / width * aspect_ratio)
  4.     target_height = max(1, target_height)
  5.     return img.resize((target_width, target_height), Image.LANCZOS)
复制代码

灰度计算与字符映射函数:
  1. def rgb_to_gray(r, g, b):
  2.     return int(0.299 * r + 0.587 * g + 0.114 * b)
  3. def gray_to_char(gray, invert):
  4.     max_index = len(CHAR_DENSITY) - 1
  5.     index = int(gray / 255 * max_index)
  6.     if invert:
  7.         index = max_index - index
  8.     index = max(0, min(index, max_index))
  9.     return CHAR_DENSITY[index]
复制代码

图像转字符画的核心循环:
  1. img = resize_image(img, target_width, aspect_ratio)
  2. img = img.convert("RGBA")
  3. for y in range(img.height):
  4.     for x in range(img.width):
  5.         r, g, b, a = img.getpixel((x, y))
  6.         if a < 128:
  7.             r, g, b = 255, 255, 255
  8.         gray = rgb_to_gray(r, g, b)
  9.         ch = gray_to_char(gray, invert)
  10.         if color:
  11.             line_parts.append(f"\x1b[38;2;{r};{g};{b}m{ch}\x1b[0m")
  12.         else:
  13.             line_parts.append(ch)
复制代码

命令行参数包括:image 为输入图片路径;-w/--width 指定输出宽度,默认 120;-c/--color 启用 ANSI 真彩色;-i/--invert 反转灰度映射;-r/--ratio 设置字符宽高比补偿系数,默认 0.5。

为避免超大图导致卡顿,代码在打开图片后检查像素总量。如果宽乘高超过 2000 万,会先按比例缩到 2000 万像素以内:
  1. width, height = img.size
  2. if width * height > 20_000_000:
  3.     ratio = (20_000_000 / (width * height)) ** 0.5
  4.     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 代码里包装标准输出:
  1. import sys, io
  2. 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 做自动对比度拉伸:
  1. from PIL import ImageOps
  2. img = ImageOps.autocontrast(img.convert("L"))
复制代码

另一个原因是输出宽度过大,例如超过 180 列后,每个字符对应的原图区域太小,灰度变化缓慢,画面会像很淡的马赛克。把 -w 调回 80 到 120 之间通常能改善。

5. 透明 PNG 图标输出成黑块

透明像素的 RGB 存储方式不统一,有的文件里是 0,有的保留背景色预乘结果。直接读取原 RGB 可能输出黑色块。cua 在 alpha 小于 128 时把像素当成白色处理,避免黑块;如果需要透明区域显示为纯黑,把代码里的 255 改成 0 即可。

六、适用场景与限制

cua 适合在终端里做轻量视觉输出,也适合作为 Python 图像处理和命令行参数解析的练习项目。它的限制也很明显:字符画本身会舍弃细节,只保留轮廓和明暗关系;最终效果依赖终端字体、背景色和真彩色支持;默认代码没有实现 256 色降级和对比度增强,需要按需扩展。理解缩放公式、灰度权重、字符索引和 ANSI 序列之后,就可以继续增加自定义密度串、自动对比度、竖图适配等参数。
回复

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Python+Pillow实现命令行字符画cua与ANSI真彩输出

内容挺实在的,几个容易踩的点基本都讲到了。终端字符宽高比补偿那段最关键,0.5 这个经验值确实看字体和行距,我这边用等宽字体时 0.45 更舒服,所以做成 -r 可调很合理,比写死强。 灰度到字符那步,int 截断时 255 刚好落到 max_index,边界没问题,但如果 CHAR_DENSITY 末尾是空格,亮部就整片留白了,像白墙、天空那种区域容易糊成一块。建议在文档里明确字符集是从密到疏排的,顺便提一句反了就用 -i,省得别人自己试。 彩色模式“亮度选字符、原始 RGB 上色”这个思路很好,终端一旦降级到 256 色,轮廓还在,不会变成色块糊在一起,这比单纯按颜色输出来得稳。透明像素 alpha 小于 128 填白也合理,深色终端下效果最好;不过要是有人在浅色背景的终端里跑,透明区反而会看不见,也许可以留个参数让使用者自己选填充色。 还有个小性能点:每个字符都拼一次 \x1b[38;2;r;g;bm 再加 \x1b[0m 重置,120 列满屏跑下来序列数量不少,输出体积和终端渲染都会有点压力。相邻同色字符可以合并成一段再设色,重置用 \x1b[39m 恢复默认前景色也比全量 reset 轻一些,大图上差别应该挺明显。 另外正文到 rgb_to_gray(r, 这里好像被截断了,彩色输出的主循环和命令行入口那部分没看到,方便的话补上?
回复 支持 反对

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Python+Pillow实现命令行字符画cua与ANSI真彩输出

楼主这篇拆得挺细,尤其是把“终端字符不是正方形”这个坑单独拎出来讲,很多人第一次做字符画都会遇到输出被纵向拉长的问题,用宽高比补偿系数来校正确实是最省事的做法。 几个我觉得可以补充或者讨论的点: 一是 Windows 下的 ANSI 支持。老版本 conhost 默认不解析转义序列,Python 3.6+ 在 Windows 上可以用 os.system("") 顺手把 VT 处理打开,或者先判断一下 sys.stdout.isatty(),非交互环境(比如重定向到文件)就别输出颜色码,否则文件里全是乱码控制符。 二是彩色模式的输出效率。每个字符后面都跟一个 \x1b[0m 重置,一屏 120x60 就是七千多个转义序列,体积和渲染开销都不小。常见优化是只在颜色发生变化时才发新的颜色码,或者每行末尾统一重置一次,视觉上基本没差别,输出能小一大截。 三是取像素的方式。getpixel 在大图上逐个调用其实挺慢的,convert("RGBA") 之后直接 list(img.getdata()) 或者 img.load() 拿像素访问对象,速度会好不少,核心循环写起来也干净。 四是 CHAR_DENSITY 的顺序和 -i 参数的配合,建议在 README 里把默认字符集的排列方向写清楚,不然用户看到明暗反了第一反应是怀疑灰度公式,其实只是映射方向的问题。 另外楼主的帖子好像发到一半截断
回复 支持 反对

使用道具 举报

发表于 1 小时前 | 显示全部楼层

Re: Python+Pillow实现命令行字符画cua与ANSI真彩输出

这篇把 cua 的定位、设计取舍和几个关键坑讲得挺清楚,尤其是终端字符宽高比补偿、BT.601 灰度权重、先按亮度选字符再按 RGB 上色这几点,确实是字符画工具里很容易被忽略但很影响效果的地方。透明像素统一改白也是个实用处理,不然透明 PNG 很容易出现黑边或黑块。 不过首帖正文到 gray = rgb_to_gray(r, 这里就断了,彩色模式完整循环、ANSI 拼接和命令行参数部分还没展开,挺想继续看后续。另外有个小地方可以顺手确认一下:gray_to_char 里最后写的是 return CHAR_DENSITY,按前面的逻辑应该返回 CHAR_DENSITY,否则字符映射不会生效,可能是截断或笔误。 如果后面补充完整,建议再强调两点:Windows 下除了 UTF-8 编码,还要确认终端启用了 VT 转义支持,否则真彩色序列可能直接显示成乱码;另外每个字符都拼一次颜色和重置序列,输出体积会比较大,彩色模式里可以把连续相同颜色的字符合并输出,终端渲染和传输都会更舒服。整体思路很适合作为图像处理加命令行开发的小项目。
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-11-9 03:31 , Processed in 0.019917 second(s), 28 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部