ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

# (实验室招新版)大津法与 OpenCV 图像基础:新手完整教程(基于 C++ 实例)

2026/8/12 13:55:19 拓冰建站 浏览量
# (实验室招新版)大津法与 OpenCV 图像基础:新手完整教程(基于 C++ 实例)

(实验室招新版)大津法与 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:直接按灰度图读
    • 0cv::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.rowsgray.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 时跳过,避免除零
  • variancedouble,乘法前先转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列开始的小方块
  • 方块内部坐标是dydx

十三、进阶:动态 ROI

静态 ROI 是固定切一块。动态 ROI 让搜索区域跟着上一帧或上一行的结果移动。

例子:上一行边线在 x = 200,窗口是 ±5。

这一行只搜索:

200 - 5 = 195 200 + 5 = 205

也就是 195 到 205,共 11 列。

如果这一行在 x = 202 找到边线,下一行就搜索 197 到 207。

注意事项:

  • 第一行或第一帧要先全图扫描初始化
  • 窗口内找不到时,先扩大窗口
  • 还找不到就把该行标记为无效
  • 连续失效时要重新全图扫描
  • 窗口太小会跟不上急弯,窗口太大又退化成全图扫描

十四、一份完整作业的交付清单

建议输出这些效果图:

  • 灰度图
  • 手写大津法二值图
  • OpenCV 大津法二值图
  • ROI 截取图
  • 旋转图
  • 平均池化图
  • 最大池化图

再把代码整理成清晰的函数,每个函数只做一件事。

实验室下一步学什么

  • 逆透视变换:把斜着拍的赛道转成鸟瞰图
  • 引导值计算:算车偏了多远
  • 信号滤波:让误差曲线更平滑
  • 道路状态机:判断直道、入弯、弯中、出弯
  • 斑马线检测:识别需要停车的位置
  • 边线补线:反光和断线时补全边线