@@ -185,14 +185,20 @@ def _depth_range(depth_pixels: Any) -> tuple[float, float]:
185185
186186@dataclass (slots = True )
187187class 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
0 commit comments