🐍 第14章 影像处理与人脸辨识
📄 一句话概括

一张 512×512 的彩图,在 Python 里就是一个 (512, 512, 3) 的数组,78 万个数,占 0.8 MB。 影像处理的第一步,是把「图」看成一堆数。 arr[100:300, 150:350] 就是裁剪,arr[:, ::-1] 就是左右翻转。 不需要任何图像库,切片就够了。 存成 PNG 还是 JPG,字节数差 7.4 倍。照片存 JPG,截图和图纸存 PNG。

📝 本章和前面几章的关系

前面十三章处理的都是文字和数字。这一章开始处理「图」。 教材第 14 章开头两节是【基本影像处理】和【NumPy 与影像处理】。 这一篇全部真跑:读图、取像素、切片、灰度、缩放、翻转、存两种格式, 用的都是本机装的 PIL 和 numpy。 演示里的数字全部跟着具体那张图走——换一张图,187、28.4、7.4 这些数全变。 所以别背数字, 记形状和关系。

一、一张图就是一个三维数组

PIL 打开一张图,告诉你尺寸 512×512、模式 RGB。转成 numpy 数组之后,shape 是 (512, 512, 3)。 三个数分别是高、宽、通道数。 512×512×3 就是「高 512 行、宽 512 列、每个点三个数」。 相乘得到 786,432 个数。每个数占一个字节(dtype 是 uint8),所以整张图 0.8 MB 左右。

uint8 的意思是「无符号 8 位整数」,取值只能是 0 到 255。 这不是随便定的:一个通道 8 位,三个通道 24 位, 能做 1677 万种颜色——「24 位真彩色」就是这么来的。 所以「彩色图」在机器眼里没有颜色,只有三张叠在一起的灰阶表。这事儿想通了,后面全通。

二、一个像素存三个数

演示第二段取三个坐标看:arr[100, 100] 是 [187, 176, 169],arr[256, 256] 是 [19, 14, 7]。 三个数分别是红、绿、蓝的亮度。(0,0,0) 是黑,(255,255,255) 是白。 [19, 14, 7] 就是很暗的那种褐色。 注意这是 RGB 顺序。OpenCV 读进来默认是 BGR——顺序是反的。

这是新手第一个大坑:用 PIL 读、用 OpenCV 处理、再用 PIL 存,如果中间不 cvtColor 转一次, 出来的图人脸是蓝的。 演示里所有 OpenCV 的调用前都插了一句 cv2.cvtColor(arr, cv2.COLOR_RGB2BGR),就是为了补这一下。 这一句忘一次,你要盯着屏幕找半小时。

三、裁剪就是切片

arr[100:300, 150:350] 拿出来一块 (200, 200, 3)。 这跟切列表一模一样:前面是行(高),后面是列(宽)。 所以「裁剪」不需要任何图像库,就是一个切片。这也意味着它极快——切片不复制像素,只是换了个视图。

顺序写反了会怎样? arr[150:350, 100:300] 一样能切出 (200, 200, 3), 形状对得上,所以不报错,但内容完全是另一块地方。 这类错误最难受的地方是它不报错。图能显示,就是裁错了位置。先高后宽,这四个字要刻住。

四、灰度:三个数变一个数

两个办法。加权是 0.299R + 0.587G + 0.114B,直接平均是三个数加起来除以三。 两种都得到 (512, 512)。 演示里两者的最大差是 28.4。同样是「灰度」,差出 28 个亮度级——肉眼能看出来。 三个权重加起来正好是 1,而且绿占得最多。因为人眼对绿最敏感。

直接平均会让绿色看起来偏暗,跟肉眼感觉不一致。所以黑白照片、灰度图都用加权那一套。 灰度不是「丢掉颜色信息」那么简单,是「按人眼的敏感度加权」。 这也是为什么好的黑白照片看着比「去色」舒服。

五、缩放和翻转

缩到 128×128,形状变成 (128, 128, 3),数据量只剩原来的 6.2%。 128×128 除以 512×512 正好是十六分之一。 翻转也是切片:arr[:, ::-1] 左右翻,arr[::-1, :] 上下翻。 ::-1 的意思是「倒着走」,放在第二维就是左右翻,放第一维就是上下翻。 缩放跟翻转不一样,它要算插值——新图上的每个点,去原图哪里取颜色。这不是切片能解决的。

PIL 默认用双三次插值,效果好但慢;缩略图用双线性就够。缩小得越狠,插值方式的影响越明显。 顺带一个实用结论:预处理时先把图缩小,能省大量计算。 512 缩到 128,像素少十六倍,后面的模糊、 边缘都会快十六倍左右。

六、存回去:格式是有代价的

同一个 200×200 的裁剪,存 PNG 是 74,436 字节,存 JPG(质量 85)是 10,061 字节。差 7.4 倍。

格式 压缩方式 适合什么
PNG 无损 截图、图纸、有硬边和纯色的图
JPG 有损 照片、颜色连续的图

为什么差这么多? PNG 要一个像素不差地还原,JPG 允许丢细节——它把相邻像素的细微差别抹掉,人眼看不出来。 所以反过来也要小心:图纸千万别存 JPG。 图纸有大片纯色和硬边,JPG 一压就在线条旁边出马赛克, 越压越糊。 而照片存 PNG 只是白白变大十倍,画质一点没涨——原始数据本来就没有更多细节了。

七、动手 Demo:影像就是一堆数字

读一张 512×512 的彩图,看它的形状、像素、切片、灰度、缩放和两种格式的大小。 重点看两处:一个像素就是三个数、裁剪和翻转都是切片。

import os

import numpy as np
from PIL import Image

IMG_DIR = 'img'          # 放 astronaut.png / coffee.png / chelsea.png


def load(name):
    return np.array(Image.open(os.path.join(IMG_DIR, name)).convert('RGB'))


arr = load('astronaut.png')
im = Image.open(os.path.join(IMG_DIR, 'astronaut.png'))

print('  ① 打开一张图,看它是什么')
print('  ' + '-' * 68)
print('    PIL 打开:尺寸 %s,模式 %s' % (im.size, im.mode))
print('    转成数组后:shape = %r,dtype = %s' % (arr.shape, arr.dtype))
print('    一共 %s 个数,占 %.1f MB' % (format(arr.size, ','), arr.nbytes / 1048576.0))
print('  shape 三个数分别是:高、宽、通道数。512×512×3 就是「高 512 行、宽 512 列、每个点三个数」。')
print('  dtype 是 uint8,也就是每个数只能 0 到 255。')
print('  三个通道加起来就是 1677 万种颜色——这就是「24 位真彩色」的来历。')
print()
print('  ② 一个像素到底存了什么')
print('  ' + '-' * 68)
for y, x in ((100, 100), (200, 300), (256, 256)):
    px = arr[y, x]
    print('    arr[%d, %d] = %r  → R=%d G=%d B=%d' % (y, x, px, px[0], px[1], px[2]))
print('  三个数分别是红、绿、蓝的亮度。(0,0,0) 是黑,(255,255,255) 是白。')
print('  注意是 RGB,而 OpenCV 读进来默认是 BGR——顺序反的,这是新手第一个大坑。')
print()
print('  ③ 裁剪就是切片')
print('  ' + '-' * 68)
crop = arr[100:300, 150:350]
print('    arr[100:300, 150:350] → shape %r' % (crop.shape,))
print('    这跟切列表一模一样:前面是行(高),后面是列(宽)')
print('  所以「裁剪」在 Python 里不需要任何图像库,就是一个切片。')
print('  注意顺序是先高后宽,写反了会切出一块完全不相干的地方。')
print()
print('  ④ 灰度:三个数变成一个数')
print('  ' + '-' * 68)
gray = arr[:, :, 0] * 0.299 + arr[:, :, 1] * 0.587 + arr[:, :, 2] * 0.114
simple = arr.mean(axis=2)
print('    加权(0.299R + 0.587G + 0.114B):shape %r' % (gray.shape,))
print('    直接平均:shape %r' % (simple.shape,))
print('    两者最大差 %.1f' % np.abs(gray - simple).max())
print('  三个权重加起来是 1,而且绿占得最多。')
print('  因为人眼对绿最敏感——直接平均会让绿色看起来偏暗,跟肉眼感觉不一致。')
print('  灰度不是「丢掉颜色信息」那么简单,是「按人眼的敏感度加权」。这也是黑白照片看起来舒服的原因。')
print()
print('  ⑤ 缩放和翻转')
print('  ' + '-' * 68)
small = np.array(Image.fromarray(arr).resize((128, 128)))
print('    缩到 128×128 → shape %r,数据量变成原来的 %.1f%%'
      % (small.shape, small.size * 100.0 / arr.size))
print('    左右翻转 arr[:, ::-1] → shape %r' % (arr[:, ::-1].shape,))
print('    上下翻转 arr[::-1, :] → shape %r' % (arr[::-1, :].shape,))
print('  翻转也是切片。::-1 的意思是「倒着走」,放在第二维就是左右翻,放第一维就是上下翻。')
print('  缩放要算插值——新图上的每个点去哪找原图的颜色。PIL 默认用双三次插值,效果好但慢;')
print('  缩略图用双线性就够。')
print()
print('  ⑥ 存回去')
print('  ' + '-' * 68)
Image.fromarray(crop).save('crop.png')
Image.fromarray(crop).save('crop.jpg', quality=85)
n1, n2 = os.path.getsize('crop.png'), os.path.getsize('crop.jpg')
print()
print('| 格式 | 字节数 | 说明 |')
print('|---|---:|---|')
print('| PNG | %s | 无损,适合截图和线条图 |' % format(n1, ','))
print('| JPG | %s | 有损,适合照片 |' % format(n2, ','))
print()
print('  同一个裁剪,PNG 是 JPG 的 %.1f 倍大。' % (n1 / float(n2)))
print('  照片存 JPG,截图和图纸存 PNG——前者颜色连续、丢一点也不明显,后者有大片纯色和硬边,')
print('  JPG 一压就出马赛克。')
os.remove('crop.png')
os.remove('crop.jpg')

实跑输出:

--------------------------------------------------------------------
影像就是一堆数字
--------------------------------------------------------------------

  ① 打开一张图,看它是什么
  --------------------------------------------------------------------
    PIL 打开:尺寸 (512, 512),模式 RGB
    转成数组后:shape = (512, 512, 3),dtype = uint8
    一共 786,432 个数,占 0.8 MB
  shape 三个数分别是:高、宽、通道数。512×512×3 就是「高 512 行、宽 512 列、每个点三个数」。
  dtype 是 uint8,也就是每个数只能 0 到 255。
  三个通道加起来就是 1677 万种颜色——这就是「24 位真彩色」的来历。

  ② 一个像素到底存了什么
  --------------------------------------------------------------------
    arr[100, 100] = array([187, 176, 169], dtype=uint8)  → R=187 G=176 B=169
    arr[200, 300] = array([232, 219, 221], dtype=uint8)  → R=232 G=219 B=221
    arr[256, 256] = array([19, 14,  7], dtype=uint8)  → R=19 G=14 B=7
  三个数分别是红、绿、蓝的亮度。(0,0,0) 是黑,(255,255,255) 是白。
  注意是 RGB,而 OpenCV 读进来默认是 BGR——顺序反的,这是新手第一个大坑。

  ③ 裁剪就是切片
  --------------------------------------------------------------------
    arr[100:300, 150:350] → shape (200, 200, 3)
    这跟切列表一模一样:前面是行(高),后面是列(宽)
  所以「裁剪」在 Python 里不需要任何图像库,就是一个切片。
  注意顺序是先高后宽,写反了会切出一块完全不相干的地方。

  ④ 灰度:三个数变成一个数
  --------------------------------------------------------------------
    加权(0.299R + 0.587G + 0.114B):shape (512, 512)
    直接平均:shape (512, 512)
    两者最大差 28.4
  三个权重加起来是 1,而且绿占得最多。
  因为人眼对绿最敏感——直接平均会让绿色看起来偏暗,跟肉眼感觉不一致。
  灰度不是「丢掉颜色信息」那么简单,是「按人眼的敏感度加权」。这也是黑白照片看起来舒服的原因。

  ⑤ 缩放和翻转
  --------------------------------------------------------------------
    缩到 128×128 → shape (128, 128, 3),数据量变成原来的 6.2%
    左右翻转 arr[:, ::-1] → shape (512, 512, 3)
    上下翻转 arr[::-1, :] → shape (512, 512, 3)
  翻转也是切片。::-1 的意思是「倒着走」,放在第二维就是左右翻,放第一维就是上下翻。
  缩放要算插值——新图上的每个点去哪找原图的颜色。PIL 默认用双三次插值,效果好但慢;
  缩略图用双线性就够。

  ⑥ 存回去
  --------------------------------------------------------------------

| 格式 | 字节数 | 说明 |
|---|---:|---|
| PNG | 74,436 | 无损,适合截图和线条图 |
| JPG | 10,061 | 有损,适合照片 |

  同一个裁剪,PNG 是 JPG 的 7.4 倍大。
  照片存 JPG,截图和图纸存 PNG——前者颜色连续、丢一点也不明显,后者有大片纯色和硬边,
  JPG 一压就出马赛克。

示例图是 scikit-image 自带的 astronaut.png,放在脚本旁边的 img/ 目录里。 装不上 scikit-image 也没关系,随便找一张 512×512 的彩色照片,把文件名改掉就行。 注意演示里所有的数字都跟着这张图走。 换一张图,187、28.4、 7.4 全变——要记的是「形状是三个数」「灰度是加权不是平均」这种关系,不是具体数字。 第四段那个「两者最大差 28.4」值得多想一下:两个都能叫「灰度」,但差了 28 个亮度级。 选错了,后面的阈值、边缘全跟着偏。 第五段那个 6.2% 是这么来的:128×128 除以 512×512 等于十六分之一。 像素少十六倍,后面的处理就快十六倍左右。 第六段两个字节数差 7.4 倍,是同一个裁剪存了两次。这就是「格式」的实际代价,不是理论值。

30 秒实验

  1. 把演示第二段的三个坐标改成 (0, 0),看左上角那个像素是什么颜色。
  2. 把演示第四段的权重改成三个三分之一(也就是直接平均),看「两者最大差」变成多少。
  3. 把演示第六段的 quality=85 改成 quality=30,看 JPG 的字节数掉到多少, 再打开图看看还能不能看。

常见误区

  1. 「图像处理得有专门的库,Python 玩不转。 」它就是数组。 切片、加减、布尔索引,全是 numpy 那套,图像库只是帮你读写文件。
  2. 「转灰度就是把三个数加起来除以三。」人眼对绿最敏感,直接平均会让绿色偏暗。 用 0.299、0.587、0.114 加权。
  3. 「PNG 比 JPG 清楚,那就都用 PNG。」照片存 PNG 只是大十倍,画质一点没涨; 反过来图纸存 JPG 会出马赛克。 是「什么图配什么格式」,不是哪个更高级。
自测三问(点开对答案) 1. 一张 512×512 的彩图,`shape` 是 `(512, 512, 3)`,这三个数分别是什么? —— 高、宽、通道数。512 行、512 列、每个点三个数(红绿蓝)。 乘起来就是 786,432 个数,每个占一个字节——所以整张图 0.8 MB 左右。 注意顺序是「先高后宽」,写反了切片不报错但会切错地方。 2. 为什么 OpenCV 读进来的图和 PIL 读进来的颜色不一样? —— 因为通道顺序不同:PIL 是 RGB,OpenCV 默认 BGR。数值都对,就是顺序反了,所以人脸会发蓝。 解决方法是中间插一句 `cv2.cvtColor`,或者干脆记住「进 OpenCV 前转一次, 出 OpenCV 后转回来」。 3. 同一个裁剪,为什么存 PNG 比存 JPG 大七倍多? —— PNG 是无损压缩,要保证一个像素不差地还原,所以压不动多少; JPG 是有损的,它把相邻像素的细微差别抹掉,人眼看不出来。 所以照片存 JPG,截图和图纸存 PNG——后者有大片纯色和硬边,JPG 一压就出马赛克。

下一篇:机器怎么从这堆数字里看出「模糊」和「边缘」。

相关笔记