(实验室招新版)大津法与 OpenCV 图像基础:新手完整教程(基于 C++ 实例)
这份教程会讲什么
这是一篇面向初学者的 OpenCV 教程,内容包括:
- 图像在程序里怎么存储
- 灰度图、直方图、二值化的概念
- 大津法的原理、公式和手写实现
- ROI 截取、图像旋转、池化
- 每个第一次出现的 OpenCV 函数的作用、参数和用法
每个知识点都会配一个具体的数字例子,方便你把“抽象的概念”变成“看得见的计算”。
一、图像在程序里是什么
图像在程序里是一张数字表格。
灰度图的例子,一张 4 行 5 列的图:
50 52 48 200 205 49 51 55 210 208 45 60 180 190 188 40 120 150 160 170每个格子存一个 0 到 255 的亮度值:
- 0 是纯黑
- 255 是纯白
- 中间是各种灰
彩色图每个格子存三个数,分别表示蓝、绿、红,OpenCV 里的顺序是 BGR。
一张 640 x 480 的彩色图:
- 像素数:640 x 480 = 307200
- 每个像素 3 个数
- 总个数:640 x 480 x 3 = 921600
OpenCV 用cv::Mat存图像,常用属性:
rows:行数,也就是高度cols:列数,也就是宽度channels():通道数,灰度图是 1,彩色图是 3
二、读取图片:cv::imread
cv::imread的作用:把图片文件读进内存,得到一个cv::Mat。
参数:
- 第一个参数:图片路径
- 可以写绝对路径,例如
C:/Users/你的名字/Pictures/test.png - 也可以写相对路径,例如
"test.png" - 本教程统一使用相对路径演示,也就是图片和程序在同一个工作目录里
- 可以写绝对路径,例如
- 第二个参数:读取方式
cv::IMREAD_COLOR:按彩色图读,默认值cv::IMREAD_GRAYSCALE:直接按灰度图读0是cv::IMREAD_GRAYSCALE的简写
返回值:一个cv::Mat,如果读取失败,这个 Mat 是空的。
用法:
// 用相对路径读取图片,成功后 image 保存整张彩色图cv::Mat image=cv::imread("test.png");读取后一定要检查是否成功:
// 判断图片是否读取成功if(image.empty()){// empty() 为真说明图片没读进来,常见原因是路径写错或文件不存在std::cout<<"图片读取失败"<<std::endl;// 返回 -1 表示程序异常结束return-1;}image.empty()的作用:判断图像是否为空,为空说明路径写错或文件不存在。
三、转灰度图:cv::cvtColor
cv::cvtColor的作用:把图像从一种颜色空间转换成另一种。
参数:
- 第一个参数:输入图像
- 第二个参数:输出图像
- 第三个参数:转换方式,这里用
cv::COLOR_BGR2GRAY
cv::COLOR_BGR2GRAY的含义:把 BGR 彩色图转成单通道灰度图。
用法:
// 先创建一张空的灰度图cv::Mat gray;// 把彩色图 image 转成灰度图,结果存到 graycv::cvtColor(image,gray,cv::COLOR_BGR2GRAY);四、保存图片:cv::imwrite
cv::imwrite的作用:把cv::Mat保存成图片文件。
参数:
- 第一个参数:保存路径,例如
"gray.png" - 第二个参数:要保存的图像
用法:
// 把 gray 保存成 gray.png,保存成功返回 truecv::imwrite("gray.png",gray);返回值:成功返回true,失败返回false。
五、读取像素:cv::Mat::at
gray.at<uchar>(y, x)的作用:读取灰度图第 y 行、第 x 列的像素。
参数:
- 尖括号里的
uchar:像素类型,表示 8 位无符号数,范围 0 到 255 - 括号里第一个数:行号 y
- 括号里第二个数:列号 x
用法:
// 读取灰度图第 y 行、第 x 列的像素值,范围 0 到 255uchar value=gray.at<uchar>(y,x);赋值也一样:
// 把二值图第 y 行、第 x 列的像素设为 255,也就是白色binary.at<uchar>(y,x)=255;注意顺序是(y, x),先写行,再写列,不要写成(x, y)。
六、直方图
直方图的作用:统计每个亮度值各有多少个像素。
例子:一张只有 4 个像素的灰度图:
50 50 200 200直方图是:
hist[50] = 2 hist[200] = 2 其他 hist 值 = 0统计代码:
// 直方图数组,下标是亮度值,范围 0 到 255inthist[256]={0};// 外层循环遍历每一行for(inty=0;y<gray.rows;y++){// 内层循环遍历每一列for(intx=0;x<gray.cols;x++){// 取出 (y, x) 位置的亮度值,并让对应的计数加 1hist[gray.at<uchar>(y,x)]++;}}代码解释:
gray.rows和gray.cols分别是行数和列数- 两层循环遍历整张图
- 拿到像素值后,让对应的
hist格子加 1
验证直方图是否正确:
// 统计直方图所有次数之和,用来验证是否正确inttotal=0;// 遍历所有亮度值 0 到 255for(inti=0;i<256;i++){// 累加每个亮度值的像素个数total+=hist[i];}total必须等于gray.rows * gray.cols,也就是总像素数。
七、二值化
二值化的作用:把灰度图变成只有黑和白两种值的图。
需要先定一个阈值。
例子:一行像素
10 40 80 150 220阈值取 100:
- 10、40、80 都小于等于 100,变黑,填 0
- 150、220 都大于 100,变白,填 255
结果:
0 0 0 255 255代码:
// 复制一份灰度图作为二值图,避免修改原图cv::Mat binary=gray.clone();// 外层循环遍历每一行for(inty=0;y<gray.rows;y++){// 内层循环遍历每一列for(intx=0;x<gray.cols;x++){// 亮度大于阈值时变成白色if(gray.at<uchar>(y,x)>threshold){binary.at<uchar>(y,x)=255;}else{// 亮度小于等于阈值时变成黑色binary.at<uchar>(y,x)=0;}}}这里第一次出现gray.clone()。它的作用:复制一份新的图像,内容和原图一样,但内存独立。如果不复制,直接操作原图,原灰度图会被覆盖。
约定很重要:
- 亮度大于阈值,变白
- 亮度小于等于阈值,变黑
OpenCV 的cv::threshold(..., cv::THRESH_BINARY)也是这个规则,方便我们验证。
八、大津法:自动找阈值
手动调阈值很麻烦,大津法可以自动找。
大津法的核心思想:找一个阈值,让暗组和亮组的平均亮度差得最远。
例子:8 个像素
0 0 0 50 150 200 200 255阈值取 100:
- 暗组:0、0、0、50
- 亮组:150、200、200、255
计算:
- 暗组人数:4
- 亮组人数:4
- 暗组平均亮度:12.5
- 亮组平均亮度:201.25
- 类间方差:4 x 4 x (201.25 - 12.5) 的平方
大津法会遍历 0 到 255,找到类间方差最大的阈值。
手写实现:
// 手写大津法:输入灰度图,返回自动算出的阈值intotsuThreshold(cv::Mat gray){// hist[i] 表示亮度为 i 的像素个数inthist[256]={0};// 遍历整张图,统计直方图for(inty=0;y<gray.rows;y++){for(intx=0;x<gray.cols;x++){hist[gray.at<uchar>(y,x)]++;}}// 总像素数inttotal=gray.rows*gray.cols;// 记录当前最大的类间方差doublemaxVariance=0;// 记录最大类间方差对应的阈值intbestThreshold=0;// 遍历所有候选阈值 0 到 255for(intt=0;t<256;t++){// 暗组人数、亮组人数longdarkCount=0;longbrightCount=0;// 暗组总亮度、亮组总亮度longdarkSum=0;longbrightSum=0;// 暗组:亮度小于等于 t 的像素for(intv=0;v<=t;v++){darkCount+=hist[v];darkSum+=hist[v]*v;}// 亮组:亮度大于 t 的像素for(intv=t+1;v<256;v++){brightCount+=hist[v];brightSum+=hist[v]*v;}// 某一组没有像素时,这个阈值没有意义if(darkCount==0||brightCount==0){continue;}// 暗组平均亮度doubledarkMean=1.0*darkSum/darkCount;// 亮组平均亮度doublebrightMean=1.0*brightSum/brightCount;// 两组平均亮度差doublediff=brightMean-darkMean;// 类间方差 = 暗组人数 x 亮组人数 x 平均亮度差的平方doublevariance=(double)darkCount*brightCount*diff*diff;// 如果当前方差更大,就更新记录if(variance>maxVariance){maxVariance=variance;bestThreshold=t;}}// 返回让类间方差最大的阈值returnbestThreshold;}代码里几个关键点:
- 循环从 0 到 255,必须包含 255
- 暗组用
<= t,亮组用> t - 人数为 0 时跳过,避免除零
variance用double,乘法前先转double,防止整数溢出
九、验证大津法:cv::threshold
cv::threshold的作用:对图像做阈值操作,可以二值化,也可以自动算大津法。
参数:
- 第一个参数:输入图像
- 第二个参数:输出图像
- 第三个参数:阈值,使用大津法时传 0
- 第四个参数:最大值,满足条件时填的值,通常 255
- 第五个参数:类型
cv::THRESH_BINARY:亮度大于阈值变白,否则变黑cv::THRESH_OTSU:自动使用大津法求阈值
返回值:实际使用的阈值。使用THRESH_OTSU时,返回的就是大津法算出的阈值。
用法:
// 创建输出二值图cv::Mat opencvBinary;// 调用 OpenCV 大津法:输入图、输出图、阈值占位、最大值、类型// THRESH_BINARY | THRESH_OTSU 表示自动算阈值并按阈值二值化doubleopencvThreshold=cv::threshold(gray,opencvBinary,0,255,cv::THRESH_BINARY|cv::THRESH_OTSU);对比手写结果:
// 调用自己写的大津法,得到阈值intmyThreshold=otsuThreshold(gray);// 和自己写的结果比较if(myThreshold==(int)opencvThreshold){std::cout<<"大津法正确"<<std::endl;}还可以用cv::countNonZero对比二值图。
cv::countNonZero的作用:数出一张图里有多少个非零像素。
参数:一个cv::Mat。
返回值:非零像素个数,类型是int。
用法:
// 数手写二值图里的白色像素数量intmyWhiteCount=cv::countNonZero(binary);// 数 OpenCV 二值图里的白色像素数量intopencvWhiteCount=cv::countNonZero(opencvBinary);两个数字一致,说明手写二值化也正确。
十、ROI 截取
ROI 的全称是 Region of Interest,感兴趣区域。
作用:从大图里切出一小块,只处理这一块。
例子:一张 640 x 480 的图,要切左上角(100, 80)、宽 300、高 200 的区域。
这个区域的右下角是:
(100 + 300 - 1, 80 + 200 - 1) = (399, 279)代码:
// 左上角横坐标intx=100;// 左上角纵坐标inty=80;// 截取宽度intw=300;// 截取高度inth=200;// 从 gray 中取出 (x, y, w, h) 矩形区域,clone 表示复制一份cv::Mat roi=gray(cv::Rect(x,y,w,h)).clone();// 保存 ROI 结果cv::imwrite("roi.png",roi);这里第一次出现cv::Rect。
cv::Rect的作用:表示一个矩形区域。
参数:
- 第一个参数:左上角 x
- 第二个参数:左上角 y
- 第三个参数:宽度
- 第四个参数:高度
gray(cv::Rect(...))的作用:从gray里取出这个矩形区域的图像。
后面的.clone()会复制一份,避免和原图共享内存。
切之前要检查边界:
// 检查区域是否在图片范围内,避免越界if(x+w<=gray.cols&&y+h<=gray.rows){// 合法时才截取并复制roi=gray(cv::Rect(x,y,w,h)).clone();}十一、图像旋转
旋转的作用:绕一个中心点,把图像转一个角度。
OpenCV 需要两个函数配合。
cv::Point2f
cv::Point2f的作用:表示一个二维点,坐标是浮点数。
参数:两个数,第一个是 x,第二个是 y。
用法:
// 创建旋转中心点:x 取宽度一半,y 取高度一半,得到图像中心cv::Point2fcenter(gray.cols/2.0f,gray.rows/2.0f);这里gray.cols / 2.0f得到宽度的一半,gray.rows / 2.0f得到高度的一半,也就是图像中心。
cv::getRotationMatrix2D
cv::getRotationMatrix2D的作用:生成一个旋转矩阵。
参数:
- 第一个参数:旋转中心,类型是
cv::Point2f - 第二个参数:旋转角度,单位是度
- 第三个参数:缩放比例,1 表示不缩放
返回值:一个cv::Mat,保存旋转矩阵。
用法:
// 生成旋转矩阵:绕 center 旋转 15 度,缩放比例 1cv::Mat M=cv::getRotationMatrix2D(center,15.0,1.0);例子:绕中心旋转 15 度,不缩放。
cv::warpAffine
cv::warpAffine的作用:用旋转矩阵把图像映射到新位置。
参数:
- 第一个参数:输入图像
- 第二个参数:输出图像
- 第三个参数:变换矩阵 M
- 第四个参数:输出图像大小
- 后面还有可选参数,例如插值方式,新手可以先用默认值
用法:
// 创建输出旋转图cv::Mat rotated;// 用矩阵 M 把 gray 映射到 rotated,输出大小和原图一样cv::warpAffine(gray,rotated,M,gray.size());// 保存旋转结果cv::imwrite("rotated.png",rotated);gray.size()返回一个cv::Size,表示宽度和高度,这里让输出图和原图一样大。
十二、池化
池化的作用:把图像分成很多小方块,每个方块只保留一个数,图像变小。
例子:4 x 4 的图,池化块大小是 2 x 2。
10 20 | 30 40 50 60 | 70 80 ---------------- 90 100 | 110 120 130 140| 150 160左上块的值是 10、20、50、60:
- 平均池化:
(10 + 20 + 50 + 60) / 4 = 35 - 最大池化:
max(10, 20, 50, 60) = 60
右上块的值是 30、40、70、80:
- 平均池化:55
- 最大池化:80
平均池化结果:
35 55 115 135最大池化结果:
60 80 140 160代码:
// 池化块大小:8 x 8intpoolSize=8;// 输出图宽度:原图宽度除以块大小intoutW=gray.cols/poolSize;// 输出图高度:原图高度除以块大小intoutH=gray.rows/poolSize;// 创建平均池化结果图:第一个参数是行数,第二个参数是列数cv::MataveragePool(outH,outW,CV_8UC1);// 创建最大池化结果图cv::MatmaxPool(outH,outW,CV_8UC1);这里第一次出现cv::Mat构造函数。
cv::Mat averagePool(outH, outW, CV_8UC1)的作用:创建一张空图。
参数:
- 第一个参数:行数
- 第二个参数:列数
- 第三个参数:类型,
CV_8UC1表示 8 位无符号单通道,也就是灰度图
注意顺序是“行在前、列在后”,不要写反。
池化主循环:
// 遍历输出图的每一行for(intoy=0;oy<outH;oy++){// 遍历输出图的每一列for(intox=0;ox<outW;ox++){// 记录当前方块所有像素的总和intsum=0;// 记录当前方块的最大值uchar maxValue=0;// 遍历方块内部的每一行for(intdy=0;dy<poolSize;dy++){// 遍历方块内部的每一列for(intdx=0;dx<poolSize;dx++){// 读取原图对应像素uchar value=gray.at<uchar>(oy*poolSize+dy,ox*poolSize+dx);// 累加总和sum+=value;// 更新最大值if(value>maxValue){maxValue=value;}}}// 平均池化:总和除以块内像素个数averagePool.at<uchar>(oy,ox)=sum/(poolSize*poolSize);// 最大池化:取块内最大值maxPool.at<uchar>(oy,ox)=maxValue;}}// 保存平均池化图cv::imwrite("average_pool.png",averagePool);// 保存最大池化图cv::imwrite("max_pool.png",maxPool);坐标对应关系:
- 输出图第
(oy, ox)个像素 - 对应输入图从第
oy * poolSize行、第ox * poolSize列开始的小方块 - 方块内部坐标是
dy和dx
十三、进阶:动态 ROI
静态 ROI 是固定切一块。动态 ROI 让搜索区域跟着上一帧或上一行的结果移动。
例子:上一行边线在 x = 200,窗口是 ±5。
这一行只搜索:
200 - 5 = 195 200 + 5 = 205也就是 195 到 205,共 11 列。
如果这一行在 x = 202 找到边线,下一行就搜索 197 到 207。
注意事项:
- 第一行或第一帧要先全图扫描初始化
- 窗口内找不到时,先扩大窗口
- 还找不到就把该行标记为无效
- 连续失效时要重新全图扫描
- 窗口太小会跟不上急弯,窗口太大又退化成全图扫描
十四、一份完整作业的交付清单
建议输出这些效果图:
- 灰度图
- 手写大津法二值图
- OpenCV 大津法二值图
- ROI 截取图
- 旋转图
- 平均池化图
- 最大池化图
再把代码整理成清晰的函数,每个函数只做一件事。
实验室下一步学什么
- 逆透视变换:把斜着拍的赛道转成鸟瞰图
- 引导值计算:算车偏了多远
- 信号滤波:让误差曲线更平滑
- 道路状态机:判断直道、入弯、弯中、出弯
- 斑马线检测:识别需要停车的位置
- 边线补线:反光和断线时补全边线