c1cd862eea
这批是 09-24 那轮仿真工作的收尾,一直没提交。内容按目的分四块: 1) 仿真真能「夹住」工件 sim_grasp_lock:给规与夹爪之间加接触锁(默认关,run_gauge_assembly --sim 才开)。 没有它时仿真里规只靠摩擦,S08 棘轮一转就滑出指间,于是把「仿真不真」误读成 「力判据不对」。新增 test_sim_grasp_lock.py。 2) 腕部力传感器进仿真 sim_wrist_ft(+ config/wrist_ft.yaml):S07 的首选判据是腕部 FT 的轴向突变, 而仿真里以前根本没有腕部力,S07 只能退到触觉兜底,两套判据从没在同一场景下比过。 现在两条通道都在,接触判定可对照。 3) 真值位姿可查(不再靠猜) sim_live_pose / sim_pose_service:把 Gazebo 里刚体的实测位姿发布出来, stage2 的真值路径按**实测**位姿算孔位,而不是按场景文件里的标称位姿。 顺带把真值孔位的推导(truth_holes)独立出来并补测。 4) 工具几何成为可配置项 config/sim_tool_geometry.yaml + stage2 的 tool_tip_below_flange_m 启动参数: 仿真里规会在指间下滑,工具长每次装夹都不同(实测见过 243/254mm), 悬停高度与 S07 下探预算必须按实测值算,不能再写死。 应用层同时跟进:gauge_fsm / gauge_config 把「悬停时工具尖端入没入工件」的判据 (hover_z_guard)与拖件判定(drag_verdict)收成纯函数并补测;stage3 搜孔、 site 导出、run_gauge_assembly 与若干文档同步。 新增 test_gauge_exit_lift / test_gauge_gripper_client / test_node_handler_collisions。 测试:全仓库 1085 passed。
611 lines
26 KiB
Python
611 lines
26 KiB
Python
#!/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 sim_robot_servo(doc: dict) -> dict:
|
||
"""仿真那台臂的 servo 段(顶层 servo 被 robots[] 里 sim 那台覆盖后的结果)。
|
||
|
||
⚠️ 必须合并,不能只读顶层:顶层那套是**真机**的(台面与基座同高,所以
|
||
工作盒 z 是 0.18~0.65),而仿真里机械臂站在地面、台面 0.40 高,一切 Z 都
|
||
大 0.412。只读顶层会拿真机的界去核对仿真的悬停高 —— 看起来"过了",
|
||
而真正生效的配置可能根本不对(或者反过来,明明配对了却报越界)。
|
||
"""
|
||
servo = dict(doc.get("servo") or {})
|
||
for robot in (doc.get("robot") or {}).get("robots") or []:
|
||
if str(robot.get("arm") or "") == "sim" or str(robot.get("sn") or "") == "sim":
|
||
servo.update(robot.get("servo") or {})
|
||
return servo
|
||
|
||
|
||
def workspace_z_limits(root: Optional[Path] = None):
|
||
"""机械臂法兰的工作空间 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 = sim_robot_servo(doc)
|
||
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。")
|
||
|
||
# 一阶段跟踪高还要再抬 TRACK_CLEARANCE_M(见 pipeline_node 的常量)。
|
||
# 单独查这一条的原因:二阶段能过、S04 一跟踪就被"超出工作盒"截断,
|
||
# 现象是"跟踪位置总差一截/越跟踪越偏",而配置看着完全合理。
|
||
try:
|
||
from arm_application.pipeline_node import TRACK_CLEARANCE_M
|
||
except Exception: # noqa: BLE001 —— 拿不到常量就不查,不因此拦人
|
||
return out
|
||
track = fz + float(TRACK_CLEARANCE_M)
|
||
if track > hi:
|
||
out.append(
|
||
f"一阶段跟踪高 = 悬停 {fz:.3f} + {TRACK_CLEARANCE_M:.2f} = {track:.3f}m "
|
||
f"**超出工作空间上界 {hi}m**。\n"
|
||
f" 二阶段的位姿检查能过、S04 一跟踪却会被「超出工作盒」截断 —— "
|
||
f"现象是「越跟踪越偏」,很难往配置上想。\n"
|
||
f" 修法(仿真里都自由):把 site.yaml 里仿真那台臂的 "
|
||
f"servo.workspace_xyz_max.z 抬到 ≥ {track:.3f},"
|
||
f"或调小 scene.yaml 的 camera_to_part_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_table_slot(scene, root) -> tuple[list[str], str]:
|
||
"""台面通槽 vs 工件:既要"规的通端过得去",又要"工件掉不下去"。
|
||
|
||
两个方向都会出事,而且都是**静默**的(2026-09-24 各栽过一次):
|
||
|
||
a) 槽开得不够 **窄**(< 规的通端):规下压到 12mm 就顶在台面上,
|
||
S07 一路压到上限都等不到台肩落面 → 报"没触发接触判据",
|
||
看起来像传感器/阈值坏了。
|
||
b) 槽开得**太宽**:工件跨在槽上时承压面太小,一有扰动就翻进槽里、
|
||
落到地面 —— 于是 `perception.source=truth` 开始撒谎:孔位按名义位姿
|
||
投到像素上,而孔位正下方已经不是工件(是槽底/地面),
|
||
深度反投影把 58mm 孔距算成 169mm,S05 连丢 9 拍超时。
|
||
|
||
所以这里同时查两件事:孔位必须落在**开口**内,承压宽度必须**够**。
|
||
"""
|
||
import xml.etree.ElementTree as ET
|
||
|
||
from arm_sim_gazebo.sim_scene import rings_local, world_boxes
|
||
from arm_sim_gazebo.sim_site import resolve_world
|
||
|
||
world_path, _ = resolve_world("", root)
|
||
try:
|
||
tree = ET.parse(world_path)
|
||
except (OSError, ET.ParseError) as exc:
|
||
return [f"读不出 world({world_path}):{exc}"], ""
|
||
table = None
|
||
for m in tree.getroot().iter("model"):
|
||
if m.get("name") == "work_table":
|
||
table = m
|
||
break
|
||
if table is None:
|
||
return [f"{world_path} 里没有 <model name=\"work_table\"> —— 台面被改名了?"], ""
|
||
|
||
def _pose_xyz(node) -> tuple[float, float, float]:
|
||
p = node.find("pose")
|
||
if p is None or not (p.text or "").strip():
|
||
return (0.0, 0.0, 0.0)
|
||
v = [float(x) for x in p.text.split()[:3]]
|
||
while len(v) < 3:
|
||
v.append(0.0)
|
||
return (v[0], v[1], v[2])
|
||
|
||
my = _pose_xyz(table)[1]
|
||
spans: list[tuple[float, float]] = []
|
||
for link in table.findall("link"):
|
||
ly = my + _pose_xyz(link)[1]
|
||
for col in link.findall("collision"):
|
||
box = col.find("geometry/box/size")
|
||
if box is None or not (box.text or "").strip():
|
||
continue
|
||
cy = ly + _pose_xyz(col)[1]
|
||
half = float(box.text.split()[1]) / 2.0
|
||
spans.append((cy - half, cy + half))
|
||
if not spans:
|
||
return ["work_table 里读不到任何 box 碰撞体 —— 台面几何解析不出来"], ""
|
||
spans.sort()
|
||
|
||
b = scene.table_bounds
|
||
# 开口 = 台面范围内的"没被碰撞体盖住"的 y 区间。
|
||
gaps: list[tuple[float, float]] = []
|
||
cur = b.y_min
|
||
for lo, hi in spans:
|
||
if lo > cur + 1e-9:
|
||
gaps.append((cur, min(lo, b.y_max)))
|
||
cur = max(cur, hi)
|
||
if cur < b.y_max - 1e-9:
|
||
gaps.append((cur, b.y_max))
|
||
gaps = [(lo, hi) for lo, hi in gaps if hi - lo > 1e-6]
|
||
|
||
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}"], ""
|
||
boxes = world_boxes(scene, part, root)
|
||
if not boxes:
|
||
return ["读不出工件的碰撞体"], ""
|
||
wy0 = min(float(v) for bx in boxes for v in (bx[2], bx[3]))
|
||
wy1 = max(float(v) for bx in boxes for v in (bx[2], bx[3]))
|
||
|
||
# 承压宽度 = 工件在 y 方向上被台面碰撞体盖住的长度(区间求交后累加)。
|
||
supported = 0.0
|
||
for lo, hi in spans:
|
||
supported += max(0.0, min(hi, wy1) - max(lo, wy0))
|
||
|
||
rings = rings_local(scene, part, root)
|
||
hole_ys: list[float] = []
|
||
for r in rings:
|
||
c, s = math.cos(float(part.yaw)), math.sin(float(part.yaw))
|
||
hole_ys.append(float(part.y) + s * float(r.x) + c * float(r.y))
|
||
|
||
probs: list[str] = []
|
||
info = (
|
||
f"台面沿 y 的支撑区间 {[(round(lo, 4), round(hi, 4)) for lo, hi in spans]};"
|
||
f"开口 {[(round(lo, 4), round(hi, 4)) for lo, hi in gaps]};"
|
||
f"工件 y∈[{wy0:.4f},{wy1:.4f}] 承压 {supported * 1000:.1f}mm"
|
||
)
|
||
|
||
# (a) 孔位必须落在开口里 —— 否则规的通端顶在台面上,S07 永远等不到台肩落面。
|
||
for i, hy in enumerate(hole_ys):
|
||
ok = any(lo < hy < hi for lo, hi in gaps)
|
||
if not ok:
|
||
probs.append(
|
||
f"第 {i + 1} 个孔(世界 y={hy:+.4f})**不在台面开口内** —— "
|
||
f"规的通端会顶在台面上。\n"
|
||
f" 后果:S07 下压 12mm 就到底,一路到上限都等不到台肩落面,"
|
||
f"报「没触发接触判据」,看起来像传感器/阈值坏了。\n"
|
||
f" 修法:把开口挪到孔位下方(worlds/assembly.world 的 work_table "
|
||
f"碰撞体 y 区间),或改工件摆放。\n"
|
||
f" 当前开口:{[(round(lo, 4), round(hi, 4)) for lo, hi in gaps]}"
|
||
)
|
||
|
||
# (b) 承压宽度必须够 —— 否则工件会翻进槽里掉下去,而真值感知会继续按
|
||
# 名义位姿算,把 58mm 孔距算成 169mm(见 truth_holes.HoleTruth 的说明)。
|
||
min_support = 0.020
|
||
if supported < min_support:
|
||
probs.append(
|
||
f"工件跨在台面开口上的**承压宽度只有 {supported * 1000:.1f}mm**"
|
||
f"(要求 ≥ {min_support * 1000:.0f}mm)。\n"
|
||
f" 后果:板一有扰动就翻进槽里、落到地面;而 "
|
||
f"perception.source=truth 仍按 scene.yaml 的**名义位姿**投孔 → "
|
||
f"孔位下方已经不是工件而是槽底/地面 →\n"
|
||
f" 深度反投影把 58mm 孔距算成 169mm → S05 每拍都丢、最后超时。"
|
||
f"现象看起来是「检测坏了」,根因却是**工件掉了**。\n"
|
||
f" 修法:把开口收窄(改 worlds/assembly.world 里 work_table 碰撞体的 "
|
||
f"y 尺寸/位姿,开口只要够规的通端过就行,不需要覆盖两个孔所在的整个 y 带)。"
|
||
)
|
||
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] 台面通槽 vs 工件(规过得去 + 工件掉不下去)")
|
||
p, info = check_table_slot(scene, root)
|
||
problems += p
|
||
if info:
|
||
print(f" {info}")
|
||
if p:
|
||
print("\n".join(f" ✘ {x}" for x in p))
|
||
else:
|
||
print(" ✓ 孔位都在开口内,且承压宽度够")
|
||
|
||
print("\n[6] 粗定位可见性(工件是否完整落在手外相机画面内)")
|
||
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[7] 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())
|