1. 海报批量生成里文字模糊和底栏不透明到底卡在哪做电商运营或者量化周报的朋友大概率都遇到过这个场景手里有一张设计好的背景图需要把每天变动的数据、标题、活动文案批量叠加上去生成几十上百张分享卡片。听起来是个很简单的需求但真动手写脚本的时候问题一个接一个冒出来。最典型的就是文字模糊。很多人第一反应是用 OpenCV 的putText直接往上写结果发现英文字母边缘全是锯齿中文压根写不出来——因为putText只支持 Hershey 点阵字体根本不认 TTF 文件。就算勉强用上了字号一大边缘的毛刺感在手机屏幕上特别明显跟设计稿的质感差了十万八千里。第二个坑是底栏不透明。为了让文字在复杂背景上能看清通常会在文字下面垫一条深色底栏。但如果直接用纯色矩形整张图会显得很生硬像贴了块膏药。想要那种半透明、带点渐变过渡的“磨砂玻璃”效果用 OpenCV 得手动分离 RGB 通道再算加权公式代码又长又难调。第三个坑是文字换行。中英文混排的时候半角和全角字符宽度不一样如果按固定字数切分排出来的文字参差不齐长的溢出背景短的又留一大片空白。这个问题的本质是你得按像素宽度来算而不是按字符个数来算。这篇内容就是围绕这三个痛点来的。我会用 Cursor 配合 Pillow 写一套可复用的海报合成脚本核心解决三件事按像素宽度自动折行、RGBA 半透明渐变底栏、8 方向偏移描边加投影。最后再通过 TaoToken 的统一 Key 把脚本接进去实现多图批处理调用。适合谁看做自动化运营、需要批量出图、又不想依赖设计软件手动改的开发者。2. 用 TaoToken 统一 Key 接管脚本调用省掉多平台配置的麻烦在正式写 Pillow 代码之前先把调用链路理清楚。我们这套脚本最终是要跑批处理的可能今天处理 50 张明天处理 200 张。如果每次都要手动改脚本里的参数、或者在不同平台之间来回切换 Key效率会非常低。TaoToken 在这里的角色是一个统一的模型调用入口。你可以把它理解成一个“总开关”不管底层用的是哪个模型、哪个服务对外都只暴露一个 Base URL 和一个 API Key。对于我们的海报脚本来说这意味着可以把“生成文案”和“合成图片”两个环节串起来——先用统一 Key 调模型生成每天的标题和正文再把返回的文本喂给 Pillow 脚本出图。具体来说TaoToken 的 API 地址是https://taotoken.net/api官网入口在https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content。你需要在控制台里创建一个 API Key这个 Key 就是后面所有请求的凭证。这里有个细节要注意TaoToken 兼容 OpenAI 风格的接口格式所以你在代码里配置的时候base_url填https://taotoken.net/apiapi_key填你申请到的那个字符串模型 ID 按你实际要用的填。这三件套Base URL Key Model ID是后面所有调用的基础缺一不可。为什么不用直连某个单一平台因为批量出图这个场景里文案生成和图像处理是两条线。文案可能今天用这个模型、明天换那个模型如果每个都单独配 Key、单独改代码维护成本太高。统一 Key 的好处就是换模型的时候只改一个 Model ID 参数其他都不用动。另外如果你后面要把这套脚本做成定时任务或者接到 CI 流程里统一 Key 也方便做权限管理和用量统计。控制台里能看到每个 Key 的调用情况排查问题的时候有据可查。3. 可复制的 Pillow 绘制配置字体、锚点、RGBA 底栏一次配好这一节是核心直接上可复制的配置和代码。整个脚本我拆成三个部分底栏渐变叠加、文字自动折行、高精度描边绘制。你可以把下面的代码存成poster_engine.py后面直接 import 用。先看依赖。在虚拟环境里装这两个pip install Pillow10.0.1 pydantic2.3.0Linux 环境下如果渲染 TTF 报错先补一个系统库sudo apt-get install libfreetype6-dev然后是核心配置。我习惯把字体路径、颜色、尺寸这些抽成一个 JSON 配置方便批量任务里动态替换。存成poster_config.json{ font_path: /System/Library/Fonts/PingFang.ttc, font_size: 24, line_spacing: 1.3, text_color: [255, 255, 255, 255], stroke_color: [12, 18, 24, 255], stroke_width: 2, shadow_offset: 3, shadow_alpha: 100, overlay: { x: 50, y: 700, w: 700, h: 220, start_alpha: 200, end_alpha: 20, fill_rgb: [18, 24, 38] } }注意font_path这里写的是 macOS 的苹方字体路径。Windows 下换成C:\\Windows\\Fonts\\msyh.ttcLinux 下换成/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf或者你项目里自带的思源黑体。千万不要硬编码一个不存在的路径否则 Pillow 会直接抛OSError: cannot open resource。接下来是引擎类。核心逻辑我写在下面关键位置加了注释# -*- coding: utf-8 -*- from PIL import Image, ImageDraw, ImageFont import os import logging logging.basicConfig(levellogging.INFO, format%(asctime)s - [%(levelname)s] - %(message)s) logger logging.getLogger(poster_engine) class PosterGenerator: def __init__(self, background_path: str): if not os.path.exists(background_path): raise FileNotFoundError(f未找到背景底图: {background_path}) self.bg_img Image.open(background_path).convert(RGBA) self.width, self.height self.bg_img.size logger.info(f底图加载成功尺寸: {self.width}x{self.height}) def _auto_wrap_text(self, text: str, font: ImageFont.FreeTypeFont, max_width: int) - list: lines [] current_line for char in list(text): test_line current_line char bbox font.getbbox(test_line) line_width bbox[2] - bbox[0] if line_width max_width: current_line test_line else: if current_line: lines.append(current_line) current_line char if current_line: lines.append(current_line) return lines def add_gradient_overlay(self, x, y, w, h, start_alpha180, end_alpha0, fill_rgb(18, 24, 38)): overlay Image.new(RGBA, self.bg_img.size, (0, 0, 0, 0)) draw ImageDraw.Draw(overlay) for offset in range(h): current_y y offset alpha int(start_alpha - ((start_alpha - end_alpha) * (offset / h))) draw.rectangle( [x, current_y, x w, current_y 1], fill(fill_rgb[0], fill_rgb[1], fill_rgb[2], alpha) ) self.bg_img Image.alpha_composite(self.bg_img, overlay) logger.info(f渐变底栏叠加完成Y{y}, 高度{h}px) def draw_high_fidelity_text(self, text, x, y, font_path, font_size, max_width, text_color(255, 255, 255, 255), stroke_color(0, 0, 0, 255), stroke_width2, line_spacing1.3, shadow_offset3, shadow_alpha100): if not os.path.exists(font_path): raise FileNotFoundError(f未找到字体文件: {font_path}) font ImageFont.truetype(font_path, font_size) wrapped_lines self._auto_wrap_text(text, font, max_width) draw ImageDraw.Draw(self.bg_img) current_y y sample_bbox font.getbbox(高) line_height int((sample_bbox[3] - sample_bbox[1]) * line_spacing) for line in wrapped_lines: draw.text((x shadow_offset, current_y shadow_offset), line, fill(0, 0, 0, shadow_alpha), fontfont) if stroke_width 0: for dx in range(-stroke_width, stroke_width 1): for dy in range(-stroke_width, stroke_width 1): if dx ! 0 or dy ! 0: draw.text((x dx, current_y dy), line, fillstroke_color, fontfont) draw.text((x, current_y), line, filltext_color, fontfont) current_y line_height total_height current_y - y logger.info(f文字绘制完成共 {len(wrapped_lines)} 行占用高度 {total_height}px) return total_height def save_poster(self, output_path: str, quality: int 95): final_jpg Image.new(RGB, self.bg_img.size, (255, 255, 255)) final_jpg.paste(self.bg_img, maskself.bg_img.split()[3]) final_jpg.save(output_path, JPEG, qualityquality) logger.info(f海报已导出: {output_path})几个关键点解释一下。_auto_wrap_text里用的是font.getbbox()来算像素宽度而不是len()。这是解决中英文混排参差不齐的核心——每个字符的物理宽度不一样必须按像素算。add_gradient_overlay里逐行画 1 像素高的矩形每行透明度线性递减这样出来的底栏是渐变的不是一块死板的纯色。描边部分用了 8 方向偏移比 Pillow 自带的stroke_width参数效果更细腻尤其是在高亮背景上。4. 跑一遍验证检查文字边缘锐度和底栏透明度代码写完了得实际跑一遍看效果。我写一个测试脚本先生成一张模拟背景图再调用引擎出图。# -*- coding: utf-8 -*- from poster_engine import PosterGenerator from PIL import Image, ImageDraw import os def create_mock_background(path: str): img Image.new(RGB, (800, 1000), color(135, 206, 250)) draw ImageDraw.Draw(img) draw.ellipse([200, 300, 700, 700], fill(255, 255, 255)) img.save(path) def get_system_font(): candidates [ /System/Library/Fonts/PingFang.ttc, C:\\Windows\\Fonts\\msyh.ttc, /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf, ] for p in candidates: if os.path.exists(p): return p raise FileNotFoundError(未检测到系统字体请手动指定 TTF 路径) def main(): mock_bg mock_bg.png output share_poster.jpg create_mock_background(mock_bg) try: font_path get_system_font() gen PosterGenerator(mock_bg) gen.add_gradient_overlay(x50, y700, w700, h220, start_alpha200, end_alpha20) text 【量化策略】今日成交汇报大盘主力资金在券商板块持续净流入策略触发加仓信号当前累计净值 1.352Sharpe 比例 1.68风险处于安全水位。 gen.draw_high_fidelity_text( texttext, x80, y720, font_pathfont_path, font_size24, max_width640, text_color(255, 255, 255, 255), stroke_color(12, 18, 24, 255), stroke_width2 ) gen.save_poster(output) finally: if os.path.exists(mock_bg): os.remove(mock_bg) if __name__ __main__: main()运行之后控制台会输出类似这样的日志2026-06-22 13:30:00 - [INFO] - 底图加载成功尺寸: 800x1000 2026-06-22 13:30:00 - [INFO] - 渐变底栏叠加完成Y700, 高度220px 2026-06-22 13:30:00 - [INFO] - 文字绘制完成共 4 行占用高度 130px 2026-06-22 13:30:01 - [INFO] - 海报已导出: share_poster.jpg打开生成的share_poster.jpg重点检查两个地方。第一文字边缘。放大到 200% 看白色文字外围有一圈深色描边边缘是平滑的没有锯齿感。第二底栏透明度。底栏从下往上透明度逐渐降低顶部几乎透明底部是深蓝黑色过渡自然不会像纯色块那样突兀。如果文字刚好压在背景的白色椭圆上因为有描边和投影依然能看清。这就是 8 方向偏移描边的作用——它相当于给文字加了一圈“保护色”不管背景是亮是暗文字都能保持可读性。验证通过之后就可以把这段逻辑接到 TaoToken 的调用链里了。用统一 Key 调模型生成文案把返回的文本传给draw_high_fidelity_text再批量循环处理多张背景图。整个流程跑通之后每天出几十张海报就是几秒钟的事。5. 常见报错排查401、字体乱码、底栏不透明怎么修实际跑的时候大概率会遇到下面几个报错。我按出现频率排一下对照着改就行。报错一OSError: cannot open resource这个是最常见的原因就一个font_path指向的文件不存在。Pillow 加载字体的时候不会帮你做路径模糊匹配写错了就直接抛。解决办法是把字体文件放到项目目录里用相对路径加载比如resources/fonts/SourceHanSans.ttf。这样不管换到哪台机器只要项目目录结构不变就不会出问题。另外注意 Windows 下路径要用双反斜杠或者正斜杠单反斜杠会被当成转义字符。报错二401 Unauthorized或local proxy failed如果你在调用 TaoToken 接口的时候看到 401先检查三件套Base URL 是不是https://taotoken.net/apiAPI Key 有没有复制完整前后不要有空格Model ID 是不是控制台里实际可用的。local proxy failed通常是本地网络配置的问题检查一下系统代理设置确保请求能正常发出去。这两个报错在接入文档里都有对照说明遇到的时候直接查文档比瞎试快。报错三文字渲染出来是方块或者问号这是字体不含中文字模导致的。DejaVuSans、Arial 这些英文字体里没有汉字FreeType 找不到对应的字形就会渲染成“豆腐块”。解决办法是统一换成支持中文的字体推荐思源黑体或者阿里巴巴普惠体这两个都是免费商用的直接打包进项目就行。报错四底栏看起来还是纯色没有渐变检查add_gradient_overlay里的start_alpha和end_alpha是不是设成了同一个值。如果两个值相等透明度就不会变化出来就是纯色。另外确认一下h参数是不是太小高度太小的话渐变区间短肉眼不容易看出来。一般底栏高度在 150px 以上渐变效果会比较明显。报错五reading choices相关错误这个通常出现在解析模型返回结果的时候。如果你用统一 Key 调模型生成文案返回的 JSON 结构里choices字段是空的或者格式不对就会报这个。检查一下请求参数里的model和messages是不是符合接口要求另外确认返回内容有没有被截断。排查的时候有个小技巧先把stroke_width设成 0把底栏start_alpha和end_alpha设成一样的值跑一遍看基础文字能不能正常显示。基础没问题了再逐步加上描边和渐变这样能快速定位是哪一层出的问题。6. 把脚本接进 TaoToken批量出图就三步整套流程跑通之后批量处理其实就三步准备背景图列表、调模型生成文案、循环调用合成引擎。先申请 API Key入口在控制台的 API Keys 页面。拿到 Key 之后用统一入口调模型生成每天的文案import requests API_BASE https://taotoken.net/api API_KEY 你的_API_Key def generate_copy(prompt: str, model_id: str 你的模型ID): headers { Authorization: fBearer {API_KEY}, Content-Type: application/json } payload { model: model_id, messages: [{role: user, content: prompt}] } resp requests.post(f{API_BASE}/v1/chat/completions, headersheaders, jsonpayload, timeout30) resp.raise_for_status() return resp.json()[choices][0][message][content]拿到文案之后循环处理背景图import glob for bg_path in glob.glob(backgrounds/*.png): copy_text generate_copy(生成一条 80 字以内的今日策略播报) gen PosterGenerator(bg_path) gen.add_gradient_overlay(x50, y700, w700, h220, start_alpha200, end_alpha20) gen.draw_high_fidelity_text( textcopy_text, x80, y720, font_pathresources/fonts/SourceHanSans.ttf, font_size24, max_width640 ) output_name foutput/{os.path.basename(bg_path).replace(.png, .jpg)} gen.save_poster(output_name)如果你后面要做成长期跑的定时任务或者接进 Agent 流程里可以考虑用 Coding Plan 来管理调用配额和模型切换。模型对话入口适合先验证文案生成的效果确认没问题了再批量跑。最后说一个实测下来的经验字体对象不要在循环里反复加载。ImageFont.truetype()每次调用都会读磁盘几百张图跑下来光加载字体就能占掉不少时间。把 Font 对象在循环外面初始化好传进去复用速度会快很多。这个坑我踩过改完之后批处理时间直接砍了一半。