简介:这份PDF文档面向Java开发者与计算机视觉初学者,系统讲解如何用Java调用OpenCV视觉库完成几何图像的颜色与形状识别,可应用于交通信号灯、信号转向灯等需要颜色与形状判别的场景。资源包内仅含1个PDF文件,大小约894KB,内容为完整的技术论文,涵盖HSV颜色空间转换、颜色分离、滤波去噪、灰度化、二值化、轮廓提取、干扰轮廓剔除、轮廓形状拟合及凸点距离判断等关键环节,并给出OpenCV Java包的导入方法与核心函数调用示例。目前已有169人学习下载。读者可从中掌握从BGR到HSV的颜色分割思路、各颜色H/S/V取值范围划分、三角形、圆、矩形、正方形、多边形的识别流程,以及用凸点个数与距离判别形状的实用技巧,适合作为图像处理课程设计、毕业设计或视觉入门项目的参考方案。
1. 几何识别:为什么用 HSV 比 RGB 更稳
很多人第一次做颜色识别,直觉都是直接拿 RGB 三个通道去卡阈值。结果代码跑起来才发现,同一张红色三角形的图,换个光照条件,R 值就从 200 掉到 130,阈值怎么调都不对。这不是代码写错了,而是 RGB 颜色空间本身就不适合做颜色分割——它把「颜色是什么」和「亮度有多高」耦合在了一起。
这份基于 Java + OpenCV 的几何图像识别方案,核心思路就是先把图像从 BGR 转到 HSV 颜色空间,用 H、S、V 三个分量的取值范围来分割颜色,再对分割后的图像做滤波、灰度化、二值化、轮廓提取、形状拟合,最后通过凸点个数和相邻凸点距离判断形状。整套流程能识别三角形、圆、矩形、正方形、多边形等几何图形,识别准确率在 99% 以上。它解决的不是「怎么调 OpenCV 的 API」,而是「为什么这样组合能在光照变化下依然稳定」——这正是搞懂底层机制的价值所在。
2. HSV 颜色空间:为什么 H 分量能扛住光照变化
2.1 RGB 的致命缺陷:颜色和亮度绑死
RGB 颜色空间用红、绿、蓝三个分量叠加来表示颜色。问题在于,这三个分量同时承载了「色相」和「明暗」两种信息。一个纯红色像素可能是 (255, 0, 0),但一个被阴影遮住的红色像素可能变成 (128, 0, 0)。如果你用 R > 200 来判断红色,阴影里的红色就被漏掉了;如果你把阈值降到 R > 100,那橙色、粉色甚至某些灰色都可能被误判成红色。
这就是 RGB 做颜色分割时最典型的翻车场景:阈值调高了漏检,调低了误检,怎么都找不到一个「万能阈值」。根本原因不是阈值选得不好,而是 RGB 这个坐标系本身就不适合做颜色分类。
2.2 HSV 的解耦设计:把「什么颜色」和「多亮」拆开
HSV 颜色空间换了一套坐标系。H(Hue)表示色相,也就是「这是什么颜色」,取值范围 0 到 180(OpenCV 中为了用 8 位存储,把标准的 0 到 360 压缩了一半);S(Saturation)表示饱和度,也就是「颜色有多纯」;V(Value)表示明度,也就是「颜色有多亮」。
关键设计在于:H 分量和 V 分量是解耦的。一个红色物体,不管它是在阳光下还是阴影里,它的 H 值都稳定地落在红色区间内。光照变化主要影响的是 V 分量,而不是 H 分量。这意味着你可以用一个固定的 H 区间来分割红色,而不用管光照怎么变。
常见的实现思路是:先确定目标颜色的 H 范围,再用一个较宽的 S 和 V 范围来包容光照变化。比如绿色的 H 范围大约是 35 到 77,S 和 V 的下限可以放到 43 和 46,上限直接拉到 255。这样即使绿色物体处于暗光环境,只要它的色相没变,就依然能被正确分割出来。
2.3 OpenCV 中的转换链路:为什么是 RGB → BGR → HSV
OpenCV 默认处理的图像是 BGR 顺序,而不是 RGB。这不是 OpenCV 故意标新立异,而是历史原因——早期很多摄像头和图像库的像素排列就是 BGR。所以当你从 Java 的 BufferedImage 拿到 RGB 数据后,不能直接转 HSV,必须先转成 BGR,再从 BGR 转 HSV。
转换代码长这样:
// 第一步:RGB 转 BGR,修正通道顺序 Imgproc.cvtColor(imageResized, bgrImg, Imgproc.COLOR_RGB2BGR); // 第二步:BGR 转 HSV,进入颜色分割友好的坐标系 Imgproc.cvtColor(bgrImg, hsvImg, Imgproc.COLOR_BGR2HSV);这里有个容易踩的坑:如果你跳过了 RGB → BGR 这一步,直接把 RGB 图像当 BGR 转 HSV,得到的 H 值会整体偏移。红色可能跑到蓝色区间去,绿色可能跑到黄色区间去。现象就是「明明图里是红色,程序却识别成蓝色」,排查半天以为是阈值写错了,其实是通道顺序没对齐。
注意:OpenCV 的 H 范围是 0 到 180,不是 0 到 360。如果你从其他资料查到红色的 H 范围是 0 到 10 和 340 到 360,在 OpenCV 里要除以 2,变成 0 到 5 和 170 到 180。
3. 颜色分割:从像素遍历到 inRange 掩膜
3.1 缩小图像:为什么先 resize 再处理
在遍历像素点之前,方案里先做了一步Imgproc.resize。这不是可有可无的优化,而是直接影响处理速度的关键步骤。一张 1920×1080 的图有超过 200 万个像素点,如果每个像素都要取 H、S、V 三个分量做判断,光是遍历就要消耗大量时间。缩小到 640×480 后,像素数降到约 30 万,处理速度能提升好几倍。
// 将图像缩小到指定尺寸,dsize 是目标宽高 Imgproc.resize(src, dst, new Size(640, 480));参数dsize的选择有个经验值:宽度控制在 640 左右通常够用。太小会丢失轮廓细节,导致形状拟合不准;太大则处理速度下降明显。如果你的几何图形本身很小,比如只占图像的一小块区域,那缩小后可能只剩几十个像素,轮廓提取会失败。这时候要么不缩小,要么先裁剪出感兴趣区域再处理。
3.2 像素遍历与阈值计数:判断「有没有这种颜色」
缩小图像后,方案遍历 HSV 图像的每个像素点,取出 H、S、V 三个分量,用预设的颜色范围判断该像素属于哪种颜色,并累计每种颜色的像素个数。
// 遍历 HSV 图像的每个像素点 for (int i = 0; i < hsvImg.rows(); i++) { for (int j = 0; j < hsvImg.cols(); j++) { // 获取当前像素的 H、S、V 值 double[] clone = hsvImg.get(i, j).clone(); int h = (int) clone[0]; // 色相 int s = (int) clone[1]; // 饱和度 int v = (int) clone[2]; // 明度 // 判断是否落在绿色区间 if (h >= 35 && h <= 77 && s >= 43 && s <= 255 && v >= 46 && v <= 255) { greenCount++; } // 其他颜色的判断逻辑类似 } }这段代码的逻辑很直白,但有一个设计决策值得注意:为什么要先计数再分割?直接对全图做Core.inRange不行吗?
原因是这样的:Core.inRange会对全图每个像素做判断,生成一个掩膜。但如果图像里根本没有某种颜色,这个掩膜就是全黑的,后续的轮廓提取、形状拟合全是无用功。先计数再设阈值(方案里设的是 200),相当于一个快速筛选:像素数不够 200 的,直接跳过,不进入后续处理流程。这既节省了计算资源,也避免了噪声像素被误判成几何图形。
3.3 inRange 与 bitwise_and:分割出目标颜色区域
确认图像中存在某种颜色后,用Core.inRange生成掩膜,再用Core.bitwise_and把原图中该颜色的区域提取出来。
// 定义绿色的 HSV 范围 Scalar minVal = new Scalar(35, 43, 46); // 绿色下限 Scalar maxVal = new Scalar(77, 255, 255); // 绿色上限 // 生成掩膜:落在范围内的像素为白色,其余为黑色 Mat mask = new Mat(); Core.inRange(hsvImg, minVal, maxVal, mask); // 用掩膜从原图中提取绿色区域 Mat greenRegion = new Mat(); Core.bitwise_and(imageResized, imageResized, greenRegion, mask);inRange的作用是生成一个二值掩膜:H、S、V 三个分量都落在[minVal, maxVal]范围内的像素,掩膜对应位置为 255(白色),否则为 0(黑色)。bitwise_and则是把原图和掩膜做按位与运算,掩膜为白色的区域保留原图像素,掩膜为黑色的区域变成纯黑。最终得到的就是只包含目标颜色的分割图像。
这里有个细节:bitwise_and的第三个参数是输出图像,第四个参数是掩膜。如果你把掩膜参数漏掉了,得到的就是原图的副本,而不是分割结果。这个错误很隐蔽,因为代码不会报错,只是结果不对。
3.4 颜色范围表:七种颜色的 H、S、V 取值
方案中覆盖了七种常见颜色。每种颜色的 H 范围是根据色相环划分的,S 和 V 的下限则用来排除灰白和过暗的像素。
| 颜色 | H 最小值 | H 最大值 | S 最小值 | V 最小值 |
|---|---|---|---|---|
| 红色 | 0 | 10 | 43 | 46 |
| 红色(环绕) | 170 | 180 | 43 | 46 |
| 橙色 | 11 | 25 | 43 | 46 |
| 黄色 | 26 | 34 | 43 | 46 |
| 绿色 | 35 | 77 | 43 | 46 |
| 青色 | 78 | 99 | 43 | 46 |
| 蓝色 | 100 | 124 | 43 | 46 |
| 紫色 | 125 | 155 | 43 | 46 |
红色的 H 范围比较特殊,因为它跨越了 0 和 180 的边界。在 OpenCV 中,红色的 H 值可能落在 0 到 10 之间,也可能落在 170 到 180 之间。处理时需要做两次inRange,然后把两个掩膜合并。如果只做一次,就会漏掉一部分红色像素。
注意:S 和 V 的下限设为 43 和 46 是为了排除接近灰白或过暗的像素。如果你处理的图像光照条件特殊,比如整体偏暗,可以适当降低 V 的下限,但不要低于 30,否则噪声会明显增多。
4. 形状识别:从轮廓提取到凸点距离判断
4.1 通道转换:为什么分割后的图像不能直接灰度化
颜色分割得到的greenRegion是一个单通道的掩膜图像,像素值只有 0 和 255。这种图像不能直接做灰度化,因为灰度化函数cvtColor期望输入是三通道图像。如果强行调用,OpenCV 会抛出异常。
解决方案是用convertTo把单通道转成三通道:
// 将单通道掩膜转为三通道图像 greenRegion.convertTo(greenRegion, CvType.CV_8UC3);CV_8UC3表示 8 位无符号三通道。转换后,每个像素的 RGB 三个通道值相同,图像看起来还是黑白的,但数据结构上已经满足后续处理的要求。
4.2 滤波、灰度化、二值化:三步预处理
转成三通道后,依次做高斯滤波、灰度化、二值化。
高斯滤波用Imgproc.GaussianBlur,目的是去除光线和反光带来的噪点。核大小一般选 5×5 或 7×7,太小去噪效果不明显,太大则会把轮廓边缘也模糊掉。
// 高斯滤波去噪,核大小 5×5 Imgproc.GaussianBlur(src, dst, new Size(5, 5), 0);灰度化用Imgproc.cvtColor配合COLOR_BGR2GRAY,把三通道图像转成单通道灰度图。二值化用Imgproc.threshold,把灰度图转成只有 0 和 255 的二值图。
// 灰度化 Imgproc.cvtColor(src, grayImg, Imgproc.COLOR_BGR2GRAY); // 二值化,阈值设为 127,最大值设为 255 Imgproc.threshold(grayImg, binaryImg, 127, 255, Imgproc.THRESH_BINARY);二值化的阈值选择有个经验:如果分割后的图像边缘比较干净,127 通常够用;如果边缘有较多过渡像素,可以用 Otsu 算法自动计算阈值,把THRESH_BINARY改成THRESH_BINARY | THRESH_OTSU。
4.3 腐蚀与膨胀:去掉零散像素,连通断裂轮廓
二值化后的图像可能有两种问题:一是存在零散的噪点像素,二是轮廓有断裂。腐蚀和膨胀就是用来解决这两个问题的。
腐蚀用Imgproc.erode,效果是让白色区域缩小,零散的白色像素点会被直接腐蚀掉。膨胀用Imgproc.dilate,效果是让白色区域扩大,断裂的轮廓会被重新连通。
// 腐蚀:去掉零散像素点 Imgproc.erode(binaryImg, erodedImg, kernel); // 膨胀:连通断裂的轮廓 Imgproc.dilate(erodedImg, dilatedImg, kernel);kernel是结构元素,通常用Imgproc.getStructuringElement生成一个矩形或椭圆形的核。核的大小决定了腐蚀和膨胀的强度,一般选 3×3 或 5×5。
顺序很重要:先腐蚀后膨胀。如果反过来,先膨胀会把噪点也放大,再腐蚀就回不去了。这个顺序在形态学操作里是固定的,叫「开运算」。
4.4 findContours 与面积过滤:去掉干扰轮廓
预处理完成后,用Imgproc.findContours提取轮廓。
// 提取轮廓 List<MatOfPoint> contours = new ArrayList<>(); Mat hierarchy = new Mat(); Imgproc.findContours(dilatedImg, contours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE);RETR_EXTERNAL表示只提取最外层轮廓,忽略嵌套轮廓。CHAIN_APPROX_SIMPLE表示只保留轮廓的拐点,压缩掉直线段上的中间点。
提取到的轮廓里可能包含干扰项,比如面积过小的噪点轮廓、面积过大的背景轮廓。方案里用面积过滤来去除:
// 遍历轮廓,去除面积过小或过大的干扰项 for (int j = 0; j < contours.size(); j++) { double area = Imgproc.contourArea(contours.get(j)); if (area <= 10 || area >= 80000) { contours.remove(contours.get(j)); } }面积下限 10 用来过滤噪点,上限 80000 用来过滤背景。这两个值需要根据实际图像尺寸调整。如果图像缩小到了 640×480,80000 大约是整图面积的 26%,对于单个几何图形来说足够大了。
注意:在遍历过程中直接
remove会导致索引错位,后面的元素会被跳过。正确的做法是用迭代器或者倒序遍历。这个坑在 Java 集合操作里很常见,但在图像处理代码里容易被忽略,因为轮廓数量通常不多,偶尔漏掉一两个也不影响最终结果。
4.5 approxPolyDP 与凸点距离:判断形状的两种武器
拿到干净的轮廓后,用Imgproc.approxPolyDP做多边形拟合,把轮廓近似成由少量顶点组成的多边形。
// 多边形拟合,epsilon 控制拟合精度 MatOfPoint2f curve = new MatOfPoint2f(contour.toArray()); MatOfPoint2f approx = new MatOfPoint2f(); double epsilon = 0.02 * Imgproc.arcLength(curve, true); Imgproc.approxPolyDP(curve, approx, epsilon, true); // 获取拟合后的顶点数 int vertexCount = (int) approx.toArray().length;epsilon是拟合精度参数,表示原始轮廓和拟合多边形之间的最大距离。通常取轮廓周长的 2% 左右。epsilon越小,拟合越精确,顶点数越多;epsilon越大,拟合越粗糙,顶点数越少。
拿到顶点数后,形状判断的逻辑就很直接了:
- 顶点数 3:三角形
- 顶点数 4:需要进一步判断是矩形、正方形还是不规则四边形
- 顶点数 5:五边形
- 顶点数 6 及以上:多边形
- 顶点数很多且相邻顶点距离接近:圆
对于顶点数为 4 的情况,方案用相邻凸点距离来判断。计算四条边的长度,如果四条边长度接近,就是正方形;如果对边长度接近但邻边长度差异明显,就是矩形。
// 计算相邻顶点之间的距离 Point[] points = approx.toArray(); double[] distances = new double[points.length]; for (int k = 0; k < points.length; k++) { Point p1 = points[k]; Point p2 = points[(k + 1) % points.length]; distances[k] = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); } // 判断是否为正方形:四条边长度接近 boolean isSquare = true; for (int k = 1; k < distances.length; k++) { if (Math.abs(distances[k] - distances[0]) > 10) { isSquare = false; break; } }圆的判断稍微特殊一些。圆没有明显的顶点,approxPolyDP会拟合出很多个顶点。方案的做法是:如果顶点数超过某个阈值(比如 8 个),且相邻顶点距离都比较接近,就判定为圆。
5. 避坑与排查:颜色识别里那些让人抓狂的问题
5.1 红色被识别成蓝色:通道顺序没对齐
现象:图像里明明是红色三角形,程序却输出「蓝色」。
原因:从 Java 的 BufferedImage 拿到的是 RGB 数据,但 OpenCV 默认按 BGR 解析。如果跳过了 RGB → BGR 的转换,直接做 BGR → HSV,H 值会整体偏移。红色(H 接近 0)会被偏移到蓝色区间(H 接近 120)。
解决:在 BGR → HSV 之前,先做一次 RGB → BGR 转换。或者直接从 BufferedImage 中按 BGR 顺序读取像素。
5.2 轮廓提取为空:二值化阈值选错了
现象:findContours返回的轮廓列表是空的,或者只有一个覆盖全图的大轮廓。
原因:二值化阈值选得不对。如果阈值太高,目标区域全变成黑色;如果阈值太低,背景也变成白色,轮廓就变成了整张图的边界。
解决:先用Imgproc.threshold配合THRESH_OTSU自动计算阈值,观察二值化结果。如果自动阈值效果不好,再手动调整。也可以在二值化之前先做一次直方图均衡化,让灰度分布更均匀。
5.3 形状判断不稳定:epsilon 参数太敏感
现象:同一个三角形,有时识别成三角形,有时识别成多边形。
原因:approxPolyDP的epsilon参数太敏感。如果epsilon太小,拟合出的顶点数会偏多,三角形可能被拟合成四边形或五边形;如果epsilon太大,顶点数会偏少,多边形可能被拟合成三角形。
解决:epsilon取轮廓周长的 2% 到 4% 之间,根据实际图像调整。如果形状判断仍然不稳定,可以在拟合之前先对轮廓做一次平滑处理,或者用凸包Imgproc.convexHull来减少凹点干扰。
5.4 噪点被误判成图形:面积过滤没做好
现象:图像里只有一个三角形,但程序输出了多个形状。
原因:二值化后残留了零散噪点,这些噪点的轮廓面积很小,但如果没有做面积过滤,就会被当成独立的图形。
解决:在findContours之后,遍历轮廓并计算面积,去掉面积小于阈值的轮廓。阈值可以根据图像尺寸设定,比如缩小到 640×480 后,面积小于 10 的轮廓基本可以确定是噪点。
5.5 处理速度慢:没有缩小图像
现象:程序运行一次要好几秒,甚至十几秒。
原因:直接对原始尺寸图像做像素遍历和形态学操作,像素数量太大。
解决:在处理之前先Imgproc.resize缩小图像。宽度控制在 640 左右通常够用。如果目标图形本身很小,可以先裁剪出感兴趣区域再处理,而不是对全图做缩小。
6. 设计取舍:为什么这套方案值得细看
这套方案最值得细看的地方,不是它用了多少 OpenCV 函数,而是它在几个关键节点上做了明确的设计取舍。
第一个取舍是颜色空间的选择。RGB 做颜色分割不是不行,但需要针对每种光照条件单独调阈值,换一个场景就要重新调一遍。HSV 把色相和亮度解耦后,H 区间可以在不同光照下复用,代价是需要多做一次颜色空间转换。这个转换的开销很小,但带来的稳定性提升很大。
第二个取舍是「先计数再分割」的流程设计。直接对全图做inRange也能得到掩膜,但方案选择先遍历像素计数,确认颜色存在后再做分割。这多了一次遍历,但避免了在无目标颜色时做无用功。对于需要处理大量图像的场景,这个设计能显著减少平均处理时间。
第三个取舍是形状判断用凸点距离而不是模板匹配。模板匹配需要预先准备每种形状的模板,对旋转和缩放敏感。凸点距离判断不需要模板,对旋转不敏感,但需要调epsilon参数。方案选择了后者,因为几何图形的顶点数本身就是最稳定的特征。
如果你要验证这套方案是否适合你的场景,可以按这个顺序做:先用一张光照均匀的图跑通全流程,确认颜色分割和形状判断都正确;然后换一张光照不均匀的图,观察 H 区间是否需要调整;最后用一批不同尺寸、不同旋转角度的图做批量测试,统计识别准确率。如果准确率下降明显,优先检查二值化阈值和epsilon参数,这两个是最容易出问题的地方。
我自己的习惯是:每次换一个新的图像来源,都先把 HSV 三个通道分别可视化出来看一眼。H 通道看色相分布,S 通道看颜色纯度,V 通道看光照均匀度。这一步花不了几分钟,但能提前发现大部分阈值问题,比在代码里反复试参数高效得多。从那以后我每次处理新的颜色识别任务,都强制走一遍这个可视化流程,踩坑次数明显少了。希望帮到你。
本文还有配套的精品资源,点击获取