上次我们把 PyTorch 的环境跑通了,这次继续往前走,把图像处理的两大基础库 OpenCV 和 PIL 装好,再拿一个真正能跑的小实战练手。很多零基础的同学在这里会卡住:不是 PyTorch 不会用,而是读进来的图片到底是个 numpy 数组还是 PIL Image,颜色通道到底是 BGR 还是 RGB,转成 tensor 之后形状为什么是[3, 224, 224]而不是[224, 224, 3]。这篇文章就是把这些绕不过去的坑一次性讲清楚,然后带着你从生成测试图开始,用 PyTorch 做一次卷积边缘检测,用 OpenCV 检测直线,再顺手做个骨架提取,全套流程你照着敲就能跑通。
看完之后,你不仅能理解 OpenCV 和 PIL 在日常图像处理里各自扮演什么角色,还能搞明白它们和 PyTorch 之间是怎么衔接的。适合刚接触 PyTorch、准备做计算机视觉项目,或者只会调用cv2.imread但不懂背后原理的人。内容不跳步,每个关键点都给了原因和验证方法。
1. 先说清楚:OpenCV 和 PIL 都是干什么的,为什么两个都要装
1.1 两个库的定位差异:一个是工具箱,一个是轻量读写器
PIL(现在的维护版本叫 Pillow)在 PyTorch 生态里的角色更像是一张“照片入口”。它负责读图片、写图片、转格式、缩缩放放、简单画线画框,API 设计非常友好。你打开一个图片就是Image.open('x.jpg'),保存就是img.save('y.png')。PyTorch 的torchvision.datasets很多内置数据集返回的就是 PIL Image,transforms.ToTensor()也默认处理 PIL Image。
OpenCV 则是计算机视觉的“瑞士军刀”。它有上千个内置算法,包括边缘检测、直线检测、轮廓查找、形态学操作、视频读取、摄像头采集、图片颜色空间转换、滤波去噪等等。你跑深度学习模型之前要做预处理、要可视化检测结果、要处理视频流,OpenCV 都是绕不开的。
说得再直白一点:PIL 是“能把图片打开和保存的轻量工具”,OpenCV 是“能对图片做各种加工的重型工作台”。两者不是替代关系,而是配合关系。我在实际项目里最常见的一段开场就是:
from PIL import Image import numpy as np img_pil = Image.open('demo.png') # 用 PIL 读取 rgb = np.array(img_pil.convert('RGB')) # 转成 numpy,后面交给 opencv 处理后面接 OpenCV 滤波、PyTorch 张量化、进入神经网络,整条流水线分工很明确。如果你只装了其中一个,遇到torchvision.transforms要 PIL Image、OpenCV 读出来却是 numpy 数组的时候,就会到处找转换代码,反而把自己绕晕。所以零基础入门,两个一起装,少很多折腾。
1.2 为什么选择 OpenCV + PIL 组合而不是二选一
有人可能会问:OpenCV 也能读图片、也能保存,为什么还要加 PIL?因为我们确实会遇到只有 PIL 更方便的场景。
第一个场景是数据集加载。torchvision.datasets.ImageFolder默认就是用 PIL 读图,你如果只用 OpenCV,还得额外包一个 Dataset 把 numpy 转成 PIL 或者直接转 tensor,多写一层代码。
第二个场景是画图。PIL 从零生成一张测试图特别省事,几行代码就能画出直线、矩形、圆,适合做单元测试和算法验证。OpenCV 也能画,但写起来总有一种写 C++ 的感觉。
第三个场景是颜色通道。OpenCV 的历史原因导致它默认用 BGR 通道顺序,而 PIL 用 RGB。新手第一次用 OpenCV 读图再保存,会发现颜色偏蓝偏红,其实就是通道顺序搞反了。PIL 没有这个问题,它更贴合人类对颜色的直觉。
我自己的习惯是:凡是“要把图片喂给 PyTorch”的流程,优先用 PIL 读;凡是“要对图片做检测、找轮廓、算边缘”的流程,优先用 OpenCV 处理;最后保存结果时,再回到 PIL 输出,因为Image.fromarray()在 RGB 模式下颜色一定是对的。这样分工,能避开一大半颜色通道的坑。
为了让你更直观地理解两个库的侧重点,我整理了一张对比表:
| 对比项 | Pillow (PIL) | OpenCV |
|---|---|---|
| 定位 | 轻量图像读写与基础处理 | 计算机视觉算法库 |
| 读图返回类型 | PIL Image 对象 | numpy.ndarray |
| 颜色通道 | RGB | BGR |
| 视频/摄像头支持 | 不支持 | 支持 |
| 常见算法 | 少 | 边缘检测、直线检测、轮廓、特征点等 |
| 和 torchvision 结合 | 原生支持 | 需要转换 |
| 适合场景 | 读图、保存、简单绘制、数据增强 | 图像预处理、检测、视频处理 |
2. 环境准备:零基础也能一次装对的安装思路
2.1 Anaconda 下创建独立环境,别把包装乱
我用 Anaconda 做演示,因为它自带 conda、pip、jupyter,而且能创建多个互相隔离的 Python 环境。你上一篇文章如果已经装过一个 PyTorch 环境,这次直接在同一个环境里补装 OpenCV 和 Pillow 就行,不一定要新建。但如果你之前乱装过很多包,我建议还是新建一个干净环境,避免版本冲突。
打开命令行(Windows 用 Anaconda Prompt,macOS/Linux 用终端),执行:
conda create -n cv python=3.9 -y conda activate cv这里我选 Python 3.9,主要原因是兼容性最稳。PyTorch 从 1.13 到 2.x,Python 3.8 到 3.11 基本都能跑,但最新的 Python 3.12 有些老版本 wheel 可能不全,新手没必要追求最新。Python 只是工具,跑得稳比跑得新重要。
激活环境之后,先用python --version确认当前 Python 是 3.9,再用which python(Windows 是where python)确认你用的解释器在 Anaconda 的环境目录里。这一步能避免后面“明明装了这个包,却 import 不到”的尴尬。
2.2 PyTorch 与 Python 版本的对应关系
PyTorch 官方对不同 Python 版本有对应的安装包。你只需要记住一个原则:不要用 pip 乱装最新版,先打开 PyTorch 官网的安装页,选择自己的操作系统、包管理器和 CUDA 版本,复制它生成的命令就可以。
如果你的电脑有 NVIDIA 显卡,想用 GPU,先运行:
nvidia-smi看右上角的 CUDA Version。这只是驱动支持的 CUDA 版本,不代表你必须装那么高的 PyTorch CUDA 版本。一般装了 CUDA 11.8 对应的 PyTorch 就能覆盖绝大多数 30 系、40 系显卡。常见做法是:
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118如果你的电脑没有 NVIDIA 显卡,或者不打算跑大模型,直接用 CPU 版:
pip install torch torchvision torchaudio千万不要有“安装 PyTorch 是不是必须要有 GPU”的焦虑。CPU 版完全可以做图像分类、边缘检测、小规模实战。GPU 只是把训练和卷积加速,不代表你学不会原理。把 CPU 版跑通,之后再用 GPU 也只是换一条安装命令的事。
2.3 opencv-python 和 opencv-contrib-python 怎么选
图像处理库的安装有一个特别常见的坑:OpenCV 有两个 pip 包,一个叫opencv-python,一个叫opencv-contrib-python。前者是最核心的模块,后者在核心模块基础上额外包含了ximgproc(骨架提取、SLIC 超像素等)、xfeatures2d(SIFT、SURF)等扩展算法。
如果只做入门,opencv-python就够了。但这次小实战里我要用到cv2.ximgproc.thinning,这个函数不在核心包里,所以直接装opencv-contrib-python:
pip install opencv-contrib-python同时把 Pillow 也装上:
pip install pillow这里有一个重要提醒:opencv-python和opencv-contrib-python不要同时装,这两个包会互相覆盖同名文件,最后可能导致import cv2直接报错或部分函数失灵。你在安装前可以先看下当前环境有没有装过:
pip list | grep opencv如果有,先卸载再装你要的那一个。至于opencv-python-headless,那是给服务器和无显示器环境用的,没有 GUI 显示能力,本地学习装普通版就行。
3. 图像处理基石:三行代码搞懂读写、颜色通道和数组转换
3.1 cv2.imread 和 PIL.Image.open 的区别
很多教程写cv2.imread读图,但我们要先搞清楚它和 PIL 读图到底有什么不同。
import cv2 from PIL import Image # OpenCV 读出来的是 numpy 数组 img_cv = cv2.imread('demo.png') print(type(img_cv)) # <class 'numpy.ndarray'> print(img_cv.shape) # (高, 宽, 通道数) # PIL 读出来的是 PIL Image 对象 img_pil = Image.open('demo.png') print(type(img_pil)) # <class 'PIL.PngImagePlugin.PngImageFile'> print(img_pil.size) # (宽, 高)注意cv2.imread如果文件路径不对,不会抛异常,而是返回None。你如果不对None做判断,后面img.shape会报AttributeError: 'NoneType' object has no attribute 'shape'。而Image.open路径不对会直接抛FileNotFoundError。两者的错误风格完全不同,这也影响了后续排查方式。
另一个区别是 shape 的维度顺序。OpenCV 返回的是(H, W, C),也就是高度在前、宽度在后。PIL 的size却是(W, H)。新手经常在这里蒙圈:img.shape的宽和高正好和img.size反着。你只要记住一个规则:numpy 统一按“先行后列”,也就是先高后宽;PIL 按“先宽后高”。后面转成 PyTorch tensor 时,还会遇到通道在前的问题,所以每一步都要用print验证形状。
3.2 BGR / RGB / 灰度:颜色通道顺序是第一个坑
OpenCV 的imread默认把图片解码成 BGR 颜色空间,而不是常见的 RGB。这就是为什么你用 OpenCV 读图后用 matplotlib 显示,图像颜色会发蓝,因为 matplotlib 期望 RGB,你给的却是 BGR。
转换方法有两种。第一种是严格型:
img_rgb = cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB)第二种是取巧型,直接在 numpy 上反转通道:
img_rgb = img_cv[:, :, ::-1][:, :, ::-1]就是把第三个维度也就是通道维度倒过来。BGR 反序自然变成 RGB。这个方法快,但读起来没有cvtColor那么明确,我建议你在正式代码里用cvtColor,语义更清晰。
如果要做灰度图,OpenCV 用:
gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)PIL 用:
gray_pil = img_pil.convert('L')灰度图只有一个通道,形状会从(H, W, 3)变成(H, W),这也是后面 PyTorch 卷积操作的常见输入格式。
3.3 图像转 PyTorch Tensor:HWC 到 CHW 的完整流程
PyTorch 的卷积神经网络默认输入形状是(N, C, H, W),N 是 batch size,C 是通道数,H 是高度,W 是宽度。而 OpenCV 和 PIL 读进来的 numpy 数组是(H, W, C)。所以必须做一次维度重排。
使用 PIL 和 torchvision 是最顺滑的:
from torchvision import transforms transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) tensor = transform(img_pil) print(tensor.shape) # torch.Size([3, 224, 224])ToTensor()做了两件重要的事:第一,把 PIL Image 或 numpy 数组从HWC变成CHW;第二,把像素值从 0-255 归一化到 0-1。这两个动作很容易被忽略,但对模型训练至关重要。
如果你手里是 OpenCV 读出来的 BGR numpy 数组,千万不要直接丢给ToTensor(),否则你的通道顺序就错了。正确做法是先用cvtColor转成 RGB,再torch.from_numpy之后手动permute:
img_rgb = cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB) tensor = torch.from_numpy(img_rgb).permute(2, 0, 1).float() / 255.0 print(tensor.shape) # torch.Size([3, H, W])这里的permute(2, 0, 1)是把原来索引为 2 的通道维度挪到最前面。这一步操作是整个视觉项目中出现频率最高的“魔法代码”,建议你亲手拆一遍:先创建一个(H, W, 3)的小数组,打印各维度索引,再做 permute,观察形状变化,记忆会深刻得多。
4. 小实战:用 PIL 生成图,PyTorch 做边缘检测,OpenCV 找直线和骨架
4.1 准备一张测试图:用 PIL 直接画出十字线
实战不需要外面找数据集,直接用 PIL 生成一张白底黑色十字线,这样所有参数都可控,便于你调试。
from PIL import Image, ImageDraw img = Image.new('RGB', (600, 400), 'white') draw = ImageDraw.Draw(img) draw.line((100, 200, 500, 200), fill='black', width=8) draw.line((300, 100, 300, 350), fill='black', width=8) img.save('demo.png')这张图里有两条直线,一条水平、一条竖直。后面无论是 Sobel 边缘检测还是 HoughLinesP 直线检测,都能得到非常明确的结果。建议你运行完这段代码后,把图片打开看一眼,记住它的样子。
4.2 用 PyTorch 的 F.conv2d 实现 Sobel 边缘检测
很多人只知道 PyTorch 能训练模型,不知道它也能直接做底层卷积运算。这里我们就用torch.nn.functional.conv2d手写一个 Sobel 算子,验证一下“卷积”到底是怎么工作的。
Sobel 算子分 x 方向和 y 方向,x 方向的卷积核是:
-1 0 1 -2 0 2 -1 0 1y 方向是:
-1 -2 -1 0 0 0 1 2 1在 PyTorch 里,图片要先从(H, W)变成(1, 1, H, W),因为conv2d要求输入是四维的:(batch, channel, height, width)。
import cv2 import numpy as np import torch import torch.nn.functional as F img_pil = Image.open('demo.png') rgb = np.array(img_pil.convert('RGB')) gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) blur = cv2.GaussianBlur(gray, (3, 3), 0) tensor = torch.from_numpy(blur).float().unsqueeze(0).unsqueeze(0) # (1,1,H,W) print(tensor.shape) # torch.Size([1, 1, 400, 600]) sobel_x = torch.tensor([[[[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]]]], dtype=torch.float32) sobel_y = torch.tensor([[[[-1, -2, -1], [ 0, 0, 0], [ 1, 2, 1]]]], dtype=torch.float32) edge_x = F.conv2d(tensor, sobel_x, padding=1) edge_y = F.conv2d(tensor, sobel_y, padding=1) edge = torch.sqrt(edge_x ** 2 + edge_y ** 2) edge = edge.squeeze().numpy() edge = (edge / edge.max() * 255).astype(np.uint8)padding=1是为了让卷积前后尺寸不变。如果不加 padding,输入 400x600、卷积核 3x3,输出会变成 398x598,很多新手调试时发现图片尺寸对不上,就是这里出了问题。卷积完用平方和开方把 xy 两个方向的边缘强度合成一个梯度幅值,这就是 Sobel 边缘检测的经典做法。
4.3 接上 OpenCV 的 HoughLinesP 检测直线
拿到边缘图之后,先用阈值二值化,再用 OpenCV 的HoughLinesP把直线找出来。
_, binary = cv2.threshold(edge, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) lines = cv2.HoughLinesP(binary, 1, np.pi / 180, threshold=50, minLineLength=30, maxLineGap=5) result = rgb.copy() if lines is not None: for line in lines: x1, y1, x2, y2 = line[0] cv2.line(result, (x1, y1), (x2, y2), (255, 0, 0), 3)HoughLinesP的几个参数值得解释一下。第一参数是像素单位的分辨率,1 就表示步长是一个像素。第二参数是角度分辨率,np.pi/180表示 1 度。第三个threshold是投票阈值,只有投票数超过 50 的直线才会被保留,值越小,检测出的直线越多,但也更容易把噪声当直线。minLineLength是最小直线长度,太短的不算直线;maxLineGap是同一线段上允许的最大间隔,它能把断断续续的边缘连成一条线。
针对我们的十字线图,threshold=50、minLineLength=30已经足够。这里用result是 RGB 数组,但cv2.line并不关心颜色空间,给它(255, 0, 0)就是红色,因为 RGB 下红色就是(255, 0, 0)。如果你用 OpenCV 的imread读图,(255, 0, 0)在 BGR 下是蓝色,这点又回到了颜色通道的问题。
4.4 用 ximgproc.thinning 再做骨架提取
骨架提取,也叫细化,是把一个二值形状压缩成单像素宽的骨架,常用于 OCR 字符识别、笔画提取、路径规划。OpenCV 的ximgproc扩展模块提供了现成的thinning函数。
import cv2.ximgproc skeleton = cv2.ximgproc.thinning(binary, thinningType=cv2.ximgproc.THINNING_GUOHALL)thinningType可以选THINNING_ZHANGSUEN或THINNING_GUOHALL,两种都是经典的细化算法。Zhang-Suen 是每次迭代剥掉边界像素但保持连通性,Guo-Hall 对直线交叉点的处理略有不同。你不需要记算法细节,只需要知道它们最后都会输出单像素宽的结果。
注意,这段代码在裸opencv-python里是不存在的,必须安装opencv-contrib-python,这也是我前面让你装它的原因。如果确实不想装 contrib,也有替代方案:循环执行cv2.erode配合连通性判断,但代码会复杂得多,学习阶段没有必要重复造轮子。
最后把结果拼在一起保存:
result_pil = Image.fromarray(np.hstack([result, cv2.cvtColor(skeleton, cv2.COLOR_GRAY2RGB)])) result_pil.save('result.png')这里用np.hstack把边缘检测结果和骨架图横向拼成一张对比图,再用 PIL 保存。使用 PIL 而不是cv2.imwrite,最大的好处是颜色通道不会出错,RGB 数组保存成 PNG 后颜色和预期完全一致。
5. 实战中高频踩坑与排查手册
5.1 ModuleNotFoundError: No module named 'cv2'
这是出现频率最高的问题。你在终端里明明装了opencv-python,但运行脚本时 Python 说找不到cv2。最常见原因是当前激活的环境和你安装包的环境不是同一个。
排查顺序两步走。第一步,在脚本开头的终端里运行:
python -m pip show opencv-contrib-python如果显示版本和路径,说明包确实在当前 Python 环境里。第二步,再用:
where python看当前 Python 解释器路径。如果你看到多个 Python 路径,说明系统里有好几个 Python,pip 装到了 A 环境,而你的脚本用 B 环境的解释器在跑。解决办法很简单:只用python -m pip install,不要直接写pip install,前者一定会把包装到当前python对应的环境里。
5.2 装了 OpenCV 却还是 ImportError
这种问题比“没装”更让人头疼。常见情况有三种。
第一种是当前环境里同时存在opencv-python和opencv-contrib-python,两个包互相覆盖,导致某些模块缺失或import cv2直接报错。解决办法是卸载两个,只装一个:
pip uninstall opencv-python opencv-contrib-python pip install opencv-contrib-python第二种是装成了opencv-python-headless。这个包没有 GUI 相关功能,某些版本里cv2.imshow会报错,尽管import cv2不报错。如果你是服务器环境,headless 可以用;本地调试最好换成普通版。
第三种是编辑器(比如 PyCharm)使用的解释器不是当前 conda 环境。打开 PyCharm 的 Settings -> Project -> Python Interpreter,确认选中的解释器就是你执行conda activate cv之后which python看到的路径。这个坑和上一类问题本质上是一回事。
5.3 cv2 里的函数名大小写:contourArea 的教训
热词里有一个很典型的问题:contourarea ()未定义标识符。这其实是 OpenCV API 的函数名大小写问题。OpenCV 的 Python 接口是从 C++ 绑定过来的,函数名严格区分大小写。找轮廓的面积函数叫cv2.contourArea,不是cv2.contourarea,也不是cv2.ContourArea。
如果你在 PyCharm 里看到“未定义标识符”,先检查函数名的大小写。OpenCV 里很多函数都是驼峰命名,比如cv2.cvtColor、cv2.GaussianBlur、cv2.HoughLinesP,中间字母的大小写不能错。类似的还有cv2.findContours,不要写成cv2.FindContours。如果你是自己手敲代码,建议尽量用 IDE 的自动补全,能少打错一半字母。
5.4 PyTorch 安装相关:GPU、WSL、版本对应
“安装 PyTorch 是不是必须要 GPU”是我见过最多的疑虑。完全不是。CPU 版能跑绝大多数入门实战,包括本文的卷积边缘检测。GPU 只是把矩阵运算加速,不是必需项。你现在要学的是原理和代码,CPU 足够。
WSL 用户要多留个心眼。在 Windows 的 WSL 里,PyTorch 的 GPU 支持受限于 WSL 版本、显卡驱动、CUDA 版本,折腾成本很高。建议第一次跑项目直接用 CPU 版,先确认代码正确,再考虑 GPU。如果你用的是 AMD 显卡,Windows 下 PyTorch 官方目前没有原生 ROCm 支持,想用 GPU 往往要研究很长的环境配置,属于进阶玩法,零基础阶段可以直接跳过。
版本对应问题同样用一句话概括:PyTorch 版本、Python 版本、CUDA 版本三者互相限制,不要自己乱拼。以官网安装页生成的命令为准,比任何博客教程都可靠。
最后分享几个我自己的操作习惯
这套流程我重复过很多次,踩过的坑比上面写的还要多。现在固定下来的习惯是:第一步永远print一下当前变量类型和shape,不要凭感觉往下写;第二步优先用Image.open读原始图,遇到需要颜色转换时再交给 OpenCV;第三步所有结果图都用 PIL 的Image.fromarray保存,彻底绕开 BGR/RGB 的烦恼。还有一个小技巧是先把测试图缩到 200 像素宽跑通流程,再换原图,调试速度会快很多。希望这篇内容能帮你把 OpenCV、PIL、PyTorch 三个工具真正串起来,之后做数据增强、模型训练预处理时,你会发现自己已经站在一个很稳的地基上了。