【Python 图像处理】零基础入门:用 Pillow + Numpy 实现图片拼接与混合
【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.hconcat、cv2.addWeighted、np.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)
记住三个关键事实,后面所有操作都建立在它们之上:
- 形状是
(H, W, C),不是(W, H, C)。行对应高度,列对应宽度。 - 数据类型是
uint8,范围[0, 255]。做乘除运算前务必先转float,否则会整数溢出“变黑”。 - 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 条踩坑的真实佐证。
九、踩坑记录(都是血泪)
- uint8 乘法溢出变黑:
img * 0.5如果数组还是uint8,结果全变 0。务必先astype(np.float32)。 - 拼接维度不匹配:
np.hstack要求两张图高度相等、np.vstack要求宽度相等,否则直接ValueError。混合要求两张图尺寸完全一致。 - RGB / BGR 顺序搞反:从 OpenCV(
cv2.imread)读进来的图是 BGR,直接喂给Image.fromarray会红蓝互换。统一用cv2.cvtColor(img, cv2.COLOR_BGR2RGB)转一下。 - 混合权重越界:
α超出[0,1]会让像素值超出 255,保存时 PIL 会裁剪,颜色失真;用.clip(0, 255)兜底更稳妥。 - PIL 与 Numpy 互转的 dtype:
Image.fromarray期望uint8;若传入float数组会报错或显示异常,转回uint8再保存。 - 四舍五入 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 原理三句话
- 高斯金字塔:把图不断 2x 下采样(每次先低通),得到从细到粗的多层表示,每层包含一段“频率带”。
- 拉普拉斯金字塔:每层 = 本层高斯 − 上一层上采样,即“该尺度上的细节残差”。
- 分频融合:用掩码(也建金字塔)对每一层拉普拉斯残差加权融合,再自顶向下重建。掩码在粗层很“宽”(大结构平缓过渡)、在细层很“窄”(细节几乎不混),于是重影消失、接缝无缝。
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 broadcast。np.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
如果本文对你有帮助,欢迎点赞、收藏、转发三连。下一篇预告:特征点匹配 + 透视变换实现自动全景拼接。
更多推荐



所有评论(0)