Add safety inference code
This commit is contained in:
parent
03775cefc0
commit
d40893cb44
@ -38,6 +38,7 @@ from lerobot.configs.policies import PreTrainedConfig
|
|||||||
from lerobot.scripts.lerobot_record import DatasetRecordConfig
|
from lerobot.scripts.lerobot_record import DatasetRecordConfig
|
||||||
from lerobot.datasets.lerobot_dataset import LeRobotDataset
|
from lerobot.datasets.lerobot_dataset import LeRobotDataset
|
||||||
from lerobot_robot_ufactory.utils.utils import init_keyboard_listener
|
from lerobot_robot_ufactory.utils.utils import init_keyboard_listener
|
||||||
|
from lerobot_robot_ufactory.utils.action_safety import ActionSafetyConfig, ActionSafetyGuard
|
||||||
from lerobot_robot_ufactory.devices.umi.vive_tracker.transformations import Transformations
|
from lerobot_robot_ufactory.devices.umi.vive_tracker.transformations import Transformations
|
||||||
|
|
||||||
|
|
||||||
@ -120,9 +121,11 @@ class EvalConfig:
|
|||||||
return ["policy"]
|
return ["policy"]
|
||||||
|
|
||||||
|
|
||||||
def eval_loop(cfg: EvalConfig, relative=False, rx_continuous=False):
|
def eval_loop(cfg: EvalConfig, relative=False, rx_continuous=False, safety_guard: ActionSafetyGuard | None = None):
|
||||||
init_logging()
|
init_logging()
|
||||||
logging.info(pformat(asdict(cfg)))
|
logging.info(pformat(asdict(cfg)))
|
||||||
|
if safety_guard is not None:
|
||||||
|
safety_guard.log_config()
|
||||||
|
|
||||||
robot = make_robot_from_config(cfg.robot)
|
robot = make_robot_from_config(cfg.robot)
|
||||||
|
|
||||||
@ -261,6 +264,8 @@ def eval_loop(cfg: EvalConfig, relative=False, rx_continuous=False):
|
|||||||
prev_robot_dict[key] = {'type': 1 if is_tcp else 0, 'pose': np.array(pose)}
|
prev_robot_dict[key] = {'type': 1 if is_tcp else 0, 'pose': np.array(pose)}
|
||||||
prev_action_dict[key] = {'type': 1 if is_tcp else 0, 'pose': np.array(pose)}
|
prev_action_dict[key] = {'type': 1 if is_tcp else 0, 'pose': np.array(pose)}
|
||||||
|
|
||||||
|
safety_halted = False
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
start_loop_t = time.perf_counter()
|
start_loop_t = time.perf_counter()
|
||||||
|
|
||||||
@ -269,6 +274,12 @@ def eval_loop(cfg: EvalConfig, relative=False, rx_continuous=False):
|
|||||||
print("\n********** Policy Eval Episode (Reset) **********")
|
print("\n********** Policy Eval Episode (Reset) **********")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Safety halt: stop inference and stop sending actions,
|
||||||
|
# wait for the operator to press the right arrow key to resume
|
||||||
|
if safety_halted:
|
||||||
|
precise_sleep(sleep_time_s)
|
||||||
|
continue
|
||||||
|
|
||||||
# Get robot observation
|
# Get robot observation
|
||||||
obs = robot.get_observation()
|
obs = robot.get_observation()
|
||||||
|
|
||||||
@ -398,6 +409,16 @@ def eval_loop(cfg: EvalConfig, relative=False, rx_continuous=False):
|
|||||||
# gripper_raw = (future_gripper_norm + 1) / 2 * (_gripper_max - _gripper_min) + _gripper_min
|
# gripper_raw = (future_gripper_norm + 1) / 2 * (_gripper_max - _gripper_min) + _gripper_min
|
||||||
# robot_action_to_send['right.gripper.pos'] = 1.0 if gripper_raw > 0.4 else 0.0
|
# robot_action_to_send['right.gripper.pos'] = 1.0 if gripper_raw > 0.4 else 0.0
|
||||||
|
|
||||||
|
# Safety check: any violation triggers an e-stop; the action is
|
||||||
|
# NOT sent and the loop waits for the operator to press right arrow
|
||||||
|
if safety_guard is not None:
|
||||||
|
violation = safety_guard.check(robot_action_to_send, curr_robot_dict, keys)
|
||||||
|
if violation is not None:
|
||||||
|
safety_halted = True
|
||||||
|
logging.error(f"*** SAFETY HALT *** {violation}")
|
||||||
|
print(f"\n*** SAFETY HALT *** {violation}\nAction was NOT sent. Press right arrow (->) to reset and resume, ESC to exit.")
|
||||||
|
continue
|
||||||
|
|
||||||
robot.send_action(robot_action_to_send)
|
robot.send_action(robot_action_to_send)
|
||||||
|
|
||||||
dt_s = time.perf_counter() - start_loop_t
|
dt_s = time.perf_counter() - start_loop_t
|
||||||
@ -418,11 +439,15 @@ def main():
|
|||||||
parser = argparse.ArgumentParser(description='configuration args')
|
parser = argparse.ArgumentParser(description='configuration args')
|
||||||
parser.add_argument('--relative', action='store_true', help='is relative motion or not')
|
parser.add_argument('--relative', action='store_true', help='is relative motion or not')
|
||||||
parser.add_argument('--rx_continuous', action='store_true', help='rx continuous or not')
|
parser.add_argument('--rx_continuous', action='store_true', help='rx continuous or not')
|
||||||
|
parser.add_argument('--enable_safety', action='store_true', help='enable action safety guard (thresholds in ActionSafetyConfig)')
|
||||||
args, unknown = parser.parse_known_args()
|
args, unknown = parser.parse_known_args()
|
||||||
sys.argv = [sys.argv[0]] + unknown
|
sys.argv = [sys.argv[0]] + unknown
|
||||||
register_third_party_plugins()
|
register_third_party_plugins()
|
||||||
cfg = get_cfg()
|
cfg = get_cfg()
|
||||||
eval_loop(cfg, args.relative, args.rx_continuous)
|
# Action safety guard: tune thresholds in ActionSafetyConfig directly.
|
||||||
|
# Any violation triggers an e-stop; press right arrow to resume.
|
||||||
|
safety_guard = ActionSafetyGuard(ActionSafetyConfig(enabled=args.enable_safety))
|
||||||
|
eval_loop(cfg, args.relative, args.rx_continuous, safety_guard)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
130
src/lerobot_robot_ufactory/utils/action_safety.py
Normal file
130
src/lerobot_robot_ufactory/utils/action_safety.py
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
"""Model-agnostic action safety checks.
|
||||||
|
|
||||||
|
Validates the final action dict right before it is sent to the robot; any
|
||||||
|
failed check triggers an emergency halt. Only relies on the action key
|
||||||
|
naming convention: `[prefix.]pose.x/y/z/rx/ry/rz`, `*.gripper.pos`, so it
|
||||||
|
works with any policy (ACT / DP / pi0 / custom backbones) and with both
|
||||||
|
single-arm and multi-arm setups.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from lerobot_robot_ufactory.devices.umi.vive_tracker.transformations import Transformations
|
||||||
|
|
||||||
|
POSE_AXES = ("x", "y", "z", "rx", "ry", "rz")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ActionSafetyConfig:
|
||||||
|
"""Configuration for ActionSafetyGuard.
|
||||||
|
|
||||||
|
All limits are compared against the robot's current actual pose per
|
||||||
|
control step. `workspace_min`/`workspace_max` use the same unit as the
|
||||||
|
pose commands (e.g. mm) and must be provided together.
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
max_step_mm: float = 25.0
|
||||||
|
max_rot_step: float = 0.35
|
||||||
|
workspace_min: list | None = None
|
||||||
|
workspace_max: list | None = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if (self.workspace_min is None) != (self.workspace_max is None):
|
||||||
|
raise ValueError("workspace_min and workspace_max must be provided together")
|
||||||
|
|
||||||
|
|
||||||
|
def _rotation_delta_norm(rot_prev, rot_curr) -> float:
|
||||||
|
"""Relative rotation angle (rad) between two axis-angle rotations.
|
||||||
|
|
||||||
|
Uses rotation matrices for the diff to avoid the ±π discontinuity
|
||||||
|
of subtracting raw rotvecs.
|
||||||
|
"""
|
||||||
|
R_prev = Transformations.rxryrz_to_rotation_matrix(*rot_prev)
|
||||||
|
R_curr = Transformations.rxryrz_to_rotation_matrix(*rot_curr)
|
||||||
|
R_delta = R_prev.T @ R_curr
|
||||||
|
delta = Transformations.rotation_matrix_to_rxryrz(R_delta)
|
||||||
|
return float(np.linalg.norm(delta))
|
||||||
|
|
||||||
|
|
||||||
|
class ActionSafetyGuard:
|
||||||
|
"""Safety checks for the action dict about to be sent to the robot.
|
||||||
|
|
||||||
|
check() returns None when the action is safe; otherwise it returns a
|
||||||
|
human-readable violation reason (the caller should trigger an e-stop).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: ActionSafetyConfig | None = None):
|
||||||
|
config = config or ActionSafetyConfig()
|
||||||
|
self.config = config
|
||||||
|
self.enabled = config.enabled
|
||||||
|
self.max_step_mm = config.max_step_mm
|
||||||
|
self.max_rot_step = config.max_rot_step
|
||||||
|
self.workspace_min = None if config.workspace_min is None else np.asarray(config.workspace_min, dtype=np.float64)
|
||||||
|
self.workspace_max = None if config.workspace_max is None else np.asarray(config.workspace_max, dtype=np.float64)
|
||||||
|
|
||||||
|
def check(self, action: dict, curr_robot_dict: dict, keys) -> str | None:
|
||||||
|
"""Check the action dict. `action` holds absolute pose commands and
|
||||||
|
`curr_robot_dict` holds each arm's current actual pose."""
|
||||||
|
if not self.enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
prefix = f".{key}" if key else ""
|
||||||
|
pose_keys = [f"{prefix}pose.{axis}" for axis in POSE_AXES]
|
||||||
|
if not all(k in action for k in pose_keys):
|
||||||
|
continue # not TCP-pose controlled (e.g. joint-only arm), skip pose checks
|
||||||
|
|
||||||
|
target = np.array([action[k] for k in pose_keys], dtype=np.float64)
|
||||||
|
|
||||||
|
# 1. Numeric validity: NaN / Inf
|
||||||
|
if not np.all(np.isfinite(target)):
|
||||||
|
return f"[{key or 'arm'}] action contains NaN/Inf: {target.tolist()}"
|
||||||
|
|
||||||
|
# 2. Single-step delta limit, measured from the robot's current actual pose
|
||||||
|
curr = curr_robot_dict.get(key)
|
||||||
|
if curr is not None and curr.get("type") == 1:
|
||||||
|
curr_pose = np.asarray(curr["pose"], dtype=np.float64)
|
||||||
|
pos_dist = float(np.linalg.norm(target[:3] - curr_pose[:3]))
|
||||||
|
if pos_dist > self.max_step_mm:
|
||||||
|
return (
|
||||||
|
f"[{key or 'arm'}] position step {pos_dist:.1f}mm exceeds limit "
|
||||||
|
f"{self.max_step_mm}mm (current {curr_pose[:3].tolist()} -> target {target[:3].tolist()})"
|
||||||
|
)
|
||||||
|
rot_delta = _rotation_delta_norm(curr_pose[3:6], target[3:6])
|
||||||
|
if rot_delta > self.max_rot_step:
|
||||||
|
return (
|
||||||
|
f"[{key or 'arm'}] rotation step {rot_delta:.3f}rad exceeds limit "
|
||||||
|
f"{self.max_rot_step}rad"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Workspace bounding box (optional)
|
||||||
|
if self.workspace_min is not None:
|
||||||
|
pos = target[:3]
|
||||||
|
if np.any(pos < self.workspace_min) or np.any(pos > self.workspace_max):
|
||||||
|
return (
|
||||||
|
f"[{key or 'arm'}] target position {pos.tolist()} outside workspace "
|
||||||
|
f"[{self.workspace_min.tolist()}, {self.workspace_max.tolist()}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Gripper numeric check
|
||||||
|
for k, v in action.items():
|
||||||
|
if "gripper" in k and isinstance(v, (int, float)) and not math.isfinite(v):
|
||||||
|
return f"[{k}] gripper action contains NaN/Inf: {v}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def log_config(self):
|
||||||
|
ws = (
|
||||||
|
f"[{self.workspace_min.tolist()}, {self.workspace_max.tolist()}]"
|
||||||
|
if self.workspace_min is not None
|
||||||
|
else "not set"
|
||||||
|
)
|
||||||
|
logging.info(
|
||||||
|
f"ActionSafetyGuard: max_step={self.max_step_mm}mm, "
|
||||||
|
f"max_rot_step={self.max_rot_step}rad, workspace={ws}"
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user