【Python 图像处理】零基础入门:用 Pillow + Numpy 实现图片拼接与混合

本文配套完整可运行代码已开源:https://gitee.com/LiaCin/python-image-stitch-blend
实验环境:Ubuntu 22.04 + Python 3.10 + Pillow 12.3.0 + Numpy 2.2.6
阅读时长:约 12 分钟 | 难度:★★★★☆(动手即可,原理稍深)


一、前言:为什么从“拼接与混合”入门?

很多同学一上手计算机视觉,就被 OpenCV 的海量 API 劝退。其实图像处理的本质只有一句话:把图片当作矩阵来运算

本实验不依赖任何外部素材,程序自动生成演示图,你只要会 Python 基础,跟着敲完就能理解:

  • Pillow(PIL)怎么读写图片;
  • Numpy 怎么把一张彩色图看成 (高, 宽, 通道) 的三维数组;
  • 最常见的两类图像操作——**拼接(Stitching)混合(Blending)**到底是怎么算出来的。

学完这篇,你再去看 OpenCV 的 cv2.hconcatcv2.addWeightednp.hstack,会发现自己早已懂了。


二、环境准备

# 1. 安装依赖
pip install pillow numpy

# 2. 拉取/创建实验目录后运行主脚本
python image_stitch_blend.py

依赖说明:

作用
Pillow Python 图像处理事实标准库,Image 对象负责“打开/保存/显示”
Numpy 提供 N 维数组,图像处理中承担“矩阵运算”角色

三、核心认知:一张彩色图片 = 三维矩阵

用 Numpy 把图片读进来,打印它的“形状”就一目了然:

from PIL import Image
import numpy as np

img = Image.open("left.png").convert("RGB")
arr = np.array(img)

print(arr.shape)   # (400, 600, 3)  =>  (高度 H, 宽度 W, 通道 C)
print(arr.dtype)   # uint8          =>  每个像素分量取值 0~255
print(arr[0, 0])   # [220  20  60]  =>  左上角像素的 (R, G, B)

记住三个关键事实,后面所有操作都建立在它们之上:

  1. 形状是 (H, W, C),不是 (W, H, C)。行对应高度,列对应宽度。
  2. 数据类型是 uint8,范围 [0, 255]。做乘除运算前务必先转 float,否则会整数溢出“变黑”。
  3. PIL 用 RGB 顺序,OpenCV 用 BGR 顺序。混用时颜色会“红蓝互换”,这是新手最高频的 bug。

四、图像矩阵格式转换(本实验重点)

下面这段把四种转换一次演示清楚。

# (1) Image -> ndarray
arr = np.array(img)                      # (H, W, 3) uint8

# (2) ndarray -> Image
img_back = Image.fromarray(arr)

# (3) RGB <-> BGR:把最后一维翻转
bgr = arr[:, :, ::-1]
img_bgr = Image.fromarray(bgr)          # 颜色会“偏色”,因为 R 与 B 互换

# (4) uint8[0,255] <-> float[0,1]
arr_f = arr.astype(np.float32) / 255.0  # 归一化
arr_u = (arr_f * 255).clip(0, 255).astype(np.uint8)  # 反归一化

一个直观的“改像素”例子:把图片右半边整体调暗 50%,只需矩阵切片与标量乘法:

dark = arr.astype(np.float32)
dark[:, dark.shape[1] // 2:, :] *= 0.5   # 右半边所有通道 × 0.5
Image.fromarray(dark.astype(np.uint8)).save("01_half_dark.png")

关键点:arr * 0.5 之前必须 .astype(np.float32),否则 uint8 乘 0.5 会被截断成 0,整片变黑。


五、图片拼接(Stitching)

拼接就是把两张图“拼”成一张更大的图。前提是参与拼接的边尺寸一致(水平拼要等高,垂直拼要等宽)。

写法一:Pillow 画布法(直观,适合理解)

canvas = Image.new("RGB", (img1.width + img2.width, h))
canvas.paste(img1, (0, 0))
canvas.paste(img2, (img1.width, 0))
canvas.save("02_horizontal_pil.png")

写法二:Numpy 数组法(简洁,工业界常用)

arr1, arr2 = np.array(img1), np.array(img2)
hstack = np.hstack((arr1, arr2))         # 水平拼接
vstack = np.vstack((arr1, arr2))         # 垂直拼接
Image.fromarray(hstack).save("02_horizontal_np.png")

np.hstack 沿“列”方向拼接(变宽),np.vstack 沿“行”方向拼接(变高)。二者都要求非拼接维度尺寸严格相等。


六、图片混合(Blending)

混合不是“拼”,而是两张图按权重叠加成一张新图,结果同时带有两者的特征。

6.1 加权混合 Alpha Blending

公式非常朴素:

out = (1 - α) · A + α · B      (α ∈ [0, 1])
# 写法一:PIL 原生
Image.blend(img1, img2, alpha).save(f"03_blend_{int(alpha*100)}.png")

# 写法二:Numpy 显式公式(更能体会“矩阵运算”)
np_blend = (a1 * (1 - alpha) + a2 * alpha).astype(np.uint8)

α=0 时全是 A,α=1 时全是 B,α=0.5 是均匀半透明叠加。两种方式数学原理一致,但实机比对会发现 0~1 个灰度级的差异——因为 PIL 内部对结果做四舍五入,而 Numpy 的 astype(uint8)向零截断。肉眼基本看不出,但做像素级断言时必须用容差比较(见第九节)。

6.2 渐变混合(线性过渡带)

加权混合是“全局均匀”的,边界生硬。更自然的做法是在两张图交界处做一条宽度渐进的过渡带

H, W, C = a1.shape
band = int(W * 0.3)                      # 过渡带宽度
x = np.arange(W)
w = np.clip((x - (W - band) // 2) / band, 0, 1).reshape(1, W, 1)  # 0->1 的权重
gradient = (a1 * (1 - w) + a2 * w).astype(np.uint8)

权重 w 在过渡带内从左边的 0 线性增加到右边的 1,于是接缝处呈现出“从 A 缓缓融进 B”的效果——这正是全景拼图消除接缝的核心思想。

6.3 通道混合(趣味拓展)

彩色图三个通道可以独立操作。例如用 B 图的绿色通道替换 A 图的绿色通道:

mix = a1.copy()
mix[:, :, 1] = a2[:, :, 1]

七、综合实战:拼接 + 渐变混合全景图

把前面所有知识点串起来:取两张等尺寸图,左半用图1、右半用图2,再在接缝处做一条渐变过渡带,消除硬边界。

W, H = img1.size
img2 = img2.resize((W, H))
a1 = np.array(img1, np.float32)
a2 = np.array(img2, np.float32)
half, seam = W // 2, 80

out = np.zeros((H, W, 3), np.float32)
out[:, :half] = a1[:, :half]          # 左半取图1
out[:, half:] = a2[:, half:]          # 右半取图2

# 接缝在 col=half:用“同列”的图1/图2 像素做线性过渡
# 权重 t 从接缝左缘 0(全图1) 平滑过渡到右缘 1(全图2)
for c in range(max(0, half - seam), min(W, half + seam)):
    t = min(max((c - (half - seam)) / (2 * seam), 0.0), 1.0)
    out[:, c, :] = a1[:, c, :] * (1 - t) + a2[:, c, :] * t
Image.fromarray(out.astype(np.uint8)).save("04_panorama.png")

注意:这里用的是 a1[:, c, :]a2[:, c, :]同列像素做混合。早期一种错误写法是拿两张图的“左边缘列”去填接缝区,结果接缝处出现怪异的色块——混合必须保证“位置对齐”。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传


八、完整运行结果(真实服务器输出)

在 Ubuntu 22.04 + Python 3.10 环境下执行 python3 image_stitch_blend.py,完整输出如下(程序自动生成演示图,无需任何外部素材):

============================================================
实验开始:图片的拼接与混合
============================================================

=== 1. 图像矩阵格式转换 ===
  Image  -> ndarray : shape=(400, 600, 3)  dtype=uint8
  左上角像素值 (R,G,B) = (220, 20, 60)
  ndarray -> Image  : mode=RGB  size=(600, 400)
  RGB->BGR 翻转后,左上角像素值 = (60, 20, 220) (注意 R 与 B 互换 -> 颜色‘偏色’)
  uint8 -> float32[0,1] : dtype=float32, 区间=[0.00,1.00]
  float -> uint8 复原   : 是否一致 = True
  已保存 01_bgr_swapped.png / 01_half_dark.png

=== 2. 图片拼接 ===
  对齐尺寸: img1=(600, 400)  img2=(600, 400)
  水平拼接(PIL)  -> 02_horizontal_pil.png  size=(1200, 400)
  水平拼接(np)   -> 02_horizontal_np.png  shape=(400, 1200, 3)
  垂直拼接(np)   -> 02_vertical_np.png  shape=(800, 600, 3)

=== 3. 图片混合 ===
  加权混合 α=0.3 -> 03_blend_30.png  (PIL 与 Numpy 最大像素差=0,源于取整方式不同)
  加权混合 α=0.5 -> 03_blend_50.png  (PIL 与 Numpy 最大像素差=0,源于取整方式不同)
  加权混合 α=0.7 -> 03_blend_70.png  (PIL 与 Numpy 最大像素差=1,源于取整方式不同)
  渐变混合(过渡带=180px) -> 03_gradient_blend.png
  通道混合(替换G通道) -> 03_channel_mix.png

=== 4. 综合:拼接 + 渐变混合全景图 ===
  全景图 -> 04_panorama.png  size=(600, 400)

=== 5. 进阶:多频段拉普拉斯金字塔融合 (levels=5) ===
  金字塔融合全景图 -> 05_pyramid_blend.png  size=(600, 400)

============================================================
全部完成!输出文件位于 ./output/ 目录
  - 01_bgr_swapped.png
  - 01_half_dark.png
  - 02_horizontal_np.png
  - 02_horizontal_pil.png
  - 02_vertical_np.png
  - 03_blend_30.png
  - 03_blend_50.png
  - 03_blend_70.png
  - 03_channel_mix.png
  - 03_gradient_blend.png
  - 04_panorama.png
  - 05_pyramid_blend.png
============================================================

共生成 12 张结果图,分别对应五个 demo。注意 α=0.7 时 PIL 与 Numpy 最大像素差为 1——这正是第九节第 6 条踩坑的真实佐证。


九、踩坑记录(都是血泪)

  1. uint8 乘法溢出变黑img * 0.5 如果数组还是 uint8,结果全变 0。务必先 astype(np.float32)
  2. 拼接维度不匹配np.hstack 要求两张图高度相等、np.vstack 要求宽度相等,否则直接 ValueError。混合要求两张图尺寸完全一致
  3. RGB / BGR 顺序搞反:从 OpenCV(cv2.imread)读进来的图是 BGR,直接喂给 Image.fromarray 会红蓝互换。统一用 cv2.cvtColor(img, cv2.COLOR_BGR2RGB) 转一下。
  4. 混合权重越界α 超出 [0,1] 会让像素值超出 255,保存时 PIL 会裁剪,颜色失真;用 .clip(0, 255) 兜底更稳妥。
  5. PIL 与 Numpy 互转的 dtypeImage.fromarray 期望 uint8;若传入 float 数组会报错或显示异常,转回 uint8 再保存。
  6. 四舍五入 vs 截断(实机踩坑)Image.blend 本质是 out = im1 + (im2-im1)*α 并对结果四舍五入;而 np.astype(np.uint8) 是向零截断。两者在 α=0.3/0.5 时像素差 0、在 α=0.7 时最大差 1。若用 assert np.array_equal(...) 强校验会直接失败,应改为容差比较或显式 .round().astype(np.uint8) 对齐 PIL 行为。

十、进阶:多频段拉普拉斯金字塔融合(Laplacian Pyramid Blending)

10.1 为什么单频段渐变混合不够好?

第六节的渐变混合本质是全局线性交叉淡化(cross-fade):整张图用同一条权重曲线 w 过渡。问题在于——

  • **低频(大块颜色、光照)**需要宽过渡才自然;
  • **高频(纹理、边缘细节)**一淡化就会出现“重影/鬼影”,因为两张图同一位置的内容并不一致,平均之后两边都糊了。

所以专业的全景拼接不会用单一权重,而是在不同尺度上用不同宽度的过渡——这就是多频段金字塔融合的思想(Burt & Adelson, 1983)。

10.2 原理三句话

  1. 高斯金字塔:把图不断 2x 下采样(每次先低通),得到从细到粗的多层表示,每层包含一段“频率带”。
  2. 拉普拉斯金字塔:每层 = 本层高斯 − 上一层上采样,即“该尺度上的细节残差”。
  3. 分频融合:用掩码(也建金字塔)对每一层拉普拉斯残差加权融合,再自顶向下重建。掩码在粗层很“宽”(大结构平缓过渡)、在细层很“窄”(细节几乎不混),于是重影消失、接缝无缝。

10.3 纯 Numpy 实现(无需 OpenCV)

def _downsample(a):
    # 2x2 平均池化下采样(保证金字塔尺寸收敛)
    if a.shape[0] % 2: a = a[:-1]
    if a.shape[1] % 2: a = a[:, :-1]
    return (a[0::2,0::2] + a[1::2,0::2] + a[0::2,1::2] + a[1::2,1::2]) / 4.0

def _upsample(a, target_shape):
    up = np.repeat(np.repeat(a, 2, axis=0), 2, axis=1)
    return up[:target_shape[0], :target_shape[1]]

def laplacian_pyramid_blend(img1, img2, levels=5):
    W, H = img1.size
    img2 = img2.resize((W, H))
    a1, a2 = np.array(img1, np.float32), np.array(img2, np.float32)

    # 关键:填充到 2^levels 整数倍,否则某些层级出现奇数尺寸,
    # 下采样后再上采样无法还原原尺寸 -> 形状不匹配报错
    Wp, Hp = ((W+31)//32)*32, ((H+31)//32)*32
    a1 = np.pad(a1, ((0,Hp-H),(0,Wp-W),(0,0)), mode="edge")
    a2 = np.pad(a2, ((0,Hp-H),(0,Wp-W),(0,0)), mode="edge")

    mask = np.zeros((Hp, Wp, 1), np.float32)
    mask[:, :W//2, :] = 1.0                      # 左半取图1,右半取图2

    gp1, gp2, gpm = [a1],[a2],[mask]
    for _ in range(levels-1):
        gp1.append(_downsample(gp1[-1])); gp2.append(_downsample(gp2[-1]))
        gpm.append(_downsample(gpm[-1]))

    lp1 = [gp1[i] - _upsample(gp1[i+1], gp1[i].shape) for i in range(levels-1)] + [gp1[-1]]
    lp2 = [gp2[i] - _upsample(gp2[i+1], gp2[i].shape) for i in range(levels-1)] + [gp2[-1]]

    blended = []
    for i in range(levels):
        m = gpm[i] if gpm[i].ndim == 3 else gpm[i][:,:,None]
        blended.append(lp1[i]*m + lp2[i]*(1-m))

    result = blended[-1]
    for i in range(levels-2, -1, -1):
        result = blended[i] + _upsample(result, blended[i].shape)
    return Image.fromarray(np.clip(result[:H,:W], 0, 255).astype(np.uint8))

实机踩坑:若不给图像补成 2^levels 的整数倍,金字塔到某一层会出现奇数边长(如 75→37),上采样后比原层少 1 列,直接 ValueError: operands could not be broadcastnp.pad(..., mode="edge") 一行解决。

10.4 效果对比

下边左为单频段渐变混合(03_gradient_blend.png),右为多频段金字塔融合(05_pyramid_blend.png)。肉眼看二者接缝都已消除,但金字塔融合在纹理细节上更干净、更“实”,没有单频段那种整体发灰的发虚感——这是因为高频细节几乎没被淡化。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传


十一、小结与拓展

本实验用最朴素的两个库,讲清了图像处理的“任督二脉”:

  • 图片即矩阵(H, W, C)uint8、RGB 顺序;
  • 操作即运算:拼接是 hstack/vstack(或画布 paste),混合是带权加法。

可以继续拓展的方向

  • (已实现)多频段拉普拉斯金字塔融合:见第十节,接缝与纹理都更自然;
  • 特征点匹配(SIFT/ORB)+ 透视变换实现真正的“自动全景拼接”;
  • 迁移到 OpenCV,对比 cv2.addWeighted / cv2.seamlessClone 与本实验手写公式的异同。

完整代码(含自动生成演示图、五个 demo、详细注释)已上传 Gitee,克隆即可运行:

https://gitee.com/LiaCin/python-image-stitch-blend

如果本文对你有帮助,欢迎点赞、收藏、转发三连。下一篇预告:特征点匹配 + 透视变换实现自动全景拼接

更多推荐