# (实验室招新版)大津法与 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 921600OpenCV 用cv::Mat存图像常用属性rows行数也就是高度cols列数也就是宽度channels()通道数灰度图是 1彩色图是 3二、读取图片cv::imreadcv::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 imagecv::imread(test.png);读取后一定要检查是否成功// 判断图片是否读取成功if(image.empty()){// empty() 为真说明图片没读进来常见原因是路径写错或文件不存在std::cout图片读取失败std::endl;// 返回 -1 表示程序异常结束return-1;}image.empty()的作用判断图像是否为空为空说明路径写错或文件不存在。三、转灰度图cv::cvtColorcv::cvtColor的作用把图像从一种颜色空间转换成另一种。参数第一个参数输入图像第二个参数输出图像第三个参数转换方式这里用cv::COLOR_BGR2GRAYcv::COLOR_BGR2GRAY的含义把 BGR 彩色图转成单通道灰度图。用法// 先创建一张空的灰度图cv::Mat gray;// 把彩色图 image 转成灰度图结果存到 graycv::cvtColor(image,gray,cv::COLOR_BGR2GRAY);四、保存图片cv::imwritecv::imwrite的作用把cv::Mat保存成图片文件。参数第一个参数保存路径例如gray.png第二个参数要保存的图像用法// 把 gray 保存成 gray.png保存成功返回 truecv::imwrite(gray.png,gray);返回值成功返回true失败返回false。五、读取像素cv::Mat::atgray.atuchar(y, x)的作用读取灰度图第 y 行、第 x 列的像素。参数尖括号里的uchar像素类型表示 8 位无符号数范围 0 到 255括号里第一个数行号 y括号里第二个数列号 x用法// 读取灰度图第 y 行、第 x 列的像素值范围 0 到 255uchar valuegray.atuchar(y,x);赋值也一样// 把二值图第 y 行、第 x 列的像素设为 255也就是白色binary.atuchar(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(inty0;ygray.rows;y){// 内层循环遍历每一列for(intx0;xgray.cols;x){// 取出 (y, x) 位置的亮度值并让对应的计数加 1hist[gray.atuchar(y,x)];}}代码解释gray.rows和gray.cols分别是行数和列数两层循环遍历整张图拿到像素值后让对应的hist格子加 1验证直方图是否正确// 统计直方图所有次数之和用来验证是否正确inttotal0;// 遍历所有亮度值 0 到 255for(inti0;i256;i){// 累加每个亮度值的像素个数totalhist[i];}total必须等于gray.rows * gray.cols也就是总像素数。七、二值化二值化的作用把灰度图变成只有黑和白两种值的图。需要先定一个阈值。例子一行像素10 40 80 150 220阈值取 10010、40、80 都小于等于 100变黑填 0150、220 都大于 100变白填 255结果0 0 0 255 255代码// 复制一份灰度图作为二值图避免修改原图cv::Mat binarygray.clone();// 外层循环遍历每一行for(inty0;ygray.rows;y){// 内层循环遍历每一列for(intx0;xgray.cols;x){// 亮度大于阈值时变成白色if(gray.atuchar(y,x)threshold){binary.atuchar(y,x)255;}else{// 亮度小于等于阈值时变成黑色binary.atuchar(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(inty0;ygray.rows;y){for(intx0;xgray.cols;x){hist[gray.atuchar(y,x)];}}// 总像素数inttotalgray.rows*gray.cols;// 记录当前最大的类间方差doublemaxVariance0;// 记录最大类间方差对应的阈值intbestThreshold0;// 遍历所有候选阈值 0 到 255for(intt0;t256;t){// 暗组人数、亮组人数longdarkCount0;longbrightCount0;// 暗组总亮度、亮组总亮度longdarkSum0;longbrightSum0;// 暗组亮度小于等于 t 的像素for(intv0;vt;v){darkCounthist[v];darkSumhist[v]*v;}// 亮组亮度大于 t 的像素for(intvt1;v256;v){brightCounthist[v];brightSumhist[v]*v;}// 某一组没有像素时这个阈值没有意义if(darkCount0||brightCount0){continue;}// 暗组平均亮度doubledarkMean1.0*darkSum/darkCount;// 亮组平均亮度doublebrightMean1.0*brightSum/brightCount;// 两组平均亮度差doublediffbrightMean-darkMean;// 类间方差 暗组人数 x 亮组人数 x 平均亮度差的平方doublevariance(double)darkCount*brightCount*diff*diff;// 如果当前方差更大就更新记录if(variancemaxVariance){maxVariancevariance;bestThresholdt;}}// 返回让类间方差最大的阈值returnbestThreshold;}代码里几个关键点循环从 0 到 255必须包含 255暗组用 t亮组用 t人数为 0 时跳过避免除零variance用double乘法前先转double防止整数溢出九、验证大津法cv::thresholdcv::threshold的作用对图像做阈值操作可以二值化也可以自动算大津法。参数第一个参数输入图像第二个参数输出图像第三个参数阈值使用大津法时传 0第四个参数最大值满足条件时填的值通常 255第五个参数类型cv::THRESH_BINARY亮度大于阈值变白否则变黑cv::THRESH_OTSU自动使用大津法求阈值返回值实际使用的阈值。使用THRESH_OTSU时返回的就是大津法算出的阈值。用法// 创建输出二值图cv::Mat opencvBinary;// 调用 OpenCV 大津法输入图、输出图、阈值占位、最大值、类型// THRESH_BINARY | THRESH_OTSU 表示自动算阈值并按阈值二值化doubleopencvThresholdcv::threshold(gray,opencvBinary,0,255,cv::THRESH_BINARY|cv::THRESH_OTSU);对比手写结果// 调用自己写的大津法得到阈值intmyThresholdotsuThreshold(gray);// 和自己写的结果比较if(myThreshold(int)opencvThreshold){std::cout大津法正确std::endl;}还可以用cv::countNonZero对比二值图。cv::countNonZero的作用数出一张图里有多少个非零像素。参数一个cv::Mat。返回值非零像素个数类型是int。用法// 数手写二值图里的白色像素数量intmyWhiteCountcv::countNonZero(binary);// 数 OpenCV 二值图里的白色像素数量intopencvWhiteCountcv::countNonZero(opencvBinary);两个数字一致说明手写二值化也正确。十、ROI 截取ROI 的全称是 Region of Interest感兴趣区域。作用从大图里切出一小块只处理这一块。例子一张 640 x 480 的图要切左上角(100, 80)、宽 300、高 200 的区域。这个区域的右下角是(100 300 - 1, 80 200 - 1) (399, 279)代码// 左上角横坐标intx100;// 左上角纵坐标inty80;// 截取宽度intw300;// 截取高度inth200;// 从 gray 中取出 (x, y, w, h) 矩形区域clone 表示复制一份cv::Mat roigray(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(xwgray.colsyhgray.rows){// 合法时才截取并复制roigray(cv::Rect(x,y,w,h)).clone();}十一、图像旋转旋转的作用绕一个中心点把图像转一个角度。OpenCV 需要两个函数配合。cv::Point2fcv::Point2f的作用表示一个二维点坐标是浮点数。参数两个数第一个是 x第二个是 y。用法// 创建旋转中心点x 取宽度一半y 取高度一半得到图像中心cv::Point2fcenter(gray.cols/2.0f,gray.rows/2.0f);这里gray.cols / 2.0f得到宽度的一半gray.rows / 2.0f得到高度的一半也就是图像中心。cv::getRotationMatrix2Dcv::getRotationMatrix2D的作用生成一个旋转矩阵。参数第一个参数旋转中心类型是cv::Point2f第二个参数旋转角度单位是度第三个参数缩放比例1 表示不缩放返回值一个cv::Mat保存旋转矩阵。用法// 生成旋转矩阵绕 center 旋转 15 度缩放比例 1cv::Mat Mcv::getRotationMatrix2D(center,15.0,1.0);例子绕中心旋转 15 度不缩放。cv::warpAffinecv::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 8intpoolSize8;// 输出图宽度原图宽度除以块大小intoutWgray.cols/poolSize;// 输出图高度原图高度除以块大小intoutHgray.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(intoy0;oyoutH;oy){// 遍历输出图的每一列for(intox0;oxoutW;ox){// 记录当前方块所有像素的总和intsum0;// 记录当前方块的最大值uchar maxValue0;// 遍历方块内部的每一行for(intdy0;dypoolSize;dy){// 遍历方块内部的每一列for(intdx0;dxpoolSize;dx){// 读取原图对应像素uchar valuegray.atuchar(oy*poolSizedy,ox*poolSizedx);// 累加总和sumvalue;// 更新最大值if(valuemaxValue){maxValuevalue;}}}// 平均池化总和除以块内像素个数averagePool.atuchar(oy,ox)sum/(poolSize*poolSize);// 最大池化取块内最大值maxPool.atuchar(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 截取图旋转图平均池化图最大池化图再把代码整理成清晰的函数每个函数只做一件事。实验室下一步学什么逆透视变换把斜着拍的赛道转成鸟瞰图引导值计算算车偏了多远信号滤波让误差曲线更平滑道路状态机判断直道、入弯、弯中、出弯斑马线检测识别需要停车的位置边线补线反光和断线时补全边线