Files
ros_contorl/scripts/check_sim_scene.py
T
arm_dev d32e6ae53f P5c-6 场景物料落地 + Gazebo 中文手册:工件/通止规模型、场景校验、两处静默失效修复
一、场景物料(新增)
  devices/sim/gazebo/models/workpiece/   带三个孔的铝板。碰撞体是"带方孔的平板",
    视觉额外加两个 φ14/φ7 亮环 —— 因为真机的 HoughCircles 就是靠这两个亮环找孔,
    只按碰撞体建视觉会让"孔检测"在仿真里永远失败,而那看起来像算法问题。
  devices/sim/gazebo/models/gauge/       通止规:通端/套环/握柄/止端四段,各段异色
    便于肉眼判断方向。接触刚度刻意比台面软 —— 位置控制器会"刚性压进去",
    用默认刚度会被弹飞(注释里写了原因,不是随手抄的参数)。
  devices/sim/gazebo/models/gauge_stand/ 规座(static)。规直接站在台面上会立刻倒,
    加个方孔底座把它立住。
  三个模型都是 model.sdf + model.config,原点在**底面中心**,单位米。

二、场景配置与校验(新增)
  config/scene.yaml     物体不再写死在 world 里,改为 scene.yaml + 启动时动态 spawn。
    理由:world 是"环境"(地面/台面/相机/系统插件),场景是"这一批活做什么"。
    动 world 的风险面太大 —— 它只要丢一个系统声明,所有模型都 spawn 不进去(见旧坑 2)。
    含 operating(相机到工件的距离等作业条件)与 gates_px(像素门槛)。
  sim_scene.py          摆放校验:从 SDF **逐碰撞体**解析 AABB(不抄尺寸,避免漂移),
    检查原点在底面 / 不穿台面 / 不出界 / 物体之间不嵌入。
    重叠判定必须逐碰撞体三维判,不能拿总包围盒相交 ——
    正常装配里本来就有嵌套(规立在规座方孔里),粗判会误报,
    而误报的后果是人把检查整个关掉,之后真重叠也抓不到。
    另有成像几何:把亮环/孔间距按相机距离换算成像素,与门槛比对。
  scripts/check_sim_scene.py  验收工具(退出码可用):摆放 + 门槛漂移 + 成像几何 +
    悬停高度 vs 工作空间 + 手外相机是否**完整**看得见工件 + Gazebo 实际位姿。

三、修掉两处静默失效(都是"看起来一切正常")
  1) parse_spawned_poses 一直是**死代码**:它按 "x: N / y: N / z: N" 解析,
     而 Fortress 实际输出是方括号两行(XYZ / RPY)。对不上 → 永远返回空 →
     调用方按设计"降级成跳过位姿核对" → 位姿检查**从来没跑过**,
     而日志只说"跳过",看起来像正常降级。
     现在两种格式都支持,并用真实抓下来的输出样本做回归测试。
     教训:宽容降级会把"没检查"伪装成"检查通过"。
  2) stop_sim_gz.sh 的模式表里漏了 robot_state_publisher(而注释里写着它有覆盖):
     脚本报"已停止",它却还在发上一份 /robot_description 与 TF,
     下一次启动就会"改了描述却不生效"。
     顺带修掉同源的两个问题:awk 用 -v 传模式导致它匹配到自己、每次复查都多报
     一个空命令行的幽灵 PID;以及"杀的时候"和"复查的时候"两份扫描实现会各自漂移。
     现在共用同一个 scan_pids(),模式改用环境变量传递,并在杀完之后**再扫一遍**:
     有残留就报出来并非零退出 —— 下次再有漏项,不再静默。
     实测:--list 列出全套 9 个进程;停止后 rc=0 且 pgrep 干净。

四、相机:D405 分辨率对齐真机
  仿真的 D405 此前渲染 640x480,而真机跑 848x480(fx 337 而非 447)。后果是
  58mm 的两孔间距成像只剩 85px,低于 hole_distance_min=90 ——
  孔检测在仿真里根本不可能通过,而现象看起来像"算法不行"。
  已把 xacro 与 cameras.yaml 一起改成 848x480,并把启动时的比对从"只比视场"
  扩成"视场 + 图幅"(sim_cameras.check_camera_consistency),
  不一致时说明它对像素门槛的影响。

五、文档
  devices/sim/gazebo/README.md 重写为中文手册:使用方法 / 建模要求 / 导入格式 /
    配置方式(含"改哪里才真的生效"的接线表)/ 11 条实测坑(每条都有"怎么发现")。
  manifest.yaml 的进度、assets、description、坑清单与本轮状态对齐。
  scripts/README.md 补两个验收工具与停止脚本;根 README 补仿真入口与手册链接。
  cameras.yaml 里一处过期的函数名(check_fov_consistency)修正。

六、测试
  新增 test_sim_scene.py(79 条,此前 sim_scene.py 完全没有单测 —— 这正是
    位姿解析死掉却无人发现的原因)与 test_check_sim_scene.py(21 条),
  含三条回归:world_boxes 的偏移碰撞体不能被对称化放大(假重叠)、
  center_bore 不能被 _bore 后缀吃掉、Fortress 位姿输出格式。
  全量:src/arm_sim_gazebo + arm_interfaces + arm_application 共 515 通过。

同时附上 config/site.example.yaml 的重新生成(此前 site.yaml 的注释改动没同步,
它的 parity 测试是红的)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 17:13:00 +08:00

439 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""仿真场景验收:摆放对不对、孔检测的成像几何够不够、粗定位看不看得见工件。
=============================================================================
为什么需要这个工具
=============================================================================
场景里的错误和相机一样,几乎都是**静默**的 —— 跑得起来、报不出错,
只是"结果不对"。这个脚本把四类问题在启动阶段就变成可打印的结论:
1. 摆放讲不通:悬空 / 穿进台面 / 超出台面 / 两个物体互相嵌入。
表现是"工件自己会动、会飞",而人会怀疑控制或标定。
2. 场景文件里的像素门槛与真机配置**漂移**。
scene.yaml 为了能在启动时就算出"能不能看见",抄了一份门槛值;
抄一份就有漂移风险。这里读 hole_detect.yaml + site.yaml 的生效值做核对,
不一致直接判失败 —— 不允许"仿真按旧门槛通过、真机按新门槛不认"。
3. 成像几何不够用:亮环太小 / 两孔间距不够像素门槛。
这些门槛是**像素**量,只有在仿真侧用几何算出来才能提前发现;
等上真机再发现要贵得多(要重新调悬停高度、甚至改工件工装)。
4. 二阶段的悬停高度**超出机械臂工作空间**。
这个坑很实在:相机到工件的距离决定了像素尺度,而"距离够近"与
"法兰别抬太高"是一对矛盾。脚本会把两者都算出来并核对工作空间上下界。
5. 粗定位(D435)看不看得见工件。
二阶段粗居中靠 D435 出工件的 bbox;工件被裁掉一角时检测仍会出结果
(框不全而已),于是算出的中心偏掉 —— 这属于"能出结果但结果是错的"。
用法(需要 ROS 环境以读 cameras.yaml 内参;查 Gazebo 位姿需要仿真在跑):
source scripts/sim_env.sh
python3 scripts/check_sim_scene.py
python3 scripts/check_sim_scene.py --no-poses # 跳过"查 Gazebo 实际位姿"
退出码 0 = 通过;非 0 = 有问题(可直接用于启动自检 / CI)。
=============================================================================
"""
from __future__ import annotations
import argparse
import math
import subprocess
import sys
from pathlib import Path
from typing import Optional
REPO_ROOT = Path(__file__).resolve().parents[1]
# ---------------------------------------------------------------------------
# 小工具
# ---------------------------------------------------------------------------
def rpy_to_R(r: float, p: float, y: float):
"""rpy(ZYX 复合顺序:R = Rz·Ry·Rx)→ 3x3 旋转矩阵。
顺序不能想当然:URDF/SDF 的 rpy 都是 Rz·Ry·Rx。
"先俯 50° 再偏航 180°" 与 "先偏航再俯" 的结果**不同**,
这个项目就为此把 D435 的视线翻到背后过(见 assembly.world 的注释)。
"""
cr, sr = math.cos(r), math.sin(r)
cp, sp = math.cos(p), math.sin(p)
cy, sy = math.cos(y), math.sin(y)
return [
[cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr],
[sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr],
[-sp, cp * sr, cp * cr],
]
def matmul3(a, b):
return [[sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
def matvec3(a, v):
return [sum(a[i][k] * v[k] for k in range(3)) for i in range(3)]
def transpose3(a):
return [[a[j][i] for j in range(3)] for i in range(3)]
# ---------------------------------------------------------------------------
# 门槛核对:scene.yaml 抄的那份 vs 真机真正生效的那份
# ---------------------------------------------------------------------------
def effective_gates(root: Optional[Path] = None) -> dict:
"""读出真机**实际生效**的孔检测像素门槛。
生效规则(见 arm_application/hole_detector.config_from_site):
包内 config/hole_detect.yaml 给默认值,config/site.yaml 的 hole_detect.* 覆盖它。
半径取两个区间的**交集**:HoughCircles 的搜索范围(hough_min/max_radius)
与工业校验的接受范围(min_radius/max_radius)必须同时满足才叫"能被用上"。
只按其中一个判,会得出"能检测到"的乐观结论,而实际在另一个环节被滤掉。
"""
import yaml
base_root = Path(root) if root is not None else REPO_ROOT
pkg = base_root / "src/arm_application/config/hole_detect.yaml"
site = base_root / "config/site.yaml"
if not site.is_file():
site = base_root / "config/site.example.yaml"
base = {}
if pkg.is_file():
base = yaml.safe_load(pkg.read_text(encoding="utf-8")) or {}
over = {}
if site.is_file():
doc = yaml.safe_load(site.read_text(encoding="utf-8")) or {}
over = (doc.get("hole_detect") or {}) if isinstance(doc, dict) else {}
def val(key, default):
v = over.get(key, base.get(key, default))
try:
return float(v)
except (TypeError, ValueError):
return float(default)
lo = max(val("hough_min_radius", 10), val("min_radius", 10))
hi = min(val("hough_max_radius", 30), val("max_radius", 30))
return {
"insert_ring_radius": (lo, hi),
"hole_spacing": (val("hole_distance_min", 150), val("hole_distance_max", 700)),
"_source": (str(site), str(pkg)),
}
def workspace_z_limits(root: Optional[Path] = None):
"""从 site.yaml 读机械臂法兰的工作空间 Z 上下界(用于核对悬停高度)。"""
import yaml
base_root = Path(root) if root is not None else REPO_ROOT
site = base_root / "config/site.yaml"
if not site.is_file():
site = base_root / "config/site.example.yaml"
if not site.is_file():
return None
doc = yaml.safe_load(site.read_text(encoding="utf-8")) or {}
servo = doc.get("servo") or {}
try:
return float(servo["workspace_xyz_min"][2]), float(servo["workspace_xyz_max"][2])
except (KeyError, IndexError, TypeError, ValueError):
return None
# ---------------------------------------------------------------------------
# 检查项
# ---------------------------------------------------------------------------
def check_placement(scene, root) -> list[str]:
from arm_sim_gazebo.sim_scene import validate_scene
return validate_scene(scene, root)
def check_gate_drift(scene, root=None) -> list[str]:
"""scene.yaml 的 gates_px 是否与真机生效值一致。"""
eff = effective_gates(root)
out = []
for key, gate in scene.gates_px.items():
want = eff.get(key)
if want is None:
continue
if abs(gate[0] - want[0]) > 1e-6 or abs(gate[1] - want[1]) > 1e-6:
out.append(
f"gates_px.{key} 写的是 [{gate[0]:g}, {gate[1]:g}],"
f"而真机生效值是 [{want[0]:g}, {want[1]:g}]。\n"
f" 真机值来自 {eff['_source'][0]}(覆盖 {eff['_source'][1]})。\n"
f" 这不是小事:两边不一致时,仿真会按旧门槛判'能检测到',"
f"而真机按新门槛不认 —— 属于最费时间的一类问题。\n"
f" 修法:把 devices/sim/gazebo/config/scene.yaml 的 gates_px 改成真机值。"
)
missing = set(eff) - {"_source"} - set(scene.gates_px)
for key in sorted(missing):
out.append(
f"gates_px 里缺 {key}(真机有这个门槛 {eff[key]})—— "
f"缺了就不会校验它,属于「静默放行」。补上它。"
)
return out
def check_geometry(scene, root) -> tuple[list[str], dict]:
"""孔检测的像素几何:算出实际像素值,与门槛比。"""
from arm_sim_gazebo.sim_cameras import intrinsics, load_cameras
from arm_sim_gazebo.sim_scene import check_pixel_gates, operating_pixel_report
specs = load_cameras(root=root)
cam = next((s for s in specs if s.ros_ns == scene.operating.camera), None)
if cam is None:
return [f"cameras.yaml 里没有相机 {scene.operating.camera}"], {}
k, _ = intrinsics(cam, cam.width or 640, cam.height or 480)
px = operating_pixel_report(scene, k[0], root)
if not px:
return ["读不出工件模型里的检测特征(visual 名字约定变了?见 sim_scene.features_from_workpiece)"], {}
return check_pixel_gates(px, scene.gates_px), px
def check_hover_height(scene, root) -> list[str]:
"""悬停高度是否落在机械臂工作空间的 Z 区间内。"""
from arm_sim_gazebo.sim_scene import operating_flange_z
fz = operating_flange_z(scene, root)
if fz is None:
return ["读不出工件高度,算不出建议悬停高度"]
lim = workspace_z_limits(root)
if lim is None:
return []
lo, hi = lim
out = []
if fz > hi:
out.append(
f"按当前 camera_to_part_m={scene.operating.camera_to_part_m}m 算出的悬停法兰高度 "
f"z≈{fz:.3f}m **超出工作空间上界 {hi}m**。\n"
f" 二阶段的位姿检查会直接拒绝执行。\n"
f" 两个旋钮:调小 camera_to_part_m(相机更贴近工件 → 像素尺度更大、"
f"更容易过像素门槛),或把工件/台面改矮。\n"
f" 注意这两者是对立的:离得越近像素越大(检测更好),但法兰越高(越够不着)。"
)
elif fz < lo:
out.append(f"算出的悬停法兰高度 z≈{fz:.3f}m 低于工作空间下界 {lo}m。")
return out
def check_d435_visibility(scene, root) -> tuple[list[str], str]:
"""把工件顶面的四角投影到 D435 画面,看是否完整可见。
这一步对应二阶段的**粗定位**:D435 出工件的 bbox,取其中心作为粗目标。
工件被裁掉一角时检测照样出结果(框小了而已),算出的中心就偏了 ——
"能出结果但结果是错的",正是这个项目反复强调的那一类。
"""
from arm_sim_gazebo.sim_cameras import intrinsics, load_cameras
from arm_sim_gazebo.sim_scene import world_boxes
from arm_sim_gazebo.robot_description import model_link_pose
from arm_sim_gazebo.sim_site import resolve_world
specs = load_cameras(root=root)
cam = next((s for s in specs if s.is_world_mounted), None)
if cam is None:
return [], "(cameras.yaml 里没有世界相机,跳过)"
part = next((o for o in scene.objects if o.name == scene.operating.part_object), None)
if part is None:
return [f"场景里没有 {scene.operating.part_object!r},无法核对粗定位可见性"], ""
world_path, _ = resolve_world("", root)
try:
(px, py, pz), (rr, rp, ry) = model_link_pose(Path(world_path), cam.world_model, cam.world_link)
except Exception as exc:
return [f"读不出 {cam.world_model} 在世界里的位姿:{exc}"], ""
boxes = world_boxes(scene, part, root)
if not boxes:
return ["读不出工件的碰撞体,无法投影"], ""
top = max(b[5] for b in boxes)
x0 = min(b[0] for b in boxes)
x1 = max(b[1] for b in boxes)
y0 = min(b[2] for b in boxes)
y1 = max(b[3] for b in boxes)
corners = [(x0, y0, top), (x0, y1, top), (x1, y0, top), (x1, y1, top)]
w, h = cam.width or 640, cam.height or 480
k, _ = intrinsics(cam, w, h)
fx, fy, cx, cy = k[0], k[4], k[2], k[5]
R_wb = rpy_to_R(rr, rp, ry)
# 本体 → 光学:相机本体是"X 朝前/Y 左/Z 上",光学系是"Z 前/X 右/Y 下"
R_bo = rpy_to_R(-math.pi / 2, 0.0, -math.pi / 2)
R_wo = matmul3(R_wb, R_bo)
R_ow = transpose3(R_wo)
t = [px, py, pz]
probs: list[str] = []
us, vs = [], []
for c in corners:
d = [c[i] - t[i] for i in range(3)]
o = matvec3(R_ow, d)
if o[2] <= 1e-6:
probs.append(
f"工件有一个角在 {cam.ros_ns} 的**背后**(光学系 z={o[2]:.3f})——"
f"相机朝向反了或工件跑到相机后面了。"
)
return probs, ""
us.append(cx + fx * o[0] / o[2])
vs.append(cy + fy * o[1] / o[2])
u0, u1, v0, v1 = min(us), max(us), min(vs), max(vs)
info = (f"{cam.ros_ns}: 工件顶面四角投影 u∈[{u0:.0f},{u1:.0f}] v∈[{v0:.0f},{v1:.0f}] "
f"(画面 {w}x{h})")
if u0 < 0 or u1 > w or v0 < 0 or v1 > h:
probs.append(
f"工件没有完整落在 {cam.ros_ns} 画面内({info})。\n"
f" 粗定位会框不全工件 → bbox 中心偏 → **照样出结果但结果是错的**。\n"
f" 修法:挪工件(scene.yaml 的 pose)、挪相机(world 里的 pose),"
f"或确认这台相机的视野本来就该覆盖整个台面。"
)
return probs, info
def check_gazebo_poses(scene, root) -> tuple[list[str], str]:
"""查 Gazebo 里物体的实际位姿,与 scene.yaml 对照。
这一步能抓到"spawn 了但掉下去/被顶飞"这类只在运行后暴露的问题。
需要仿真在跑;不在跑就跳过(不算失败)。
"""
from arm_sim_gazebo.sim_scene import parse_spawned_poses, pose_mismatches
try:
r = subprocess.run(
["ign", "model", "--list"],
capture_output=True,
text=True,
timeout=20,
)
except (OSError, subprocess.TimeoutExpired) as exc:
return [], f"(跳过:调不动 ign({exc}))"
if r.returncode != 0 or "Requesting state" not in (r.stdout + r.stderr):
return [], "(跳过:Gazebo 没在跑)"
actual = {}
for obj in scene.objects:
try:
rr = subprocess.run(
["ign", "model", "-m", obj.name, "-p"],
capture_output=True,
text=True,
timeout=20,
)
except (OSError, subprocess.TimeoutExpired):
continue
got = parse_spawned_poses(rr.stdout + rr.stderr)
if obj.name in got:
actual[obj.name] = got[obj.name]
if not actual:
return [], "(跳过:解析不出 Gazebo 的位姿输出)"
return pose_mismatches(scene, actual), f"(已在 Gazebo 里核对 {len(actual)} 个物体)"
# ---------------------------------------------------------------------------
def main() -> int:
ap = argparse.ArgumentParser(
description="仿真场景验收:摆放 + 孔检测成像几何 + 粗定位可见性。",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
ap.add_argument("--scene", default="", help="场景文件路径(留空=仓库默认)")
ap.add_argument("--no-poses", action="store_true", help="跳过「查 Gazebo 实际位姿」")
args = ap.parse_args()
try:
from arm_sim_gazebo.sim_scene import SceneError, load_scene
except ImportError as exc:
print(
f"✘ 导入 arm_sim_gazebo 失败:{exc}\n"
f" 先执行: source scripts/sim_env.sh && . install_sim/setup.bash",
file=sys.stderr,
)
return 2
root = REPO_ROOT
try:
scene = load_scene(Path(args.scene) if args.scene else None, root=root)
except SceneError as exc:
print(f"✘ 场景配置读不了:\n{exc}", file=sys.stderr)
return 2
print(f"场景文件: {scene.source}")
print(f"模型目录: {scene.models_dir} 台面顶面 z={scene.table_top_z}")
print("物体:")
for o in scene.objects:
print(f" {o.name:<14} model={o.model:<14} pose=({o.x:+.3f}, {o.y:+.3f}, {o.z:+.3f}) yaw={o.yaw:+.3f}")
problems: list[str] = []
print("\n[1] 摆放校验(悬空/穿模/出界/嵌入)")
p = check_placement(scene, root)
problems += p
print(" ✓ 通过" if not p else "\n".join(f" ✘ {x}" for x in p))
print("\n[2] 像素门槛与真机配置核对")
p = check_gate_drift(scene, root)
problems += p
print(" ✓ 与真机生效值一致" if not p else "\n".join(f" ✘ {x}" for x in p))
print("\n[3] 孔检测成像几何")
p, px = check_geometry(scene, root)
problems += p
for k, v in sorted(px.items()):
gate = scene.gates_px.get(k)
tag = f"门槛 [{gate[0]:g}, {gate[1]:g}]" if gate else "无门槛"
ok = "" if (gate is None or gate[0] <= v <= gate[1]) else " ← 不合格"
print(f" {k:<22} = {v:7.1f}px {tag}{ok}")
if p:
print("\n".join(f" ✘ {x}" for x in p))
print("\n[4] 二阶段悬停高度 vs 工作空间")
p = check_hover_height(scene, root)
problems += p
from arm_sim_gazebo.sim_scene import operating_flange_z
fz = operating_flange_z(scene, root)
lim = workspace_z_limits(root)
if fz is not None:
print(f" 建议悬停法兰 z≈{fz:.3f}m"
+ (f" 工作空间 z∈[{lim[0]}, {lim[1]}]" if lim else ""))
print(
" (真机 site.yaml 写的是 0.23 —— 两者不同是已知的:"
"决定成像的是相机到工件的距离,不是绝对高度)"
)
if p:
print("\n".join(f" ✘ {x}" for x in p))
print("\n[5] 粗定位可见性(工件是否完整落在手外相机画面内)")
p, info = check_d435_visibility(scene, root)
problems += p
if info:
print(f" {info}")
if p:
print("\n".join(f" ✘ {x}" for x in p))
elif info and not info.startswith("("):
print(" ✓ 完整可见")
if not args.no_poses:
print("\n[6] Gazebo 实际位姿核对")
p, info = check_gazebo_poses(scene, root)
problems += p
if info:
print(f" {info}")
print(" ✓ 一致" if not p else "\n".join(f" ✘ {x}" for x in p))
print()
if problems:
print(f"=== ✘ 不通过:{len(problems)} 个问题 ===")
print(" 场景与成像的问题大多是静默的:跑得起来、报不出错,只是结果不对。")
return 1
print("=== ✓ 全部通过 ===")
return 0
if __name__ == "__main__":
sys.exit(main())