1. 这份教程怎么读 #

Pillow 是 Python 里处理图片的标准库。缩略图、水印、验证码、格式转换、给机器学习准备数据,几乎都从它开始。它的 API 本身不难,难的是有一堆「不报错但结果不对」的坑:旋转之后内容被悄悄切掉、中文写出来是一串方块、PNG 转 JPEG 之后背景变黑。这些坑在网上的教程里很少被讲清楚,而它们恰恰是新手卡住最久的地方。

这份教程按「先搞懂图片是什么,再动手改它」的顺序组织,每一个结论都是在 Pillow 12.3.0 上真跑出来的。

1.1 你会得到什么 #

学完这份教程,你应该能做到下面这些事,并且知道每一步为什么这么写:

1.2 每个例子都是完整可运行的 #

网上大部分 Pillow 教程有一个共同的毛病:所有例子都以 img = Image.open('example.jpg') 开头。而这个 example.jpg 从来没有出现过,你复制过去第一行就报 FileNotFoundError。

这份教程里没有任何一个例子依赖外部图片文件。每个例子都会用代码先画出自己要用的测试图,然后再演示操作。这样做有三个好处:

文中每段代码下面跟着的输出,都是这段代码真实跑出来的结果,不是手写的示意。少数涉及计时的数字会因机器而异,正文里会点明。

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 个像素。

定位某个像素要用坐标。图像处理的坐标系和数学课上学的不一样,有两点要特别注意:

      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 模式的图,可以存成 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 numpy

3.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 在缩小时会丢内容的直观证据。它每 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 $$

三个系数不相等,是因为人眼对不同颜色的敏感度不同——对绿色最敏感,对蓝色最迟钝。实测验证了这一点:

同样是「满格」的纯色,绿色转出来的亮度是蓝色的五倍多。如果用简单平均 $(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 值是什么。实测:

而绝大多数绘图软件导出 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))

这类才是实际项目里真正常用的,因为强度可控:

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 #

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")

两个相关参数:

量多行文字的尺寸要用 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 UI

f.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 ms

11.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 代码里。

优先级很清楚:

  1. 有现成函数就用现成函数(最快,代码也最短)
  2. 自定义的逐像素映射用 point()
  3. 需要通道之间互相计算才转 NumPy
  4. 永远不要写 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 变成了 44

uint8 只能表示 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,那转换就是净亏损。


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 的两个参数:

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 只播一遍
)

四个参数里有两个是「不写就出错」的:

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 字节

临时目录已清理: True

14.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 继续往下学 #

这份教程覆盖了日常九成以上的场景。如果还想深入:

官方文档是 pillow.readthedocs.io,其中 Handbook 里的 Concepts 一章值得读一遍,讲清楚了模式和 band 的设计思路。