RoboDojo¶
RoboDojo is a bimanual manipulation benchmark in Isaac Sim with a
public leaderboard. PyRUA-Lean runs on it as an
XPolicyLab policy: RoboDojo's own evaluation client runs the simulator
and judges every episode, and PyRUA-Lean answers it as a policy server. A language model writes Python against
robo, one cell at a time, while the episode runs. No VLA policy, no privileged information.
Overview¶
| Robot | Two ARX-X5 arms with one gripper each; joint actions (env_cfg_type arx_x5, action_type joint) |
| Agent runtime | openai: any OpenAI-compatible chat endpoint (GPT-6 Astra in our evaluation) |
| Models on the policy side | SAM3 (segmentation); no VLA |
| Tasks | 42 simulation tasks in five dimensions: Generalization, Precision, Long-Horizon, Memory, Open |
| Hardware | Linux, NVIDIA GPU; SAM3 takes about 8 GB on the policy side, Isaac Sim needs its own share |
Observation and Action¶
| Item | Description |
|---|---|
| Observation | RoboDojo's default observation: three colour images (cam_head, cam_left_wrist, cam_right_wrist), the joint and end-effector state of both arms, and the instruction. No depth image, no camera matrices, no object poses. |
| Action | The agent's cells call robo's primitives; each motion is solved to joint targets in the policy server and sent to the client as native actions. |
| Success | RoboDojo's own check of the scene. The agent never sees it; when a program finishes before the task is solved, RoboDojo judges the scene as it stands. |
| Task prompt | The task's instruction, and one knowledge text for every task: how the tools behave and how RoboDojo judges an episode (for most tasks: both arms back at their start pose with the grippers open). No task recipes. |
Installation and Resources¶
- Install RoboDojo with its XPolicyLab submodule and the simulator's environment, as RoboDojo describes.
- Put the adapter into XPolicyLab: copy
integrations/xpolicylab/PyRUA_Leanof this repository toXPolicyLab/policy/PyRUA_Lean(or link it from a PyRUA-Lean checkout). - In the policy environment, a conda environment (XPolicyLab's scripts
conda activateit by name) with a CUDA build oftorchand thetorchvisionbuilt for it:
cd XPolicyLab/policy/PyRUA_Lean
bash install.sh # XPolicyLab's server, PyRUA-Lean, the sam3 package
hf auth login # facebook/sam3 is gated: accept its licence on Hugging Face first
bash download_assets.sh # SAM3's checkpoint (3.4 GB) into ./checkpoints/sam3.pt
install.sh installs PyRUA-Lean from the checkout the directory is linked from, or from the public repository when
the directory was copied; it keeps the environment's torch and torchvision as they are.
download_assets.sh modelscope fetches the checkpoint from the ModelScope mirror, and SAM3_CHECKPOINT_PATH names a
checkpoint kept elsewhere. The simulator's environment needs nothing beyond RoboDojo's own installation.
Model Configuration¶
The cells are written by a model behind an OpenAI-compatible chat endpoint:
export OPENAI_BASE_URL=https://YOUR_API_HOST/v1
export OPENAI_API_KEY=...
export PYRUALEAN_MODEL=gpt-6-astra # the model's name at that endpoint
The adapter's deploy.yml sets the episode:
| Key | Default | Meaning |
|---|---|---|
max_turns |
100 |
decision budget: code cells per episode |
budget_s |
5400 |
wall-clock limit of an episode, in seconds |
images |
on-demand |
the model sees a camera only when its code asks for it (robo.show) |
feedback |
pure |
a tool call returns what the code printed, nothing else |
openai_options |
max_tokens: 8192, nudges: 20, extra_body: {reasoning_effort: high} |
the longest reply, the replies without a tool call tolerated, and JSON merged into every request |
Task Selection¶
| Option | Meaning |
|---|---|
--task / task_name |
A RoboDojo task, e.g. stack_blocks. |
--seed / seed |
The layout set: 0, 1 or 2, each with its own layouts of every task. |
--eval-num |
Episodes of the task; native runs RoboDojo's own number (50, or 25 for each half of a Generalization task). Start with 1: an episode took about 25 LLM calls and 13 minutes in the paper's runs, about $2.4 at GPT-6 Astra's gateway price. |
--ckpt / ckpt_name |
Ignored: there are no weights. no-checkpoint is the placeholder. |
Run a Task¶
From the RoboDojo checkout, the official entry point:
bash scripts/robodojo.sh eval --policy-dir XPolicyLab/policy/PyRUA_Lean --task stack_blocks \
--ckpt no-checkpoint --policy-env <policy env> --eval-env <simulator env> \
--env-cfg arx_x5 --action-type joint --seed 0 --eval-num native
robodojo.sh server (on the policy machine) and robodojo.sh client (on the simulator machine, with
--policy-host and --policy-port) run the two sides apart; give both --action-type joint and the same
--ckpt, since their default is ee.
View Results¶
- RoboDojo records every episode under
eval_result/of its checkout;bash scripts/robodojo.sh summarizewrites the leaderboard's table (SR and Score per dimension) toeval_result/RoboDojo/_summary.md. A task counts there only once all its episodes for that seed are in. - Each episode's programs, tool calls and full transcript are kept under the adapter's
result_dir:./results/<task>_l<layout>_s<seed>-<time>/.
Results¶
GPT-6 Astra, seed 0, all 42 tasks (2,100 episodes):
| Generalization | Precision | Long-Horizon | Memory | Open | Average | |
|---|---|---|---|---|---|---|
| SR (%) | 45.83 | 37.25 | 48.00 | 63.00 | 64.00 | 51.62 |
| Score | 50.65 | 47.02 | 63.26 | 63.00 | 67.09 | 58.20 |
That is 19.4 points above the best entry (32.26) of a snapshot of the public leaderboard taken on October 9, 2026; the board changes as entries arrive. An episode takes 25.7 LLM calls on average.
What Runs Where¶
- Evaluation client (RoboDojo, in the simulator's environment) owns Isaac Sim: it builds the observation, counts the task's action budget and gives the verdict.
- Policy server (XPolicyLab's server with the adapter, in the policy environment) runs the PyRUA-Lean episode: the agent loop, the sandboxed cells, the primitives (inverse kinematics, gripper, perception) and SAM3.
- Model endpoint writes the cells.
The two sides talk XPolicyLab's websocket protocol (update_obs / get_action). A model may think for minutes
between two actions, so the policy server answers "wait" until the next action is ready and the client asks again;
no request is held longer than poll_s (20 s).
Primitives the Agent Can Call¶
Motion:
move_to(xyz, arm, gripper, pitch, yaw, roll, path): move one arm's grasp point to a world position, with an orientation. A pose out of reach moves nothing, and the result names reachable poses nearby.set_gripper(arm, gripper, opening=None): close or open one gripper until the fingers stop; the result says whether something is held and how wide it is.home(arm="both"): return an arm to the joint angles it had when the episode started.
Perception and state:
locate(text_prompt, camera): find an object by words and measure it in 3D from the colour cameras: centre, top, footprint, heading and the yaw of a top grasp across it.segment(text_prompt, camera): the SAM3 mask of an object in the current image.back_project(row, col, camera): the world point seen at one pixel.state(),image(camera),show(camera),camera_meta(camera),artifact(name): the recorded state of both arms, the images, and what the model looks at.
Twists and button presses are compositions of these; the knowledge text says how.
API reference: what the agent reads, generated from pyrualean.robodojo_xpl_rgb when these docs are built
class RobodojoOfficialColourRobot
The RoboDojo dual-arm station: two 6-DoF ARX-X5 arms with parallel grippers at a table.
World frame: x runs across the table (the LEFT arm is at negative x and
appears on the LEFT of the head image), y points away from the robot
towards the far edge of the table, z is up; metres. Orientations are unit
quaternions ordered [w, x, y, z]. The arms are ``"left"`` and ``"right"``
(``robo.LEFT`` / ``robo.RIGHT``); a motion call names one arm and holds
the other where it is.
``move_to`` drives an arm's GRASP POINT, the middle of the finger pads
(``state().left.tcp_pos``), to a world position, with an orientation given
in degrees: ``pitch`` is how far the gripper points below the horizontal
(0 level, 90 straight down), ``yaw`` the heading of the pointing direction
on the table (0 away from the robot, +90 towards the robot's left, -90
towards its right) and ``roll`` the turn of the fingers about the pointing
direction (0: side by side, as at the start). Pointing straight down, the
fingers close along the line at ``yaw`` degrees from +x (counter-clockwise
seen from above): ``locate``'s ``grasp_yaw`` is that line across an
object's narrow side. The table
surface is at z = 0.765; the arms stand on it at x = -0.30 and +0.30,
y = -0.45.
Two gripper conventions, do not mix them up. COMMANDS are signed:
``robo.CLOSE`` (1) closes, ``robo.OPEN`` (-1) opens, ``robo.KEEP`` (0)
leaves the gripper as it is. MEASUREMENTS (``state().left.gripper``,
``Gripper.opening``) are openings: 1.0 = open, 0.0 = closed on nothing.
Every native action (one step of ``move_to``, ``set_gripper`` or
``home``) counts against the episode's
native-action budget; when it is spent the state reports ``truncated``
and motion calls raise ``EpisodeFinished``. Motion calls block until the
primitive finishes and return a typed result; nothing is queued.
Perception is from colour images alone; there is no depth image. A fixed
head camera (``cam_head``, the whole table) and one wrist camera per arm
(``cam_left_wrist``, ``cam_right_wrist``), 640x480 each, and where each
one is, is known from the robot's state. ``locate`` finds an object by a
text prompt and measures it in 3D (centre, top, footprint, heading) from
the cameras that see it: it takes two of them to measure a height.
``segment`` / ``back_project`` give a mask centroid and the world point at
one pixel. Object poses are never given.
Constants:
robo.ARMS = ('left', 'right')
robo.LEFT = 'left'
robo.RIGHT = 'right'
robo.CLOSE = 1
robo.OPEN = -1
robo.KEEP = 0
Methods and attributes of `robo`:
robo.task -> str (read-only attribute)
The task instruction the benchmark generated for this layout (authoritative).
Read it before planning.
robo.state(step: int = -1) -> State
Read the recorded state of both arms.
Args:
step: recorded step to read; ``-1`` is the latest, ``0`` the
initial scene. A new step is recorded after every motion call.
Returns:
State with ``left`` / ``right`` end-effector poses, measured
gripper openings and joint angles, and the ``truncated`` flag.
robo.image(camera: str = 'cam_head', *, resolution: str = 'high', step: int = -1) -> Any
Load a recorded camera image as a ``numpy`` uint8 array (480, 640, 3), RGB.
Args:
camera: ``"cam_head"`` (fixed view of the whole table: identify
objects, destinations and the global layout),
``"cam_left_wrist"`` or ``"cam_right_wrist"`` (on the gripper:
what that arm is about to grasp or is holding).
resolution: there is one resolution per camera, ``"high"``. Pixel
coordinates are (row, col) with row 0 at the top and col 0 at
the left.
step: recorded step, ``-1`` = latest. ``segment`` and
``back_project`` always read the CURRENT observation, which is
the latest recorded step.
robo.show(camera: str = 'cam_head') -> None
Attach the current image of ``camera`` to the feedback of this turn.
Images are not returned automatically; call ``show("cam_head")``,
``show("cam_left_wrist")`` or ``show("cam_right_wrist")`` when *you*
(the author) need to look at the scene before writing the next turn.
Costs tokens: look only when a decision depends on it.
robo.artifact(name: str, *, step: int = -1) -> bytes
Return the raw bytes of a recorded artifact (advanced).
Names are ``cam_head.png``, ``cam_left_wrist.png`` and
``cam_right_wrist.png``.
robo.camera_meta(camera: str = 'cam_head', *, step: int = -1) -> dict[str, Any]
Return the calibration of one camera at a recorded step as a dict.
Keys: ``shape`` (image shape), ``intrinsic_matrix`` (3x3) and
``extrinsic_matrix`` (4x4 camera-to-world; the camera looks down its
own -z axis with +y up). The head camera is fixed; a wrist camera
moves with its arm, and its pose is worked out from that arm's state.
robo.segment(
text_prompt: str,
camera: str = 'cam_head',
min_score: float = 0.2,
) -> Segment
Segment an object in the current image of ``camera`` by a text prompt (SAM3).
Args:
text_prompt: what to find, e.g. ``"the green bottle"``. Name one
object by its visible properties.
camera: ``"cam_head"`` (default), ``"cam_left_wrist"`` or
``"cam_right_wrist"``.
min_score: masks scoring below this are reported as not found.
Returns:
Segment; when ``found``, pass ``centroid`` (row, col) to
``back_project`` with the same camera to get a world point. The
mask is the best match for the text, not a guarantee of identity:
check where it is against the image.
Raises:
ToolError: the camera image or the SAM3 service is unavailable.
robo.back_project(row: int, col: int, camera: str = 'cam_head') -> SeenPoint
Return the world point seen at one pixel of the current image of ``camera``.
There is no depth image, so a pixel alone is only a ray. The object
under the pixel is segmented from that pixel (SAM3) and measured as
``locate`` measures one, and the answer is where the pixel's ray
meets it. A pixel on the table top itself gives the point of the
table it shows, exactly.
Args:
row / col: pixel, row 0 at the top, col 0 at the left (images are
480 rows x 640 cols).
camera: the camera the pixel was read from (default ``"cam_head"``).
Returns:
SeenPoint; check ``surface``. ``"object"``: ``xyz`` is on the
object under the pixel, as well as ``locate`` would measure that
object from this camera and the others that see it.
``"table"``: ``xyz`` is on the table top; a pixel that shows
something too flat or too far to measure also answers so. A thin
part in mid-air (a peg, a handle, a cable) is not measured
reliably: reason from the image and from ``locate`` of the whole
object instead.
Raises:
ToolError: pixel out of range, or it looks past the table.
robo.locate(
text_prompt: str,
camera: str = 'cam_head',
min_score: float = 0.2,
) -> ObjectSight
Find an object by a text prompt and measure it in 3D from the colour cameras.
SAM3 segments the current image of ``camera`` (as ``segment``); the
same object is then looked for in the other two cameras, and its
height and footprint are those that fit every camera that shows it.
One camera cannot measure a height: a low deep thing and a tall
shallow one look alike from it. ``views`` says how many cameras
agreed. Nothing moves.
Args:
text_prompt: one object by its visible properties, e.g. ``"the
small red cube on the left"``.
camera: ``"cam_head"`` (default; the whole table),
``"cam_left_wrist"`` or ``"cam_right_wrist"`` (close up, from
the gripper).
min_score: masks scoring below this are reported as not found.
Returns:
ObjectSight. For a top grasp: ``move_to([x, y, top_z + 0.09],
pitch=90)`` above it, ``locate`` it again from there (the wrist
camera then looks down on it and the reading is good to 1-2 mm),
turn to ``yaw=grasp_yaw`` and go down to the height to hold it
at. With ``views >= 2`` and no ``note`` a block, a can, a bowl
or the body of a bottle is measured to 1-3 mm; a ``note`` that
the cameras agree only roughly leaves the height uncertain. With
``views == 1`` the height is a guess, the tallest upright shape
that fits the one image: close for a box or a can, wrong for a
bowl, a cup or anything wider above than below, and for a tall
thing that tapers; ``center_xyz`` is then off, along the line
away from the robot, by about half of what the height is wrong
by. An object once measured with ``views >= 2`` keeps that height
in later calls with the same prompt. No position comes back
(``center_xyz`` None, ``note`` says why) for a mask on the robot's
own arm or on something that is not on the table. The mask is
the best match for the text, not proof of identity: check it
against the image.
Raises:
ToolError: the camera image or the SAM3 service is unavailable.
robo.move_to(
xyz: Sequence[float],
arm: str = 'right',
gripper: int = 0,
pitch: float | None = None,
yaw: float | None = None,
roll: float | None = None,
path: str = 'auto',
) -> Move
Move one arm's grasp point to a world position, with an orientation.
The grasp point is the middle of the finger pads (``tcp_pos``). The
library solves the arm's inverse kinematics for the full pose and,
with ``path="auto"``, moves the grasp point along a straight line to
it, about 2.5 cm or 8 degrees per native action; when the line leaves
the arm's reach it swings the joints straight to the solution instead.
Nothing moves when the pose is out of reach or every way there takes
the arm into the table: ``ik_error`` says why.
Args:
xyz: target grasp-point position [x, y, z], metres.
arm: ``"left"`` or ``"right"``. The other arm holds its joints.
gripper: ``robo.CLOSE`` (1) or ``robo.OPEN`` (-1) to command the
moving arm's gripper on every action of the motion,
``robo.KEEP`` (0) to keep commanding what it was last told (a
held object stays squeezed). Keep it closed (1) while
carrying an object.
pitch / yaw / roll: the orientation to arrive with, degrees (the
class docstring defines them). An angle left None keeps its
current value, so a plain position move does not turn the
gripper. With an EMPTY open gripper the fingers are
interchangeable, so the pose turned half a turn about the
pointing direction may be taken when it needs less joint
travel (``note`` says so; yaw / roll then read 180 off);
holding something, the pose is taken exactly as asked.
path: ``"auto"`` (default), ``"line"`` (straight line or nothing)
or ``"joint"`` (swing the joints: fewer actions, but the grasp
point does not follow a line: not near objects).
Returns:
Move; test ``reached`` (within 0.01 m and 5 degrees). ``blocked``
means the arm was stopped by something on the way.
Raises:
ValueError: a malformed argument (nothing is sent).
robo.set_gripper(arm: str, gripper: int, opening: float | None = None) -> Gripper
Close or open one gripper, without moving the arms.
The command is repeated until the fingers stop (2 to 10 native
actions). After a close, ``holding`` tells whether something stopped
the fingers apart and ``width_m`` how wide it is. Later motions with
``robo.KEEP`` keep commanding what was commanded last, so a held
object stays squeezed and a partial opening stays as it is.
Args:
arm: ``"left"`` or ``"right"``.
gripper: ``robo.CLOSE`` (1) or ``robo.OPEN`` (-1).
opening: with ``robo.OPEN`` only: open to this reading
(0 < opening <= 1) instead of all the way, e.g. to reach
between close neighbours. Something 0.002 + 0.109 x opening
metres wide then just fits between the fingers.
Returns:
Gripper. ``holding`` is not proof that the INTENDED object is
held: confirm it moves with the arm (wrist camera, or ``locate``
it again after a lift).
robo.home(arm: str = 'both') -> Home
Return an arm to the joint angles it had when the episode started.
A grasp point lower than 1.0 m first goes straight up by up to 6 cm;
then each native action commands the starting angles directly, so the
arm swings straight back: no IK and no path planning beyond that lift
(clear tall things yourself). The call returns when every joint is
within 0.01 rad or after 25 actions of the swing. The gripper keeps
the opening it has; an arm that is not named holds its joints. Most
tasks count as done only with both arms back where they started.
Args:
arm: ``"left"``, ``"right"`` or ``"both"`` (default).
Returns:
Home; test ``reached`` and call again when it is False.
Result types (frozen dataclasses; access fields as attributes):
class State(step: int, task: str, left: ArmState, right: ArmState, truncated: bool)
Snapshot of both arms at one recorded step.
step: index of the recorded step (0 = initial scene). A new step is
recorded after every motion call.
task: the task instruction given by the benchmark (authoritative).
left / right: the two arms (``state.arm("left")`` picks one by name).
truncated: the native-action budget of the episode is exhausted; no
further motion is possible.
class ArmState(
tcp_pos: tuple[float, float, float],
pitch: float,
yaw: float,
roll: float,
eef_pos: tuple[float, float, float],
eef_quat: tuple[float, float, float, float],
gripper: float,
joints: tuple[float, ...],
)
One arm at a recorded step.
tcp_pos: the grasp point [x, y, z] in the world frame, metres: the middle of
the finger pads, 0.145 m out along the pointing direction from the
end-effector link. ``move_to`` drives this point.
pitch / yaw / roll: the gripper's orientation, degrees (see the class
docstring): ``pitch`` 0 points level and 90 straight down, ``yaw`` is
the heading of the pointing direction on the table (0 away from the
robot, +90 towards the robot's left), ``roll`` the turn of the fingers
about the pointing direction (0 as at the start). Pointing straight
down, ``yaw`` alone says how the fingers lie and ``roll`` reads 0.
eef_pos: position of the end-effector link (the wrist flange), metres.
eef_quat: end-effector orientation as a unit quaternion [w, x, y, z].
gripper: measured gripper opening, normalised: 1.0 = open, 0.0 = closed on
nothing; closed on an object 0.002 + 0.109 x this reading is the
object's width in metres.
(Gripper COMMANDS use the opposite sign convention: see ``set_gripper``.)
joints: the six arm joint angles, radians (the last turns the fingers about
the pointing direction).
class Move(
arm: str,
target_xyz: tuple[float, float, float],
start_xyz: tuple[float, float, float],
final_xyz: tuple[float, float, float],
dist_to_target_m: float,
orientation_error_deg: float,
pitch: float,
yaw: float,
roll: float,
reached: bool,
path: str | None,
ik_error: str | None,
note: str | None,
blocked: bool,
steps_used: int,
truncated: bool,
)
Outcome of ``move_to``.
target_xyz: the requested grasp-point position.
start_xyz / final_xyz: where the grasp point was before and after.
dist_to_target_m: distance from ``final_xyz`` to ``target_xyz``.
orientation_error_deg: how far the final orientation is from the one asked
(the angles passed, the others as they were).
pitch / yaw / roll: the orientation the gripper ended with, degrees.
reached: within 0.01 m and 5 degrees of the target.
path: ``"line"`` (the grasp point moved along a straight line),
``"joint"`` (the joints swung straight to the solution) or None
(nothing moved).
ik_error: why nothing moved (the pose is out of reach, or every way there
goes through the table), or None. An out-of-reach target names the
nearby poses that ARE reachable (a less steep pitch here, or this
orientation higher or lower).
note: set when the exact pose was out of reach but one within 2 cm and
8 degrees was not: the arm went there instead (``dist_to_target_m``
and ``orientation_error_deg`` say how far off it is).
blocked: the arm stopped short of a planned pose it could reach: something
is in the way, or a held object is pushing against something.
steps_used: native actions consumed.
truncated: the native-action budget ran out.
class Gripper(
arm: str,
commanded: str,
opening: float,
holding: bool,
width_m: float | None,
steps_used: int,
step: int | None,
step_limit: int | None,
truncated: bool,
)
Outcome of ``set_gripper``.
commanded: ``"closed"`` or ``"open"``.
opening: the opening once the fingers stopped (1.0 = open, 0.0 = closed on
nothing).
holding: after a close, the fingers stopped apart (an opening above 0.01,
about 3 mm): something is between them. It does not say what: check
that the intended object moves with the arm.
width_m: after a close, the width of what is held, across the fingers
(0.0 when nothing is), else None.
steps_used: native actions consumed.
step / step_limit: native actions executed so far and the episode budget
(None if the runtime did not report them).
truncated: the native-action budget ran out.
class Home(
arm: str,
reached: bool,
max_joint_error_rad: float,
steps_used: int,
truncated: bool,
)
Outcome of ``home``.
arm: ``"left"``, ``"right"`` or ``"both"``, as requested.
reached: every joint of the returned arm(s) ended within 0.01 rad of the
angle it had when the episode started.
max_joint_error_rad: the largest difference that remains.
steps_used: native actions consumed.
truncated: the native-action budget ran out.
class ObjectSight(
found: bool,
center_xyz: tuple[float, float, float] | None,
top_z: float | None,
height_m: float | None,
length_m: float | None,
width_m: float | None,
long_axis_deg: float | None,
grasp_yaw: float | None,
score: float | None,
centroid: tuple[int, int] | None,
area_px: int,
camera: str,
note: str | None,
views: int,
)
Outcome of ``locate``: an object measured from its masks in the colour cameras.
found: SAM3 produced a mask scoring at least ``min_score``.
center_xyz: [x, y, z] of the middle of the smallest rectangle around the
object's footprint, at half its height above the table, metres. A
handle, a spout or a tail is part of the footprint and moves the
middle away from the body's. None when no 3D measurement was
possible (see ``note``).
top_z: height of the object's top surface.
height_m: ``top_z`` above the table surface (0.765 m).
length_m / width_m: the long and the short side of the smallest rectangle
around the footprint.
long_axis_deg: direction of the long side on the table, degrees from +x
(-90 .. 90); of a square, one of its sides.
grasp_yaw: the ``yaw`` for ``move_to(..., pitch=90, yaw=grasp_yaw)``:
pointing straight down, the fingers then close across the short side
(a cube's: across two faces).
views: how many cameras agreed on the measurement. 1: nothing but the
camera asked saw the object, and its height is a guess (see
``locate``); ``note`` says which guess.
score: SAM3 confidence.
centroid: (row, col) of the mask in the image, as ``Segment.centroid``.
area_px: mask pixels.
camera: the camera measured from.
note: what limits the measurement (one camera only; two that agree only
roughly; the mask is the robot's own arm, or not on the table; too
flat or too far to measure), else None.
The object is taken to stand on the table. What other things or the
robot's own arm hide of it is not measured, so a partly hidden object
comes out smaller and off-centre. A held object is usually not found from
the head camera.
class Segment(
found: bool,
centroid: tuple[int, int] | None,
area_px: int,
score: float | None,
box: Any,
camera: str,
)
Outcome of ``segment``.
found: SAM3 produced a mask scoring at least ``min_score``.
centroid: (row, col) of the mask centroid (mean of the mask pixels, floored),
or None. Pass exactly this pixel, with the same camera, to
``back_project``.
area_px: number of mask pixels (0 when not found).
score: SAM3 confidence in [0, 1], None when not found.
box: mask bounding box as reported by SAM3, or None.
class SeenPoint(
pixel: tuple[int, int],
xyz: tuple[float, float, float],
depth_m: float,
camera: str,
surface: str,
)
Outcome of ``back_project``: the point seen at one pixel.
xyz: world [x, y, z], metres: where the pixel's ray meets the object under
the pixel, or the table.
depth_m: distance of that point along the camera's optical axis.
surface: ``"object"`` when the pixel lies on something that could be
measured, ``"table"`` when the point is on the table top (z = 0.765):
the pixel shows the table, or something too flat or too far to measure.
Exceptions:
ToolError(RuntimeError): a call was rejected or a service failed; catch it if you have a fallback.
ValueError: an argument was invalid (e.g. a planar move over 0.30 m) - nothing was executed.
EpisodeFinished(BaseException): the episode is over (reason: truncated | timeout | cancelled); return.