不锈钢筷子头挨着头、反光闪瞎眼,OpenCV 霍夫圆直接投降。这个小项目走了一遍从传统 CV 到深度学习的完整路径。

那些反光的筷子头,把 OpenCV 逼疯了

事情是这样的:要数一把不锈钢筷子有多少根。听起来很简单对吧?拿个相机拍一下,数圆圈就行了。

用 OpenCV 的霍夫圆变换(HoughCircles)试了一下——惨不忍睹。不锈钢顶部的金属反光和阴影被边缘检测器当成独立实体,满屏幕乱飞的假阳性圆圈。筷子头挨得又近,阴影轮廓互相粘连,算法根本分不清这是 16 根筷子还是 2 个异形物体。

更要命的是调参。换个光照条件,param1param2 又得重来。换个桌面背景,再来一遍。这不是写代码,这是在做手工。

这就是传统 CV 的死穴:它只认几何形状,不认"东西"。霍夫圆变换的逻辑是"找到符合圆方程的像素点集合",它不理解"这是一根筷子的截面"这个概念。光照一变、角度一变、排列一变,像素梯度就变了,算法就废了。

为什么 YOLO 能搞定

YOLO 的思路完全不同。它不是在像素层面找几何形状,而是从数百万张图片里学会了"什么是筷子头"。它理解的是语义特征,不是像素梯度。

具体到这个场景:

问题OpenCV (霍夫圆)YOLO
不锈钢高光把光斑误判为实体能通过上下文推理出这是光斑
筷子头紧密排列把多个物体融合成一个擅长分辨紧密排列的多个实例
换光照/换背景每次都要重调参一次训练,到处通用

简单说:OpenCV 是"按规则找",YOLO 是"凭经验认"。规则怕变化,经验怕没见过——但筷子头这种常见物体,YOLO 的训练集里见得多了。

从标注到训练:三步走

第一步:安装标注工具

先检查 Python 版本:

python --version
  • Python ≥ 3.9 → 用 LabelMe,支持圆形/多边形标注,本项目用的就是它
  • Python ≤ 3.8 → 建议用 LabelImg,只支持矩形框,但兼容性更好

💡 LabelMe 依赖的 pyqt5 在 Python 3.8 以下版本经常装不上,折腾半小时不如直接换工具。

国内环境建议先配 pip 镜像:

pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
# LabelMe(推荐,支持圆形标注)
pip install labelme

# 或者 LabelImg(Python ≤ 3.8 时用)
pip install labelImg

LabelMe vs LabelImg 怎么选?

LabelMeLabelImg
标注类型矩形、圆形、多边形、线段仅矩形框
输出格式JSON(含完整坐标信息)XML(Pascal VOC)或 YOLO TXT
圆形目标✅ 原生支持,圆心+圆周点❌ 只能画矩形近似
Python 要求≥ 3.9≥ 3.6
安装难度中(PyQt5 依赖偶尔翻车)
适合场景需要精确形状标注(圆、多边形)快速矩形框标注

本项目的筷子头是圆形的,用 LabelMe 的圆形标注比矩形框更贴合,训练时边界更精准。如果你只是标注方方正正的物体(盒子、瓶子),LabelImg 完全够用。

第二步:标注

labelme dataset_labelme/

打开 GUI,用圆形标注(不是矩形框)在每根筷子头上画圈,标签统一叫 circle1。圆形标注比矩形框更贴合筷子头的形状,训练时边界更精准。

LabelMe 保存的是 JSON 格式,里面记录了圆心坐标和圆周上的一个点。这一步是最枯燥的,但标注质量直接决定模型上限——垃圾标注出垃圾模型,没有例外

第三步:JSON → YOLO 格式转换

LabelMe 的 JSON 不能直接给 YOLO 用,需要转换成 YOLO 的 TXT 格式:

class_id x_center y_center width height  (归一化到 0~1)

核心难点在于 LabelMe 的圆形标注存的是"圆心 + 圆周点",而 YOLO 要的是矩形边界框。转换逻辑:

# 圆心就是边界框中心,半径决定宽高
cx, cy = points[0]  # 圆心
px, py = points[1]  # 圆周点
r = math.sqrt((px - cx)**2 + (py - cy)**2)

# YOLO 格式:中心坐标 + 宽高(归一化)
x = cx / img_w
y = cy / img_h
w = (2 * r) / img_w
h = (2 * r) / img_h

💡 踩坑提醒:坐标系别搞混。LabelMe 用的是左上角原点的像素坐标,YOLO 要的是归一化的中心坐标。漏了归一化这一步,模型直接训废。

第四步:训练

from ultralytics import YOLO

model = YOLO("yolov8n.pt")  # 预训练的轻量级模型
model.train(
    data="./dataset/dataset.yaml",
    epochs=200,
    imgsz=640,
    device="cpu",  # 有 GPU 用 "0",Mac 用 "mps"
)

YAML 配置文件很简单:

path: ./dataset
train: images/train
val: images/val
names:
  0: circle1

用 CPU 跑 200 个 epoch,最终 mAP50 达到 0.81,recall 达到 1.0(所有筷子头都检出来了)。对于只有 3 张训练图的极端小数据集,这个结果相当不错——说明 YOLO 的预训练权重里已经包含了足够的"圆形物体"先验知识。

推理:从矩形框到圆形标注

YOLO 检测输出的是矩形边界框,但筷子头是圆的。为了让可视化更精准,做了一个后处理修正

  1. 在 YOLO 检测到的矩形框内,截取灰度图的局部 ROI
  2. 自适应阈值二值化,把筷子头截面的轮廓抠出来
  3. 计算这个局部区域的真实质心(用图像矩 moments)
  4. 以质心为圆心画圆,替代 YOLO 的矩形框
roi = gray[y1:y2, x1:x2]
roi_thresh = cv2.adaptiveThreshold(
    roi, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 15, 2
)
moments = cv2.moments(roi_thresh)
center_x = int(moments["m10"] / moments["m00"]) + x1
center_y = int(moments["m01"] / moments["m00"]) + y1

这个思路是YOLO 负责"找",OpenCV 负责"精修"。深度学习擅长语义定位(大概在哪),传统 CV 擅长亚像素级精度(精确到哪)。两者结合,比单独用任何一个都好。

踩坑与排查

1. 数据集只有 3 张图,能训吗?

能,但要过拟合。这个项目的目的不是做通用模型,而是验证 YOLO 的训练流程。把同一张图同时放进训练集和验证集,确保模型能"记住"这些图。实际项目中,至少需要 100+ 张标注图。

2. 椭圆标注(圆的边界框)转 YOLO 的坑

LabelMe 的圆形标注存的是 (圆心, 圆周点),不是 (左上角, 右下角)。如果你当成矩形框处理,生成的边界框会完全错误。转换脚本必须判断 shape_type == "circle" 并走专用逻辑。

3. 小目标检测不理想

如果筷子头在图中占比很小(比如远景拍整把筷子),YOLO 的 640×640 输入分辨率可能不够。可以试试 imgsz=1280,或者用 YOLOv8 的小目标检测头。

4. CPU 训练太慢

200 epochs 用 CPU 跑了约 2 分钟(3 张图的数据集)。如果数据量上千,CPU 可能要跑几小时。建议至少有一张入门级 GPU。

效果对比:一眼看出差距

OpenCV 霍夫圆

OpenCV 检测结果

问题很明显:

  • 绿圈大小不一,有些比筷子头大出一圈,把旁边的缝隙也圈进去了
  • 中间密集区域严重重叠,根本分不清哪根是哪根
  • 高光区域被误判为独立的圆圈,假阳性满屏飞

YOLO + OpenCV 精修

YOLO 检测结果

同样的图,同样的筷子:

  • 16 根筷子全部识别,每根一个圈,没有遗漏
  • 圆圈大小均匀,精准贴合筷子头截面
  • 编号 1-16,计数一目了然
  • 紧密排列的筷子也能准确分开

核心差异:OpenCV 只认“圆的形状”,YOLO 认的是“筷子头这个东西”。前者被光照和排列骗了,后者用语义理解扛住了。

核心收获

  1. 传统 CV 和深度学习不是替代关系,是互补关系。YOLO 负责语义定位,OpenCV 负责亚像素精修,组合拳比单打独斗强。
  2. 标注质量 > 模型选择。3 张精心标注的图,比 100 张随便框的图效果好。
  3. YOLO 的预训练权重是宝藏。即使数据集极小,预训练权重里的先验知识也能让模型快速收敛。
  4. 圆形标注比矩形框更适合圆形目标。虽然转换脚本复杂一点,但训练时边界更精准,mAP 更高。

项目代码和数据集已开源:yolo-detection