
很多刚开始接触计算机视觉的朋友包括我自己当年最容易犯的一个错就是还没搞清楚图像在计算机里到底是怎么存的就急着跑去调模型、跑深度学习。结果连“读出来为什么是反的”“为什么图像处理完保存到本地就变绿了”这种基础问题都能卡一整天。所谓基本功扎实就是先把图像操作和处理这件事弄明白图像读进来是一堆数字处理是改动这一堆数字输出还是一堆数字仅此而已。这篇文章就是一套可以直接跟着跑的计算机视觉入门实操指南。我会用 OpenCV Python 作为主工具从图像在内存里是长什么样的到颜色空间、缩放裁剪、滤波去噪、形态学操作、几何与透视变换、边缘检测和直线检测一条线走下来。整个过程中会穿插大量“为什么这样做”“这里为什么要用这个参数”“实测下来什么效果更好”的经验之谈。如果你正在学计算机视觉、准备智能车比赛、或者刚开始用图像处理做毕设项目这篇文章对你应该很合适。不想烧脑啃理论公式直接拿代码跑起来看效果就好。1. 动手之前先把“图像”这两个字理解透1.1 图像在计算机里到底是一堆什么东西计算机视觉处理的对象不是“一张图片”而是一个多维数组。灰度图就是一个二维矩阵每个位置的数值代表该像素的亮度范围一般是 0 到 2550 是纯黑255 是纯白。彩色图则多了通道这个概念最常见的 RGB 图像是一个三维数组高和宽决定图像尺寸第三维是通道数 3分别对应红、绿、蓝三个颜色分量。用 OpenCV 读一张图后你会得到一个numpy.ndarray它的shape属性大概长这样(height, width, channels)。注意这个顺序很多人在这里栽过跟头。如果看到(480, 640, 3)说明图像高 480 像素、宽 640 像素、3 个颜色通道灰度图则是(480, 640)。我第一次做图像处理大作业时把shape当成了(width, height)结果所有坐标全反了检测出来的位置全都不对。实操里有个很有用的手段自己生成一张纯色图像来验证对图像表示的理解是不是对的。import numpy as np import cv2 # 生成一张 200x200 的纯红色图像BGR 顺序Red 255 red_img np.zeros((200, 200, 3), dtypenp.uint8) red_img[:, :, 2] 255 # 注意 OpenCV 的通道顺序是 BGR所以红色是第三个通道 cv2.imwrite(red_test.jpg, red_img)你打开生成的图片应该是一张红色的图。如果你写成red_img[:, :, 0] 255显示出来就是蓝色。这个小实验能把“通道顺序”这件事彻底刻进脑子里因为后面你会遇到大量色彩相关的奇怪问题根子都在通道顺序上。1.2 为什么 OpenCV 读进来的颜色老是和预期不一样你没看错OpenCV 读取彩色图像的默认通道顺序是 BGR而不是我们常说的 RGB。所以当你用 OpenCV 读图再用 OpenCV 的窗口直接显示时颜色是正常的可一旦你用 OpenCV 读图然后转给 Matplotlib 去显示颜色就变了红蓝互换看起来整体发蓝发怪。这不是 OpenCV 的 bug而是它的历史遗留。OpenCV 早期在底层接口设计上选择了 BGR 存储顺序后来为了兼容一直沿用到现在。解决方式也很简单用cv2.cvtColor做通道顺序转换import cv2 import matplotlib.pyplot as plt img cv2.imread(test.jpg) img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) plt.imshow(img_rgb) plt.axis(off) plt.show()实际项目中只要你始终坚持“同一条链路里只使用同一种通道顺序”问题就不大。比如全部用 OpenCV 读、OpenCV 显示、OpenCV 保存那你甚至不用关心 BGR 和 RGB 的区别。但如果你要跟深度学习框架PyTorch、TensorFlow对接就一定要注意很多模型训练时用的是 RGB 顺序喂数据之前必须转换否则模型效果会很差而且你根本不知道问题出在通道顺序上。2. 环境准备与工具选型为什么我推荐 OpenCV2.1 一套能跑通图像处理的开发环境做图像处理工具链的选择直接决定你后面顺不顺利。Python 生态里常见的图像处理库有 OpenCV、Pillow、scikit-image。我做智能车视觉和日常项目最常用的还是 OpenCV原因很简单它不仅覆盖了读图、显示、滤波、形态学、几何变换这些基础操作连摄像头采集、视频处理、特征匹配都一并解决了社区资料也最多。Pillow 更轻量但偏底层适合做一些简单的格式转换和图像生成scikit-image 更贴近科研和教学封装比较友好算法种类也很全但实时性场景下不如 OpenCV 直接。环境安装没什么复杂的用 pip 一把梭pip install opencv-python numpy matplotlib装完以后可以跑一个快速验证脚本确认环境没问题import cv2 import numpy as np print(OpenCV version:, cv2.__version__)能正常打印版本号就说明环境好了。这里提一句非常容易踩的坑很多人跑cv2.imread返回None第一反应是代码写得不对其实十有八九是文件路径出了问题尤其是 Windows 系统里的中文路径OpenCV 经常读不出来。稳妥的做法是项目目录里尽量避免中文如果实在躲不开可以先用 Python 原生的Path处理一下路径再传给imread但最简单粗暴有效的方式还是整个项目一律用英文路径。2.2 从读入一张图到保存一张图基础读写全流程基础读写是图像处理最底层的动作搞清楚它等于拿到了万能入口。读取用到的是cv2.imread它的第二个参数可以指定读取模式最常用是下面三种cv2.IMREAD_COLOR读取彩色图忽略 alpha 通道默认值。cv2.IMREAD_GRAYSCALE读取灰度图返回单通道数组。cv2.IMREAD_UNCHANGED保留原图所有通道包括透明通道。保存用的是cv2.imwrite注意它的返回结果会告诉你是否保存成功。还有一个容易被忽略的点保存图片的质量。如果保存成 JPGcv2.imwrite默认质量还不错但如果想控制质量可以传入编码参数cv2.imwrite(output.jpg, img, [cv2.IMWRITE_JPEG_QUALITY, 95])对于 PNG可以设置压缩级别[cv2.IMWRITE_PNG_COMPRESSION, 3]数值越低压缩越快但文件越大。处理视频流时读摄像头用的是cv2.VideoCapture比文件读取多一个“预热”过程如果摄像头刚打开就读帧前面几帧可能会是黑的实测下来可以先丢几帧再开始正式处理或者加一个短暂的 sleep 做稳定。下面是一个完整的读取、检查、显示、保存流程import cv2 img cv2.imread(scenes/road.jpg) if img is None: print(图像读取失败请检查路径) exit() gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) cv2.imwrite(scenes/road_gray.png, gray) cv2.imshow(Original, img) cv2.imshow(Gray, gray) cv2.waitKey(0) cv2.destroyAllWindows()cv2.waitKey(0)会一直等待键盘输入按下任意键继续这在处理单张图像时很常用。显示窗口之后一定要调用destroyAllWindows不然在 Jupyter 或者多次运行脚本时窗口会叠加界面会很乱。3. 图像预处理三板斧缩放、裁剪与颜色变换3.1 图像缩放和裁剪保持宽高比与控制 ROI实际项目中原始图像往往很大尤其是相机像素高一点的工业相机动辄 2000 万像素。直接把原图丢给后续处理耗时成倍增加。所以在预处理阶段缩放是第一件事。cv2.resize的参数里有fx和fy两个缩放系数它们比直接写目标尺寸更不容易出错。比如把图像缩小到原来的 0.5 倍img_small cv2.resize(img, None, fx0.5, fy0.5, interpolationcv2.INTER_LINEAR)关于插值方式一般放大图像用INTER_LINEAR缩小图像用INTER_AREA效果更好。如果你直接把目标宽高写成语原图不成比例的数值图像会被拉伸这在一些后续需要测量尺寸的场景下会引入误差。裁剪是 ROIRegion of Interest感兴趣区域操作就是简单的数组切片。比如我想裁出图像上半部分也就是(0, 0)到(width, height//2)区域roi img[:height // 2, :]注意数组切片的顺序是先高度方向再宽度方向。很多新手在这里会把顺序搞反死活裁不对。实操建议是每裁一次立刻用roi.shape打印尺寸来验证不要凭感觉。3.2 颜色空间转换实战灰度化与 HSV颜色空间转换可以说是图像处理里出场率最高的操作之一。把彩色图转灰度图能降低数据量很多算法在灰度图上效果就足够把图像从 BGR 转到 HSV 则能在颜色识别上提供巨大便利。用 HSV 对颜色进行分割比直接拿 RGB 做阈值判断要稳定得多。原因是 RGB 三个通道对亮度变化非常敏感同一个物体在阳光下和阴影里RGB 值的差异可能很大而 HSV 把色相Hue和亮度Value分开识别颜色时只用 H 通道就大差不差对光照的鲁棒性明显更好。举个智能车识别红色锥桶的例子。假设我要提取画面里的红色区域可以用cv2.inRange指定 HSV 的上下限import cv2 import numpy as np img cv2.imread(cone.jpg) hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 红色在 HSV 中通常对应两个区间注意边界 lower_red1 np.array([0, 100, 100]) upper_red1 np.array([10, 255, 255]) lower_red2 np.array([160, 100, 100]) upper_red2 np.array([179, 255, 255]) mask1 cv2.inRange(hsv, lower_red1, upper_red1) mask2 cv2.inRange(hsv, lower_red2, upper_red2) mask cv2.bitwise_or(mask1, mask2) result cv2.bitwise_and(img, img, maskmask)第一次写颜色识别的人经常会问为什么红色的上下限要写两段因为 HSV 中色相是一个环形红色恰好在 0 度附近所以 0 到 10 和 160 到 179 这两个区域都属于红色。只写一段就会漏掉一半。这个细节网上很多教程不说只有自己调试过颜色识别的人才知道。调试 HSV 阈值的时候最好做一个带滑动条的调参窗口实时查看mask效果不然你一遍遍改代码重启脚本效率极低。OpenCV 的cv2.createTrackbar可以做滑动条代码写一次后面能省很多时间。4. 滤波去噪为什么图像处理要用卷积CNN 为什么能大火4.1 三种最常用的滤波器与现场对比相机采集的图像尤其是光线不好或者感光度拉高时噪声会很明显。噪声不去掉后续边缘检测会出来一堆假边缘霍夫直线检测也会被干扰得没法看。滤波的作用本质上是用邻域像素的信息对当前像素做平滑让噪声的影响被“摊平”。最常用的三种滤波是均值滤波、高斯滤波和中值滤波它们的实现思路和适用场景差别不小。均值滤波最简单就是把核覆盖区域的所有像素取平均核越大图像越模糊但边缘也被磨得越厉害。高斯滤波给离中心近的像素更高的权重效果比均值滤波更自然边缘保留得也更好一点。中值滤波则取核区域内像素的中位数它对椒盐噪声黑白点噪声有奇效因为它能把孤立的异常像素直接干掉而不会像均值那样把异常值“拖”进全图。实际项目里如果只是普通降噪我建议首选高斯滤波核大小选奇数一般是 3x3 到 5x5img_blur cv2.GaussianBlur(img, (5, 5), 0)第二个参数(5, 5)是卷积核大小第三个参数是标准差填 0 表示让 OpenCV 根据核大小自己算。核越大噪声越少但细节也越少需要自己权衡。做边缘检测之前高斯滤波几乎是标配因为 Canny 本身对噪声敏感先平滑能显著减少误检。4.2 从滤波看 CNN为什么图像处理用 CNN 而不是前馈神经网络很多人手里有深度学习基础会问一张图就是一个二维数组把它摊平成一个一维向量喂给普通的多层感知机前馈神经网络不也能做分类吗为什么图像处理领域用了这么多 CNN卷积神经网络关键就在两组词局部连接和权值共享。图像里的像素之间存在很强的空间局部性边缘、纹理这些特征本质上都是某个像素和它周围像素的关系决定的。前馈神经网络里每个输出神经元都要和输入的全部像素相连参数数量大得惊人而且它对“像素之间离得多远”完全没概念等于把二维结构强行拍扁了。CNN 用卷积核在图像上滑动每个卷积核只关注一个小邻域天然匹配图像特征的局部性同时一个卷积核在所有位置共享同一份参数既大幅减少了参数量也让模型对“特征出现在图像哪个位置”没那么敏感泛化能力更强。正因为如此你在做图像处理基础时学到的卷积滤波其实和 CNN 里的卷积是同一个思想。区别只在于传统图像滤波的卷积核参数是人为设计的比如高斯核、Sobel 核而 CNN 里的卷积核参数是靠训练学出来的。理解了滤波你就理解了 CNN 最核心的起点这也是我一直建议大家不要跳过传统图像处理直接学深度学习的原因。5. 形态学操作膨胀与腐蚀在智能车和工业视觉里的实际意义5.1 膨胀、腐蚀、开运算、闭运算形态学操作听名字很高端实际上就是针对二值图像黑白图做“变胖”或“变瘦”的处理。腐蚀是让白色区域变小可以去掉小白点膨胀是让白色区域变大可以连接断开的区域。举个实际的例子。二值化之后画面里总会出现一些小噪点比如一个孤立的亮点这时候做一次腐蚀它可能就消失了。但腐蚀在去掉噪点的同时也会让目标区域变细。所以要先去噪再放大目标这个组合操作叫作开运算先腐蚀后膨胀效果是“去掉小噪点保持目标大小”。反过来先膨胀后腐蚀也就是闭运算效果是“填上小洞连接断开的区域”。用 OpenCV 实现非常直接import cv2 import numpy as np kernel np.ones((5, 5), np.uint8) eroded cv2.erode(mask, kernel, iterations1) dilated cv2.dilate(mask, kernel, iterations1) opening cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) closing cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)核的大小决定了“膨胀多少”或“腐蚀多少”。如果目标区域本身很细而核选大了膨胀之后目标可能连到一起去核选小了又达不到预期效果。我调参的时候习惯先打印目标区域的cv2.connectedComponentsWithStats看一下连通域数量和大小再决定核的参数这样不会瞎调。5.2 形态学在图像处理里的典型应用做智能车赛道识别时摄像头采集到的图像经过二值化后赛道边缘经常会断尤其是光照不均匀的时候赛道中间还会有反光形成的空洞。这时候闭运算可以把断掉的赛道补回来再把反光滑掉的区域填上。做完闭运算后再用一次开运算把赛道外的小噪点清掉最后得到的二值图就干净很多后续边缘检测和巡线就稳定多了。工业场景里形态学也有大量应用比如检测 PCB 板上的划痕、定位焊点位置。在缺陷检测里膨胀和腐蚀通常用来判断缺陷相对于正常区域是偏大还是偏小在字符识别里闭运算能把断裂的字符笔画连接起来提高识别准确率。可以说只要处理的是二值图形态学操作基本绕不开它就是图像预处理的“清道夫”。6. 几何变换与透视几何从图像坐标到真实世界坐标6.1 缩放、平移、旋转仿射变换几何变换解决的是“图像里的点在平面上做了移动”的问题。缩放、平移、旋转都属于仿射变换它们保持直线和平行线不变。在 OpenCV 里仿射变换通常用cv2.warpAffine实现核心是一个 2x3 的变换矩阵。旋转一个正方形或者任意图像最常见的需求是“绕图像中心旋转”而不是绕原点旋转。OpenCV 提供了cv2.getRotationMatrix2D来生成旋转矩阵import cv2 h, w img.shape[:2] center (w // 2, h // 2) matrix cv2.getRotationMatrix2D(center, angle45, scale1.0) rotated cv2.warpAffine(img, matrix, (w, h))旋转之后图像会飞出原来的画布边缘变成黑色。如果希望保留完整图像内容输出尺寸就要相应扩大如果只是做数据增强黑色边缘也能接受。这里有个容易被忽略的点warpAffine默认用双线性插值处理精度要求高的场景时可以换成interpolationcv2.INTER_CUBIC但速度会略慢。平移就更简单了构造一个[[1, 0, tx], [0, 1, ty]]的矩阵就行。实际操作里我不太建议直接改矩阵用cv2.warpAffine加getRotationMatrix2D这套索引方式语义更清晰不容易写出错。6.2 透视变换原理与车道/文档矫正实战透视变换Perspective Transform是比仿射变换更高阶的几何变换它描述的现实场景是一个平面在三维空间里经过旋转后投影到相机传感器上的像。想象一下你站在路边往前看标准的车道在正前方是平行的但在图像里它们是汇聚到远处的。透视变换可以把这种“近大远小”的透视图转换成一种类似从高处垂直往下看的“鸟瞰图”这在智能车视觉里非常关键。透视变换的数学本质是单应矩阵一个 3x3 的矩阵由 4 对对应点求解。OpenCV 里用cv2.getPerspectiveTransform求解矩阵用cv2.warpPerspective做变换src_points np.float32([[100, 200], [540, 200], [0, 480], [640, 480]]) dst_points np.float32([[100, 0], [540, 0], [100, 480], [540, 480]]) matrix cv2.getPerspectiveTransform(src_points, dst_points) bird_view cv2.warpPerspective(img, matrix, (640, 480))关键点就在这 4 对点的选取上。源点应该选取图像中一个真实矩形区域的 4 个角点目标点则是你想让它变成的矩形区域的 4 个角点。如果源点选得不太准变换出来的鸟瞰图就会歪斜。实操时我会先用画图工具在图上标出 4 个角点的坐标或者在代码里打印鼠标点击坐标再填进去效果比随手估坐标靠谱得多。透视几何在计算机视觉中真的无处不在。文档拍照识别的时候拍出来的纸张往往是歪的用透视变换把纸的 4 个角矫正成矩形OCR 的识别率能提升一大截。智能车做车道线检测时把前视图像转换成鸟瞰图后续拟合车道线就要简单很多因为鸟瞰图里的车道线更接近直线不容易受到远处透视收缩的影响。7. 边缘检测与直线检测让计算机“看到”轮廓7.1 Canny 边缘检测为什么是标配边缘是图像中灰度变化剧烈的地方。Canny 边缘检测是当前最经典的边缘检测算法它不是一步就出结果而是四步走先用高斯滤波平滑图像接着计算梯度的幅值和方向然后做非极大值抑制把非边缘的“粗线条”细化为单个像素宽最后用双阈值检测和滞后连接确认哪些边缘是真正的强边缘。代码很短短得让新手误以为它很简单edges cv2.Canny(gray, threshold150, threshold2150)threshold1和threshold2是双阈值的低阈值和高阈值。如果像素梯度高于高阈值保留为强边缘低于低阈值直接丢弃处于两者之间的只有当它和强边缘相连才保留。这个“滞后连接”机制是 Canny 的精髓它抗噪声能力比单纯用大阈值一刀切要好得多。调参的心得是低阈值和高阈值的比例范围一般在 1:2 到 1:3 之间比较合适。如果图像噪声重可以先把低阈值提一点如果边缘断断续续可以把高阈值调低一点。最开始我习惯设成 100 和 200后来发现不同场景差异很大最好的办法还是做一个滑动条实时调参调到满意后再固定数值。7.2 用霍夫变换检测直线有了边缘图计算机看到的是一个个白色像素点但这些点串起来是什么形状还需要进一步拟合成直线、圆等几何元素。检测直线最常用的就是霍夫变换。它把图像空间中的点映射成参数空间中的曲线然后通过统计投票找出共线的点。OpenCV 里常用的是概率霍夫变换cv2.HoughLinesP它的效率比标准霍夫变换高很多而且直接输出线段端点用起来更方便lines cv2.HoughLinesP(edges, rho1, thetanp.pi/180, threshold80, minLineLength50, maxLineGap10)threshold是投票阈值值越小检出的直线越多但误检也越多minLineLength是最小线段长度太短的不认为是直线maxLineGap是允许把间隔多少个像素的断点连接成同一条直线。智能车巡线时我一般会先把图像下半部分裁出来只在这块区域做霍夫变换因为近处的车道线特征稳定远处的线又细又短容易干扰判断。霍夫变换最常见的坑是误检图像里横着的一道阴影、一条拼接缝都可能被判成直线。解决办法是在检测结果里加条件过滤比如根据直线的斜率角度只保留和车道线方向接近的线段。这一步看着简单实际效果提升非常大能让整个系统稳定很多。8. 常见问题与排查技巧实录图像处理上手阶段会遇到很多“看似玄学”的问题实际原因往往就那么几个。我把这些年踩过的坑整理成一张速查表遇到问题先对着表查一遍。现象可能原因解决办法cv2.imread返回None路径不存在、中文路径、文件名后缀错误检查文件路径项目路径尽量用英文用 Matplotlib 显示图片颜色失真BGR 和 RGB 通道顺序没转换用cv2.cvtColor转成 RGB图片保存后是一片纯黑图像数组全为 0可能 ROI 裁取错误或处理流程失败打印img.min()和img.max()检查数据范围图像保存后变绿/变蓝通道顺序颠倒保存前确认通道顺序是 BGR摄像头读取到黑帧初始化后立刻读帧相机未稳定预热 1-2 秒或丢弃前 5 帧Canny 边缘噪点很多没有先做高斯滤波或阈值太低先GaussianBlur再调高阈值霍夫直线误检严重没有限制检测区域或角度范围只处理感兴趣区域并过滤不合理的角度代码运行很慢图像尺寸太大在反复imshow先缩放图像减少显示刷新频率除了这个表还有一个排查思路值得记住把每一步的中间结果都可视化出来存成图片或者用窗口显示。比如滤波后存一张二值化后存一张边缘检测后再存一张。这样做有个巨大好处你能迅速定位是从哪个步骤开始出现问题的。有一次我处理智能车图像发现直线检测结果乱飞排了半天才发现是二值化阈值定得太低画面里的影子全被当成赛道了。如果一开始就检查二值化结果这个问题半分钟就能发现。另一个实用技巧是学会用cv2.inRange之后的mask图像做调试。先可视化mask颜色分割是否正确一目了然再对该mask做膨胀、腐蚀观察连通域变化最后再往后续的几何计算走。整个过程都是高度可视化的哪里错了马上就能看到比盯着代码猜要高效太多。我个人实际操作中的体会是图像处理这个方向入门和进阶之间隔着的不是公式而是“调试量”。你亲手调过一张图的滤波参数、亲手截取过一次 ROI、亲手把一个歪歪扭扭的透视图像矫正过来这些经验是看十篇教程都换不来的。最后再分享一个小技巧调试图像处理程序尽量不要一次跑完全部流程而是拆成多个小脚本单独验证每个环节的输出单独保存。这样不仅在研发阶段高效后续做性能优化或换算法时也能快速定位改动的影响范围。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。