图片转字符画这件事,我最早是在一个终端工具里看到的——把一张人像照片丢进去,屏幕上立刻用@#%*+=-:.这些符号重新拼出一张脸,远看轮廓分明,近看全是字符。当时第一反应是"这不就是个玩具",直到我自己动手写了一遍转换逻辑,才发现里面藏着不少值得琢磨的细节:灰度怎么映射、字符密度怎么排、宽高比为什么总是被拉歪、为什么美人图转出来好看而动物照容易糊成一团。这篇就把我从零实现一个图片转字符画工具的完整思路拆开讲,覆盖人像、动物照和带注释的趣味图案三类场景,代码可以直接跑,参数可以照着调。
字符画(ASCII Art)本质是一种用字符的疏密来模拟明暗的降维表达。它解决的问题很具体:在没有图像渲染能力的环境里(纯文本终端、日志、聊天窗口、代码注释),依然想传达一张图的视觉信息。适合谁来参考?想练手图像处理的初学者、想做终端小工具的开发者、想给项目 README 加点花样的维护者,以及纯粹想玩点视觉创意的人。下面所有实现都基于 Python 加 Pillow,不依赖任何重型框架,一台普通机器几分钟就能跑通。
1. 先把"像素到字符"这条链路想清楚
很多人一上来就写代码,结果转出来的图要么全黑要么全白,要么比例诡异。问题不在代码,在于没想清楚这条转换链路到底经过了几次信息损失。我习惯先把链路画在纸上:原图 → 缩放 → 灰度化 → 灰度值分档 → 映射到字符 → 按行拼接输出。每一步都在丢信息,而字符画好不好看,取决于你在哪一步丢、丢多少。
1.1 为什么必须先缩放再灰度化
顺序很关键。如果你先对原图做灰度化再缩放,计算量会大很多,而且缩放算法在彩色空间和灰度空间的表现略有差异。更实际的原因是:字符画的"分辨率"由字符网格决定,一张 3000×4000 的照片最终可能只输出 80×40 个字符,也就是说你根本不需要原图那么精细的灰度信息。先缩放到目标字符网格大小,再灰度化,既省算力又避免缩放引入的伪影干扰灰度分档。
这里有个容易忽略的点:缩放的目标尺寸不是随便定的。字符在等宽字体里大致是"高约为宽的两倍",所以如果你按图片原始宽高比直接缩放到 80×40,转出来的人脸会被纵向拉长。正确做法是先按字符宽高比修正,再缩放。我一般用这样一个换算:
# 目标字符画宽度(列数) target_width = 100 # 字符高宽比,等宽字体里字符高度约为宽度的 2 倍 char_aspect = 0.5 # 按比例算出目标高度 target_height = int(img.height / img.width * target_width * char_aspect)char_aspect = 0.5的意思是"一个字符占的垂直空间是水平空间的两倍",所以高度要乘 0.5 来抵消这个拉伸。这个系数不是固定的,取决于你最终用什么字体渲染——终端里通常是 0.5 左右,某些等宽字体接近 0.45 或 0.55,调这个值就是在调"脸胖脸瘦"。
1.2 灰度分档:字符密度表的排序逻辑
灰度化之后每个像素是 0 到 255 的值,你要把它映射到一组字符上。这组字符的排列顺序决定了明暗过渡是否自然。核心原则只有一条:字符的"视觉密度"必须单调递增或递减。
常用的密度表长这样,从最暗到最亮:
@%#*+=-:.@笔画最多、占的墨最多,视觉上最暗;空格几乎没有墨,视觉上最亮。中间%#*+=-:.大致按墨量递减排列。但这里有个坑:不同字体下字符的实际墨量排序会变。比如#和%谁更暗,取决于字体设计。我实测下来,在大多数等宽字体里@最暗没问题,但M和W有时比#还暗。所以如果你追求极致效果,应该针对你实际使用的字体重新排一遍密度表,而不是照抄网上的。
分档的粒度也值得说。假设密度表有 10 个字符,那灰度 0-255 就要分成 10 档,每档约 25.5 个灰度级。分档公式:
# 灰度值 g 在 0-255,密度表长度 n index = int(g / 256 * n) # 防止 g=255 时越界 index = min(index, n - 1) char = density_table[index]注意g / 256而不是g / 255,这样 255 会落到最后一档而不是溢出。这个细节不处理,最亮的像素会报 IndexError,我第一次写就栽在这。
1.3 一个最小可运行版本
把上面几步串起来,就是一个能跑的核心。我把它写成函数,方便后面针对不同场景调参:
from PIL import Image def image_to_ascii(path, width=100, char_aspect=0.5, density="@%#*+=-:. "): img = Image.open(path).convert("RGB") # 按字符宽高比修正目标高度 height = int(img.height / img.width * width * char_aspect) img = img.resize((width, height)) img = img.convert("L") # 转灰度 pixels = img.load() n = len(density) lines = [] for y in range(height): line = [] for x in range(width): g = pixels[x, y] idx = min(int(g / 256 * n), n - 1) line.append(density[idx]) lines.append("".join(line)) return "\n".join(lines) print(image_to_ascii("portrait.jpg", width=80))这段代码跑通之后,你会得到一张能看的字符画。但"能看"和"好看"之间还有距离,接下来的章节就是填这段距离。
2. 美人图为什么容易出效果,以及怎么调到位
人像,尤其是正面、光线柔和的美人图,是字符画最友好的题材。原因不复杂:人脸有大面积的平滑明暗过渡(脸颊、额头),轮廓清晰(发际线、下颌线),而且人眼对人脸极其敏感,只要五官位置大致对,大脑会自动补全细节。但正因为人眼敏感,参数没调好时瑕疵也特别扎眼——眼睛糊成一片、鼻子消失、头发变成一坨噪点。
2.1 对比度预增强:让人脸"立"起来
原始照片直接转灰度,往往会偏灰偏平,因为现代照片的动态范围很宽,而字符画只有 10 档左右的灰度可用。这时候需要先做一次对比度拉伸,把有用的灰度区间撑满 0-255。Pillow 里可以用ImageEnhance.Contrast,也可以手动做线性拉伸。我偏好手动,因为可控:
from PIL import ImageEnhance img = Image.open("portrait.jpg").convert("RGB") # 对比度增强 1.5 倍,这个值人像场景实测比较稳 img = ImageEnhance.Contrast(img).enhance(1.5) # 亮度微调,避免整体偏暗 img = ImageEnhance.Brightness(img).enhance(1.1)对比度系数 1.5 是我在几十张人像上试出来的经验值。低于 1.2 效果不明显,高于 2.0 会让高光溢出、暗部死黑,脸颊的过渡直接断掉。亮度 1.1 是为了补偿对比度增强后整体变暗的倾向。这两个值不是金科玉律,但作为起点很靠谱。
2.2 密度表的选择:人像要"细腻"不要"粗犷"
前面那个@%#*+=-:.密度表偏通用,转人像时我建议换成更长的表,增加中间调的层次。字符越多,灰度分档越细,脸颊的渐变就越平滑。我常用的一张 12 字符表:
@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\|()1{}[]?-_+~<>i!lI;:,"^`'.这是网上流传很广的一张"长密度表",从暗到亮排列。但直接用有个问题:里面混了大量字母,转出来的人像会显得"文字感"太重,远看像一篇文章而不是一张脸。我的折中方案是用符号为主的表:
@#&$%*o!;:,.@#&$%*负责暗部,o!;:,.负责亮部,中间过渡靠*和o。这张表转人像时既有层次又不会太"字"。你可以根据自己审美换,但记住一个判断标准:眯起眼睛看字符画,如果还能看出人脸轮廓,密度表就选对了。
2.3 眼睛和嘴的处理:局部提亮的小技巧
人像字符画最容易翻车的地方是眼睛。眼睛区域通常偏暗(瞳孔、睫毛),在低分辨率字符网格里很容易和眼窝糊成一团,变成两个黑块。我试过一个简单有效的办法:在灰度化之后、映射之前,对眼睛所在的水平带做一次局部提亮。但更通用的做法是整体提高中间调的权重——把密度表里中间几个字符的"档位"拉宽。
具体操作是改分档公式,给中间灰度更大的映射区间:
def map_gray(g, n): # 对中间调做非线性拉伸,让 80-180 区间占更多档位 if g < 80: norm = g / 80 * 0.3 elif g < 180: norm = 0.3 + (g - 80) / 100 * 0.5 else: norm = 0.8 + (g - 180) / 75 * 0.2 return min(int(norm * n), n - 1)这段逻辑的意思是:暗部(0-80)只占 30% 的字符档位,中间调(80-180)占 50%,亮部(180-255)占 20%。人脸的明暗信息主要集中在中间调,这样分配能让脸颊、鼻梁的过渡更细腻,而暗部(头发、瞳孔)压缩后反而更干净,不会糊成一片。这个非线性映射是我调人像时最有用的一招,比单纯调对比度有效得多。
2.4 分辨率与字符宽高比的联动
人像字符画的宽度选多少?我试过 40 到 200 的各种值。结论是:60 到 100 之间最适合人像。低于 60,五官挤在一起认不出;高于 120,字符太密,远看反而像噪点,失去了字符画的味道。宽度定了之后,char_aspect要跟着微调——宽度越大,纵向拉伸的视觉误差越明显,char_aspect可以往 0.45 靠一点。
还有一个实操细节:输出到终端时,终端窗口的字体大小和行距会影响最终观感。同一张字符画,在小字体终端里看是"细腻的人像",在大字体终端里看可能变成"粗糙的色块"。所以调参时最好在最终展示环境里看效果,别在编辑器里调完就完事。
3. 动物照的难点:边缘、毛发与背景干扰
动物照比人像难转,这是我踩过的最大的一个坑。第一次拿一张猫的照片去转,出来一团灰蒙蒙的东西,完全看不出是猫。后来分析才明白:动物照的问题在于高频细节太多——毛发、胡须、斑纹都是细碎的明暗变化,而字符画的分辨率根本承载不了这些细节,它们会退化成随机噪点,把整体轮廓淹没。
3.1 先降噪再转换,而不是直接转
对付毛发噪点,最有效的一步是在缩放之前做一次轻微模糊。注意是"轻微",目的是抹掉高频噪点但保留轮廓。Pillow 的GaussianBlur半径设 1 到 2 就够:
from PIL import ImageFilter img = Image.open("cat.jpg").convert("RGB") # 半径 1.5 的轻度高斯模糊,压掉毛发噪点 img = img.filter(ImageFilter.GaussianBlur(radius=1.5))半径超过 3 就会把耳朵、尾巴这些关键轮廓也糊掉,得不偿失。1.5 是我在猫、狗、鸟几类照片上试出来的平衡点。模糊之后再做缩放和灰度化,转出来的动物轮廓会清晰很多,毛发区域变成柔和的灰度块而不是噪点。
3.2 背景分离:让主体"跳"出来
动物照另一个大问题是背景。人像照通常背景简单(纯色墙、虚化),而动物照经常是草地、地板、家具,背景的明暗变化会和主体抢戏。字符画里没有颜色区分,背景的纹理很容易和动物身体混在一起。
一个不需要复杂分割的土办法:提高对比度 + 压暗背景。如果背景比主体亮(比如白墙前的黑猫),就整体压暗;如果背景比主体暗,就整体提亮。判断方法很简单,看灰度直方图的重心在哪。更精细一点,可以用一个简单的阈值把明显属于背景的亮部或暗部"压平":
def flatten_background(img, low=30, high=225): # 把过暗和过亮的像素往中间压,减少背景干扰 pixels = img.load() w, h = img.size for y in range(h): for x in range(w): g = pixels[x, y] if g < low: pixels[x, y] = low elif g > high: pixels[x, y] = high return img这段把灰度低于 30 的压到 30、高于 225 的压到 225,相当于砍掉两端的极端值。效果是背景里那些纯黑纯白的大块区域变得"温和",不再抢主体的视觉权重。这个操作要在灰度化之后、映射之前做。
3.3 密度表要"粗"一点,轮廓优先
和人像相反,动物照我建议用更短、更"粗"的密度表。原因还是毛发噪点——密度表越长,灰度分档越细,噪点就越容易被表现出来。用一张 6 到 8 字符的表,相当于强制降采样,把细碎变化合并成大块明暗:
@#*+=-.这张表只有 7 档,转动物照时轮廓会明显更清晰。代价是细节丢失,但动物照本来就不该追求细节,追求的是"一眼认出是什么动物"。我拿一张柴犬照片对比过:12 字符表转出来是"一团有毛的东西",7 字符表转出来能清楚看到耳朵和嘴的轮廓。
3.4 一个动物照的完整参数组合
把上面几点串起来,动物照的推荐参数是这样的:
| 参数 | 人像推荐值 | 动物照推荐值 | 说明 |
|---|---|---|---|
| 目标宽度 | 80-100 | 60-80 | 动物照窄一点轮廓更集中 |
| 高斯模糊半径 | 0(可不做) | 1.0-2.0 | 压毛发噪点 |
| 对比度增强 | 1.5 | 1.8-2.2 | 动物照需要更强对比拉开主体 |
| 密度表长度 | 10-12 | 6-8 | 短表抗噪 |
| 背景压平阈值 | 一般不需要 | low=30, high=225 | 减少背景干扰 |
这张表是我实际调参时记下来的,不是理论推导。你可以直接拿去当起点,再根据具体照片微调。动物照的调参空间比人像大,因为动物种类、毛色、背景差异太大,没有一套参数通吃。
4. 有趣注释图案:把字符画塞进代码和文档
字符画最有意思的用法,其实是当"注释图案"——在代码文件头部、README、配置文件里放一个小字符画,既当装饰又当标识。这类场景和前面两种完全不同:它不追求还原度,追求的是在小尺寸下依然可辨识,而且要能塞进注释符号里不破坏语法。
4.1 小尺寸字符画的极限在哪
注释图案通常只有 20 到 40 列宽,因为再宽就会在代码编辑器里换行、破坏排版。在这个尺寸下,一张复杂照片根本没法还原,所以选图很关键:要选轮廓极简、对比极强的图。logo、简笔画、图标、表情符号这类"本来就是符号化"的图,转成字符画效果最好。拿一张写实照片去转 30 列字符画,结果必然是糊的,这不是技术问题,是信息量不够。
我试过用一张简笔画的猫头(就是那种几根线条的卡通猫)转 30 列字符画,效果出奇地好,耳朵和胡须都能看出来。换成写实猫照片,同样 30 列,完全认不出。所以做注释图案的第一步不是调参,是选对源图。
4.2 用注释符号包裹:别破坏语法
字符画放进代码里,最怕的是字符画本身包含注释结束符。比如 C 系语言用/* */,如果字符画里出现*/,注释就提前结束了,代码直接报错。Python 用#,如果字符画某行以#开头倒没事,但密度表里如果有#字符,整行看起来就像注释,虽然不影响语法但很乱。
我的处理办法是:为注释场景单独定制密度表,避开目标语言的注释符号。比如给 Python 用,密度表里就不要#;给 C 用,就不要*和/相邻出现。一个安全的通用密度表:
@%&$+=-:.这张表避开了#、*、/、\这些容易和注释符号冲突的字符。转出来的图案用#或//包裹,安全无虞。
4.3 输出成可直接粘贴的格式
注释图案的最终形态是一段可以直接粘进文件的文本。我写了个小函数,把字符画每行前面加上注释前缀:
def to_comment_block(ascii_art, prefix="# "): lines = ascii_art.split("\n") return "\n".join(prefix + line for line in lines) art = image_to_ascii("logo.png", width=30, density="@%&$+=-:. ") print(to_comment_block(art, prefix="# "))输出就是每行带#前缀的字符画,直接粘进 Python 文件头部即可。给 C 系语言用就把 prefix 换成//。这里有个小技巧:prefix 后面留一个空格,能让字符画和注释符号之间有个视觉间隔,看起来更舒服。不留空格的话,#@%&连在一起会很挤。
4.4 趣味图案的另一种玩法:文字嵌入
除了转照片,字符画还有个玩法是"文字嵌入"——用字符拼出文字形状。这个严格说不是图片转换,而是字符画的一个变种,但和"有趣注释图案"的场景高度契合。思路是:先渲染一段文字(用 Pillow 的ImageDraw.text),得到一张文字形状的二值图,再用字符填充文字区域、空格填充背景。
from PIL import Image, ImageDraw, ImageFont def text_to_ascii(text, font_path, size=40, density="@#*+=-. "): # 先渲染文字到一张图 font = ImageFont.truetype(font_path, size) bbox = font.getbbox(text) w = bbox[2] - bbox[0] + 20 h = bbox[3] - bbox[1] + 20 img = Image.new("L", (w, h), 255) draw = ImageDraw.Draw(img) draw.text((10, 10), text, font=font, fill=0) # 再走一遍字符画转换 return image_to_ascii_from_image(img, width=60, density=density)这样转出来的字符画是"用字符组成的文字",放在 README 标题区或者项目 banner 位置很抓眼球。注意fill=0是黑字白底,转换时黑字区域会映射到密度表前端的重字符,白底映射到空格,正好形成"文字由字符构成"的效果。
5. 踩过的坑与参数速查
写到这,核心链路和三类场景的调法都过了一遍。最后把我实际踩过的坑集中列一下,这些是文档里不会写、但动手时一定会遇到的东西。
5.1 宽高比拉歪:最常见的"看起来不对"
字符画转出来"脸变长"或"图变扁",九成是char_aspect没设对。这个值的物理含义是"字符高度 / 字符宽度",等宽字体里通常是 2.0 左右,但我们在缩放时用的是它的倒数 0.5。如果你用的字体特别宽或特别窄,这个值要跟着改。判断方法:转一张正圆形的图,如果输出是椭圆,就调char_aspect直到接近正圆。这个校准步骤建议每个新环境都做一次。
5.2 全黑或全白:分档公式的边界
前面提过g / 256而不是g / 255,但还有个更隐蔽的坑:如果密度表末尾是空格,而你的分档公式让最亮的像素落到最后一档,那输出里会有大量空格,看起来像"图缺了一块"。解决办法是确保密度表末尾的空格只对应真正的纯白(255),或者干脆把空格从密度表里去掉,用.代替最亮档。我现在的习惯是密度表末尾放.而不是空格,输出更稳定。
5.3 大图转换慢:别用 Python 逐像素
我第一版用纯 Python 双层循环逐像素处理,一张 100 列的图要跑好几秒。后来发现瓶颈在pixels[x, y]的访问开销。优化办法是用 Pillow 的getdata()拿到一维像素序列,或者直接用 NumPy 做向量化:
import numpy as np def fast_convert(img, width, char_aspect, density): height = int(img.height / img.width * width * char_aspect) img = img.resize((width, height)).convert("L") arr = np.array(img) # shape: (height, width) n = len(density) indices = np.clip((arr / 256 * n).astype(int), 0, n - 1) # 用 NumPy 索引把灰度矩阵映射成字符矩阵 char_arr = np.array(list(density))[indices] return "\n".join("".join(row) for row in char_arr)NumPy 版本比纯 Python 快一个数量级,100 列的图基本瞬间出结果。如果你要批量处理一堆图,这个优化很值得。
5.4 参数速查表
最后给一张按场景查的表,省得每次从头试:
| 场景 | 宽度 | char_aspect | 模糊半径 | 对比度 | 密度表 | 特殊处理 |
|---|---|---|---|---|---|---|
| 美人图 | 80-100 | 0.5 | 0 | 1.5 | @#&$%*o!;:,. | 中间调非线性拉伸 |
| 动物照 | 60-80 | 0.5 | 1.5 | 2.0 | @#*+=-. | 背景压平 low=30 high=225 |
| 注释图案 | 20-40 | 0.5 | 0 | 1.2 | @%&$+=-:. | 避开注释符号,加前缀 |
| 文字嵌入 | 60 | 0.5 | 0 | 1.0 | @#*+=-. | 先渲染文字再转换 |
这张表里的值都是起点,不是终点。真正好用的参数永远是在具体图片上试出来的,我上面给的每个数字背后都是若干次"转出来看看、不行再调"的循环。字符画这东西的魅力也在这——它没有唯一正确答案,同一张图用不同参数能转出完全不同的气质,调参的过程本身就是创作。
我个人在实际操作中的体会是:别追求"还原原图",字符画的本质是"用字符重新诠释",接受它的粗糙和抽象,反而能转出更有味道的东西。有时候参数调得"不够准",出来的效果比精确还原还好看。