Files
ros_contorl/scripts/lib/export_site_env.py
T
2026-10-09 14:51:03 +08:00

1080 lines
51 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
"""读取 ``~/ros2/config/site.yaml``,打印 bash 的 export 行或 JSON。
用法:
eval "$(python3 ~/ros2/scripts/lib/export_site_env.py)"
python3 ~/ros2/scripts/lib/export_site_env.py --json
python3 ~/ros2/scripts/lib/export_site_env.py --print
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from pathlib import Path
try:
import yaml
except ImportError:
yaml = None # type: ignore
def _root() -> Path:
return Path(__file__).resolve().parents[2]
def site_path() -> Path:
env = os.environ.get("SITE_CONFIG", "").strip()
if env:
return Path(env).expanduser()
return _root() / "config" / "site.yaml"
def load_site(path: Path | None = None) -> dict:
path = path or site_path()
if not path.is_file():
raise FileNotFoundError(f"找不到 site 配置: {path}")
text = path.read_text(encoding="utf-8")
if yaml is None:
raise RuntimeError("需要 PyYAML:pip/conda install pyyaml")
data = yaml.safe_load(text) or {}
if not isinstance(data, dict):
raise ValueError(f"无效的 site.yaml: {path}")
return data
def list_robots(data: dict) -> list[dict]:
"""从 robot.robots 取清单条目(列表或按 SN 索引的映射)。"""
raw = (data.get("robot") or {}).get("robots") or []
out: list[dict] = []
if isinstance(raw, dict):
for sn, item in raw.items():
row = dict(item or {}) if isinstance(item, dict) else {}
row.setdefault("sn", str(sn))
out.append(row)
return out
if isinstance(raw, list):
for item in raw:
if isinstance(item, dict) and str(item.get("sn") or "").strip():
out.append(dict(item))
return out
# 侧别词 —— 这是**语言别名**,不是站点数据:任何站点都希望
# `--arm left` / `--arm 左` / `--arm 左臂` 能选中 `robots[]` 里 `arm: left` 的那台。
#
# 这里**绝不允许**出现具体 SN(`Rizon4-063989` 之类)。历史上这里是一张
# `89 -> Rizon4-063989` 的写死映射表:换一台机器人后 `--arm 89` 仍然会选中
# 上一台,而且不报错(R1.1)。现在 `89` 这个写法的来源是
# **这台机器 SN 的末两位**,由 `_arm_index()` 从 `robots[]` 现算出来。
_SIDE_WORDS = {
"left": "left",
"l": "left",
"左": "left",
"左臂": "left",
"right": "right",
"r": "right",
"右": "right",
"右臂": "right",
}
_ARM_SECTION_KEYS = (
"camera", "gripper", "paths", "servo", "handeye", "go_home", "yolo", "perception",
# gauge_assembly:几何类参数是**按台子**的 —— 同一个数在仿真与真机上不同。
# 例:workpiece_surface_z_m 仿真 0.412(臂站地面、台面 0.40m),真机 0.012。
# 不登记就会**静默失效**:site.yaml 里写了 robots[sim].gauge_assembly,
# 合出来还是全局值,S07 的几何自检算错 0.4m 却不报错。
"gauge_assembly",
)
def arm_short_id(sn: str) -> str:
"""Flexiv 序列号的"臂号"= 末两位数字(``Rizon4-063989`` → ``89``)。
手眼文件名、结果文件都用这个编号。它是从 SN 派生出来的,不是常量。
"""
tail = str(sn or "").strip().rsplit("-", 1)[-1]
return tail[-2:] if tail.isdigit() and len(tail) >= 2 else ""
def _arm_index(data: dict) -> dict[str, str]:
"""从 ``robots[]`` 派生 ``别名 -> SN`` 索引。
别名包括:完整 SN、``label``(如 ``左臂89``)、侧别词(``left``/``左``)、
SN 末两位(``89``)。全部来自 site.yaml —— 这是"换一台机器人只改配置"的前提。
"""
index: dict[str, str] = {}
for item in list_robots(data):
sn = str(item.get("sn") or "").strip()
if not sn:
continue
names = {sn.lower()}
label = str(item.get("label") or "").strip()
if label:
names.add(label.lower())
arm = str(item.get("arm") or "").strip().lower()
if arm:
names.add(arm)
names.update(word for word, side in _SIDE_WORDS.items() if side == arm)
short = arm_short_id(sn)
if short:
names.add(short)
if arm:
names.add(f"{arm}{short}")
for name in names:
if name:
index.setdefault(name, sn)
return index
def _looks_like_full_sn(token: str) -> bool:
"""区分"完整 SN"与"臂别名"。
``Rizon4-063989`` 这种带连字符的写法允许不在 ``robots[]`` 里
(单臂站点可以只写 ``robot.sn`` 不登记清单);而 ``89`` / ``left`` 这类
**别名**查不到就必须报错,不能当成 SN 硬用。
"""
return "-" in token
def normalize_robot_sn(data: dict, want: str) -> str:
"""把 89 / left / 左臂 / label / 完整 SN 归一成完整 SN(查不到则原样返回)。"""
return resolve_robot_sn(data, want)[0]
def resolve_robot_sn(data: dict, want: str) -> tuple[str, bool]:
"""归一化并报告是否命中。
返回 ``(sn, matched)``。``matched=False`` 表示这个写法在 ``robots[]`` 里
查不到 —— 调用方必须**报错**(见 ``resolve_robot``),不能静默拿它当 SN,
否则会去连一台不存在的机器人。
"""
w = (want or "").strip()
if not w:
return "", False
index = _arm_index(data)
hit = index.get(w.lower())
if hit:
return hit, True
if _looks_like_full_sn(w):
return w, True
return w, False
def describe_arms(data: dict) -> str:
"""给报错信息用:列出本配置里可选的全部机械臂。"""
lines = []
for item in list_robots(data):
sn = str(item.get("sn") or "").strip()
if not sn:
continue
bits = [f" - {sn}"]
arm = str(item.get("arm") or "").strip()
label = str(item.get("label") or "").strip()
if arm or label:
bits.append(f"arm={arm or '?'}")
if label:
bits.append(f"label={label}")
short = arm_short_id(sn)
if short:
bits.append(f"--arm {short}")
lines.append(" ".join(bits))
return "\n".join(lines) or " (robot.robots[] 是空的)"
def _deep_merge(base: dict, overlay: dict) -> dict:
out = dict(base)
for k, v in overlay.items():
if isinstance(out.get(k), dict) and isinstance(v, dict):
out[k] = _deep_merge(out[k], v)
else:
out[k] = v
return out
def resolve_robot(data: dict, sn: str | None = None) -> dict:
"""当前生效的机械臂:环境变量 ``ROBOT_SN`` 覆盖 robot.sn;清单补齐 ip/iface/frames。
选不出来时**抛 ``ValueError``**,绝不猜:
- 给了别名(``89`` / ``left``)但 ``robots[]`` 里没有对应的臂;
- ``robot.sn`` 没写、也没给 ``--arm``。
以前这两种情况会静默落回一个写死的 SN 或一个空的 ``sn``,结果就是
"配置漏了一键,程序照样启动,连的是哪台看运气"。
"""
robot = dict(data.get("robot") or {})
catalog = list_robots(data)
raw = (
sn
or os.environ.get("ROBOT_SN")
or os.environ.get("ROBOT_ARM")
or robot.get("sn")
or ""
)
want, matched = resolve_robot_sn(data, raw)
if str(raw).strip() and not matched:
raise ValueError(
f"选不出机械臂:{str(raw).strip()!r} 既不是 robot.robots[] 里的别名,"
f"也不像完整 SN(完整 SN 形如 Rizon4-063989)。\n"
f"config/site.yaml 里现有:\n{describe_arms(data)}\n"
f"用法:所有终端加同一个 --arm,或直接写完整 SN。"
)
if not want:
raise ValueError(
"config/site.yaml 没有指定机械臂:robot.sn 为空,也没给 --arm。\n"
f"可选:\n{describe_arms(data)}"
)
match: dict = {}
for item in catalog:
if str(item.get("sn") or "").strip() == want:
match = dict(item)
break
out = {k: v for k, v in robot.items() if k != "robots"}
out.update({k: v for k, v in match.items() if k != "robots"})
out["sn"] = want
# 侧别只认 robots[].arm。**不要**从 SN 里猜数字("063989 就是左臂")——
# 那是站点数据写进了代码,换个站点就会把左右搞反。
if not str(out.get("joint_prefix") or "").strip():
out["joint_prefix"] = f"{want}_"
if not str(out.get("base_frame") or "").strip():
out["base_frame"] = f"{want}_base_link"
if not str(out.get("ee_frame") or "").strip():
out["ee_frame"] = f"{want}_flange"
net = data.get("network") or {}
if not str(out.get("iface") or "").strip():
out["iface"] = str(net.get("prefer_iface") or "")
if not str(out.get("ip") or "").strip():
out["ip"] = "192.168.100.1"
if not str(out.get("pc_ip") or "").strip():
out["pc_ip"] = "192.168.100.10"
return out
def apply_active_arm(data: dict, sn: str | None = None) -> tuple[dict, dict]:
"""把 robots[] 里的 camera/gripper/paths/servo 合并到当前 SN 对应的 site 默认值上。"""
robot = resolve_robot(data, sn)
merged = dict(data)
for key in _ARM_SECTION_KEYS:
base = merged.get(key) if isinstance(merged.get(key), dict) else {}
ov = robot.get(key) if isinstance(robot.get(key), dict) else {}
merged[key] = _deep_merge(dict(base or {}), dict(ov or {}))
return merged, robot
def camera_ns(data: dict) -> str:
"""当前腕部相机(d405_left / d405_right)的话题命名空间。"""
cam = data.get("camera") or {}
name = str(cam.get("name") or "d405").strip().strip("/")
return name or "d405"
def camera_frames(data: dict) -> tuple[str, str, str]:
"""当前机械臂**腕部**相机的(命名空间, 光学坐标系, 深度话题)。
一阶段跟踪用哪台相机见 ``track_camera_frames`` —— 不许改本函数去跟
``perception.track_camera``:``SITE_CAM_NAME`` / 二阶段 / 入孔检测都走这里,
切跟踪相机不该把精拍那路也切走。
"""
ns = camera_ns(data)
yolo = data.get("yolo") or {}
frame = str(yolo.get("camera_frame") or "").strip()
depth = str(yolo.get("depth_topic") or "").strip()
if frame in ("", "d405_color_optical_frame"):
frame = f"{ns}_color_optical_frame"
if depth in ("", "/d405/aligned_depth_to_color/image_raw"):
depth = f"/{ns}/aligned_depth_to_color/image_raw"
return ns, frame, depth
#: 顶视跟踪必须自己写的深度量程。缺了就借腕部 0.55m 上限 = D435 的点全丢。
_OVERHEAD_RANGE_REQUIRED = ("min_depth_m", "max_depth_m", "feature_min_range_m")
#: D405 主跟踪切 D435 一次性时必须显式写的键。缺了就静默不切 = 臂停在半空。
_ONESHOT_REQUIRED = (
"oneshot_conf",
"cooldown_s",
"frame_max_age_s",
"oneshot_hold_s",
)
def track_camera_frames(data: dict) -> tuple[str, str, str, str]:
"""一阶段跟踪用的(命名空间, 光学坐标系, 深度话题, 手眼模式)。
``perception.track_camera``:
* ``wrist`` → 腕部相机 + ``eye_in_hand``
* ``overhead`` → 手外相机 + ``eye_to_hand``
"""
data, robot = apply_active_arm(data)
sio = _load_site_io()
role = sio.track_camera_role(data)
mode = sio.track_handeye_mode(role)
if role == "overhead":
over = overhead_camera(data, robot)
ns = str(over.get("name") or "").strip("/")
if not ns:
raise RuntimeError(
"perception.track_camera=overhead,但 cameras 里没有 "
"role 以 overhead 开头的相机"
)
return ns, f"{ns}_color_optical_frame", f"/{ns}/aligned_depth_to_color/image_raw", mode
ns, frame, depth = camera_frames(data)
return ns, frame, depth, mode
def _track_handeye_file(data: dict, robot: dict, mode: str) -> str:
"""跟踪用的手眼文件。overhead 不许去读 ``paths.handeye_file``(那是腕部的)。"""
if mode != "eye_to_hand":
return str((data.get("paths") or {}).get("handeye_file") or "")
over = overhead_camera(data, robot)
cam = str(over.get("name") or "").strip("/")
short = arm_short_id(str(robot.get("sn") or ""))
name = _load_site_io().eye_to_hand_result_name(short, cam)
return str(_root() / "handeye" / "results" / name)
def _apply_overhead_range(data: dict, params: dict) -> None:
"""把 ``perception.overhead`` 的深度量程盖到感知参数上。缺键就红。"""
oh = (data.get("perception") or {}).get("overhead")
if not isinstance(oh, dict):
raise RuntimeError(
"perception.track_camera=overhead 必须在 perception.overhead 写顶视"
"深度量程(min_depth_m / max_depth_m / feature_min_range_m)。"
"腕部那组 0.55m 上限会把 D435 的点全丢掉,看起来像检测失败。"
)
missing = [k for k in _OVERHEAD_RANGE_REQUIRED if k not in oh]
if missing:
raise RuntimeError(
f"perception.overhead 缺 {missing} —— 不许借腕部量程。"
)
params["min_depth_m"] = float(oh["min_depth_m"])
params["max_depth_m"] = float(oh["max_depth_m"])
params["feature_min_range_m"] = float(oh["feature_min_range_m"])
if "work_z_max_m" in oh:
params["work_z_max_m"] = float(oh["work_z_max_m"])
if "z_ref_m" in oh:
params["z_ref_m"] = float(oh["z_ref_m"])
if "feature_mode" in oh:
params["feature_mode"] = str(oh["feature_mode"])
if "hold_point_on_miss" in oh:
params["hold_point_on_miss"] = bool(oh["hold_point_on_miss"])
def _apply_wrist_hybrid(data: dict, robot: dict, params: dict) -> None:
"""腕部主跟踪 + D435 一次性:参数接到感知节点,**不许盖掉腕部量程**。
顶视量程写进 ``overhead_*`` 键。若直接复用 ``min_depth_m``,D405 会按
0.25–1.80 m 收点,近处工件全丢,看起来像检测失败。
"""
perc = data.get("perception") or {}
if "wrist_handoff_conf" not in perc:
raise RuntimeError(
"perception.track_camera=wrist 的混合跟踪必须写 "
"perception.wrist_handoff_conf(画面内最高 conf 低于此才切 D435)。"
"不许在代码里写死 0.5 再抄一份到 yaml。"
)
params["wrist_handoff_conf"] = float(perc["wrist_handoff_conf"])
oh = perc.get("overhead")
if not isinstance(oh, dict):
raise RuntimeError(
"wrist 混合跟踪必须在 perception.overhead 写顶视量程和一次性参数"
"(min_depth_m / max_depth_m / feature_min_range_m / oneshot_conf / "
"cooldown_s / frame_max_age_s / oneshot_hold_s)。"
)
missing = [k for k in _OVERHEAD_RANGE_REQUIRED if k not in oh]
if missing:
raise RuntimeError(
f"perception.overhead 缺 {missing} —— 不许借腕部量程做 D435 一次性。"
)
miss2 = [k for k in _ONESHOT_REQUIRED if k not in oh]
if miss2:
raise RuntimeError(f"perception.overhead 缺一次性键 {miss2} —— 不许用代码默认值。")
over = overhead_camera(data, robot)
ns = str(over.get("name") or "").strip("/")
if not ns:
raise RuntimeError(
"wrist 混合跟踪需要 cameras 里 role 以 overhead 开头的相机"
)
e2h = _track_handeye_file(data, robot, "eye_to_hand")
params["overhead_oneshot_enable"] = True
params["overhead_min_depth_m"] = float(oh["min_depth_m"])
params["overhead_max_depth_m"] = float(oh["max_depth_m"])
params["overhead_feature_min_range_m"] = float(oh["feature_min_range_m"])
params["overhead_oneshot_conf"] = float(oh["oneshot_conf"])
params["overhead_cooldown_s"] = float(oh["cooldown_s"])
params["overhead_frame_max_age_s"] = float(oh["frame_max_age_s"])
params["overhead_hold_s"] = float(oh["oneshot_hold_s"])
params["overhead_handeye_file"] = e2h
params["overhead_camera_source"] = ns
params["overhead_camera_frame"] = f"{ns}_color_optical_frame"
params["overhead_image_topic"] = f"/{ns}/color/image_raw"
params["overhead_info_topic"] = f"/{ns}/color/camera_info"
params["overhead_depth_topic"] = f"/{ns}/aligned_depth_to_color/image_raw"
#: 手眼标定的两种装法。写在这里当"合法值清单",避免各处各写一遍字符串。
HANDEYE_MODES = ("eye_in_hand", "eye_to_hand")
def handeye_board(he: dict, mode: str) -> tuple[dict, str]:
"""取某个模式用的标定板尺寸 → ``({cols, rows, square_size_m}, 来源)``。
权威只有一处:``handeye.boards.<mode>``。为什么按模式分开——两种装法用的是
**两块不同的物理板**(本项目:眼在手上 A5 / 眼在手外 A4),方格边长不一样。
合成一组键的后果不是报错,而是"跑另一种模式时静默用错边长",结果只是系统性偏,
现场几乎联想不到板尺寸。
兼容:没有 ``boards`` 段时,回落到旧的顶层 ``board_cols/rows/square_size_m``。
返回的第二个值是来源(``boards`` / ``legacy`` / ``default``),调用方据此决定
要不要拦下——``default`` 意味着**没人配过这块板**,此时开标定等于赌一个尺寸。
"""
boards = he.get("boards") if isinstance(he.get("boards"), dict) else {}
node = boards.get(mode) if isinstance(boards, dict) else None
src = "boards"
if not isinstance(node, dict):
node = he
src = "legacy"
if mode not in HANDEYE_MODES:
# 不认识的模式:不猜板子,交给调用方报错(猜错就是静默用错尺寸)。
node, src = {}, "default"
def pick(key: str, cast, default):
v = node.get(key) if isinstance(node, dict) else None
if v is None:
return default, False
try:
return cast(v), True
except (TypeError, ValueError):
return default, False
# boards 段用 cols/rows;旧的顶层键是 board_cols/board_rows。两种都认,
# 免得"兼容旧配置"其实读不到旧键,悄悄退回默认 8×11。
cols, ok_c = pick("cols", int, 8)
if not ok_c:
cols, ok_c = pick("board_cols", int, 8)
rows, ok_r = pick("rows", int, 11)
if not ok_r:
rows, ok_r = pick("board_rows", int, 11)
sq, ok_s = pick("square_size_m", float, 0.015)
if src == "legacy" and not (ok_c or ok_r or ok_s):
# 顶层键一个都没有 ⇒ 真的没人配过,不要把"内置默认值"伪装成"配置"。
src = "default"
return {"cols": cols, "rows": rows, "square_size_m": sq}, src
def overhead_camera(data: dict, robot: dict | None = None) -> dict:
"""手外(固定)相机:``cameras.*`` 里 ``role`` 以 ``overhead`` 开头的那台。
**规则只有一份**,在 ``arm_application.site_io.overhead_camera``——S03 粗定位
取命名空间走的也是它。这里若自己再写一遍"按 role 找 + 按 arm 收敛",两份判据
必然漂移,症状是"标定用的相机"和"推理用的相机"不是同一台,且错而不报。
所以这里只做一层薄封装(从源码树 import),拿到配置字典。
"""
fn = _load_overhead_camera_fn()
cam, _others = fn(data, robot)
return cam
def _load_site_io():
"""从 ``src/arm_application`` 取 ``site_io`` 模块。
为什么从源码树而不是已安装包:``export_site_env.py`` 在 ``colcon build``
之前就被调用(``load_site_config.sh`` 的第一件事),那时 install 里可能还没有
这个包。源码树在本仓库里恒定存在(``scripts/`` 与 ``src/`` 同根)。
"""
src = _root() / "src" / "arm_application"
if not (src / "arm_application" / "site_io.py").is_file():
raise RuntimeError(
f"找不到 {src}/arm_application/site_io.py —— 手外相机 / 结果文件名的规则"
"只有那一处,不接受本文件自己再实现一份(两份判据必然漂移)。"
)
if str(src) not in sys.path:
sys.path.insert(0, str(src))
from arm_application import site_io as _mod # noqa: PLC0415
return _mod
def _load_overhead_camera_fn():
return _load_site_io().overhead_camera
def flatten(data: dict) -> dict:
"""供 bash 脚本使用的扁平 ``SITE_*`` 环境变量映射。"""
data, robot = apply_active_arm(data)
paths = data.get("paths") or {}
cam = data.get("camera") or {}
yolo = data.get("yolo") or {}
servo = data.get("servo") or {}
home = data.get("go_home") or {}
net = data.get("network") or {}
he = data.get("handeye") or {}
grip = data.get("gripper") or {}
named_poses = str(
robot.get("named_poses_file")
or paths.get("named_poses_file")
or str(_root() / "src" / "arm_control" / "config" / "named_poses.yaml")
)
weights = str(paths.get("yolo_weights") or "")
# 解析符号链接,方便日志里看到真实文件(历史上有人用 active.pt 做切换,已废弃)
try:
weights_resolved = str(Path(weights).expanduser().resolve())
except Exception:
weights_resolved = weights
# 派生文件名的口径:臂号 = 当前 SN 末两位(见 arm_short_id)。
# 用当前臂现算,**不要**钉死 "89" —— 否则换成 91 或另一台臂时,
# 两边会共用同一个手眼文件,表现是"标定做了但不生效"。
short = arm_short_id(str(robot.get("sn") or ""))
he_mode = str(he.get("mode") or "eye_in_hand")
over = overhead_camera(data, robot)
over_name = str(over.get("name") or "").strip("/")
root = _root()
# 手眼标定的"按模式"派生物:结果文件 / 示教点 / 标定板。
#
# 为什么一切都要按模式分开(eye_in_hand / eye_to_hand):
# 1) 结果文件:眼在手外的产物是"**这台相机的光心 → 这条臂的基座**",换条臂
# (基座不同)或换台相机都不通用 ⇒ 名字要同时带臂号与相机名
# (<臂号>_<相机>_eye_to_hand)。曾经只有 <臂号>_<模式>,于是一个双臂共用
# 的手外相机只能存一份外参,第二条臂拿到的必然错;
# 2) 标定板:两种装法用两块不同的物理板(A5 / A4),方格边长不同 ⇒ 见
# handeye_board() 的说明;
# 3) 示教点:两种模式采的关节角完全不同,共用一份会把上一模式的点位当成本
# 模式的(merge 之后才发现在动错的姿态)。
he_modes_out: dict[str, str] = {}
_e2h_name = _load_site_io().eye_to_hand_result_name
for _m in HANDEYE_MODES:
_tag = _m.upper()
_pose_stem = f"{short}_{_m}" if short else _m
# 结果文件名:眼在手外走 site_io.eye_to_hand_result_name(唯一口径);
# 眼在手上仍是 <臂号>_eye_in_hand.yaml。
if _m == "eye_to_hand":
_result_name = _e2h_name(short, over_name)
else:
_result_name = f"{_pose_stem}.yaml"
# eye_to_hand 允许用显式路径覆盖(与 eye_in_hand 的 paths.handeye_file 对称);
# 不写就用派生的名字。
if _m == "eye_in_hand":
_file = str(
paths.get("handeye_file")
or root / "handeye" / "results" / _result_name
)
_wps = str(
paths.get("handeye_waypoints")
or root / "handeye" / "poses" / f"{_pose_stem}.yaml"
)
else:
_file = str(
paths.get(f"handeye_file_{_m}")
or root / "handeye" / "results" / _result_name
)
_wps = str(
paths.get(f"handeye_waypoints_{_m}")
or root / "handeye" / "poses" / f"{_pose_stem}.yaml"
)
_board, _src = handeye_board(he, _m)
he_modes_out[f"SITE_HANDEYE_FILE_{_tag}"] = _file
he_modes_out[f"SITE_HANDEYE_WAYPOINTS_{_tag}"] = _wps
he_modes_out[f"SITE_HANDEYE_BOARD_COLS_{_tag}"] = str(_board["cols"])
he_modes_out[f"SITE_HANDEYE_BOARD_ROWS_{_tag}"] = str(_board["rows"])
he_modes_out[f"SITE_HANDEYE_SQUARE_{_tag}"] = str(_board["square_size_m"])
he_modes_out[f"SITE_HANDEYE_BOARD_SRC_{_tag}"] = _src
out = {
"SITE_CONFIG": str(site_path()),
"SITE_ROS2_ROOT": str(_root()),
# 必须来自配置。缺了就留空让调用方报错 —— 兜底成某个真实 SN
# 等于"配置漏了照样启动,连的是哪台看运气"。
"SITE_ROBOT_SN": str(robot.get("sn") or ""),
"SITE_ROBOT_IP": str(robot.get("ip") or "192.168.100.1"),
"SITE_PC_IP": str(robot.get("pc_ip") or "192.168.100.10"),
"SITE_JOINT_PREFIX": str(robot.get("joint_prefix") or ""),
"SITE_BASE_FRAME": str(robot.get("base_frame") or ""),
"SITE_EE_FRAME": str(robot.get("ee_frame") or ""),
"SITE_ROBOT_TOOL_NAME": str(robot.get("tool_name") or ""),
"SITE_ROBOT_LABEL": str(robot.get("label") or ""),
"SITE_ROBOT_ARM": str(robot.get("arm") or ""),
"SITE_ROBOT_SHORT_ID": short,
# 本臂有没有装 Orisys 触觉。以前脚本靠 `SN == Rizon4-063989` 来判断,
# 于是"哪台臂有传感器"变成了代码里的常量。现在由 site.yaml 说了算。
"SITE_ROBOT_HAS_TACTILE": "1" if robot.get("tactile") else "0",
"SITE_NAMED_POSES_FILE": named_poses,
"SITE_ROBOT_CATALOG": ",".join(
str(r.get("sn") or "") for r in list_robots(data) if r.get("sn")
),
"SITE_YOLO_WEIGHTS": weights,
"SITE_YOLO_WEIGHTS_RESOLVED": weights_resolved,
"SITE_HANDEYE_WAYPOINTS": he_modes_out["SITE_HANDEYE_WAYPOINTS_EYE_IN_HAND"],
"SITE_HANDEYE_CALIBRATE": str(
paths.get("handeye_calibrate")
or str(_root() / "handeye" / "calibrate.yaml")
),
"SITE_HANDEYE_TOUR": str(
paths.get("handeye_tour")
or str(_root() / "handeye" / "waypoints.yaml")
),
"SITE_HANDEYE_WORKDIR": str(
paths.get("handeye_workdir")
or str(_root() / "handeye" / "tmp" / short)
),
# 不带后缀的两个名字保持旧语义(= 腕部 eye-in-hand 的产物),因为
# launch / 其它脚本一直按这两个名字读;换成"当前模式"会让 site.yaml 里
# 一改 handeye.mode 就把 S05 精拍定位的 TF 换掉,那是另一个功能的静默失效。
# 两个模式各自的名字在 *_EYE_IN_HAND / *_EYE_TO_HAND 里。
"SITE_HANDEYE_FILE": he_modes_out["SITE_HANDEYE_FILE_EYE_IN_HAND"],
"SITE_HANDEYE_MODE": he_mode,
**he_modes_out,
# 不带后缀的板尺寸 = **当前模式**那一组,供 `show` 之类只看当前模式的调用方;
# 标定脚本用带后缀的那三组(按 --mode 选),不依赖这个。
"SITE_HANDEYE_BOARD_COLS": he_modes_out[
f"SITE_HANDEYE_BOARD_COLS_{he_mode.upper()}"
],
"SITE_HANDEYE_BOARD_ROWS": he_modes_out[
f"SITE_HANDEYE_BOARD_ROWS_{he_mode.upper()}"
],
"SITE_HANDEYE_SQUARE_M": he_modes_out[
f"SITE_HANDEYE_SQUARE_{he_mode.upper()}"
],
"SITE_HANDEYE_VEL_SCALE": str(float(he.get("velocity_scale") or 0.05)),
"SITE_HANDEYE_MOVE_T": str(float(he.get("move_time_sec") or 8.0)),
"SITE_HANDEYE_SETTLE_SEC": str(float(he.get("settle_sec") or 1.5)),
"SITE_HANDEYE_SETTLE_QD": str(float(he.get("settle_qd_max") or 0.015)),
"SITE_HANDEYE_SETTLE_STABLE": str(float(he.get("settle_stable_sec") or 0.6)),
"SITE_HANDEYE_SETTLE_TO": str(float(he.get("settle_timeout_sec") or 8.0)),
"SITE_HANDEYE_PIN_HOLDS": str(int(he.get("pin_holds") if he.get("pin_holds") is not None else 2)),
"SITE_HANDEYE_PIN_SEC": str(float(he.get("pin_hold_sec") or 0.45)),
"SITE_HANDEYE_SKIP_UNSTABLE": (
"true" if he.get("skip_unstable_poses", True) else "false"
),
"SITE_FASTDDS_PROFILE": str(paths.get("fastdds_profile") or ""),
"SITE_CAM_NAME": camera_ns(data),
"SITE_CAM_SERIAL": str(cam.get("serial") or ""),
"SITE_CAM_WIDTH": str(int(cam.get("width") or 848)),
"SITE_CAM_HEIGHT": str(int(cam.get("height") or 480)),
"SITE_CAM_FPS": str(int(cam.get("fps") or 30)),
"SITE_CAM_ENABLE_DEPTH": "1" if cam.get("enable_depth", True) else "0",
"SITE_CAM_ENABLE_INFRA": "1" if cam.get("enable_infra", True) else "0",
"SITE_CAM_PREVIEW": "1" if cam.get("preview", True) else "0",
"SITE_CAM_PREVIEW_STEREO": "1" if cam.get("preview_stereo", True) else "0",
"SITE_CAM_OVERLAY_HOLD": str(float(cam.get("overlay_hold_sec") or 1.5)),
"SITE_CAM_USE_SITE_RES": "1" if cam.get("use_site_resolution", True) else "0",
"SITE_CAM_AUTO_EXPOSURE": "1" if cam.get("auto_exposure", True) else "0",
"SITE_CAM_AE_LIMIT_US": str(float(cam.get("ae_limit_us") if cam.get("ae_limit_us") is not None else 4000)),
"SITE_CAM_EMITTER": "1" if cam.get("emitter_enabled", False) else "0",
"SITE_CAM_LASER_POWER": str(float(cam.get("laser_power") if cam.get("laser_power") is not None else 0)),
"SITE_CAM_EXPOSURE_US": str(float(cam.get("exposure_us") or 0)),
"SITE_CAM_GAIN": str(float(cam.get("gain") or 0)),
# 手外(固定)相机的**按角色**导出。eye-to-hand 标定要用它:标定时得订阅
# 这台固定相机,而不是腕部相机(run_handeye.sh 原来恒用 SITE_CAM_NAME,
# 于是 `--mode eye_to_hand` 会去开腕部相机 —— 标出一个"看着成功"的错结果)。
"SITE_OVERHEAD_CAM_NAME": over_name,
"SITE_OVERHEAD_CAM_SERIAL": str(over.get("serial") or ""),
"SITE_OVERHEAD_CAM_WIDTH": str(int(over.get("width") or 1280)),
"SITE_OVERHEAD_CAM_HEIGHT": str(int(over.get("height") or 720)),
"SITE_OVERHEAD_CAM_FPS": str(int(over.get("fps") or 30)),
"SITE_OVERHEAD_CAM_AUTO_EXPOSURE": "1" if over.get("auto_exposure", True) else "0",
"SITE_OVERHEAD_CAM_AE_LIMIT_US": str(
float(over.get("ae_limit_us") if over.get("ae_limit_us") is not None else 8000)
),
"SITE_OVERHEAD_CAM_EMITTER": "1" if over.get("emitter_enabled", True) else "0",
"SITE_OVERHEAD_CAM_EXPOSURE_US": str(float(over.get("exposure_us") or 0)),
"SITE_OVERHEAD_CAM_GAIN": str(float(over.get("gain") or 0)),
"SITE_D435_SERIAL": str(((data.get("cameras") or {}).get("d435") or {}).get("serial") or ""),
"SITE_D435_WIDTH": str(int(((data.get("cameras") or {}).get("d435") or {}).get("width") or 1280)),
"SITE_D435_HEIGHT": str(int(((data.get("cameras") or {}).get("d435") or {}).get("height") or 720)),
"SITE_D435_FPS": str(int(((data.get("cameras") or {}).get("d435") or {}).get("fps") or 30)),
# 序列号**不给默认值**:兜底成某个真实序列号 = 换站点后悄悄打开别人的相机。
# 缺了就让相机节点以"序列号为空"报错,比连错设备好得多(M1)。
"SITE_D405_LEFT_SERIAL": str(((data.get("cameras") or {}).get("d405_left") or {}).get("serial") or ""),
"SITE_D405_RIGHT_SERIAL": str(((data.get("cameras") or {}).get("d405_right") or {}).get("serial") or ""),
"SITE_YOLO_CONF": str(yolo.get("conf") or 0.10),
"SITE_YOLO_IMGSZ": str(int(yolo.get("imgsz") or 640)),
"SITE_YOLO_DEVICE": str(yolo.get("device") or "0"),
"SITE_YOLO_MIN_CONF": str(yolo.get("min_conf") or 0.12),
"SITE_GO_HOME_POSE": str(home.get("pose") or "home"),
"SITE_GO_HOME_SPEED": str(home.get("speed") or 0.25),
"SITE_NET_IFACE": str(robot.get("iface") or net.get("prefer_iface") or ""),
"SITE_SERVO_Z_REF": str(servo.get("z_ref_m") or 0.40),
"SITE_EE_X_M": str(float(servo.get("ee_x_m") or 0.25)),
"SITE_EE_Y_M": str(float(servo.get("ee_y_m") or 0.0)),
"SITE_EE_Z_M": str(float(servo.get("ee_z_m") or 0.24)),
"SITE_SERVO_WATCHDOG": str(servo.get("watchdog_timeout_sec") or 0.3),
"SITE_CAMERA_FRAME": camera_frames(data)[1],
# 一阶段跟踪用的相机/手眼(可与腕部精拍不同)。SITE_CAM_NAME 仍是腕部。
"SITE_TRACK_CAMERA": _load_site_io().track_camera_role(data),
"SITE_TRACK_CAM_NAME": track_camera_frames(data)[0],
"SITE_TRACK_CAMERA_FRAME": track_camera_frames(data)[1],
"SITE_TRACK_HANDEYE_MODE": track_camera_frames(data)[3],
"SITE_TRACK_HANDEYE_FILE": _track_handeye_file(
data, robot, track_camera_frames(data)[3]
),
"SITE_GRIP_VENDOR": str(grip.get("vendor") or "dh"),
"SITE_GRIP_MODEL": str(grip.get("model") or "PGEA-100-20-W"),
"SITE_GRIP_SN": str(grip.get("sn") or ""),
"SITE_GRIP_BACKEND": str(grip.get("backend") or "usb"),
"SITE_GRIP_DEVICE": str(grip.get("device_name") or "GripperDahuanModbus"),
"SITE_GRIP_FORCE_N": str(float(grip.get("force_n") or 50.0)),
"SITE_GRIP_VEL_MPS": str(float(grip.get("vel_mps") or 0.05)),
"SITE_GRIP_PORT": str(grip.get("port") or "/dev/ttyUSB0"),
"SITE_GRIP_BAUD": str(int(grip.get("baud") or 115200)),
"SITE_GRIP_SLAVE": str(int(grip.get("slave_id") or 1)),
"SITE_GRIP_STROKE_MM": str(float(grip.get("stroke_mm") or 20.0)),
"SITE_GRIP_FORCE": str(int(grip.get("force_pct") or 50)),
"SITE_GRIP_SPEED": str(int(grip.get("speed_pct") or 50)),
"SITE_GRIP_AUTO_INIT": "true" if grip.get("auto_init", True) else "false",
"SITE_GRIP_RS485_RTS": str(grip.get("rs485_rts") or ""),
"SITE_HOLE_BACKEND": str((data.get("hole_detect") or {}).get("backend") or "two_stage"),
"SITE_HOLE_WEIGHTS": str(
(data.get("hole_detect") or {}).get("weights")
or paths.get("hole_weights")
or str(_root() / "models" / "hole_insert" / "detect_best_1.onnx")
),
"SITE_HOLE_WORKPIECE_WEIGHTS": str(
(data.get("hole_detect") or {}).get("workpiece_weights")
or paths.get("hole_workpiece_weights")
or weights
),
"SITE_HOLE_CONFIG": str(
(data.get("hole_detect") or {}).get("config")
or (
str(_root() / "src" / "arm_application" / "config" / "hole_detect.yaml")
if str((data.get("hole_detect") or {}).get("backend") or "two_stage")
in ("two_stage", "hough_roi")
else str(_root() / "src" / "arm_application" / "config" / "hole_insert.yaml")
)
),
"SITE_TACTILE_BACKEND": str((data.get("tactile") or {}).get("backend") or "orisys"),
"SITE_TACTILE_CONFIG": str((data.get("tactile") or {}).get("config") or "dd03"),
"SITE_TACTILE_CONFIG_FILE": str(
(data.get("tactile") or {}).get("config_file")
or str(_root() / "src" / "arm_application" / "config" / "orisys" / "dd03.json")
),
"SITE_TACTILE_LEFT": str((data.get("tactile") or {}).get("left_video", 0)),
"SITE_TACTILE_RIGHT": str((data.get("tactile") or {}).get("right_video", 2)),
}
return out
def gripper_overrides(data: dict) -> dict:
"""供 dh_pgea_node 使用的 ROS 参数字典(DH Modbus,不是 Flexiv 夹爪)。"""
data, _robot = apply_active_arm(data)
g = data.get("gripper") or {}
return {
"port": str(g.get("port") or "/dev/ttyUSB0"),
"baud": int(g.get("baud") or 115200),
"slave_id": int(g.get("slave_id") or 1),
"stroke_mm": float(g.get("stroke_mm") or 20.0),
"force_pct": int(g.get("force_pct") or 50),
"speed_pct": int(g.get("speed_pct") or 50),
"auto_init": bool(g.get("auto_init", True)),
"state_hz": float(g.get("state_hz") or 20.0),
"model": str(g.get("model") or "PGEA-100-20-W"),
"serial_number": str(g.get("sn") or ""),
"open_position": int(g.get("open_position") if g.get("open_position") is not None else 1000),
"close_position": int(g.get("close_position") if g.get("close_position") is not None else 0),
"rs485_rts": str(g.get("rs485_rts") or ""),
}
def perception_overrides(data: dict) -> dict:
"""供 perception_node 使用的 ROS 参数字典。"""
data, _robot = apply_active_arm(data)
paths = data.get("paths") or {}
yolo = data.get("yolo") or {}
cam_ns, cam_frame, depth_topic, _he_mode = track_camera_frames(data)
obj = str(yolo.get("object_frame") or "").strip()
if not obj or obj == "tracked_object":
# 跟踪目标的坐标系按**臂号**派生(SN 末两位),两侧互不干扰。
# 以前是从 SN 里找 "063989"/"063991" 子串,换个站点就失去意义了。
short = arm_short_id(str((_robot or {}).get("sn") or ""))
obj = f"tracked_object_{short}" if short else "tracked_object"
out = {
"weights": str(paths.get("yolo_weights") or ""),
"conf": float(yolo.get("conf") or 0.10),
"iou": float(yolo.get("iou") or 0.45),
"imgsz": int(yolo.get("imgsz") or 640),
"device": str(yolo.get("device") or "0"),
"half_precision": bool(yolo.get("half_precision", True)),
"backend": str(yolo.get("backend") or "auto"),
"mp_infer": bool(yolo.get("mp_infer", True)),
"min_conf": float(yolo.get("min_conf") or 0.12),
"vision_hz": float(yolo.get("vision_hz") or 30.0),
"infer_max_width": int(yolo.get("infer_max_width") if yolo.get("infer_max_width") is not None else 0),
"box_ema_alpha": float(yolo.get("box_ema_alpha") or 0.85),
"miss_clear_frames": int(yolo.get("miss_clear_frames") or 12),
"overlay_hold_on_miss": bool(yolo.get("overlay_hold_on_miss", True)),
"jump_px": float(yolo.get("jump_px") or 280.0),
"jump_z_m": float(yolo.get("jump_z_m") or 0.20),
"z_ref_m": float(yolo.get("z_ref_m") or 0.22),
"min_depth_m": float(yolo.get("min_depth_m") or 0.04),
"max_depth_m": float(yolo.get("max_depth_m") or 0.55),
"work_z_max_m": float(yolo.get("work_z_max_m") or 0.50),
"hold_point_on_miss": bool(yolo.get("hold_point_on_miss", True)),
"miss_hold_point_frames": int(yolo.get("miss_hold_point_frames") or 2),
"meas_blend": float(yolo.get("meas_blend") or 0.65),
"z_alpha_min": float(yolo.get("z_alpha_min") or 0.10),
"z_alpha_max": float(yolo.get("z_alpha_max") or 0.60),
"z_alpha_gain": float(yolo.get("z_alpha_gain") or 2.0),
"camera_frame": cam_frame,
"object_frame": obj,
"depth_topic": depth_topic,
"camera_source": cam_ns,
"base_frame": str((_robot or {}).get("base_frame") or ""),
"feature_mode": str(yolo.get("feature_mode") or "plane"),
# feature_mode=keypoint 时用:哪个关键点当跟踪点(k0)、可信度门槛。
"keypoint_index": int(yolo.get("keypoint_index") if yolo.get("keypoint_index") is not None else 0),
"keypoint_min_conf": float(yolo.get("keypoint_min_conf") or 0.30),
"keypoint_win_px": int(yolo.get("keypoint_win_px") if yolo.get("keypoint_win_px") is not None else 6),
# 三条关键点量纲守卫(2026-10-08 真机一阶段被假 k0 带走,见
# feature_guard.py 的注释)。**必须在这里透传**:节点里声明的默认值
# 恰好等于 site.yaml 当前值,漏了不会报错,只会让以后改配置毫无反应
# (R1.1 的"声明了却没接线")。
"keypoint_border_px": float(yolo.get("keypoint_border_px") if yolo.get("keypoint_border_px") is not None else 6.0),
"track_max_step_m": float(yolo.get("track_max_step_m") if yolo.get("track_max_step_m") is not None else 0.04),
"reacquire_far_px": float(yolo.get("reacquire_far_px") if yolo.get("reacquire_far_px") is not None else 120.0),
"reacquire_confirm_frames": int(yolo.get("reacquire_confirm_frames") if yolo.get("reacquire_confirm_frames") is not None else 3),
"plane_inlier_m": float(yolo.get("plane_inlier_m") or 0.008),
"plane_band_m": float(yolo.get("plane_band_m") or 0.06),
}
sio = _load_site_io()
role = sio.track_camera_role(data)
perc = data.get("perception") or {}
if role == "overhead":
_apply_overhead_range(data, out)
out["overhead_oneshot_enable"] = False
elif perc.get("track_camera") or "wrist_handoff_conf" in perc:
# 显式 wrist / 写了混合键 → 必须接全套。缺键就红,不许用代码默认。
# 没写 perception 段的探针测试保持旧行为(不加一次性)。
_apply_wrist_hybrid(data, _robot, out)
else:
out["overhead_oneshot_enable"] = False
return out
def servo_overrides(data: dict) -> dict:
"""供 servo_node 使用的 ROS 参数字典。"""
data, robot = apply_active_arm(data)
paths = data.get("paths") or {}
yolo = data.get("yolo") or {}
servo = data.get("servo") or {}
_ns, cam_frame, _depth, he_mode = track_camera_frames(data)
he_file = _track_handeye_file(data, robot, he_mode)
xy_track = str(servo.get("xy_track") or "hover").strip().lower()
if he_mode == "eye_to_hand" and xy_track == "lookat":
raise RuntimeError(
"perception.track_camera=overhead 时相机固定,lookat(把目标对准画面中心)"
"没有意义。site.yaml 的 servo.xy_track 必须是 hover。"
)
out = {
"joint_prefix": str(robot.get("joint_prefix") or ""),
"base_frame": str(robot.get("base_frame") or ""),
"ee_frame": str(robot.get("ee_frame") or ""),
"handeye_file": he_file,
"handeye_mode": he_mode,
"camera_frame": cam_frame,
"handeye_child_frame": cam_frame,
"arm_label": str(robot.get("label") or robot.get("arm") or ""),
"z_ref_m": float(servo.get("z_ref_m") or yolo.get("z_ref_m") or 0.40),
}
# 透传其余与节点参数同名的 servo 键
keys = [
"control_hz", "watchdog_timeout_sec", "lost_timeout_sec", "stale_hold_sec",
"max_extrapolate_sec", "predict_enable", "predict_sec",
"predict_v_max", "predict_jump_m", "jump_confirm_frames", "feat_ws_margin_m",
"kp_xy", "ki_xy", "kd_xy", "kp_z", "ki_z", "kd_z", "i_sep_pos_m",
"deadzone_m", "deadzone_z_m", "v_max_xy", "v_max_z", "v_approach_z",
# 远处限速(见 _slow_xy_limit):漏了这两个键,site.yaml 改了也不生效,
# 节点会静默退回声明默认值(R1.1)。
"slow_far_m", "v_slow_xy",
"z_min_m", "approach_xy_gate_m", "min_ee_z_m", "e_xy_clip_m",
"ee_x_m", "ee_y_m", "ee_z_m", "ee_hold_xy", "ee_vertical_down",
"xy_track", "hover_offset_ee_xy_m", "arm_label",
"ee_hold_kp", "ee_w_max",
"lock_hold_sec",
"lock_still_sec",
"lock_still_move_m",
"a_max", "max_qdot", "damping",
"vel_lpf_alpha", "dq_window_size", "traj_stream_hz", "exec_horizon_sec",
"min_cmd_delta_rad", "point_topic", "traj_topic",
"use_static_handeye_tf",
"workspace_xyz_min", "workspace_xyz_max",
]
for k in keys:
if k in servo:
out[k] = servo[k]
sio = _load_site_io()
perc = data.get("perception") or {}
if sio.track_camera_role(data) == "wrist" and (
perc.get("track_camera") or "wrist_handoff_conf" in perc
):
if "wrist_handoff_conf" not in perc:
raise RuntimeError(
"servo 混合跟踪必须读 perception.wrist_handoff_conf "
"(与感知同一门槛,不许两边各写一个数)。"
)
out["wrist_handoff_conf"] = float(perc["wrist_handoff_conf"])
oh = perc.get("overhead") if isinstance(perc.get("overhead"), dict) else {}
if "oneshot_hold_s" not in oh:
raise RuntimeError(
"servo 混合跟踪必须写 perception.overhead.oneshot_hold_s "
"(D435 一次性设定保持多久)。"
)
out["overhead_hold_s"] = float(oh["oneshot_hold_s"])
if "lock_still_sec" not in servo:
raise RuntimeError(
"servo.lock_still_sec 必须显式写"
"(D405 跟住后法兰静止多久才进二阶段)。不许用代码默认 3。"
)
if "lock_still_move_m" not in servo:
raise RuntimeError(
"servo.lock_still_move_m 必须显式写"
"(静止判据的法兰 XY 位移上限)。不许用代码默认 15mm。"
)
out["lock_still_sec"] = float(servo["lock_still_sec"])
out["lock_still_move_m"] = float(servo["lock_still_move_m"])
out["overhead_k0_topic"] = "visual_servo/overhead_k0"
out["wrist_conf_topic"] = "visual_servo/wrist_conf"
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--json", action="store_true")
ap.add_argument("--print", action="store_true", dest="do_print")
ap.add_argument("--config", default="")
ap.add_argument("--sn", "--arm", dest="sn", default="", help="89|91|left|right|完整 SN")
args = ap.parse_args()
if args.sn:
os.environ["ROBOT_SN"] = str(args.sn)
path = Path(args.config).expanduser() if args.config else site_path()
data = load_site(path)
try:
flat = flatten(data)
except ValueError as exc:
# 调用方是 `eval "$(python3 export_site_env.py)"`,抛栈对使用者毫无意义,
# 而且 eval 拿到空字符串后 SITE_* 全是空的,真正失败的地方会离这里很远。
# 所以这里必须打印一句人能看懂的原因并明确非零退出。
print(f"错误:{exc}", file=sys.stderr)
return 2
if args.json:
print(json.dumps({"site": data, "env": flat}, ensure_ascii=False, indent=2))
return 0
if args.do_print:
print(f"site 配置: {path}")
print(
f" 机械臂 SN : {flat['SITE_ROBOT_SN']}"
f"{(' (' + flat['SITE_ROBOT_LABEL'] + ')') if flat.get('SITE_ROBOT_LABEL') else ''}"
f" arm={flat.get('SITE_ROBOT_ARM') or '?'}"
)
print(f" 清单 : {flat.get('SITE_ROBOT_CATALOG') or '(无)'}")
print(f" 机械臂 IP : {flat['SITE_ROBOT_IP']} pc={flat['SITE_PC_IP']} iface={flat.get('SITE_NET_IFACE') or '?'}")
print(f" 坐标系 : prefix={flat['SITE_JOINT_PREFIX']} ee={flat['SITE_EE_FRAME']}")
print(f" 工具名 : {flat.get('SITE_ROBOT_TOOL_NAME') or '(保持当前)'}")
catalog_sns = [str(r.get("sn") or "") for r in list_robots(data)]
if catalog_sns and flat["SITE_ROBOT_SN"] not in catalog_sns:
print(
f" 警告:robot.sn {flat['SITE_ROBOT_SN']!r} 不在 robots[] 里",
file=sys.stderr,
)
print(f" YOLO 权重 : {flat['SITE_YOLO_WEIGHTS']}")
print(f" 解析后 : {flat['SITE_YOLO_WEIGHTS_RESOLVED']}")
print(f" 手眼 : {flat['SITE_HANDEYE_FILE']}")
print(f" 手眼路点 : {flat['SITE_HANDEYE_WAYPOINTS']}")
print(f" 手眼目录 : {flat['SITE_HANDEYE_WORKDIR']}")
print(f" 命名位姿 : {flat.get('SITE_NAMED_POSES_FILE')}")
print(f" 眼在手外结果 : {flat['SITE_HANDEYE_FILE_EYE_TO_HAND']}")
print(f" 眼在手外路点 : {flat['SITE_HANDEYE_WAYPOINTS_EYE_TO_HAND']}")
for _m in ("eye_in_hand", "eye_to_hand"):
_t = _m.upper()
_src = flat.get(f"SITE_HANDEYE_BOARD_SRC_{_t}")
print(
f" 标定板[{_m:12s}]: "
f"{flat.get(f'SITE_HANDEYE_BOARD_COLS_{_t}')}x"
f"{flat.get(f'SITE_HANDEYE_BOARD_ROWS_{_t}')} 内角点, "
f"square={flat.get(f'SITE_HANDEYE_SQUARE_{_t}')} m"
+ (" ← 没配过(标定会被拦下)" if _src == "default" else "")
)
print(
f" 手外相机 : {flat.get('SITE_OVERHEAD_CAM_NAME') or '(未登记)'} "
f"serial={flat.get('SITE_OVERHEAD_CAM_SERIAL') or '(空)'}"
)
print(
f" 相机 : serial={flat['SITE_CAM_SERIAL'] or '(空)'} "
f"{flat['SITE_CAM_WIDTH']}x{flat['SITE_CAM_HEIGHT']}@{flat['SITE_CAM_FPS']} "
f"ae={flat.get('SITE_CAM_AUTO_EXPOSURE')} "
f"exp={flat.get('SITE_CAM_EXPOSURE_US')}us "
f"gain={flat.get('SITE_CAM_GAIN')} "
f"ae_limit={flat.get('SITE_CAM_AE_LIMIT_US')}us emitter={flat.get('SITE_CAM_EMITTER')}"
)
print(
f" 清单相机 : leftD405={flat.get('SITE_D405_LEFT_SERIAL')} "
f"rightD405={flat.get('SITE_D405_RIGHT_SERIAL')} "
f"D435={flat.get('SITE_D435_SERIAL')}"
)
print(
f" 夹爪 : {flat.get('SITE_GRIP_MODEL')} port={flat.get('SITE_GRIP_PORT')}"
)
print(
f" hole_detect : backend={flat.get('SITE_HOLE_BACKEND')} "
f"weights={flat.get('SITE_HOLE_WEIGHTS')}"
)
print(
f" 触觉 : backend={flat.get('SITE_TACTILE_BACKEND')} "
f"L={flat.get('SITE_TACTILE_LEFT')} R={flat.get('SITE_TACTILE_RIGHT')} "
f"cfg={flat.get('SITE_TACTILE_CONFIG')}"
)
print(f" go_home : pose={flat['SITE_GO_HOME_POSE']} speed={flat['SITE_GO_HOME_SPEED']}")
he_path = Path(flat["SITE_HANDEYE_FILE"]).expanduser()
if not he_path.is_file():
print(f" 警告:手眼文件缺失: {he_path}", file=sys.stderr)
else:
try:
he_data = yaml.safe_load(he_path.read_text(encoding="utf-8")) or {}
except Exception:
he_data = {}
if isinstance(he_data, dict) and (
he_data.get("uncalibrated") or "T_4x4" not in he_data
):
arm_hint = flat.get("SITE_ROBOT_SHORT_ID") or flat.get("SITE_ROBOT_ARM") or "?"
print(
f" 警告:{he_path.name} 没有针对该机械臂标定 "
f"(run_handeye.sh teach/calibrate --arm {arm_hint})",
file=sys.stderr,
)
# 右臂不该用左腕 D405 的序列号 —— 判据是**侧别**,不是 SN 里的数字。
# (两台臂的相机装反了不会报错,只会"跟踪一直差几厘米"。)
if (
flat.get("SITE_ROBOT_ARM") == "right"
and flat.get("SITE_CAM_SERIAL")
and flat.get("SITE_CAM_SERIAL") == flat.get("SITE_D405_LEFT_SERIAL")
):
print(
" 警告:右臂用的是左腕 D405 的序列号 — 请检查 robots[].camera.serial",
file=sys.stderr,
)
if "RGI" in str(flat.get("SITE_GRIP_MODEL") or ""):
arm_hint = flat.get("SITE_ROBOT_SHORT_ID") or flat.get("SITE_ROBOT_ARM") or "?"
print(
f" 提示:本臂是 RGI。CLI: bash ~/ros2/scripts/run_gripper.sh --arm {arm_hint} --cli open|rotate",
file=sys.stderr,
)
w = Path(flat["SITE_YOLO_WEIGHTS"]).expanduser()
if not w.is_file():
print(f" 警告:权重文件缺失: {w}", file=sys.stderr)
return 2
return 0
# bash 导出
for k, v in flat.items():
# 为 eval 转义
vv = str(v).replace("'", "'\"'\"'")
print(f"export {k}='{vv}'")
return 0
if __name__ == "__main__":
raise SystemExit(main())