资讯详情

资讯详情

OpenCV色差提取实战:HSV阈值分割与光照鲁棒性调参

1. 先把色差提取这件事想明白整体设计与思路拆解色差提取说白了就是一句话把图像里颜色长得像某个参照的像素挑出来剩下的全当背景扔掉。在 OpenCV 里这件事的骨架非常短——转色彩空间、设阈值、inRange、形态学清一下、找轮廓。真正让人翻车的从来不是代码写不出来而是阈值设不对、光照一变全崩、噪声把轮廓撕成碎片。我在几个小项目里反复用这套方法做过色块定位、分拣预处理、特定颜色标记物追踪踩过的坑基本都集中在参数和光照两个字上。这个方法能干什么举几个我实际见过或做过的场景流水线上找特定颜色的零件、检测印刷品的色卡偏差、从杂乱背景里抠出某个颜色的包装盒、机器人视觉里追踪贴了彩色标记的物体、给后续的深度学习模型做半自动标注。它解决的核心问题是在颜色差异足够明显的前提下用极低的算力把目标区域框出来。适合谁来参考我觉得三类人最合适刚学 OpenCV 想做点能看见效果的东西的新手、需要快速搭一个视觉原型的开发者、以及被深度学习标注成本逼得想先试试传统方法的老手。下面我按为什么这么选—关键细节—完整实现—出问题怎么查—怎么上工程的顺序把它拆开讲代码都是能直接跑的。1.1 色差提取到底在解决什么问题边界在哪先说清楚色差这个词。在色彩学里它有严格定义指的是两个颜色在某个色彩空间里的距离常用 ΔE 表示但在工程口语里我们说的色差提取指的其实是按颜色差异做分割——给定一个或几个目标颜色把图像里跟它接近的像素选出来。理解了这一点你就明白它的能力边界完全由两个因素决定目标和背景的颜色差有多大以及光照有多稳定。这就带来一个很实用的判断标准。如果你拿到一张图目标和背景的色相差异肉眼一眼就能分辨比如黄色的零件放在灰色桌面上、红色的盖子在白色输送带上那这套方法又快又准几十行代码就能出结果单帧处理在普通笔记本上往往是毫秒级。反过来如果目标和背景颜色接近比如深灰和浅灰、或者目标表面有强反光、或者场景里有和它同色但不想抓的东西那硬阈值就会非常难受——调松了抓一堆噪声调紧了一直断。我的经验是动手写代码之前先花十分钟看数据。把要处理的图挑出十几张摆在同一个窗口里横向看重点看三件事——目标颜色的色相是否稳定、背景里有没有同色干扰物、光照是否均匀。这三件事的答案直接决定了你后面是走简单硬阈值还是颜色距离 形状过滤的复合路线。很多人一上来就写inRange结果现场跑两天就崩问题往往不是代码而是这一步没做。注意色差法不是万能的。它最怕的是同色异体和异色同体也就是背景里有和目标同色的东西以及目标自身颜色不均匀比如渐变、脏污、贴纸。这类情况要么换特征要么老老实实上学习方法。再补一个常被忽略的点色差提取输出的不该只是一张好看的掩膜而应该是结构化的结果——目标的数量、位置、外接框、面积、中心点。因为下游大概率要做计数、定位或者发给别的模块。所以从设计之初就应该把轮廓分析当成流程的正式一步而不是最后画个框就完事。1.2 为什么绕开 RGB直接进 HSV这是新手最常问的问题我直接对 B、G、R 三个通道各设一个上下限不就行了答案是可以但非常难调而且不稳。原因在于 RGB 三个通道是强耦合的——光照变亮三个通道一起涨光照变暗三个通道一起跌。你为某个亮度设的区间换个亮度就全废了。更麻烦的是一个纯红色的物体在 RGB 里可能是 (200, 30, 30)也可能因为反光变成 (255, 180, 170)——第二组数字看起来完全不像红色但它在视觉上仍然是那个物体。HSV 把这件事拆开了H色相只说是什么颜色S饱和度说颜色有多纯V明度说有多亮。物体的颜色种类主要由 H 决定而 H 对光照强度的变化相对不敏感——一个红苹果在阳光下的 H 和在阴影里的 H 差距很小变的主要是 V。这意味着你可以只对 H 设一个较窄的范围把 S、V 的范围放宽从而获得很强的光照鲁棒性。这里有个必须记住的坑OpenCV 的 H 范围是 0 到 179不是 0 到 360。因为它用 8 位无符号数存 HSV360 度被压缩成了 180 个刻度每刻度 2 度。所有从网上抄来的 HSV 阈值表如果是按 0-360 写的直接塞进inRange会全军覆没。另外 S 和 V 在 OpenCV 里是 0-255而很多调色软件用的是 0-100%换算时也要乘 2.55。还有一个特性必须提前知道H 是环形的。红色位于 H 轴的两端大约 0-10 和 170-179这就意味着红色目标没法用一个区间覆盖必须写成两段再合并。我第一次做红色检测时就栽在这儿阈值写成 160-180 只能抓到一半的红另一半偏橙的红全漏了。色彩空间优点缺点适用场景BGR / RGB直接、无需转换、和图像原始数据一致三通道耦合光照敏感阈值难调几乎不用来做阈值分割HSVH 与亮度解耦对光照相对鲁棒参数直观H 环形、需处理边界低饱和区 H 不稳定大部分颜色分割的首选Lab颜色距离接近人眼感知ΔE 有明确语义计算稍多参数不像 HSV 那么直观需要精细色差判断、色卡检测YCrCb亮度和色度分离计算简单色度范围和 HSV 不同参考资料少肤色检测等特定任务1.3 三条技术路线的取舍硬阈值、颜色距离、直方图反投影同样是按颜色找目标业内其实有三条主流路线差别在于你对颜色接近的定义方式。第一条是硬阈值也就是cv2.inRange。它把每个通道切成一个盒子落在盒子里的留下。优点是极快、参数少、结果可解释缺点是盒子是轴对齐的H、S、V 之间存在相关性时盒子会切得不贴合而且边界是硬跳变颜色刚好落在边界附近的像素会抖动。绝大多数简单场景用它就够了。第二条是颜色距离。先选一个参考色然后计算每个像素到参考色的距离距离小于阈值就保留。在 Lab 空间里用 ΔE 是最贴近人眼感知的做法因为它近似均匀色彩空间同样的数值差在不同色区给人的感觉差不多。这条路线的好处是半径比盒子更符合直觉且只需要一个阈值参数。代价是多几次浮点运算以及对参考色的选取比较敏感。第三条是直方图反投影cv2.calcBackProject。它不设阈值而是先用一小块目标区域算出色相直方图然后拿这个分布去给全图每个像素打像不像的概率分。它对颜色分布不均匀的目标特别友好比如带花纹的物体、有高光的塑料件。缺点是它输出的是一张概率图还得再阈值化一次参数反而更多而且容易被背景里颜色相近的大面积区域带偏。路线参数数量抗光照抗颜色不均速度我的使用建议硬阈值 inRange6 个HSV 各上下限中差极快首选80% 的场景够用Lab 颜色距离1 个ΔE 阈值中中快需要精细色差判断时用直方图反投影阈值 分箱数较强强中目标有花纹、颜色分布宽时用我一般的做法是先用硬阈值把流程跑通拿到肉眼可接受的掩膜如果发现现场光照波动大或者颜色不均导致掩膜碎裂再考虑换成颜色距离或反投影。不要一上来就上复杂方案那会让你在调试阶段同时面对太多变量根本不知道是哪个参数在作怪。2. 动手之前环境、标定和素材这三件事2.1 环境搭建与两个必踩的版本坑环境本身不复杂pip install opencv-python numpy就够。但我建议装opencv-python而不是opencv-contrib-python除非你需要 SIFT、跟踪器那类扩展模块因为 contrib 包体积大、装起来慢纯色差提取用不上。numpy 是硬依赖OpenCV 的 Python 接口传参、掩膜运算、数组切片都靠它。第一个坑是findContours的返回值。OpenCV 3.x 返回三个值(image, contours, hierarchy)4.x 返回两个(contours, hierarchy)。很多老教程按三个值写你复制过来直接报ValueError: not enough values to unpack。我现在的写法是统一按 4.x 来如果需要在两个版本间兼容加一句判断即可但生产环境最好锁死版本别让环境漂移。第二个坑是中文路径。cv2.imread在 Windows 上遇到中文路径会静默返回None然后你在cvtColor那里炸出一堆看不懂的错。解决办法是用np.fromfile读字节再cv2.imdecode写文件同理用cv2.imencode加tofile。这个坑我见过太多人在群里问其实两行代码就能绕过去。import cv2 import numpy as np def imread_unicode(path): data np.fromfile(path, dtypenp.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR) def imwrite_unicode(path, img): ext . path.rsplit(., 1)[-1] ok, buf cv2.imencode(ext, img) if ok: buf.tofile(path) return ok第三个容易被忽略的是读图后要检查是否为 None。摄像头断连、路径写错、文件损坏都可能拿到None。加上一句assert img is not None能省掉后面半小时的排查。2.2 阈值怎么定别靠猜靠取色标定阈值是这个方法里唯一真正需要手调的东西也是最容易凭感觉瞎设的地方。我见过有人直接从博客抄一组[0, 43, 46]到[10, 255, 255]就上线结果换个场景全废。正确做法是从你自己的数据里量出来。最土也最有效的办法是写个交互取色脚本把图显示出来用鼠标在目标区域点若干下把每个点的 HSV 打印出来再统计这些点的均值和方法差。阈值就取均值 ± k × 标准差k 一般取 2 到 3。为什么用统计而不是直接取最小最大值因为最小最大对异常点极敏感——你手抖点到一个高光像素V 的上限就被拉到 255等于没有上限。import cv2 import numpy as np img cv2.imread(sample.jpg) hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) samples [] def on_mouse(event, x, y, flags, param): if event cv2.EVENT_LBUTTONDOWN: h, s, v hsv[y, x] samples.append((int(h), int(s), int(v))) print(f点({x},{y}) HSV({h},{s},{v}) 累计{len(samples)}个样本) if len(samples) 3: arr np.array(samples) print(H 均值/方差:, arr[:,0].mean(), arr[:,0].std()) print(S 均值/方差:, arr[:,1].mean(), arr[:,1].std()) print(V 均值/方差:, arr[:,2].mean(), arr[:,2].std()) cv2.namedWindow(pick) cv2.setMouseCallback(pick, on_mouse) cv2.imshow(pick, img) cv2.waitKey(0)采样的原则很重要要覆盖目标的各种状态而不只是最漂亮的那一块。同一个零件正对着光的、背光的、边缘的、脏污的都要点。我一般会点 10 到 20 个点分散在不同样本上。如果发现样本的 H 标准差很大比如超过 10说明目标本身的色相就不稳定这时候硬阈值就不合适了得回到 1.3 里换方案。还有一个实用技巧分通道看图像。把 H、S、V 分别用imshow显示出来你会立刻看到在 H 通道里目标和背景是不是分得开。如果 H 通道里目标就是一片均匀的灰白、背景是另一片恭喜你问题很简单如果 H 通道里目标区域是花屏说明该区域的饱和度太低H 值本身就是噪声这时候你该调的是 S 的下限。2.3 光照和白平衡色差法真正的天敌我可以说得再直白一点这套方法 90% 的失败都不是算法问题是光照问题。同一块红色塑料在正午阳光下和在阴天、在暖色灯下、在阴影像素里HSV 数值能差出一大截。尤其是廉价摄像头自动曝光和自动白平衡一直在动你上午调好的阈值下午就飘了。几个我能给出的实操建议。第一能固定就固定固定曝光关掉自动曝光、固定白平衡关掉自动白平衡用固定色温、固定光源位置。这一步做完问题会消失一大半。如果用的是 USB 摄像头cv2.VideoCapture配合set可以设置CAP_PROP_AUTO_EXPOSURE、CAP_PROP_AUTO_WB虽然不同平台支持度参差但值得试。第二避免过曝。当某个通道饱和到 255颜色信息就被截断了反光点上你的红色会变成白色H 值直接跳到噪声区。处理反光最有效的办法其实是改变打光——用漫射光源、加偏振片、把光源角度调偏都比后期算法强。算法层面能做的是把 V 的上限稍微压一点不是 255而是 240 左右或者在掩膜里先排除掉高光区域再补洞。第三考虑在线白平衡补偿。如果你的场景光照色温会变可以用画面里一块确定的白色或灰色参考区域做校正把整体颜色的偏色拉回来。做法是求得参考区域的平均 BGR然后按比例缩放整幅图。这个技巧在流水线场景里很常见因为输送带本身往往就是固定颜色的可以当参考。提示不要指望用复杂度换来鲁棒性。如果光照变化剧烈到人眼都要适应一会儿那色差法本身就不适合这个场景应该换用对光照不敏感的特征形状、纹理或者用学习方法。硬上只会把维护成本拖到天上。3. 从一张图到可用的掩膜完整实操流程3.1 第一步读图与色彩空间转换先把地基打稳这一步看起来最简单但有两个细节会决定后面所有步骤的质量。第一个是通道顺序cv2.imread读进来是 BGR不是 RGB。如果你用 matplotlib 显示会看到颜色诡异用 PIL 读的图和 OpenCV 读的图混着处理颜色就全错了。我的习惯是全程用 OpenCV 的 BGR只在需要和别的库交互时显式转换并且在变量名里带上色彩空间后缀比如img_bgr、img_hsv减少自己搞混的概率。第二个是分辨率。原始相机分辨率可能是 4000×3000直接跑形态学运算很慢而且形态学核的大小是绝对像素数分辨率一变参数全变。我在流程开头一般会统一缩放到短边 640 到 960 之间既保留足够的轮廓细节又能让处理速度稳定在毫秒级。缩放之后所有参数核大小、面积阈值都按缩放后的图来算这一点必须写进文档否则换台相机参数就崩了。import cv2 import numpy as np TARGET_SHORT_SIDE 720 def load_and_prepare(path): img cv2.imread(path) if img is None: raise FileNotFoundError(path) h, w img.shape[:2] scale TARGET_SHORT_SIDE / min(h, w) if scale 1.0: img cv2.resize(img, (int(w * scale), int(h * scale)), interpolationcv2.INTER_AREA) hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) return img, hsv这里用INTER_AREA而不是默认的双线性是因为缩小时它做了面积平均不会产生额外的振铃伪影。这个细节对颜色分割影响不小——插值伪影会在颜色边界上造出本来不存在的中间色让阈值判断出现毛边。3.2 第二步inRange 阈值分割六个参数各管什么cv2.inRange(hsv, lower, upper)做的是逐像素的区间判断落在[lower, upper]闭区间内的输出 255否则输出 0。参数就是两个三元数组顺序对应 H、S、V。六个数字各有分工理解它们的作用比记住具体数值重要得多。H 是主判据控制什么颜色。范围窄则只抓最纯的那一档范围宽则能容下光照和材质带来的色相偏移。我的经验是 H 的窗口给到 10 到 30OpenCV 刻度比较合适太窄会在目标上打出空洞太宽会把邻近颜色一起吃进来。红色必须分两段一段[0, 10]一段[170, 179]两次inRange后bitwise_or合并。S 的下限是很多人忽略的关键参数。饱和度过低说明这个像素接近灰白H 值在数学上仍然存在但物理上毫无意义——一张白纸的 H 可以是任意值。所以把 S 的下限抬到 50 到 80能有效清掉背景里的白色块和灰色块。V 的下限则用来排除阴影和纯黑区域放在 40 到 60 之间比较稳V 的上限刚才说过可以压到 240 附近抑制反光。def build_mask_red(hsv): lower1 np.array([0, 80, 50], dtypenp.uint8) upper1 np.array([10, 255, 255], dtypenp.uint8) lower2 np.array([170, 80, 50], dtypenp.uint8) upper2 np.array([179, 255, 255], dtypenp.uint8) m1 cv2.inRange(hsv, lower1, upper1) m2 cv2.inRange(hsv, lower2, upper2) return cv2.bitwise_or(m1, m2) def build_mask_single(hsv, h_lo, h_hi, s_lo80, v_lo50, v_hi255): lower np.array([h_lo, s_lo, v_lo], dtypenp.uint8) upper np.array([h_hi, 255, v_hi], dtypenp.uint8) return cv2.inRange(hsv, lower, upper)数据类型要小心lower和upper必须是uint8或者至少能被 OpenCV 正确解释。我用np.array([...], dtypenp.uint8)显式声明避免 H 值超过 127 时被当成负数——这个坑很隐蔽因为 numpy 默认是 int64不会报错但行为可能和你想的不一样。调试这一步的方法单独开一个窗口显示掩膜不要只看最终结果。掩膜是黑白的问题一眼就能看出来——目标区域有洞说明阈值太紧背景一片白说明阈值太松或背景有同色物。我调试时会把原图和掩膜并排放方便对照。3.3 第三步形态学清理核大小怎么算不是拍脑袋inRange出来的掩膜必然是脏的目标内部有噪点造成的空洞背景有零散的小白点边缘是锯齿状的。形态学操作就是用来收拾这些的。原则很明确——先开后闭。开运算先腐蚀后膨胀去掉小噪点。腐蚀会把所有白色区域缩小孤立的单个像素点直接消失目标整体变小一圈随后膨胀补回来。它擅长处理背景里的碎点。闭运算先膨胀后腐蚀填补目标内部的小孔。膨胀让白色区域扩张目标内部的黑洞被周围吞掉随后腐蚀把外轮廓收回来。它擅长处理目标内部的破洞。核的大小是这里唯一的自由参数也是最容易拍脑袋的地方。我的算法是核直径 ≈ 图像短边 × 0.005 到 0.01。短边 720 的图核直径大约 4 到 7取 5 是好起点。为什么这么算因为噪点的尺度通常和分辨率成正比传感器噪声、压缩块尺寸都随分辨率走用比例而不是固定像素能让参数在不同分辨率下保持一致的物理含义。def clean_mask(mask, short_side): d max(3, int(round(short_side * 0.007))) if d % 2 0: d 1 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (d, d)) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations1) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations2) return mask, d几个经验点。第一核直径必须是奇数getStructuringElement可以接受偶数但锚点会偏导致掩膜整体位移一个像素多步叠加后误差会累积。第二用椭圆核而不是矩形核。矩形核在斜边缘上会产生明显的方块感椭圆核更接近圆盘边缘更自然。第三闭运算的iterations可以比开运算多一次因为填洞的需求通常比去噪更强烈而且闭运算对目标外轮廓的影响相对温和。注意形态学不是什么都能救。如果目标是细长条、或者两个目标挨得很近开运算会把它们腐蚀断开闭运算会把它们粘成一坨。这种情况要么减小核要么改用轮廓层面的后处理别硬靠形态学。3.4 第四步轮廓提取与面积过滤把像素变成对象掩膜只是哪些像素是目标真正有用的是有几个目标、在哪、多大。这一步靠findContours。参数选择上有两个决定结果的点。第一个是检索模式。RETR_EXTERNAL只返回最外层轮廓RETR_TREE返回完整的层级结构。色差提取场景下我几乎总是用RETR_EXTERNAL因为目标内部的洞比如红色盒子上的白色标签不该被当成独立目标而且外轮廓已经够用了。用RETR_TREE会让你的轮廓数量翻几倍还得处理父子关系徒增复杂度。第二个是轮廓点压缩方式。CHAIN_APPROX_SIMPLE会压缩直线段只保留端点。对一个 200×200 的矩形CHAIN_APPROX_NONE返回 800 个点SIMPLE只返回 4 个。后续算面积、算外接框都不需要密集点用SIMPLE能省下大量内存和计算。拿到轮廓之后必须做面积过滤。面积太小的一定是噪声直接丢。阈值怎么定同样用比例min_area ≈ (短边 × 0.02)²。720 短边的图就是(14.4)² ≈ 207像素。这个值对应的物理含义是目标至少占画面短边 2% 的宽度比这个还小的东西基本不可能是你真正关心的目标。如果场景里目标很小比如远处的零件就把系数调到 0.01。def find_targets(img, mask): h, w img.shape[:2] short_side min(h, w) min_area (short_side * 0.02) ** 2 max_area (short_side * 0.95) ** 2 contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) results [] for c in contours: area cv2.contourArea(c) if area min_area or area max_area: continue x, y, bw, bh cv2.boundingRect(c) rect cv2.minAreaRect(c) fill_ratio area / (bw * bh 1e-6) results.append({ area: area, bbox: (x, y, bw, bh), center: (x bw // 2, y bh // 2), rect: rect, fill_ratio: fill_ratio, }) results.sort(keylambda r: r[area], reverseTrue) return resultsfill_ratio轮廓面积除以外接矩形面积是个非常好用的辅助判据。规则目标矩形、圆形的填充率通常在 0.7 以上而破碎的、细长的、形状不规则的噪声往往低于 0.5。多加这一道过滤能把不少面积刚好卡在阈值上的干扰干掉。我一般会设一个 0.5 的下限代价是可能误杀一些本身形状就很奇怪的合法目标所以要视场景来。3.5 第五步结果可视化与输出别只给自己看可视化看着是小事其实是调试效率的大头。我的习惯是输出三张图原图叠加掩膜半透明、掩膜本身、以及带标注框和中心点的结果图。三张并排一看问题定位非常快——掩膜干净但框不对说明是轮廓阶段的问题掩膜本身就脏说明阈值或形态学的问题。标注的时候有几个细节值得注意。框的颜色要和目标颜色形成反差抓红色目标就用青色的框别用红色框画在红色目标上等于没画。文字标注要写序号和面积方便和日志对照。中心点画一个小十字比画个圆点更容易看清位置。def draw_results(img, results, maskNone): vis img.copy() if mask is not None: overlay np.zeros_like(img) overlay[mask 0] (0, 255, 0) vis cv2.addWeighted(vis, 0.7, overlay, 0.3, 0) for i, r in enumerate(results): x, y, w, h r[bbox] cx, cy r[center] cv2.rectangle(vis, (x, y), (x w, y h), (255, 255, 0), 2) cv2.drawMarker(vis, (cx, cy), (0, 0, 255), cv2.MARKER_CROSS, 12, 2) cv2.putText(vis, f#{i} a{int(r[area])}, (x, max(y - 6, 12)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 0), 1, cv2.LINE_AA) return vis另外一定要把中间结果落盘。调参的时候人眼会疲劳把掩膜存成文件回头用图片浏览器批量翻比盯着实时窗口有效率得多。我在一个项目里就是靠翻两百张保存的掩膜才发现某批次的原料颜色整体偏了一个色阶。4. 让方法活得更久三个进阶方向4.1 换 Lab 空间用 ΔE 做颜色距离硬阈值的问题是阈值形状是个轴对齐的盒子而人眼对颜色接近的判断其实是个球。Lab 空间的 L 分量表示明度a 和 b 表示两个色度轴在这个空间里算欧氏距离得到的就是 ΔE76它的大小和人眼感觉差异有多大基本成正比。经验数值ΔE 小于 2 人眼几乎看不出差别2 到 10 是同一色系内的差别10 到 20 是明显不同超过 30 就是两种颜色了。用它做分割就只需要一个参数——ΔE 阈值。做法是先选定参考色可以取样本像素的均值再把全图每个像素转到 Lab 算距离最后阈值化。这个方法的优点是参数语义清晰只要和参考色的感知差异小于 15 就算目标。缺点是计算量比inRange大一些但用向量化的 numpy 运算720p 图上也是几毫秒的量级完全可接受。def build_mask_lab_distance(img_bgr, ref_bgr, delta_thresh15.0): lab cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB).astype(np.float32) L lab[..., 0] * 100.0 / 255.0 a lab[..., 1] - 128.0 b lab[..., 2] - 128.0 ref np.uint8([[ref_bgr]]) ref_lab cv2.cvtColor(ref, cv2.COLOR_BGR2LAB).astype(np.float32)[0, 0] rL ref_lab[0] * 100.0 / 255.0 ra ref_lab[1] - 128.0 rb ref_lab[2] - 128.0 dist np.sqrt((L - rL) ** 2 (a - ra) ** 2 (b - rb) ** 2) return (dist delta_thresh).astype(np.uint8) * 255这里有个容易出错的换算OpenCV 的 8 位 Lab 存法里L 是 0-255 映射到 0-100a 和 b 是加了 128 偏移的。如果你直接用原始 uint8 值算欧氏距离得到的数值和标准 ΔE 不一致需要按上面的方式还原。我踩过一次参数按标准 ΔE 调结果怎么调都不对最后发现是没还原偏移。ΔE 法特别适合色卡比对、产品色差检测这类需要数值精度的场景。如果你的需求是这块布和标准色卡差多少那硬阈值完全无能为力必须用 ΔE而且要算的是区域平均色不是逐像素。4.2 直方图反投影对付花里胡哨的目标有些目标颜色并不均匀——带花纹的包装、有高光斑点的塑料件、有渐变色的液体。硬阈值要么漏掉一半要么放进太多背景。直方图反投影的思路完全不同先在一小块目标区域上统计色相分布得到这个目标的颜色长什么样的统计画像再用这个画像给全图打分。def build_backproject_mask(img_bgr, roi): hsv cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) roi_hsv cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) hist cv2.calcHist([roi_hsv], [0, 1], None, [30, 32], [0, 180, 0, 256]) cv2.normalize(hist, hist, 0, 255, cv2.NORM_MINMAX) back cv2.calcBackProject([hsv], [0, 1], hist, [0, 180, 0, 256], 1) _, mask cv2.threshold(back, 50, 255, cv2.THRESH_BINARY) return mask关键点只用了 H 和 S 两个通道没用 V。这是故意的——把亮度剔掉反投影就对光照变化更稳。分箱数30 和 32也不能太少太少会把不同颜色混进同一个桶也不能太多太多会导致目标实际出现的颜色只占几个稀疏的桶反投影出来的值偏低。我一般用 30×32是个经验上比较均衡的配置。反投影出来的概率图阈值也要调50 是个起点。这个参数的含义是分布相似度超过 50 分满分 255就算目标调高就严调低就松。如果目标占 ROI 的比例很大直接拿整块 ROI 算直方图会引入背景颜色这时候应该用形状标注好的精确 ROI或者用掩膜限制计算区域。4.3 用形状和位置先验做二次过滤颜色只是特征之一。当你发现光靠颜色总会混进来一些不相干的东西时最省力的办法是加一道形状或位置的先验。这些先验不需要多复杂几个简单的几何量就够。比如长宽比。如果目标是方块那bw/bh应该在 0.7 到 1.3 之间横条竖条类的干扰一下就没了。再比如实心度轮廓面积除以凸包面积规则物体的实心度接近 1而破碎形状、环形、叉状物会明显偏低。还有周长平方除以面积这个值对圆形是 4π 左右对细长形状会大很多可以用来区分圆和条。位置先验也有用。比如在流水线场景里目标只会出现在画面上半部分那就在掩膜上做一个 ROI 裁剪把下三分之一直接置零能省掉很多问题。或者在时间维度上做一致性过滤视频里如果某一帧检测到的目标位置和前后帧差得离谱那多半是误检可以用简单的滑动窗口剔除。def filter_by_shape(results, aspect_range(0.6, 1.7), min_solidity0.85): kept [] for r in results: x, y, w, h r[bbox] aspect w / (h 1e-6) if not (aspect_range[0] aspect aspect_range[1]): continue if r.get(solidity, 1.0) min_solidity: continue kept.append(r) return kept我要提醒一句先验是双刃剑。加得越多对场景的假设就越多换一个场景就全崩。我的原则是只在确有必要时加且每个先验都要有明确的业务理由——这批零件都是方的是理由我觉得应该是方的不是。5. 常见问题与排查实录5.1 症状速查表这套流程出问题时症状就那么几种我把它们整理成了表按症状—可能原因—处理办法对应查比漫无目的地改参数快得多。症状最可能的原因处理办法掩膜全黑什么都没抓到HSV 范围写错尤其 H 用了 0-360、图像通道顺序错、图像本身过暗打印几个目标像素的 HSV 核对检查是否 BGR/RGB 混用掩膜全白整张图都是目标阈值过宽、S 或 V 下限设成 0、uint8 溢出把 S 下限抬到 70 以上V 下限抬到 40 以上目标边缘一直缺口H 范围偏窄、目标边界处存在色相过渡放宽 H 上下各 5-10或用闭运算补目标中间有空洞目标上有高光或污渍V 超上限或 H 跳出范围把 V 上限从 255 降到 240闭运算 iterations 加到 2-3背景里有同色干扰被误抓背景存在同色物加位置 ROI、形状先验或改用反投影轮廓被撕成好几块噪声大、形态学核太小、目标颜色不均加大开运算核、增加闭运算次数、改用 ΔE 法两个目标粘成一个目标距离近、闭运算核太大减小核或用距离变换 分水岭换一批样品效果就崩光照变了、样品来料色差锁曝光锁白平衡重新取色标定处理速度慢分辨率太高、每帧重建核、反复转换色彩空间降分辨率、核提到循环外、只算 ROI5.2 几个我常用的土办法第一个是二分法调阈值。面对一组参数不要一次调一个数而是先固定 S 和 V只调 H 的上下限一次动 10看到明显变化就缩小步长到 5、2、1。这样五分钟内能收敛到一个可用值比随机试快得多。第二个是建失败样本集。把跑失败的图挑出来单独存一个文件夹每次改完参数先跑这一批看有没有把之前的问题修好、有没有引入新问题。这个习惯让我避免了很多次修好一个崩掉三个的循环。样本集不需要大二十张覆盖各种极端情况就够。第三个是把中间结果全存下来。原图、HSV 三通道、掩膜、清理后的掩膜、标注结果一次跑完全存盘然后离线慢慢翻。实时窗口的信息量其实很小你只能看到现在这一帧看不到这一类。批量翻图能看出系统性偏差比如某一批样品整体偏黄那就是来料问题不是算法问题。第四个是在画面里放一个已知颜色的参照物。比如在视野角落固定放一块标准色块每次处理时先检测它的 HSV 值如果和基准值偏差超过阈值说明光照变了触发重标定或者直接报错。这个做法听起来麻烦但在需要长期无人值守的场景里它能帮你把悄悄崩掉变成明确报警。注意所有参数都要有据可查。我见过最坑的情况是半年前调的阈值没人知道为什么是那个数新人不敢改只能一直将就。每个参数旁边写一句来源哪怕就是基于 20 张样本统计k2.5一年后能救命。6. 上工程之后性能与可维护性6.1 视频流场景下的性能账图片处理毫秒级视频流就不一样了。30 帧每秒意味着每帧只有 33 毫秒的预算而色彩空间转换、形态学、轮廓提取每一步都在吃时间。我实测下来的经验是720p 分辨率下单帧完整流程转换 inRange 两次形态学 轮廓在普通笔记本上大约 8 到 15 毫秒勉强够用1080p 会翻倍就容易掉帧。优化的顺序应该是这样。第一降分辨率这是收益最大的一步从 1080p 降到 720p 能省将近一半时间而且色差分割对这种精度损失非常不敏感。第二只处理 ROI如果目标一定出现在画面某个区域把其他部分裁掉省下的时间非常可观。第三把不变量提到循环外形态学核、阈值数组、参考 Lab 值这些每帧都一样的东西绝不要在循环里重复创建。第四减少不必要的色彩空间转换如果你同时需要 BGR 和 HSV各转一次就好别在多个函数里反复转。def process_video_stream(cap, h_lo, h_hi, s_lo80, v_lo50): kernel None while True: ok, frame cap.read() if not ok: break h, w frame.shape[:2] scale 720 / min(h, w) if scale 1.0: frame cv2.resize(frame, (int(w * scale), int(h * scale)), interpolationcv2.INTER_AREA) hsv cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) lower np.array([h_lo, s_lo, v_lo], dtypenp.uint8) upper np.array([h_hi, 255, 255], dtypenp.uint8) mask cv2.inRange(hsv, lower, upper) if kernel is None: d max(3, int(round(min(frame.shape[:2]) * 0.007))) if d % 2 0: d 1 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (d, d)) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations2) results find_targets(frame, mask) vis draw_results(frame, results) cv2.imshow(result, vis) if cv2.waitKey(1) 0xFF 27: break cap.release() cv2.destroyAllWindows()还有一点处理频率不必等于采集频率。如果只是做定位5 帧每秒往往够了中间的帧直接丢。这比费劲优化算法划算得多代价只是响应延迟增加 100 多毫秒很多场景完全能接受。6.2 参数外置与回归验证参数写死在代码里是维护噩梦的开始。我的做法是全部抽到一个配置文件里键名设计得可读比如hue_lower、hue_upper、saturation_min、min_area_ratio加上一段注释说明来源和单位。这样现场调参的人不用改代码、不用重启程序可以做热加载出了问题也能直接看配置定位。面积类参数我建议一律用比例而不是绝对像素值。因为一旦换相机、换分辨率绝对像素全废而比例参数能自动适配。核大小也是同理用短边乘以系数来算。这条原则我在几个项目里坚持下来效果很明显——同一套配置在 640 和 1280 两种分辨率下都能跑只需要改一下系数。最后是回归验证。每次改参数或者改逻辑都应该跑一遍固定的测试样本集输出通过率。样本集不用大但要覆盖正常样本、光照偏暗样本、有干扰物样本、目标偏小样本、目标偏大样本。哪怕只是简单地人工看一眼二十张结果图也比改完直接上线强。我吃过一次亏为了修一个边缘缺陷把 H 范围放宽了 10结果另一条产线上背景里的木托盘被大面积误抓那批数据其实早就在样本集里躺着只是没人跑。我个人在实际操作中的体会是色差提取这套东西的技术门槛不高但它对数据观察和参数管理的要求特别高。写得快的人一天就能出demo能长期稳定跑下去的人靠的都是那些看起来很笨的功夫——把参数记清楚、把失败样本留下来、把每一次改动都验证一遍。真正的分水岭不在算法在这套笨功夫上。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →