ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用Python复刻“东北味大姐头”:几何头像、ASCII字符画与动态GIF全实现

用Python复刻“东北味大姐头”:几何头像、ASCII字符画与动态GIF全实现 这个标题是我在群里看到别人玩梗时随口起的结果当真把它做成了一个Python绘图项目。今天就把整个实现过程拆开讲讲。先说清楚这项目的本质不是“复刻某位真人”而是用代码把“东北味大姐头”这个形象符号给抽象出来。圆脸、红头巾、粗眉、大嘴巴、大耳环再加两片红腮红这些元素拼在一起一张让人一眼就乐的卡通脸就有了。整个过程只用Python和几个常用库不碰PS不碰AI绘图纯手搓。我把它拆成了几何头像、ASCII字符画、表情动图、批量随机生成这几条路线每一块都能单独拿出来玩。适合刚学Python不久、想找个有趣项目练手的人也适合那些已经会用matplotlib画折线图、但还没试过“用代码画人物”的朋友。下面按我实际开发的顺序来写。1. 先把话说清楚这个玩梗项目到底要做什么1.1 一个梗如何变成Python练手项目网络上的梗来得快去得也快但“用代码画出来”这件事本身不会过时。我当时的想法很简单与其在评论区刷“梦中情人”不如自己写个脚本把这张脸“算”出来。这听起来像玩笑但真做起来涉及的知识点可真不少图像坐标、几何图形绘制、颜色填充、动画帧、命令行参数解析……每个环节都能学到东西。很多人学Python卡在“不知道学完语法能干嘛”这个项目就是很好的答案。它不是教科书里的例题而是一个有明确目标、有视觉反馈、还能拿来发朋友圈的完整小工具。1.2 三种实现路线的选型对比我一开始其实纠结过用什么方式画。市面上画图的路子很多但纯Python方案里适合这个场景的主要有三条我做了个对比方案核心库风格难度适合场景几何拼贴matplotlib卡通矢量风中头像、海报、动图像素画Pillow复古像素风低游戏素材、点阵头像ASCII字符画Pillow numpy赛博字符风低终端展示、聊天表情最后我选了matplotlib几何拼贴作为主路线因为它的图形API最丰富能画圆、椭圆、多边形、弧线特别适合拼人物而且matplotlib自带动画模块后面做眨眼、咧嘴这些效果不用另起炉灶。Pillow负责把成品转成字符画和GIF两条线正好互补。1.3 环境准备与依赖安装先交代环境。我用的是Python 3.9装三个库就够了pip install matplotlib numpy pillow如果电脑里还没装Python去官网下载安装包时记得勾选“Add Python to PATH”。很多新手卡在这一步后面在命令行里敲python没反应就是PATH没配好。装完库验证一下import matplotlib import numpy import PIL print(ok)能打印ok就说明环境没问题。这个项目不需要GPU不需要虚拟环境普通笔记本完全跑得动。2. 从零画一张“东北味大姐头”matplotlib几何头像拆解2.1 先拆元素一张脸由哪些形状构成画人物最难的不是代码而是“把一个复杂形象拆成简单形状”。我的办法是先画一张草图然后逐个圈出几何元素脸一个椭圆宽度3.4高度4.6肤色填充头发脸两侧的深色多边形像两片鬓角头巾红色多边形罩在额头以上头巾结头顶的小圆眼睛两个黑色小椭圆加白色高光点眉毛两条粗黑线略微上扬显得有精神腮红两片半透明红色圆位置在颧骨嘴一条向上弯的弧线咧嘴笑耳朵脸两侧的小椭圆耳环金色圆环这是灵魂配饰身体领口以下一个深色多边形这种拆解方式不只适用于这个梗图任何卡通角色都可以这么干。核心思路是先确定画面中心再定五官的相对坐标最后补装饰。别一开始就纠结细节形状到位了气质自然就出来了。2.2 核心绘制代码与坐标系安排matplotlib的坐标系是数学坐标系原点在画布中间x向右为正y向上为正。我把头像中心放在(0, -0.2)整个画面范围控制在x: -4~4、y: -5~5这样所有元素都能舒舒服服地摆下。下面是核心绘制函数我把每个部分都封装进一个函数里方便后面做动画和批量生成时复用import random import matplotlib.pyplot as plt from matplotlib.patches import Circle, Ellipse, Polygon, Arc def draw_face(ax, seed0, skin#f6c89f, scarf#d0342c, cloth#2c3e50): rng random.Random(seed) face_w rng.uniform(3.2, 3.6) face_h rng.uniform(4.4, 4.8) ax.set_xlim(-4, 4) ax.set_ylim(-5, 5) ax.set_aspect(equal) ax.axis(off) # 背景圆框 bg Circle((0, 0), 5.6, facecolor#fdf6e3, edgecolorscarf, lw10, zorder0) ax.add_patch(bg) # 身体 body Polygon([(-1.8, -3.2), (1.8, -3.2), (1.4, -4.6), (-1.4, -4.6)], facecolorcloth, edgecolorblack, lw1, zorder1) ax.add_patch(body) # 脖子 neck Polygon([(-0.45, -2.3), (0.45, -2.3), (0.30, -3.2), (-0.30, -3.2)], facecolorskin, edgecolorblack, lw1, zorder2) ax.add_patch(neck) # 头发脸两侧的鬓角 for sign in (-1, 1): hair Polygon([(sign * 1.9, 0.2), (sign * 1.7, 2.2), (sign * 1.0, 0.5)], facecolor#3b2a1c, zorder3) ax.add_patch(hair) # 脸 face Ellipse((0, -0.2), face_w, face_h, facecolorskin, edgecolorblack, lw2, zorder4) ax.add_patch(face) # 耳朵 for sign in (-1, 1): ear Ellipse((sign * 1.9, -0.5), 0.5, 0.9, facecolorskin, edgecolorblack, lw1, zorder5) ax.add_patch(ear) # 耳环 for sign in (-1, 1): ring Circle((sign * 2.15, -1.1), 0.3, facecolor#f1c40f, edgecolor#8e6b00, lw1, zorder6) ax.add_patch(ring) hole Circle((sign * 2.15, -1.1), 0.12, facecolorwhite, zorder7) ax.add_patch(hole) # 眼睛 for sign in (-1, 1): eye Ellipse((sign * 0.75, 0.60), 0.44, 0.24, facecolorblack, zorder8) ax.add_patch(eye) light Circle((sign * 0.82, 0.68), 0.07, facecolorwhite, zorder9) ax.add_patch(light) # 眉毛两条粗线 ax.plot([-1.1, -0.3], [1.25, 1.05], lw8, colorblack, zorder10) ax.plot([0.3, 1.1], [1.05, 1.25], lw8, colorblack, zorder10) # 腮红 for sign in (-1, 1): blush Circle((sign * 1.35, -0.15), 0.45, facecolor#e74c3c, alpha0.3, zorder11) ax.add_patch(blush) # 鼻子 ax.plot([0], [-0.25], markero, markersize6, color#c1865c, zorder12) # 嘴向上弯的弧线 mouth Arc((0, -1.3), 1.2, 0.9, angle0, theta1200, theta2340, lw4, colorblack, zorder13) ax.add_patch(mouth) # 头巾 scarf_patch Polygon([(-2.2, 1.1), (-2.0, 2.8), (0, 3.4), (2.0, 2.8), (2.2, 1.1)], facecolorscarf, edgecolorblack, lw1.5, zorder14) ax.add_patch(scarf_patch) # 头巾结 knot Circle((0, 3.4), 0.45, facecolorscarf, edgecolorblack, lw1.5, zorder15) ax.add_patch(knot) return ax这段代码跑起来你就能得到一张完整的卡通大姐头头像。注意zorder参数它决定了谁盖在谁上面。头巾需要压住头顶但绝不能盖住眼睛所以头巾底部卡在y1.1左右正好在眉毛上方耳朵先画然后耳环叠在耳朵上五官再叠在最上层。坐标和层级安排是我调了十几遍才定下来的。2.3 头巾、耳环和腮红那些“一眼东北”的灵魂细节如果说脸型五官是基础那么头巾、耳环、腮红这三样就是整个头像的“气质担当”。我单独把它们拎出来说。头巾我用的不是标准半圆而是一个上宽下窄的五边形顶点还带一点弧度这样看起来更像扎在头上的布料而不是一顶钢盔。颜色选正红色#d0342c边缘加了一圈黑边让它在浅色背景上更突出。头顶那个小圆结是点睛之笔没有它头巾就少了“扎起来”的感觉。耳环用金色圆环配白色镂空这个细节非常小但作用很大。我试过去掉耳环整张脸立刻显得素了很多加上之后就有那种“干活利索但也要美”的气质。腮红则是半透明圆alpha0.3颜色不能太深否则像喝醉了太浅又看不出来这个透明度是反复调过的。这几个细节其实说明一件事卡通画的好看与否不在于形状复杂而在于特征是否鲜明。红色头巾、金耳环、大红腮红三样东西一上身不用看五官都知道画的是谁。3. 把矢量头像转成ASCII字符画3.1 灰度映射的数学原理画好的矢量头像是一张彩色图但字符画只能显示黑白字符。所以第一步是把彩色图转成灰度图。这背后的原理其实很简单人眼对红、绿、蓝三种颜色的敏感度不同纯平均(RGB)/3会让画面发灰发闷所以标准做法是用加权公式gray 0.299 * R 0.587 * G 0.114 * B这个公式在Pillow里已经内置了调用Image.open(...).convert(L)就是在做这件事。转成灰度之后每个像素变成0到255之间的整数0是最黑255是最白。接着把0~255这个区间映射到一串字符上。我会准备一个字符集按视觉重量从重到轻排列CHARS %#*-:. 最左边视觉上最“重”用来表示暗部最右边是空格表示亮部。映射关系就是字符索引 灰度值 * (字符集长度 - 1) // 255这个公式把像素值和字符下标一一对应起来。之所以用字符串的索引而不是if判断是因为这样既简洁又高效一行代码就能完成整行像素的映射。3.2 完整的图片转字符画脚本先用上一章的draw_face生成一张头像并保存为rainjie.pngif __name__ __main__: fig, ax plt.subplots(figsize(6, 8)) draw_face(ax) fig.savefig(rainjie.png, dpi100, bbox_inchestight)然后写一个独立的转换脚本from PIL import Image import numpy as np CHARS %#*-:. def img_to_ascii(img_path, out_width80): img Image.open(img_path).convert(L) # 字符在终端里的宽高比大约是 1:2 # 所以高度方向的采样要压缩一半否则画面会被拉高 ratio 0.5 out_height max(1, int(out_width * img.height / img.width * ratio)) img img.resize((out_width, out_height), Image.LANCZOS) data np.asarray(img) lines [] for row in data: line .join(CHARS[p * (len(CHARS) - 1) // 255] for p in row) lines.append(line) return \n.join(lines) if __name__ __main__: text img_to_ascii(rainjie.png, 80) print(text) with open(rainjie_ascii.txt, w, encodingutf-8) as f: f.write(text)注意这段脚本里的out_height计算。终端的等宽字符通常是上下高、左右窄的矩形比例大约1:2。如果直接把图片等比缩放转出来的字符画会显得又高又瘦所以高度方向要乘以0.5压缩一下。这个比例不是固定的不同终端、不同字体略有差异可以自己在0.45到0.6之间微调。3.3 输出宽度、字符集和终端对齐的调参经验字符画的观感主要受三个参数影响输出宽度、字符集、是否使用等宽字体。输出宽度out_width决定整幅画的细腻程度。宽度越大细节越丰富但太宽会撑爆终端。我建议先设80看效果再往100或120调。宽度超过120之后提升不明显反而因为字符间空隙变大显得稀疏。字符集的选择也很有讲究。我上面给的%#*-:. 只有10个字符属于精简版黑白对比强适合小尺寸输出。如果画面细节多、尺寸大可以换更长更平滑的字符集比如CHARS $B%8WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_~i!lI;:,\^. 这个集合有70多个字符灰度过渡更细腻但字符之间差异小远了看容易糊。我的经验是小图用短字符集大图用长字符集。最后一个容易忽略的点终端里跑字符画一定要用等宽字体。如果你用的是宋体、微软雅黑这类非等宽字体字符宽度不一样整幅画会歪歪扭扭像哈哈镜。把终端字体改成Consolas、Source Code Pro或者中文字体内的“等线”对齐问题就解决了。4. 让头像活起来眨眼、咧嘴和GIF导出4.1 用参数化的眼睛和嘴实现表情变化静态图看久了就想让它动起来。实现动画的思路不是重新画一张脸而是给关键参数留出变化空间。上一章的draw_face里眼睛高度是固定的0.24嘴巴弧线高度是固定的0.9。我把它改成参数def draw_face(ax, seed0, skin#f6c89f, scarf#d0342c, cloth#2c3e50, eye_h0.24, mouth_h0.9): # ... 省略其余部分 ... eye Ellipse((sign * 0.75, 0.60), 0.44, eye_h, facecolorblack, zorder8) # 注意eye_h 越小眼睛看起来就越“闭着” mouth Arc((0, -1.3), 1.2, mouth_h, angle0, theta1200, theta2340, lw4, colorblack, zorder13) # mouth_h 越大嘴张得越开眨眼就是把eye_h从0.24快速变成0.06再变回来嘴开合则是让mouth_h随时间周期性变化。这个思路可以推广到眉毛角度、头巾位置、腮红深浅每一个参数都能变成动画的一个维度。4.2 FuncAnimation保存GIF的两种方式matplotlib自带的动画模块是FuncAnimation最直接的用法是每帧清空坐标轴、重画一遍import numpy as np from matplotlib.animation import FuncAnimation, PillowWriter fig, ax plt.subplots(figsize(6, 8)) def update(frame): ax.clear() eye_h 0.06 if (frame % 20) 14 else 0.24 mouth_h 0.5 0.4 * abs(np.sin(frame * 0.6)) draw_face(ax, eye_heye_h, mouth_hmouth_h) return [] ani FuncAnimation(fig, update, frames40, interval80) ani.save(rainjie_anim.gif, writerPillowWriter(fps12))这种“每帧清空重画”的方式代码最简单逻辑也直观保存GIF完全够用。缺点是实时显示时会闪烁但既然目标是导出GIF文件这点不是问题。如果你不想依赖matplotlib的动画writer也可以自己逐帧保存PNG再用Pillow合成GIFfrom PIL import Image frames [] for frame in range(30): ax.clear() eye_h 0.06 if (frame % 20) 14 else 0.24 mouth_h 0.5 0.4 * abs(np.sin(frame * 0.6)) draw_face(ax, eye_heye_h, mouth_hmouth_h) fig.savefig(ftmp_{frame:02d}.png, dpi80) frames.append(Image.open(ftmp_{frame:02d}.png)) frames[0].save(rainjie_anim.gif, save_allTrue, append_imagesframes[1:], duration100, loop0)第二种方式更可控你可以对每一帧做额外处理比如加文字、裁剪而且不依赖matplotlib版本对writer的支持。缺点是要临时生成一堆PNG文件用完记得清理。4.3 Windows下运行脚本闪退的排查经验这个项目我是在Windows上开发的踩了一个很经典的坑写好脚本后直接双击.py文件运行窗口一闪而过啥也看不到。这不是代码写错了而是Python脚本在Windows下运行时解释器执行完毕就会自动关闭窗口。解决办法有两个第一在脚本末尾加一行input(按回车退出...)让窗口停在那边等人按键。第二用命令行运行。打开cmd或PowerShell切到脚本目录敲python 你的脚本名.py这样就算报错错误信息也会留在终端里方便排查。如果你用pythonw运行那更是什么输出都不显示不建议新手用。另外还有一种情况plt.show()之后程序卡住不动或者窗口不刷新。这通常是matplotlib的后端backend没选对。可以强制指定import matplotlib matplotlib.use(TkAgg)然后再导入pyplot。TkAgg是Windows下最稳的后端之一遇到显示问题优先试它。5. 命令行化的批量头像生成器5.1 参数化设计把颜色、脸型变成变量静态画一张图只是入门真正让这个项目“活”起来的是参数化。所谓参数化就是把画面里的每个特征都变成变量。颜色是最容易参数化的。肤色、头巾色、衣服色每样我准备几个候选SKINS [#f6c89f, #f1c27d, #e0ac69, #c68642] SCARFS [#d0342c, #e67e22, #8e44ad, #16a085, #3498db] CLOTHES [#2c3e50, #7f8c8d, #a93226, #1a5276]然后用random.choice随机选。更进一步脸型宽窄、眼睛位置、嘴巴大小也可以用random.uniform在合理范围内随机浮动rng random.Random(seed) face_w rng.uniform(3.0, 3.8) # 脸宽 eye_y rng.uniform(0.50, 0.75) # 眼睛高度 mouth_h rng.uniform(0.7, 1.2) # 嘴巴张开程度这些浮动幅度不能太大否则五官会跑出脸的范围。我的经验是控制在基准值的正负15%以内既有变化又不会崩。5.2 用argparse做成可复用的命令行工具参数化之后我想让这个工具可以接受外部参数而不是每次改代码。Python标准库的argparse派上了用场import argparse import os import random import matplotlib.pyplot as plt def parse_args(): parser argparse.ArgumentParser(description批量生成东北味大姐头头像) parser.add_argument(--count, typeint, default6, help生成数量) parser.add_argument(--outdir, defaultfaces, help输出目录) parser.add_argument(--seed, typeint, default42, help随机种子) return parser.parse_args() def main(): args parse_args() os.makedirs(args.outdir, exist_okTrue) random.seed(args.seed) for i in range(args.count): fig, ax plt.subplots(figsize(6, 8)) color random.choice(SKINS) scarf random.choice(SCARFS) cloth random.choice(CLOTHES) seed random.randint(0, 999999) draw_face(ax, seedseed, skincolor, scarfscarf, clothcloth) fig.savefig(os.path.join(args.outdir, fface_{i:02d}.png), dpi100, bbox_inchestight) plt.close(fig) print(f生成: face_{i:02d}.png) if __name__ __main__: main()这样在命令行里就能直接控制生成数量python generate_faces.py --count 10 --outdir faces --seed 2024设置--seed很重要。随机种子固定之后哪怕换台电脑生成的图像序列也完全一样方便复现。调试的时候强烈建议固定一个种子不然每次跑出来都不一样很难对照排查。5.3 拼接头像墙和成果展示批量生成之后还能把它们拼成一张“头像墙”一眼看过去非常有成就感。拼接思路是用matplotlib的子图网格import glob from PIL import Image def make_grid(image_dirfaces, cols3): files sorted(glob.glob(os.path.join(image_dir, *.png))) images [Image.open(f) for f in files] rows (len(images) cols - 1) // cols thumb_w 300 thumb_h 400 grid Image.new(RGB, (thumb_w * cols, thumb_h * rows), white) for idx, img in enumerate(images): img img.resize((thumb_w, thumb_h), Image.LANCZOS) x (idx % cols) * thumb_w y (idx // cols) * thumb_h grid.paste(img, (x, y)) grid.save(avatar_wall.png) print(头像墙已保存: avatar_wall.png) if __name__ __main__: make_grid()这里有个小坑Image.new创建的是RGB模式贴图前先把每张头像缩放到统一尺寸。直接贴不缩放会报错或者排版错乱。做头像墙时统一高度宽度是第一步否则整个网格歪七扭八。6. 收尾这个项目里我踩过的坑和几个建议6.1 matplotlib中文乱码和颜色取值问题这个项目虽然全程没用到中文标签但我最初往里加标题时踩过乱码的坑。matplotlib默认字体不含中文字形直接写ax.set_title(东北雨姐)会显示成方块。解决方法是切换字体plt.rcParams[font.sans-serif] [Microsoft YaHei, SimHei] plt.rcParams[axes.unicode_minus] False第二行是处理负号问题的不设的话坐标轴负号会显示成乱码。如果你用macOS字体名换成PingFang SC或Arial Unicode MS。颜色取值方面建议不要用red、blue这种命名颜色一是可选少二是显示效果偏“塑料感”。用十六进制色值更精确。我常用肤色#f6c89f、正红#d0342c、深色#2c3e50这几种都是直接可以在浏览器里预览的调起来很方便。6.2 Pillow与matplotlib联动的几个坑Pillow和matplotlib都是图像处理常用的库但联用时要注意三件事。第一Pillow的resize默认使用双线性插值缩小时容易产生锯齿。转字符画这种对清晰度敏感的场景请务必用Image.LANCZOS。新版本Pillow还支持Image.Resampling.LANCZOS两种写法我都见过推荐后者更语义化。第二matplotlib保存的PNG是RGB模式但Pillow处理灰度时会自动转成L模式这一步不会报错但如果你之后又想保存成彩色图需要先convert(RGB)否则部分操作会提示模式不支持。第三用Pillow拼GIF时帧尺寸必须完全一致。如果某一帧画布大小不一样保存时要么报错要么画面跳动。我前面make_grid里统一缩放的思路同样适用于GIF帧处理。6.3 画风提升的后续思路这个项目做到最后只用了椭圆、多边形、弧线这几类基础图形但已经能画出有辨识度的卡通脸。如果想继续提升有几个方向可以延伸。一个是增加背景元素比如加几根麦穗、加个红太阳或者给头巾换图案。背景会大大增强画面的故事感但也要注意别抢了主体的风头。另一个是给五官加“骨骼”现在眼睛只是椭圆嘴只是弧线如果换成贝塞尔曲线就能画出更丰富的表情。这需要用到PathPatch和Path对象matplotlib完全支持。还有一个很实用的方向把draw_face这个函数打包成一个小模块供其它脚本导入。我在做壁纸批量生成时就是直接from draw_face import draw_face然后配合字符串模板把随机头像和文字排版放在一张图上效果非常好。我现在电脑里还存着一堆当时生成的随机头像每次看到都觉得这项目虽然是从一个玩梗标题开始的但真的把matplotlib绘图、Pillow图像处理、动画保存、命令行参数解析这些知识点全串起来了。我学东西一向靠这种“先找个想画的东西再硬着头皮画出来”的路子效率比对着文档啃高得多。你要是也有一直想画的梗图不妨照着这套流程自己搓一个。
返回列表