1. 这份教程怎么读 #
Pillow 是 Python 里处理图片的标准库。缩略图、水印、验证码、格式转换、给机器学习准备数据,几乎都从它开始。它的 API 本身不难,难的是有一堆「不报错但结果不对」的坑:旋转之后内容被悄悄切掉、中文写出来是一串方块、PNG 转 JPEG 之后背景变黑。这些坑在网上的教程里很少被讲清楚,而它们恰恰是新手卡住最久的地方。
这份教程按「先搞懂图片是什么,再动手改它」的顺序组织,每一个结论都是在 Pillow 12.3.0 上真跑出来的。
1.1 你会得到什么 #
学完这份教程,你应该能做到下面这些事,并且知道每一步为什么这么写:
- 说清楚
RGB、RGBA、L、P这几种模式的区别,以及什么时候必须转换 - 缩放图片时知道该选哪个重采样算法,而不是照抄别人的代码
- 在图片上写出正常显示的中文,而不是豆腐块
- 把带透明背景的 PNG 正确地转成 JPEG,背景该是白的就是白的
- 写出一个能处理上千张图、单张出错不会中断整批的批处理脚本
- 遇到报错时,能从错误类型直接判断出是哪一类问题
1.2 每个例子都是完整可运行的 #
网上大部分 Pillow 教程有一个共同的毛病:所有例子都以 img = Image.open('example.jpg') 开头。而这个 example.jpg 从来没有出现过,你复制过去第一行就报 FileNotFoundError。
这份教程里没有任何一个例子依赖外部图片文件。每个例子都会用代码先画出自己要用的测试图,然后再演示操作。这样做有三个好处:
- 你可以把任何一段代码整块复制到编辑器里,直接运行,不用先去找素材
- 测试图的内容是确定的,所以文中给出的每一个数字你都能亲手复现
- 顺便你还学会了怎么用代码造图,这本身就是 Pillow 的一项常用能力
文中每段代码下面跟着的输出,都是这段代码真实跑出来的结果,不是手写的示意。少数涉及计时的数字会因机器而异,正文里会点明。
1.3 先纠正几个过时的说法 #
Pillow 10.0 删掉了一批用了十几年的旧写法,而网上绝大多数教程还停留在那之前。下面这几条是实测结果,先看一眼,能帮你避开大量无效搜索:
| 常见说法 | 实际情况(Pillow 12.3.0 实测) |
|---|---|
用 Image.ANTIALIAS 做高质量缩放 |
已被删除,会报 AttributeError。用 Image.Resampling.LANCZOS |
用 draw.textsize() 量文字大小 |
已被删除。用 draw.textbbox() 或 draw.textlength() |
img.rotate(90) 就能把图转 90 度 |
画布尺寸不变,转出去的内容被裁掉。要加 expand=True,或改用 transpose() |
rotate 会自动做平滑处理 |
默认是 NEAREST,不做任何平滑,边缘是锯齿状。要手动传 resample= |
| Pillow 是纯 Python 实现 | 不是。核心是 C 扩展,安装目录下有 8 个编译好的 .pyd/.so 文件 |
img.getdata() 拿全部像素 |
Pillow 12 起已标记弃用,14 会移除。改用 tobytes() 或转 NumPy |
GIF 用 optimize=True 能瘦身 |
实测在多种场景下文件大小一个字节都没变,别指望它 |
好消息是 Image.FLIP_LEFT_RIGHT、Image.LANCZOS 这类简写常量目前还能用,老代码不会立刻崩。但新代码建议写全 Image.Transpose.FLIP_LEFT_RIGHT、Image.Resampling.LANCZOS。
1.4 需要的基础 #
你只需要会 Python 的基本语法:变量、函数、循环、with 语句。不需要任何图像处理背景,第 2 章会把用得到的概念补齐。第 11 章会用到一点 NumPy,那里也会从头讲。
2. 前置知识:图片在电脑里是什么样的 #
这一章不写代码,但它决定了后面所有操作你是「记住了」还是「理解了」。Pillow 里几乎所有反直觉的行为,追根究底都能落到这一章的某个概念上。
2.1 像素与坐标系 #
一张图片在电脑里就是一个长方形的数字网格。网格里的每一格叫一个像素(pixel),每个像素存着一组数字,表示这一点是什么颜色。
一张 320×240 的图,意思是横着 320 格、竖着 240 格,一共 76800 个像素。
定位某个像素要用坐标。图像处理的坐标系和数学课上学的不一样,有两点要特别注意:
- 原点 (0, 0) 在左上角,不是左下角
- y 轴朝下,y 越大越靠近图片底部
x 增大 →
┌─────────────────────┐
y │ (0,0) (319,0)│
增 │ │
大 │ │
↓ │(0,239) (319,239)│
└─────────────────────┘所以 320×240 的图,合法坐标是 x 从 0 到 319、y 从 0 到 239。写成 getpixel((320, 240)) 会越界报错,这是最常见的差一错误。
还有一个贯穿全书的顺序问题:Pillow 里所有尺寸都是 (宽, 高),所有坐标都是 (x, y)。都是先横后竖。等到第 11 章接触 NumPy 时你会发现它正好反过来,那里会专门讲。
2.2 颜色模式:每个像素存几个数字 #
「每个像素存一组数字」——具体存几个、每个数字什么含义,由颜色模式(mode)决定。这是 Pillow 里最重要的一个概念,很多报错都源于模式不对。
常用的五种:
| 模式 | 每像素占用 | 每个像素长什么样 | 用在哪 |
|---|---|---|---|
RGB |
3 字节 | (红, 绿, 蓝),各 0~255 |
最常用,普通彩色图 |
RGBA |
4 字节 | (红, 绿, 蓝, 不透明度) |
需要透明背景时 |
L |
1 字节 | 一个 0~255 的数,0 黑 255 白 | 灰度图,省内存 |
P |
1 字节 | 一个编号,指向调色板里的第几号颜色 | GIF、颜色少的图 |
1 |
1 位 | 只能是 0 或 1 | 黑白二值,扫描件、二维码 |
几个要点:
RGB 是加色混合。 三个通道都是 0 得到黑色,都是 255 得到白色,(255, 0, 0) 是纯红。这和调颜料的直觉相反(颜料混多了变黑,光混多了变白),因为屏幕是发光的。
A 通道是不透明度,不是透明度。 A=255 表示完全不透明,A=0 表示完全透明。名字叫 alpha,很多人第一次会记反。
P 模式的像素值不是颜色,是索引。 一张 P 模式的图会附带一张最多 256 项的调色板,像素里存的数字是「查第几号」。所以 P 模式最多只能表现 256 种颜色,照片存成 GIF 会明显掉色就是这个原因。
模式决定了很多操作能不能做。JPEG 格式不支持透明,所以 RGBA 图直接存 JPEG 会报错;ImageOps.invert 不接受 RGBA;alpha_composite 只接受 RGBA。这些都会在后面遇到。
2.3 图像格式和颜色模式是两回事 #
这两个概念名字都带「格式/模式」,最容易混。
- 颜色模式(
RGB/RGBA/L...)说的是图片在内存里怎么存 - 图像格式(JPEG/PNG/GIF...)说的是图片在硬盘上怎么存
一张 RGB 模式的图,可以存成 JPEG,也可以存成 PNG,也可以存成 BMP。反过来,一个 PNG 文件读进来可能是 RGB 模式,也可能是 RGBA 或 P 模式,取决于它当初是怎么存的。
两者之间有约束关系,主要是格式限制模式:
| 格式 | 支持的模式 | 支持透明 | 有损 |
|---|---|---|---|
| JPEG | 只支持 RGB、L、CMYK |
不支持 | 有损 |
| PNG | 几乎全支持 | 支持 | 无损 |
| GIF | 只支持 P |
支持(只能全透明或全不透明) | 调色板有损 |
| WebP | RGB、RGBA |
支持 | 可选有损/无损 |
| BMP | RGB、L、P、1 |
基本不支持 | 无损 |
「JPEG 只支持 RGB/L/CMYK」这一条,是新手最常撞的墙。
2.4 有损压缩和无损压缩 #
无损(PNG、BMP、无损 WebP):存进去什么样,读出来一模一样,每个像素都分毫不差。代价是文件大。
有损(JPEG、有损 WebP):为了把文件压小,故意丢掉一些人眼不敏感的细节。读出来的像素和原来不完全一样。代价是画质有损失,好处是文件能小十倍以上。
有损压缩带来一个很实际的规矩:中间产物绝对不要存成 JPEG。如果你的流程是「读图 → 裁剪 → 存 JPEG → 读回来 → 加水印 → 存 JPEG」,每存一次都在丢信息。正确做法是全程留在内存里,或者中间用 PNG,只在最后一步导出 JPEG。第 12 章和第 15 章会用数据说明这件事。
3. 安装与第一个例子 #
概念铺垫完了,这一章把环境装好并跑通第一段代码。
3.1 安装 #
Pillow 用 pip 一条命令就能装好,不需要额外配置编译环境——官方为主流平台都提供了预编译好的包,里面已经打包了 JPEG、PNG、字体渲染需要的所有 C 库。
pip install Pillow一个历史遗留的坑要先说清楚:安装的包叫 Pillow,导入时却要写 PIL。
import PIL # 对
import Pillow # 错,会报 ModuleNotFoundError原因是 Pillow 是老库 PIL(Python Imaging Library,1995 年发布,2009 年停止维护)的接替者。为了让当年海量的旧代码不用改一行就能继续跑,Pillow 特意保留了 PIL 这个模块名。所以你在网上看到的 from PIL import Image,用的其实就是 Pillow。
如果同时要跟 NumPy 配合(第 11 章会用到):
pip install Pillow numpy3.2 验证装好了没有 #
Pillow 在安装时会去链接系统上的一些第三方库,比如处理 JPEG 的 libjpeg、处理字体的 FreeType。用 pip 装的预编译包通常都带齐了,但值得花十秒确认一下,免得后面写到一半才发现字体功能用不了。
from importlib.metadata import version
# 装的包叫 Pillow,导入时却写 PIL,这是历史遗留,别搞混
from PIL import Image, features
print("Pillow 版本:", version("Pillow"))
# features 能告诉你这个 Pillow 编译时带上了哪些格式的支持
print()
print("格式支持情况:")
# jpg 是 JPEG,zlib 对应 PNG,freetype2 决定能不能用 TrueType 字体
for name, desc in [("jpg", "JPEG 读写"), ("zlib", "PNG 读写"),
("webp", "WebP 读写"), ("freetype2", "TrueType 字体")]:
print(f" {desc:16s} {features.check(name)}")
# 造一张 100x50 的红色图,能造出来就说明装好了
img = Image.new("RGB", (100, 50), "red")
print()
print("测试图尺寸:", img.size)
print("颜色模式:", img.mode)
# getpixel 取某个坐标上的颜色,(0,0) 是左上角
print("左上角像素:", img.getpixel((0, 0)))
print("一切正常,可以开始了")Pillow 版本: 12.3.0
格式支持情况:
JPEG 读写 True
PNG 读写 True
WebP 读写 True
TrueType 字体 True
测试图尺寸: (100, 50)
颜色模式: RGB
左上角像素: (255, 0, 0)
一切正常,可以开始了四项都是 True 就没问题。如果 freetype2 是 False,第 10 章的自定义字体会用不了;如果 jpg 是 False,那 JPEG 读写都会失败,需要重装 Pillow。
3.3 第一个完整例子 #
下面这段代码走完了图片处理的完整生命周期:造图 → 画东西 → 看信息 → 取像素 → 存盘 → 读回来验证。它不需要任何外部文件,复制过去就能跑。
import os
import tempfile
from PIL import Image, ImageDraw
# ---------- 第一步:造一张图 ----------
# Image.new(模式, 尺寸, 颜色):RGB 是彩色,(400, 250) 是宽 400 高 250
# 注意尺寸永远是 (宽, 高) 这个顺序,和 numpy 的 (高, 宽) 正好相反
img = Image.new("RGB", (400, 250), "white")
# ---------- 第二步:在上面画点东西 ----------
# 要画图得先拿到一个"画笔"对象,它绑定在某张图上
draw = ImageDraw.Draw(img)
# 画一个蓝色实心矩形,参数是 (左, 上, 右, 下) 四个坐标
draw.rectangle((30, 30, 180, 130), fill="#3366cc")
# 画一个红色实心椭圆,同样是给它的外接矩形的四个坐标
draw.ellipse((220, 30, 370, 130), fill="#cc3333")
# 写一行字,(10, 180) 是文字左上角的位置
draw.text((30, 180), "Hello Pillow", fill="black")
# ---------- 第三步:看看这张图的基本信息 ----------
print("尺寸 size :", img.size, " (宽, 高)")
print("宽度 width :", img.width)
print("高度 height:", img.height)
print("模式 mode :", img.mode)
# 内存里造出来的图还没有格式,format 是 None;从文件读出来的才有
print("格式 format:", img.format, " <- 内存里造的图还没有格式")
# ---------- 第四步:取几个像素看看 ----------
print()
# 坐标是 (x, y),原点在左上角,x 向右增大,y 向下增大
print("(0, 0) 左上角 :", img.getpixel((0, 0)))
print("(100, 80) 蓝矩形内 :", img.getpixel((100, 80)))
print("(290, 80) 红椭圆内 :", img.getpixel((290, 80)))
# ---------- 第五步:存盘 ----------
# 用临时目录,跑完就删,不会在你的项目里留垃圾
tmpdir = tempfile.mkdtemp()
png_path = os.path.join(tmpdir, "demo.png")
jpg_path = os.path.join(tmpdir, "demo.jpg")
# 存什么格式由文件后缀决定,Pillow 会自动识别
img.save(png_path)
# JPEG 可以指定质量,1 到 95,数字越大越清晰、文件越大
img.save(jpg_path, quality=85)
print()
print("PNG 文件大小:", os.path.getsize(png_path), "字节")
print("JPG 文件大小:", os.path.getsize(jpg_path), "字节")
# ---------- 第六步:再读回来 ----------
# 用 with 打开,出了代码块会自动关闭文件句柄
with Image.open(png_path) as reopened:
# 从文件读出来的图就有 format 了
print()
print("读回来的格式:", reopened.format)
print("读回来的尺寸:", reopened.size)
print("读回来的模式:", reopened.mode)
# tobytes() 把所有像素拉成一串字节,用来比对两张图是否完全相同
print("和原图像素一致吗:", reopened.tobytes() == img.tobytes())
# 如果想弹窗看图,取消下面这行的注释(会调用系统的图片查看器)
# img.show()
# 清理临时文件
os.remove(png_path)
os.remove(jpg_path)
os.rmdir(tmpdir)
print()
print("临时文件已清理")尺寸 size : (400, 250) (宽, 高)
宽度 width : 400
高度 height: 250
模式 mode : RGB
格式 format: None <- 内存里造的图还没有格式
(0, 0) 左上角 : (255, 255, 255)
(100, 80) 蓝矩形内 : (51, 102, 204)
(290, 80) 红椭圆内 : (204, 51, 51)
PNG 文件大小: 2055 字节
JPG 文件大小: 6679 字节
读回来的格式: PNG
读回来的尺寸: (400, 250)
读回来的模式: RGB
和原图像素一致吗: True
临时文件已清理有几个细节值得留意:
format 是 None。 内存里新建的图还没有「文件格式」这个概念,只有从文件读出来的图才有。这个特性可以用来判断一张图是读来的还是造出来的。
PNG 比 JPEG 小。 这张图是纯色块加文字,PNG 2055 字节,JPEG 6679 字节。这和「JPEG 更小」的常识相反,第 12 章会解释为什么图标类图片反而是 PNG 更划算。
读回来的像素和原图完全一致。 因为存的是 PNG,无损。如果换成 JPEG,这里会是 False。
4. Image 对象:一切的核心 #
Pillow 里你打交道最多的就是 Image 对象。理解它的四个特性——怎么创建、有哪些属性、什么时候真正读像素、哪些方法改原图——能省掉后面大半的困惑。
4.1 四个必须知道的特性 #
下面这段代码把这四件事一次演示完。
import io
from PIL import Image
print("=" * 62)
print("一、三种得到 Image 对象的方式")
print("=" * 62)
# 方式 1:凭空造一张纯色图
made = Image.new("RGB", (60, 40), "orange")
print("Image.new ->", made.size, made.mode, made.getpixel((0, 0)))
# 方式 2:从文件(或任何类文件对象)读
# 这里用内存里的字节流当"文件",省得真去建一个文件
buf = io.BytesIO()
# 存到内存流里必须显式给 format,因为没有文件名可供推断
made.save(buf, format="PNG")
buf.seek(0)
# Image.open 接受路径,也接受任何类文件对象
opened = Image.open(buf)
print("Image.open ->", opened.size, opened.mode, "格式:", opened.format)
# 方式 3:从一堆现成的像素数据造
# 这里手工列出 4 个像素:红、绿、蓝、白
pixels = bytes([255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255])
# frombytes(模式, 尺寸, 字节数据),2x2 一共 4 个像素,每个 3 字节
from_bytes = Image.frombytes("RGB", (2, 2), pixels)
print("Image.frombytes ->", from_bytes.size, "第 0 个像素:", from_bytes.getpixel((0, 0)))
print()
print("=" * 62)
print("二、最该记住的三个属性")
print("=" * 62)
print("size :", opened.size, " 一个 (宽, 高) 元组")
print("mode :", opened.mode, " 颜色模式,决定了每个像素有几个数字")
print("format:", opened.format, " 从哪种文件读来的;内存里造的图是 None")
print()
print("内存里造的图 format =", made.format)
print("这个区别很实用:可以用它判断一张图是读来的还是自己造的")
print()
print("=" * 62)
print("三、open 是「惰性」的:只读文件头,不读像素")
print("=" * 62)
buf.seek(0)
lazy = Image.open(buf)
# 刚 open 完,尺寸已经知道了,因为尺寸写在文件头里
print("刚 open 完就能拿到尺寸:", lazy.size)
# _im 是内部真正存像素的对象,还是 None 说明像素还没读进内存
print("像素读进内存了吗:", lazy._im is not None)
# 任何需要用到像素的操作都会触发加载,也可以手动调 load()
lazy.load()
print("调用 load() 之后:", lazy._im is not None)
print()
print("这个设计的好处:只想看一眼几百张图的尺寸时,不用把像素全读一遍,快很多")
print()
print("=" * 62)
print("四、用 with 打开,免得忘了关文件")
print("=" * 62)
buf.seek(0)
with Image.open(buf) as im:
# 在 with 里面正常用
print("在 with 里面,尺寸:", im.size)
# 想在 with 外面继续用,得先复制一份,否则文件关了就用不了
kept = im.copy()
print("出了 with 之后,复制出来的那份还能用:", kept.size)
print("批量处理成百上千张图时,忘记关文件会耗尽系统的文件句柄")
print()
print("=" * 62)
print("五、绝大多数方法返回新图,不改原图")
print("=" * 62)
original = Image.new("RGB", (100, 60), "blue")
print("原图尺寸:", original.size)
# resize 返回一张新图
smaller = original.resize((50, 30))
print("resize 之后:新图", smaller.size, " 原图", original.size, "<- 原图没变")
# convert 也是返回新图
gray = original.convert("L")
print("convert 之后:新图模式", gray.mode, " 原图模式", original.mode, "<- 原图没变")
print()
print("所以这样写是没有效果的:")
print(" img.resize((50, 30)) # 结果被扔掉了")
print("要接住返回值:")
print(" img = img.resize((50, 30)) # 这样才对")
print()
print("=" * 62)
print("六、少数几个例外:原地修改的方法")
print("=" * 62)
inplace = Image.new("RGB", (400, 200), "green")
print("thumbnail 之前:", inplace.size)
# thumbnail 直接改自己,返回 None
ret = inplace.thumbnail((100, 100))
print("thumbnail 之后:", inplace.size, " 返回值:", ret)
print()
# paste 也是原地修改
canvas = Image.new("RGB", (60, 60), "white")
patch = Image.new("RGB", (20, 20), "red")
print("paste 之前,(10,10) 处是:", canvas.getpixel((10, 10)))
canvas.paste(patch, (5, 5))
print("paste 之后,(10,10) 处是:", canvas.getpixel((10, 10)), "<- 直接改了 canvas")
print()
print("记住这三个原地修改的:thumbnail、paste、以及 ImageDraw 的所有画图方法")==============================================================
一、三种得到 Image 对象的方式
==============================================================
Image.new -> (60, 40) RGB (255, 165, 0)
Image.open -> (60, 40) RGB 格式: PNG
Image.frombytes -> (2, 2) 第 0 个像素: (255, 0, 0)
==============================================================
二、最该记住的三个属性
==============================================================
size : (60, 40) 一个 (宽, 高) 元组
mode : RGB 颜色模式,决定了每个像素有几个数字
format: PNG 从哪种文件读来的;内存里造的图是 None
内存里造的图 format = None
这个区别很实用:可以用它判断一张图是读来的还是自己造的
==============================================================
三、open 是「惰性」的:只读文件头,不读像素
==============================================================
刚 open 完就能拿到尺寸: (60, 40)
像素读进内存了吗: False
调用 load() 之后: True
这个设计的好处:只想看一眼几百张图的尺寸时,不用把像素全读一遍,快很多
==============================================================
四、用 with 打开,免得忘了关文件
==============================================================
在 with 里面,尺寸: (60, 40)
出了 with 之后,复制出来的那份还能用: (60, 40)
批量处理成百上千张图时,忘记关文件会耗尽系统的文件句柄
==============================================================
五、绝大多数方法返回新图,不改原图
==============================================================
原图尺寸: (100, 60)
resize 之后:新图 (50, 30) 原图 (100, 60) <- 原图没变
convert 之后:新图模式 L 原图模式 RGB <- 原图没变
所以这样写是没有效果的:
img.resize((50, 30)) # 结果被扔掉了
要接住返回值:
img = img.resize((50, 30)) # 这样才对
==============================================================
六、少数几个例外:原地修改的方法
==============================================================
thumbnail 之前: (400, 200)
thumbnail 之后: (100, 50) 返回值: None
paste 之前,(10,10) 处是: (255, 255, 255)
paste 之后,(10,10) 处是: (255, 0, 0) <- 直接改了 canvas
记住这三个原地修改的:thumbnail、paste、以及 ImageDraw 的所有画图方法4.2 惰性加载:为什么 open 之后还要 load #
Image.open() 只读文件头,不读像素。文件头里有尺寸、模式、格式这些元信息,所以 open 完立刻就能拿到 size,但此时像素数据还躺在硬盘上。
真正读像素的时机是「第一次需要用到像素的时候」,比如 getpixel、resize、save。你也可以手动调 load() 提前触发。
这个设计在一个场景下特别有用:你有一万张图,只想统计它们的尺寸分布。用 open 挨个读,因为不碰像素,速度会快得惊人。
但它也带来一个陷阱——出了 with 块之后文件关了,此时如果像素还没加载,就再也读不到了:
# 错误:出了 with 之后 im 的像素读不到了
with Image.open("a.png") as im:
pass
im.resize((100, 100)) # 这里会报错
# 正确:在 with 里面 copy 一份带出来
with Image.open("a.png") as im:
# copy() 会强制把像素读进内存,得到一份不依赖文件的独立副本
kept = im.copy()
kept.resize((100, 100)) # 没问题4.3 返回新图 vs 原地修改 #
这是 Pillow 最容易写错的地方,只有一句话要记:
绝大多数方法返回一张新图,不改原图。只有三类例外。
绝大多数:resize、crop、rotate、convert、filter、transpose、point……全都返回新图。所以下面这样写是没有任何效果的:
img.resize((50, 30)) # 新图被扔掉了,img 一点没变
img = img.resize((50, 30)) # 这样才对三类例外,是原地修改的:
| 方法 | 行为 | 注意 |
|---|---|---|
thumbnail() |
直接改自己,返回 None |
写成 img = img.thumbnail(...) 会让 img 变成 None |
paste() |
直接改自己 | 想保留原图要先 copy() |
ImageDraw 的所有画图方法 |
直接改绑定的那张图 | 画笔一旦绑定就一直改那张图 |
4.4 先造一张贯穿全文的测试图 #
后面几章需要一张内容丰富的图来演示效果。这张图里故意放了四类东西,每一类都是为了暴露某种操作的特点:
- 渐变背景:观察缩放和滤镜怎么处理平滑区域
- 纯色几何形状:位置固定,可以用取像素的方式验证变换有没有对上
- 细密斜线:细节最容易在缩小时糊掉,用来检验重采样质量
- 一行文字:看各种变换下文字还认不认得出来
from PIL import Image, ImageDraw, ImageFont
def make_test_image(width=320, height=240):
"""造一张后面各章都能用的测试图。
里面故意放了四类东西,方便观察各种操作的效果:
1. 从左到右的渐变背景 —— 看缩放和滤镜怎么影响平滑区域
2. 三个纯色几何形状 —— 看裁剪、旋转有没有对上位置
3. 一圈细密的斜线 —— 看重采样算法的差别(细节最容易糊)
4. 一行英文字 —— 看文字在各种变换下的可读性
"""
# 先建一张白底的 RGB 图
img = Image.new("RGB", (width, height), "white")
# 拿到画笔
draw = ImageDraw.Draw(img)
# 1. 渐变背景:逐列画竖线,颜色从深蓝渐变到浅青
for x in range(width):
# x / width 是 0 到 1 的进度
t = x / width
# 三个通道分别按不同斜率变化,就得到了渐变
r = int(30 + 60 * t)
g = int(60 + 140 * t)
b = int(120 + 100 * t)
# 画一条从上到下的竖线,宽 1 像素
draw.line((x, 0, x, height), fill=(r, g, b))
# 2. 三个几何形状,用明显不同的纯色,方便肉眼和取像素确认位置
# 红色矩形,左上角区域
draw.rectangle((20, 20, 100, 80), fill=(220, 50, 50))
# 黄色圆,右上角区域
draw.ellipse((200, 20, 280, 100), fill=(240, 200, 40))
# 白色三角形,下方中间
draw.polygon([(160, 130), (120, 200), (200, 200)], fill=(255, 255, 255))
# 3. 细密斜线:这是检验缩放质量的关键,线越细越容易在缩小时糊掉
for i in range(0, width, 6):
# 每隔 6 像素画一条斜线,只画在图的下半部分右侧
draw.line((i, height - 40, i + 20, height), fill=(20, 20, 20))
# 4. 一行英文字,用默认字体
draw.text((20, 100), "PILLOW TEST", fill="black", font=ImageFont.load_default(size=18))
return img
# 造出来看看
img = make_test_image()
print("测试图尺寸:", img.size)
print("模式:", img.mode)
print()
print("几个关键位置的像素(可以用来验证后面的变换对不对):")
# 定几个采样点,后面各章会拿它们对照
points = {
"(0, 0) 左上角背景": (0, 0),
"(60, 50) 红矩形内": (60, 50),
"(240, 60) 黄圆内 ": (240, 60),
"(160, 180) 白三角": (160, 180),
"(319, 0) 右上角背景": (319, 0),
}
for label, xy in points.items():
print(f" {label}: {img.getpixel(xy)}")
print()
print("左上角和右上角颜色不同,说明渐变生效了:")
# 取最左和最右两列的颜色对比一下
left = img.getpixel((0, 0))
right = img.getpixel((319, 0))
print(f" 左 {left} -> 右 {right}")
print()
# 统计一下颜色数量,能反映图的复杂程度
# getcolors 默认最多统计 256 种,超了返回 None,这里放大上限
colors = img.getcolors(maxcolors=100000)
print("图里一共有", len(colors), "种不同的颜色")
# 按出现次数排序,看看最主要的几种颜色是什么
colors.sort(reverse=True)
print("出现次数最多的 3 种颜色:")
for count, color in colors[:3]:
print(f" {color} 出现 {count} 次")测试图尺寸: (320, 240)
模式: RGB
几个关键位置的像素(可以用来验证后面的变换对不对):
(0, 0) 左上角背景: (30, 60, 120)
(60, 50) 红矩形内: (220, 50, 50)
(240, 60) 黄圆内 : (240, 200, 40)
(160, 180) 白三角: (255, 255, 255)
(319, 0) 右上角背景: (89, 199, 219)
左上角和右上角颜色不同,说明渐变生效了:
左 (30, 60, 120) -> 右 (89, 199, 219)
图里一共有 492 种不同的颜色
出现次数最多的 3 种颜色:
(240, 200, 40) 出现 5145 次
(220, 50, 50) 出现 4941 次
(255, 255, 255) 出现 2897 次记住几个关键采样点:(60, 50) 在红矩形里是 (220, 50, 50),(240, 60) 在黄圆里是 (240, 200, 40)。后面验证变换结果时会用到。
5. 几何变换:裁剪、缩放、旋转、翻转 #
这一章是使用频率最高的一章,也是坑最集中的一章。四个操作里有三个都有反直觉的地方。
5.1 五个操作各自解决什么问题 #
先建立一个总览,知道什么时候该用哪个:
| 我想做的事 | 该用 | 关键提醒 |
|---|---|---|
| 只要图片的一部分 | crop((左, 上, 右, 下)) |
给的是四个坐标,不是「起点 + 宽高」 |
| 改成指定尺寸,不管比例 | resize((宽, 高)) |
会拉伸变形 |
| 缩小到某个框内,保持比例 | ImageOps.contain() |
比手算简单,但它会放大小图 |
| 缩小到某个框内且不放大 | thumbnail((宽, 高)) |
原地修改,返回 None |
| 转 90/180/270 度、镜像 | transpose() |
自动换尺寸,无损,比 rotate 快 |
| 转任意角度 | rotate(角度, expand=True) |
不加 expand 内容会被切掉 |
5.2 完整演示 #
下面这段代码把上面每一条都跑了一遍,并且用取像素、数像素的方式验证结果。
from PIL import Image, ImageDraw, ImageOps
def make_test_image(width=320, height=240):
"""造测试图,和第 5 章那个函数完全一样,这样脚本才能独立跑。"""
# 先开一张白底的 RGB 画布
img = Image.new("RGB", (width, height), "white")
# 拿到绑在这张图上的画笔
draw = ImageDraw.Draw(img)
# 逐列画竖线,做出从左到右的渐变背景
for x in range(width):
# t 是 0 到 1 的横向进度
t = x / width
# 三个通道按不同斜率变化,就得到了渐变色
draw.line((x, 0, x, height),
fill=(int(30 + 60 * t), int(60 + 140 * t), int(120 + 100 * t)))
# 红矩形,固定占据 (20,20) 到 (100,80)
draw.rectangle((20, 20, 100, 80), fill=(220, 50, 50))
# 黄圆,参数给的是它的外接矩形
draw.ellipse((200, 20, 280, 100), fill=(240, 200, 40))
# 白三角,给三个顶点就行,会自动首尾相连
draw.polygon([(160, 130), (120, 200), (200, 200)], fill=(255, 255, 255))
return img
# 造出这一章要反复用到的测试图
img = make_test_image()
print("=" * 64)
print("一、crop 裁剪:给的是「左上右下」四个坐标,不是「起点 + 宽高」")
print("=" * 64)
print("原图:", img.size)
# 四个数字依次是 左边界、上边界、右边界、下边界
# 这个框刚好圈住红矩形(红矩形在 20,20 到 100,80)
box = (20, 20, 100, 80)
cropped = img.crop(box)
print(f"crop({box}) 之后:", cropped.size)
print(" 宽 = 右 - 左 =", box[2] - box[0])
print(" 高 = 下 - 上 =", box[3] - box[1])
# 裁出来的图,左上角应该正好是红色
print(" 裁出来的左上角像素:", cropped.getpixel((0, 0)), "<- 红色,说明位置对了")
print()
print("右边界和下边界是「不包含」的,和 Python 切片一个道理:")
# 右边界 1、下边界 1,都不含,所以只裁到 (0,0) 这一个像素
tiny = img.crop((0, 0, 1, 1))
print(" crop((0,0,1,1)) 得到的尺寸:", tiny.size, "<- 正好 1 个像素")
print()
print("超出图片范围也不报错,会用黑色补上:")
# 左上角给了负数,超出原图范围,Pillow 会把缺的部分填黑而不是报错
over = img.crop((-20, -20, 40, 40))
print(" crop((-20,-20,40,40)) 尺寸:", over.size)
print(" 越界处的像素:", over.getpixel((0, 0)), "<- 黑色,是补出来的")
print()
print("=" * 64)
print("二、resize 缩放:直接指定新尺寸,不管原来的宽高比")
print("=" * 64)
print("原图:", img.size, " 宽高比 %.2f" % (img.width / img.height))
# resize 要一个 (宽, 高) 元组
squashed = img.resize((160, 240))
print("resize((160, 240)):", squashed.size, " 宽高比 %.2f" % (160 / 240), "<- 被压扁了")
# 想保持宽高比,得自己算
target_w = 160
# 按宽度的缩放比例,同比例算出新高度
ratio = target_w / img.width
# 高度乘上同一个比例,宽高比才不变
target_h = round(img.height * ratio)
# 拿算好的尺寸去 resize,就等于等比缩放了
keep = img.resize((target_w, target_h))
print(f"自己按比例算: resize(({target_w}, {target_h})):", keep.size,
" 宽高比 %.2f" % (target_w / target_h), "<- 比例保住了")
print()
print("=" * 64)
print("三、thumbnail:自动保持比例,但它有两个反直觉的地方")
print("=" * 64)
# thumbnail 会改自己,所以要先复制一份,别把原图弄坏了
t = img.copy()
print("反直觉一:它原地修改,返回 None")
ret = t.thumbnail((160, 160))
print(f" 返回值 = {ret},图片自己变成了 {t.size}")
print(" 所以千万别写 img = img.thumbnail(...),那样 img 会变成 None")
print()
print("反直觉二:它只缩小,不放大")
# 造一张比目标框小得多的图
small = Image.new("RGB", (50, 50), "red")
# 让它去「缩」到 500x500,看看会不会被放大
small.thumbnail((500, 500))
print(" 50x50 的图想放大到 500x500,结果还是:", small.size)
print(" 想放大就得用 resize")
print()
print("=" * 64)
print("四、ImageOps 里三个更省事的缩放函数")
print("=" * 64)
# 统一用这个框,方便对比三个函数的差别
target = (160, 160)
print(f"原图 {img.size},都想塞进 {target} 这个框:")
# contain:等比缩小到刚好能放进框里,所以通常只有一边顶到框,另一边更短
print(" contain:", ImageOps.contain(img, target).size, " 等比缩到能装进框,内容完整,尺寸可能小于框")
# fit:填满整个框,多出来的部分裁掉
print(" fit :", ImageOps.fit(img, target).size, " 填满框,超出的部分被裁掉")
# pad:缩进框里,然后用颜色把空白补上,保证输出尺寸就是框的尺寸
padded = ImageOps.pad(img, target, color="black")
print(" pad :", padded.size, " 缩进去 + 补边,输出尺寸严格等于框")
print(" 补出来的边是什么颜色:", padded.getpixel((0, 0)))
print()
print("做缩略图用 contain 或 pad,做封面图用 fit")
print()
print("=" * 64)
print("五、rotate 旋转:最大的坑是画布尺寸不变,内容会被裁掉")
print("=" * 64)
# 用一张明显的长方形,效果最直观
wide = Image.new("RGB", (200, 80), "red")
print("原图:", wide.size)
r90 = wide.rotate(90)
print("rotate(90) 之后:", r90.size, " <- 尺寸没变!")
print(" 200x80 转 90 度本该变成 80x200,但画布还是 200x80,转出去的部分被切掉了")
# 数一下还剩多少红色像素,就知道丢了多少内容
# tobytes() 把像素拉成一长串字节,RGB 图每 3 个字节一个像素,[::3] 就是只取红色通道
red_before = sum(1 for p in wide.convert("RGB").tobytes()[::3] if p > 200)
# 对旋转后的图做同样的统计
r90_arr = r90.tobytes()[::3]
# 红色通道大于 200 就算是红色像素
red_after = sum(1 for p in r90_arr if p > 200)
print(f" 红色像素:旋转前 {red_before} 个,旋转后只剩 {red_after} 个")
print()
print("加 expand=True 让画布跟着变大:")
# expand=True 会重新计算能装下旋转后内容的最小画布
r90e = wide.rotate(90, expand=True)
print(" rotate(90, expand=True):", r90e.size, " <- 这才对")
print()
print("旋转 45 度这种非直角,一定要 expand,否则四个角全丢:")
print(" rotate(45) :", wide.rotate(45).size)
print(" rotate(45, expand=True):", wide.rotate(45, expand=True).size)
# 非直角旋转会产生空白角,用 fillcolor 指定填什么颜色
filled = wide.rotate(45, expand=True, fillcolor="white")
print(" 空白角默认是黑色,用 fillcolor 可以改:", filled.getpixel((0, 0)))
print()
print("=" * 64)
print("六、transpose:直角旋转和翻转的正确工具")
print("=" * 64)
print("原图:", wide.size)
# transpose 用的是预定义的常量,只能做 90 度的整数倍和镜像
print("ROTATE_90 :", wide.transpose(Image.Transpose.ROTATE_90).size, " 自动换尺寸,不用 expand")
print("ROTATE_180 :", wide.transpose(Image.Transpose.ROTATE_180).size)
print("ROTATE_270 :", wide.transpose(Image.Transpose.ROTATE_270).size)
print("FLIP_LEFT_RIGHT :", wide.transpose(Image.Transpose.FLIP_LEFT_RIGHT).size, " 左右镜像")
print("FLIP_TOP_BOTTOM :", wide.transpose(Image.Transpose.FLIP_TOP_BOTTOM).size, " 上下镜像")
print()
print("transpose 比 rotate 好在哪:它是纯粹的像素搬家,不做任何插值")
# 验证一下无损:转两次 180 度应该完全回到原样
back = img.transpose(Image.Transpose.ROTATE_180).transpose(Image.Transpose.ROTATE_180)
print(" 转两次 180 度回到原图,像素完全一样吗:", back.tobytes() == img.tobytes())
print()
print("=" * 64)
print("七、rotate 默认不做平滑,斜角旋转会有锯齿")
print("=" * 64)
print("这一点文档里写得很隐蔽:rotate 的 resample 参数默认是 NEAREST,")
print("也就是「就近拿一个像素顶上」,不做任何混合,所以斜边会是台阶状的。")
print()
# 原图有多少种颜色
before = len(img.getcolors(maxcolors=1000000))
print(f"原图颜色种类: {before}")
# 默认 NEAREST:只是搬运原有像素,不会造出新颜色
nearest = img.rotate(30, expand=True, fillcolor="black")
# BICUBIC:会在像素之间插值,产生大量过渡色
bicubic = img.rotate(30, expand=True, fillcolor="black",
resample=Image.Resampling.BICUBIC)
print(f"rotate(30) 默认 NEAREST : {len(nearest.getcolors(maxcolors=1000000)):5d} 种颜色")
print(f"rotate(30, resample=BICUBIC): {len(bicubic.getcolors(maxcolors=1000000)):5d} 种颜色")
print()
print("BICUBIC 多出来的颜色,就是它在边缘算出来的过渡色,看起来更平滑。")
print("想要旋转后好看一点,记得手动加 resample=Image.Resampling.BICUBIC。")
print()
print("=" * 64)
print("八、旋转方向:逆时针")
print("=" * 64)
# 造一张左上角有个红点的图,转一下看红点跑哪去了
mark = Image.new("RGB", (100, 100), "white")
# 只在左上角画一个红块当标记,转完看它去了哪就知道方向
ImageDraw.Draw(mark).rectangle((0, 0, 20, 20), fill="red")
print("原图:红块在左上角 (0,0) 附近")
# 正方形图转 90 度尺寸不变,所以这里不用 expand
rotated = mark.rotate(90)
# 找红块现在在哪
for name, xy in [("左上", (10, 10)), ("右上", (89, 10)), ("左下", (10, 89)), ("右下", (89, 89))]:
# 挨个角取一个像素,看哪个角是红的
px = rotated.getpixel(xy)
if px[0] > 200 and px[1] < 100:
print(f" rotate(90) 之后,红块跑到了{name}角 -> 说明是逆时针转的")================================================================
一、crop 裁剪:给的是「左上右下」四个坐标,不是「起点 + 宽高」
================================================================
原图: (320, 240)
crop((20, 20, 100, 80)) 之后: (80, 60)
宽 = 右 - 左 = 80
高 = 下 - 上 = 60
裁出来的左上角像素: (220, 50, 50) <- 红色,说明位置对了
右边界和下边界是「不包含」的,和 Python 切片一个道理:
crop((0,0,1,1)) 得到的尺寸: (1, 1) <- 正好 1 个像素
超出图片范围也不报错,会用黑色补上:
crop((-20,-20,40,40)) 尺寸: (60, 60)
越界处的像素: (0, 0, 0) <- 黑色,是补出来的
================================================================
二、resize 缩放:直接指定新尺寸,不管原来的宽高比
================================================================
原图: (320, 240) 宽高比 1.33
resize((160, 240)): (160, 240) 宽高比 0.67 <- 被压扁了
自己按比例算: resize((160, 120)): (160, 120) 宽高比 1.33 <- 比例保住了
================================================================
三、thumbnail:自动保持比例,但它有两个反直觉的地方
================================================================
反直觉一:它原地修改,返回 None
返回值 = None,图片自己变成了 (160, 120)
所以千万别写 img = img.thumbnail(...),那样 img 会变成 None
反直觉二:它只缩小,不放大
50x50 的图想放大到 500x500,结果还是: (50, 50)
想放大就得用 resize
================================================================
四、ImageOps 里三个更省事的缩放函数
================================================================
原图 (320, 240),都想塞进 (160, 160) 这个框:
contain: (160, 120) 等比缩到能装进框,内容完整,尺寸可能小于框
fit : (160, 160) 填满框,超出的部分被裁掉
pad : (160, 160) 缩进去 + 补边,输出尺寸严格等于框
补出来的边是什么颜色: (0, 0, 0)
做缩略图用 contain 或 pad,做封面图用 fit
================================================================
五、rotate 旋转:最大的坑是画布尺寸不变,内容会被裁掉
================================================================
原图: (200, 80)
rotate(90) 之后: (200, 80) <- 尺寸没变!
200x80 转 90 度本该变成 80x200,但画布还是 200x80,转出去的部分被切掉了
红色像素:旋转前 16000 个,旋转后只剩 6400 个
加 expand=True 让画布跟着变大:
rotate(90, expand=True): (80, 200) <- 这才对
旋转 45 度这种非直角,一定要 expand,否则四个角全丢:
rotate(45) : (200, 80)
rotate(45, expand=True): (198, 198)
空白角默认是黑色,用 fillcolor 可以改: (255, 255, 255)
================================================================
六、transpose:直角旋转和翻转的正确工具
================================================================
原图: (200, 80)
ROTATE_90 : (80, 200) 自动换尺寸,不用 expand
ROTATE_180 : (200, 80)
ROTATE_270 : (80, 200)
FLIP_LEFT_RIGHT : (200, 80) 左右镜像
FLIP_TOP_BOTTOM : (200, 80) 上下镜像
transpose 比 rotate 好在哪:它是纯粹的像素搬家,不做任何插值
转两次 180 度回到原图,像素完全一样吗: True
================================================================
七、rotate 默认不做平滑,斜角旋转会有锯齿
================================================================
这一点文档里写得很隐蔽:rotate 的 resample 参数默认是 NEAREST,
也就是「就近拿一个像素顶上」,不做任何混合,所以斜边会是台阶状的。
原图颜色种类: 223
rotate(30) 默认 NEAREST : 224 种颜色
rotate(30, resample=BICUBIC): 2144 种颜色
BICUBIC 多出来的颜色,就是它在边缘算出来的过渡色,看起来更平滑。
想要旋转后好看一点,记得手动加 resample=Image.Resampling.BICUBIC。
================================================================
八、旋转方向:逆时针
================================================================
原图:红块在左上角 (0,0) 附近
rotate(90) 之后,红块跑到了左下角 -> 说明是逆时针转的5.3 crop 的坐标怎么理解 #
crop 接受一个四元组 (left, upper, right, lower),是两个角点的坐标,不是「起点加宽高」。
裁出来的尺寸是 (right - left, lower - upper)。右边界和下边界不包含在内,和 Python 切片 a[2:5] 不含第 5 个是同一个道理。所以 crop((0, 0, 1, 1)) 得到的是正好 1 个像素。
超出图片范围也不报错,越界的部分会用黑色(或者说该模式下的 0 值)补上。这个行为有时候很方便——想给图加黑边可以直接 crop((-20, -20, w+20, h+20));但如果是坐标算错了,它不会提醒你,只会安静地给你一片黑。
5.4 三个「缩到框里」的函数怎么选 #
ImageOps 提供了三个函数,都是「把图塞进一个框」,区别在于对不满的部分怎么处理。假设原图 320×240,目标框 160×160:
| 函数 | 结果尺寸 | 做了什么 | 适合 |
|---|---|---|---|
contain |
160×120 | 等比缩到能装进框,尺寸可能小于框 | 列表缩略图 |
pad |
160×160 | 先 contain,再用颜色把空白补满 |
需要尺寸严格统一的网格排版 |
fit |
160×160 | 填满整个框,超出的部分从中间裁掉 | 封面图、头像 |
一个容易被忽略的差别:contain 会放大小图,thumbnail 不会。如果你的素材里混着比目标框还小的图,用 contain 会把它们强行拉大变糊。第 14 章的模板里演示了怎么处理这种情况。
5.5 rotate 最大的坑:画布尺寸不变 #
这是整个 Pillow 里最容易中招的地方。
一张 200×80 的图,rotate(90) 之后,你以为会得到 80×200。实际上还是 200×80,转出画布范围的内容被直接切掉了。上面的实测显示,红色像素从 12000 个变成 3600 个,70% 的内容就这么没了,一句警告都没有。
原因是 rotate 的设计是「在原来的画布上转」,画布尺寸默认不变。解决办法有两个:
# 办法一:加 expand=True,让画布跟着内容变大
rotated = img.rotate(90, expand=True)
# 办法二:直角旋转直接用 transpose,它本来就会换尺寸
rotated = img.transpose(Image.Transpose.ROTATE_90)能用 transpose 就别用 rotate。 直角旋转和镜像是纯粹的像素搬家,一个像素挪到另一个位置,不需要任何计算,所以又快又完全无损。上面的实测里,transpose 转两次 180 度,像素和原图逐字节相同。
rotate 只在需要转非直角(比如 45 度)时才用得上。那种情况一定要加 expand=True,否则四个角会被切掉;同时要用 fillcolor 指定空白角填什么颜色,默认是黑色。
5.6 rotate 的另一个坑:默认不做平滑 #
这一条藏得更深。rotate 有个 resample 参数,默认值是 NEAREST——就近抓一个像素顶上,不做任何混合。结果就是斜边全是台阶状的锯齿。
上面的实测数据说明了这一点:原图 223 种颜色,rotate(30) 之后 224 种(多的那一种是填充的黑色),说明它一个新颜色都没造;换成 resample=Image.Resampling.BICUBIC 之后变成 2144 种,多出来的近两千种就是它在边缘算出来的平滑过渡色。
# 默认:快,但边缘有锯齿
rotated = img.rotate(30, expand=True)
# 想要好看:手动指定平滑算法
rotated = img.rotate(30, expand=True, resample=Image.Resampling.BICUBIC)特别要注意的是 resize 的默认值是 BICUBIC(平滑),rotate 的默认值是 NEAREST(不平滑),两个函数不一致。这是官方文档里一行小字带过、但实际影响很大的差异。
5.7 旋转方向是逆时针 #
rotate(90) 是逆时针转 90 度。上面的实测里,左上角的红块转完跑到了左下角,验证了这一点。
想顺时针转 90 度,可以写 rotate(-90),或者用 transpose(Image.Transpose.ROTATE_270)。
6. 重采样:缩放质量的关键 #
「重采样」(resample)说的是:把图从一个尺寸变成另一个尺寸时,新图的每个像素该取什么值。这个选择直接决定了缩放后好不好看,而大部分教程只是简单说一句「用 LANCZOS 最好」,没说清楚代价和例外。
6.1 五种算法的取舍 #
Pillow 提供五种算法,本质区别是算新像素时参考了周围多少个原像素:
| 算法 | 参考范围 | 特点 |
|---|---|---|
NEAREST |
1 个 | 直接抄最近的那个像素,最快,边缘硬 |
BOX |
落在同一格里的全部 | 求平均,缩小时表现不错 |
BILINEAR |
2×2 | 加权平均,比较柔和 |
BICUBIC |
4×4 | 更平滑,resize 的默认值 |
LANCZOS |
更大的窗口 | 质量最高,缩小首选,最慢 |
参考的像素越多,过渡越自然,但计算量也越大。
6.2 实测对比 #
光看表格很难有体感。下面这段代码造了一张「密集条纹 + 同心圆」的图——细条纹是检验缩放质量的最佳素材,因为它最容易在缩小时糊掉甚至整段消失。代码分三组实验:比较五种算法的耗时和产生的颜色数量、验证缩小时哪种算法会丢信息、以及放大时各自的表现。
import time
from PIL import Image, ImageDraw
# 造一张细节很密的图:黑白相间的细条纹最能暴露缩放算法的差别
src = Image.new("RGB", (400, 400), "white")
# 绑定画笔
draw = ImageDraw.Draw(src)
# 每隔 4 像素画一条黑竖线,就形成了密集条纹
for x in range(0, 400, 4):
# 线宽 2、间隔 4,正好黑白各占一半
draw.line((x, 0, x, 400), fill="black", width=2)
# 再叠一圈同心圆,斜向的边缘也能看出锯齿
for r in range(20, 200, 12):
# 圆心固定在 (200,200),半径每次加 12
draw.ellipse((200 - r, 200 - r, 200 + r, 200 + r), outline="red", width=2)
print("原图:", src.size, ",里面是密集条纹 + 同心圆")
print()
print("=" * 66)
print("一、五种重采样算法,缩小到 100x100")
print("=" * 66)
print("算法 耗时(ms) 颜色种类 说明")
print("-" * 66)
# 这几个算法从快到慢、从糙到精排列
algos = [
("NEAREST", Image.Resampling.NEAREST, "直接抄最近的那个像素,最快最糙"),
("BOX", Image.Resampling.BOX, "把落在一起的像素求平均"),
("BILINEAR", Image.Resampling.BILINEAR, "取周围 2x2 个像素加权平均"),
("BICUBIC", Image.Resampling.BICUBIC, "取周围 4x4 个像素,更平滑"),
("LANCZOS", Image.Resampling.LANCZOS, "窗口更大的高质量算法,缩小首选"),
]
results = {}
for name, algo, desc in algos:
# 重复几次取平均,单次太快测不准
t0 = time.perf_counter()
for _ in range(20):
# 把 400x400 缩到 100x100,也就是缩到 1/4
out = src.resize((100, 100), algo)
# 总耗时除以次数,再换算成毫秒
dt = (time.perf_counter() - t0) / 20 * 1000
# 颜色种类能反映算法「混合」了多少:混得越多,过渡色越多
n_colors = len(out.getcolors(maxcolors=1000000))
# 留着后面用
results[name] = out
print(f"{name:10s} {dt:8.3f} {n_colors:6d} {desc}")
print()
print("NEAREST 的颜色种类最少,因为它只是挑像素,一个新颜色都不造;")
print("LANCZOS 造出大量过渡色,这些过渡色就是「看起来平滑」的来源。")
print()
print("=" * 66)
print("二、缩小时 NEAREST 会丢内容,看得见")
print("=" * 66)
# 条纹周期是 4 像素,缩小到 1/4 之后,NEAREST 可能整片只抄到白的或整片只抄到黑的
# 用灰度模式,这样每个像素只有一个数字,算平均亮度方便
stripe = Image.new("L", (400, 20), 255)
d2 = ImageDraw.Draw(stripe)
for x in range(0, 400, 4):
# 宽 2 的黑线 + 宽 2 的白间隔,黑白严格各占一半
d2.line((x, 0, x, 20), fill=0, width=2)
print("原图是黑白各半的条纹,平均亮度应该在 128 左右")
import statistics
# get_flattened_data() 返回所有像素值组成的元组,直接求平均就是平均亮度
orig_mean = statistics.mean(stripe.get_flattened_data())
print(f" 原图平均亮度: {orig_mean:.1f}")
for name, algo, _ in algos:
# 缩到 1/4,每 4 个原像素合成 1 个新像素
small = stripe.resize((100, 20), algo)
# 缩完再算一次平均亮度,理论上应该还是 128
m = statistics.mean(small.get_flattened_data())
# 偏离超过 25 就说明这个算法把信息弄丢了
flag = " <- 亮度跑偏了,说明丢信息了" if abs(m - orig_mean) > 25 else ""
print(f" 缩到 1/4 后 {name:9s} 平均亮度: {m:6.1f}{flag}")
print()
print("=" * 66)
print("三、放大时该选谁")
print("=" * 66)
# 造一个很小的图,放大看效果
tiny = Image.new("RGB", (4, 4), "white")
dt3 = ImageDraw.Draw(tiny)
# 左上角 2x2 涂红
dt3.rectangle((0, 0, 1, 1), fill="red")
# 右下角 2x2 涂蓝,这样就有了明确的硬边缘
dt3.rectangle((2, 2, 3, 3), fill="blue")
print("原图 4x4,放大 20 倍到 80x80:")
for name, algo, _ in algos:
# 4x4 放大 20 倍
big = tiny.resize((80, 80), algo)
# 颜色种类越多,说明边缘被抹得越平滑
n = len(big.getcolors(maxcolors=1000000))
print(f" {name:9s} 颜色种类 {n:5d}", end="")
if name == "NEAREST":
print(" <- 保持硬边缘,像素风、二维码、色块图用这个")
elif name == "BICUBIC":
print(" <- 边缘平滑,照片放大用这个")
else:
print()
print()
print("=" * 66)
print("四、一句话结论")
print("=" * 66)
print(" 缩小照片 -> LANCZOS(默认给的 BICUBIC 也够用)")
print(" 放大照片 -> BICUBIC")
print(" 像素画 / 二维码 / 色块 -> NEAREST,别让它平滑")
print(" 只在乎速度、图很小 -> NEAREST")
print()
print("resize 不写 resample 参数时,默认是 BICUBIC,对大多数情况都合适。")
print("要注意的是 rotate 的默认值是 NEAREST,和 resize 不一样,别记混。")原图: (400, 400) ,里面是密集条纹 + 同心圆
==================================================================
一、五种重采样算法,缩小到 100x100
==================================================================
算法 耗时(ms) 颜色种类 说明
------------------------------------------------------------------
NEAREST 0.010 2 直接抄最近的那个像素,最快最糙
BOX 0.298 62 把落在一起的像素求平均
BILINEAR 0.490 990 取周围 2x2 个像素加权平均
BICUBIC 0.890 1757 取周围 4x4 个像素,更平滑
LANCZOS 1.341 2811 窗口更大的高质量算法,缩小首选
NEAREST 的颜色种类最少,因为它只是挑像素,一个新颜色都不造;
LANCZOS 造出大量过渡色,这些过渡色就是「看起来平滑」的来源。
==================================================================
二、缩小时 NEAREST 会丢内容,看得见
==================================================================
原图是黑白各半的条纹,平均亮度应该在 128 左右
原图平均亮度: 127.5
缩到 1/4 后 NEAREST 平均亮度: 255.0 <- 亮度跑偏了,说明丢信息了
缩到 1/4 后 BOX 平均亮度: 128.0
缩到 1/4 后 BILINEAR 平均亮度: 128.0
缩到 1/4 后 BICUBIC 平均亮度: 128.0
缩到 1/4 后 LANCZOS 平均亮度: 128.0
==================================================================
三、放大时该选谁
==================================================================
原图 4x4,放大 20 倍到 80x80:
NEAREST 颜色种类 3 <- 保持硬边缘,像素风、二维码、色块图用这个
BOX 颜色种类 3
BILINEAR 颜色种类 328
BICUBIC 颜色种类 755 <- 边缘平滑,照片放大用这个
LANCZOS 颜色种类 1414
==================================================================
四、一句话结论
==================================================================
缩小照片 -> LANCZOS(默认给的 BICUBIC 也够用)
放大照片 -> BICUBIC
像素画 / 二维码 / 色块 -> NEAREST,别让它平滑
只在乎速度、图很小 -> NEAREST
resize 不写 resample 参数时,默认是 BICUBIC,对大多数情况都合适。
要注意的是 rotate 的默认值是 NEAREST,和 resize 不一样,别记混。6.3 从数据里能看出什么 #
耗时差距在实际项目里可以忽略。 NEAREST 0.010 毫秒,LANCZOS 1.319 毫秒,看起来差了 130 倍,但绝对值都在毫秒级。处理一千张图也就多花一秒多。所以除非有特殊理由,质量优先。
「颜色种类」这一列反映了算法做了多少混合。 NEAREST 只有 2 种,说明它纯粹在挑像素,一个新颜色都没造;LANCZOS 造出 2811 种过渡色,这些过渡色就是「看起来平滑」的物理来源。
第二组实验最有说服力。 一张黑白各占一半的条纹图,平均亮度应该是 127.5。缩小到 1/4 之后:
- 用
NEAREST,平均亮度变成了 255.0——整张图变成了纯白,黑条纹全部消失 - 用其他任何算法,平均亮度都是 128.0,信息保住了
这就是 NEAREST 在缩小时会丢内容的直观证据。它每 4 个像素只抄 1 个,恰好每次都抄到了白的,黑的就凭空蒸发了。真实场景里这会表现为「缩小后的图出现莫名其妙的摩尔纹」或者「细线条整段消失」。
6.4 怎么选 #
把上面的结论浓缩成一张表。核心判断只有一个:你的图是「连续变化的内容」还是「一格一格的内容」。照片属于前者,需要平滑;像素画、二维码、纯色图标属于后者,平滑反而会毁掉它。
| 场景 | 选择 |
|---|---|
| 缩小照片 | LANCZOS(不写的话默认 BICUBIC 也够用) |
| 放大照片 | BICUBIC |
| 像素画、二维码、纯色块图 | NEAREST,就是要硬边缘,别让它平滑 |
| 图很小、只在乎速度 | NEAREST |
放大像素画是 NEAREST 唯一真正优于其他算法的场景:你要的就是清晰的大方块,用 LANCZOS 反而会糊成一团。
再强调一次容易记混的一点:resize 默认 BICUBIC,rotate 默认 NEAREST。
7. 颜色模式与转换 #
第 2.2 节讲了模式是什么,这一章动手验证,并解决实际转换中的问题。
7.1 完整演示 #
下面这段代码把五种模式挨个造一遍,量出各自的内存占用,验证灰度转换公式,对比二值化的两条路线,并且揭开 P 模式和 A 通道的内部结构。所有结论都能从输出里直接读到数字。
from PIL import Image, ImageDraw
print("=" * 66)
print("一、常见的 5 种颜色模式,每个像素分别存几个数字")
print("=" * 66)
print("模式 每像素 取值范围 含义")
print("-" * 66)
# 把五种模式的关键信息列成表,下面循环打印
info = [
("1", "1 位", "0 或 1", "黑白二值,只有纯黑和纯白"),
("L", "1 字节", "0 ~ 255", "灰度,0 是黑,255 是白"),
("P", "1 字节", "0 ~ 255", "调色板,数字是「第几号颜色」的编号"),
("RGB", "3 字节", "各 0 ~ 255", "红绿蓝三通道"),
("RGBA", "4 字节", "各 0 ~ 255", "红绿蓝 + 透明度,A 为 0 是全透明"),
]
for mode, per, rng, desc in info:
print(f"{mode:6s} {per:8s} {rng:13s} {desc}")
print()
print("看看每种模式的像素长什么样:")
for mode in ["1", "L", "P", "RGB", "RGBA"]:
# 不给颜色参数,各模式都会填 0(也就是黑)
im = Image.new(mode, (4, 4))
# getpixel 返回的东西,模式不同长得不一样
print(f" {mode:5s} getpixel((0,0)) 返回 {im.getpixel((0, 0))!r:20s} 类型 {type(im.getpixel((0, 0))).__name__}")
print()
print("=" * 66)
print("二、同一张图,不同模式占多少内存")
print("=" * 66)
# 用一张一百万像素的图当基准,数字好换算
base = Image.new("RGB", (1000, 1000), "red")
for mode in ["1", "L", "P", "RGB", "RGBA"]:
# 转成目标模式,内容不重要,只看占多少字节
conv = base.convert(mode)
# tobytes 拿到原始像素数据,长度就是实际占的字节数
n = len(conv.tobytes())
print(f" 1000x1000 的 {mode:5s} 图: {n:9,d} 字节 ({n / 1024 / 1024:.2f} MB)")
print()
print("处理大图内存吃紧时,能用 L 就别用 RGB,能省 2/3。")
print()
print("=" * 66)
print("三、彩色转灰度:convert('L') 用的是什么公式")
print("=" * 66)
# 造几个纯色,看转灰度之后变成多少
samples = [("纯红", (255, 0, 0)), ("纯绿", (0, 255, 0)), ("纯蓝", (0, 0, 255)),
("白", (255, 255, 255)), ("中灰", (128, 128, 128))]
print("颜色 RGB 转灰度后 手算 0.299R+0.587G+0.114B")
print("-" * 66)
for name, rgb in samples:
# 造一个 1x1 的纯色图,只是为了拿它做转换
px = Image.new("RGB", (1, 1), rgb)
# 转成灰度后读出那唯一一个像素的值
gray = px.convert("L").getpixel((0, 0))
# 这是电视信号沿用下来的亮度公式,绿色权重最大是因为人眼对绿最敏感
manual = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]
print(f"{name:6s} {str(rgb):18s} {gray:6d} {manual:8.2f}")
print()
print("绿色的系数最大(0.587),因为人眼对绿色最敏感;")
print("所以纯绿转灰度后是 150 左右,比纯红的 76 亮得多。")
print()
print("=" * 66)
print("四、转成二值图 '1':两种做法差别很大")
print("=" * 66)
# 造一张有渐变的灰度图
# 宽度正好 256,这样第 x 列的亮度就可以直接设成 x
grad = Image.new("L", (256, 20))
d = ImageDraw.Draw(grad)
for x in range(256):
# 第 x 列填亮度 x,得到一条从纯黑到纯白的完美渐变
d.line((x, 0, x, 20), fill=x)
# 做法 A:直接 convert('1'),Pillow 会做「抖动」,用黑白点的疏密模拟灰度
a = grad.convert("1")
# 做法 B:自己定阈值,超过 128 算白,否则算黑
b = grad.point(lambda v: 255 if v > 128 else 0, mode="1")
# 做法 C:convert('1') 关掉抖动
c = grad.convert("1", dither=Image.Dither.NONE)
print("下面每一行是从纯黑(左)到纯白(右)横扫一遍的结果,█ 代表黑,· 代表白:")
print()
# 四行分别是:原始渐变、抖动版、阈值版、关抖动版
for label, im in [("原始灰度(示意) ", grad),
("convert('1') 默认开抖动 ", a),
("point 自定阈值 128 ", b),
("convert('1', dither=NONE)", c)]:
# 沿着整条渐变均匀取 48 个点,这样才能看到从黑到白的完整过程
row = [im.getpixel((x, 5)) for x in range(0, 256, 256 // 48)]
if im.mode == "L":
# 灰度图用深浅不同的方块表示,方便和下面的二值结果对照
art = "".join("█" if v < 64 else "▓" if v < 128 else "▒" if v < 192 else "·"
for v in row)
else:
# 二值图只有 0 和 255 两种值,0 画成方块
art = "".join("█" if v == 0 else "·" for v in row)
print(f" {label}: {art}")
print()
print("抖动版本用黑白点的疏密去模拟灰阶,远看有层次;")
print("阈值版本是一刀切,左边全黑右边全白,适合做扫描件、二维码这类。")
print()
print("=" * 66)
print("五、P 模式(调色板):像素存的是编号,不是颜色")
print("=" * 66)
colorful = Image.new("RGB", (60, 60))
dp = ImageDraw.Draw(colorful)
# 画几个不同颜色的块
for i, col in enumerate([(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0)]):
# 四个宽 15 的竖条,颜色各不相同
dp.rectangle((i * 15, 0, i * 15 + 14, 59), fill=col)
# 转成调色板模式,Pillow 会自动生成一张调色板
p = colorful.convert("P")
print("RGB 图第 0 个像素:", colorful.getpixel((0, 0)), " <- 直接就是颜色")
print("P 图第 0 个像素:", p.getpixel((0, 0)), " <- 这是调色板里的编号")
# getpalette 拿到调色板,每 3 个数字是一个颜色
palette = p.getpalette()
# 先拿到这个像素存的编号
idx = p.getpixel((0, 0))
print(f"查一下 {idx} 号颜色是:", tuple(palette[idx * 3: idx * 3 + 3]))
print()
print("GIF 只支持 P 模式,最多 256 种颜色,所以照片存成 GIF 会明显掉色。")
# 验证一下颜色被压缩了多少
rich = Image.new("RGB", (100, 100))
dr = ImageDraw.Draw(rich)
for x in range(100):
for y in range(100):
# 让每个像素的颜色都跟坐标挂钩,这样一万个像素几乎没有重样的
dr.point((x, y), fill=(x * 2, y * 2, (x + y)))
print(f" 一张有 {len(rich.getcolors(maxcolors=100000))} 种颜色的图,")
print(f" 转成 P 模式后只剩 {len(rich.convert('P').convert('RGB').getcolors(maxcolors=100000))} 种。")
print()
print("=" * 66)
print("六、RGBA 的 A 通道:透明度")
print("=" * 66)
# 造一张半透明的红色图
half = Image.new("RGBA", (10, 10), (255, 0, 0, 128))
print("半透明红色像素:", half.getpixel((0, 0)), " 最后那个 128 就是半透明")
# split 把各个通道拆开
r, g, b, alpha = half.split()
print("拆出来的 alpha 通道模式:", alpha.mode, " 第一个值:", alpha.getpixel((0, 0)))
print()
print("A = 0 完全透明")
print("A = 128 半透明")
print("A = 255 完全不透明")
print()
print("convert('RGB') 做的事情很简单粗暴:把 A 通道直接扔掉,RGB 三个值原样保留。")
# 透明区底下藏着什么颜色,转完就露出什么颜色
for label, rgba in [("透明区底下是黑色 (0,0,0,0) ", (0, 0, 0, 0)),
("透明区底下是白色 (255,255,255,0)", (255, 255, 255, 0))]:
# 造一张全透明的图,只是底下的 RGB 值不同,看转完露出什么颜色
got = Image.new("RGBA", (4, 4), rgba).convert("RGB").getpixel((0, 0))
print(f" {label} -> convert('RGB') -> {got}")
print()
print("绝大多数绘图软件导出 PNG 时,透明像素底下存的就是黑色,")
print("所以「PNG 转 JPEG 之后透明背景变黑」才会成为最经典的坑之一。")
print("正确做法是先铺一层白底再合成,第 12 章会讲。")==================================================================
一、常见的 5 种颜色模式,每个像素分别存几个数字
==================================================================
模式 每像素 取值范围 含义
------------------------------------------------------------------
1 1 位 0 或 1 黑白二值,只有纯黑和纯白
L 1 字节 0 ~ 255 灰度,0 是黑,255 是白
P 1 字节 0 ~ 255 调色板,数字是「第几号颜色」的编号
RGB 3 字节 各 0 ~ 255 红绿蓝三通道
RGBA 4 字节 各 0 ~ 255 红绿蓝 + 透明度,A 为 0 是全透明
看看每种模式的像素长什么样:
1 getpixel((0,0)) 返回 0 类型 int
L getpixel((0,0)) 返回 0 类型 int
P getpixel((0,0)) 返回 0 类型 int
RGB getpixel((0,0)) 返回 (0, 0, 0) 类型 tuple
RGBA getpixel((0,0)) 返回 (0, 0, 0, 0) 类型 tuple
==================================================================
二、同一张图,不同模式占多少内存
==================================================================
1000x1000 的 1 图: 125,000 字节 (0.12 MB)
1000x1000 的 L 图: 1,000,000 字节 (0.95 MB)
1000x1000 的 P 图: 1,000,000 字节 (0.95 MB)
1000x1000 的 RGB 图: 3,000,000 字节 (2.86 MB)
1000x1000 的 RGBA 图: 4,000,000 字节 (3.81 MB)
处理大图内存吃紧时,能用 L 就别用 RGB,能省 2/3。
==================================================================
三、彩色转灰度:convert('L') 用的是什么公式
==================================================================
颜色 RGB 转灰度后 手算 0.299R+0.587G+0.114B
------------------------------------------------------------------
纯红 (255, 0, 0) 76 76.24
纯绿 (0, 255, 0) 150 149.69
纯蓝 (0, 0, 255) 29 29.07
白 (255, 255, 255) 255 255.00
中灰 (128, 128, 128) 128 128.00
绿色的系数最大(0.587),因为人眼对绿色最敏感;
所以纯绿转灰度后是 150 左右,比纯红的 76 亮得多。
==================================================================
四、转成二值图 '1':两种做法差别很大
==================================================================
下面每一行是从纯黑(左)到纯白(右)横扫一遍的结果,█ 代表黑,· 代表白:
原始灰度(示意) : █████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒·············
convert('1') 默认开抖动 : ███·████████·███████·█·██·█·███··█····█·············
point 自定阈值 128 : ██████████████████████████··························
convert('1', dither=NONE): ██████████████████████████··························
抖动版本用黑白点的疏密去模拟灰阶,远看有层次;
阈值版本是一刀切,左边全黑右边全白,适合做扫描件、二维码这类。
==================================================================
五、P 模式(调色板):像素存的是编号,不是颜色
==================================================================
RGB 图第 0 个像素: (255, 0, 0) <- 直接就是颜色
P 图第 0 个像素: 15 <- 这是调色板里的编号
查一下 15 号颜色是: (255, 0, 0)
GIF 只支持 P 模式,最多 256 种颜色,所以照片存成 GIF 会明显掉色。
一张有 10000 种颜色的图,
转成 P 模式后只剩 77 种。
==================================================================
六、RGBA 的 A 通道:透明度
==================================================================
半透明红色像素: (255, 0, 0, 128) 最后那个 128 就是半透明
拆出来的 alpha 通道模式: L 第一个值: 128
A = 0 完全透明
A = 128 半透明
A = 255 完全不透明
convert('RGB') 做的事情很简单粗暴:把 A 通道直接扔掉,RGB 三个值原样保留。
透明区底下是黑色 (0,0,0,0) -> convert('RGB') -> (0, 0, 0)
透明区底下是白色 (255,255,255,0) -> convert('RGB') -> (255, 255, 255)
绝大多数绘图软件导出 PNG 时,透明像素底下存的就是黑色,
所以「PNG 转 JPEG 之后透明背景变黑」才会成为最经典的坑之一。
正确做法是先铺一层白底再合成,第 12 章会讲。7.2 内存占用:能省则省 #
同样是 1000×1000 的图,L 模式占 0.95 MB,RGB 占 2.86 MB,RGBA 占 3.81 MB。做灰度处理时先转 L,内存直接省掉 2/3,速度也会跟着快。
处理超大图(比如几千万像素的扫描件)时,这个差别会从「无所谓」变成「跑不跑得起来」。
7.3 彩色转灰度用的是什么公式 #
convert('L') 用的是从电视信号时代沿用下来的亮度公式:
$$ L = 0.299 R + 0.587 G + 0.114 B $$
三个系数不相等,是因为人眼对不同颜色的敏感度不同——对绿色最敏感,对蓝色最迟钝。实测验证了这一点:
- 纯红
(255, 0, 0)→ 灰度 76 - 纯绿
(0, 255, 0)→ 灰度 150 - 纯蓝
(0, 0, 255)→ 灰度 29
同样是「满格」的纯色,绿色转出来的亮度是蓝色的五倍多。如果用简单平均 $(R+G+B)/3$,三者都会是 85,转出来的灰度图看起来会很不自然。
7.4 转二值图:抖动还是阈值 #
转成 1 模式(只有纯黑纯白)有两条路,效果差别很大:
# 路线一:convert('1'),默认开启「抖动」
binary = gray.convert("1")
# 路线二:自己定阈值,一刀切
binary = gray.point(lambda v: 255 if v > 128 else 0, mode="1")
# 路线三:convert 但关掉抖动,等价于阈值 128 一刀切
binary = gray.convert("1", dither=Image.Dither.NONE)抖动(dithering)是用黑白点的疏密去模拟灰阶:该显示 30% 灰的地方,就撒 30% 的黑点。远看有层次感,近看是噪点。上面输出里那一行 ███·████████·███████·█·██·█·███··█····█··· 就是抖动的效果,从密到疏平滑过渡。
阈值是一刀切:超过 128 全白,否则全黑。输出是干净的 ██████████████████████████······,中间没有过渡。
怎么选:做扫描件、二维码、文字识别的预处理,用阈值,因为你要的是干净的黑白块;做复古像素效果、要在只能显示黑白的设备上呈现照片,用抖动。
7.5 RGBA 转 RGB 会发生什么 #
这是「PNG 转 JPEG 背景变黑」这个经典问题的根源。
convert('RGB') 做的事情非常简单粗暴:把 A 通道直接扔掉,RGB 三个值原样保留。它不会做任何合成。
那透明区域会变成什么颜色?取决于透明像素底下藏着的 RGB 值是什么。实测:
- 透明区底下是
(0,0,0,0)→ 转完变成黑色 - 透明区底下是
(255,255,255,0)→ 转完变成白色
而绝大多数绘图软件导出 PNG 时,透明像素底下存的就是黑色。所以你会看到透明背景一转 JPEG 就变黑。
正确的做法是先铺一层底色做合成,第 12.4 节会给出两种写法。
8. 调色与滤镜 #
ImageEnhance 负责整体调节(亮度、对比度、饱和度、锐度),ImageFilter 负责各种效果(模糊、锐化、找边缘)。这一章把它们放在一起讲,因为实际用的时候经常要搭配。
8.1 用什么衡量效果 #
图像效果本来是要用眼睛看的,但在纯文字的教程里没法贴图。下面的例子用两个统计量来量化:
- 平均亮度:所有像素的平均值,反映整体明暗
- 标准差:像素值偏离平均的程度,反映反差大小。标准差大 = 明暗对比强烈;标准差小 = 灰蒙蒙一片
有了这两个数字,「变亮了」「反差变大了」「变糊了」这些说法就都能被验证。
8.2 完整演示 #
下面这段代码把四个增强器的三档强度、两类滤镜的全部成员、以及 ImageOps 的四个函数各跑一遍,每一项都打出平均亮度和标准差。你可以横着对比同一个增强器不同倍数的效果,也可以竖着对比不同滤镜之间的差异。
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageOps, ImageStat
def make_test_image(width=320, height=240):
"""造测试图,和第 5 章的函数一样,保证脚本能独立跑。"""
# 白底 RGB 画布
img = Image.new("RGB", (width, height), "white")
# 绑定画笔
draw = ImageDraw.Draw(img)
for x in range(width):
# t 是 0 到 1 的横向进度
t = x / width
# 逐列画竖线,三个通道按不同斜率变化就成了渐变
draw.line((x, 0, x, height),
fill=(int(30 + 60 * t), int(60 + 140 * t), int(120 + 100 * t)))
# 红矩形
draw.rectangle((20, 20, 100, 80), fill=(220, 50, 50))
# 黄圆,参数给的是外接矩形
draw.ellipse((200, 20, 280, 100), fill=(240, 200, 40))
# 白三角,给三个顶点
draw.polygon([(160, 130), (120, 200), (200, 200)], fill=(255, 255, 255))
# 加一圈细线,滤镜的效果在细节上最明显
for i in range(0, width, 6):
# 每隔 6 像素画一条短斜线
draw.line((i, height - 40, i + 20, height), fill=(20, 20, 20))
return img
def describe(label, im):
"""用几个统计量描述一张图,方便在纯文字环境里比较效果。"""
# ImageStat 能算出每个通道的均值和标准差
st = ImageStat.Stat(im)
# 均值代表整体明暗,标准差代表反差大小
mean = sum(st.mean) / len(st.mean)
stddev = sum(st.stddev) / len(st.stddev)
print(f" {label:26s} 平均亮度 {mean:6.1f} 反差(标准差) {stddev:6.2f}")
# 造出这一章要反复用到的测试图
img = make_test_image()
print("=" * 70)
print("一、ImageEnhance:四个调节旋钮")
print("=" * 70)
print("用法都一样:先包一层 Enhancer,再调 enhance(倍数)。")
print("倍数 1.0 表示不变,小于 1 减弱,大于 1 增强。")
print()
# 先记下原图的两个指标,作为后面所有对比的基准
describe("原图", img)
print()
print("Brightness 亮度:")
# 包一层亮度增强器,它记住了这张图
bright = ImageEnhance.Brightness(img)
for factor in [0.5, 1.0, 1.5]:
# enhance 每次都返回一张新图,可以反复调不同倍数
describe(f"enhance({factor})", bright.enhance(factor))
print(" 亮度就是把每个像素值乘上倍数:0.5 倍时平均亮度差不多也减半。")
print(" 但 1.5 倍时平均亮度并没有到 186,因为像素最大只能到 255,")
print(" 本来就很亮的地方乘完超过 255 会被截断,这部分细节就永久丢了。")
print()
print("Contrast 对比度:")
contrast = ImageEnhance.Contrast(img)
for factor in [0.3, 1.0, 2.0]:
describe(f"enhance({factor})", contrast.enhance(factor))
print(" 对比度改的是「离平均亮度多远」,所以标准差变了,平均亮度基本不变")
print(" enhance(0) 会把整张图变成一片纯灰:")
describe("enhance(0)", contrast.enhance(0))
print()
print("Color 饱和度:")
color = ImageEnhance.Color(img)
for factor in [0.0, 1.0, 2.0]:
describe(f"enhance({factor})", color.enhance(factor))
print(" enhance(0) 等于把图变成灰度(但模式还是 RGB)")
# 把饱和度降到 0,看看彩色的红块变成了什么
zero_color = color.enhance(0.0)
# (60, 50) 这个位置在红矩形里面
px = zero_color.getpixel((60, 50))
print(f" 原来的红块 {img.getpixel((60, 50))} 变成了 {px}")
print(f" 三个通道都一样,说明确实是灰的: {px[0] == px[1] == px[2]}")
print()
print("Sharpness 锐度:")
sharp = ImageEnhance.Sharpness(img)
for factor in [0.0, 1.0, 3.0]:
describe(f"enhance({factor})", sharp.enhance(factor))
print(" 锐度加大会强化边缘,标准差跟着变大;调到 0 相当于轻微模糊")
print()
print("=" * 70)
print("二、ImageFilter:现成的滤镜")
print("=" * 70)
print("分两类:直接用的常量,和需要传参数的类。")
print()
print("第一类,直接当参数传进 filter():")
# 这八个是 Pillow 自带的固定强度滤镜
for name in ["BLUR", "SMOOTH", "SHARPEN", "DETAIL", "EDGE_ENHANCE", "CONTOUR",
"FIND_EDGES", "EMBOSS"]:
# getattr 是为了循环里按名字取常量,实际用时直接写 ImageFilter.BLUR 就行
out = img.filter(getattr(ImageFilter, name))
describe(f"ImageFilter.{name}", out)
print()
print("第二类,要先实例化再传,因为得给参数:")
# 高斯模糊,radius 越大越糊
for radius in [1, 3, 8]:
# 每个半径跑一次,看标准差怎么随半径下降
describe(f"GaussianBlur(radius={radius})", img.filter(ImageFilter.GaussianBlur(radius)))
print(" 半径越大越糊,反差(标准差)越小")
print()
# 锐化蒙版,是修图软件里最常用的锐化方式
describe("UnsharpMask() 默认参数", img.filter(ImageFilter.UnsharpMask()))
# percent 是锐化强度,radius 是影响范围,两个一起调才好用
describe("UnsharpMask(radius=2, percent=200)",
img.filter(ImageFilter.UnsharpMask(radius=2, percent=200)))
print()
# 中值滤波,专门去椒盐噪点
describe("MedianFilter(size=3) 去噪点", img.filter(ImageFilter.MedianFilter(size=3)))
print(" MedianFilter 取邻域的中位数,对付零星噪点比模糊好,因为它不糊边缘")
print()
print("=" * 70)
print("三、几个常用的滤镜怎么选")
print("=" * 70)
print(" 想让图柔和一点 -> GaussianBlur(radius=1~2)")
print(" 想做毛玻璃背景 -> GaussianBlur(radius=10 以上)")
print(" 想让图清楚一点 -> UnsharpMask(),比 SHARPEN 可控")
print(" 想去掉零星噪点 -> MedianFilter(size=3)")
print(" 想提取轮廓做特效 -> FIND_EDGES 或 CONTOUR")
print()
print("BLUR、SHARPEN 这些常量滤镜的强度是写死的,调不了,")
print("真要精细控制就用 GaussianBlur 和 UnsharpMask 这种带参数的。")
print()
print("=" * 70)
print("四、ImageOps 里几个一步到位的处理")
print("=" * 70)
describe("原图", img)
# 自动对比度:把最暗的拉到 0,最亮的拉到 255,自动铺满整个范围
describe("autocontrast 自动对比度", ImageOps.autocontrast(img))
# 反色
describe("invert 反色", ImageOps.invert(img))
# 转灰度,和 convert('L') 效果一样
describe("grayscale 转灰度", ImageOps.grayscale(img).convert("RGB"))
# 均衡化:让直方图变平,暗部细节会被拉出来
describe("equalize 直方图均衡", ImageOps.equalize(img))
print()
print("autocontrast 对「拍得灰蒙蒙」的照片很有效,一行就能救回来;")
print("equalize 拉伸得更狠,容易出现不自然的颜色,慎用。")
print()
print("=" * 70)
print("五、这些操作都不改原图")
print("=" * 70)
print("原图平均亮度还是:", round(sum(ImageStat.Stat(img).mean) / 3, 1))
print("上面做了这么多操作,原图一点没变,因为它们全都返回新图。")======================================================================
一、ImageEnhance:四个调节旋钮
======================================================================
用法都一样:先包一层 Enhancer,再调 enhance(倍数)。
倍数 1.0 表示不变,小于 1 减弱,大于 1 增强。
原图 平均亮度 124.0 反差(标准差) 60.55
Brightness 亮度:
enhance(0.5) 平均亮度 61.8 反差(标准差) 30.24
enhance(1.0) 平均亮度 124.0 反差(标准差) 60.55
enhance(1.5) 平均亮度 169.5 反差(标准差) 67.43
亮度就是把每个像素值乘上倍数:0.5 倍时平均亮度差不多也减半。
但 1.5 倍时平均亮度并没有到 186,因为像素最大只能到 255,
本来就很亮的地方乘完超过 255 会被截断,这部分细节就永久丢了。
Contrast 对比度:
enhance(0.3) 平均亮度 121.4 反差(标准差) 18.20
enhance(1.0) 平均亮度 124.0 反差(标准差) 60.55
enhance(2.0) 平均亮度 122.8 反差(标准差) 90.44
对比度改的是「离平均亮度多远」,所以标准差变了,平均亮度基本不变
enhance(0) 会把整张图变成一片纯灰:
enhance(0) 平均亮度 121.0 反差(标准差) 0.00
Color 饱和度:
enhance(0.0) 平均亮度 120.8 反差(标准差) 47.50
enhance(1.0) 平均亮度 124.0 反差(标准差) 60.55
enhance(2.0) 平均亮度 126.9 反差(标准差) 81.43
enhance(0) 等于把图变成灰度(但模式还是 RGB)
原来的红块 (220, 50, 50) 变成了 (101, 101, 101)
三个通道都一样,说明确实是灰的: True
Sharpness 锐度:
enhance(0.0) 平均亮度 124.0 反差(标准差) 58.07
enhance(1.0) 平均亮度 124.0 反差(标准差) 60.55
enhance(3.0) 平均亮度 125.6 反差(标准差) 63.42
锐度加大会强化边缘,标准差跟着变大;调到 0 相当于轻微模糊
======================================================================
二、ImageFilter:现成的滤镜
======================================================================
分两类:直接用的常量,和需要传参数的类。
第一类,直接当参数传进 filter():
ImageFilter.BLUR 平均亮度 124.0 反差(标准差) 56.67
ImageFilter.SMOOTH 平均亮度 124.0 反差(标准差) 58.07
ImageFilter.SHARPEN 平均亮度 125.2 反差(标准差) 63.08
ImageFilter.DETAIL 平均亮度 124.7 反差(标准差) 62.53
ImageFilter.EDGE_ENHANCE 平均亮度 128.5 反差(标准差) 65.91
ImageFilter.CONTOUR 平均亮度 243.8 反差(标准差) 47.62
ImageFilter.FIND_EDGES 平均亮度 17.3 反差(标准差) 53.30
ImageFilter.EMBOSS 平均亮度 128.1 反差(标准差) 25.22
第二类,要先实例化再传,因为得给参数:
GaussianBlur(radius=1) 平均亮度 123.9 反差(标准差) 57.33
GaussianBlur(radius=3) 平均亮度 124.0 反差(标准差) 55.37
GaussianBlur(radius=8) 平均亮度 124.0 反差(标准差) 51.16
半径越大越糊,反差(标准差)越小
UnsharpMask() 默认参数 平均亮度 126.1 反差(标准差) 64.73
UnsharpMask(radius=2, percent=200) 平均亮度 127.0 反差(标准差) 65.67
MedianFilter(size=3) 去噪点 平均亮度 126.7 反差(标准差) 58.01
MedianFilter 取邻域的中位数,对付零星噪点比模糊好,因为它不糊边缘
======================================================================
三、几个常用的滤镜怎么选
======================================================================
想让图柔和一点 -> GaussianBlur(radius=1~2)
想做毛玻璃背景 -> GaussianBlur(radius=10 以上)
想让图清楚一点 -> UnsharpMask(),比 SHARPEN 可控
想去掉零星噪点 -> MedianFilter(size=3)
想提取轮廓做特效 -> FIND_EDGES 或 CONTOUR
BLUR、SHARPEN 这些常量滤镜的强度是写死的,调不了,
真要精细控制就用 GaussianBlur 和 UnsharpMask 这种带参数的。
======================================================================
四、ImageOps 里几个一步到位的处理
======================================================================
原图 平均亮度 124.0 反差(标准差) 60.55
autocontrast 自动对比度 平均亮度 112.3 反差(标准差) 65.67
invert 反色 平均亮度 131.0 反差(标准差) 60.55
grayscale 转灰度 平均亮度 120.8 反差(标准差) 47.50
equalize 直方图均衡 平均亮度 130.4 反差(标准差) 76.61
autocontrast 对「拍得灰蒙蒙」的照片很有效,一行就能救回来;
equalize 拉伸得更狠,容易出现不自然的颜色,慎用。
======================================================================
五、这些操作都不改原图
======================================================================
原图平均亮度还是: 124.0
上面做了这么多操作,原图一点没变,因为它们全都返回新图。8.3 ImageEnhance 的四个旋钮 #
四个增强器的用法完全一样:先用图片包一个 Enhancer,再调 enhance(倍数)。倍数 1.0 表示原样,小于 1 减弱,大于 1 增强。
from PIL import ImageEnhance
# 第一步:用图片包一个「亮度增强器」,这一步本身不改图
enhancer = ImageEnhance.Brightness(img)
brighter = enhancer.enhance(1.5) # 同一个 enhancer 可以反复用
# 每次 enhance 都是从原图算起,不会在上一次结果上叠加
darker = enhancer.enhance(0.5)| 增强器 | 调的是什么 | enhance(0) 的效果 |
|---|---|---|
Brightness |
每个像素值乘以倍数 | 全黑 |
Contrast |
像素离平均亮度的距离 | 变成一片纯灰 |
Color |
饱和度 | 等于灰度图(但模式还是 RGB) |
Sharpness |
边缘的锐利程度 | 轻微模糊 |
从实测数据能看出它们的区别:调 Brightness 时平均亮度跟着变(124 → 61.8),标准差也跟着变;调 Contrast 时平均亮度基本不动(124 → 122.8),只有标准差变(60.55 → 90.44)。这正好对应了「亮度是整体平移,对比度是围绕平均值拉伸」。
一个要注意的截断问题:Brightness.enhance(1.5) 之后,平均亮度是 169.5,而不是 124 × 1.5 = 186。因为像素值最大只能到 255,本来就很亮的地方乘完超过 255 会被截断。这部分细节是永久丢失的,再乘回 1/1.5 也救不回来。所以提亮要适可而止。
8.4 ImageFilter 的两类滤镜 #
第一类是现成的常量,直接传给 filter():
# 常量直接传进 filter(),不用加括号实例化
blurred = img.filter(ImageFilter.BLUR)BLUR、SMOOTH、SHARPEN、DETAIL、EDGE_ENHANCE、CONTOUR、FIND_EDGES、EMBOSS 都属于这类。它们的强度是写死的,调不了。
从数据能看出各自在干什么:FIND_EDGES 之后平均亮度掉到 17.3(几乎全黑,只有边缘处是亮的);CONTOUR 之后升到 243.8(几乎全白,边缘是黑线);EMBOSS 之后标准差从 60.55 掉到 25.22(浮雕效果把颜色都压成了灰)。
第二类是需要传参数的类,得先实例化:
# radius 是模糊半径,数字越大越糊
blurred = img.filter(ImageFilter.GaussianBlur(radius=5))
# percent 是锐化强度,100 表示标准强度,200 就是加倍
sharpened = img.filter(ImageFilter.UnsharpMask(radius=2, percent=200))
# size 必须是奇数,表示取 3x3 邻域的中位数
denoised = img.filter(ImageFilter.MedianFilter(size=3))这类才是实际项目里真正常用的,因为强度可控:
GaussianBlur(radius):半径越大越糊。实测半径 1/3/8 对应标准差 57.33/55.37/51.16UnsharpMask():修图软件里标准的锐化方式,比SHARPEN可控得多MedianFilter(size):取邻域的中位数。专门对付零星噪点,好处是不会糊掉边缘,这一点模糊类滤镜做不到
8.5 ImageOps 里三个一步到位的函数 #
上面两类都需要你自己决定参数。ImageOps 里还有几个「不用调参、直接见效」的函数,适合快速修图:
from PIL import ImageOps
fixed = ImageOps.autocontrast(img) # 自动对比度
gray = ImageOps.grayscale(img) # 转灰度,等价于 convert('L')
inverted = ImageOps.invert(img) # 反色autocontrast 的原理是:找出图里最暗和最亮的值,把它们分别拉到 0 和 255,中间等比例拉伸。对「拍得灰蒙蒙」的照片一行就能救回来,实测标准差从 60.55 提到 65.67。
equalize(直方图均衡)拉伸得更狠,标准差直接到 76.61。它能把暗部细节强行拉出来,但很容易产生不自然的颜色,慎用。
注意 ImageOps.invert 不接受 RGBA 模式的图,得先转成 RGB。
9. 在图上画东西 #
ImageDraw 用来在图片上画线、画形状、写字。做水印、加标注、生成图表、画验证码都靠它。
9.1 固定套路 #
用 ImageDraw 永远是三步:
from PIL import Image, ImageDraw
img = Image.new("RGB", (400, 300), "white") # 1. 准备画布
draw = ImageDraw.Draw(img) # 2. 拿到绑在这张图上的画笔
draw.rectangle((10, 10, 50, 50), fill="red") # 3. 画,直接改 img关键点:draw 的所有方法都是原地修改绑定的那张图,没有返回值。所以不要写 img = draw.rectangle(...),那只会得到 None。
9.2 完整演示 #
下面这段代码把坐标系、七种图形、六种颜色写法、fill/outline/width 的关系,以及半透明绘制的正确姿势全部演示了一遍。每一步都用取像素的方式验证了结果,所以你能确认它画到的位置和你想的一致。
from PIL import Image, ImageDraw
print("=" * 68)
print("一、画图的固定套路:先有图,再有画笔")
print("=" * 68)
# 第一步,准备一张画布
img = Image.new("RGB", (400, 300), "white")
# 第二步,拿到绑在这张图上的画笔
draw = ImageDraw.Draw(img)
print("画笔对象:", type(draw).__name__)
print("它绑在哪张图上:", draw._image.size if hasattr(draw, "_image") else img.size)
print()
print("关键点:draw 的所有方法都直接改 img,没有返回值。")
# 故意接一下返回值,看看它是什么
ret = draw.rectangle((10, 10, 50, 50), fill="red")
print(" draw.rectangle(...) 的返回值:", ret)
print(" 但 img 已经被改了,(20,20) 处现在是:", img.getpixel((20, 20)))
print()
print("=" * 68)
print("二、坐标系:原点在左上角,y 轴朝下")
print("=" * 68)
print("这和数学课上的坐标系不一样,是图形学的惯例。")
canvas = Image.new("RGB", (200, 120), "white")
d = ImageDraw.Draw(canvas)
# 在四个角各画一个小方块,标出坐标
corners = [((0, 0), "red", "(0,0) 左上"), ((180, 0), "green", "(180,0) 右上"),
((0, 100), "blue", "(0,100) 左下"), ((180, 100), "orange", "(180,100) 右下")]
for (x, y), col, label in corners:
# 每个角画一个 20x20 的小方块
d.rectangle((x, y, x + 19, y + 19), fill=col)
print(f" 在 {str((x, y)):12s} 画了 {col:7s} -> {label}")
print()
print("所以 y 越大越靠下,(0,0) 是左上角那个像素。")
print()
print("=" * 68)
print("三、各种图形怎么画")
print("=" * 68)
img = Image.new("RGB", (400, 300), "white")
draw = ImageDraw.Draw(img)
# 直线:给起点和终点
draw.line((20, 20, 380, 20), fill="red", width=3)
print("line((x1,y1,x2,y2)) 画一条线,width 是粗细")
# 折线:给一串点,会依次连起来
draw.line([(20, 40), (100, 80), (180, 40), (260, 80)], fill="blue", width=2)
print("line([点1, 点2, 点3, ...]) 给一串点就画折线")
# 矩形:给左上和右下两个角
draw.rectangle((20, 100, 120, 160), fill="lightblue", outline="navy", width=3)
print("rectangle((左,上,右,下)) fill 填充色,outline 边框色")
# 圆角矩形
draw.rounded_rectangle((140, 100, 240, 160), radius=15, fill="lightgreen", outline="darkgreen", width=3)
print("rounded_rectangle(..., radius=) 圆角矩形,radius 是圆角半径")
# 椭圆:给的是它的外接矩形,不是圆心和半径
draw.ellipse((260, 100, 380, 160), fill="pink", outline="crimson", width=3)
print("ellipse((左,上,右,下)) 注意给的是外接矩形,不是圆心半径")
# 想画正圆,得让外接矩形是正方形
draw.ellipse((20, 180, 90, 250), fill="gold", outline="black", width=2)
print(" 想画正圆 -> 让外接矩形宽高相等")
# 多边形:给一串顶点,会自动首尾相连
draw.polygon([(120, 250), (160, 180), (200, 250)], fill="mediumpurple", outline="black")
print("polygon([顶点列表]) 自动首尾相连")
# 圆弧、扇形、弦
# 角度是从 3 点钟方向开始,顺时针算
draw.arc((230, 180, 300, 250), start=0, end=270, fill="black", width=3)
print("arc(框, start, end) 只画一段弧线")
draw.pieslice((310, 180, 380, 250), start=0, end=120, fill="tomato", outline="black")
print("pieslice(框, start, end) 画扇形(像饼图那一块)")
print()
print("角度从 3 点钟方向算起,顺时针增大:0 度指右,90 度指下。")
print()
print("=" * 68)
print("四、颜色可以怎么写")
print("=" * 68)
probe = Image.new("RGB", (10, 10), "white")
pd = ImageDraw.Draw(probe)
# 六种写法,下面会逐个画一遍再读回像素,验证它们确实等价
ways = [
("英文名", "red"),
("十六进制", "#ff0000"),
("短十六进制", "#f00"),
("RGB 元组", (255, 0, 0)),
("rgb() 函数式", "rgb(255,0,0)"),
("hsl() 函数式", "hsl(0,100%,50%)"),
]
for label, color in ways:
pd.rectangle((0, 0, 9, 9), fill=color)
print(f" {label:12s} {str(color):18s} -> 实际画出来是 {probe.getpixel((0, 0))}")
print()
print("这六种写法画出来是同一个红色,挑顺手的用就行。")
print()
print("=" * 68)
print("五、fill 和 outline 的区别,以及 width 的坑")
print("=" * 68)
test = Image.new("RGB", (60, 60), "white")
td = ImageDraw.Draw(test)
# 只填充,没边框
td.rectangle((5, 5, 25, 25), fill="red")
print(" 只写 fill -> 实心,没有边框")
print(f" 中心 {test.getpixel((15, 15))} 边上 {test.getpixel((5, 5))}")
# 只有边框,中间是透明的(也就是保持原来的底色)
td.rectangle((35, 5, 55, 25), outline="blue", width=2)
print(" 只写 outline -> 空心,中间保持原来的底色")
print(f" 中心 {test.getpixel((45, 15))} 边上 {test.getpixel((35, 5))}")
print()
print(" width 是往里长的:outline 画在框线以内,不会超出你给的坐标范围")
big = Image.new("RGB", (40, 40), "white")
bd = ImageDraw.Draw(big)
bd.rectangle((10, 10, 30, 30), outline="black", width=5)
print(f" 框在 (10,10)-(30,30),width=5")
print(f" (9,20) 框外一格: {big.getpixel((9, 20))} <- 还是白的,没溢出")
print(f" (10,20) 框线上 : {big.getpixel((10, 20))} <- 黑的")
print(f" (14,20) 往里 4 格: {big.getpixel((14, 20))} <- 还是黑的,粗度往里长")
print(f" (15,20) 往里 5 格: {big.getpixel((15, 20))} <- 白的,边框到此为止")
print()
print("=" * 68)
print("六、画半透明的东西,要用 RGBA 图层再合成")
print("=" * 68)
print("直接在 RGB 图上画半透明色是没用的,透明度会被忽略:")
solid = Image.new("RGB", (60, 60), "white")
sd = ImageDraw.Draw(solid)
# 给了 alpha=128,但底图是 RGB,装不下透明度
sd.rectangle((10, 10, 50, 50), fill=(255, 0, 0, 128))
print(" 在 RGB 图上画 fill=(255,0,0,128),结果:", solid.getpixel((30, 30)))
print(" <- 变成了不透明的纯红,alpha 被丢掉了")
print()
print("正确做法:单独开一个 RGBA 图层画,再用 alpha_composite 叠上去")
# 底图也要是 RGBA 才能合成
base = Image.new("RGBA", (60, 60), (255, 255, 255, 255))
# 新建一个完全透明的图层
layer = Image.new("RGBA", (60, 60), (0, 0, 0, 0))
ld = ImageDraw.Draw(layer)
# 在这一层上画半透明红
ld.rectangle((10, 10, 50, 50), fill=(255, 0, 0, 128))
# 把图层叠到底图上,这时才会真正做透明混合
merged = Image.alpha_composite(base, layer)
print(" 合成之后:", merged.getpixel((30, 30)))
print(" <- 红色和白底混了一半,这才是半透明该有的样子")
print()
print("这个「开图层再合成」的套路,做水印的时候还会再用一次。")====================================================================
一、画图的固定套路:先有图,再有画笔
====================================================================
画笔对象: ImageDraw
它绑在哪张图上: (400, 300)
关键点:draw 的所有方法都直接改 img,没有返回值。
draw.rectangle(...) 的返回值: None
但 img 已经被改了,(20,20) 处现在是: (255, 0, 0)
====================================================================
二、坐标系:原点在左上角,y 轴朝下
====================================================================
这和数学课上的坐标系不一样,是图形学的惯例。
在 (0, 0) 画了 red -> (0,0) 左上
在 (180, 0) 画了 green -> (180,0) 右上
在 (0, 100) 画了 blue -> (0,100) 左下
在 (180, 100) 画了 orange -> (180,100) 右下
所以 y 越大越靠下,(0,0) 是左上角那个像素。
====================================================================
三、各种图形怎么画
====================================================================
line((x1,y1,x2,y2)) 画一条线,width 是粗细
line([点1, 点2, 点3, ...]) 给一串点就画折线
rectangle((左,上,右,下)) fill 填充色,outline 边框色
rounded_rectangle(..., radius=) 圆角矩形,radius 是圆角半径
ellipse((左,上,右,下)) 注意给的是外接矩形,不是圆心半径
想画正圆 -> 让外接矩形宽高相等
polygon([顶点列表]) 自动首尾相连
arc(框, start, end) 只画一段弧线
pieslice(框, start, end) 画扇形(像饼图那一块)
角度从 3 点钟方向算起,顺时针增大:0 度指右,90 度指下。
====================================================================
四、颜色可以怎么写
====================================================================
英文名 red -> 实际画出来是 (255, 0, 0)
十六进制 #ff0000 -> 实际画出来是 (255, 0, 0)
短十六进制 #f00 -> 实际画出来是 (255, 0, 0)
RGB 元组 (255, 0, 0) -> 实际画出来是 (255, 0, 0)
rgb() 函数式 rgb(255,0,0) -> 实际画出来是 (255, 0, 0)
hsl() 函数式 hsl(0,100%,50%) -> 实际画出来是 (255, 0, 0)
这六种写法画出来是同一个红色,挑顺手的用就行。
====================================================================
五、fill 和 outline 的区别,以及 width 的坑
====================================================================
只写 fill -> 实心,没有边框
中心 (255, 0, 0) 边上 (255, 0, 0)
只写 outline -> 空心,中间保持原来的底色
中心 (255, 255, 255) 边上 (0, 0, 255)
width 是往里长的:outline 画在框线以内,不会超出你给的坐标范围
框在 (10,10)-(30,30),width=5
(9,20) 框外一格: (255, 255, 255) <- 还是白的,没溢出
(10,20) 框线上 : (0, 0, 0) <- 黑的
(14,20) 往里 4 格: (0, 0, 0) <- 还是黑的,粗度往里长
(15,20) 往里 5 格: (255, 255, 255) <- 白的,边框到此为止
====================================================================
六、画半透明的东西,要用 RGBA 图层再合成
====================================================================
直接在 RGB 图上画半透明色是没用的,透明度会被忽略:
在 RGB 图上画 fill=(255,0,0,128),结果: (255, 0, 0)
<- 变成了不透明的纯红,alpha 被丢掉了
正确做法:单独开一个 RGBA 图层画,再用 alpha_composite 叠上去
合成之后: (255, 127, 127, 255)
<- 红色和白底混了一半,这才是半透明该有的样子
这个「开图层再合成」的套路,做水印的时候还会再用一次。9.3 各种图形的参数怎么记 #
ImageDraw 的方法名都很直白,真正容易记错的是第一个参数到底给什么。下面这张表按这个角度整理,重点看第二列:
| 方法 | 第一个参数 | 记忆点 |
|---|---|---|
line((x1,y1,x2,y2)) |
起点和终点 | 给一串点就变成折线 |
rectangle((左,上,右,下)) |
两个角点 | 和 crop 的参数格式一致 |
rounded_rectangle(框, radius=) |
两个角点 | 多一个圆角半径 |
ellipse((左,上,右,下)) |
外接矩形 | 不是圆心和半径!想画正圆就让外接矩形是正方形 |
polygon([点列表]) |
顶点列表 | 自动首尾相连 |
arc(框, start, end) |
外接矩形 + 起止角度 | 只画弧线 |
pieslice(框, start, end) |
外接矩形 + 起止角度 | 画扇形,做饼图用 |
ellipse 给的是外接矩形不是圆心半径,这一点几乎每个人第一次都会写错。想在 (cx, cy) 处画半径 r 的圆,要写成 ellipse((cx-r, cy-r, cx+r, cy+r))。
角度从 3 点钟方向算起,顺时针增大。所以 0 度指右,90 度指下,180 度指左。
9.4 颜色的六种写法 #
fill 和 outline 接受的颜色可以有六种写法,效果完全等价。知道有这么多写法的好处是:从别的地方(比如 CSS、设计稿)抄颜色时不用再手工换算。
draw.rectangle(box, fill="red") # 英文名
draw.rectangle(box, fill="#ff0000") # 十六进制
draw.rectangle(box, fill="#f00") # 短十六进制
draw.rectangle(box, fill=(255, 0, 0)) # RGB 元组
draw.rectangle(box, fill="rgb(255,0,0)") # 函数式
draw.rectangle(box, fill="hsl(0,100%,50%)") # HSL 函数式六种写出来是同一个红色,挑顺手的用。英文名支持 CSS 的全部颜色名(skyblue、tomato、mediumpurple 等)。
9.5 fill、outline 和 width #
- 只给
fill→ 实心,没有边框 - 只给
outline→ 空心,中间保持原来的底色 - 两个都给 → 实心加边框
width 是往里长的。实测:框在 (10,10)-(30,30)、width=5 时,框外一格 (9,20) 还是白的,往里第 4 格 (14,20) 还是黑的,第 5 格 (15,20) 变白。也就是说边框严格画在你给的坐标范围以内,不会向外溢出。这在需要精确控制布局时很重要。
9.6 画半透明的东西要开图层 #
在 RGB 模式的图上画半透明色是完全无效的。实测:在 RGB 图上写 fill=(255,0,0,128),结果是 (255, 0, 0),透明度被静默丢弃了。原因很简单——RGB 模式每个像素只有三个数字的位置,第四个数字没地方放。
正确做法是「开图层再合成」,这个套路后面做水印时还会用到:
# 1. 底图转成 RGBA
base = img.convert("RGBA")
# 2. 新建一个完全透明的图层,尺寸和底图一样
layer = Image.new("RGBA", base.size, (0, 0, 0, 0))
# 3. 在图层上画,这时 alpha 才有地方存
draw = ImageDraw.Draw(layer)
# 128 是半透明,取值范围 0(全透明)到 255(不透明)
draw.rectangle((10, 10, 50, 50), fill=(255, 0, 0, 128))
# 4. 合成,这一步才真正做透明混合
merged = Image.alpha_composite(base, layer)实测结果 (255, 127, 127, 255)——红色和白底混了一半,这才是半透明该有的样子。
10. 写文字:中文字体这道坎 #
这一章单独拿出来,因为「在图上写中文」是中文用户最常见的需求,同时也是 Pillow 里最隐蔽的一个坑:它不报错、不警告,只是默默地把每个汉字画成一个空方块。
10.1 完整演示 #
下面这段代码从最简单的一行 draw.text 开始,逐步演示豆腐块问题的检测方法、跨平台找中文字体、字号、居中、多行、描边和字体集合。其中第二节和第三节会把渲染结果打印成字符画,让你在纯终端里也能「看见」区别。
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
def render_char(ch, font):
"""把单个字符画在小画布上,返回这块画布,用来比对两个字长得一样不一样。"""
# 用灰度模式白底,只有一个通道,比对像素时最简单
im = Image.new("L", (40, 40), 255)
# 在固定位置画这一个字符,黑色
ImageDraw.Draw(im).text((4, 2), ch, fill=0, font=font)
return im
def show_as_art(im):
"""把小图打印成字符画,方便在终端里「看」到渲染结果。"""
lines = []
# 找出所有深色像素的范围,只打印有内容的那一块
dark = [(x, y) for y in range(im.height) for x in range(im.width)
if im.getpixel((x, y)) < 128]
if not dark:
# 一个深色像素都没有,说明这个字体连占位方块都没画
return [" (一片空白,什么都没画出来)"]
# 算出有内容区域的四条边,只打印这一块
x0 = min(p[0] for p in dark)
x1 = max(p[0] for p in dark)
y0 = min(p[1] for p in dark)
y1 = max(p[1] for p in dark)
# 隔行取样,不然打出来太高
for y in range(y0, y1 + 1, 2):
# 深色画成实心方块,浅色画成点
row = "".join("█" if im.getpixel((x, y)) < 128 else "·" for x in range(x0, x1 + 1))
# 前面加缩进,和其他输出对齐
lines.append(" " + row)
return lines
print("=" * 68)
print("一、最简单的写字")
print("=" * 68)
img = Image.new("RGB", (300, 80), "white")
# 绑定画笔
draw = ImageDraw.Draw(img)
# text(位置, 内容, fill=颜色),位置是文字左上角
draw.text((10, 10), "Hello Pillow", fill="black")
print("draw.text((10, 10), 'Hello Pillow', fill='black')")
print("不指定 font 就用内置的默认字体,字很小,只有约 11 像素高。")
# 量一下这行字占了多大
bbox = draw.textbbox((10, 10), "Hello Pillow")
print("这行字的包围盒:", bbox, f" -> 宽 {bbox[2] - bbox[0]},高 {bbox[3] - bbox[1]}")
print()
print("=" * 68)
print("二、最大的坑:默认字体画不了中文,只会画出「豆腐块」")
print("=" * 68)
# load_default 从 Pillow 10.1 起支持 size 参数,字大一点看得清楚
default_font = ImageFont.load_default(size=24)
print("先用一个能自动判断的办法:让默认字体分别画「中」和「文」,")
print("如果两个完全不同的字画出来一模一样,那画的肯定不是字。")
print()
# 分别画两个完全不同的汉字
a = render_char("中", default_font)
b = render_char("文", default_font)
# 再画两个英文字母作为对照组
c = render_char("A", default_font)
e = render_char("B", default_font)
print(" 默认字体画的「中」和「文」,像素完全相同吗:", a.tobytes() == b.tobytes())
print(" 默认字体画的「A」 和「B」 ,像素完全相同吗:", c.tobytes() == e.tobytes())
print()
print("英文的 A 和 B 长得不一样(正常),中文的两个字却长得一模一样。")
print("说明它们都是同一个「这个字我没有」的占位方块。")
print()
print("把默认字体画的「中」打印出来看看:")
for line in show_as_art(a):
print(line)
print(" 这就是俗称的豆腐块:一个空心方框,里面一个叉。")
print()
print("=" * 68)
print("三、解决办法:加载一个真正带中文字形的字体文件")
print("=" * 68)
def find_cjk_font():
"""在常见位置找一个能显示中文的字体文件,找不到就返回 None。"""
# 不同系统的中文字体放在不同地方,挨个试
candidates = [
# Windows:微软雅黑、黑体、宋体
"C:/Windows/Fonts/msyh.ttc",
"C:/Windows/Fonts/simhei.ttf",
"C:/Windows/Fonts/simsun.ttc",
# macOS
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
# Linux 常见的开源中文字体
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
]
for path in candidates:
if Path(path).exists():
return path
return None
# 在当前系统上找一个可用的中文字体
font_path = find_cjk_font()
print("当前系统:", sys.platform)
print("找到的中文字体:", font_path)
if font_path is None:
print("没找到中文字体,后面的例子跳过。")
print("Linux 上可以装一个:sudo apt install fonts-wqy-zenhei")
sys.exit(0)
# truetype(字体文件路径, 字号),字号单位是像素
cjk_font = ImageFont.truetype(font_path, 24)
print()
print("换成这个字体再画「中」和「文」:")
a2 = render_char("中", cjk_font)
b2 = render_char("文", cjk_font)
print(" 两个字像素完全相同吗:", a2.tobytes() == b2.tobytes(), " <- False 才对")
print()
print("现在的「中」长这样:")
for line in show_as_art(a2):
print(line)
print(" 能看出「中」字的横竖结构了。")
print()
print("=" * 68)
print("四、字号、颜色、位置")
print("=" * 68)
canvas = Image.new("RGB", (400, 200), "white")
cd = ImageDraw.Draw(canvas)
for i, size in enumerate([12, 18, 24, 32]):
# 每种字号都要重新 truetype 一次,字号是在加载时定死的
f = ImageFont.truetype(font_path, size)
# 每行往下挪 40 像素,免得叠在一起
y = 10 + i * 40
# 用这个字号画一行中英混排
cd.text((10, y), f"{size}号 中文测试", fill="black", font=f)
# textbbox 量一下实际占了多大
bb = cd.textbbox((10, y), f"{size}号 中文测试", font=f)
print(f" 字号 {size:2d}: 宽 {bb[2] - bb[0]:3d} 像素, 高 {bb[3] - bb[1]:3d} 像素")
print()
print("字号是在 truetype() 里定的,画的时候改不了;")
print("要几种字号就 truetype 几次,可以提前存成字典复用。")
print()
print("=" * 68)
print("五、怎么把文字摆正中间")
print("=" * 68)
# 画布尺寸,后面算居中位置要用
box_w, box_h = 300, 100
center_img = Image.new("RGB", (box_w, box_h), "white")
ccd = ImageDraw.Draw(center_img)
f = ImageFont.truetype(font_path, 28)
# 要摆到正中间的这段文字
text = "居中的文字"
print("办法一:自己算(要理解原理时用)")
# textbbox 告诉你如果从 (0,0) 开始画,文字会占据哪个矩形
bb = ccd.textbbox((0, 0), text, font=f)
# 右减左得到宽度
tw = bb[2] - bb[0]
# 下减上得到高度
th = bb[3] - bb[1]
print(f" 文字实际尺寸: {tw} x {th}")
# 画布中心减去文字的一半,就是文字左上角该在的位置
# 注意还要减去 bb[0]、bb[1],因为文字上方通常有一段空白(字体的行间距)
x = (box_w - tw) // 2 - bb[0]
y = (box_h - th) // 2 - bb[1]
print(f" 算出来的起点: ({x}, {y})")
ccd.text((x, y), text, fill="black", font=f)
final = ccd.textbbox((x, y), text, font=f)
print(f" 画完之后的实际位置: {final}")
print(f" 左边距 {final[0]},右边距 {box_w - final[2]} <- 两边差不多就是居中了")
print()
print("办法二:用 anchor 参数(推荐,一行搞定)")
center2 = Image.new("RGB", (box_w, box_h), "white")
c2 = ImageDraw.Draw(center2)
# anchor='mm' 表示:给的坐标是文字的「中心点」,而不是左上角
# 第一个字母管水平(l左 m中 r右),第二个管垂直(a顶 m中 d基线 s上沿 b底)
c2.text((box_w // 2, box_h // 2), text, fill="black", font=f, anchor="mm")
# 量的时候也要带上同样的 anchor,否则量出来的位置对不上
bb2 = c2.textbbox((box_w // 2, box_h // 2), text, font=f, anchor="mm")
print(f" anchor='mm' 画完的位置: {bb2}")
print(f" 左边距 {bb2[0]},右边距 {box_w - bb2[2]}")
print()
print("常用的几个 anchor:")
print(" 'la' 左上角(默认,等于不写)")
print(" 'mm' 正中心")
print(" 'ra' 右上角,做右上角标注很方便")
print(" 'ms' 水平居中、垂直贴着文字顶")
print()
print("=" * 68)
print("六、多行文字")
print("=" * 68)
multi = Image.new("RGB", (300, 150), "white")
md = ImageDraw.Draw(multi)
lines = "第一行\n第二行\n第三行比较长一点"
# text 本身就认 \n,会自动换行
md.text((10, 10), lines, fill="black", font=ImageFont.truetype(font_path, 20))
print("直接在字符串里写 \\n 就能换行。")
# multiline_textbbox 专门量多行文字
mb = md.multiline_textbbox((10, 10), lines, font=ImageFont.truetype(font_path, 20))
print("三行字占的范围:", mb)
print()
# spacing 控制行距,align 控制对齐
md2 = ImageDraw.Draw(Image.new("RGB", (300, 200), "white"))
f20 = ImageFont.truetype(font_path, 20)
for spacing in [0, 4, 20]:
# 只改行距,看三行字的总高度怎么变
bb = md2.multiline_textbbox((10, 10), lines, font=f20, spacing=spacing)
print(f" spacing={spacing:2d} 时总高度: {bb[3] - bb[1]}")
print(" spacing 是行与行之间额外加的像素,默认 4")
print()
print("align 可选 'left'(默认)、'center'、'right',控制几行之间怎么对齐。")
print()
print("=" * 68)
print("七、给文字加描边,让它在任何背景上都看得清")
print("=" * 68)
# 在一张深浅不一的背景上写字,不描边就会有一段看不清
bg = Image.new("RGB", (300, 60))
bd = ImageDraw.Draw(bg)
# 左半边黑,右半边白
bd.rectangle((0, 0, 149, 59), fill="black")
bd.rectangle((150, 0, 299, 59), fill="white")
f = ImageFont.truetype(font_path, 28)
# stroke_width 是描边粗细,stroke_fill 是描边颜色
bd.text((20, 15), "有描边的白字", fill="white", font=f,
stroke_width=2, stroke_fill="black")
print("stroke_width=2, stroke_fill='black' 给白字加了一圈黑边,")
print("这样不管背景是黑是白,字都看得清。做水印、字幕必备。")
# 量一下描边让文字变大了多少
no_stroke = bd.textbbox((20, 15), "有描边的白字", font=f)
with_stroke = bd.textbbox((20, 15), "有描边的白字", font=f, stroke_width=2)
print(f" 不描边的包围盒: {no_stroke}")
print(f" 描边后的包围盒: {with_stroke} <- 四周各胀出 2 像素")
print()
print("=" * 68)
print("八、字体文件里有多个字体时,用 index 挑")
print("=" * 68)
print(".ttc 结尾的是「字体集合」,一个文件里打包了好几个字重。")
if font_path.endswith(".ttc"):
for idx in [0, 1]:
try:
# index 指定用集合里的第几个字体
f = ImageFont.truetype(font_path, 24, index=idx)
# getname 返回 (字体家族名, 字重名)
print(f" index={idx}: {f.getname()}")
except Exception as ex:
print(f" index={idx}: 取不到 ({type(ex).__name__})")
else:
f = ImageFont.truetype(font_path, 24)
print(f" 当前字体是 .ttf 单字体文件: {f.getname()}")
print()
print("想要粗体,可以直接换成加粗版的字体文件(比如 msyhbd.ttc),")
print("Pillow 没有「把普通字体变粗」的开关。")====================================================================
一、最简单的写字
====================================================================
draw.text((10, 10), 'Hello Pillow', fill='black')
不指定 font 就用内置的默认字体,字很小,只有约 11 像素高。
这行字的包围盒: (10, 12, 65, 20) -> 宽 55,高 8
====================================================================
二、最大的坑:默认字体画不了中文,只会画出「豆腐块」
====================================================================
先用一个能自动判断的办法:让默认字体分别画「中」和「文」,
如果两个完全不同的字画出来一模一样,那画的肯定不是字。
默认字体画的「中」和「文」,像素完全相同吗: True
默认字体画的「A」 和「B」 ,像素完全相同吗: False
英文的 A 和 B 长得不一样(正常),中文的两个字却长得一模一样。
说明它们都是同一个「这个字我没有」的占位方块。
把默认字体画的「中」打印出来看看:
████████████
███······███
█··█····█··█
█···█··█···█
█····██····█
█···█··█···█
█··█····█··█
███······███
████████████
这就是俗称的豆腐块:一个空心方框,里面一个叉。
====================================================================
三、解决办法:加载一个真正带中文字形的字体文件
====================================================================
当前系统: win32
找到的中文字体: C:/Windows/Fonts/msyh.ttc
换成这个字体再画「中」和「文」:
两个字像素完全相同吗: False <- False 才对
现在的「中」长这样:
·········██·········
·········██·········
████████████████████
██·······██·······██
██·······██·······██
██·······██·······██
████████████████████
██·······██·······██
·········██·········
·········██·········
·········██·········
能看出「中」字的横竖结构了。
====================================================================
四、字号、颜色、位置
====================================================================
字号 12: 宽 78 像素, 高 13 像素
字号 18: 宽 117 像素, 高 19 像素
字号 24: 宽 155 像素, 高 24 像素
字号 32: 宽 207 像素, 高 33 像素
字号是在 truetype() 里定的,画的时候改不了;
要几种字号就 truetype 几次,可以提前存成字典复用。
====================================================================
五、怎么把文字摆正中间
====================================================================
办法一:自己算(要理解原理时用)
文字实际尺寸: 140 x 29
算出来的起点: (80, 30)
画完之后的实际位置: (80, 35, 220, 64)
左边距 80,右边距 80 <- 两边差不多就是居中了
办法二:用 anchor 参数(推荐,一行搞定)
anchor='mm' 画完的位置: (80, 36, 220, 65)
左边距 80,右边距 80
常用的几个 anchor:
'la' 左上角(默认,等于不写)
'mm' 正中心
'ra' 右上角,做右上角标注很方便
'ms' 水平居中、垂直贴着文字顶
====================================================================
六、多行文字
====================================================================
直接在字符串里写 \n 就能换行。
三行字占的范围: (10, 15, 170, 87)
spacing= 0 时总高度: 64
spacing= 4 时总高度: 72
spacing=20 时总高度: 104
spacing 是行与行之间额外加的像素,默认 4
align 可选 'left'(默认)、'center'、'right',控制几行之间怎么对齐。
====================================================================
七、给文字加描边,让它在任何背景上都看得清
====================================================================
stroke_width=2, stroke_fill='black' 给白字加了一圈黑边,
这样不管背景是黑是白,字都看得清。做水印、字幕必备。
不描边的包围盒: (20, 21, 188, 49)
描边后的包围盒: (18, 19, 190, 51) <- 四周各胀出 2 像素
====================================================================
八、字体文件里有多个字体时,用 index 挑
====================================================================
.ttc 结尾的是「字体集合」,一个文件里打包了好几个字重。
index=0: ('Microsoft YaHei', 'Regular')
index=1: ('Microsoft YaHei UI', 'Regular')
想要粗体,可以直接换成加粗版的字体文件(比如 msyhbd.ttc),
Pillow 没有「把普通字体变粗」的开关。10.2 豆腐块是怎么回事 #
draw.text() 不指定 font 时,用的是 Pillow 内置的默认字体。这个字体只包含拉丁字母、数字和常用符号,没有任何汉字字形。
当字体里找不到某个字符时,按照字体规范会渲染一个叫 .notdef 的占位字形——通常就是一个空心方框。因为长得像一块豆腐,中文社区管它叫「豆腐块」。
关键在于这个过程不会产生任何错误或警告。代码正常跑完,文件正常生成,你不打开图片看根本不知道出了问题。
上面的例子给了一个可以写进单元测试的检测方法:
# 用同一个字体分别画两个不同的汉字,如果像素完全相同,说明画的是占位方块
def can_render_cjk(font):
# 存放两个字各自渲染出来的原始像素
imgs = []
for ch in ("中", "文"):
# 用灰度模式就够了,255 是白底
im = Image.new("L", (40, 40), 255)
# 在白底上用黑色画这个字
ImageDraw.Draw(im).text((4, 2), ch, fill=0, font=font)
# tobytes() 把整张图的像素拉平成字节串,方便直接比较
imgs.append(im.tobytes())
# 两个字长得不一样才说明字体真的认得它们
return imgs[0] != imgs[1]实测结果:默认字体下「中」和「文」画出来完全相同(True),而「A」和「B」不同(False)。这就证实了汉字走的是占位路径。
把默认字体画的「中」打印成字符画,能直接看到那个空心方框:
████████████
███······███
█··█····█··█
█···█··█···█
█····██····█
█···█··█···█
█··█····█··█
███······███
████████████换成微软雅黑之后,同一个「中」字:
·········██·········
·········██·········
████████████████████
██·······██·······██
██·······██·······██
██·······██·······██
████████████████████
██·······██·······██
·········██·········
·········██·········
·········██·········一横一竖一个口,这才是真的「中」字。
10.3 怎么找到一个能用的中文字体 #
解决办法只有一个:加载一个真正包含汉字字形的字体文件。
from PIL import ImageFont
# truetype(字体文件路径, 字号),字号单位是像素
font = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 24)
# 把 font 传进去,汉字才会用这个字体渲染
draw.text((10, 10), "中文没问题了", fill="black", font=font)不同系统的中文字体位置不一样。下面这个函数照顾了三大平台,可以直接抄:
from pathlib import Path
from PIL import ImageFont
def find_cjk_font(size):
"""找一个能显示中文的字体,找不到就退回默认字体。"""
# 按平台把常见路径都列出来,谁在就用谁
candidates = [
# Windows:微软雅黑、黑体、宋体
"C:/Windows/Fonts/msyh.ttc",
"C:/Windows/Fonts/simhei.ttf",
"C:/Windows/Fonts/simsun.ttc",
# macOS
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
# Linux 常见的开源中文字体
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
]
for path in candidates:
if Path(path).exists():
# 找到第一个真实存在的字体文件就加载并返回
return ImageFont.truetype(path, size)
# 一个都没有时至少别崩,但中文会变成豆腐块
return ImageFont.load_default(size=size)如果部署到 Linux 服务器上发现一个中文字体都没有,装一个:
# Debian / Ubuntu
sudo apt install fonts-wqy-zenhei
# CentOS / RHEL
sudo yum install wqy-zenhei-fonts更稳妥的做法是把字体文件直接放进项目目录,跟代码一起提交。这样换到任何机器上都能用,不依赖系统装了什么。注意字体有版权,商用前确认许可证;开源可商用的中文字体可以选思源黑体(Noto Sans CJK)。
10.4 字号和字体缓存 #
字号是在 truetype() 里定死的,画的时候改不了。要几种字号就得加载几次。
字体加载有开销,如果在循环里反复加载同一个字体会很慢。实际项目里应该缓存:
_font_cache = {}
def get_font(size):
# 这个字号没加载过才去读文件
if size not in _font_cache:
# 加载一次,之后一直放在字典里复用
_font_cache[size] = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", size)
# 之后同样字号直接命中缓存,省掉重复的磁盘读取
return _font_cache[size]10.5 量文字大小:textsize 已经没了 #
老教程里的 draw.textsize() 在 Pillow 10.0 被删除了,现在会报 AttributeError。替代品有两个:
# textbbox:返回 (左, 上, 右, 下),是文字实际占据的矩形
bbox = draw.textbbox((0, 0), "Hello", font=font)
# 右减左得到宽度
width = bbox[2] - bbox[0]
# 下减上得到高度
height = bbox[3] - bbox[1]
# textlength:只返回宽度,是个浮点数,做排版时更精确
w = draw.textlength("Hello", font=font)有个细节要注意:textbbox((0, 0), ...) 返回的左上角通常不是 (0, 0)。实测 (0, 2, 24, 10)——上边界是 2 不是 0。这是因为字体上方有一段留白(字体的行高设计)。做精确定位时必须把这个偏移减掉,下一节的居中计算里会用到。
10.6 把文字摆到正中间 #
把一行字精确摆到画布正中间,是做封面、做占位图时最常见的需求,也是最容易差几个像素的地方。两种办法,推荐第二种。
两种办法,推荐第二种。
办法一:自己算。 理解原理时用这个:
# 先在 (0, 0) 处虚量一次,得到这段文字占的矩形
bbox = draw.textbbox((0, 0), text, font=font)
# 算出文字的宽和高
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
# 画布中心减去文字的一半,再减去 bbox 本身的偏移
x = (img.width - tw) // 2 - bbox[0]
y = (img.height - th) // 2 - bbox[1]
# 这里传的仍然是左上角坐标
draw.text((x, y), text, font=font, fill="black")那两个 - bbox[0] 和 - bbox[1] 就是在抵消上一节说的留白偏移。漏掉它们,文字会偏下几个像素。
办法二:用 anchor 参数。 一行搞定:
# anchor="mm" 表示传进去的坐标是文字的中心点,不是左上角
draw.text((img.width // 2, img.height // 2), text, font=font,
fill="black", anchor="mm")anchor 是两个字母:第一个管水平(l 左、m 中、r 右),第二个管垂直(a 顶、m 中、s 基线、d 下沿、b 底)。给的坐标不再是左上角,而是这个锚点的位置。
常用组合:
| anchor | 含义 | 用途 |
|---|---|---|
la |
左上角(默认) | 不写就是这个 |
mm |
正中心 | 居中标题 |
ra |
右上角 | 右上角标注 |
rd |
右下角 | 右下角水印 |
注意 anchor 只对单行文字有效,多行文字要用 multiline_text()。
10.7 多行文字 #
text() 本身就认 \n,会自动换行:
# 字符串里的 \n 会被自动识别成换行,不用自己逐行画
draw.text((10, 10), "第一行\n第二行\n第三行", font=font, fill="black")两个相关参数:
spacing:行与行之间额外加多少像素,默认 4。实测三行字在spacing=0/4/20时总高度分别是 64/72/104align:几行之间怎么对齐,可选'left'(默认)、'center'、'right'
量多行文字的尺寸要用 multiline_textbbox(),不是 textbbox()。
10.8 描边:让文字在任何背景上都看得清 #
做水印和字幕时,背景明暗不可控,纯白字在浅色背景上会看不见。解决办法是给文字加一圈描边:
# stroke_width 是描边粗细(像素),stroke_fill 是描边颜色
draw.text((20, 15), "有描边的白字", fill="white", font=font,
stroke_width=2, stroke_fill="black")白字黑边,不管背景是黑是白都清晰可读。实测描边会让文字包围盒四周各胀出 stroke_width 个像素,做精确定位时要把这部分算进去。
10.9 粗体怎么办 #
Pillow 没有「把普通字体变粗」的开关。想要粗体,只能换成加粗版的字体文件:
normal = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 24) # 微软雅黑常规
bold = ImageFont.truetype("C:/Windows/Fonts/msyhbd.ttc", 24) # 微软雅黑粗体另外,.ttc 结尾的文件是「字体集合」,一个文件里打包了好几个字体。用 index 参数挑:
# index 是集合里的第几个字体,不写默认是 0
f0 = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 24, index=0) # Microsoft YaHei
f1 = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 24, index=1) # Microsoft YaHei UIf.getname() 可以查当前加载的到底是哪个。
11. 像素级操作与 NumPy #
前面几章用的都是 Pillow 现成的功能。这一章讲当现成功能不够用时,怎么直接操作像素——以及一个至关重要的性能问题。
11.1 完整演示 #
下面这段代码演示三种读像素的方式、Pillow 和 NumPy 的下标差异、四种做法的性能对比、point() 的几种典型用法,以及和 NumPy 互转时最容易出错的两个地方。其中性能那一段的绝对数字会因机器而异,但数量级差距是稳定的。
import time
import numpy as np
from PIL import Image, ImageDraw, ImageOps
print("=" * 70)
print("一、读单个像素的三种方式")
print("=" * 70)
# 造一张 100x60 的白底小图当实验对象
img = Image.new("RGB", (100, 60), "white")
# 绑定画笔
d = ImageDraw.Draw(img)
# 画一个颜色特殊的方块,方便后面确认取到的是不是同一个点
d.rectangle((10, 10, 40, 40), fill=(200, 30, 60))
# 方式 1:getpixel,最直白,但每次调用都有函数调用开销
print("getpixel((20, 20)):", img.getpixel((20, 20)))
# 方式 2:load() 拿到一个支持下标的对象,读写都比 getpixel 快
px = img.load()
print("load()[20, 20] :", px[20, 20])
# 方式 3:转成 numpy 数组,一次性拿全部
arr = np.array(img)
# 注意!numpy 的下标顺序是 [行, 列] 也就是 [y, x],和 Pillow 的 (x, y) 相反
# tolist() 是为了打印得干净点,不然会显示成 np.uint8(200) 这种
print("np.array(img)[20, 20]:", arr[20, 20].tolist())
print()
print("最容易搞混的地方:")
print(" Pillow 用 (x, y),先横后竖")
print(" NumPy 用 [y, x],先行后列")
print(f" img.size = {img.size} 但 np.array(img).shape = {arr.shape}")
print(" shape 是 (高, 宽, 通道数),和 size 的 (宽, 高) 正好前两个反过来")
print()
print("=" * 70)
print("二、改单个像素")
print("=" * 70)
small = Image.new("RGB", (5, 5), "white")
sp = small.load()
# 用下标直接赋值,改的是原图
sp[2, 2] = (255, 0, 0)
print("改完之后 (2,2):", small.getpixel((2, 2)))
# putpixel 是另一种写法,效果一样
small.putpixel((0, 0), (0, 255, 0))
print("putpixel 改完 (0,0):", small.getpixel((0, 0)))
print()
print("=" * 70)
print("三、四种做同一件事的方法,速度差几十倍")
print("=" * 70)
print("任务:把一张 800x600 的图反色(每个通道用 255 减)")
print()
# 造一张有随机内容的测试图
rng = np.random.default_rng(0)
# 随机噪点图,600 行 800 列 3 通道;乘 255 再转 uint8 才是合法的像素值
src = Image.fromarray((rng.random((600, 800, 3)) * 255).astype("uint8"))
def by_loop():
"""办法一:纯 Python 双重循环,一个像素一个像素改。"""
# 复制一份,别改坏原图
im = src.copy()
# load() 拿到支持下标读写的对象
p = im.load()
# size 是 (宽, 高),所以这样拆包
w, h = im.size
for x in range(w):
for y in range(h):
# 读出三个通道
r, g, b = p[x, y]
# 每个通道用 255 减,就是反色
p[x, y] = (255 - r, 255 - g, 255 - b)
return im
def by_point():
"""办法二:point() 传一个函数,Pillow 会先建一张 256 项的查找表,
然后在 C 层面对每个像素查表,Python 函数只被调用 256 次。"""
return src.point(lambda v: 255 - v)
def by_numpy():
"""办法三:转成 numpy 数组,整个数组一次减完。"""
return Image.fromarray(255 - np.array(src))
def by_ops():
"""办法四:Pillow 自带的现成函数。"""
return ImageOps.invert(src)
results = {}
for name, fn in [("纯 Python 双重循环", by_loop), ("point() 查表", by_point),
("numpy 数组运算", by_numpy), ("ImageOps.invert", by_ops)]:
t0 = time.perf_counter()
out = fn()
dt = time.perf_counter() - t0
results[name] = (dt, np.array(out))
base = results["纯 Python 双重循环"][0]
print(f"{'做法':22s} {'耗时':>10s} {'相对最慢的':>10s}")
print("-" * 52)
for name, (dt, _) in results.items():
print(f"{name:22s} {dt * 1000:8.1f} ms 快 {base / dt:6.1f} 倍")
print()
# 确认四种做法结果完全一样,不是拿速度换了正确性
ref = results["numpy 数组运算"][1]
allsame = all(np.array_equal(a, ref) for _, a in results.values())
print("四种做法结果完全一致吗:", allsame)
print()
print("结论:处理整张图时,永远不要写 for x: for y: 这样的双重循环。")
print(" 能用现成函数就用现成函数(最快,代码也最短)")
print(" 自定义的逐像素映射用 point()")
print(" 需要复杂运算(比如通道之间互相计算)就转 numpy")
print()
print("=" * 70)
print("四、point() 能做什么")
print("=" * 70)
gray = Image.new("L", (256, 10))
gd = ImageDraw.Draw(gray)
for x in range(256):
gd.line((x, 0, x, 10), fill=x)
# point 接受一个函数,输入是 0~255 的像素值,输出是新值
print("原图是 0 到 255 的渐变,取几个点看看:")
print(" x=0:", gray.getpixel((0, 5)), " x=128:", gray.getpixel((128, 5)),
" x=255:", gray.getpixel((255, 5)))
print()
# 提亮:所有值加 50,超过 255 的自动截断
brighter = gray.point(lambda v: min(255, v + 50))
print("提亮 50 point(lambda v: min(255, v + 50)):")
print(" x=0:", brighter.getpixel((0, 5)), " x=128:", brighter.getpixel((128, 5)),
" x=255:", brighter.getpixel((255, 5)))
# 二值化
binary = gray.point(lambda v: 255 if v > 128 else 0)
print("二值化 point(lambda v: 255 if v > 128 else 0):")
print(" x=0:", binary.getpixel((0, 5)), " x=128:", binary.getpixel((128, 5)),
" x=200:", binary.getpixel((200, 5)))
# 提高对比度的 S 曲线
curve = gray.point(lambda v: int(255 * (v / 255) ** 0.5))
print("伽马校正 point(lambda v: int(255 * (v/255) ** 0.5)):")
print(" x=64:", curve.getpixel((64, 5)), " x=128:", curve.getpixel((128, 5)))
print()
print("point 也可以直接给一个 256 长的列表当查找表,比 lambda 还快一点:")
table = [255 - v for v in range(256)]
inverted = gray.point(table)
print(" point([255-v for v in range(256)]) 的 x=0 处:", inverted.getpixel((0, 5)))
print()
print("=" * 70)
print("五、和 NumPy 互转")
print("=" * 70)
img2 = Image.new("RGB", (4, 3), "white")
ImageDraw.Draw(img2).point((1, 1), fill=(10, 20, 30))
# Image -> numpy
a = np.array(img2)
print("Image -> numpy:")
print(" shape:", a.shape, " (高, 宽, 通道)")
print(" dtype:", a.dtype, " uint8 就是 0~255 的整数")
print(" a[1, 1] =", a[1, 1], " 对应 Pillow 的 (x=1, y=1)")
# numpy -> Image
back = Image.fromarray(a)
print()
print("numpy -> Image:")
print(" fromarray 之后:", back.size, back.mode)
print(" 和原图一致吗:", back.tobytes() == img2.tobytes())
print()
print("fromarray 对数组的要求:")
print(" dtype 必须是 uint8(RGB/L 图),别的类型要先转")
# 演示:float 数组直接转会出问题
f = np.array([[0.5, 1.0], [0.0, 0.5]])
try:
Image.fromarray(f).convert("RGB")
print(" float64 数组:", Image.fromarray(f).mode, " <- 变成了 F 模式,不是普通灰度图")
except Exception as ex:
print(f" float64 数组: {type(ex).__name__}: {ex}")
print(" 正确做法:先把值缩放到 0~255 再转 uint8")
print(" Image.fromarray((f * 255).astype('uint8')).mode =",
Image.fromarray((f * 255).astype("uint8")).mode)
print()
print("还有一个常见错误:算完之后忘了限制范围")
x = np.array([[200, 100]], dtype="uint8")
# uint8 加法会「绕回来」,200 + 100 = 300,但 uint8 存不下,变成 44
print(" uint8 数组 [200, 100] 直接 + 100:", (x + 100)[0], " <- 200+100 变成了 44!")
print(" 这是 uint8 溢出后绕回来了,正确做法是先转成更大的类型:")
safe = np.clip(x.astype("int16") + 100, 0, 255).astype("uint8")
print(" np.clip(x.astype('int16') + 100, 0, 255):", safe[0])
print()
print("=" * 70)
print("六、什么时候该转 numpy")
print("=" * 70)
print(" 该转:通道之间要互相计算、要做卷积、要和其他科学计算库对接")
print(" 不该转:Pillow 已经有现成函数的(缩放、旋转、滤镜、调色)")
print()
print("因为转换本身也要时间,来回转一次就把省下的时间搭进去了:")
big = Image.fromarray((rng.random((1200, 1600, 3)) * 255).astype("uint8"))
t0 = time.perf_counter()
tmp = np.array(big)
t1 = time.perf_counter()
Image.fromarray(tmp)
t2 = time.perf_counter()
print(f" 1600x1200 的图,Image -> numpy: {(t1 - t0) * 1000:.1f} ms")
print(f" numpy -> Image: {(t2 - t1) * 1000:.1f} ms")======================================================================
一、读单个像素的三种方式
======================================================================
getpixel((20, 20)): (200, 30, 60)
load()[20, 20] : (200, 30, 60)
np.array(img)[20, 20]: [200, 30, 60]
最容易搞混的地方:
Pillow 用 (x, y),先横后竖
NumPy 用 [y, x],先行后列
img.size = (100, 60) 但 np.array(img).shape = (60, 100, 3)
shape 是 (高, 宽, 通道数),和 size 的 (宽, 高) 正好前两个反过来
======================================================================
二、改单个像素
======================================================================
改完之后 (2,2): (255, 0, 0)
putpixel 改完 (0,0): (0, 255, 0)
======================================================================
三、四种做同一件事的方法,速度差几十倍
======================================================================
任务:把一张 800x600 的图反色(每个通道用 255 减)
做法 耗时 相对最慢的
----------------------------------------------------
纯 Python 双重循环 86.6 ms 快 1.0 倍
point() 查表 0.8 ms 快 104.3 倍
numpy 数组运算 3.3 ms 快 26.1 倍
ImageOps.invert 0.8 ms 快 111.5 倍
四种做法结果完全一致吗: True
结论:处理整张图时,永远不要写 for x: for y: 这样的双重循环。
能用现成函数就用现成函数(最快,代码也最短)
自定义的逐像素映射用 point()
需要复杂运算(比如通道之间互相计算)就转 numpy
======================================================================
四、point() 能做什么
======================================================================
原图是 0 到 255 的渐变,取几个点看看:
x=0: 0 x=128: 128 x=255: 255
提亮 50 point(lambda v: min(255, v + 50)):
x=0: 50 x=128: 178 x=255: 255
二值化 point(lambda v: 255 if v > 128 else 0):
x=0: 0 x=128: 0 x=200: 255
伽马校正 point(lambda v: int(255 * (v/255) ** 0.5)):
x=64: 127 x=128: 180
point 也可以直接给一个 256 长的列表当查找表,比 lambda 还快一点:
point([255-v for v in range(256)]) 的 x=0 处: 255
======================================================================
五、和 NumPy 互转
======================================================================
Image -> numpy:
shape: (3, 4, 3) (高, 宽, 通道)
dtype: uint8 uint8 就是 0~255 的整数
a[1, 1] = [10 20 30] 对应 Pillow 的 (x=1, y=1)
numpy -> Image:
fromarray 之后: (4, 3) RGB
和原图一致吗: True
fromarray 对数组的要求:
dtype 必须是 uint8(RGB/L 图),别的类型要先转
float64 数组: F <- 变成了 F 模式,不是普通灰度图
正确做法:先把值缩放到 0~255 再转 uint8
Image.fromarray((f * 255).astype('uint8')).mode = L
还有一个常见错误:算完之后忘了限制范围
uint8 数组 [200, 100] 直接 + 100: [ 44 200] <- 200+100 变成了 44!
这是 uint8 溢出后绕回来了,正确做法是先转成更大的类型:
np.clip(x.astype('int16') + 100, 0, 255): [255 200]
======================================================================
六、什么时候该转 numpy
======================================================================
该转:通道之间要互相计算、要做卷积、要和其他科学计算库对接
不该转:Pillow 已经有现成函数的(缩放、旋转、滤镜、调色)
因为转换本身也要时间,来回转一次就把省下的时间搭进去了:
1600x1200 的图,Image -> numpy: 4.5 ms
numpy -> Image: 3.3 ms11.2 三种读像素的方式 #
读像素有三种方式,适用场景各不相同:getpixel 适合偶尔取一两个点,load() 适合要读写很多次时,转 NumPy 适合要对整张图做运算时。
# 方式 1:getpixel,最直白
color = img.getpixel((20, 20))
# 方式 2:load() 拿到支持下标的对象,读写都比 getpixel 快
px = img.load()
# 注意这里是方括号加逗号,不是传元组
color = px[20, 20]
px[20, 20] = (255, 0, 0) # 也能写
# 方式 3:转 NumPy,一次拿全部
arr = np.array(img)
color = arr[20, 20] # 注意下标顺序!改单个像素还有 img.putpixel((x, y), color),和 load() 的写法等价。
11.3 最容易搞混的:(x, y) 还是 [y, x] #
这是 Pillow 和 NumPy 混用时最高频的 bug:
| 顺序 | 示例 | |
|---|---|---|
| Pillow 的坐标 | (x, y) 先横后竖 |
img.getpixel((250, 50)) |
| Pillow 的尺寸 | (宽, 高) |
img.size == (300, 100) |
| NumPy 的下标 | [y, x] 先行后列 |
arr[50, 250] |
| NumPy 的形状 | (高, 宽, 通道) |
arr.shape == (100, 300, 3) |
一张 300×100 的图,img.size 是 (300, 100),但 np.array(img).shape 是 (100, 300, 3)。前两个数字是反的。
阴险之处在于:用正方形图片测试永远发现不了这个错误。等你换成长方形图片,轻则取到错误的像素,重则直接 IndexError。
11.4 性能:不要写双重循环 #
这是本章最重要的一条。同样是把一张 800×600 的图反色,四种写法的实测耗时:
| 做法 | 耗时 | 相对最慢的 |
|---|---|---|
| 纯 Python 双重循环 | 81.1 ms | 1.0× |
numpy 数组运算 |
3.4 ms | 23.6× |
ImageOps.invert |
1.2 ms | 70.1× |
point() 查表 |
1.0 ms | 85.3× |
(具体数字取决于机器,但数量级差距是稳定的。)
四种做法的结果逐像素完全相同,所以这纯粹是速度差异,没有拿正确性做交换。
为什么双重循环这么慢?因为 800×600 = 48 万个像素,每个像素都要走一遍 Python 解释器的循环开销、函数调用、元组拆包。而后三种做法里,实际的逐像素计算全都发生在编译好的 C 代码里。
优先级很清楚:
- 有现成函数就用现成函数(最快,代码也最短)
- 自定义的逐像素映射用
point() - 需要通道之间互相计算才转 NumPy
- 永远不要写
for x: for y:
11.5 point() 为什么这么快 #
point() 看起来是给每个像素调用一次你传的函数,实际上不是。它先用你的函数建一张 256 项的查找表(把 0 到 255 挨个代入算一遍),然后在 C 层面对每个像素查表。
所以你的 Python 函数只被调用 256 次,而不是 48 万次。这就是它比 NumPy 还快的原因。
img.point(lambda v: min(255, v + 50)) # 提亮 50
img.point(lambda v: 255 if v > 128 else 0) # 二值化
img.point(lambda v: int(255 * (v / 255) ** 0.5)) # 伽马校正
img.point([255 - v for v in range(256)]) # 直接给查找表,更快这个机制也决定了 point() 的限制:因为是查表,所以新像素值只能由旧像素值决定,不能依赖坐标位置,也不能依赖其他通道。想做「红色通道减去蓝色通道」这种跨通道运算,就得转 NumPy。
11.6 和 NumPy 互转的两个坑 #
坑一:dtype 必须是 uint8。
Image.fromarray() 会根据数组的 dtype 猜模式。float64 的数组会变成 F 模式(32 位浮点灰度图),不是你想要的普通图片。
# 错:得到 F 模式
Image.fromarray(float_array)
# 对:先缩放到 0~255 再转 uint8
Image.fromarray((float_array * 255).astype("uint8"))坑二:uint8 溢出会静默绕回。
# dtype="uint8" 就是图片像素用的类型:每个数只有 0~255
a = np.array([200, 100, 50], dtype="uint8")
a + 100 # 得到 [44, 200, 150],200+100 变成了 44uint8 只能表示 0~255。200 + 100 = 300 存不下,就从头绕回来变成 44。一句警告都没有(实测确认过,即使打开所有警告也不会提示)。表现在图上就是本该过曝的亮部突然出现诡异的黑斑。
正确做法是先转成更大的类型,算完再夹回范围:
# 先升到 int16 算(装得下 300),clip 夹回 0~255,最后再降回 uint8
safe = np.clip(a.astype("int16") + 100, 0, 255).astype("uint8")
# 得到 [255, 200, 150]11.7 什么时候值得转 NumPy #
转换本身也要时间。实测 1600×1200 的图,Image → numpy 约 5.7 ms,numpy → Image 约 3.5 ms,来回一趟接近 10 ms。
如果中间的运算只省下 2 ms,那转换就是净亏损。
- 该转:通道之间要互相计算、要做卷积、要和 OpenCV / scikit-image / PyTorch 对接
- 不该转:Pillow 已经有现成函数的(缩放、旋转、滤镜、调色)
12. 保存:格式与参数怎么选 #
「这张图该存成什么格式、质量参数给多少」是每个项目都要面对的问题。这一章用实测数据给出答案。
12.1 完整演示 #
下面这段代码造了两种典型素材——一张「照片」(渐变加噪点)和一张「图标」(大片纯色),然后横向比较它们在各种格式和参数下的体积,量化 JPEG 到底损失了多少,并演示 RGBA 存 JPEG 的正确处理方式。
import io
import numpy as np
from PIL import Image, ImageDraw, ImageFont
def make_photo(w=600, h=400):
"""造一张像照片的图:渐变 + 平滑色块 + 细噪点。"""
# 固定随机种子,保证每次跑出来的数字都一样
rng = np.random.default_rng(7)
# 从上到下、从左到右都有渐变
base = np.add.outer(np.linspace(30, 220, h), np.linspace(0, 60, w))
# mgrid 生成两个和图一样大的坐标网格,yy 是行号,xx 是列号
yy, xx = np.mgrid[0:h, 0:w]
# 叠一层波纹,模拟照片里物体的明暗起伏
base += 40 * np.sin(xx / 40.0) * np.cos(yy / 55.0)
# 加细颗粒噪点,真实照片总有噪声
base += rng.normal(0, 12, (h, w))
# 三个通道给不同系数,就有了颜色
rgb = np.stack([base, base * 0.85 + 20, base * 0.7 + 40], axis=-1)
# clip 把超出 0~255 的值夹回来,再转 uint8 才能变成图片
return Image.fromarray(np.clip(rgb, 0, 255).astype("uint8"))
def make_graphic(w=600, h=400):
"""造一张像图标/截图的图:大片纯色 + 硬边缘。"""
# 白底,和照片那种满屏噪点形成对比
img = Image.new("RGB", (w, h), "white")
# 绑定画笔
d = ImageDraw.Draw(img)
# 大片纯色的矩形和圆,是「图标类」图片的典型特征
d.rectangle((50, 50, 250, 250), fill="#3366cc")
d.ellipse((300, 80, 500, 280), fill="#cc3333")
d.text((60, 300), "LOGO", fill="black", font=ImageFont.load_default(size=40))
return img
def size_of(img, fmt, **kw):
"""把图存进内存,返回占多少字节,不落盘。"""
buf = io.BytesIO()
img.save(buf, format=fmt, **kw)
return len(buf.getvalue())
photo = make_photo()
graphic = make_graphic()
print("=" * 70)
print("一、save 怎么决定存成什么格式")
print("=" * 70)
print("两种方式,二选一:")
print(" 1. 靠文件后缀:img.save('a.png') -> Pillow 看到 .png 就存 PNG")
print(" 2. 明确指定: img.save(f, format='PNG') -> 存到没有名字的流里时必须这样")
print()
print("两个都给了而且冲突时,format 说了算:")
print(" img.save('a.png', format='JPEG') 会得到一个「名字叫 png,内容是 JPEG」的文件。")
print(" Pillow 不会拦着你,所以文件后缀和实际格式对不上时,排查会很难受。")
print()
print("=" * 70)
print("二、JPEG:适合照片,有损")
print("=" * 70)
png_size = size_of(photo, "PNG")
print(f"一张 600x400 的照片,存成 PNG(无损)要 {png_size:,} 字节")
print()
print("质量参数 文件大小 占 PNG 的")
print("-" * 46)
for q in [95, 85, 75, 60, 40]:
n = size_of(photo, "JPEG", quality=q)
print(f"quality={q:<3d} {n:9,d} 字节 {n / png_size:6.1%}")
n = size_of(photo, "JPEG", quality=85, optimize=True)
print(f"quality=85 +optimize {n:6,d} 字节 {n / png_size:6.1%}")
print()
print("quality 范围 1~95(写 95 以上没意义,Pillow 也不推荐)。")
print("optimize=True 会多花一点时间重新算编码表,通常能再小 5%~10%,画质不变。")
print()
print("有损到什么程度?存一次读回来,看像素变了多少:")
orig = np.array(photo).astype(int)
for q in [95, 85, 60]:
buf = io.BytesIO()
photo.save(buf, format="JPEG", quality=q)
buf.seek(0)
back = np.array(Image.open(buf)).astype(int)
diff = np.abs(back - orig)
print(f" quality={q:3d}: 平均每个通道差 {diff.mean():5.2f},最大差 {diff.max():3d}")
buf = io.BytesIO()
photo.save(buf, format="PNG")
buf.seek(0)
print(" 存 PNG 读回来,和原图完全一样吗:",
np.array_equal(np.array(Image.open(buf)), np.array(photo)))
print()
print("反复存 JPEG 会不会越来越糊?实测一下:")
cur = photo
for gen in range(1, 6):
buf = io.BytesIO()
cur.save(buf, format="JPEG", quality=85)
buf.seek(0)
cur = Image.open(buf).copy()
diff = np.abs(np.array(cur).astype(int) - orig)
print(f" 第 {gen} 次存取后,和原图平均差 {diff.mean():5.2f}")
print(" 第一次损失最大,之后基本不再恶化(前提是每次质量一样、没有缩放裁剪)。")
print(" 但只要中间做了缩放、旋转这类操作,损失就会真的累积,所以:")
print(" 中间产物一律存 PNG,只在最后一步导出成 JPEG。")
print()
print("=" * 70)
print("三、PNG:适合图标、截图、需要透明的场合,无损")
print("=" * 70)
print("同一张「照片」和同一张「图标」,分别存三种格式:")
print()
print("格式 照片(600x400) 图标(600x400)")
print("-" * 44)
for label, fmt, kw in [("PNG", "PNG", {}), ("JPEG q85", "JPEG", {"quality": 85}),
("WebP q85", "WEBP", {"quality": 85})]:
a = size_of(photo, fmt, **kw)
b = size_of(graphic, fmt, **kw)
print(f"{label:10s} {a:11,d} B {b:11,d} B")
print()
print("照片:JPEG 比 PNG 小得多,选 JPEG。")
print("图标:PNG 反而比 JPEG 小,而且边缘干净没有压缩噪点,选 PNG。")
print("原因:PNG 用的压缩擅长处理大片相同的颜色,照片里几乎没有相同的像素。")
print()
print("PNG 也有个 optimize 参数,用时间换体积:")
print(f" 普通存 {size_of(graphic, 'PNG'):,} 字节")
print(f" optimize=True {size_of(graphic, 'PNG', optimize=True):,} 字节")
print(f" compress_level=9 {size_of(graphic, 'PNG', compress_level=9):,} 字节")
print(" compress_level 范围 0~9,默认 6,9 最小最慢,0 最快最大。")
print()
print("=" * 70)
print("四、最经典的坑:RGBA 存不进 JPEG")
print("=" * 70)
logo = Image.new("RGBA", (100, 100), (0, 0, 0, 0))
ImageDraw.Draw(logo).ellipse((10, 10, 90, 90), fill=(220, 60, 60, 255))
print("有透明背景的图,模式是:", logo.mode)
buf = io.BytesIO()
try:
logo.save(buf, format="JPEG")
except Exception as ex:
print(f"直接存 JPEG -> {type(ex).__name__}: {ex}")
print()
print("JPEG 格式本身就没有透明通道这个概念,所以只能报错。")
print("会遇到同样问题的还有 P 模式(GIF/调色板图)和 LA 模式。")
print()
print("错误的解决办法:直接 convert('RGB')")
wrong = logo.convert("RGB")
print(" 透明区域变成了:", wrong.getpixel((0, 0)), " <- 黑色,因为透明像素底下存的就是黑")
print()
print("正确的解决办法:先铺一层白底,把透明部分合成掉")
# 造一张和原图一样大的纯白 RGBA 底
background = Image.new("RGBA", logo.size, (255, 255, 255, 255))
# alpha_composite 要求两张图都是 RGBA 且尺寸相同
merged = Image.alpha_composite(background, logo)
# 合成完再转 RGB,此时透明区已经是白的了
right = merged.convert("RGB")
print(" 透明区域变成了:", right.getpixel((0, 0)), " <- 白色,对了")
print(" 圆形内部还是:", right.getpixel((50, 50)))
buf = io.BytesIO()
right.save(buf, format="JPEG", quality=90)
print(f" 现在能存 JPEG 了,{len(buf.getvalue()):,} 字节")
print()
print("另一种写法,用 paste 加遮罩,代码更短:")
bg2 = Image.new("RGB", logo.size, "white")
# paste(要贴的图, 位置, 遮罩):遮罩用 logo 自己,它的 alpha 通道就是遮罩
bg2.paste(logo, (0, 0), logo)
print(" 透明区域:", bg2.getpixel((0, 0)), " 圆形内部:", bg2.getpixel((50, 50)))
print(" 注意这个写法的底图直接就是 RGB,不用最后再 convert。")
print()
print("=" * 70)
print("五、WebP:又小又支持透明,但老浏览器不认")
print("=" * 70)
print(f"照片 JPEG q85: {size_of(photo, 'JPEG', quality=85):,} 字节")
print(f"照片 WebP q85: {size_of(photo, 'WEBP', quality=85):,} 字节")
print()
# WebP 既能有损也能无损
print(f"WebP 有损 q85 : {size_of(photo, 'WEBP', quality=85):,} 字节")
print(f"WebP 无损 lossless: {size_of(photo, 'WEBP', lossless=True):,} 字节")
print()
print("WebP 支持透明,所以 RGBA 能直接存:")
print(f" RGBA 存 WebP: {size_of(logo, 'WEBP'):,} 字节,成功")
print()
print("=" * 70)
print("六、一张速查表:该存什么格式")
print("=" * 70)
print(" 照片、需要小体积 -> JPEG,quality=85,加 optimize=True")
print(" 图标、截图、线条图、文字图 -> PNG")
print(" 需要透明背景 -> PNG(或 WebP)")
print(" 中间过程、要反复处理 -> PNG,绝不能用 JPEG")
print(" 网页上用、不管老浏览器 -> WebP")
print(" 动图 -> GIF(颜色少)或 WebP(颜色多)")======================================================================
一、save 怎么决定存成什么格式
======================================================================
两种方式,二选一:
1. 靠文件后缀:img.save('a.png') -> Pillow 看到 .png 就存 PNG
2. 明确指定: img.save(f, format='PNG') -> 存到没有名字的流里时必须这样
两个都给了而且冲突时,format 说了算:
img.save('a.png', format='JPEG') 会得到一个「名字叫 png,内容是 JPEG」的文件。
Pillow 不会拦着你,所以文件后缀和实际格式对不上时,排查会很难受。
======================================================================
二、JPEG:适合照片,有损
======================================================================
一张 600x400 的照片,存成 PNG(无损)要 423,274 字节
质量参数 文件大小 占 PNG 的
----------------------------------------------
quality=95 122,267 字节 28.9%
quality=85 70,769 字节 16.7%
quality=75 47,673 字节 11.3%
quality=60 30,798 字节 7.3%
quality=40 20,151 字节 4.8%
quality=85 +optimize 66,418 字节 15.7%
quality 范围 1~95(写 95 以上没意义,Pillow 也不推荐)。
optimize=True 会多花一点时间重新算编码表,通常能再小 5%~10%,画质不变。
有损到什么程度?存一次读回来,看像素变了多少:
quality= 95: 平均每个通道差 1.99,最大差 25
quality= 85: 平均每个通道差 4.47,最大差 29
quality= 60: 平均每个通道差 6.65,最大差 49
存 PNG 读回来,和原图完全一样吗: True
反复存 JPEG 会不会越来越糊?实测一下:
第 1 次存取后,和原图平均差 4.47
第 2 次存取后,和原图平均差 4.49
第 3 次存取后,和原图平均差 4.50
第 4 次存取后,和原图平均差 4.50
第 5 次存取后,和原图平均差 4.51
第一次损失最大,之后基本不再恶化(前提是每次质量一样、没有缩放裁剪)。
但只要中间做了缩放、旋转这类操作,损失就会真的累积,所以:
中间产物一律存 PNG,只在最后一步导出成 JPEG。
======================================================================
三、PNG:适合图标、截图、需要透明的场合,无损
======================================================================
同一张「照片」和同一张「图标」,分别存三种格式:
格式 照片(600x400) 图标(600x400)
--------------------------------------------
PNG 423,274 B 4,263 B
JPEG q85 70,769 B 12,393 B
WebP q85 69,534 B 3,928 B
照片:JPEG 比 PNG 小得多,选 JPEG。
图标:PNG 反而比 JPEG 小,而且边缘干净没有压缩噪点,选 PNG。
原因:PNG 用的压缩擅长处理大片相同的颜色,照片里几乎没有相同的像素。
PNG 也有个 optimize 参数,用时间换体积:
普通存 4,263 字节
optimize=True 3,568 字节
compress_level=9 3,568 字节
compress_level 范围 0~9,默认 6,9 最小最慢,0 最快最大。
======================================================================
四、最经典的坑:RGBA 存不进 JPEG
======================================================================
有透明背景的图,模式是: RGBA
直接存 JPEG -> OSError: cannot write mode RGBA as JPEG
JPEG 格式本身就没有透明通道这个概念,所以只能报错。
会遇到同样问题的还有 P 模式(GIF/调色板图)和 LA 模式。
错误的解决办法:直接 convert('RGB')
透明区域变成了: (0, 0, 0) <- 黑色,因为透明像素底下存的就是黑
正确的解决办法:先铺一层白底,把透明部分合成掉
透明区域变成了: (255, 255, 255) <- 白色,对了
圆形内部还是: (220, 60, 60)
现在能存 JPEG 了,2,630 字节
另一种写法,用 paste 加遮罩,代码更短:
透明区域: (255, 255, 255) 圆形内部: (220, 60, 60)
注意这个写法的底图直接就是 RGB,不用最后再 convert。
======================================================================
五、WebP:又小又支持透明,但老浏览器不认
======================================================================
照片 JPEG q85: 70,769 字节
照片 WebP q85: 69,534 字节
WebP 有损 q85 : 69,534 字节
WebP 无损 lossless: 282,248 字节
WebP 支持透明,所以 RGBA 能直接存:
RGBA 存 WebP: 764 字节,成功
======================================================================
六、一张速查表:该存什么格式
======================================================================
照片、需要小体积 -> JPEG,quality=85,加 optimize=True
图标、截图、线条图、文字图 -> PNG
需要透明背景 -> PNG(或 WebP)
中间过程、要反复处理 -> PNG,绝不能用 JPEG
网页上用、不管老浏览器 -> WebP
动图 -> GIF(颜色少)或 WebP(颜色多)12.2 save 怎么决定格式 #
存盘时 Pillow 需要知道存成什么格式,有两种告诉它的方式,理解优先级能避免一类难查的问题。
两种方式:
img.save("out.png") # 靠文件后缀
img.save(file_obj, format="PNG") # 明确指定,存到没有名字的流里时必须这样两个都给了而且冲突时,format 说了算。img.save("a.png", format="JPEG") 会得到一个「名字叫 png、内容是 JPEG」的文件,Pillow 不会拦着你。这种文件在别处打开时容易出莫名其妙的问题,排查起来很费劲。
12.3 JPEG 和 PNG 该选哪个 #
答案取决于图片内容,不能一概而论。实测同样尺寸的两张图:
| 格式 | 照片(渐变 + 噪点) | 图标(大片纯色) |
|---|---|---|
| PNG | 423,274 字节 | 4,263 字节 |
| JPEG q85 | 70,769 字节 | 12,393 字节 |
| WebP q85 | 69,534 字节 | 3,928 字节 |
照片:JPEG 比 PNG 小 6 倍,选 JPEG。 图标:PNG 反而比 JPEG 小 3 倍,选 PNG。
原因在于两种压缩的原理不同。PNG 用的是无损压缩,擅长处理「大片相同颜色」——一整片纯白只要记一次。照片里几乎没有两个像素完全相同,所以 PNG 压不动。JPEG 则是把图切成小块做频率变换,扔掉高频细节,对渐变和纹理很有效,但遇到纯色块边缘的锐利跳变反而要花更多字节去描述,还会在边缘产生难看的振铃噪点。
一句话记法:内容连续渐变的用 JPEG,内容是色块和线条的用 PNG。
JPEG 的两个参数:
quality:范围 1~95。写 95 以上没意义,Pillow 官方也不推荐。日常用 85 就够,追求体积可以到 75optimize=True:多花一点时间重新计算编码表,通常能再小 5%~10%,画质完全不变。实测 70,769 → 66,418 字节。基本上应该总是开着
PNG 的参数:
optimize=True和compress_level=9效果一样(实测都是 3,568 字节),前者其实就是把 level 设成 9quality对 PNG 无效,它是无损格式,传了也不起作用
12.4 RGBA 存不进 JPEG:最经典的坑 #
这是新手最常撞的一堵墙,前面已经提过好几次,这里给出完整的解决方案。
# 带透明背景的 PNG 打开后是 RGBA 模式
logo = Image.open("logo.png") # RGBA 模式
# 直接存 JPEG 会报错:OSError: cannot write mode RGBA as JPEG
logo.save("logo.jpg")JPEG 格式本身就没有「透明通道」这个概念,所以只能报错。同样存不进 JPEG 的还有 P 模式(GIF、调色板图)和 LA 模式。
错误的解决办法是直接 convert('RGB')。第 7.5 节讲过,这只是把 A 通道扔掉,透明区域会露出底下的黑色。
两种正确写法:
# 写法一:alpha_composite,语义最清晰
background = Image.new("RGBA", logo.size, (255, 255, 255, 255))
# 按 alpha 把 logo 混合到白底上,得到一张不再有透明区的 RGBA 图
merged = Image.alpha_composite(background, logo)
# 转 RGB 丢掉已经没用的 alpha 通道,这时才能存 JPEG
merged.convert("RGB").save("logo.jpg", quality=90)
# 写法二:paste 加遮罩,代码更短
background = Image.new("RGB", logo.size, "white")
background.paste(logo, (0, 0), logo) # 第三个参数是遮罩,用 logo 自己的 alpha
# paste 是原地修改,所以这里存的是 background 而不是新变量
background.save("logo.jpg", quality=90)两种写法结果一样:透明区变成白色 (255,255,255),图形本体保持原色 (220,60,60)。
区别是写法二的底图直接就是 RGB,不用最后再 convert。日常用写法二更省事,但要理解原理的话写法一更直白。
注意 alpha_composite 有两个硬性要求,违反了会报错:两张图都必须是 RGBA,而且尺寸必须完全相同。
12.5 JPEG 到底损失了多少 #
实测同一张照片,存一次 JPEG 再读回来,和原图逐像素比较:
| 质量 | 平均每通道差 | 最大差 |
|---|---|---|
| 95 | 1.99 | 25 |
| 85 | 4.47 | 29 |
| 60 | 6.65 | 49 |
而存成 PNG 再读回来,和原图完全相同。
关于「反复存 JPEG 会不会越来越糊」,实测结果可能和你想的不一样:
第 1 次存取后,和原图平均差 4.47
第 2 次存取后,和原图平均差 4.49
第 3 次存取后,和原图平均差 4.50
第 4 次存取后,和原图平均差 4.50
第 5 次存取后,和原图平均差 4.51第一次损失最大,之后几乎不再恶化。 因为用同样的质量参数重新编码同一张已经量化过的图,结果基本收敛了。
但这个结论有个重要前提:中间不能做任何缩放、裁剪、旋转。一旦做了,像素网格和 JPEG 的 8×8 块就对不齐了,损失会真正开始累积。第 15 章的坑 14 用对照实验量化了这一点。
所以那条规矩依然成立:中间产物存 PNG,只在最后一步导出 JPEG。
12.6 WebP #
WebP 是比较新的格式,特点是又小又支持透明,还能选有损或无损:
img.save("out.webp", quality=85) # 有损
img.save("out.webp", lossless=True) # 无损实测照片有损 WebP 69,534 字节,跟同质量 JPEG 的 70,769 差不多;但图标类 WebP 3,928 字节,比 PNG 的 4,263 更小。而且 RGBA 能直接存,不用像 JPEG 那样先合成。
唯一的顾虑是兼容性——IE 和很老的浏览器不认。现代浏览器和主流图片查看器都已经支持了。
12.7 速查表 #
把这一章的结论压缩成一张可以直接照着抄的表:
| 场景 | 格式 | 参数 |
|---|---|---|
| 照片,要小体积 | JPEG | quality=85, optimize=True |
| 图标、截图、线条图、文字图 | PNG | optimize=True |
| 需要透明背景 | PNG 或 WebP | — |
| 中间过程、要反复处理 | PNG | 绝不能用 JPEG |
| 网页用,不管老浏览器 | WebP | quality=85 |
| 动图 | GIF(颜色少)或 WebP(颜色多) | 见第 13 章 |
13. GIF 与多帧图像 #
GIF 和一般图片的区别在于它有多帧。Pillow 处理多帧图的方式有一套固定套路,还有一个几乎人人都会踩的坑。
13.1 完整演示 #
下面这段代码从零造出一个 12 帧的旋转小球动图,然后把它读回来、拆成单帧、验证每帧内容确实不同,并对比 GIF 和 WebP 在不同素材下的体积。整个过程不需要任何外部 GIF 文件。
import io
import math
from PIL import Image, ImageDraw, ImageSequence
print("=" * 70)
print("一、做一个 GIF:把一串 Image 存成一个文件")
print("=" * 70)
frames = []
# 一共 12 帧,转一整圈
n = 12
for i in range(n):
# 每一帧都是独立的一张图
f = Image.new("RGB", (120, 120), "white")
# 每一帧都要重新绑一次画笔,因为画笔是绑在具体某张图上的
d = ImageDraw.Draw(f)
# 让一个红球绕着圆心转,每帧转 1/n 圈
angle = 2 * math.pi * i / n
# 用三角函数算出这一帧球心的横坐标,35 是转动半径
cx = 60 + 35 * math.cos(angle)
# 纵坐标同理
cy = 60 + 35 * math.sin(angle)
# 以 (cx, cy) 为中心画一个半径 12 的红球
d.ellipse((cx - 12, cy - 12, cx + 12, cy + 12), fill="red")
# 顺便在中间写上帧号
d.text((52, 55), str(i), fill="black")
# 收进列表,等下一起存成 GIF
frames.append(f)
print(f"准备了 {len(frames)} 帧,每帧 {frames[0].size}")
# 用内存流当文件,省得真在硬盘上建文件
buf = io.BytesIO()
# 存 GIF 的固定写法:用第一帧调 save,其余帧放在 append_images 里
frames[0].save(
buf,
format="GIF",
# save_all=True 是关键,不写的话只会存第一帧
save_all=True,
# 第 2 帧到最后一帧
append_images=frames[1:],
# 每帧停留多少毫秒,100 就是每秒 10 帧
duration=100,
# loop=0 表示无限循环,loop=1 表示播完一遍就停
loop=0,
)
gif_bytes = buf.getvalue()
print(f"存出来的 GIF: {len(gif_bytes):,} 字节")
print()
print("三个必须记住的参数:")
print(" save_all=True 不写就只存第一帧,这是最常见的错误")
print(" append_images 除第一帧外的所有帧")
print(" duration 每帧毫秒数,也可以传一个列表给每帧单独设时长")
print(" loop=0 无限循环;不写 loop 参数的话只播一遍")
print()
print("=" * 70)
print("二、读一个 GIF")
print("=" * 70)
buf.seek(0)
gif = Image.open(buf)
print("n_frames :", gif.n_frames, " 一共几帧")
print("is_animated:", gif.is_animated, " 是不是动图")
print("size :", gif.size)
print("mode :", gif.mode, " <- GIF 一定是 P(调色板)模式")
print("info :", {k: v for k, v in gif.info.items() if k in ("duration", "loop")})
print()
print("刚 open 时停在第 0 帧,用 seek 跳到别的帧:")
for i in [0, 3, 6]:
gif.seek(i)
print(f" seek({i}) 之后,tell() = {gif.tell()}")
# 用完记得回到第 0 帧,不然后面的操作会从当前帧开始
gif.seek(0)
print()
print("=" * 70)
print("三、遍历每一帧:一个必踩的坑")
print("=" * 70)
print("ImageSequence.Iterator 每次返回的其实是同一个对象,只是内部指针挪了。")
print("直接把它们收集到列表里,最后会发现列表里全是最后一帧。")
print()
buf.seek(0)
gif = Image.open(buf)
# 错误写法:直接收集,得到的是 n 个指向同一对象的引用
wrong = [frame for frame in ImageSequence.Iterator(gif)]
# 用 tobytes 比一比,如果全都一样就说明踩坑了
unique_wrong = len({f.tobytes() for f in wrong})
print(f" 直接收集: {len(wrong)} 帧,其中真正不同的只有 {unique_wrong} 种")
buf.seek(0)
gif = Image.open(buf)
# 正确写法:每一帧都 copy() 一份,把当前状态固定下来
right = [frame.copy() for frame in ImageSequence.Iterator(gif)]
unique_right = len({f.tobytes() for f in right})
print(f" 加 .copy(): {len(right)} 帧,其中真正不同的有 {unique_right} 种")
print()
print("记住:从 ImageSequence.Iterator 拿出来的帧,只要还想留着用,就必须 .copy()")
print()
print("=" * 70)
print("四、把 GIF 拆成一张张图片")
print("=" * 70)
buf.seek(0)
gif = Image.open(buf)
extracted = []
# ImageSequence.Iterator 会自动帮你 seek,不用手动一帧帧翻
for i, frame in enumerate(ImageSequence.Iterator(gif)):
# GIF 帧是 P 模式,想存成 PNG/JPEG 一般要先转 RGB 或 RGBA
# convert 本身就会返回新图,所以这里不用再额外 copy
rgb = frame.convert("RGB")
# 收集起来,下面验证每帧内容确实不同
extracted.append(rgb)
print(f"拆出 {len(extracted)} 张 RGB 图,每张 {extracted[0].size} {extracted[0].mode}")
# 看看红球确实在动:找出每帧红色像素的重心
print()
print("验证一下每帧内容确实不同(找红球中心):")
for i in [0, 3, 6, 9]:
# load() 比 getpixel 快,这里要扫很多点
px = extracted[i].load()
# 每隔 3 像素采样一次,挑出「红多绿少」的点,也就是红球上的点
reds = [(x, y) for y in range(0, 120, 3) for x in range(0, 120, 3)
if px[x, y][0] > 180 and px[x, y][1] < 100]
if reds:
# 所有红点的横坐标求平均,就是红球中心的 x
cx = sum(p[0] for p in reds) / len(reds)
# 纵坐标同理
cy = sum(p[1] for p in reds) / len(reds)
print(f" 第 {i} 帧,红球中心约在 ({cx:.0f}, {cy:.0f})")
print()
print("=" * 70)
print("五、GIF 只有 256 色,照片存 GIF 会明显掉色")
print("=" * 70)
# 造一张颜色丰富的图
rich = Image.new("RGB", (150, 150))
rp = rich.load()
for x in range(150):
for y in range(150):
# 让颜色跟着坐标走,这样两万多个像素几乎没有重样的
rp[x, y] = (x * 255 // 150, y * 255 // 150, (x + y) * 255 // 300)
print("原图颜色种类:", len(rich.getcolors(maxcolors=1000000)))
b2 = io.BytesIO()
# 存成 GIF 会强制转成 P 模式,颜色被压到 256 以内
rich.save(b2, format="GIF")
b2.seek(0)
# 读回来转成 RGB,才好统计还剩多少种颜色
back = Image.open(b2).convert("RGB")
print("存成 GIF 再读回来:", len(back.getcolors(maxcolors=1000000)), "种")
print("<- 被压到 256 色以内了,渐变会出现一圈圈的色带")
print()
print("颜色多的动图,优先考虑 WebP 动图或者 MP4,不要用 GIF。")
print()
print("=" * 70)
print("六、GIF 还是 WebP 动图?取决于颜色多不多")
print("=" * 70)
print("存 WebP 动图的写法和 GIF 一模一样,只要把 format 换掉:")
print(" frames[0].save(f, format='WEBP', save_all=True, append_images=..., duration=..., loop=0)")
print()
def anim_size(frs, fmt, **kw):
"""把一组帧存成动图,返回字节数。"""
b = io.BytesIO()
# GIF 和 WebP 的动图参数完全一样,只有 format 不同
frs[0].save(b, format=fmt, save_all=True, append_images=frs[1:],
duration=100, loop=0, **kw)
# getvalue() 拿到内存流里的全部字节
return len(b.getvalue())
# 第一组:就是上面那个红球,只有三种颜色
print(f"少色动画(红球,只有 {len(frames[0].getcolors(maxcolors=10 ** 6))} 种颜色):")
print(f" GIF : {anim_size(frames, 'GIF'):8,d} 字节")
print(f" WebP: {anim_size(frames, 'WEBP'):8,d} 字节 <- 反而更大")
# 第二组:造一个颜色丰富的流动波纹动画
colorful = []
for i in range(n):
f = Image.new("RGB", (120, 120))
p = f.load()
for x in range(120):
for y in range(120):
# 用正弦波做出平滑流动的彩色条纹,每帧相位往前挪一点
v = int(math.sin((x + i * 10) / 15.0) * 80 + 128)
# 三个通道用不同方式从 v 派生,得到平滑流动的彩色条纹
p[x, y] = (v, (v + y) % 256, 255 - v)
colorful.append(f)
print()
print(f"多色动画(波纹,{len(colorful[0].getcolors(maxcolors=10 ** 6))} 种颜色):")
print(f" GIF : {anim_size(colorful, 'GIF'):8,d} 字节")
print(f" WebP: {anim_size(colorful, 'WEBP', quality=80):8,d} 字节 <- 小了好几倍")
print()
print("所以:")
print(" 纯色块、线条、少量颜色的动图 -> GIF 更小")
print(" 照片、渐变、颜色多的动图 -> WebP 小得多,而且不会被压到 256 色")
print()
print("顺便提一句:GIF 的 optimize=True 和 colors= 这两个参数,")
print("在上面这些例子里实测完全没有改变文件大小,别指望靠它们瘦身。")
print("真要让 GIF 变小,有效的办法是:减少帧数、缩小尺寸、缩短时长。")======================================================================
一、做一个 GIF:把一串 Image 存成一个文件
======================================================================
准备了 12 帧,每帧 (120, 120)
存出来的 GIF: 4,551 字节
三个必须记住的参数:
save_all=True 不写就只存第一帧,这是最常见的错误
append_images 除第一帧外的所有帧
duration 每帧毫秒数,也可以传一个列表给每帧单独设时长
loop=0 无限循环;不写 loop 参数的话只播一遍
======================================================================
二、读一个 GIF
======================================================================
n_frames : 12 一共几帧
is_animated: True 是不是动图
size : (120, 120)
mode : P <- GIF 一定是 P(调色板)模式
info : {'loop': 0, 'duration': 100}
刚 open 时停在第 0 帧,用 seek 跳到别的帧:
seek(0) 之后,tell() = 0
seek(3) 之后,tell() = 3
seek(6) 之后,tell() = 6
======================================================================
三、遍历每一帧:一个必踩的坑
======================================================================
ImageSequence.Iterator 每次返回的其实是同一个对象,只是内部指针挪了。
直接把它们收集到列表里,最后会发现列表里全是最后一帧。
直接收集: 12 帧,其中真正不同的只有 1 种
加 .copy(): 12 帧,其中真正不同的有 12 种
记住:从 ImageSequence.Iterator 拿出来的帧,只要还想留着用,就必须 .copy()
======================================================================
四、把 GIF 拆成一张张图片
======================================================================
拆出 12 张 RGB 图,每张 (120, 120) RGB
验证一下每帧内容确实不同(找红球中心):
第 0 帧,红球中心约在 (95, 60)
第 3 帧,红球中心约在 (60, 95)
第 6 帧,红球中心约在 (25, 60)
第 9 帧,红球中心约在 (59, 25)
======================================================================
五、GIF 只有 256 色,照片存 GIF 会明显掉色
======================================================================
原图颜色种类: 22500
存成 GIF 再读回来: 256 种
<- 被压到 256 色以内了,渐变会出现一圈圈的色带
颜色多的动图,优先考虑 WebP 动图或者 MP4,不要用 GIF。
======================================================================
六、GIF 还是 WebP 动图?取决于颜色多不多
======================================================================
存 WebP 动图的写法和 GIF 一模一样,只要把 format 换掉:
frames[0].save(f, format='WEBP', save_all=True, append_images=..., duration=..., loop=0)
少色动画(红球,只有 17 种颜色):
GIF : 4,551 字节
WebP: 7,798 字节 <- 反而更大
多色动画(波纹,10440 种颜色):
GIF : 109,095 字节
WebP: 18,874 字节 <- 小了好几倍
所以:
纯色块、线条、少量颜色的动图 -> GIF 更小
照片、渐变、颜色多的动图 -> WebP 小得多,而且不会被压到 256 色
顺便提一句:GIF 的 optimize=True 和 colors= 这两个参数,
在上面这些例子里实测完全没有改变文件大小,别指望靠它们瘦身。
真要让 GIF 变小,有效的办法是:减少帧数、缩小尺寸、缩短时长。13.2 做一个 GIF #
固定写法:用第一帧调 save,其余帧放在 append_images 里。
# 用第一帧当「主图」发起保存,后面的帧靠 append_images 挂上去
frames[0].save(
"out.gif",
save_all=True, # 不写的话只会存第一帧!
append_images=frames[1:], # 第 2 帧到最后一帧
duration=100, # 每帧停留 100 毫秒,也就是每秒 10 帧
loop=0, # 0 表示无限循环;不写 loop 只播一遍
)四个参数里有两个是「不写就出错」的:
save_all=True不写,只会存第一帧,得到一张静态图。这是最常见的错误loop=0不写,动图只播一遍就停。想循环播放必须显式写上
duration 除了给一个数字,也可以给一个列表,为每帧单独设置停留时间。
13.3 读一个 GIF #
读多帧图和读普通图不一样:一个 Image 对象同一时刻只「停」在某一帧上,要看别的帧得先跳过去。
# 打开后对象默认停在第 0 帧
gif = Image.open("in.gif")
print(gif.n_frames) # 一共几帧
print(gif.is_animated) # 是不是动图
print(gif.mode) # 一定是 'P',GIF 只支持调色板模式
gif.seek(3) # 跳到第 3 帧
print(gif.tell()) # 当前在第几帧Image.open 打开 GIF 后,对象停在第 0 帧。seek() 换帧,tell() 查当前帧号。用完记得 seek(0) 回到开头,否则后续操作会从当前帧开始。
13.4 遍历每一帧:必踩的坑 #
ImageSequence.Iterator 每次返回的其实是同一个对象,只是内部指针挪了位置。所以直接把它们收进列表,最后列表里 n 个元素全都指向同一帧。
实测:一个 12 帧的 GIF,直接收集得到 12 个元素,但其中真正不同的只有 1 种。
# 错误:列表里 12 个元素全是同一帧
frames = [f for f in ImageSequence.Iterator(gif)]
# 正确:每帧 copy 一份,把当前状态固定下来
frames = [f.copy() for f in ImageSequence.Iterator(gif)]加上 .copy() 之后,12 个元素确实是 12 种不同的内容。
规矩很简单:从 ImageSequence.Iterator 拿出来的帧,只要还想留着用,就必须 .copy()。 如果是「取一帧、处理、马上存盘」的流水线用法,不 copy 也没关系。
13.5 GIF 只有 256 色 #
GIF 只支持 P 模式,最多 256 种颜色。实测一张有 22500 种颜色的渐变图,存成 GIF 再读回来只剩 256 种。
表现在画面上,就是平滑渐变会变成一圈一圈的色带。所以照片和渐变丰富的内容不适合存 GIF。
另外,从 GIF 读出来的帧是 P 模式,想存成 PNG 或做进一步处理,一般要先 convert("RGB") 或 convert("RGBA")。
13.6 GIF 还是 WebP 动图 #
WebP 动图的写法和 GIF 一模一样,只要换个 format:
# 除了 format 换成 WEBP,其余参数和存 GIF 完全一致
frames[0].save("out.webp", format="WEBP", save_all=True,
append_images=frames[1:], duration=100, loop=0)哪个更小?实测结果分两种情况,和第 12.3 节的规律一致:
| 内容 | GIF | WebP |
|---|---|---|
| 少色动画(17 种颜色的小球) | 4,551 字节 | 7,798 字节 |
| 多色动画(10440 种颜色的波纹) | 109,095 字节 | 18,874 字节 |
纯色块、线条、颜色少的动图,GIF 更小;照片、渐变、颜色多的动图,WebP 小好几倍,而且不会被压到 256 色。
最后说一个反常识的实测结论:GIF 的 optimize=True 和 colors= 这两个参数,在多种测试场景下文件大小一个字节都没变。 网上很多教程把它们当作 GIF 瘦身的标准手段,但至少在 Pillow 12 上不要指望它们。真正有效的办法是:减少帧数、缩小尺寸、缩短总时长。
14. 实战:批量缩略图与水印 #
前面各章是拆开讲的知识点,这一章把它们串成两个能直接用的完整程序。
14.1 完整演示 #
下面这段代码先造出五张尺寸各异的假素材(模拟真实项目里五花八门的输入),然后依次演示三种缩略图策略、半透明中文水印,最后把两件事串成一个带错误处理的批处理函数。全程不需要任何外部图片。
import os
import shutil
import tempfile
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageOps
# ====================================================================
# 准备工作:造几张假的"原始图片",模拟一个待处理的文件夹
# ====================================================================
# 全部在临时目录里操作,跑完自动清理,不会在你的项目里留垃圾
workdir = Path(tempfile.mkdtemp(prefix="pillow_demo_"))
# 存放「原始素材」的子目录
src_dir = workdir / "原图"
# mkdir 只建一层,父目录 workdir 已经由 mkdtemp 建好了
src_dir.mkdir()
# 造 5 张尺寸各不相同的图,模拟真实场景里五花八门的素材
specs = [("横图.jpg", 800, 500), ("竖图.jpg", 400, 700),
("方图.png", 600, 600), ("小图.png", 150, 100),
("超宽图.jpg", 1200, 300)]
for name, w, h in specs:
# 按指定尺寸造图
im = Image.new("RGB", (w, h))
# 绑定画笔
d = ImageDraw.Draw(im)
# 每张图画一个渐变加一个圆,方便肉眼区分
for y in range(h):
# t 是 0 到 1 的纵向进度
t = y / h
# 逐行画横线做出上下渐变
d.line((0, y, w, y), fill=(int(200 * t), int(100 + 80 * t), int(220 - 120 * t)))
# 在中间画个白圆,用相对比例定位,各种尺寸都合适
d.ellipse((w * 0.3, h * 0.3, w * 0.7, h * 0.7), fill="white")
# 在图上标出它原本的尺寸
d.text((10, 10), f"{w}x{h}", fill="black", font=ImageFont.load_default(size=24))
# 后缀决定存成什么格式,specs 里混了 jpg 和 png
im.save(src_dir / name)
print("准备好的原始文件:")
for p in sorted(src_dir.iterdir()):
with Image.open(p) as im:
print(f" {p.name:12s} {str(im.size):12s} {os.path.getsize(p):7,d} 字节")
def find_cjk_font(size):
"""找一个能显示中文的字体,找不到就退回默认字体。"""
# 按 Windows、macOS、Linux 的顺序挨个试
for path in ["C:/Windows/Fonts/msyh.ttc", "C:/Windows/Fonts/simhei.ttf",
"/System/Library/Fonts/PingFang.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"]:
if Path(path).exists():
# 找到第一个存在的就用它
return ImageFont.truetype(path, size)
# 找不到中文字体时至少别崩,但中文会变成豆腐块
return ImageFont.load_default(size=size)
# ====================================================================
print()
print("=" * 70)
print("实战一:批量生成缩略图")
print("=" * 70)
# ====================================================================
# 缩略图单独放一个目录,和原图分开
thumb_dir = workdir / "缩略图"
thumb_dir.mkdir()
# 缩略图统一按这个框来做
THUMB_BOX = (200, 200)
for path in sorted(src_dir.iterdir()):
# 用 with 打开,处理完自动关文件,批量处理时这点很重要
with Image.open(path) as im:
# exif_transpose 会读手机照片里的方向标记,把躺着的照片扶正
# 不做这一步,手机竖着拍的照片处理完可能是横的
im = ImageOps.exif_transpose(im)
# 统一转成 RGB:万一原图是 RGBA 或 P,后面存 JPEG 才不会报错
if im.mode != "RGB":
im = im.convert("RGB")
# contain 保持比例缩进框里,内容完整不裁切
thumb = ImageOps.contain(im, THUMB_BOX, method=Image.Resampling.LANCZOS)
# 换成 .jpg 后缀存出去
out = thumb_dir / (path.stem + ".jpg")
# optimize=True 免费再省几个百分点,画质不变
thumb.save(out, quality=85, optimize=True)
print(f" {path.name:12s} {str(im.size):12s} -> {str(thumb.size):12s} "
f"{os.path.getsize(out):6,d} 字节")
print()
print("留意「小图.png」:它本来只有 150x100,contain 把它放大到了 200x133。")
print("contain 是会放大的,这一点和 thumbnail 不同(thumbnail 只缩不放)。")
print("不希望小图被强行放大糊掉的话,处理前先判断一下尺寸再决定要不要缩放。")
print()
print("如果要求所有缩略图尺寸完全一致(比如做网格排版),把 contain 换成 pad:")
for path in sorted(src_dir.iterdir())[:3]:
with Image.open(path) as im:
im = im.convert("RGB")
# pad 先等比缩进去,再用指定颜色把空白补满,输出尺寸严格等于框
padded = ImageOps.pad(im, THUMB_BOX, color="white",
method=Image.Resampling.LANCZOS)
print(f" {path.name:12s} -> {padded.size}")
print()
print("如果宁可裁掉一点也要填满整个框(比如做封面),用 fit:")
for path in sorted(src_dir.iterdir())[:3]:
with Image.open(path) as im:
im = im.convert("RGB")
# fit 会先按短边缩放,再从中间裁掉多余的部分
fitted = ImageOps.fit(im, THUMB_BOX, method=Image.Resampling.LANCZOS)
print(f" {path.name:12s} -> {fitted.size}")
# ====================================================================
print()
print("=" * 70)
print("实战二:批量加半透明中文水印")
print("=" * 70)
# ====================================================================
# 加过水印的图放这里
mark_dir = workdir / "带水印"
mark_dir.mkdir()
def add_watermark(img, text, opacity=110, margin=16):
"""在图片右下角加一段半透明文字水印,返回新图。
opacity 是水印的不透明度,0 全透明,255 完全不透明。
"""
# 水印必须画在独立的 RGBA 图层上,直接在 RGB 图上画是没有透明效果的
base = img.convert("RGBA")
# 新建一个和原图一样大、完全透明的空图层
layer = Image.new("RGBA", base.size, (255, 255, 255, 0))
draw = ImageDraw.Draw(layer)
# 字号跟着图片宽度走,这样大图小图上的水印看起来一样大
font_size = max(14, base.width // 22)
# 必须用带中文字形的字体,否则水印会是一串豆腐块
font = find_cjk_font(font_size)
# 量一下这段文字有多大,才能算出右下角该从哪里开始画
bbox = draw.textbbox((0, 0), text, font=font)
# 右减左得到文字宽度
tw = bbox[2] - bbox[0]
# 下减上得到文字高度
th = bbox[3] - bbox[1]
# 右下角坐标减去文字尺寸和边距,再减去 bbox 的偏移
x = base.width - tw - margin - bbox[0]
y = base.height - th - margin - bbox[1]
# 白字加黑描边,这样不管底图是深是浅都能看清
draw.text((x, y), text, font=font,
fill=(255, 255, 255, opacity),
stroke_width=max(1, font_size // 14),
stroke_fill=(0, 0, 0, opacity))
# 把水印层叠到原图上,这一步才真正做透明混合
merged = Image.alpha_composite(base, layer)
# 转回 RGB,这样才能存成 JPEG
return merged.convert("RGB")
for path in sorted(src_dir.iterdir()):
with Image.open(path) as im:
im = ImageOps.exif_transpose(im)
# 加水印,返回的已经是可以直接存 JPEG 的 RGB 图
marked = add_watermark(im, "© 我的相册")
out = mark_dir / (path.stem + ".jpg")
marked.save(out, quality=88, optimize=True)
print(f" {path.name:12s} -> {out.name:12s} {os.path.getsize(out):7,d} 字节")
print()
print("验证水印确实是半透明的(右下角的像素既不是纯白也不是原来的颜色):")
# 同时打开加水印前后的两张图做对比
with Image.open(src_dir / "横图.jpg") as before:
with Image.open(mark_dir / "横图.jpg") as after:
# 挑一个水印文字覆盖到的位置
probe = (before.width - 60, before.height - 30)
print(f" 加水印前 {probe}: {before.getpixel(probe)}")
print(f" 加水印后 {probe}: {after.getpixel(probe)}")
print(" 颜色变了但没变成纯白,说明是混合上去的,不是盖上去的")
# ====================================================================
print()
print("=" * 70)
print("实战三:把上面两件事串成一个完整的批处理函数")
print("=" * 70)
# ====================================================================
def process_folder(src, dst, box=(400, 400), watermark=None, quality=85):
"""把 src 里的图片统一处理后放进 dst,返回 (成功数, 失败列表)。"""
# 允许传字符串路径进来,统一转成 Path 好操作
dst = Path(dst)
# parents 连父目录一起建,exist_ok 让重复运行不报错
dst.mkdir(parents=True, exist_ok=True)
# Pillow 认得的常见后缀,其他文件直接跳过
exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".gif", ".tif", ".tiff"}
# ok 记成功张数,failed 记 (文件名, 错误类型)
ok, failed = 0, []
for path in sorted(Path(src).iterdir()):
# 先按后缀粗筛一遍,省得对文本文件也去开图
if path.suffix.lower() not in exts:
continue
try:
with Image.open(path) as im:
# 摆正方向 -> 统一模式 -> 缩放 -> 加水印 -> 存盘
im = ImageOps.exif_transpose(im)
if im.mode != "RGB":
im = im.convert("RGB")
im = ImageOps.contain(im, box, method=Image.Resampling.LANCZOS)
# 没传 watermark 就跳过加水印这一步
if watermark:
im = add_watermark(im, watermark)
# 不管原来是什么格式,一律输出成 jpg
im.save(dst / (path.stem + ".jpg"), quality=quality, optimize=True)
# 走到这里说明这张图全程没出错
ok += 1
except Exception as ex:
# 一张图坏了不该让整批任务停下来,记下来继续
failed.append((path.name, type(ex).__name__))
# 把统计结果交回给调用方,方便打日志或者重试
return ok, failed
# 故意在文件夹里塞一个假图片,测试错误处理
(src_dir / "假图片.jpg").write_text("这根本不是图片", encoding="utf-8")
# 再塞一个不该被处理的文本文件
(src_dir / "说明.txt").write_text("说明文字", encoding="utf-8")
result_dir = workdir / "成品"
# 一行代码跑完整条流水线
ok, failed = process_folder(src_dir, result_dir, box=(400, 400), watermark="© 示例")
print(f"成功处理 {ok} 张")
print(f"失败 {len(failed)} 张:")
for name, err in failed:
print(f" {name} -> {err}(说明它虽然叫 .jpg,内容却不是图片)")
print()
print("注意 说明.txt 被后缀过滤掉了,根本没进入处理流程;")
print("而 假图片.jpg 后缀是对的,只有真去打开时才发现不是图片,所以要靠 try 兜住。")
print()
print("成品文件:")
for p in sorted(result_dir.iterdir()):
with Image.open(p) as im:
print(f" {p.name:12s} {str(im.size):12s} {os.path.getsize(p):6,d} 字节")
# 清理临时目录
# rmtree 会连目录带里面的文件一起删,注意别对着真实目录用
shutil.rmtree(workdir)
print()
print("临时目录已清理:", not workdir.exists())准备好的原始文件:
小图.png (150, 100) 2,495 字节
方图.png (600, 600) 6,206 字节
横图.jpg (800, 500) 15,489 字节
竖图.jpg (400, 700) 10,484 字节
超宽图.jpg (1200, 300) 16,114 字节
======================================================================
实战一:批量生成缩略图
======================================================================
小图.png (150, 100) -> (200, 133) 3,243 字节
方图.png (600, 600) -> (200, 200) 2,783 字节
横图.jpg (800, 500) -> (200, 125) 2,340 字节
竖图.jpg (400, 700) -> (114, 200) 2,128 字节
超宽图.jpg (1200, 300) -> (200, 50) 1,433 字节
留意「小图.png」:它本来只有 150x100,contain 把它放大到了 200x133。
contain 是会放大的,这一点和 thumbnail 不同(thumbnail 只缩不放)。
不希望小图被强行放大糊掉的话,处理前先判断一下尺寸再决定要不要缩放。
如果要求所有缩略图尺寸完全一致(比如做网格排版),把 contain 换成 pad:
小图.png -> (200, 200)
方图.png -> (200, 200)
横图.jpg -> (200, 200)
如果宁可裁掉一点也要填满整个框(比如做封面),用 fit:
小图.png -> (200, 200)
方图.png -> (200, 200)
横图.jpg -> (200, 200)
======================================================================
实战二:批量加半透明中文水印
======================================================================
小图.png -> 小图.jpg 3,705 字节
方图.png -> 方图.jpg 17,253 字节
横图.jpg -> 横图.jpg 17,324 字节
竖图.jpg -> 竖图.jpg 10,722 字节
超宽图.jpg -> 超宽图.jpg 21,137 字节
验证水印确实是半透明的(右下角的像素既不是纯白也不是原来的颜色):
加水印前 (740, 470): (188, 175, 105)
加水印后 (740, 470): (222, 215, 169)
颜色变了但没变成纯白,说明是混合上去的,不是盖上去的
======================================================================
实战三:把上面两件事串成一个完整的批处理函数
======================================================================
成功处理 5 张
失败 1 张:
假图片.jpg -> UnidentifiedImageError(说明它虽然叫 .jpg,内容却不是图片)
注意 说明.txt 被后缀过滤掉了,根本没进入处理流程;
而 假图片.jpg 后缀是对的,只有真去打开时才发现不是图片,所以要靠 try 兜住。
成品文件:
小图.jpg (400, 267) 8,456 字节
方图.jpg (400, 400) 7,719 字节
横图.jpg (400, 250) 6,509 字节
竖图.jpg (229, 400) 5,892 字节
超宽图.jpg (400, 100) 3,872 字节
临时目录已清理: True14.2 批量缩略图的四个必要步骤 #
从上面的代码里可以提炼出一个顺序,每一步都不能少:
with Image.open(path) as im: # 1. 用 with 打开,避免句柄泄漏
im = ImageOps.exif_transpose(im) # 2. 按 EXIF 摆正方向
if im.mode != "RGB":
im = im.convert("RGB") # 3. 统一模式,避免存 JPEG 时报错
thumb = ImageOps.contain(im, (200, 200),
method=Image.Resampling.LANCZOS) # 4. 缩放
# 5. quality 控画质,optimize 再免费省几个百分点
thumb.save(out, quality=85, optimize=True)第 2 步 exif_transpose 最容易被漏掉,后果也最明显。 手机竖着拍的照片,实际存的往往是横着的像素,靠 EXIF 里的一个方向标记告诉查看器「显示时请转 90 度」。Pillow 读像素时不会自动应用这个标记,所以你直接处理会得到一张躺倒的图。而一旦做了裁剪缩放,EXIF 标记通常也丢了,最终结果就是歪的。
ImageOps.exif_transpose(im) 会读这个标记并真正把像素转正,之后就不用再操心方向问题。它必须在任何裁剪、缩放之前调用。
第 3 步的模式统一也是必需的。素材里可能混着 PNG(RGBA)、GIF(P),不转成 RGB 的话,存 JPEG 时会报 cannot write mode RGBA as JPEG。
14.3 三种缩略图策略 #
同样是「做缩略图」,具体要求不同,选的函数也不同。三种策略对应三种典型场景:
| 需求 | 用什么 | 结果 |
|---|---|---|
| 内容完整,尺寸可以不一致 | ImageOps.contain |
横图 200×125,竖图 114×200 |
| 尺寸严格一致,内容完整 | ImageOps.pad |
全都是 200×200,空白处补色 |
| 尺寸严格一致,允许裁切 | ImageOps.fit |
全都是 200×200,超出部分裁掉 |
上面输出里有一处值得注意:150×100 的「小图」被 contain 放大到了 200×133。contain 是会放大的,这一点和 thumbnail 不同。如果不希望小图被强行拉大变糊,处理前先判断尺寸:
# 只有至少有一边超出框时才缩放,本来就小的图原样保留
if img.width > box[0] or img.height > box[1]:
img = ImageOps.contain(img, box, method=Image.Resampling.LANCZOS)14.4 半透明水印的完整思路 #
水印函数把前面好几章的知识点串了起来:
def add_watermark(img, text, opacity=110, margin=16):
# 1. 底图转 RGBA,并新建一个全透明图层(第 9.6 节:RGB 图上画不了半透明)
base = img.convert("RGBA")
# 最后一个 0 是 alpha,表示这层初始完全透明
layer = Image.new("RGBA", base.size, (255, 255, 255, 0))
# 画笔绑在透明层上,不碰底图
draw = ImageDraw.Draw(layer)
# 2. 字号跟着图宽走,大图小图上水印的视觉比例才一致
font_size = max(14, base.width // 22)
font = find_cjk_font(font_size) # 第 10.3 节:必须用中文字体
# 3. 量出文字尺寸,算右下角的位置(第 10.5 节:要减掉 bbox 偏移)
bbox = draw.textbbox((0, 0), text, font=font)
# 右减左是宽,下减上是高
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
# 从右边缘往回退:文字宽 + 边距 + bbox 自带的偏移
x = base.width - tw - margin - bbox[0]
# 纵向同理,从下边缘往回退
y = base.height - th - margin - bbox[1]
# 4. 白字黑描边,深浅背景上都看得清(第 10.8 节)
draw.text((x, y), text, font=font, fill=(255, 255, 255, opacity),
stroke_width=max(1, font_size // 14), stroke_fill=(0, 0, 0, opacity))
# 5. 合成,这一步才真正做透明混合
return Image.alpha_composite(base, layer).convert("RGB")有两个设计值得说明:
字号按图宽的比例算(base.width // 22),而不是写死一个固定值。否则同一段水印在 1200 像素宽的图上小得看不见,在 150 像素宽的图上又会盖满整张图。
用 alpha_composite 而不是直接画。实测验证了水印确实是混合上去的:同一位置加水印前 (188, 175, 105),加水印后 (222, 215, 169)——颜色变亮了但没变成纯白,说明底下的内容还透出来了。
14.5 批处理的错误处理 #
真实的文件夹里什么都有:损坏的图片、伪装成图片的文本文件、根本不是图片的文件。一张出错不能让整批任务停下来。
上面的 process_folder 用了两道防线:
# 第一道:按后缀过滤,非图片文件根本不去打开
if path.suffix.lower() not in IMAGE_EXTS:
continue
# 第二道:try 兜住,出错记下来继续
try:
# 这里放真正的打开、缩放、存盘逻辑
...
except Exception as ex:
# 只记录文件名和错误类型,然后接着处理下一张
failed.append((path.name, type(ex).__name__))实测结果说明了为什么两道都需要:说明.txt 被第一道拦下了,根本没进流程;而 假图片.jpg 后缀是对的,第一道拦不住,只有真去打开时才发现内容不是图片,抛出 UnidentifiedImageError,靠第二道兜住。
15. 踩坑清单 #
这一章是全书的错误索引。分成两类:会报错的和不报错但结果是错的。第二类危险得多,因为你不主动去看图片就永远不知道出了问题。
15.1 完整演示 #
下面每一个坑都有可复现的代码和真实的错误信息。
import io
import numpy as np
from PIL import Image, ImageDraw, ImageFont
print("#" * 72)
print("第一类:会直接报错的(好事,至少你立刻知道错了)")
print("#" * 72)
print()
print("坑 1:文件不存在 / 不是图片")
print("-" * 72)
try:
Image.open("根本没有这个文件.jpg")
except Exception as ex:
print(f" {type(ex).__name__}: 文件路径不对")
# 造一个后缀是 jpg 但内容不是图片的东西
fake = io.BytesIO(b"I am not an image")
try:
Image.open(fake)
except Exception as ex:
print(f" {type(ex).__name__}: 后缀对但内容不是图片")
print(" 两种错误的类型不一样,批处理时要都接住:")
print(" except (FileNotFoundError, UnidentifiedImageError):")
print(" UnidentifiedImageError 要从 PIL 导入: from PIL import UnidentifiedImageError")
print()
print("坑 2:RGBA / P / LA 模式存不进 JPEG")
print("-" * 72)
for mode in ["RGBA", "P", "LA", "RGB", "L"]:
im = Image.new(mode, (10, 10))
b = io.BytesIO()
try:
im.save(b, format="JPEG")
print(f" {mode:5s} -> 成功")
except Exception as ex:
print(f" {mode:5s} -> {type(ex).__name__}: {ex}")
print(" 解决:存 JPEG 之前先 convert('RGB'),有透明的还要先合成白底")
print()
print("坑 3:alpha_composite 要求两张图都是 RGBA 且尺寸相同")
print("-" * 72)
rgba = Image.new("RGBA", (20, 20), (255, 0, 0, 128))
try:
Image.alpha_composite(Image.new("RGB", (20, 20), "white"), rgba)
except Exception as ex:
print(f" 底图是 RGB -> {type(ex).__name__}: {ex}")
try:
Image.alpha_composite(Image.new("RGBA", (30, 30)), rgba)
except Exception as ex:
print(f" 尺寸不一样 -> {type(ex).__name__}: {ex}")
ok = Image.alpha_composite(Image.new("RGBA", (20, 20), (255, 255, 255, 255)), rgba)
print(f" 都是 RGBA 同尺寸 -> 成功,结果 {ok.getpixel((0, 0))}")
print()
print("坑 4:draw.textsize() 已经被删了")
print("-" * 72)
d = ImageDraw.Draw(Image.new("RGB", (100, 50)))
try:
d.textsize("hello")
except Exception as ex:
print(f" d.textsize('hello') -> {type(ex).__name__}")
print(" Pillow 10.0 起移除,网上的老教程几乎全在用它。改用:")
bb = d.textbbox((0, 0), "hello")
print(f" d.textbbox((0,0), 'hello') = {bb} -> 宽 {bb[2] - bb[0]},高 {bb[3] - bb[1]}")
print(f" d.textlength('hello') = {d.textlength('hello')} -> 只要宽度时用这个")
print()
print("坑 5:Image.ANTIALIAS 也被删了")
print("-" * 72)
im = Image.new("RGB", (40, 40))
try:
im.resize((20, 20), Image.ANTIALIAS)
except Exception as ex:
print(f" Image.ANTIALIAS -> {type(ex).__name__}: {ex}")
print(" Pillow 10.0 起移除。它当年就是 LANCZOS 的别名,直接换成:")
print(f" im.resize((20,20), Image.Resampling.LANCZOS) -> "
f"{im.resize((20, 20), Image.Resampling.LANCZOS).size}")
print(" 顺带一提,Image.LANCZOS 这个短写法现在还能用,但推荐写全 Image.Resampling.LANCZOS")
print()
print()
print("#" * 72)
print("第二类:不报错但结果是错的(危险得多,要专门记住)")
print("#" * 72)
print()
print("坑 6:rotate 不加 expand,内容被悄悄切掉")
print("-" * 72)
wide = Image.new("RGB", (200, 60), "red")
r = wide.rotate(90)
# 数一下还剩多少红色像素
before = np.count_nonzero(np.array(wide)[:, :, 0] > 200)
after = np.count_nonzero(np.array(r)[:, :, 0] > 200)
print(f" 200x60 的红图 rotate(90) 之后尺寸还是 {r.size}")
print(f" 红色像素从 {before:,} 个变成 {after:,} 个,丢了 {(1 - after / before):.0%}")
print(" 一声不吭就把内容切了。解决:rotate(90, expand=True),或者直接用 transpose")
print()
print("坑 7:忘了接住返回值")
print("-" * 72)
img = Image.new("RGB", (100, 100), "blue")
# 错误:结果被扔了
img.resize((50, 50))
print(f" 写了 img.resize((50,50)) 之后,img.size 还是 {img.size}")
img2 = img.resize((50, 50))
print(f" 写成 img = img.resize((50,50)) 才行 -> {img2.size}")
print(" Pillow 几乎所有方法都返回新图,不改原图")
print()
print(" 但 thumbnail 恰好相反,它原地改并返回 None:")
t = Image.new("RGB", (400, 400))
wrong = t.thumbnail((100, 100))
print(f" 写成 x = img.thumbnail(...) 会得到 {wrong},然后下一行就 AttributeError")
print(f" 正确写法是直接 img.thumbnail(...),之后 img 自己变成 {t.size}")
print()
print("坑 8:(x, y) 和 [y, x] 搞反")
print("-" * 72)
im = Image.new("RGB", (300, 100), "white")
im.putpixel((250, 50), (255, 0, 0))
arr = np.array(im)
print(f" img.size = {im.size} <- (宽, 高)")
print(f" arr.shape = {arr.shape} <- (高, 宽, 通道)")
print(f" Pillow 的 (250, 50) 对应 numpy 的 [50, 250] = {arr[50, 250].tolist()}")
try:
print(f" 写成 arr[250, 50] 会怎样: {arr[250, 50].tolist()}")
except Exception as ex:
print(f" 写成 arr[250, 50] -> {type(ex).__name__}: 越界了,因为高只有 100")
print(" 这个错在非正方形的图上才会暴露,用正方形图测试是发现不了的")
print()
print("坑 9:uint8 溢出,静默绕回")
print("-" * 72)
a = np.array([[200, 100, 50]], dtype="uint8")
print(f" 原数组: {a[0].tolist()} dtype={a.dtype}")
print(f" 直接 + 100: {(a + 100)[0].tolist()} <- 200+100 变成了 44!")
print(" 一句警告都没有,图会出现诡异的黑斑。正确写法:")
safe = np.clip(a.astype("int16") + 100, 0, 255).astype("uint8")
print(f" np.clip(a.astype('int16') + 100, 0, 255): {safe[0].tolist()}")
print()
print("坑 10:默认字体画中文,出来一堆豆腐块")
print("-" * 72)
def same_glyph(ch1, ch2, font):
"""判断两个字符用这个字体画出来是不是一模一样。"""
ims = []
for ch in (ch1, ch2):
im = Image.new("L", (40, 40), 255)
ImageDraw.Draw(im).text((4, 2), ch, fill=0, font=font)
ims.append(im.tobytes())
return ims[0] == ims[1]
f = ImageFont.load_default(size=24)
print(f" 默认字体:'中' 和 '文' 画出来相同吗 -> {same_glyph('中', '文', f)}")
print(f" 默认字体:'A' 和 'B' 画出来相同吗 -> {same_glyph('A', 'B', f)}")
print(" 两个不同的汉字画出来完全一样,说明画的是同一个占位方块")
print(" 解决:ImageFont.truetype('C:/Windows/Fonts/msyh.ttc', 24)")
print(" 而且这个坑不报错、不警告,只有真去看图才发现")
print()
print("坑 11:在 RGB 图上画半透明色,透明度被无声丢弃")
print("-" * 72)
solid = Image.new("RGB", (20, 20), "white")
ImageDraw.Draw(solid).rectangle((0, 0, 19, 19), fill=(255, 0, 0, 128))
print(f" RGB 图上画 fill=(255,0,0,128) -> {solid.getpixel((10, 10))} <- 完全不透明")
layer = Image.new("RGBA", (20, 20), (0, 0, 0, 0))
ImageDraw.Draw(layer).rectangle((0, 0, 19, 19), fill=(255, 0, 0, 128))
merged = Image.alpha_composite(Image.new("RGBA", (20, 20), (255, 255, 255, 255)), layer)
print(f" RGBA 图层 + alpha_composite -> {merged.getpixel((10, 10))} <- 这才是半透明")
print()
print("坑 12:从 GIF 迭代出来的帧不 copy,最后全是同一帧")
print("-" * 72)
from PIL import ImageSequence
frames = [Image.new("RGB", (20, 20), c) for c in ["red", "green", "blue"]]
buf = io.BytesIO()
frames[0].save(buf, format="GIF", save_all=True, append_images=frames[1:])
buf.seek(0)
gif = Image.open(buf)
no_copy = [fr for fr in ImageSequence.Iterator(gif)]
print(f" 不 copy:收集到 {len(no_copy)} 帧,其中不同的只有 {len({f.tobytes() for f in no_copy})} 种")
buf.seek(0)
gif = Image.open(buf)
with_copy = [fr.copy() for fr in ImageSequence.Iterator(gif)]
print(f" 加 copy:收集到 {len(with_copy)} 帧,其中不同的有 {len({f.tobytes() for f in with_copy})} 种")
print()
print("坑 13:resize 和 rotate 的默认重采样算法不一样")
print("-" * 72)
print(" resize 默认 BICUBIC(平滑)")
print(" rotate 默认 NEAREST(不平滑,边缘有锯齿)")
detail = Image.new("RGB", (100, 100), "white")
dd = ImageDraw.Draw(detail)
for i in range(0, 100, 4):
dd.line((i, 0, i, 100), fill="black", width=2)
n1 = len(detail.rotate(30, expand=True).getcolors(maxcolors=10 ** 6))
n2 = len(detail.rotate(30, expand=True, resample=Image.Resampling.BICUBIC)
.getcolors(maxcolors=10 ** 6))
print(f" rotate(30) 默认 -> 结果里有 {n1:4d} 种颜色")
print(f" rotate(30, BICUBIC) -> 结果里有 {n2:4d} 种颜色(多出来的是平滑过渡色)")
print(" 想让旋转结果好看,记得手动加 resample=Image.Resampling.BICUBIC")
print()
print("坑 14:JPEG 存了又存,前面的处理白做了")
print("-" * 72)
# 造一张平滑的照片样图(纯随机噪点会被任何缩放毁掉,不适合做这个对比)
yy, xx = np.mgrid[0:120, 0:120]
smooth = np.stack([(np.sin(xx / 12.0) * 60 + 150),
(np.cos(yy / 15.0) * 60 + 130),
((xx + yy) % 200 + 30)], axis=-1)
photo = Image.fromarray(np.clip(smooth, 0, 255).astype("uint8"))
orig = np.array(photo).astype(int)
def pipeline(fmt, rounds=3):
"""走 rounds 轮「存盘 -> 读回 -> 缩放」,返回和原图的平均差。"""
cur = photo
for _ in range(rounds):
b = io.BytesIO()
# 唯一的区别就是中间产物存成什么格式
cur.save(b, format=fmt, quality=85) if fmt == "JPEG" else cur.save(b, format=fmt)
b.seek(0)
cur = Image.open(b).copy()
# 每轮都缩小再放大,模拟真实项目里的多步处理
cur = cur.resize((100, 100)).resize((120, 120))
return np.abs(np.array(cur).astype(int) - orig).mean()
png_diff = pipeline("PNG")
jpg_diff = pipeline("JPEG")
print(f" 中间产物存 PNG (无损),三轮之后和原图平均差 {png_diff:5.2f} <- 这部分是缩放造成的")
print(f" 中间产物存 JPEG(有损),三轮之后和原图平均差 {jpg_diff:5.2f} <- 多出来的是 JPEG 加的")
print(f" 同样的处理流程,只因为中间存了 JPEG,误差多了 {jpg_diff - png_diff:.2f}")
print(" 解决:中间步骤全部用 PNG 或者干脆留在内存里,只在最后一步导出 JPEG")
print()
print("坑 15:批量处理时不关文件,句柄耗尽")
print("-" * 72)
print(" 错误写法:img = Image.open(path) # 处理几千张之后报 OSError: too many open files")
print(" 正确写法:with Image.open(path) as img:")
print(" 要在 with 外面继续用,先 img = img.copy() 把像素复制出来")########################################################################
第一类:会直接报错的(好事,至少你立刻知道错了)
########################################################################
坑 1:文件不存在 / 不是图片
------------------------------------------------------------------------
FileNotFoundError: 文件路径不对
UnidentifiedImageError: 后缀对但内容不是图片
两种错误的类型不一样,批处理时要都接住:
except (FileNotFoundError, UnidentifiedImageError):
UnidentifiedImageError 要从 PIL 导入: from PIL import UnidentifiedImageError
坑 2:RGBA / P / LA 模式存不进 JPEG
------------------------------------------------------------------------
RGBA -> OSError: cannot write mode RGBA as JPEG
P -> OSError: cannot write mode P as JPEG
LA -> OSError: cannot write mode LA as JPEG
RGB -> 成功
L -> 成功
解决:存 JPEG 之前先 convert('RGB'),有透明的还要先合成白底
坑 3:alpha_composite 要求两张图都是 RGBA 且尺寸相同
------------------------------------------------------------------------
底图是 RGB -> ValueError: image has wrong mode
尺寸不一样 -> ValueError: images do not match
都是 RGBA 同尺寸 -> 成功,结果 (255, 127, 127, 255)
坑 4:draw.textsize() 已经被删了
------------------------------------------------------------------------
d.textsize('hello') -> AttributeError
Pillow 10.0 起移除,网上的老教程几乎全在用它。改用:
d.textbbox((0,0), 'hello') = (0, 2, 24, 10) -> 宽 24,高 8
d.textlength('hello') = 24.0 -> 只要宽度时用这个
坑 5:Image.ANTIALIAS 也被删了
------------------------------------------------------------------------
Image.ANTIALIAS -> AttributeError: module 'PIL.Image' has no attribute 'ANTIALIAS'
Pillow 10.0 起移除。它当年就是 LANCZOS 的别名,直接换成:
im.resize((20,20), Image.Resampling.LANCZOS) -> (20, 20)
顺带一提,Image.LANCZOS 这个短写法现在还能用,但推荐写全 Image.Resampling.LANCZOS
########################################################################
第二类:不报错但结果是错的(危险得多,要专门记住)
########################################################################
坑 6:rotate 不加 expand,内容被悄悄切掉
------------------------------------------------------------------------
200x60 的红图 rotate(90) 之后尺寸还是 (200, 60)
红色像素从 12,000 个变成 3,600 个,丢了 70%
一声不吭就把内容切了。解决:rotate(90, expand=True),或者直接用 transpose
坑 7:忘了接住返回值
------------------------------------------------------------------------
写了 img.resize((50,50)) 之后,img.size 还是 (100, 100)
写成 img = img.resize((50,50)) 才行 -> (50, 50)
Pillow 几乎所有方法都返回新图,不改原图
但 thumbnail 恰好相反,它原地改并返回 None:
写成 x = img.thumbnail(...) 会得到 None,然后下一行就 AttributeError
正确写法是直接 img.thumbnail(...),之后 img 自己变成 (100, 100)
坑 8:(x, y) 和 [y, x] 搞反
------------------------------------------------------------------------
img.size = (300, 100) <- (宽, 高)
arr.shape = (100, 300, 3) <- (高, 宽, 通道)
Pillow 的 (250, 50) 对应 numpy 的 [50, 250] = [255, 0, 0]
写成 arr[250, 50] -> IndexError: 越界了,因为高只有 100
这个错在非正方形的图上才会暴露,用正方形图测试是发现不了的
坑 9:uint8 溢出,静默绕回
------------------------------------------------------------------------
原数组: [200, 100, 50] dtype=uint8
直接 + 100: [44, 200, 150] <- 200+100 变成了 44!
一句警告都没有,图会出现诡异的黑斑。正确写法:
np.clip(a.astype('int16') + 100, 0, 255): [255, 200, 150]
坑 10:默认字体画中文,出来一堆豆腐块
------------------------------------------------------------------------
默认字体:'中' 和 '文' 画出来相同吗 -> True
默认字体:'A' 和 'B' 画出来相同吗 -> False
两个不同的汉字画出来完全一样,说明画的是同一个占位方块
解决:ImageFont.truetype('C:/Windows/Fonts/msyh.ttc', 24)
而且这个坑不报错、不警告,只有真去看图才发现
坑 11:在 RGB 图上画半透明色,透明度被无声丢弃
------------------------------------------------------------------------
RGB 图上画 fill=(255,0,0,128) -> (255, 0, 0) <- 完全不透明
RGBA 图层 + alpha_composite -> (255, 127, 127, 255) <- 这才是半透明
坑 12:从 GIF 迭代出来的帧不 copy,最后全是同一帧
------------------------------------------------------------------------
不 copy:收集到 3 帧,其中不同的只有 1 种
加 copy:收集到 3 帧,其中不同的有 3 种
坑 13:resize 和 rotate 的默认重采样算法不一样
------------------------------------------------------------------------
resize 默认 BICUBIC(平滑)
rotate 默认 NEAREST(不平滑,边缘有锯齿)
rotate(30) 默认 -> 结果里有 2 种颜色
rotate(30, BICUBIC) -> 结果里有 213 种颜色(多出来的是平滑过渡色)
想让旋转结果好看,记得手动加 resample=Image.Resampling.BICUBIC
坑 14:JPEG 存了又存,前面的处理白做了
------------------------------------------------------------------------
中间产物存 PNG (无损),三轮之后和原图平均差 0.22 <- 这部分是缩放造成的
中间产物存 JPEG(有损),三轮之后和原图平均差 1.47 <- 多出来的是 JPEG 加的
同样的处理流程,只因为中间存了 JPEG,误差多了 1.25
解决:中间步骤全部用 PNG 或者干脆留在内存里,只在最后一步导出 JPEG
坑 15:批量处理时不关文件,句柄耗尽
------------------------------------------------------------------------
错误写法:img = Image.open(path) # 处理几千张之后报 OSError: too many open files
正确写法:with Image.open(path) as img:
要在 with 外面继续用,先 img = img.copy() 把像素复制出来15.2 会报错的坑(速查) #
这一类会抛异常,程序直接停下来。虽然当时很烦,但排查方向很明确——看错误类型就知道是哪一类问题。
| # | 现象 | 错误类型 | 解决 |
|---|---|---|---|
| 1 | 文件路径不对 | FileNotFoundError |
检查路径 |
| 1 | 后缀对但内容不是图片 | UnidentifiedImageError |
from PIL import UnidentifiedImageError 后捕获 |
| 2 | RGBA/P/LA 存 JPEG | OSError: cannot write mode ... as JPEG |
先合成白底再 convert('RGB') |
| 3 | alpha_composite 底图是 RGB |
ValueError: image has wrong mode |
两张图都转 RGBA |
| 3 | alpha_composite 尺寸不同 |
ValueError: images do not match |
先统一尺寸 |
| 4 | 用了 draw.textsize() |
AttributeError |
改用 textbbox() / textlength() |
| 5 | 用了 Image.ANTIALIAS |
AttributeError |
改用 Image.Resampling.LANCZOS |
这类坑虽然让程序崩了,但至少你立刻知道哪里错了,反而是好事。
15.3 不报错但结果是错的坑(速查) #
这一类才是真正危险的:程序正常跑完,文件正常生成,只有你打开图片仔细看才会发现不对。有几个(比如豆腐块、uint8 溢出)连一条警告都不会打印。
| # | 现象 | 后果 | 解决 |
|---|---|---|---|
| 6 | rotate(90) 不加 expand |
内容被切掉 70%,无任何提示 | 加 expand=True 或用 transpose |
| 7 | 写了 img.resize(...) 没接返回值 |
什么都没发生 | img = img.resize(...) |
| 7 | 写了 x = img.thumbnail(...) |
x 是 None,下一行 AttributeError |
直接 img.thumbnail(...) |
| 8 | Pillow 的 (x,y) 和 NumPy 的 [y,x] 搞反 |
取到错误像素,或 IndexError |
记住 size 是 (宽,高),shape 是 (高,宽,通道) |
| 9 | uint8 数组直接加减 |
溢出静默绕回,200+100=44 |
np.clip(a.astype('int16') + n, 0, 255) |
| 10 | 默认字体写中文 | 全是豆腐块,不报错不警告 | ImageFont.truetype() 加载中文字体 |
| 11 | 在 RGB 图上画半透明色 | alpha 被静默丢弃,变成不透明 | 开 RGBA 图层 + alpha_composite |
| 12 | GIF 帧不 copy() |
收集到的 n 帧全是同一帧 | [f.copy() for f in Iterator(gif)] |
| 13 | 以为 rotate 会自动平滑 |
边缘全是锯齿 | 手动传 resample=Image.Resampling.BICUBIC |
| 14 | 中间产物存 JPEG | 画质逐步劣化 | 中间用 PNG 或留在内存里 |
| 15 | 批量处理不用 with |
句柄耗尽,OSError: too many open files |
with Image.open(path) as im: |
15.4 三个最值得记住的 #
如果只能记住三条,就记这三条:
一、rotate(90) 不改画布尺寸。 实测 200×60 的图转完还是 200×60,70% 的内容被无声切掉。直角旋转永远用 transpose。
二、默认字体画不出中文。 不报错、不警告,只是把每个汉字画成空方块。任何要写中文的地方都必须 ImageFont.truetype() 加载真字体。
三、RGBA 转 RGB 只是扔掉 alpha,不做合成。 透明背景会露出底下的黑色。要先铺白底 alpha_composite,或者用 paste(img, (0,0), img)。
15.5 坑 14 的对照实验 #
坑 14 值得单独说一下,因为它的实验设计能说明「怎么正确归因」。
直接测「反复存 JPEG」会得到 12.5 节那个结论——损失会收敛,不再恶化。但真实项目里不会只是反复存,中间还有缩放裁剪。所以坑 14 用了对照实验:同样的处理流程(存盘 → 读回 → 缩小 → 放大,三轮),唯一的变量是中间产物存 PNG 还是 JPEG。
中间产物存 PNG (无损),三轮之后和原图平均差 0.22 <- 这部分是缩放造成的
中间产物存 JPEG(有损),三轮之后和原图平均差 1.47 <- 多出来的是 JPEG 加的PNG 那条线代表「光是缩放就会有的损失」,JPEG 那条线减去它,才是 JPEG 真正额外贡献的部分——误差多了 6 倍多。这样归因才站得住。
16. 可复用模板与总结 #
最后一章把前面所有内容收束成三样东西:一份可以直接抄进项目的代码模板、一份按功能分类的 API 速查表,以及几条最该长期记住的原则。
16.1 一个躲开所有坑的模板 #
下面这份代码把前面 15 个坑全部处理掉了,可以直接抄进项目里当基础设施用。每个函数都对应前面讲过的某几节。
"""一个把前面所有坑都躲开的图片处理模板,可以直接抄进项目里用。"""
import shutil
import tempfile
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageOps, UnidentifiedImageError
# Pillow 能认的常见图片后缀,用来在打开之前先粗筛一遍
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".gif", ".tif", ".tiff"}
# 各系统上常见的中文字体,按顺序找第一个存在的
CJK_FONT_CANDIDATES = [
"C:/Windows/Fonts/msyh.ttc",
"C:/Windows/Fonts/simhei.ttf",
"/System/Library/Fonts/PingFang.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
]
# 字体加载比较慢,同样的(路径, 字号)只加载一次,之后从这里取
_font_cache = {}
def get_font(size):
"""拿一个能显示中文的字体。找不到就退回默认字体(中文会变豆腐块)。"""
# 这个字号之前加载过就直接复用,省掉重复读文件的开销
if size in _font_cache:
# 命中缓存,直接返回上次加载好的字体对象
return _font_cache[size]
for path in CJK_FONT_CANDIDATES:
if Path(path).exists():
# 找到第一个真实存在的字体文件就用它
font = ImageFont.truetype(path, size)
# break 会跳过下面的 else 分支
break
else:
# for...else:一个都没找到才会走到这里
font = ImageFont.load_default(size=size)
# 存进缓存,下次同样字号直接命中
_font_cache[size] = font
return font
def load_image(path):
"""安全地打开一张图,返回一个已经加载到内存、模式规范化过的 Image。
做了三件事,缺一不可:
1. 用 with 打开,避免文件句柄泄漏
2. 按 EXIF 的方向标记把手机照片扶正
3. 把 P、RGBA 之类的模式统一掉,避免后续操作出意外
"""
with Image.open(path) as im:
# 先按 EXIF 摆正,这一步必须在任何裁剪缩放之前做
im = ImageOps.exif_transpose(im)
# 有透明通道的统一成 RGBA,其余统一成 RGB
if im.mode in ("RGBA", "LA") or (im.mode == "P" and "transparency" in im.info):
im = im.convert("RGBA")
else:
im = im.convert("RGB")
# copy 一份带出 with,否则出了这个块就用不了了
return im.copy()
def flatten(img, background="white"):
"""把带透明通道的图铺到纯色底上,得到一张能存 JPEG 的 RGB 图。"""
# 本来就没有透明通道,直接转 RGB 就行
if img.mode != "RGBA":
# convert 对 RGB 图是空操作,对 P、L 等模式会做真正的转换
return img.convert("RGB")
# 底图必须也是 RGBA 且尺寸相同,alpha_composite 才肯干活
bg = Image.new("RGBA", img.size, background)
# 先按 alpha 混合,再丢掉已经没用的透明通道
return Image.alpha_composite(bg, img).convert("RGB")
def resize_to_box(img, box, mode="contain", allow_upscale=False):
"""把图缩放到 box 这个框里。
mode 有三种:
contain 完整装进框,可能有一边小于框
pad 完整装进框,再补边,输出尺寸严格等于框
fit 填满框,超出的部分裁掉
allow_upscale 为 False 时,比框还小的图保持原样,不放大。
"""
# 图本来就比框小,而且不允许放大,就原样返回
if not allow_upscale and img.width <= box[0] and img.height <= box[1]:
return img.copy()
# 缩小时 LANCZOS 质量最好
method = Image.Resampling.LANCZOS
if mode == "contain":
# 等比缩进框里,输出尺寸最多有一边等于框
return ImageOps.contain(img, box, method=method)
if mode == "pad":
# 等比缩完再补白边,输出尺寸严格等于框
return ImageOps.pad(img, box, method=method, color="white")
if mode == "fit":
# 填满框并居中裁掉多余部分,输出尺寸也严格等于框
return ImageOps.fit(img, box, method=method)
# 传了不认识的 mode 就直接报错,别让调用方拿到一张莫名其妙的图
raise ValueError(f"mode 只能是 contain/pad/fit,收到的是 {mode!r}")
def add_text_watermark(img, text, position="br", opacity=110, margin=16):
"""加半透明文字水印。position: br 右下, bl 左下, tr 右上, tl 左上, c 居中。"""
# 水印必须画在独立 RGBA 图层上,在 RGB 图上画 alpha 会被丢掉
base = img.convert("RGBA")
# 最后一个 0 是 alpha,表示这一层初始完全透明
layer = Image.new("RGBA", base.size, (255, 255, 255, 0))
# 画笔绑在透明层上,不碰原图
draw = ImageDraw.Draw(layer)
# 字号跟着图宽走,大图小图上水印的视觉比例才一致
font = get_font(max(14, base.width // 22))
# textbbox 量出文字实际占的矩形;注意 bbox[0]、bbox[1] 通常不是 0
bbox = draw.textbbox((0, 0), text, font=font)
# 右减左是宽,下减上是高
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
# 按九宫格算左上角坐标,最后减去 bbox 偏移才能真正贴住边
xs = {"l": margin, "r": base.width - tw - margin, "c": (base.width - tw) // 2}
ys = {"t": margin, "b": base.height - th - margin, "c": (base.height - th) // 2}
# 把 br 这类简写翻译成 (横向键, 纵向键)
key = {"br": ("r", "b"), "bl": ("l", "b"), "tr": ("r", "t"),
"tl": ("l", "t"), "c": ("c", "c")}[position]
# 减 bbox[0] 是为了抵消字形左侧的空白
x = xs[key[0]] - bbox[0]
# 减 bbox[1] 是为了抵消字形上方的空白
y = ys[key[1]] - bbox[1]
# 白字黑描边,深色浅色背景上都看得清
draw.text((x, y), text, font=font, fill=(255, 255, 255, opacity),
stroke_width=max(1, font.size // 14), stroke_fill=(0, 0, 0, opacity))
# 合成后仍是 RGBA,交给 save_image 决定要不要压平
return Image.alpha_composite(base, layer)
def save_image(img, path, quality=85):
"""按后缀存图,自动处理 JPEG 不支持透明的问题。"""
# 允许传字符串路径进来
path = Path(path)
# 转小写,这样 .JPG 和 .jpg 一视同仁
ext = path.suffix.lower()
# 目标目录可能还不存在,先建出来免得 save 报错
path.parent.mkdir(parents=True, exist_ok=True)
if ext in (".jpg", ".jpeg"):
# JPEG 存不了 RGBA/P/LA,先铺白底压平
flatten(img).save(path, quality=quality, optimize=True)
elif ext == ".png":
# PNG 无损,quality 参数对它没用,用 optimize 换体积
img.save(path, optimize=True)
elif ext == ".webp":
# WebP 既支持透明又支持有损压缩,RGBA 可以直接存
img.save(path, quality=quality)
else:
# 其他格式交给 Pillow 按后缀自己判断
img.save(path)
return path
def process_folder(src, dst, box=(800, 800), watermark=None, out_ext=".jpg"):
"""批量处理一个文件夹,返回 (成功数, [(文件名, 错误类型), ...])。"""
# 两个路径都统一成 Path 对象
src, dst = Path(src), Path(dst)
# 输出目录不存在就建,已存在也不报错
dst.mkdir(parents=True, exist_ok=True)
# ok 计成功数,failed 收集出错的文件
ok, failed = 0, []
for path in sorted(src.iterdir()):
# 先看后缀,非图片文件根本不用去开
if not path.is_file() or path.suffix.lower() not in IMAGE_EXTS:
continue
try:
# 打开 + 摆正方向 + 规范模式,一步到位
img = load_image(path)
# 默认不放大,小图会原样保留
img = resize_to_box(img, box, mode="contain")
# 没传水印文字就跳过这一步
if watermark:
# 返回的是 RGBA,save_image 会按后缀决定要不要压平
img = add_text_watermark(img, watermark)
# stem 是不含后缀的文件名,换上目标后缀
save_image(img, dst / (path.stem + out_ext))
# 整条流程都没抛异常才算成功
ok += 1
# 只捕获这三类「可预期」的错误,其他异常照样抛出来暴露 bug
except (UnidentifiedImageError, OSError, ValueError) as ex:
# 单张出错不能让整批停下来
failed.append((path.name, type(ex).__name__))
# 返回统计结果,调用方可以据此打日志或重试
return ok, failed
# ====================================================================
# 下面是演示:造几张图,跑一遍完整流程
# ====================================================================
if __name__ == "__main__":
# 演示全程在临时目录里进行,结束时整个删掉
work = Path(tempfile.mkdtemp(prefix="pillow_tpl_"))
# 输入目录
src = work / "in"
# 父目录已由 mkdtemp 建好,这里只建这一层
src.mkdir()
# 造三张不同模式、不同尺寸的图,覆盖常见情况
# 1. 普通 RGB 大图
# 背景色可以直接写十六进制字符串
a = Image.new("RGB", (900, 600), "#446688")
# 画笔用完就不再需要,可以不接变量直接链式调用
ImageDraw.Draw(a).ellipse((200, 100, 700, 500), fill="#ffcc33")
a.save(src / "大图.jpg")
# 2. 带透明背景的 RGBA 图
# alpha 为 0,整张图初始是全透明的
b = Image.new("RGBA", (400, 400), (0, 0, 0, 0))
# 只有这个圆是不透明的,其余仍然透明
ImageDraw.Draw(b).ellipse((50, 50, 350, 350), fill=(220, 60, 60, 255))
# 存成 PNG 才能保住透明通道
b.save(src / "透明图.png")
# 3. 比目标框还小的图,用来验证「不放大」
c = Image.new("RGB", (120, 90), "#66cc66")
c.save(src / "小图.png")
# 4. 一个假图片,用来验证错误处理
(src / "坏文件.jpg").write_bytes(b"not an image")
# 5. 一个非图片文件,应该被后缀过滤掉
(src / "备注.txt").write_text("hello", encoding="utf-8")
print("输入文件:")
for p in sorted(src.iterdir()):
print(f" {p.name}")
print()
out = work / "out"
# 一行调用跑完整条流水线
ok, failed = process_folder(src, out, box=(300, 300), watermark="© 模板演示")
print(f"处理完成:成功 {ok} 张,失败 {len(failed)} 张")
for name, err in failed:
print(f" 失败: {name} -> {err}")
print()
print("输出文件:")
for p in sorted(out.iterdir()):
with Image.open(p) as im:
print(f" {p.name:14s} {str(im.size):12s} {im.mode:5s} {p.stat().st_size:6,d} 字节")
print()
print("逐项验证模板做对了没有:")
with Image.open(out / "大图.jpg") as im:
print(f" 900x600 缩进 300x300 的框 -> {im.size},比例保持了吗:"
f" {abs(im.width / im.height - 900 / 600) < 0.02}")
with Image.open(out / "透明图.jpg") as im:
# 透明区被铺成白底了,所以四个角应该是白的
print(f" 透明 PNG 转 JPEG 后,角落像素 {im.getpixel((2, 2))}(应该是白色,不是黑色)")
print(f" 圆形内部还是原来的红 {im.getpixel((im.width // 2, im.height // 2))}")
with Image.open(out / "小图.jpg") as im:
print(f" 120x90 的小图没有被放大 -> {im.size}")
# 演示结束,把临时目录连同里面的文件一起删掉
shutil.rmtree(work)
print()
print("临时目录已清理:", not work.exists())输入文件:
坏文件.jpg
备注.txt
大图.jpg
小图.png
透明图.png
处理完成:成功 3 张,失败 1 张
失败: 坏文件.jpg -> UnidentifiedImageError
输出文件:
大图.jpg (300, 200) RGB 3,975 字节
小图.jpg (120, 90) RGB 1,269 字节
透明图.jpg (300, 300) RGB 5,754 字节
逐项验证模板做对了没有:
900x600 缩进 300x300 的框 -> (300, 200),比例保持了吗: True
透明 PNG 转 JPEG 后,角落像素 (255, 255, 255)(应该是白色,不是黑色)
圆形内部还是原来的红 (220, 60, 60)
120x90 的小图没有被放大 -> (120, 90)
临时目录已清理: True模板里的每个函数解决什么问题:
| 函数 | 解决的问题 | 对应章节 |
|---|---|---|
get_font |
跨平台找中文字体 + 缓存 | 10.3、10.4 |
load_image |
with 防句柄泄漏 + EXIF 扶正 + 模式规范化 |
4.2、14.2 |
flatten |
带透明的图铺白底,能存 JPEG | 7.5、12.4 |
resize_to_box |
三种缩放策略 + 不放大小图 | 5.4、14.3 |
add_text_watermark |
RGBA 图层 + 中文字体 + 描边 + 九宫格定位 | 9.6、10.6、10.8 |
save_image |
按格式自动处理模式和参数 | 12.3、12.4 |
process_folder |
两道防线的错误处理 | 14.5 |
演示部分的输出验证了每一项都做对了:900×600 缩进 300×300 的框后是 300×200(比例保住了),透明 PNG 转 JPEG 后角落是白色而不是黑色,120×90 的小图没有被强行放大。
16.2 API 速查 #
把全书用到的 API 按功能归类整理在这里,方便写代码时回来查。括号里标注了容易记错的地方。
创建和打开
Image.new("RGB", (400, 300), "white") # 造纯色图
Image.open("a.png") # 打开文件,惰性加载
Image.frombytes("RGB", (w, h), data) # 从原始像素数据造
Image.fromarray(numpy_array) # 从 NumPy 数组造属性
img.size # (宽, 高)
img.width # 宽
img.height # 高
img.mode # 'RGB' / 'RGBA' / 'L' / 'P' / '1'
img.format # 'PNG' / 'JPEG' / ...;内存里造的是 None几何变换(都返回新图)
img.crop((左, 上, 右, 下)) # 右下角不含在内
img.resize((宽, 高), Image.Resampling.LANCZOS) # 不写算法时默认 BICUBIC
img.rotate(45, expand=True, resample=Image.Resampling.BICUBIC, fillcolor="white") # 默认会裁切且不插值
img.transpose(Image.Transpose.ROTATE_90) # 直角旋转,无损无插值
img.transpose(Image.Transpose.FLIP_LEFT_RIGHT) # 左右镜像
ImageOps.contain(img, (200, 200)) # 装进框,内容完整
ImageOps.fit(img, (200, 200)) # 填满框,裁掉多余
ImageOps.pad(img, (200, 200)) # 装进框 + 补边原地修改
img.thumbnail((200, 200)) # 返回 None,只缩不放
img.paste(patch, (10, 10)) # 把 patch 贴到 (10, 10) 处
img.paste(patch, (10, 10), mask) # 第三个参数是遮罩模式转换
img.convert("RGB") # 丢掉透明通道,存 JPEG 前必做
img.convert("L") # 转 8 位灰度
img.convert("1", dither=Image.Dither.NONE) # 转纯黑白,关掉抖动就是直接阈值化
Image.alpha_composite(rgba_bg, rgba_fg) # 两张都必须 RGBA 且同尺寸调色和滤镜
ImageEnhance.Brightness(img).enhance(1.5) # 提亮,1.0 是原样
ImageEnhance.Contrast(img).enhance(1.8) # 加大对比度
ImageEnhance.Color(img).enhance(2.0) # 加饱和度,0 就是灰度
ImageEnhance.Sharpness(img).enhance(3.0) # 锐化
img.filter(ImageFilter.GaussianBlur(radius=3)) # 高斯模糊,半径越大越糊
img.filter(ImageFilter.UnsharpMask()) # 修图软件同款锐化
img.filter(ImageFilter.MedianFilter(size=3)) # 去噪点且不糊边缘
ImageOps.autocontrast(img) # 自动把最暗拉到 0、最亮拉到 255
ImageOps.grayscale(img) # 等价于 convert("L")
ImageOps.invert(img) # 不接受 RGBA
ImageOps.exif_transpose(img) # 按 EXIF 摆正绘图
draw = ImageDraw.Draw(img) # 画笔绑定到某张图,之后都是原地修改
draw.line((x1, y1, x2, y2), fill="red", width=3) # 画直线,width 是粗细
draw.rectangle((左, 上, 右, 下), fill="blue", outline="navy", width=2) # fill 填充色,outline 边框色
draw.rounded_rectangle(框, radius=15, fill="green") # 圆角矩形,radius 是圆角半径
draw.ellipse((左, 上, 右, 下), fill="pink") # 参数是外接矩形
draw.polygon([(x1,y1), (x2,y2), (x3,y3)], fill="purple") # 多边形,给一串顶点自动闭合
draw.arc(框, start=0, end=270, fill="black", width=3) # 圆弧,角度从三点钟方向顺时针算
draw.pieslice(框, start=0, end=120, fill="tomato") # 扇形,做饼图用文字
font = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 24) # 中文必须指定字体文件
draw.text((x, y), "文字", fill="black", font=font) # 坐标默认是左上角
draw.text((cx, cy), "居中", font=font, anchor="mm") # anchor="mm" 让坐标变成中心点
draw.text((x, y), "描边", font=font, fill="white",
stroke_width=2, stroke_fill="black") # 加描边,深浅背景都看得清
bbox = draw.textbbox((0, 0), "量尺寸", font=font) # 返回 (左, 上, 右, 下)
w = draw.textlength("只量宽度", font=font) # 只要宽度时用这个,返回浮点数像素
img.getpixel((x, y)) # 读一个点,坐标是 (x, y)
img.putpixel((x, y), (255, 0, 0)) # 写一个点
px = img.load(); px[x, y] = (255, 0, 0) # 要反复读写时用 load(),比 getpixel 快得多
img.point(lambda v: 255 - v) # 对每个通道值套同一个函数,C 层循环
np.array(img) # shape 是 (高, 宽, 通道)
Image.fromarray(arr.astype("uint8")) # 转回 Image,dtype 必须是 uint8保存
img.save("a.jpg", quality=85, optimize=True) # 有损,quality 建议 80~90
img.save("a.png", optimize=True) # 无损,quality 参数对它无效
img.save("a.webp", quality=85) # 有损但比 JPEG 小,且支持透明
img.save("a.webp", lossless=True) # WebP 的无损模式
frames[0].save("a.gif", save_all=True, append_images=frames[1:],
duration=100, loop=0) # save_all 和 loop 都不能漏16.3 三条最重要的原则 #
一、几乎所有方法都返回新图。 记住接住返回值。三个例外是 thumbnail、paste 和 ImageDraw 的画图方法。
二、模式决定了能做什么。 存 JPEG 之前先转 RGB,画半透明之前先转 RGBA,alpha_composite 两边都要 RGBA。遇到 OSError 或 ValueError,先看模式对不对。
三、不要写逐像素的双重循环。 现成函数 > point() > NumPy > 双重循环,速度差几十倍。
16.4 Pillow 的边界在哪 #
Pillow 的定位是图像处理,不是计算机视觉。它擅长的是缩放、裁剪、调色、绘图、格式转换这类操作。
需要换工具的场景:
| 需求 | 更合适的工具 |
|---|---|
| 人脸检测、物体识别、特征提取 | OpenCV |
| 图像分割、形态学、科研级算法 | scikit-image |
| 深度学习的图像预处理 | torchvision / albumentations(底层往往仍是 Pillow) |
| 处理超大图(几十亿像素) | pyvips |
| 矢量图 SVG | cairosvg |
| 视频 | ffmpeg / PyAV |
顺带纠正一个流传很广的说法:Pillow 不是纯 Python 实现。 它的核心是 C 扩展,安装目录下有 _imaging、_imagingft、_webp 等 8 个编译好的二进制模块。它的性能不如 OpenCV,主要原因是 OpenCV 用了更激进的 SIMD 优化和多线程,而不是「Python 比 C++ 慢」。
对绝大多数「把图片改一改」的需求来说,Pillow 的速度绰绰有余,而它的 API 比 OpenCV 友好得多。
16.5 继续往下学 #
这份教程覆盖了日常九成以上的场景。如果还想深入:
ImageChops:图像之间的算术运算(相加、相减、取差异),做图片对比、变化检测时会用到ImageCms:色彩管理,处理印刷品和专业摄影时需要- EXIF 完整读写:
img.getexif(),读拍摄时间、相机型号、GPS 坐标 Image.draft():读 JPEG 时直接按缩小的尺寸解码,处理超大图能省很多时间和内存- 多帧 TIFF:和 GIF 类似的
seek/n_frames接口
官方文档是 pillow.readthedocs.io,其中 Handbook 里的 Concepts 一章值得读一遍,讲清楚了模式和 band 的设计思路。