OpenCV Day40 —— K-means 聚类与 EM 高斯混合

效果先行:两张图,151 条断言

横轴是簇数 K(从 2 到 64),琥珀色是簇内平方误差 SSE(对数轴,越低越好),绿色是量化后图像的 PSNR(越高越好)。红色竖线是拐点,落在 K=4。

官方两个例程 kmeans.cpp 和 em.cpp 加起来只有 153 行 ,没有一条断言,全部靠 imshow 加 waitKey 用眼睛看。我把它们改成无头穷举版本,写了 151 条断言,跑出七个官方代码里看不到的结论。

其中最反直觉的一条:attempts 和 K-means++ 初始化,在数据好收敛时是纯浪费;在长尾数据上,不优化最差会到最优解的 9.53 倍。

而最该记进项目规范的一条是:EM 的簇编号每次训练都不一样。


一、两个例程是同一族算法的两个端点

这两个例程经常被当成两回事,其实数学上是一家。

例程 模型 分配方式 输出
kmeans K 个中心点,最小化 SSE 硬分配(非 0 即 1) labels + centers
em K 个高斯(均值+协方差+权重) 软分配(后验概率) labels + probs + means + covs + weights

K-means 有一个更严格的写法:它是 EM 在高斯协方差各向同性、且方差趋于 0 时的特例。

所以两者的 API 长得完全不一样,但骨架是同一个------「用一组参数描述数据分布,再让参数自己去贴合数据」,区别只在样本归属是硬切还是软分。

抓住这个视角,后面的坑就都好定位了:K-means 的坑集中在「收敛到哪个局部极小」,EM 的坑集中在「簇号是谁」。


二、compactness 不是聚类质量,是簇内平方误差

官方 kmeans.cpp:450 只有一行:

cpp 复制代码
compactness = sum(Mat(Size(N, 1), CV_64F, &dists[0]))[0];

它就是 SSE(Sum of Squared Errors),单位是「坐标单位的平方」。

这个语义决定了它只有一种合法用法:

  • 随 K 单调不增。多给一个中心,每个点到最近中心的距离只会变小或不变。所以绝对不能用它比较「K=3 和 K=5 哪个聚类更好」------K 大的必胜,这是数学决定的,不是算法优劣。
  • 只能比同一个 K 下的不同初始化 。这才是 attempts 存在的全部意义。

真实照片实测(attempts=3,PP 初始化):

K compactness PSNR (dB) 耗时 (ms) SSE 降幅
2 1 727 452 903 17.69 96 ---
4 555 552 984 22.62 192 67.84%
8 228 697 700 26.47 389 58.83%
16 109 241 998 29.68 801 52.23%
32 57 183 367 32.49 1531 47.65%
64 32 596 416 34.92 2654 43.00%

到这里我发现一个必须提前说清的问题:PSNR 才是能跨 K 比较的指标,因为它衡量的是「压缩后图像与原图的差距」,与 K 无关。SSE 只能在同一个 K 内部横向比,跨 K 比毫无意义。


三、K 的拐点:我第一版判据永远不触发

既然 SSE 单调下降、PSNR 单调上升,那「选多大的 K」就要靠拐点------再增加 K,收益开始明显变缓的那个点。

我第一版的判据是这样的:

cpp 复制代码
// 降幅首次低于 30% 即认为到拐点
if (drop_ratio < 0.30) return k;

结果永远返回 -1。因为实测降幅是 68 / 59 / 52 / 48 / 43%,单调平缓下滑,从头到尾没有任何一个「断崖」,30% 的线在 K=64 都没碰到。

固定百分比阈值在真实数据上不可用。正确做法是二阶差分(曲率):

复制代码
K=2→4   降幅 1 171 899 920,下一段降幅  326 855 284,曲率  845 044 636   ← 最大
K=4→8   降幅   326 855 284,下一段降幅  119 455 702,曲率  207 399 581
K=8→16  降幅   119 455 702,下一段降幅   52 058 630,曲率   67 397 072

曲率最大的位置就是拐点,落在 K=4。物理含义是「从 2 到 4 拿到的收益,是从 4 到 8 的 3.6 倍」,再往后每一段的边际收益都只有前一段的一小半。

看图像也能印证:K=4 已经能分出建筑的主色调(墙体、天空、阴影、窗户),K=8 接近视觉无损,K=16 之后肉眼基本分不出来------钱花在后面的 K 上,回报很低。


四、attempts 与 K-means++:收益完全取决于数据长什么样

这是本日最有价值的一组实测。结论和直觉相反,而且分两种情况,必须是两种情况。

先用「好收敛」的数据:5 团高斯,每团 200 点,sigma=35,簇分得开、大小均匀。

配置 平均 SSE 相对最优
PP attempts=1 2 275 297 1.000×
PP attempts=3 2 275 297 1.000×
PP attempts=10 2 275 297 1.000×
RANDOM attempts=1 2 275 394 1.000×
RANDOM attempts=3 2 275 394 1.000×
RANDOM attempts=10 2 275 297 1.000×

七组配置几乎同解,差距不到 0.005%。 我第一次跑出这张表时以为是代码写错了------怎么会一模一样?

原因是数据决定的:簇分得开、大小均匀时,k-means 的局部极小就只有一个,初始中心放哪儿都能爬回去。

这个结论本身会骗人。 换成「难收敛」的数据------簇大小改成 700 / 150 / 100 / 40 / 10,模拟严重长尾的类别分布------差距才炸出来:

配置 平均 SSE 最差 SSE 相对最优
PP attempts=1 751 688 777 975 1.16×
PP attempts=3 650 640 650 640 1.00×
PP attempts=10 650 640 650 640 1.00×
RANDOM attempts=1 4 020 615 6 200 688 6.18×
RANDOM attempts=3 1 829 346 6 173 553 2.81×
RANDOM attempts=10 650 640 650 640 1.00×

对数轴,以全局最优为 1.00× 基准线。RANDOM attempts=1 最差一轮达到 9.53 倍------同一个算法、同一份数据,只是初始中心随机撒的位置不好,代价是一个数量级。

三条可以直接抄进工程的结论:

  1. PP 初始化在 attempts=1 时就拿到最优,RANDOM 要 attempts=10 才追平。付 1 次 PP 的开销约等于付 10 次 RANDOM 的开销,但前者的最差情况好 8 倍。
  2. attempts 是「重启取最优」,不是「并行加速」。它只降低「落在坏局部极小」的概率,对已经最优的情况没有任何提升------表里 PP 的 1/3/10 三行完全一样,就是这个道理。
  3. 数据好收敛时,attempts 和 PP 都是白花钱。 工业现场如果簇分得很开(比如颜色分档、形状分档),默认 attempts=3 已经足够;只有类别严重长尾或重叠时,加大 attempts 才有意义。

五、TermCriteria:maxCount 会被强制夹到 2, 100

这是一个不查源码绝对想不到的坑。kmeans.cpp:289-298:

cpp 复制代码
if (criteria.type & TermCriteria::EPS)
    criteria.epsilon = std::max(criteria.epsilon, 0.);
else
    criteria.epsilon = FLT_EPSILON;
criteria.epsilon *= criteria.epsilon;              // ← EPS 被平方

if (criteria.type & TermCriteria::COUNT)
    criteria.maxCount = std::min(std::max(criteria.maxCount, 2), 100);   // ← 夹到 [2,100]
else
    criteria.maxCount = 100;                        // ← 不给 COUNT 就默认 100

三个直接后果:

  1. eps 传进去会被平方 。传 0.01,实际阈值是 1e-4。也就是说 eps 的物理量纲是「中心位移的平方」,不是像素。
  2. maxCount 传 1000 也没用,最多 100。 官方例程传 10 是安全的,随便加大不会更快收敛,也不会更慢。
  3. 只给 EPS 不给 COUNT,仍会按 100 次兜底,不会死循环。

实测在强分离的三团定点数据上,6 种 TermCriteria 组合(COUNT 5/10、EPS 0.01/1/50、只给 EPS)全部收敛到同一解 ,compactness=466754.28。

这说明收敛判据只在数据纠缠时才影响结果。 数据可分的时候,怎么配都无所谓------所以官方例程用 TermCriteria(EPS+COUNT, 10, 1.0) 看起来「很讲究」,其实那 10 次迭代根本用不完。


六、predict2() 的返回值:我一度以为官方有 bug

官方 em.cpp:52 有这么一行:

cpp 复制代码
int response = cvRound(em_model->predict2( sample, noArray() )[1]);

predict2 返回 Vec2d,两个 double。我当时的推理是:[0] 是对数似然,那么 [1] 应该是后验概率 ,取值在 0,1。可 cvRound 一个 0,1 的数,结果只能得 0 或 1------4 个簇里只有 2 个能上色,图上应该只有两种颜色。

结论很自然:官方例程有截断 bug,只能画出两个簇。 我甚至为这个假设写了断言:

cpp 复制代码
CHECK(kinds <= 2);   // "验证" 只出现 2 种颜色

而且它通过了。

这个结论是错的,我写完去核源码后整个推翻了。 证据三处:

cpp 复制代码
// em.cpp:624-627
Vec2d res;
res[0] = std::log(expDiffSum) + maxLVal - 0.5 * dim * CV_LOG2PI;   // 对数似然
res[1] = label;                                                     // ← 簇下标
return res;

// em.cpp:648  eStep 里直接这么用
trainLabels.at<int>(sampleIndex) = static_cast<int>(res[1]);
复制代码
// ml.hpp:926-928 官方文档
/// Zero element is a likelihood logarithm value for the sample.
/// First element is an index of the most probable mixture component.

label 是 em.cpp:607-608 里 argmax(L) 累出来的整数簇号 。所以 cvRound 在这里根本没做截断,它只是把一个整数从 double 转回 int。官方写法完全正确。

实测验证(15625 个采样点):

复制代码
像素            logLik        [1]      probs(4簇后验)  predict()
( 50,250)      -16.573          1  [0.416 0.584 0.000 0.000]          1
(210,250)      -11.217          0  [0.645 0.354 0.000 0.000]          0
(290,250)      -11.015          2  [0.000 0.000 0.736 0.263]          2

→ predict2()[1] 覆盖 4 个簇;与 predict() 不一致点数 = 0

三条断言全部成立:[1] 是整数、[1] == argmax(probs)、[1] == predict()。

这条必须记进方法论 :断言「通过」不代表结论正确。我当时写的 CHECK(kinds <= 2) 是拿自己的错误假设当标准来验证自己,属于自证。只有回头核 em.cpp 和 ml.hpp 原文才推得翻。官方例程有疑问,先怀疑自己的理解。


七、EM 的真实工程陷阱:簇编号不稳定

如果这篇文章只记住一条,记这条。

把 em.cpp 换成三种协方差类型(SPHERICAL / DIAGONAL / GENERIC),分别训练同一个模型,然后逐像素比较聚类结果。

直接按 label 比较------看起来彻底不一样:

复制代码
SPHERICAL vs DIAGONAL   73.97%
SPHERICAL vs GENERIC    98.83%
DIAGONAL  vs GENERIC    49.87%

98.83% 的像素不一样,看起来这是三个完全不同的算法。

按簇中心位置重映射后再比------几乎完全相同:

复制代码
SPHERICAL vs DIAGONAL    1.44%   映射 [0 1 2 3] -> [2 0 1 3]
SPHERICAL vs GENERIC     2.06%   映射 [0 1 2 3] -> [2 3 1 0]
DIAGONAL  vs GENERIC     1.00%   映射 [0 1 2 3] -> [3 1 2 0]

三次训练得到的簇中心坐标完全一致(都在 (155,331) / (344,167) / (165,163) / (340,330) 附近),但编号顺序每次都不一样,因为 EM 的初始均值来自内部 k-means,而它是随机的。

肉眼看这三张图几乎一样------这就是真相:区域形状几乎没变,变的只是「哪个簇被叫做 0 号」。

工业影响很直接 :如果按 label 索引固定颜色或固定类别名,每次重训颜色就乱跳,「红色=缺陷、绿色=合格」这种约定会随机失效。产线上表现为「昨天好好的,今天相机重启后判定全反了」。

对策三选一:

  1. 按簇中心的某个物理量排序后重新编号(如中心亮度、中心 Y 坐标)
  2. 保存训练时的 means,推理时按最近中心做一次对齐映射
  3. 干脆不用簇号,直接用 predict2 的第二个参数 probs 做软判决加阈值

八、两个被实测推翻的猜测

学习笔记里,被推翻的猜测和成立的结论一样值钱。

猜测 验证结果
predict2()[1] 是后验概率,官方 em.cpp 有截断 bug ❌ 错 。em.cpp:626 res[1] = label,是簇下标,官方写法正确
centers 在 K 大于唯一颜色数时会少于 K 行 ❌ 错 。kmeans.cpp:284 恒分配 K 行

第二条值得多说一句。我原本的推理是:如果图里只有 3 种颜色,K 却给了 8,那一定有空簇,空簇应该被「删掉」,所以 centers 会少于 8 行。

源码 kmeans.cpp:284:

cpp 复制代码
Mat centers(K, dims, type), old_centers(K, dims, type), temp(1, dims, type);

centers 在循环外就按 K 分配好了。空簇的处理是「保留旧中心,不再移动」,而不是删掉一个中心。

实测 3 种唯一颜色的图上 K=8,返回 8×3 个值,compactness ≈ 0(唯一颜色被完美覆盖)。

工程含义 :centers.total() == K * dims 恒成立,可以放心按 K 索引。但要警惕多个中心坐标完全重合------那些就是空簇,它们会让「实际用到的簇数」虚高。


九、官方例程隐藏的坑:sampleCount 可能只有 1

官方 kmeans.cpp 第 36 行:

cpp 复制代码
int i, sampleCount = rng.uniform(1, 1001);      // 最小值是 1!

rng.uniform(1, 1001) 返回 1 到 1000 之间的任意整数,包含 1。

此时 clusterCount 被 MIN(clusterCount, sampleCount) 压成 1,而 kmeans() 对 K==1 有特殊分支(kmeans.cpp:300-304):attempts=1、maxCount=2。

本工程跑 10 轮,最少一轮只拿到 n=23 ,compactness=28007,与其它轮完全不是一个量级。

这说明官方例程的 for(;;) 循环里,每轮的 compactness 是在不同量级的数据上算出来的。 按回车看下一张图的人不会意识到这一点------他会以为数字变小是算法变好了。

教训 :任何要在官方例程上加自动化判断的人,必须先固定 sampleCount,否则那些数字毫无意义。


十、本日 API 速查

接口 关键参数 说明
kmeans(data, K, labels, TermCriteria, attempts, flags, centers) labels 为 N×1 列向量 返回 compactness = SSE
KMEANS_PP_CENTERS --- K-means++ 初始化,attempts=1 即可
KMEANS_RANDOM_CENTERS --- 纯随机,需靠 attempts 兜底
TermCriteria(type, maxCount, eps) maxCount∈[2,100] eps 会被平方
EM::setClustersNumber(n) --- 高斯个数
EM::setCovarianceMatrixType(t) COV_MAT_SPHERICAL / DIAGONAL / GENERIC 形状自由度递增
EM::trainEM(samples) 返回 bool 内部先用 k-means 初始化均值
EM::predict2(sample, probs) 返回 Vec2d [0] 对数似然,[1] 簇下标
EM::getMeans() / getWeights() --- 簇中心 / 混合权重
EM::getCovs(vector<Mat>&) 输出参数,非返回值 我在这里编译失败过一次

十一、踩坑速查表

# 现象 根因 解决
1 CMake 报 No SOURCES given to target 先写了 CMakeLists,源文件还没建 先 touch 占位,再逐个编 target
2 改完源码,make 说没变化 共享目录时钟偏移,判定目标比源文件新 rm -rf build 全量重编
3 labels.rows == labels.cols 断言失败 我以为输出是方阵,实际是 N×1 列向量 改 rows==N && cols==1
4 getCovs() 编译不过 它是输出参数 void getCovs(vector<Mat>&) 传入容器,不接返回值
5 三种协方差类型对比时程序崩溃 types[3] 定义在循环内却用 c 索引到 3 越界 数组提到循环外
6 7 组配置结果完全一致 数据太「好收敛」,局部极小唯一 换成 700/150/100/40/10 长尾数据
7 拐点检测永远返回 -1 用了固定阈值 30%,实际降幅平缓下滑 改二阶差分(曲率最大处)
8 配图里 SSE 曲线画反了 log(max)-log(v) 方向写反 改 log(v)-log(min),逐像素核对

第 8 条值得单独提一句:数值下降但视觉上升 ,这种图发出去是最丢人的。我最后是逐像素取 y 中位数核对才发现的------画完图不能只靠眼睛扫一眼觉得对。


十二、AI 与 LLM Wiki:这一课沉淀了什么

这一天的产物不是八张图和 151 条断言,而是三个可以复用的判断规则:

  1. 先分清「能跨条件比较的量」和「只能在同条件下比较的量」。 SSE 不能跨 K 比,PSNR 可以。搞混这一条,后面所有的选型结论都是错的。这个区分比记住 API 参数重要得多。
  2. 断言通过不等于结论正确。 我拿自己的错误假设去写断言,它当然通过。真正推翻它的是回头核 em.cpp:626 和 ml.hpp:926-928 的原文。自证是所有技术判断里最危险的陷阱。
  3. 「看起来不同」和「真的不同」之间,隔着一次对齐。 三种协方差的聚类结果直接比差 50%~98%,按簇中心重映射后只差 1%~2%。这个差值不是算法差异,是编号差异。没有对齐过的对比数据,不能下任何结论。

LLM Wiki 里这一节建了 聚类与期望最大化:K-means与EM高斯混合 概念页,包含 30 多条 API 表、逐段语法分析和 15 条踩坑记录。四张负面结论(predict2 截断、centers 少于 K、协方差类型差异大、EM 软分配可见)都单独记了一栏------负面结论是最省后来人时间的那部分知识。

进度推进到 77 / 97(79.4%),剩下的 20 节里,ml 模块还有 6 节。

下一节:digits_svm 和 train_svmsgd------从无监督切到有监督,正好和今天形成对照。

相关推荐
AI的探索之旅3 小时前
OpenCV Day39 —— 无缝克隆 seamless cloning(cloning_demo / cloning_gui)
人工智能·opencv
小豆庄5 小时前
会议录屏忘打码后的技术复盘:本地自动化隐私脱敏方案
运维·opencv·目标检测·自动化·视频·纹理贴图
小豆庄7 小时前
监控视频脱敏:夜视/低光线条件下人脸检测的工程处理思路
opencv·计算机视觉·阿里云·视觉检测·音视频·视频
小豆庄20 小时前
视频遮罩追踪退场后ID撞号的排查—多目标场景下的失效
opencv·算法·bug·视频·python3.11
zwc32104320551 天前
Day 2:图像内存、数据类型与stride
c++·人工智能·opencv·算法·计算机视觉
誰能久伴不乏1 天前
OpenCV 入门(二):像素访问、ROI 与深浅拷贝
人工智能·opencv·计算机视觉
zwc32104320551 天前
第四周第五天——重构成类
c++·人工智能·opencv·算法·计算机视觉
zwc32104320551 天前
Day 5:threshold与inRange阈值分割
c++·图像处理·人工智能·opencv·算法·计算机视觉
fanruitian2 天前
python 机器学习 ML LinearRegression KNN KMeans
python·机器学习·kmeans