多边形轮廓:把"一团像素"简化成"几个顶点"
上个实验我们能把目标抠出来了,但产物是一堆白色像素 —— 几千个点报给机器人,等于没报。这个实验把它压缩成几个顶点坐标:三角形 3 个点,四边形 4 个点,圆则"点多到不像多边形"。有了顶点,才有角度、边长、朝向这些机器人真能用的量。
01目标:三个形状标出顶点数、中心和面积
- 用「松紧带」类比解释 approxPolyDP 怎么把几千个边界点收成几个顶点,说清
approx_epsilon与顶点数的关系 - 跑通「二值化 → 轮廓 → 凸包 → 逼近」全流程,在画面里认出每个形状被标了几个顶点,并解释圆为什么点多
- 换背景后一个轮廓都没有时,第一步查
invert;顶点数不对时,第一步查approx_epsilon
直觉是「顶点数越多越准」,实际是顶点数是个可调出来的量 —— 同一个正方形,epsilon 调小能报 7 个顶点,调大能报 3 个。它反映的是"你允许多大偏差",不是"形状本身有几个角"。
直觉是凸包只是"画个外框",实际是它会吃掉真实的凹角 —— L 形工件套上凸包就变成矩形,所以配置里留了 use_hull 开关。
02问题:一堆白点,机器人拿它没法用
想象一条分拣线:传送带上过来三角形、四边形、圆盖三种工件,下游要按形状分流。相机装在上方,上节课那套颜色阈值能把工件从皮带背景里抠出来 —— 可抠出来的结果是一张白花花的面具。你要跟 PLC 说"3 号口来一个三角件",它听不懂"这里有一万两千个白像素"。它要的是:什么形状、在画面哪个位置、偏了多少度。
把标题那句话拆开 —— 「一堆白点,机器人拿它没法用」,其实是三问:
- ① 怎么把白点变成几个点?一万多个边界点,凭什么敢丢到只剩 3 个还不失真?(本节的四步瘦身)
- ② 变成点以后,到底能干什么?哪些活儿是"有顶点就能干"的,哪些还差得远?
- ③ 什么情况下这招不灵?光照一变、工件挨在一起、形状本身是凹的 —— 分别会怎么坏?(
invert/min_area/use_hull)
03现象:形状被多边形勒住,画面标出顶点数
你会看到:一幅双栏图 —— 左栏原始画面,右栏纯黑背景上每个形状被一条彩色多边形线勒住、中心有一个同色实心圆点(质心)、多边形第一个顶点上方标着顶点数(本例 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
⚠ 必须带 --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
浏览器打开 http://<板IP>:8080/polygon.mjpg,把形状逐个放到镜头前。
③ 想边调边看,加 --tune:滑杆窗口开在板子桌面(远程用 noVNC 看),换参数不用改文件,细节见第 05 节。
python src/app.py --tune --ros
🔍 看什么:① 右栏黑底上,每个形状被彩色线勒住、中心有实心圆点(质心);② 第一个顶点上方的数字就是顶点数 —— 三角 3、四方 4、圆会给出 8 个以上;③ 左栏是原始画面,没有任何叠加,两栏并排就是为了让你一眼对回"这条线勒的是哪个形状"。
单图模式用来"把每一步看清楚"(可重复、不依赖相机);实时模式用来"接场景"。两者跑的是同一套 pipeline,只是输入源不同。
04原理:从一帧彩色画面到几个顶点
轮廓有几千个点太啰嗦,用一条"松紧带"把它勒紧,几千个点就变成几个顶点。
打个比方:你要跟朋友描述一个形状 —— 说"这里有 3847 个像素点"没人听得懂,说"这是个三角形,三个角大概在这儿"一下就懂了。approxPolyDP 干的就是这个"精简描述"的工作:拿一根直尺去套曲线,弯得不够厉害的地方就拉直。
八个环节,从相机拍到的一帧彩色画面,到最后贴在画面上的形状标签。前半段一直在做减法(彩色 → 灰度 → 黑白 → 去掉噪点 → 1716 个点 → 3 个顶点),最后两步做加法:把数字变回人能看懂的名字。
下面这张图来自源码目录自带的 image.png:8 个环节每一步都是 src/pipeline.py 真跑出来的,参数用 config/image_demo.yaml(binary 阈值 90、核 11×11)。看图顺序:上排从左到右,再下排从左到右。
八个环节:谁在干活、数据变成什么、为什么少不了
| # | 环节 | 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.approxPolyDP | 1716 个点 → 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 标顶点数 —— 这四个只负责"把结果表达出来",不参与形状判断。cv2.approxPolyDP(shape, epsilon_ratio * peri, True) —— 容差 = 比例 × 周长。这样大工件和小工件能共用同一个 0.02:近处的三角形周长大,允许的绝对偏差也大;远处的小三角形周长小,容差自动收紧。如果写成固定像素值,换一个距离就得重调。先认识 4 个词
把图切成只有黑和白。为什么必须做:轮廓只认"连通的白区",灰阶图里目标边界是模糊的,找不到闭合轮廓。什么时候失效:目标和背景灰度接近(白纸在白桌上)时,怎么调阈值都分不开 —— 这时回到上一节用颜色,或者把配置里 color.enabled 打开改走 HSV 区间。
用最小的凸多边形把所有边界点包起来。为什么需要:真实轮廓被阴影、反光咬出小凹口,直接逼近会把凹口当拐点。什么时候失效:目标本身是凹形状时,凸包会把凹角吃掉 —— 这时把 use_hull 关掉。
逼近容差,源码里是"周长 × 比例"。为什么需要:没有容差,每个像素抖动都会被当拐点。什么时候失效:太大把圆压成四边形、太小把四边形拆成七八边形 —— 它决定"顶点数"这个结论可不可信。
形状的面积中心,用图像矩算(m10/m00、m01/m00)。为什么需要:巡线、抓取要的是"目标在哪",质心是这个问题最直接的答案。什么时候失效:形状严重缺失(被挡掉一半)时,质心会跟着残片跑偏。
invert 这个参数是干什么的?选读 30 秒
invert: false 表示"亮的是目标,暗的是背景";改成 true 就在二值化后做一次反相,变成"暗的是目标"。
怎么判断该用哪个?看要找的东西比背景亮还是暗:浅色纸片放深色桌面 → false;深色胶带贴白地板 → true。
配置注释里写着"板上实测暗底亮物场景用 false" —— 也就是这套参数是按"深桌 + 浅工件"调好的。换到白桌子上必须改成 true,否则一个轮廓都找不到。
它吃什么、吐什么
画面里要有清晰轮廓的物体。实验用三角形、四边形、圆盖,背景与工件有明显明暗差。
双栏画面:左原图叠红色轮廓与黄点,右黑底彩色多边形 + 质心圆点 + 顶点数标签。终端只打印帧率。
参数从哪来:一份 YAML 怎么变成画面上的效果
前面八个环节里所有"调大会怎样"的参数,代码里一个都没有写死 —— 它们全从配置文件读进来。整条路是这样走的:
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 段则走灰度,不影响 |
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:先起相机约 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:用 --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可以另开一窗对照。 -
拖滑杆试参数,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 红(108)蓝(111)被切掉、只剩绿圆(149);invert=1 目标变背景;adaptive 在纯色底只剩边缘线;color=1 改按颜色取
use_hull(行)× epsilon(列)的六种组合。上排 hull=1:L 形的凹口被"松紧带"填平,成了 5 边形的凸多边形(那条斜穿的对角线就是被吃掉的凹角);下排 hull=0:凹角保留,L 形老老实实 6 条边。从左到右 epsilon 变大,圆从 16 边掉到 8 边、再到 4 边
关键代码:全链路只有三个函数
源码 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 的绝对容差也会跟着变。
你该看到的画面
左栏红轮廓 + 黄顶点;右栏纯黑底上每条轮廓一种颜色,中心同色实心圆点(质心),第一个顶点上方标顶点数。
圆没法用少量直线段描述,逼近后会剩很多顶点。程序按"≥8 就当圆"分类 —— 这个门限是经验值,不是数学定义。
按顺序查: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 |
关键模块(改它会发生什么 / 什么时候失效):
| 模块 · 函数 | 输入 → 输出 | 改它会发生什么 | 什么时候失效 |
|---|---|---|---|
pipeline.to_binary | BGR 帧 → 单通道掩膜 | 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_polygons | BGR 帧 + cfg → PolygonResult 列表 | 面积降序决定"谁是第一个",画面上的标签顺序随之变 | 轮廓数量很多时每帧耗时上升,帧率掉 |
app.process_frame | BGR 帧 → 待推流图 | 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 个参数,改哪个都一样重算)image.png--tune 里看不到这层)--tune 右栏)七个预设:先猜结果,再按按钮
每个预设下面这行「应该看到」都是从板上真值抄下来的,不是描述性说法。按完对不上,说明你漏看了某张面板。
| 预设 | 改了什么 | 应该看到(实测) | 为什么 |
|---|---|---|---|
| ① 默认 | binary 90 / k 11 / minA 100 / eps 0.02 / hull 1 | 3 个轮廓,顶点 3 / 4 / 8,面积 28279 / 22408 / 16052 px² | 背景灰度 68 全在阈值下,三个形状 108~149 全在阈值上,一刀切干净 |
| ② thr → 66 | 阈值 90 → 66 | 只剩 1 个轮廓,面积 306081 px²(≈ 640×480) | 背景灰度就是 68。阈值一旦压到它下面,背景也变成目标,整幅画面连成一块 —— 临界点就在 68 |
| ③ thr → 110 | 阈值 90 → 110 | 2 个轮廓,顶点 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.005 | 5 | 6 | 17 | 毛刺全留着,圆被数成 17 边 —— 这时「≥8 就当圆」的门限纯属巧合 |
| 0.010 | 3 | 4 | 14 | 三角形、正方形已经收干,圆还太碎 |
| 0.020(默认) | 3 | 4 | 8 | 三类刚好分开,也是配置里的值 |
| 0.040 | 3 | 4 | 6 | 圆开始掉边 |
| 0.060 及以上 | 3 | 4 | 4 | 圆被压成四边形 —— 按顶点数分类在这个区间失效 |
八个参数各改哪一层
| 参数 | 板上范围 | 动的是哪一层 | 拖大会怎样 / 拖小会怎样 |
|---|---|---|---|
threshold_value | 0~255 | 灰度 → 二值 | 大了目标被切掉(>149 全灭);小了背景一起变白(<68) |
epsilon x1000 | 0~200 | 凸包 → 顶点 | 大了顶点掉到 3、形状信息丢光;小了毛刺全保留 |
morph_kernel | 0~9(偶数自动 +1) | 掩膜去噪 | 大了小目标被抹平;小了噪点全留下 |
min_area | 0~5000 | 轮廓过滤 | 大了只剩大工件;小了 38 px² 的噪点也算目标 |
adaptive | 0/1 | 阈值方式 | 1 = 局部自适应(thr 立刻失效);0 = 固定阈值 |
invert | 0/1 | 取亮区还是暗区 | 1 = 找暗的;0 = 找亮的。换台面第一件事就是判它 |
use_hull | 0/1 | 拟合前是否绷紧 | 1 = 真凹角被吃掉(L 形变矩形);0 = 保留真实凹角 |
color | 0/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。答不上的条目回到对应小节补完再交卷 —— 三条全 ✓,这一课就完成了。
invert: false 是找"亮的区域"。白纸上纸片和背景都亮,灰度上分不开。改成 true 后找的是暗区 —— 但如果纸片本身也亮,灰度这条路就走不通了,得回到上一节用颜色区分。这也是传统视觉的边界:先问"目标和背景差在哪一维"。use_hull 开关。08章节总结:这些概念怎么串起来、能带去哪
| 概念 | 它解决了什么 | 和别的关系 |
|---|---|---|
| 二值化 + 形态学 | 把"灰度混在一起"变成"目标/背景两块",顺便去掉毛刺 | 是轮廓的前提;invert 选错,后面全白费 |
| findContours | 把一片白区变成一串有序的边界点 | 依赖二值化质量;它的输出喂给凸包与逼近 |
| convexHull | 把假凹角绷平,稳住顶点数 | 可选一步;目标真凹时必须关掉,否则形状被吃平 |
| approxPolyDP | 把几千个点压成几个顶点 | 容差 = 比例 × 凸包周长;它直接决定"顶点数"这个结论 |
| 顶点 / 面积 / 质心 | 变成几何量:形状、大小、位置 | 形状分类、框选、质心转向都从这里出发 |
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与机械臂课程的视觉抓取实验。
下一个实验换一种完全不同的思路:不再自己算几何,而是调一个成熟的接口把黑白格子读成字符串 —— 顺便看看"写成什么、读回什么"的闭环是什么感觉。