Skip to content

Commit 1e8a5f4

Browse files
committed
Align RoboCasa and BEHAVIOR adapter contracts
1 parent 94f0142 commit 1e8a5f4

40 files changed

Lines changed: 5222 additions & 567 deletions

adapter/protocol.py

Lines changed: 56 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -185,14 +185,20 @@ def _depth_range(depth_pixels: Any) -> tuple[float, float]:
185185

186186
@dataclass(slots=True)
187187
class CameraFrame:
188-
"""RGBD camera frame in JSON-serializable form for the initial bridge."""
188+
"""RGBD camera frame in JSON-serializable form for the initial bridge.
189+
190+
``role`` is an optional backend-neutral semantic hint such as
191+
``scene_primary`` or ``wrist_primary``. ``frame_id`` remains the stable
192+
backend identifier and is never rewritten to emulate another simulator.
193+
"""
189194

190195
frame_id: str
191196
rgb: list[list[list[int]]]
192197
depth: list[list[float]] | None = None
193198
intrinsics: JsonDict = field(default_factory=dict)
194199
extrinsics: JsonDict = field(default_factory=dict)
195200
timestamp_s: float | None = None
201+
role: str = ""
196202

197203
def to_dict(self) -> JsonDict:
198204
"""Convert to a plain JSON-serialisable dict."""
@@ -205,6 +211,8 @@ def to_dict(self) -> JsonDict:
205211
}
206212
if self.timestamp_s is not None:
207213
d["timestamp_s"] = self.timestamp_s
214+
if self.role:
215+
d["role"] = self.role
208216
return d
209217

210218
@classmethod
@@ -233,6 +241,7 @@ def from_dict(cls, d: dict, *, frame_id: str = "") -> CameraFrame:
233241
intrinsics=_ensure_plain_dict(d.get("intrinsics")),
234242
extrinsics=_ensure_plain_dict(d.get("extrinsics")),
235243
timestamp_s=d.get("timestamp_s"),
244+
role=str(d.get("role") or ""),
236245
)
237246

238247
def to_mcp_dict(self) -> JsonDict:
@@ -249,59 +258,50 @@ def to_mcp_dict(self) -> JsonDict:
249258
250259
depth_m = depth_uint16 / 1000.0
251260
261+
Newly normalized OpenCV packets make that wire representation
262+
machine-readable with ``depth_encoding="uint16_png"`` and
263+
``depth_scale=1000.0`` (encoded units per metre). Legacy packets
264+
intentionally retain their existing shape for reproducibility.
265+
252266
Values fall within ``[znear, zfar]`` from ``intrinsics`` (metric
253267
clip planes, in metres).
254268
255-
**Extrinsics convention** (MuJoCo: MetaWorld / LIBERO). The dict is
256-
self-describing — read the tags rather than assuming a layout:
269+
**Extrinsics convention.** Every dict is self-describing: consumers
270+
must read ``camera_frame`` instead of inferring a convention from the
271+
simulator name or matrix shape.
272+
273+
Agent-facing RoboCasa and BEHAVIOR adapters normalize the pose to the
274+
same **OpenCV optical** frame used by their returned RGB, metric depth,
275+
and intrinsics (+X right, +Y down, +Z forward):
257276
258277
* ``matrix_layout`` — ``"row_major"``
259278
* ``frame_transform`` — ``"camera_to_world"``
260-
* ``camera_frame`` — ``"opengl"`` (camera looks along local **-Z**)
279+
* ``camera_frame`` — ``"opencv"``
280+
* ``image_origin`` — ``"top_left"``
261281
* ``pos`` — ``[x, y, z]`` camera position in **world** coordinates
262-
(metres), NOT relative to the end-effector
282+
(metres), not relative to the end-effector
263283
* ``mat`` — 3×3 rotation matrix, **camera → world**, flattened
264-
**row-major**: ``[m00, m01, m02, m10, m11, m12, m20, m21, m22]``.
265-
So ``np.array(mat).reshape(3, 3)`` gives the rotation directly.
266-
267-
**Columns** = camera-local axes expressed in world::
268-
269-
col 0 = camera X (right) in world
270-
col 1 = camera Y (up) in world
271-
col 2 = camera Z (forward) in world
272-
273-
**Rows** = world axes expressed in camera-local::
284+
row-major. ``camera_to_world`` also carries the equivalent 4×4
285+
homogeneous matrix.
286+
* ``normalized_from`` / ``raw_camera_convention`` — optional debug
287+
provenance for the renderer frame normalized by the adapter.
274288
275-
row 0 = world X in camera
276-
row 1 = world Y in camera
277-
row 2 = world Z in camera
278-
279-
Transformation formulas (``R = np.array(mat).reshape(3, 3)``)::
280-
281-
p_world = R @ p_cam + pos # camera → world
282-
p_cam = R.T @ (p_world - pos) # world → camera
283-
284-
The camera looks along **-Z** locally, so the look direction
285-
in world coordinates is ``-col2`` (i.e. ``-R[:, 2]``).
286-
287-
**ManiSkill** (SAPIEN): ``pos`` + ``quat_xyzw`` (reordered from
288-
SAPIEN's native wxyz), ``frame_transform="camera_to_world"``,
289-
``camera_frame="ros"`` (camera looks along local **+X**, +Z up).
290-
291-
**Pixel → world (deprojection).** Pinhole deprojection yields a
292-
point in the **OpenCV optical** frame (X right, Y down, Z forward);
293-
convert it into the camera's native frame *before* rotating::
289+
For these normalized packets, pinhole deprojection is direct::
294290
295291
x = (u - cx) * d / fx
296292
y = (v - cy) * d / fy
297293
p_opencv = np.array([x, y, d])
298-
# optical -> camera-native (per ``camera_frame``):
299-
# "opengl" (MuJoCo): p_cam = diag(1, -1, -1) @ p_opencv
300-
# "ros" (ManiSkill): p_cam = np.array([d, -x, -y])
301-
p_world = R @ p_cam + pos
302-
303-
The optical->native flip is mandatory and backend-specific; a
304-
correct round-trip recovers object centres to ~2-3 cm.
294+
R = np.array(mat).reshape(3, 3)
295+
p_world = R @ p_opencv + pos
296+
297+
LIBERO is deliberately kept on its existing reproducible v1 packet:
298+
``camera_frame="opengl"``, where local +Z is the renderer's backward
299+
axis and the camera looks along -Z. MetaWorld and other legacy
300+
MuJoCo adapters currently use the same form. For those packets,
301+
convert the OpenCV point with ``diag(1, -1, -1)`` before applying
302+
``R``. ManiSkill remains self-described as ``camera_frame="ros"``.
303+
The generic ``camera_pose_to_world`` tool accepts these legacy forms;
304+
no consumer should guess from the backend name.
305305
"""
306306
d: JsonDict = {
307307
"frame_id": self.frame_id,
@@ -314,6 +314,8 @@ def to_mcp_dict(self) -> JsonDict:
314314
}
315315
if self.timestamp_s is not None:
316316
d["timestamp_s"] = self.timestamp_s
317+
if self.role:
318+
d["role"] = self.role
317319

318320
# RGB → base64 PNG
319321
if self.rgb:
@@ -326,9 +328,9 @@ def to_mcp_dict(self) -> JsonDict:
326328
except Exception:
327329
pass
328330

329-
# Depth → base64 PNG (16-bit, scaled to 0–65535)
330-
# Also record depth_min/depth_max so the consumer can reconstruct
331-
# absolute depth: depth_m = dmin + (pixel / 65535) * (dmax - dmin)
331+
# Depth → uint16 PNG in fixed millimetres. depth_min/depth_max remain
332+
# informational scene-range hints; reconstruction is always
333+
# depth_m = pixel / 1000.0.
332334
if self.depth:
333335
try:
334336
_dmin, _dmax = _depth_range(self.depth)
@@ -337,6 +339,16 @@ def to_mcp_dict(self) -> JsonDict:
337339
d["depth_base64"] = denc
338340
d["depth_min"] = _dmin
339341
d["depth_max"] = _dmax
342+
if (
343+
self.extrinsics.get("camera_frame") == "opencv"
344+
and self.extrinsics.get("normalized_from")
345+
):
346+
# Additive metadata for the new canonical camera
347+
# contract. Do not alter LIBERO's legacy OpenGL
348+
# packet, whose exact wire shape is reproducibility
349+
# sensitive.
350+
d["depth_encoding"] = "uint16_png"
351+
d["depth_scale"] = 1000.0
340352
if not d["width"]:
341353
d["width"] = dw
342354
d["height"] = dh

agent/runtime/image_artifacts.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class ImageArtifact:
3333
source_field: str
3434
format: str = "png"
3535
frame_id: str = ""
36+
role: str = ""
3637
width: int | None = None
3738
height: int | None = None
3839
byte_size: int = 0
@@ -48,6 +49,8 @@ def to_dict(self) -> JsonDict:
4849
}
4950
if self.frame_id:
5051
payload["frame_id"] = self.frame_id
52+
if self.role:
53+
payload["role"] = self.role
5154
if self.width is not None:
5255
payload["width"] = self.width
5356
if self.height is not None:
@@ -151,6 +154,7 @@ def _materialize_dict(
151154
images: list[ImageArtifact],
152155
) -> JsonDict:
153156
frame_id = str(payload.get("frame_id") or payload.get("camera") or "")
157+
role = str(payload.get("role") or "")
154158
if not frame_id and path_parts:
155159
frame_id = path_parts[-1]
156160

@@ -172,6 +176,7 @@ def _materialize_dict(
172176
source_field=source_field,
173177
format=fmt,
174178
frame_id=frame_id,
179+
role=role,
175180
width=_optional_int(payload.get("width")),
176181
height=_optional_int(payload.get("height")),
177182
byte_size=len(data),

agent/runtime/memory.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,22 +1408,35 @@ def _capture_sam3_selection_state(self, action: EnvAction) -> None:
14081408
parameters = details.get("parameters")
14091409
if not isinstance(parameters, dict):
14101410
parameters = {}
1411+
source_camera_role = str(
1412+
outputs.get("source_camera_role")
1413+
or details.get("source_camera_role")
1414+
or ""
1415+
)
1416+
source_frame_id = (
1417+
outputs.get("frame_id")
1418+
or outputs.get("source_frame_id")
1419+
or (
1420+
details.get("source_frame_id")
1421+
if source_camera_role
1422+
else None
1423+
)
1424+
or parameters.get("frame_id")
1425+
)
14111426
base = {
14121427
"result_id": result_id,
14131428
"target_prompt": outputs.get("prompt") or parameters.get("prompt"),
14141429
"source_image": outputs.get("source_image") or parameters.get("image"),
1415-
"frame_id": (
1416-
outputs.get("frame_id")
1417-
or outputs.get("source_frame_id")
1418-
or parameters.get("frame_id")
1419-
),
1430+
"frame_id": source_frame_id,
14201431
"ranking": outputs.get("ranking") or "score_descending",
14211432
"candidate_count": len(candidates),
14221433
"candidates": candidates,
14231434
"selection_bundle": dict(selection_bundle),
14241435
"segmentation_mode": outputs.get("segmentation_mode"),
14251436
"scene_epoch": self.scene_epoch(),
14261437
}
1438+
if source_camera_role:
1439+
base["camera_role"] = source_camera_role
14271440
self.facts.pop(REFERENCE_LOCALIZATION_FAILURE_KEY, None)
14281441
asset_reference = self.target_asset_reference()
14291442
if isinstance(asset_reference, dict):
@@ -6360,7 +6373,16 @@ def _camera_packet_from_payload(
63606373
"anygrasp_intrinsics": dict(normalized_intrinsics),
63616374
"extrinsics": dict(extrinsics),
63626375
}
6363-
for field_name in ("width", "height", "depth_min", "depth_max"):
6376+
role = camera.get("role")
6377+
if isinstance(role, str) and role:
6378+
packet["role"] = role
6379+
for field_name in (
6380+
"width",
6381+
"height",
6382+
"depth_min",
6383+
"depth_max",
6384+
"depth_encoding",
6385+
):
63646386
if field_name in camera:
63656387
packet[field_name] = camera[field_name]
63666388
if depth_scale is not None:
@@ -6745,6 +6767,7 @@ def summarize_memory_artifact(artifact: JsonDict) -> JsonDict:
67456767
"mcp_server_url",
67466768
"dashboard_url",
67476769
"frame_id",
6770+
"role",
67486771
"rgb_path",
67496772
"depth_path",
67506773
"width",

0 commit comments

Comments
 (0)