核心课程 / 视觉入门课 / EXP-02
EXP-02 · 第 1 阶段 · 传统视觉 · 设计性 · 4 学时

多边形轮廓:把"一团像素"简化成"几个顶点"

上个实验我们能把目标抠出来了,但产物是一堆白色像素 —— 几千个点报给机器人,等于没报。这个实验把它压缩成几个顶点坐标:三角形 3 个点,四边形 4 个点,圆则"点多到不像多边形"。有了顶点,才有角度、边长、朝向这些机器人真能用的量。

难度 ⭐⭐ · 4 学时 二值化凸包 approxPolyDP质心
01 颜色02 轮廓03 二维码04 检测05 分类06 分割07 跟踪08 人脸09 骨骼10 3D11 体感12 手势

01目标:三个形状标出顶点数、中心和面积

🎯 实验目标 · 学完你将能
  • 用「松紧带」类比解释 approxPolyDP 怎么把几千个边界点收成几个顶点,说清 approx_epsilon 与顶点数的关系
  • 跑通「二值化 → 轮廓 → 凸包 → 逼近」全流程,在画面里认出每个形状被标了几个顶点,并解释圆为什么点多
  • 换背景后一个轮廓都没有时,第一步查 invert;顶点数不对时,第一步查 approx_epsilon
⚠ 重难点 · 直觉和实际差在哪

直觉是「顶点数越多越准」,实际是顶点数是个可调出来的量 —— 同一个正方形,epsilon 调小能报 7 个顶点,调大能报 3 个。它反映的是"你允许多大偏差",不是"形状本身有几个角"。

直觉是凸包只是"画个外框",实际是它会吃掉真实的凹角 —— L 形工件套上凸包就变成矩形,所以配置里留了 use_hull 开关。

本实验的目标:画面里放三角形、四边形、圆,程序各自找出来,在画面上标出顶点数与质心,终端保持着稳定的帧率。

02问题:一堆白点,机器人拿它没法用

想象一条分拣线:传送带上过来三角形、四边形、圆盖三种工件,下游要按形状分流。相机装在上方,上节课那套颜色阈值能把工件从皮带背景里抠出来 —— 可抠出来的结果是一张白花花的面具。你要跟 PLC 说"3 号口来一个三角件",它听不懂"这里有一万两千个白像素"。它要的是:什么形状、在画面哪个位置、偏了多少度。

① 相机看到 工件、皮带、反光混在一起 threshold ② 阈值抠成掩膜 只剩白点,一万两千个 approx ③ 压成顶点 3 个点 / 4 个点 —— 能算了 从「一堆像素」到「几个坐标」,就是这一步:数据量掉四个数量级,信息反而变清楚了
分拣线的真实处境:掩膜能告诉你"东西在哪一片",只有顶点才能告诉你"是什么形状、朝向如何"

把标题那句话拆开 —— 「一堆白点,机器人拿它没法用」,其实是三问:

  • ① 怎么把白点变成几个点?一万多个边界点,凭什么敢丢到只剩 3 个还不失真?(本节的四步瘦身)
  • ② 变成点以后,到底能干什么?哪些活儿是"有顶点就能干"的,哪些还差得远?
  • ③ 什么情况下这招不灵?光照一变、工件挨在一起、形状本身是凹的 —— 分别会怎么坏?(invert / min_area / use_hull)
三问归一句话:「一堆白点没法用」,坏在点太多、而且没有结构。本章只做一件事 —— 用几个顶点把这堆点替换掉,让它带上形状、大小、位置和朝向。

03现象:形状被多边形勒住,画面标出顶点数

🎬 现象速览 · 先单图,再接相机

你会看到:一幅双栏图 —— 左栏原始画面,右栏纯黑背景上每个形状被一条彩色多边形线勒住、中心有一个同色实心圆点(质心)、多边形第一个顶点上方标着顶点数(本例 3 / 4 / 8)。

单图模式的真实输出:左栏原始画面三个彩色形状,右栏黑底上三个彩色多边形,分别标着顶点数 3、4、8,中心有质心圆点
图 3-1|就是这幅画面:左栏原图、右栏黑底彩色多边形 + 质心圆点 + 顶点数 3 / 4 / 8(三角 3、四方 4、圆 8)。下面是跑出它的那条命令

① 先跑单图(30 秒,不需要相机)。源码目录自带一张示例图 image.png:三个几何形状 + 5 个孤立噪点。配套的 config/image_demo.yaml 是专为这张图准备的参数(binary 阈值 90、核 11×11),先把这一刻的"该看到什么"对上,再谈原理:

cd ~/ros2_ws/src/xflz_eai_car1/vision/05_line_following
python src/app.py --image image.png --config config/image_demo.yaml --save out.png
saved -> out.png ← 终端只打印这一行;形状和顶点数全在 out.png 里,终端不打印

⚠ 必须带 --config。不带就用了主配置 line_config.yaml(adaptive),它对这张纯色底的图会把整片背景判成前景:检测结果是「1 个轮廓、面积 306081」,也就是把整张 640×480 画面当成一个目标,右栏只会剩一条贴边框的线和中间一个质心点 —— 看起来像"什么都没框出来",其实是参数和场景不匹配。

② 再接相机跑实时。终端 1 起相机驱动(ROS_DOMAIN_ID 各终端必须一致),终端 2 跑程序——注意实时模式用的是主配置 line_config.yaml(adaptive),参数要按你面前的场景调,调法见第 05 节:

source /opt/ros/humble/setup.bash && source ~/ros2_ws/install/setup.bash
export ROS_DOMAIN_ID=N
ros2 launch ascamera hp60c.launch.py
cd ~/ros2_ws/src/xflz_eai_car1/vision/05_line_following
python src/app.py --ros
30帧平均帧率: 151.3 30帧平均帧率: 153.4 ← 稳定在 140~160 FPS;顶点数在画面上的多边形旁边,不在终端

浏览器打开 http://<板IP>:8080/polygon.mjpg,把形状逐个放到镜头前。

③ 想边调边看,加 --tune:滑杆窗口开在板子桌面(远程用 noVNC 看),换参数不用改文件,细节见第 05 节。

python src/app.py --tune --ros

🔍 看什么:① 右栏黑底上,每个形状被彩色线勒住、中心有实心圆点(质心);② 第一个顶点上方的数字就是顶点数 —— 三角 3、四方 4、圆会给出 8 个以上;③ 左栏是原始画面,没有任何叠加,两栏并排就是为了让你一眼对回"这条线勒的是哪个形状"。

单图模式用来"把每一步看清楚"(可重复、不依赖相机);实时模式用来"接场景"。两者跑的是同一套 pipeline,只是输入源不同。

04原理:从一帧彩色画面到几个顶点

CORE IDEA · 一句话原理

轮廓有几千个点太啰嗦,用一条"松紧带"把它勒紧,几千个点就变成几个顶点。

打个比方:你要跟朋友描述一个形状 —— 说"这里有 3847 个像素点"没人听得懂,说"这是个三角形,三个角大概在这儿"一下就懂了。approxPolyDP 干的就是这个"精简描述"的工作:拿一根直尺去套曲线,弯得不够厉害的地方就拉直。

八个环节,从相机拍到的一帧彩色画面,到最后贴在画面上的形状标签。前半段一直在做减法(彩色 → 灰度 → 黑白 → 去掉噪点 → 1716 个点 → 3 个顶点),最后两步做加法:把数字变回人能看懂的名字。

下面这张图来自源码目录自带的 image.png:8 个环节每一步都是 src/pipeline.py 真跑出来的,参数用 config/image_demo.yaml(binary 阈值 90、核 11×11)。看图顺序:上排从左到右,再下排从左到右。

一帧画面经过八个环节:彩色输入帧、转灰度、阈值二值化、开运算去噪、找轮廓 1716 个边界点、凸包加多边形逼近得到 3/4/8 个顶点、按顶点数判定形状、在原图上贴形状标签
图 4-1|一帧彩色画面(①)→ 灰度(②)→ 黑白(③)→ 去噪(④)→ 1716 个边界点(⑤)→ 3 / 4 / 8 个顶点(⑥)→ 只算出这几个数字(⑦)→ 描回原图(⑧)。对比 ③ 与 ④:5 个小白点和边缘毛刺在 ④ 里全没了;对比 ① 与 ②:颜色没了,只剩亮度。⑦⑧ 就是程序的原样输出:只有数字,没有"三角形"这三个字

八个环节:谁在干活、数据变成什么、为什么少不了

#环节OpenCV 函数数据变成了什么为什么必须有这一步 / 动参数会怎样
1读入cv2.imread('image.png')(实时则走 Ros2Camera.read)彩色 640×480×3 的 BGR 数组图 ① 就是这张 image.png —— 源码目录自带、命令行 --image image.png 读的那张。三个几何形状 + 5 个噪点是故意留的,为的是看清算法的每一步。分辨率翻倍 → 帧率下降
2去色cv2.cvtColor(bgr, COLOR_BGR2GRAY)三通道 → 单通道灰度 0~255
本例:背景 68 / 红 108 / 蓝 111 / 绿 149
轮廓只认亮度边界,三通道没法比大小。看图 ①→②:颜色没了,但三个形状的深浅不同 —— 代价:红和蓝的灰度只差 3,这正是"灰度分不开颜色"的根源
3二值化cv2.threshold / cv2.adaptiveThreshold + cv2.bitwise_not灰度 → 只剩 0 和 255 的白块就是一刀切:比阈值 90 亮的变白、暗的变黑。图 ②→③ 背景(68)被切掉、形状(108~149)留下。阈值抬高 → 暗一点的目标也被切掉;invert 一翻 → 目标变背景
4开运算去噪cv2.getStructuringElement + cv2.morphologyEx(MORPH_OPEN)白块边缘的毛刺、孤立小点被抹掉先腐蚀(白块缩一圈)再膨胀(长回来):比核小的东西缩没了就长不回来。对比 ③ 与 ④ —— 5 个小白点全消失、边缘毛刺磨圆。核越大 → 噪点去得越干净,但小工件也一起没了(本例为看清效果用了 11×11,实际配置是 3)
5找轮廓 + 面积过滤cv2.findContours(RETR_EXTERNAL, CHAIN_APPROX_SIMPLE) + cv2.contourArea白块 → 一串有序边界点(本例 1716 个),面积小于 min_area 的直接扔掉这一步才第一次有了"形状边界"。RETR_EXTERNAL 只要最外层(内部孔洞不算);面积过滤是第二道去噪,管那些"没被核吃掉但太小"的块
6凸包 + 逼近cv2.convexHull → cv2.arcLength → cv2.approxPolyDP1716 个点 → 3 / 4 / 8 个顶点机器人要的不是点云,是"几个角"。epsilon 调大 → 圆被压成四边形;use_hull 关掉 → 凹形状保真但顶点变多
7只数顶点len(poly) + cv2.contourArea(都是普通 Python / 一行调用)顶点 → 3 个数字(3 / 4 / 8)+ 各自面积,并按面积降序排好程序到这里就停了:它只记 n_vertices = int(len(poly)),不会说"这是三角形"。图 ⑦ 那三个大数字就是程序能给你的全部结论
8描回原图cv2.polylines + cv2.circle + cv2.putText;黑底版另加 cv2.moments 算质心数字 → 画面上的多边形 + 黄顶点 + 一个数字只负责表达,不参与判断。标签位置取的是 vertices[0][0](第一个顶点),不是包围盒;黑底彩色版的实心圆点是 moments 算出的质心
⚠ 程序到此为止:它不会告诉你"这是三角形"

pipeline.py 里只有 n_vertices = int(len(poly)),画面上也只印这个数字。把 3 读成"三角形",是你看画面时自己做的判断,代码里没有这行 if。所以第 05 节看到的画面只有数字 —— 判据在你脑子里,不在程序里。

想让程序自己判,加四行就够(本实验代码没有这段,留作练习):

n = len(poly)
if n == 3:      name = 'TRIANGLE'
elif n == 4:    name = 'QUAD'
elif n >= 8:    name = 'CIRCLE'
else:           name = 'POLY%d' % n

但这套规则很靠不住,两个方向都会错:正方形被光照歪一点、epsilon 小一点,可能报 5 个顶点;小圆或模糊的圆可能只报 6 个,被当成六边形。更稳的是算圆度 4π·面积/周长²(真圆≈1、正方形≈0.785,第 05 节进阶 1),再稳是 cv2.matchShapes(抗旋转缩放)。

🔍 下游用的函数(本页不细讲,但你会看到) cv2.contourArea 算面积、cv2.moments 算质心、cv2.polylines 描多边形、cv2.putText 标顶点数 —— 这四个只负责"把结果表达出来",不参与形状判断。
🔍 epsilon 为什么用"周长比例"而不是像素源码里是 cv2.approxPolyDP(shape, epsilon_ratio * peri, True) —— 容差 = 比例 × 周长。这样大工件和小工件能共用同一个 0.02:近处的三角形周长大,允许的绝对偏差也大;远处的小三角形周长小,容差自动收紧。如果写成固定像素值,换一个距离就得重调。
一句话记住:轮廓 = 沿着边走一圈,凸包 = 绷紧松紧带,approxPolyDP = 把接近直线的点合并。三个函数,一个目的:少给点数据,但别丢形状。

先认识 4 个词

二值化 threshold

把图切成只有黑和白。为什么必须做:轮廓只认"连通的白区",灰阶图里目标边界是模糊的,找不到闭合轮廓。什么时候失效:目标和背景灰度接近(白纸在白桌上)时,怎么调阈值都分不开 —— 这时回到上一节用颜色,或者把配置里 color.enabled 打开改走 HSV 区间。

凸包 convexHull

用最小的凸多边形把所有边界点包起来。为什么需要:真实轮廓被阴影、反光咬出小凹口,直接逼近会把凹口当拐点。什么时候失效:目标本身是凹形状时,凸包会把凹角吃掉 —— 这时把 use_hull 关掉。

epsilon

逼近容差,源码里是"周长 × 比例"。为什么需要:没有容差,每个像素抖动都会被当拐点。什么时候失效:太大把圆压成四边形、太小把四边形拆成七八边形 —— 它决定"顶点数"这个结论可不可信。

质心 centroid

形状的面积中心,用图像矩算(m10/m00、m01/m00)。为什么需要:巡线、抓取要的是"目标在哪",质心是这个问题最直接的答案。什么时候失效:形状严重缺失(被挡掉一半)时,质心会跟着残片跑偏。

invert 这个参数是干什么的?选读 30 秒

invert: false 表示"亮的是目标,暗的是背景";改成 true 就在二值化后做一次反相,变成"暗的是目标"。

怎么判断该用哪个?看要找的东西比背景亮还是暗:浅色纸片放深色桌面 → false;深色胶带贴白地板 → true。

配置注释里写着"板上实测暗底亮物场景用 false" —— 也就是这套参数是按"深桌 + 浅工件"调好的。换到白桌子上必须改成 true,否则一个轮廓都找不到。

它吃什么、吐什么

INPUT · 喂进去
相机 RGB 帧 / 单张图片 / 视频

画面里要有清晰轮廓的物体。实验用三角形、四边形、圆盖,背景与工件有明显明暗差。

→
OUTPUT · 吐出来
每个轮廓:顶点坐标 + 顶点数 + 面积

双栏画面:左原图叠红色轮廓与黄点,右黑底彩色多边形 + 质心圆点 + 顶点数标签。终端只打印帧率。

参数从哪来:一份 YAML 怎么变成画面上的效果

前面八个环节里所有"调大会怎样"的参数,代码里一个都没有写死 —— 它们全从配置文件读进来。整条路是这样走的:

config/*.yaml line_config / image_demo 启动读一次 load_config() config.py:校验键/类型/枚举 cfg(dict) 内存里的参数表 每帧 to_binary() detect_polygons() --tune 滑杆 改的是同一份副本 校验不过就抛 ConfigError,程序直接退出: 缺键 / 类型不对 / threshold_mode 不是 binary|adaptive / 核是偶数 改文件 = 下次启动生效 | 拖滑杆 = 当前进程生效,退出时打印可粘回 YAML 的片段
图 4-2|配置生效路径。文件在启动时读一次变成内存里的 cfg;pipeline 每帧都从 cfg 取值;--tune 的滑杆改的就是这份内存副本

为什么参数要外置?换台面、换工件、换光照,代码一行不用动 —— 这是把"算法"和"现场标定"分开的基本做法。代价是:配置写错,程序启动即失败,所以 config.py 有一层校验:

校验项规则写错会怎样
必需键齐全preprocess / contour / polygon / camera / display 五段,各自字段不能少启动即报 missing required keys: preprocess.invert 这类错误并退出
类型对如 approx_epsilon 必须是数字、use_hull 必须是布尔写成 "0.02"(带引号)会报类型错误
枚举值threshold_mode 只能是 binary 或 adaptive写 adaptiv(少个 e)直接拒
核必须是奇数morph_kernel % 2 == 1写 4 会报 morph_kernel must be odd —— 偶数核没有中心像素,形态学运算无定义
颜色区间(可选)color.enabled: true 时 ranges 必须非空,每个 lo/hi 是 3 个 0~255 整数区间写漏一个数 → 报错;不填 color 段则走灰度,不影响
⚠ 有三个数故意没放进 YAML

adaptiveThreshold(gray, 255, MEAN_C, BINARY, 11, 2) 里的 blockSize=11 和常数 C=2 是写死在 to_binary() 里的,配置文件里没有。

原因:这两个数决定"自适应"这套算法本身的行为(局部窗口多大、要比周围亮多少),跟着算法走;暴露出去,学员会拿它当万能旋钮乱调,反而偏离"先看 invert、再看阈值"的排查路径。但你要知道它们存在 —— 所以 --tune 里 adaptive 拖到 1 时,threshold_value 是空转的,画面会提示 thr=50(bin only)。

完整参数清单(下面是示例图配的那份 image_demo.yaml,主配置 line_config.yaml 结构完全一样,只是数值不同。这是配置内容,不用执行,改完存盘再跑程序):

preprocess:
  threshold_mode: adaptive    # binary | adaptive
  threshold_value: 50
  invert: false               # 亮物暗底用 false;暗物亮底改 true
  morph_kernel: 3

contour:
  min_area: 100               # 小于这个面积的轮廓直接丢掉

polygon:
  approx_epsilon: 0.02       # 最该调的那个数(× 轮廓周长)
  use_hull: true              # 先凸包再逼近

05实操:--tune 一条命令,八个滑杆调到底

  1. 终端 1:先起相机约 2 分钟

    相机话题必须先由驱动节点发出来,程序才读得到;ROS_DOMAIN_ID 各终端保持一致(N 取 0~101 任一整数,每台设备各选各的)。

    source /opt/ros/humble/setup.bash && source ~/ros2_ws/install/setup.bash
    export ROS_DOMAIN_ID=N
    ros2 launch ascamera hp60c.launch.py
  2. 终端 2:用 --tune 跑起来约 1 分钟

    加 --tune 会弹出八个滑杆,画面照常推 MJPEG —— 这是本实验最该先学的命令,后面所有参数都在这里拖出来:

    cd ~/ros2_ws/src/xflz_eai_car1/vision/05_line_following
    python src/app.py --tune --ros
    30帧平均帧率: 55.5 30帧平均帧率: 53.2 ← 约 55 FPS(比纯 --ros 的 150 FPS 低,因为多了滑杆窗口的渲染开销);参数实时叠在画面左上角

    滑杆窗口开在板子的桌面上:接了显示器就直接看;远程用浏览器打开 http://<板IP>:6080/vnc.html(板端自带 noVNC)就能拖。MJPEG 同时在推,http://<板IP>:8080/polygon.mjpg 可以另开一窗对照。

  3. 拖滑杆试参数,Ctrl+C 收工约 10 分钟

    对照下面那张参数表逐个拖,看画面怎么变。调满意后按 Ctrl+C,终端会打印一段能直接粘回 config/line_config.yaml 的配置片段 —— 滑杆值不用手抄:

    调参结果(改 config/line_config.yaml 后重跑即生效): preprocess: threshold_mode: binary threshold_value: 90 invert: false morph_kernel: 11 contour: min_area: 100 polygon: approx_epsilon: 0.020 use_hull: true color: enabled: false

    一次只改一个滑杆,否则出了变化你不知道是谁的功劳。

八个滑杆各管什么:拖一下,画面怎么变

滑杆管哪一步拖大 / 拖小,画面怎么变什么时候动它
threshold_value二值化拖高 → 暗的目标被切掉,只剩最亮的;拖低 → 连背景一起变白目标和背景亮度差明确时;adaptive 模式下它是空转的
adaptive 0/1二值化方式1 = 按局部均值算阈值(抗光照不均);0 = 全局固定阈值(thr 才生效)有阴影、光照渐变用 1;纯色台面用 0
invert 0/1二值化1 = 黑白反转,改成"找暗区";0 = 找亮区目标比背景暗(深色件放浅台面)
morph_kernel开运算去噪1 → 噪点全留;3 → 小毛刺消失;11 → 连小工件一起没了画面有点状噪点、边缘毛刺
min_area轮廓过滤调大 → 小轮廓被丢掉;过大 → 一个都不剩满屏小轮廓,或要滤掉远处小目标
epsilon x1000多边形逼近大 → 顶点少(圆被压成多边形);小 → 顶点暴增(毛刺都算拐角)顶点数虚多,或形状被简化得太狠
use_hull 0/1逼近前取凸包1 → 凹口被填平(L 形变凸多边形);0 → 保留凹角目标本身是凹形状时必须关掉
color 0/1掩膜来源1 → 不看灰度,按 color.ranges 的 HSV 区间取色目标和台面灰度分不开时

前五个滑杆决定"哪些像素算目标",后三个决定"目标被描述成什么形状"。两边各有一张图对照:

同一张 image.png 在六种二值化设置下的掩膜:thr=60 全白、thr=90 三个形状、thr=120 只剩圆、invert 反相、adaptive 只剩边缘、color 按颜色取
图 5-1|同一张 image.png,六种设置下的掩膜(白色 = 算作目标)。左上 thr=60 连背景一起白;thr=90 正好三个形状;thr=120 红(108)蓝(111)被切掉、只剩绿圆(149);invert=1 目标变背景;adaptive 在纯色底只剩边缘线;color=1 改按颜色取
L 形和圆在 use_hull 与 epsilon 六种组合下的拟合结果:hull=1 时 L 形凹口被填平成五边形,hull=0 保留六边形凹角;epsilon 越大圆边数越少
图 5-2|use_hull(行)× epsilon(列)的六种组合。上排 hull=1:L 形的凹口被"松紧带"填平,成了 5 边形的凸多边形(那条斜穿的对角线就是被吃掉的凹角);下排 hull=0:凹角保留,L 形老老实实 6 条边。从左到右 epsilon 变大,圆从 16 边掉到 8 边、再到 4 边
三条命令跑完你就走通了完整链路:二值化 → 找轮廓 → 凸包 → 逼近 → 标顶点。这条链路是所有传统形状识别的地基。
🔁 现象呼应这三步跑出来的画面,就是第 03 节「🎬 现象速览」里那个:双栏、黑底彩色多边形、质心圆点、顶点数标签。两处对上了,说明代码框架、流程、步骤你已完整走通。

关键代码:全链路只有三个函数

源码 src/pipeline.py 里,一条完整链路就是这三段(下面贴的是真源码,行号对应仓库文件):

def to_binary(bgr, cfg):
    gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
    if p["threshold_mode"] == "adaptive":
        mask = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_MEAN_C,
                                     cv2.THRESH_BINARY, 11, 2)
    else:
        _, mask = cv2.threshold(gray, p["threshold_value"], 255, cv2.THRESH_BINARY)
    if p["invert"]:
        mask = cv2.bitwise_not(mask)
    k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (p["morph_kernel"],) * 2)
    return cv2.morphologyEx(mask, cv2.MORPH_OPEN, k)

块外说明:自适应阈值固定用 blockSize=11、常数 C=2(这两个数没放进配置文件,是代码里写死的);invert 在阈值之后做反相;最后一步开运算用椭圆核去毛刺。

def fit_polygon(contour, epsilon_ratio=0.02, use_hull=True):
    shape = contour
    if use_hull:
        shape = cv2.convexHull(contour)
    peri = cv2.arcLength(shape, True)
    return cv2.approxPolyDP(shape, epsilon_ratio * peri, True)

块外说明:顺序是"先凸包、再量周长、再逼近"。注意周长量的是凸包的周长 —— 所以关掉 use_hull 时,同一个 epsilon 的绝对容差也会跟着变。

你该看到的画面

画面 A · 正常
双栏:原图 + 黑底彩色

左栏红轮廓 + 黄顶点;右栏纯黑底上每条轮廓一种颜色,中心同色实心圆点(质心),第一个顶点上方标顶点数。

画面 B · 圆形
顶点数很多(8 以上)

圆没法用少量直线段描述,逼近后会剩很多顶点。程序按"≥8 就当圆"分类 —— 这个门限是经验值,不是数学定义。

画面 C · 一片空白
一个轮廓都没有

按顺序查:invert 对不对 → min_area 是不是太大 → 光照是否让目标和背景灰度接近。

踩坑速查

现象原因怎么办
换成白背景后一个轮廓都没有invert 没改,还在找"亮的区域"暗底亮物用 false,亮底暗物用 true
一个正方形被标成 7 个顶点epsilon 太小,把边缘毛刺也当拐点了把 approx_epsilon 从 0.02 调到 0.04 左右
圆形只认成 4 个顶点epsilon 太大,圆被压成四边形了把 epsilon 调小到 0.01 左右
满屏都是小轮廓min_area 太小,把噪点也当目标了从 100 提到 300~500
一个工件被切成好几块表面有反光/阴影,自适应阈值把一块分成了几块换 threshold_mode: binary 手调 threshold_value,或加柔光
轮廓边缘坑坑洼洼没有做形态学平滑把 morph_kernel 从 3 提到 5(别太大,会吃掉小目标)
L 形工件被认成矩形use_hull: true 把凹口绷平了关掉 use_hull,直接对原轮廓做逼近
红蓝方块怎么调阈值都不框,只框到桌沿那条白边灰度上根本分不开:红块≈85、蓝块≈84,木桌面 65~140 全都有,两者重叠;画面里最亮的永远是桌沿白边把 --tune 的 color 拖到 1(改按 HSV 取色);或铺白纸当背景重调阈值
单图模式跑出来右栏几乎全黑,只有一个点忘了 --config,用了主配置的 adaptive(对纯色底整片判白)补上 --config config/image_demo.yaml;adaptive 只适合光照不均的真实场景
--tune 时帧率只有 --ros 的三分之一(55 vs 150)滑杆窗口走 X11 渲染,每帧要额外绘制一次正常现象;量产部署别带 --tune,调完参数写回 YAML 再跑 --ros
拖 threshold_value 一点反应都没有adaptive 模式不读这个值(走固定 blockSize=11 / C=2)先把 adaptive 拖到 0 切成 binary,thr 才生效
目标明明在画面里,却一个轮廓都不剩min_area 被拖得比目标面积还大(比如 4024 ≈ 63×63 像素)拖回 100~300;画面出现 minA=…! 就是这个原因
💡 排查口诀先看有没有轮廓,再看顶点对不对。没轮廓 = 二值化问题(invert / 阈值 / 光照);有轮廓但顶点数不对 = epsilon 问题;轮廓碎成好几块 = 阈值模式或反光;凹形状被吃平 = use_hull。

进阶(可选)

进阶 1 · 用面积和周长区分形状约 30 分钟

"按顶点数分类"太粗糙。更稳的办法是算圆度:

圆度 = 4π × 面积 / 周长²

完美的圆,圆度 = 1;正方形约 0.785;细长条更小。代码里面积和周长都已经算好了(contourArea / arcLength),加几行就能试。

进阶 2 · 从质心偏移算转向指令约 40 分钟

这是"巡线"真正需要的一步。质心的 x 相对画面中心的偏移,直接决定该往哪拐:

offset = (质心x - 画面中心x) / (画面宽/2)   # 归一化到 -1 ~ +1
角速度 = -k × offset                       # 正偏移(偏右)→ 负角速度(左转)

k 从 0.5 开始试。这个"偏移 × 系数 → 控制量"的结构就是最简单的比例控制器(P 控制)。方向别搞反:物在右边,车要右转才追得上,而右转的角速度是负的。

进阶 3 · 为什么用凸包而不是直接用轮廓选读 50 秒

真实图像里目标边缘很少光滑,手指挡一下、反光、阴影都会在轮廓上咬出小凹口。直接逼近会把这些凹口当成真实拐点,顶点数就不准了。凸包先把凹口绷平,再逼近,分类就稳定了。

代价是:目标本身有真实凹角(L 形工件)时,凸包会把它吃成矩形 —— 所以配置留了 use_hull 开关。

进阶 4 · 文档扫描矫正:把拍歪的纸拉正约 40 分钟

这是 approxPolyDP 最经典的应用,也是本章能力往"真实产品"迈的第一步:拍一张纸,找到它的四个角,把梯形拉回矩形。

前半截本章已经给了——放宽 epsilon 让纸的四条边贴合,得到的 4 个顶点就是纸角;后半截要两个新函数:

pts = cv2.approxPolyDP(hull, 0.02 * cv2.arcLength(hull, True), True)
if len(pts) == 4:
    src = order_corners(pts.reshape(4, 2))          # 排成 左上 右上 右下 左下
    dst = np.float32([[0, 0], [W, 0], [W, H], [0, H]])
    M = cv2.getPerspectiveTransform(src, dst)
    warped = cv2.warpPerspective(img, M, (W, H))

块外说明:order_corners 要自己写(按 x+y、y-x 分出四个角)—— 顶点顺序不排好,透视变换会把纸拉成麻花。注意本章教的"顶点"到这里才真正变现:没有 4 个有序顶点,这一步无从下手。

坑:纸必须是唯一的大四边形(min_area 调大先滤掉杂项);边缘要有对比(纸压深色桌面最稳);四边不能有明显弯曲的书页。

进阶 5 · 框选与角度:一个函数让顶点变成"框"约 20 分钟

分拣线上要"框出"工件、还要知道它歪了多少度,这两个都是一行:

x, y, w, h = cv2.boundingRect(contour)          # 正框(水平垂直)
(cx, cy), (w, h), angle = cv2.minAreaRect(contour)  # 旋转框 + 角度

minAreaRect 给的是最小面积旋转矩形:中心点、宽高、旋转角。抓取时"夹爪该转多少度"就是从 angle 来的。注意:OpenCV 不同版本的角度定义(0~90 还是 -90~0)不一致,第一次用一定要打印出来看正负。

代码复盘:这套代码是怎么搭起来的

技术框架(分层) —— 每格都是仓库里的真实文件,没有"数据处理模块"这种空话:

层文件 · 函数职责
入口 / 编排src/app.py · main() / process_frame()解析 --ros/--image/--video/--camera,跑主循环,每 30 帧打印帧率
输入src/ros2_source.py · Ros2Camera.read()订阅 /ascamera_hp60c/camera_publisher/rgb0/image,把话题帧包装成 VideoCapture 接口
处理src/pipeline.py · to_binary → find_contours → fit_polygon → detect_polygons二值化、找轮廓、凸包 + 逼近、按面积降序返回 PolygonResult
输出src/pipeline.py · draw_polygons_dark / draw_polygons;src/mjpeg_server.py · MjpegServer黑底彩色 / 原图叠加两种画法;np.hstack 拼双栏推到 :8080/polygon.mjpg
配置与资源config/line_config.yaml · src/config.py · load_config()阈值模式、invert、形态学核、min_area、epsilon、use_hull、colored_mode
结果数组太小轮廓点顶点回到循环开头读帧失败一帧 BGR掩膜cap.read()Ros2Camerato_binary()灰度→阈值→反相find_contours()RETR_EXTERNALfit_polygon()hull + approx汇总排序area 降序set_frame()推流 + 帧率退帧ok=False → break丢弃面积 < min_area青=取流 黄=处理 绿=输出 红=出口/环回
图 5-3|主循环流程图。两个分支决定"有没有结果":读帧失败直接退出、面积不够的轮廓被丢掉。
相机驱动节点 Ros2Camera(回调线程) 主循环(app.py) 浏览器 ① /ascamera_hp60c/camera_publisher/rgb0/image ② _on_image():cv_bridge 转 BGR8,跳过黑帧,存最新帧 ③ read():阻塞到有帧或 5s 超时 ④ detect_polygons() ≈ 30 FPS ⑤ set_frame() → 拉 /polygon.mjpg
图 5-4|跨进程时序。相机是独立节点,app 只是订阅者;回调线程不停收帧、主循环按自己的节奏取最新一帧 —— 这是"永远显示最新画面"的关键。

关键模块(改它会发生什么 / 什么时候失效):

模块 · 函数输入 → 输出改它会发生什么什么时候失效
pipeline.to_binaryBGR 帧 → 单通道掩膜invert 一翻,目标全丢;morph_kernel 提到 5,噪点没了、小工件也没了目标和背景灰度接近时,怎么调阈值都分不开
pipeline.find_contours掩膜 → 轮廓列表min_area 调小 → 满屏噪点被当目标;调大 → 小工件消失两个工件粘连成一块白区时,只会出一个轮廓
pipeline.fit_polygon轮廓 → 顶点数组approx_epsilon 0.02→0.04:四边形从 7 顶点收回 4;0.10:圆被压成四边形凹工件 + use_hull: true 时,凹角被吃平
pipeline.detect_polygonsBGR 帧 + cfg → PolygonResult 列表面积降序决定"谁是第一个",画面上的标签顺序随之变轮廓数量很多时每帧耗时上升,帧率掉
app.process_frameBGR 帧 → 待推流图colored_mode 一分:黑底双栏 ↔ 原图叠加红轮廓双栏是 hstack,分辨率翻倍,软编码推流时带宽翻倍
ros2_source.Ros2Camera话题 → VideoCapture 接口timeout 从 5s 调小:驱动慢时直接报打不开相机驱动没起、ROS_DOMAIN_ID 不一致时收不到帧
🧪 Vibe Coding · 用一段话让 AI 重写这条链路约 15 分钟

① 先写提示词(这是给 AI 的提示词,不是终端命令;可直接复制粘进任意对话框):

用 Python + OpenCV 写一个循环,逐帧处理摄像头画面:
- 读一帧,读不到就退出
- 转灰度,做自适应阈值得到黑白掩膜
- 掩膜可能反相,用配置里的开关控制
- 用椭圆核做开运算去掉小噪点
- 找最外层轮廓
- 面积小于阈值的轮廓丢掉
- 每个轮廓先求凸包,再用 approxPolyDP 简化,容差 = 比例 × 凸包周长
- 在画面上描出多边形、标出顶点数和质心
- 把画面推成 MJPEG 流,端口从参数读
参数全部从 YAML 配置文件读,不要硬编码

② 对照原码,找出 3 处差异 —— AI 版能跑,不代表工程版这样写:

差异点AI 版常见写法工程版(本仓)为什么工程这样写
自适应阈值参数adaptiveThreshold(gray,255,MEAN_C,BINARY,11,2) 随手写,或塞进配置同样 blockSize=11, C=2,但写死在 to_binary 里,没进 YAML这两个数在板上实测调过一轮,跟着算法走;暴露出去反而让人乱调
容差单位approxPolyDP(shape, 3, True) —— 固定 3 像素epsilon_ratio * cv2.arcLength(shape, True)固定像素换距离就得重调;按周长比例,远近工件共用 0.02
输出排序按 findContours 返回顺序直接画results.sort(key=area, reverse=True)画面上的第一个标签、下游取"最大目标"都依赖这个顺序
显示形态原图上直接画彩色框colored_mode=true 时 np.hstack 拼"原图 + 黑底彩色"双栏黑底能看清轮廓本身的形状,原图看实际位置,一眼对照
相机接入cv2.VideoCapture(0)Ros2Camera 订阅 ROS 话题并伪装成 VideoCapture 接口板上相机只走 ROS 话题,但主循环不该知道这件事

③ 改一处、先预测再验证:把 approx_epsilon 从 0.02 改成 0.08,先写下"圆的顶点数会变成几个",再改配置重跑对答案。提示:想想"容差变大 = 直尺跨得更远"。

06互动:把 --tune 的八个滑杆搬进页面

📌 这一节是「掌握检查」:亲手改参数,看现象是否朝你预期的方向变,还要能说出为什么 —— 说得出,才算掌握。

第 05 节那条 python src/app.py --tune --ros 会在板上弹出一个滑杆窗口。下面这个是它的网页版:同一张 image.png、同样 8 个参数,而且不是提前画好的示意图 —— 浏览器里真的把 cvtColor → threshold / adaptiveThreshold → morphologyEx → findContours → convexHull → approxPolyDP 重跑一遍,参数口径与源码逐项对齐。

它不是「猜一个看起来合理的结果」:这套重写对着 260 组参数组合、用同一份 pipeline.py 跑出的 OpenCV 结果逐条比对过,轮廓数、顶点数、面积一致;只有 1 组(use_hull=0 + epsilon≈0.009)顶点数差 1,属边角情况。对照值是用 cv2 5.0 生成的,板上 venv 是 cv2 4.11 —— 这两版之间 approxPolyDP / convexHull 的算法没变,但如果板上和这里差 1 个顶点,先怀疑版本差异,别急着改参数。

⚡ 参数实验台 · 复刻 --tune 的滑杆窗口(8 个参数,改哪个都一样重算)
90
0.020
11
100
① 原图 image.png
② 掩膜 + 拟合(--tune 里看不到这层)
③ 黑底彩色(= --tune 右栏)
正在载入 image.png…

七个预设:先猜结果,再按按钮

每个预设下面这行「应该看到」都是从板上真值抄下来的,不是描述性说法。按完对不上,说明你漏看了某张面板。

预设改了什么应该看到(实测)为什么
① 默认binary 90 / k 11 / minA 100 / eps 0.02 / hull 13 个轮廓,顶点 3 / 4 / 8,面积 28279 / 22408 / 16052 px²背景灰度 68 全在阈值下,三个形状 108~149 全在阈值上,一刀切干净
② thr → 66阈值 90 → 66只剩 1 个轮廓,面积 306081 px²(≈ 640×480)背景灰度就是 68。阈值一旦压到它下面,背景也变成目标,整幅画面连成一块 —— 临界点就在 68
③ thr → 110阈值 90 → 1102 个轮廓,顶点 4 / 8,三角形消失三角形灰度 108 被切掉;再抬到 120,连正方形也没了,只剩绿圆
④ k→1 + minA→0关掉开运算、放开面积门限8 个轮廓:3 个形状 + 5 个 38 px² 的小点那 5 个点画面上本来就有。k=11 的开运算把它们抹平了;k=1 时原样留着,minA 一抬到 100 又立刻消失
⑤ eps → 0.05容差 0.02 → 0.05顶点 3 / 4 / 6(圆的 8 段折线被并成 6 段)容差是周长的比例。圆心角处每段折线很短,容差一放大就被并掉
⑥ adaptive → 1换自适应阈值2 个轮廓:286732 + 16052 px²,三角形和正方形不见局部均值让整幅画面一起变白;两个形状外面只剩一圈 1 像素宽的暗环,RETR_EXTERNAL 只返回顶层轮廓,被完整圈住的就丢掉了
⑦ color → 1改走 HSV 区间2 个轮廓,顶点 3 / 4(红三角、蓝正方)绿色圆不落在红/蓝两个区间里,被直接排除。这条路是给「目标和背景灰度分不开」准备的

epsilon 在这张图上的实际效果

同一帧、其余参数取默认,只动 epsilon(= 容差比例 × 凸包周长):

epsilon三角正方圆说明
0.0055617毛刺全留着,圆被数成 17 边 —— 这时「≥8 就当圆」的门限纯属巧合
0.0103414三角形、正方形已经收干,圆还太碎
0.020(默认)348三类刚好分开,也是配置里的值
0.040346圆开始掉边
0.060 及以上344圆被压成四边形 —— 按顶点数分类在这个区间失效

八个参数各改哪一层

参数板上范围动的是哪一层拖大会怎样 / 拖小会怎样
threshold_value0~255灰度 → 二值大了目标被切掉(>149 全灭);小了背景一起变白(<68)
epsilon x10000~200凸包 → 顶点大了顶点掉到 3、形状信息丢光;小了毛刺全保留
morph_kernel0~9(偶数自动 +1)掩膜去噪大了小目标被抹平;小了噪点全留下
min_area0~5000轮廓过滤大了只剩大工件;小了 38 px² 的噪点也算目标
adaptive0/1阈值方式1 = 局部自适应(thr 立刻失效);0 = 固定阈值
invert0/1取亮区还是暗区1 = 找暗的;0 = 找亮的。换台面第一件事就是判它
use_hull0/1拟合前是否绷紧1 = 真凹角被吃掉(L 形变矩形);0 = 保留真实凹角
color0/1掩膜来源1 = 走 HSV 区间;0 = 走灰度

两个从源码里读出来、但界面上看不出的坑:

  • morph_kernel 的滑杆上限是 9(createTrackbar("morph_kernel", win, …, 9, …)),而 config/image_demo.yaml 写的是 11 —— 也就是说这个值在滑杆窗口里到不了。上面这个面板放开到 11,方便你对比它和 9 的差别(面积会差 0.3% 左右)。
  • threshold_value 只在 adaptive=0 时有用。代码里 blockSize=11、C=2 是写死的,走的不是 YAML。所以 adaptive=1 时怎么拖 thr 都没反应 —— 板上会显示 thr=xx(bin only),就是这个意思。

调参顺序(和现场一样):先看 ② 掩膜对不对(thr / invert / adaptive / color 决定「目标有没有被切出来」)→ 再看 ③ 顶点数对不对(epsilon / use_hull 决定「形状判得准不准」)→ 最后看 min_area 有没有把该留的滤掉。顺序反了会白折腾:掩膜里根本没有目标时,调 epsilon 是把噪声拟合得更漂亮而已。

八个滑杆不是八个旋钮,是一条流水线上的八道闸。前四道决定「目标进不进得来」,后两道决定「进来的被切成几块」,中间两道决定「切成几块还算数」。调参就是按流水线顺序一道一道验。

07验证:三道小测

✅ 目标对账 · 验证掌握得有多好

回页首对照「实验目标」逐条自检:① 「松紧带」原理与 epsilon 的作用 → 试 Q2、Q3;② 是否在画面里读到过顶点数并解释得出圆为什么点多 → 翻回第 05 节自查;③ 换背景先查 invert → 试 Q1。答不上的条目回到对应小节补完再交卷 —— 三条全 ✓,这一课就完成了。

Q1你把浅色纸片从黑桌子挪到白纸上,程序突然一个轮廓都找不到了。第一个该查什么?
把 min_area 调小
把 invert 从 false 改成 true,让程序去找"暗的区域"
把 approx_epsilon 调大
对。invert: false 是找"亮的区域"。白纸上纸片和背景都亮,灰度上分不开。改成 true 后找的是暗区 —— 但如果纸片本身也亮,灰度这条路就走不通了,得回到上一节用颜色区分。这也是传统视觉的边界:先问"目标和背景差在哪一维"。
Q2一个方方正正的四边形,画面上的顶点数标成了 7。最该怎么调?
把 epsilon 从 0.02 调到 0.005
把 epsilon 从 0.02 调到 0.04 左右
把 min_area 调大
对。顶点多说明程序把边缘毛刺也当成了拐点。epsilon 是"允许多大偏差",调大会合并更多相邻点,顶点数下降;调小反而更糟。
Q3为什么在逼近之前要先算凸包?
因为凸包算得快,能提速
因为真实轮廓上有很多小凹口(阴影、遮挡),凸包先把它绷平,顶点数就稳定了
因为凸包能算出面积
对。凸包的作用是"消除假凹角"。但注意反面:如果目标本身就是凹形状(L 形、C 形),凸包会把它吃成矩形 —— 所以配置里留了 use_hull 开关。

08章节总结:这些概念怎么串起来、能带去哪

概念它解决了什么和别的关系
二值化 + 形态学把"灰度混在一起"变成"目标/背景两块",顺便去掉毛刺是轮廓的前提;invert 选错,后面全白费
findContours把一片白区变成一串有序的边界点依赖二值化质量;它的输出喂给凸包与逼近
convexHull把假凹角绷平,稳住顶点数可选一步;目标真凹时必须关掉,否则形状被吃平
approxPolyDP把几千个点压成几个顶点容差 = 比例 × 凸包周长;它直接决定"顶点数"这个结论
顶点 / 面积 / 质心变成几何量:形状、大小、位置形状分类、框选、质心转向都从这里出发
⚠ 三个反直觉的地方 ① 直觉是顶点数越少越"简化",实际是顶点数是被 epsilon 造出来的 —— 同一个正方形能报 3 个也能报 7 个,不谈参数就没有"它是几边形"; ② 直觉是圆应该"顶点很多所以被判成圆",实际是判据是"≥8 就当圆"的经验门限,epsilon 一大圆就变成四边形; ③ 直觉是凸包只是画个外框,实际是它会吃掉真实凹角 —— L 形工件套上就是矩形。
💡 四条要注意 ① approx_epsilon 是周长比例不是像素 —— 0.02 表示"允许偏差为周长的 2%",别按像素理解; ② invert 只看"目标比背景亮还是暗",换台面第一件事就是判它; ③ 两个工件挨在一起会粘成一个轮廓,顶点数直接失真,要么分开摆、要么先做腐蚀分离; ④ 自适应阈值的 blockSize=11 / C=2 在代码里写死,别去 YAML 里找。

前置依赖 · 缺一样就跑不起来

类型具体项缺了会怎样 / 怎么补
环境source /opt/ros/humble/setup.bash + source ~/ros2_ws/install/setup.bash只 source 第一个,后面起相机找不到 ascamera 包
Python 库opencv-python、numpy、pyyaml、mjpeg_streamer缺 mjpeg_streamer 时程序启动即提示安装并退出(require() 在入口就检查)
模型 / 资源不需要模型—— 本实验纯传统算法,无权重文件—
配置config/line_config.yaml:invert 与台面对得上、min_area 与工件大小匹配配错 → 一个轮廓都找不到,或满屏噪点
硬件 / 前置实验HP60C 相机 + 驱动节点;ROS_DOMAIN_ID 各终端一致;上一节的颜色阈值思路(掩膜从哪来)驱动没起 → 报 cannot open ros topic;没读 EXP-01 也能做,但会少了"掩膜怎么来"的直觉

这套代码用在哪儿(每一条都能对回上面那几个几何量:顶点数、面积、质心、朝向):

  • 分拣线按形状分流(本章直接产出):传送带上方装相机,顶点数 + 面积判三角/四方/圆盖,质心坐标决定推杆往哪拨。适合高对比、形状规则、节拍快、成本敏感的线体 —— 这也是不用上深度学习就能搞定的那一类活。
  • 工件框选与朝向(+1 个函数):minAreaRect 给出最小旋转框的中心、宽高与角度,机械臂据此决定"夹爪转多少度"。
  • 文档 / 单据扫描矫正(+2 个函数):手机拍纸质表单,找到四个纸角 → 透视变换拉正,再送 OCR。经典的四点矫正 pipeline,前半截就是本章。
  • 缺陷与破损判断(+1 个函数):convexityDefects 把"凸包和真实轮廓之间的缺口"量出来,缺角、崩边一目了然。
  • 不该用它的场合:弱光杂乱背景、柔性件变形、工件堆叠遮挡、需要细分材质或纹理 —— 这些交给第 2 阶段的 YOLO;相机标定与完整视觉抓取也不在本章,分别见 core/calibration 与机械臂课程的视觉抓取实验。
核心记忆点:这一章把"像素"翻译成"几何" —— 掩膜说"东西在这一片",顶点说"它是几边形、朝哪",质心说"它在哪"。顶点数是参数造出来的,不是形状天生带的。

下一个实验换一种完全不同的思路:不再自己算几何,而是调一个成熟的接口把黑白格子读成字符串 —— 顺便看看"写成什么、读回什么"的闭环是什么感觉。