ARTICLE DETAIL

资讯详情

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

图片添加文字全攻略:Pillow与ImageMagick实战指南

图片添加文字全攻略:Pillow与ImageMagick实战指南 在图片处理这个圈子里有一个看着平平无奇、实际操作起来全是坑的需求给图片文件添加文本内容。你可能会觉得不就是往图上写几个字吗但真等你上手做的时候字体乱码、文字位置漂移、批量处理效率低、保存后画质变差……每一个问题都能让你怀疑人生。这篇文章我就把我这些年处理类似需求的经验完整梳理一遍从思路到工具再到代码一次讲透。1. 先搞清楚需求你需要的到底是哪种“加文字”1.1 三种常见场景与对应的技术方案给图片文件添加文本内容看起来是一件事实际上它背后对应着三种完全不同的需求技术手段也截然不同。第一种也是最常见的一种是给图片加水印。比如你拍了一些原创照片要发到社交平台或者做了一些设计图要发给客户确认为了防止被随意盗用需要在图片上打上你的网名、Logo或者一句版权说明。这类需求对文字的要求是清晰可见、不影响主体内容、最好是半透明的、位置固定一般放在角落。第二种是给图片加说明性文字。比如在电商平台上处理商品图需要在图上标注“新到货”“限时优惠”“包邮”等促销信息或者在摄影作品上加上拍摄地点、拍摄参数等辅助信息。这种情况下文字需要比较醒目往往还要配合一些颜色、字体样式来保证视觉效果。第三种是批量化、程序化地给图片添加文字。比如后台需要自动给用户上传的头像打上ID水印或者需要给一批商品图统一加上分类名称和价格标签这个时候不可能靠修图工具一张张手动改必须用代码批量处理。你看到区别了吗水印场景讲究“克制”说明场景讲究“突出”而批量场景讲究“效率和一致”。很多人一上来就直接搜索“给图片加文字软件”然后被一堆在线工具的广告淹没结果导出来的图要么分辨率不对要么文字位置怎么调都不顺手。所以我建议你先明确自己属于哪种场景再来选工具。1.2 确定技术选型的核心逻辑选什么样的方案来给图片加文字核心就看三个维度你要处理的图片数量、你对文字排版的控制精度、以及你是否需要把这件事集成到自动化的流程里。如果只是偶尔一两张图直接用 PS 或者在线网页工具手点几下就行没有必要写代码。但如果你需要每周处理上百张图而且每张图的文字内容还不太一样那手工操作就完全不现实了。我见过很多运营人员用 PS 的“动作”功能批量加水印但一旦遇到图片尺寸不统一、文字内容需要变化的情况动作录制的方案就崩了。还有一个容易被忽视的维度是文字渲染的控制精度。用 PS 或者说美图秀秀加文字位置全靠鼠标拖精度完全凭手感。但如果你是通过代码给图片添加文本内容文字相对于图片的位置、间距、透明度、旋转角度全都是精确到像素的。这个精度在某些场景下是硬性要求。比如给合同扫描件上加“仅供参考”的水印要求斜着铺满整张页面角度必须严格是45度手工拖出来的效果总是差那么一点意思。所以我个人建议如果你有这个需求出现的频次比较高哪怕你完全不懂编程也值得花一点时间学会用 Python 的 Pillow 库或者 ImageMagick 命令行工具来做这件事。一次性投入的时间成本不高但后面能帮你省下大量的重复劳动。接下来我会把这两个方案的核心操作都讲透。2. 工具准备与环境搭建两条腿走路2.1 Python Pillow 方案最灵活的编程处理方式如果你选 Python 方案核心依赖就是 Pillow它是 Python 图像处理库 PIL 的活跃分支版本接口稳定文档丰富是目前在 Python 里处理图片最主流的库。安装非常简单只需要一行命令pip install pillow但这里有两个容易踩坑的细节。第一个是 Python 环境的版本问题。Pillow 对 Python 版本有最低要求如果你用的是比较老的 Python 3.6 或更低版本可能装不上最新版的 Pillow建议优先使用 Python 3.8 及以上版本。第二个是如果你在服务器上操作建议先创建一个干净的虚拟环境来安装避免和系统里其他 Python 包产生依赖冲突。装好之后验证一下是否能用写一个两行的小脚本测试from PIL import Image img Image.open(example.jpg) print(img.size)如果能正常输出图片的宽和高就说明环境没有问题。这里我提醒一句Pillow 默认不支持打开的图片太大如果你要处理超高分辨率的图片比如一两亿像素的全景图可能需要在打开时先设置Image.MAX_IMAGE_PIXELS None否则会直接报错提示图片过大这是为了防范恶意图像解压炸弹的安全机制但在处理自有的高清图片时确实会造成困扰。2.2 ImageMagick 命令行方案不写代码的批处理利器如果说 Pillow 是给你编程用的那 ImageMagick 则是不写代码也能高效工作的命令行工具。它是一个老牌的图像处理软件支持超过200种图片格式给图片添加文本内容只是它众多能力中的一个。Windows 用户需要去官网下载安装包安装时注意勾选“Add application directory to your system path”这个选项否则在命令行里用不了convert命令。macOS 用户推荐用 Homebrew 安装brew install imagemagickLinux 用户则可以根据发行版使用对应的包管理器比如sudo apt-get install imagemagick安装完成后在终端输入convert -version能输出版本信息就说明成功了。这里有一个反而容易出问题的点ImageMagick 有一个安全策略文件policy.xml部分默认配置会限制某些操作比如读取 PDF 或执行部分字体渲染如果后续你执行命令时遇到 “not authorized” 或者“attempt to perform an operation not allowed by the security policy” 的报错就可以去这个文件里调整对应的权限配置。我为什么建议两个方案都掌握因为实际工作中它们各有适用场景。你在自己电脑上临时处理几张图大概率用 ImageMagick 更快但如果你需要把“给图片加文字”这个能力集成到 Web 服务、自动化脚本或者数据分析流程中去那用 Python 的 Pillow 更顺滑。两条腿走路走到哪儿都不会被卡住。3. 核心实现用代码给图片添加文本内容的关键环节3.1 Pillow 基础实现从加载图片到写出第一段文字我们先看一段最基础的给图片添加文本内容的 Pillow 代码然后逐步拆解其中的关键点。from PIL import Image, ImageDraw, ImageFont # 打开原始图片 img Image.open(input.jpg).convert(RGBA) width, height img.size # 创建绘图对象 draw ImageDraw.Draw(img) # 加载字体注意这里的TTF字体路径需要替换为你本机实际存在的字体文件 font ImageFont.truetype(simhei.ttf, size48) # 定义文字内容 text 示例文字 # 计算文字实际占用的宽高用于定位 bbox draw.textbbox((0, 0), text, fontfont) text_width bbox[2] - bbox[0] text_height bbox[3] - bbox[1] # 设置文字位置比如放到右上角边距为20像素 margin 20 x width - text_width - margin y margin # 绘制文字 draw.text((x, y), text, fontfont, fill(255, 255, 255, 255)) # 保存结果 img.convert(RGB).save(output.jpg, quality95)这段代码看着简单但里面至少有四个细节值得说道说道。第一个细节是为什么要执行.convert(RGBA)。如果你的原始图片是 JPG 格式它本身没有 alpha 通道直接绘制带透明度的颜色时表现会不符合预期而转换成 RGBA 模式后每个像素都有红、绿、蓝、透明度四个维度后续要做半透明水印就特别方便。不过要注意的是保存 JPG 格式时最终需要再把 RGBA 转换回 RGB因为 JPG 格式不支持 alpha 通道。第二个细节是字体文件的路径问题。上面的代码里用了simhei.ttf也就是“黑体”但这个文件并不是所有系统都自带。Windows 系统常见可用的是C:/Windows/Fonts/simhei.ttf或者msyh.ttc微软雅黑macOS 系统则可以使用/System/Library/Fonts/PingFang.ttc苹方。如果字体路径填错了Pillow 会直接报OSError: cannot open resource这个错误信息特别的直白和误导你会以为图片文件损坏了其实只是找不到字体文件。第三个细节是获取文字尺寸的方式。注意我用了draw.textbbox((0,0), text, fontfont)而不是font.getsize(text)。后者在旧版本 Pillow 中常用但已经被标记为过时尤其是在处理中文或复杂字体时返回的宽度和高度可能会比实际渲染结果偏小导致文字靠边太近甚至被裁剪。用textbbox获取的是实际绘制区域的包围盒计算更准确推荐优先使用。第四个细节是填充颜色fill(255,255,255,255)。这里的四个值分别代表 R、G、B、A也就是红、绿、蓝和透明度。前三个值决定颜色最后一个值为0时文字完全透明为255时完全不透明。如果你想做半透明水印把最后的 A 值改成 128 左右就会出现类似 PS 里降低不透明度的效果。3.2 进阶文字效果半透明水印、描边、阴影一次搞定基础的文字绘制只是入场真正让“给图片添加文本内容”这项工作看起来专业的是几个进阶的文字效果。我在实际项目中用得最多的是三个半透明水印、文字描边、阴影文字。半透明水印的实现方式是用 RGBA 颜色加透明度但这只是表面。真正的问题是半透明水印颜色太浅容易被涂抹掉太深又会遮挡图片主体。我的经验是用作版权保护的水印透明度值建议在 100-150 之间颜色用纯白或纯黑即可既有存在感又不会过度干扰视觉。如果还需要更强的抗涂抹性能可以把一段文字在图片上重复平铺排列这个后面在批量技巧中再展开。文字描边的效果主要依赖stroke_width和stroke_fill这两个参数。这两个参数是 Pillow 自带的用起来非常简单draw.text((x, y), text, fontfont, fill(255, 255, 255, 255), stroke_width2, stroke_fill(0, 0, 0, 255))这段代码会在白色文字的边缘画上一条2像素宽的黑色描边让文字在浅色背景上依然清晰可读。这个技巧在处理商品促销标签时特别好用不需要额外的背景色块画面也更干净。阴影文字的实现稍微麻烦一点。核心思路是先在稍微偏下的位置用半透明的黑色绘制一遍文字然后在原始位置再绘制一遍正常颜色的文字利用视觉差制造出阴影效果。代码如下# 先绘制阴影 shadow_offset 3 draw.text((x shadow_offset, y shadow_offset), text, fontfont, fill(0, 0, 0, 128)) # 再绘制前景文字 draw.text((x, y), text, fontfont, fill(255, 255, 255, 255))这里有个在实际项目中很容易踩的坑如果你处理的是海量图片每一张都创建一次ImageDraw对象和加载一遍字体性能会非常低下。在我的一个批量处理项目中总共要处理8000张图片最初版本的代码每张图都重复加载字体最终耗时将近40分钟。优化过后的方案是把字体加载和绘图对象的创建放到循环外面只用同一套资源去处理所有图片耗时直接降到了9分钟。所以注意一点字体对象和绘图对象一旦创建尽量复用。3.3 字体选择的坑想要的效果和实际效果的距离字体选择的坑恐怕是我见过新手栽跟头最多的地方。很多人用 Pillow 给图片添加文本内容发现输出的中文全是方块或者乱码第一反应往往是怀疑编码有问题实际上90%的情况是字体文件的问题。Pillow 本身不内置任何字体文件它只负责把字体文件里的字形渲染到图片上。如果系统里没有中文字体或者代码里指定的字体文件路径不对Pillow 就会退回到默认字体而默认字体通常不支持中文字符于是渲染结果就是一个个的空心方框。解决方式很简单就是显式指定一个支持中文的 TTF 或 TTC 字体文件。在我这里比较推荐的是思源黑体它是开源字体质量高、商业使用无版权风险而且有不同字重可以选比如 Regular、Bold、Medium下载后放到项目目录的 fonts 文件夹里代码中通过相对路径引用即可这样项目换到别的电脑上也能正常运行不会因为系统缺少字体而崩溃。另外一个字体相关的坑是字体大小的适配问题。如果你处理的多张图片尺寸差异非常大比如有的图片是 800x600有的是 4000x3000固定字体大小就会出现大字图文字显得很小的效果。更合理的做法是让字体大小和图片宽度形成比例关系比如font_size int(width / 20) font ImageFont.truetype(font_path, font_size)这里的width / 20是一个经验值你可以根据实际视觉效果调整分母大小分母越大文字越小。这个方法在我的实际项目中表现很好能保证不同尺寸下文字的视觉占比保持稳定。4. 实操过程中的问题排查与避坑指南4.1 文字位置计算偏差为什么总是差那么几像素文字位置的计算偏差是另一个高频问题。明明按照坐标写了draw.text((x, y), text, fontfont)但渲染出来的文字就是不在想要的位置有时候上下偏移有时候左右不对称。这里有一个核心概念需要理解Pillow 的text()方法中(x, y) 指的是文字包围盒的左上角坐标而不是文字的基线坐标。对于英文字符来说基线是文字底部对齐的那条线会存在有部分字符超出基线的情况比如“g”“j”“y”这些字母的下半部分对于中文来说所有字符的视觉范围相对统一但也不是正好填满整个包围盒的。所以如果你需要把文字精确地居中在某个区域不能直接算好中心点就传进去。正确做法是先算出文字的实际宽高然后计算左上角坐标center_x width // 2 center_y height // 2 bbox draw.textbbox((0, 0), text, fontfont) tw bbox[2] - bbox[0] th bbox[3] - bbox[1] x center_x - tw // 2 - bbox[0] y center_y - th // 2 - bbox[1] draw.text((x, y), text, fontfont, fillfill_color)这里减去bbox[0]和bbox[1]是很多教程不会讲到的细节。因为textbbox返回的包围盒坐标可能是负值或者带偏移的如果你不减去这个偏移做“严格居中”时文字会整体偏差几个像素。这些偏差在单张图中肉眼可能看不出来但如果你是用程序生成大量图片这些图片后续还要被统一切片或者做视觉对齐那问题就会被放大仔细看会非常难受。4.2 图片质量与格式兼容保存时最容易忽略的细节图片保存阶段的坑经常用一句话就能概括处理一时爽保存火葬场。最常见的坑是保存为 JPG 格式时的质量参数。很多人用 Pillow 保存时省略了quality参数默认值是75这会导致图片出现明显的压缩痕迹尤其是有渐变天空或者细腻皮肤纹理的图片压缩伪影会非常明显。我个人的建议是如果图片最终用途是屏幕展示quality90到quality95是一个甜区画质和文件大小能取得比较好的平衡如果要用在打印场景可以考虑quality100但文件体积会大幅增加需要做好权衡。还有一类格式兼容性坑与透明通道相关。如果原始图片是 PNG 格式并且带有透明背景你直接在上面画了文字后保存回 PNG透明通道是能保留的。但如果你处理的图片是 JPG然后你把convert(RGBA)执行完的结果再次保存为 JPG此时不执行.convert(RGB)的话Pillow 会报错或者自动丢弃 alpha 通道造成颜色变化。稳妥的做法就是在保存 JPG 之前显式转换模式。另外一个我踩过比较深的坑是色彩模式不一致导致的色差。在设计图中经常遇到色彩模式不是 RGB 而是 CMYK 的图片比如从某些印刷稿素材库下载的文件用 Pillow 打开后如果直接画文字再保存你会发现输出图片的整体色彩变得非常奇怪。解决手法也很简单打开图片后统一使用.convert(RGB)先把模式统一再进行绘制操作。多花这一行代码的时间能避免后面大量的颜色校准工作。4.3 批量处理脚本编写技巧别让你的循环拖垮性能当你准备给一批图片批量添加文本内容时性能优化就不再是可选项而是必选项了。我早期写的批量脚本非常天真代码逻辑是这样的循环打开图片创建ImageDraw加载字体绘制文字保存然后进入下一张。看似没毛病但实际跑起来慢得让人抓狂。耗时主要消耗在两个地方。一是重复加载字体二是在循环内重复打开和保存图片。前者的优化方案我已经在上文提到那字体加载放在循环外面能节省大概 20% 的时间。后者的优化方案取决于你的具体业务如果图片是放在同一文件夹下且不需要保留原始格式可以考虑使用 Unix 管道的方式用更底层的读写来加速但在普通场景下最直接有效的方式是使用多进程。因为 Pillow 在底层的部分操作会释放 GIL而文本渲染这种计算密集型的任务实际是可以并行加速的所以用 Python 的 multiprocessing 库把图片列表分发到多个 worker 进程里每张图片独立处理加速效应非常明显。一个简单的并行处理模板如下from multiprocessing import Pool import os def process_one_image(filepath): # 打开、绘制、保存 pass if __name__ __main__: files [os.path.join(images, f) for f in os.listdir(images)] with Pool(processes4) as pool: pool.map(process_one_image, files)但这里有一个需要特别注意的问题如果你把“给图片添加文本内容”的逻辑封装在匿名函数或局部函数里并且函数依赖了从主模块定义的变量多进程模式下这些变量在子进程中可能会丢失导致执行报错。一个比较稳健的做法是把所有需要共享的资源比如字体路径、文字内容模板通过参数传递进子进程函数中而不是以全局变量的方式访问。5. 进阶扩展给图片加文字还能玩出这些花样5.1 动态生成多行文本排版单行文字是最简单的但实际需求里经常涉及多行文本。比如给活动海报上添加一段活动说明文字可能要换行每一行还可能要对齐。这个时候如果直接用 Pillow 的text()手动逐行计算位置代码会比较繁琐。我的做法是封装一个简单的多行绘制函数。核心逻辑是根据预设的最大宽度逐字分行并使用textlength方法来测定每个候选行的宽度超过宽度就换行然后在绘制时逐行更新 y 坐标。需要注意中文沃行时不能按照英文那样按单词切分需要按单字切分否则会出现很突兀的断字效果。def draw_multiline_text(draw, text, font, max_width, x, y, line_height_ratio1.5): lines [] current_line for char in text: test_line current_line char if draw.textlength(test_line, fontfont) max_width: current_line test_line else: lines.append(current_line) current_line char if current_line: lines.append(current_line) line_height int(font.size * line_height_ratio) for i, line in enumerate(lines): draw.text((x, y i * line_height), line, fontfont, fill(255, 255, 255, 255)) return len(lines) * line_height这段代码里用一个逐步试探的方式来确定每一行能容纳多少字符。textlength方法相比textbbox更快专门用于测量单行文本宽度很适合这种场景。关于行高中文排版通常建议设置为字体大小的1.5倍左右太紧了文字会黏在一起太松了则显得疏散如果你处理的是多语言的混排文本这个倍率可能需要适当放大因为部分自带高低起伏的语言比如拉丁字母中的 y、g、j 等下行部分较多的字符行间距太小时容易发生视觉碰撞。5.2 批量给图片添加编号标签的实用脚本还有一个常见的应用场景是给一批图片批量添加编号或者分类标签。比如将拍摄好的几百张素材图统一加上“第X号作品”的标记或者电商运营需要给每个商品图加上名称和价格信息。这种场景的核心要点有两个一个是数据来源的组织方式一个是文件名与图片内容的对应关系。我建议用 CSV 文件来维护图片文件名和需要绘制文字的对应关系通过脚本逐行读取并绘制这样数据可以随时在 Excel 里修改不需要改动代码。import csv from PIL import Image, ImageDraw, ImageFont font_path fonts/SourceHanSansCN-Bold.otf with open(labels.csv, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: filename row[filename] label row[label] price row[price] img Image.open(fimages/{filename}).convert(RGBA) draw ImageDraw.Draw(img) font ImageFont.truetype(font_path, 40) # 在左上角绘制标签 draw.text((30, 30), label, fontfont, fill(255, 255, 255, 255)) # 在右下角绘制价格 price_text f{price} bbox draw.textbbox((0, 0), price_text, fontfont) tw bbox[2] - bbox[0] th bbox[3] - bbox[1] x img.width - tw - 30 y img.height - th - 30 draw.text((x, y), price_text, fontfont, fill(255, 0, 0, 255)) img.convert(RGB).save(foutput/{filename}, quality92) print(处理完成)注意 CSV 文件在读取时一定要指定encodingutf-8否则在 Windows 环境下用 Excel 另存的 CSV 默认是 GBK 编码Python 直接读取会出现乱码导致图片上绘制出来的文字变成一堆乱码文本。如果你确实只能拿到 GBK 编码的 CSV也可以把编码参数改为encodinggbk。5.3 平铺水印的刁钻需求最后再说一个我最近帮一个摄影朋友解决的需求给图片铺满半透明斜向水印目的是防止别人裁剪掉角落水印后盗用。单靠一段文字打上去很容易被裁掉真正可靠的做法是让水印以一定的角度和间距平铺覆盖整张图。实现思路可以这样拆解创建一个和原图同样尺寸的透明图层在透明图层上沿着主对角线方向隔一段距离重复绘制同一段文字同时给文字旋转一个角度把透明图层和原图合成。在 Pillow 中绘制旋转文字需要借助ImageDraw.text()的anchor参数和图层旋转的配合。我可以先创建一个尺寸较大的透明图层在上面绘制水印文字然后旋转这个图层最后通过多次paste拼接成一个密铺图案再与原图合成。写成代码大致是这样from PIL import Image, ImageDraw, ImageFont img Image.open(input.jpg).convert(RGB) width, height img.size # 创建一个透明水印层 watermark Image.new(RGBA, (width, height), (0, 0, 0, 0)) draw ImageDraw.Draw(watermark) font ImageFont.truetype(simhei.ttf, 30) text 版权所有 盗图必究 text_bbox draw.textbbox((0, 0), text, fontfont) tw text_bbox[2] - text_bbox[0] th text_bbox[3] - text_bbox[1] # 在透明图层上重复绘制文字 step_x tw 100 step_y th 100 for y in range(-th, height, step_y): for x in range(-tw, width, step_x): draw.text((x, y), text, fontfont, fill(255, 255, 255, 80)) # 旋转整个水印层用 expandTrue 保持所有内容可见 watermark watermark.rotate(30, expandTrue, resampleImage.BICUBIC) # 将水印层居中贴回到原图尺寸 watermark watermark.crop(((watermark.width - width) // 2, (watermark.height - height) // 2, (watermark.width width) // 2, (watermark.height height) // 2)) result Image.alpha_composite(img.convert(RGBA), watermark) result.convert(RGB).save(output_watermark.jpg, quality95)这里有几个细节。第一个是循环中 x 和 y 的起始值从负值开始是为了确保旋转后水印层依然能覆盖整张图转角区域不会出现空白。第二个是rotate时要使用expandTrue否则旋转过程中图片尺寸保持原样边缘内容会被裁剪掉。第三个是旋转后的重采样算法默认的NEAREST会让文字边缘出现锯齿换成BICUBIC平滑度会好很多。如果你需要水印更加密集可以调小step_x和step_y如果你觉得太密影响观感可以把fill的 alpha 值调小一些或者增大步长。总的来说这组代码做一个简单的防裁剪水印是完全没问题的。写在最后的经验小结给图片文件添加文本内容这件事做多了你会发现真正决定最终效果的不是某个单一的技术亮点而是对细节的把握字体路径对不对、文字位置偏不偏、质量参数合不合适、批量处理有没有做好异常处理。这些细节单看都不起眼但全部堆积起来效果差异会特别明显。我建议你先按这篇文章的思路把单个功能跑通再逐步扩展成批量脚本最后再根据你自己的场景去优化细节。遇到问题别慌大部分都能拆成“字体、位置、格式、性能”四类逐个排查就好。如果你在实践中发现新的坑欢迎随时交流毕竟图片处理这条路一个人走太累了。
返回列表