diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml index 57a3b9040..b3bba345e 100644 --- a/.github/workflows/unit-test.yaml +++ b/.github/workflows/unit-test.yaml @@ -36,7 +36,7 @@ jobs: PYTHONPATH: ${{ env.PYTHONPATH }}:$PWD run: >- uv run pytest --cov-report=html - --override-ini='testpaths=pimm/tests positronic/cfg/tests positronic/dataset/tests positronic/geom/tests positronic/offboard/tests positronic/policy/tests positronic/simulator/molmo_spaces/tests positronic/tests positronic/utils/tests' + --override-ini='testpaths=packages/positronic-client/positronic_client/tests pimm/tests positronic/cfg/tests positronic/dataset/tests positronic/geom/tests positronic/offboard/tests positronic/policy/tests positronic/simulator/molmo_spaces/tests positronic/tests positronic/utils/tests' - name: Coverage summary (job summary) if: always() diff --git a/packages/positronic-client/positronic_client/__init__.py b/packages/positronic-client/positronic_client/__init__.py new file mode 100644 index 000000000..8f4faa198 --- /dev/null +++ b/packages/positronic-client/positronic_client/__init__.py @@ -0,0 +1,14 @@ +from . import keys +from .client import DEFAULT_INFER_TIMEOUT, InferenceClient, InferenceSession +from .serialization import deserialise, encode_jpeg, make_wire, serialise + +__all__ = [ + 'DEFAULT_INFER_TIMEOUT', + 'InferenceClient', + 'InferenceSession', + 'deserialise', + 'encode_jpeg', + 'keys', + 'make_wire', + 'serialise', +] diff --git a/positronic/offboard/client.py b/packages/positronic-client/positronic_client/client.py similarity index 55% rename from positronic/offboard/client.py rename to packages/positronic-client/positronic_client/client.py index 7378a887b..4fa27b463 100644 --- a/positronic/offboard/client.py +++ b/packages/positronic-client/positronic_client/client.py @@ -1,14 +1,18 @@ +import collections.abc as cabc import logging import ssl import time +from collections.abc import Callable from typing import Any import httpx +import numpy as np +from PIL import Image as PilImage from websockets.exceptions import ConnectionClosed, InvalidHandshake, InvalidStatus from websockets.sync.client import connect from websockets.sync.connection import Connection -from positronic.utils.serialization import deserialise, serialise +from .serialization import deserialise, encode_jpeg, serialise logger = logging.getLogger(__name__) @@ -19,11 +23,46 @@ class InferenceSession: - def __init__(self, websocket: Connection, infer_timeout: float = DEFAULT_INFER_TIMEOUT): + """One inference session: streams observations, receives action chunks. + + Images are downsized before sending to reduce bandwidth: the server reports the sizes its codec expects via + ``image_sizes`` in its metadata (see ``Codec.meta``), and every frame is fit into the reported size + (aspect-preserving, never upscaled). The ``resize`` parameter acts as a fallback when the server does not + report sizes; server-reported sizes always take precedence. + + This downsizing is a property of the WIRE, applied to every session — including a localhost server, where it + still shrinks the msgpack payload on both ends while leaving the codec's output unchanged (the codec resizes + to the same advertised target regardless; the fit just moves that resample before serialization). In-process + inference never constructs a session, so nothing local-to-the-process is ever touched. A client-side codec + whose output is already model-sized passes through untouched (fit never upscales). + """ + + def __init__( + self, + websocket: Connection, + infer_timeout: float = DEFAULT_INFER_TIMEOUT, + *, + resize: int | None = None, + compress_images: bool = False, + serialise: Callable[[Any], bytes] = serialise, + deserialise: Callable[[bytes], Any] = deserialise, + ): self._websocket = websocket self._infer_timeout = infer_timeout + self._resize = resize + self._compress_images = compress_images + self._serialise = serialise + self._deserialise = deserialise self._metadata = self._handshake() + self._image_sizes: dict[str, tuple[int, int]] = {} + self._default_image_size: tuple[int, int] | None = None + sizes = self._metadata.get('image_sizes') + if isinstance(sizes, dict): + self._image_sizes = {k: tuple(v) for k, v in sizes.items()} + elif isinstance(sizes, tuple | list): + self._default_image_size = tuple(sizes) + def _handshake(self, timeout_per_message: float = 30.0) -> dict[str, Any]: """Receive status updates until server is ready. @@ -33,7 +72,7 @@ def _handshake(self, timeout_per_message: float = 30.0) -> dict[str, Any]: """ try: while True: - response = deserialise(self._websocket.recv(timeout=timeout_per_message)) + response = self._deserialise(self._websocket.recv(timeout=timeout_per_message)) status = response.get('status') if status == 'ready': @@ -59,19 +98,60 @@ def _handshake(self, timeout_per_message: float = 30.0) -> dict[str, Any]: def metadata(self) -> dict[str, Any]: return self._metadata - def infer(self, obs: dict[str, Any]) -> dict[str, Any]: + @staticmethod + def _resize_to(image: np.ndarray, width: int, height: int) -> np.ndarray: + h, w = image.shape[:2] + if w == width and h == height: + return image + return np.array(PilImage.fromarray(image).resize((width, height), resample=PilImage.Resampling.BILINEAR)) + + @staticmethod + def _fit(image: np.ndarray, tw: int, th: int) -> np.ndarray: + h, w = image.shape[:2] + scale = min(1.0, tw / w, th / h) + return InferenceSession._resize_to(image, int(w * scale), int(h * scale)) + + def _prepare_obs(self, obs: dict[str, Any]) -> dict[str, Any]: + return {key: self._prepare_value(key, value) for key, value in obs.items()} + + def _prepare_value(self, key: str, value: Any) -> Any: + # Client-side codecs (e.g. GR00T) nest images inside dicts/lists, so recurse to reach every + # image array rather than scanning the top level alone. + if isinstance(value, np.ndarray) and value.ndim in (3, 4) and value.shape[-1] == 3: + return self._prepare_image(key, value) + if isinstance(value, cabc.Mapping): + return {k: self._prepare_value(k, v) for k, v in value.items()} + if isinstance(value, list | tuple): + return type(value)(self._prepare_value(key, v) for v in value) + return value + + def _prepare_image(self, key: str, image: np.ndarray) -> np.ndarray | dict[bytes, Any]: + # Resize single RGB frames and temporal stacks of them alike (TemporalStack emits a + # (T, H, W, 3) stack), so a stack of hd720 frames isn't shipped full-resolution. + target = self._image_sizes.get(key, self._default_image_size) + r = self._resize or 0 + tw, th = target or (r, r) + if tw > 0 and th > 0: + image = np.stack([self._fit(f, tw, th) for f in image]) if image.ndim == 4 else self._fit(image, tw, th) + # Optionally JPEG-compress before sending: a raw HD frame — and especially a (T, H, W, 3) + # stack — can exceed the ~2 MB websocket message cap of a Modal-fronted endpoint. Off by default. + if self._compress_images: + image = encode_jpeg(image) + return image + + def infer(self, obs: dict[str, Any]) -> Any: """ Send an observation and get an action. Both `obs` and the returned action must be wire-serializable: plain-data containers and scalars, plus numeric numpy arrays/scalars. Do not pass arbitrary Python objects. """ - serialised = serialise(obs) + serialised = self._serialise(self._prepare_obs(obs)) logger.debug('Size of serialised obs: %1.f KiB', len(serialised) / 1024) self._websocket.send(serialised) try: - response = deserialise(self._websocket.recv(timeout=self._infer_timeout)) + response = self._deserialise(self._websocket.recv(timeout=self._infer_timeout)) except TimeoutError: # The observation is in flight but unanswered; the server's late response would sit in the socket and # the next ``recv`` would pair it with a future observation. Close so the desynced session can't be @@ -92,10 +172,27 @@ def close(self): class InferenceClient: - def __init__(self, host: str, port: int, *, headers: dict[str, str] | None = None, secure: bool = False): + def __init__( + self, + host: str, + port: int, + *, + model_id: str | None = None, + headers: dict[str, str] | None = None, + secure: bool = False, + resize: int | None = None, + compress_images: bool = False, + serialise: Callable[[Any], bytes] = serialise, + deserialise: Callable[[bytes], Any] = deserialise, + ): self.host = host self.port = port self.headers = dict(headers) if headers else None + self._model_id = model_id + self._resize = resize + self._compress_images = compress_images + self._serialise = serialise + self._deserialise = deserialise ws_scheme = 'wss' if secure else 'ws' http_scheme = 'https' if secure else 'http' default_port = 443 if secure else 80 @@ -114,11 +211,12 @@ def new_session( Creates a new inference session. Args: - model_id: Optional model ID to connect to + model_id: Optional model ID to connect to; falls back to the client's pinned ``model_id``. open_timeout: Timeout for initial WebSocket connection (default: 10s). This only covers TCP/HTTP handshake, not model loading. Model loading timeout is controlled by per-message timeout in handshake. """ + model_id = model_id if model_id is not None else self._model_id uri = self.base_uri if model_id is None else f'{self.base_uri}/{model_id}' connect_kwargs: dict[str, object] = {'open_timeout': open_timeout} if self.headers: @@ -130,7 +228,14 @@ def new_session( ws = None try: ws = connect(uri, **connect_kwargs) - return InferenceSession(ws, infer_timeout=infer_timeout) + return InferenceSession( + ws, + infer_timeout=infer_timeout, + resize=self._resize, + compress_images=self._compress_images, + serialise=self._serialise, + deserialise=self._deserialise, + ) # ``SSLCertVerificationError`` is an ``ssl.SSLError``, but a bad certificate is permanent # misconfiguration, not a cold start — surface it immediately instead of retrying to the deadline. except ssl.SSLCertVerificationError as e: diff --git a/packages/positronic-client/positronic_client/keys.py b/packages/positronic-client/positronic_client/keys.py new file mode 100644 index 000000000..cee0da63b --- /dev/null +++ b/packages/positronic-client/positronic_client/keys.py @@ -0,0 +1,13 @@ +"""Canonical raw observation keys of the positronic inference wire. + +Server-side codecs consume these keys from the raw observation dict; every client — positronic's own +``RemotePolicy``, sim adapters, customer integrations — sends them. They are defined here, once, so a rename is a +one-site change the type checker propagates instead of a silent client/server desync. +""" + +JOINTS = 'robot_state.q' +EE_POSE = 'robot_state.ee_pose' +GRIP = 'grip' +WRIST_IMAGE = 'image.wrist' +EXTERIOR_IMAGE = 'image.exterior' +TASK = 'task' diff --git a/packages/positronic-client/positronic_client/serialization.py b/packages/positronic-client/positronic_client/serialization.py new file mode 100644 index 000000000..7f9c23e8a --- /dev/null +++ b/packages/positronic-client/positronic_client/serialization.py @@ -0,0 +1,92 @@ +"""Wire serialization for numpy arrays and standard Python types, with extension hooks for domain envelopes. + +The base wire supports: +- built-in scalars: `str`, `int`, `float`, `bool`, `None` +- containers: `dict` / `list` / `tuple` recursively composed of supported values +- numeric numpy values: `numpy.ndarray` and `numpy` scalar types +- JPEG-compressed images (``encode_jpeg`` markers, decoded transparently) + +Domain types ride as extension hooks: ``make_wire`` builds a ``(serialise, deserialise)`` pair whose hooks run +after the base handling, so a dialect (e.g. positronic's roboarm-command envelopes) layers on top without this +module knowing about it. A consumer of the base wire receives unknown envelopes as the plain dicts they are on +the wire. +""" + +import collections.abc as cabc +import functools +import io +from collections.abc import Callable +from typing import Any + +import msgpack +import numpy as np +from PIL import Image as PilImage + +# JPEG quality for images on the wire. A single HD frame — and especially a (T, H, W, 3) stack — is many +# MB raw, over the ~2 MB websocket message cap of a Modal-fronted endpoint. Per-frame JPEG keeps a +# 25-frame two-camera stack around 1-2 MB and cuts upload latency; q=90 is visually lossless here. +_JPEG_QUALITY = 90 + +# A pack hook returns the wire form of a domain object, or None when the object isn't its to handle. +# An unpack hook returns the domain object for a wire dict, or None to pass it to the next hook. +PackHook = Callable[[Any], Any | None] +UnpackHook = Callable[[dict], Any | None] + + +def encode_jpeg(image: np.ndarray) -> dict[bytes, Any]: + """JPEG-encode a single ``(H, W, 3)`` image or a ``(T, H, W, 3)`` stack to a compact wire marker. + + Sends one JPEG per frame plus the original ``ndim`` so ``_unpack`` restores the exact shape. + """ + frames = image if image.ndim == 4 else image[None] + bufs = [] + for frame in frames: + buf = io.BytesIO() + PilImage.fromarray(np.ascontiguousarray(frame, dtype=np.uint8)).save(buf, format='JPEG', quality=_JPEG_QUALITY) + bufs.append(buf.getvalue()) + return {b'__jpeg__': True, b'frames': bufs, b'ndim': int(image.ndim)} + + +def _decode_jpeg(marker: dict) -> np.ndarray: + """Inverse of ``encode_jpeg``: decode per-frame JPEGs and restore the original shape.""" + frames = np.stack([np.asarray(PilImage.open(io.BytesIO(buf))) for buf in marker[b'frames']]) + return frames if marker[b'ndim'] == 4 else frames[0] + + +def make_wire( + pack_hooks: tuple[PackHook, ...] = (), unpack_hooks: tuple[UnpackHook, ...] = () +) -> tuple[Callable[[Any], bytes], Callable[[bytes], Any]]: + """Build a ``(serialise, deserialise)`` pair extending the base wire with domain hooks.""" + + def _pack(obj): + if isinstance(obj, cabc.Mapping): + return dict(obj) + if isinstance(obj, np.ndarray | np.generic) and obj.dtype.kind in ('V', 'O', 'c'): + raise ValueError(f'Unsupported dtype: {obj.dtype}') + if isinstance(obj, np.ndarray): + return {b'__ndarray__': True, b'data': obj.tobytes(), b'dtype': obj.dtype.str, b'shape': obj.shape} + if isinstance(obj, np.generic): + return {b'__npgeneric__': True, b'data': obj.item(), b'dtype': obj.dtype.str} + for hook in pack_hooks: + wire = hook(obj) + if wire is not None: + return wire + return obj + + def _unpack(obj): + if b'__ndarray__' in obj: + return np.ndarray(buffer=obj[b'data'], dtype=np.dtype(obj[b'dtype']), shape=obj[b'shape']) + if b'__npgeneric__' in obj: + return np.dtype(obj[b'dtype']).type(obj[b'data']) + if b'__jpeg__' in obj: + return _decode_jpeg(obj) + for hook in unpack_hooks: + value = hook(obj) + if value is not None: + return value + return obj + + return functools.partial(msgpack.packb, default=_pack), functools.partial(msgpack.unpackb, object_hook=_unpack) + + +serialise, deserialise = make_wire() diff --git a/packages/positronic-client/positronic_client/tests/__init__.py b/packages/positronic-client/positronic_client/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/positronic-client/positronic_client/tests/test_client.py b/packages/positronic-client/positronic_client/tests/test_client.py new file mode 100644 index 000000000..d5fff5744 --- /dev/null +++ b/packages/positronic-client/positronic_client/tests/test_client.py @@ -0,0 +1,161 @@ +from unittest.mock import MagicMock, patch + +import numpy as np + +from positronic_client.client import DEFAULT_INFER_TIMEOUT, InferenceClient, InferenceSession +from positronic_client.serialization import serialise + + +def _ready_ws(metadata=None): + """A mock websocket whose handshake immediately reports ready with the given metadata.""" + ws = MagicMock() + ws.recv.return_value = serialise({'status': 'ready', 'meta': metadata or {}}) + return ws + + +def _make_image(h, w): + return np.zeros((h, w, 3), dtype=np.uint8) + + +class TestPrepareObs: + """Tests for InferenceSession._prepare_obs image resize logic.""" + + def test_server_tuple_resizes_all_images(self): + session = InferenceSession(_ready_ws({'image_sizes': (64, 48)})) + obs = {'cam_a': _make_image(480, 640), 'cam_b': _make_image(240, 320), 'state': np.array([1.0])} + result = session._prepare_obs(obs) + assert result['cam_a'].shape == (48, 64, 3) + assert result['cam_b'].shape == (48, 64, 3) + np.testing.assert_array_equal(result['state'], obs['state']) + + def test_server_dict_resizes_per_key(self): + sizes = {'cam_a': (64, 48), 'cam_b': (32, 24)} + session = InferenceSession(_ready_ws({'image_sizes': sizes})) + obs = {'cam_a': _make_image(480, 640), 'cam_b': _make_image(480, 640)} + result = session._prepare_obs(obs) + assert result['cam_a'].shape == (48, 64, 3) + assert result['cam_b'].shape == (24, 32, 3) + + def test_fallback_resize_scales_by_max_dim(self): + session = InferenceSession(_ready_ws(), resize=160) + obs = {'cam': _make_image(480, 640)} + result = session._prepare_obs(obs) + assert result['cam'].shape == (120, 160, 3) + + def test_no_resize_when_already_correct_size(self): + session = InferenceSession(_ready_ws({'image_sizes': (64, 48)})) + img = _make_image(48, 64) + result = session._prepare_obs({'cam': img}) + assert result['cam'] is img + + def test_no_resize_without_server_sizes_or_fallback(self): + session = InferenceSession(_ready_ws()) + img = _make_image(480, 640) + result = session._prepare_obs({'cam': img}) + assert result['cam'] is img + + def test_normalizes_list_to_tuple(self): + """Wire format (msgpack) turns tuples into lists — must normalize.""" + session = InferenceSession(_ready_ws({'image_sizes': [64, 48]})) + assert session._default_image_size == (64, 48) + assert isinstance(session._default_image_size, tuple) + + def test_normalizes_dict_values(self): + session = InferenceSession(_ready_ws({'image_sizes': {'cam_a': [64, 48], 'cam_b': [32, 24]}})) + assert session._image_sizes == {'cam_a': (64, 48), 'cam_b': (32, 24)} + assert all(isinstance(v, tuple) for v in session._image_sizes.values()) + + def test_non_image_values_pass_through(self): + session = InferenceSession(_ready_ws({'image_sizes': (64, 48)})) + obs = {'state': np.array([1.0, 2.0]), 'task': 'pick cube', 'flag': True} + result = session._prepare_obs(obs) + np.testing.assert_array_equal(result['state'], obs['state']) + assert result['task'] == 'pick cube' + assert result['flag'] is True + + +class TestInferenceClientHeaders: + def test_default_headers_empty_and_ws_scheme(self): + client = InferenceClient('localhost', 8000) + assert client.headers is None + assert client.base_uri == 'ws://localhost:8000/api/v1/session' + assert client.api_url == 'http://localhost:8000/api/v1' + + def test_headers_stored_and_copied(self): + headers = {'Modal-Key': 'k', 'Modal-Secret': 's'} + client = InferenceClient('localhost', 8000, headers=headers) + assert client.headers == headers + # Defensive copy — mutating the caller's dict must not affect the client. + headers['Modal-Key'] = 'mutated' + assert client.headers['Modal-Key'] == 'k' + + def test_secure_switches_scheme_and_omits_default_port(self): + client = InferenceClient('example.com', 443, secure=True) + assert client.base_uri == 'wss://example.com/api/v1/session' + assert client.api_url == 'https://example.com/api/v1' + + def test_secure_keeps_non_default_port(self): + client = InferenceClient('example.com', 8443, secure=True) + assert client.base_uri == 'wss://example.com:8443/api/v1/session' + assert client.api_url == 'https://example.com:8443/api/v1' + + def test_insecure_omits_default_port(self): + client = InferenceClient('example.com', 80, secure=False) + assert client.base_uri == 'ws://example.com/api/v1/session' + assert client.api_url == 'http://example.com/api/v1' + + def test_new_session_passes_additional_headers(self): + headers = {'Modal-Key': 'k', 'Modal-Secret': 's'} + with ( + patch('positronic_client.client.connect') as mock_connect, + patch('positronic_client.client.InferenceSession') as mock_session_cls, + ): + client = InferenceClient('localhost', 8000, headers=headers) + client.new_session() + + mock_connect.assert_called_once() + assert mock_connect.call_args.kwargs['additional_headers'] == headers + assert mock_session_cls.call_args.args == (mock_connect.return_value,) + assert mock_session_cls.call_args.kwargs['infer_timeout'] == DEFAULT_INFER_TIMEOUT + + def test_new_session_without_headers_omits_additional_headers(self): + with ( + patch('positronic_client.client.connect') as mock_connect, + patch('positronic_client.client.InferenceSession'), + ): + client = InferenceClient('localhost', 8000) + client.new_session() + + mock_connect.assert_called_once() + assert 'additional_headers' not in mock_connect.call_args.kwargs + + def test_new_session_uses_pinned_model_id(self): + with ( + patch('positronic_client.client.connect') as mock_connect, + patch('positronic_client.client.InferenceSession'), + ): + client = InferenceClient('localhost', 8000, model_id='m42') + client.new_session() + assert mock_connect.call_args.args[0].endswith('/api/v1/session/m42') + # A per-call model_id overrides the pinned one. + client.new_session(model_id='m7') + assert mock_connect.call_args.args[0].endswith('/api/v1/session/m7') + + def test_list_models_passes_headers(self): + headers = {'Modal-Key': 'k', 'Modal-Secret': 's'} + with patch('positronic_client.client.httpx.get') as mock_get: + mock_get.return_value.json.return_value = {'models': ['m1']} + client = InferenceClient('localhost', 8000, headers=headers) + + models = client.list_models() + + assert models == ['m1'] + assert mock_get.call_args.kwargs['headers'] == headers + + def test_list_models_without_headers_passes_none(self): + with patch('positronic_client.client.httpx.get') as mock_get: + mock_get.return_value.json.return_value = {'models': []} + client = InferenceClient('localhost', 8000) + client.list_models() + + assert mock_get.call_args.kwargs['headers'] is None diff --git a/packages/positronic-client/pyproject.toml b/packages/positronic-client/pyproject.toml new file mode 100644 index 000000000..8be83a118 --- /dev/null +++ b/packages/positronic-client/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "positronic-client" +version = "0.1.0" +description = "Light client for the positronic inference wire: canonical observation keys, serialization, WebSocket client" +requires-python = ">=3.11, <3.14" +license = { text = "Apache-2.0" } +dependencies = [ + "httpx", + "msgpack", + # Deliberately unconstrained: this package is pure Python (no compiled numpy ABI dependency; the APIs it + # uses are stable across 1.19-2.x), and its whole point is installing into foreign venvs whose ML stacks + # already pin their own numpy. Any constraint here only manufactures resolver conflicts — never add one. + "numpy", + "pillow", + "websockets>=15.0.1", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["positronic_client*"] diff --git a/positronic/cfg/policy.py b/positronic/cfg/policy.py index dc36d6d7c..db52215a1 100644 --- a/positronic/cfg/policy.py +++ b/positronic/cfg/policy.py @@ -1,8 +1,8 @@ import configuronic as cfn import pos3 +from positronic_client.client import DEFAULT_INFER_TIMEOUT from positronic.cfg import codecs -from positronic.offboard.client import DEFAULT_INFER_TIMEOUT from positronic.policy import ActionHorizon, Codec, Policy, Recorder, RemotePolicy from positronic.utils import get_latest_checkpoint diff --git a/positronic/offboard/README.md b/positronic/offboard/README.md index 074ab813a..e5e35193a 100644 --- a/positronic/offboard/README.md +++ b/positronic/offboard/README.md @@ -170,7 +170,7 @@ uv run positronic-inference sim \ **Server-side recording:** Servers accept an optional `recording_dir`. When set, each WebSocket session writes a rerun `.rrd` file that taps both sides of the codec: `raw` captures the obs/action at the wire boundary, and `inference` captures the encoded observation and raw model output. -**Python Client:** We provide a Python client (`positronic.offboard.client.InferenceClient`) that handles the WebSocket protocol automatically. While the API is currently in alpha and may change, we'll do our best to maintain backward compatibility for the inference client. +**Python Client:** We provide a Python client (`positronic_client.InferenceClient`, from the light `positronic-client` package under `packages/positronic-client/`) that handles the WebSocket protocol automatically. The package carries only the wire-facing pieces — canonical observation keys (`positronic_client.keys`), serialization, and the client with its negotiated image downsizing — with no dependency on positronic itself, so foreign-venv consumers (sim adapters, customer integrations) install it without positronic's stack. While the API is currently in alpha and may change, we'll do our best to maintain backward compatibility for the inference client. ## Classes @@ -190,11 +190,11 @@ server = InferenceServer(registry, host='0.0.0.0', port=8000) server.serve() ``` -### `client.InferenceClient` +### `positronic_client.InferenceClient` A Python client for connecting to an inference server. ```python -from positronic.offboard.client import InferenceClient +from positronic_client import InferenceClient client = InferenceClient('localhost', 8000) diff --git a/positronic/offboard/__init__.py b/positronic/offboard/__init__.py index 807f52dd0..e94604512 100644 --- a/positronic/offboard/__init__.py +++ b/positronic/offboard/__init__.py @@ -1,4 +1,3 @@ -from .client import InferenceClient, InferenceSession from .vendor_server import VendorServer -__all__ = ['InferenceClient', 'InferenceSession', 'VendorServer'] +__all__ = ['VendorServer'] diff --git a/positronic/offboard/tests/test_offboard.py b/positronic/offboard/tests/test_offboard.py index fa713fe81..b4576fb3b 100644 --- a/positronic/offboard/tests/test_offboard.py +++ b/positronic/offboard/tests/test_offboard.py @@ -1,11 +1,12 @@ from types import MappingProxyType import numpy as np +from positronic_client.client import InferenceClient +from positronic_client.serialization import encode_jpeg from positronic.drivers.roboarm.command import CartesianPosition, JointDelta, JointPosition, Reset from positronic.geom import Rotation, Transform3D -from positronic.offboard.client import InferenceClient -from positronic.utils.serialization import deserialise, encode_jpeg, serialise +from positronic.utils.serialization import deserialise, serialise def test_inference_client_connect_and_infer(inference_server, mock_policy): diff --git a/positronic/offboard/tests/test_remote_policy.py b/positronic/offboard/tests/test_remote_policy.py index 9df303653..78e25699b 100644 --- a/positronic/offboard/tests/test_remote_policy.py +++ b/positronic/offboard/tests/test_remote_policy.py @@ -1,11 +1,7 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock -import numpy as np - -from positronic.offboard.client import DEFAULT_INFER_TIMEOUT, InferenceClient from positronic.policy import RemotePolicy from positronic.policy.codec import ActionHorizon -from positronic.policy.remote import RemoteSession def _mock_ws_session(metadata=None): @@ -15,141 +11,6 @@ def _mock_ws_session(metadata=None): return session -def _make_image(h, w): - return np.zeros((h, w, 3), dtype=np.uint8) - - -class TestPrepareObs: - """Tests for RemoteSession._prepare_obs image resize logic.""" - - def test_server_tuple_resizes_all_images(self): - session = RemoteSession(_mock_ws_session({'image_sizes': (64, 48)}), resize=None) - obs = {'cam_a': _make_image(480, 640), 'cam_b': _make_image(240, 320), 'state': np.array([1.0])} - result = session._prepare_obs(obs) - assert result['cam_a'].shape == (48, 64, 3) - assert result['cam_b'].shape == (48, 64, 3) - np.testing.assert_array_equal(result['state'], obs['state']) - - def test_server_dict_resizes_per_key(self): - sizes = {'cam_a': (64, 48), 'cam_b': (32, 24)} - session = RemoteSession(_mock_ws_session({'image_sizes': sizes}), resize=None) - obs = {'cam_a': _make_image(480, 640), 'cam_b': _make_image(480, 640)} - result = session._prepare_obs(obs) - assert result['cam_a'].shape == (48, 64, 3) - assert result['cam_b'].shape == (24, 32, 3) - - def test_fallback_resize_scales_by_max_dim(self): - session = RemoteSession(_mock_ws_session(), resize=160) - obs = {'cam': _make_image(480, 640)} - result = session._prepare_obs(obs) - assert result['cam'].shape == (120, 160, 3) - - def test_no_resize_when_already_correct_size(self): - session = RemoteSession(_mock_ws_session({'image_sizes': (64, 48)}), resize=None) - img = _make_image(48, 64) - result = session._prepare_obs({'cam': img}) - assert result['cam'] is img - - def test_no_resize_without_server_sizes_or_fallback(self): - session = RemoteSession(_mock_ws_session(), resize=None) - img = _make_image(480, 640) - result = session._prepare_obs({'cam': img}) - assert result['cam'] is img - - def test_normalizes_list_to_tuple(self): - """Wire format (msgpack) turns tuples into lists — must normalize.""" - session = RemoteSession(_mock_ws_session({'image_sizes': [64, 48]}), resize=None) - assert session._default_image_size == (64, 48) - assert isinstance(session._default_image_size, tuple) - - def test_normalizes_dict_values(self): - session = RemoteSession(_mock_ws_session({'image_sizes': {'cam_a': [64, 48], 'cam_b': [32, 24]}}), resize=None) - assert session._image_sizes == {'cam_a': (64, 48), 'cam_b': (32, 24)} - assert all(isinstance(v, tuple) for v in session._image_sizes.values()) - - def test_non_image_values_pass_through(self): - session = RemoteSession(_mock_ws_session({'image_sizes': (64, 48)}), resize=None) - obs = {'state': np.array([1.0, 2.0]), 'task': 'pick cube', 'flag': True} - result = session._prepare_obs(obs) - np.testing.assert_array_equal(result['state'], obs['state']) - assert result['task'] == 'pick cube' - assert result['flag'] is True - - -class TestInferenceClientHeaders: - def test_default_headers_empty_and_ws_scheme(self): - client = InferenceClient('localhost', 8000) - assert client.headers is None - assert client.base_uri == 'ws://localhost:8000/api/v1/session' - assert client.api_url == 'http://localhost:8000/api/v1' - - def test_headers_stored_and_copied(self): - headers = {'Modal-Key': 'k', 'Modal-Secret': 's'} - client = InferenceClient('localhost', 8000, headers=headers) - assert client.headers == headers - # Defensive copy — mutating the caller's dict must not affect the client. - headers['Modal-Key'] = 'mutated' - assert client.headers['Modal-Key'] == 'k' - - def test_secure_switches_scheme_and_omits_default_port(self): - client = InferenceClient('example.com', 443, secure=True) - assert client.base_uri == 'wss://example.com/api/v1/session' - assert client.api_url == 'https://example.com/api/v1' - - def test_secure_keeps_non_default_port(self): - client = InferenceClient('example.com', 8443, secure=True) - assert client.base_uri == 'wss://example.com:8443/api/v1/session' - assert client.api_url == 'https://example.com:8443/api/v1' - - def test_insecure_omits_default_port(self): - client = InferenceClient('example.com', 80, secure=False) - assert client.base_uri == 'ws://example.com/api/v1/session' - assert client.api_url == 'http://example.com/api/v1' - - def test_new_session_passes_additional_headers(self): - headers = {'Modal-Key': 'k', 'Modal-Secret': 's'} - with ( - patch('positronic.offboard.client.connect') as mock_connect, - patch('positronic.offboard.client.InferenceSession') as mock_session_cls, - ): - client = InferenceClient('localhost', 8000, headers=headers) - client.new_session() - - mock_connect.assert_called_once() - assert mock_connect.call_args.kwargs['additional_headers'] == headers - mock_session_cls.assert_called_once_with(mock_connect.return_value, infer_timeout=DEFAULT_INFER_TIMEOUT) - - def test_new_session_without_headers_omits_additional_headers(self): - with ( - patch('positronic.offboard.client.connect') as mock_connect, - patch('positronic.offboard.client.InferenceSession'), - ): - client = InferenceClient('localhost', 8000) - client.new_session() - - mock_connect.assert_called_once() - assert 'additional_headers' not in mock_connect.call_args.kwargs - - def test_list_models_passes_headers(self): - headers = {'Modal-Key': 'k', 'Modal-Secret': 's'} - with patch('positronic.offboard.client.httpx.get') as mock_get: - mock_get.return_value.json.return_value = {'models': ['m1']} - client = InferenceClient('localhost', 8000, headers=headers) - - models = client.list_models() - - assert models == ['m1'] - assert mock_get.call_args.kwargs['headers'] == headers - - def test_list_models_without_headers_passes_none(self): - with patch('positronic.offboard.client.httpx.get') as mock_get: - mock_get.return_value.json.return_value = {'models': []} - client = InferenceClient('localhost', 8000) - client.list_models() - - assert mock_get.call_args.kwargs['headers'] is None - - class TestRemotePolicyHeaderPropagation: def test_headers_and_secure_forwarded_to_client(self): headers = {'Modal-Key': 'k'} diff --git a/positronic/offboard/tests/test_vendor_server.py b/positronic/offboard/tests/test_vendor_server.py index c864c35bb..2e5e0f6a6 100644 --- a/positronic/offboard/tests/test_vendor_server.py +++ b/positronic/offboard/tests/test_vendor_server.py @@ -7,8 +7,8 @@ import pytest import uvicorn +from positronic_client.client import InferenceClient -from positronic.offboard.client import InferenceClient from positronic.offboard.vendor_server import VendorServer from positronic.policy import Codec diff --git a/positronic/policy/remote.py b/positronic/policy/remote.py index 60f73aa02..80e281bba 100644 --- a/positronic/policy/remote.py +++ b/positronic/policy/remote.py @@ -1,12 +1,9 @@ -import collections.abc as cabc from typing import Any -import numpy as np -from PIL import Image as PilImage +from positronic_client.client import DEFAULT_INFER_TIMEOUT, InferenceClient, InferenceSession -from positronic.offboard.client import DEFAULT_INFER_TIMEOUT, InferenceClient, InferenceSession from positronic.utils import flatten_dict -from positronic.utils.serialization import encode_jpeg +from positronic.utils.serialization import deserialise, serialise from .base import Policy, Session @@ -14,59 +11,8 @@ class RemoteSession(Session): """Per-episode session that forwards observations to a remote inference server.""" - def __init__(self, ws_session: InferenceSession, resize: int | None, compress_images: bool = False): + def __init__(self, ws_session: InferenceSession): self._session = ws_session - self._resize = resize - self._compress_images = compress_images - self._image_sizes: dict[str, tuple[int, int]] = {} - self._default_image_size: tuple[int, int] | None = None - - sizes = ws_session.metadata.get('image_sizes') - if isinstance(sizes, dict): - self._image_sizes = {k: tuple(v) for k, v in sizes.items()} - elif isinstance(sizes, tuple | list): - self._default_image_size = tuple(sizes) - - @staticmethod - def _resize_to(image: np.ndarray, width: int, height: int) -> np.ndarray: - h, w = image.shape[:2] - if w == width and h == height: - return image - return np.array(PilImage.fromarray(image).resize((width, height), resample=PilImage.Resampling.BILINEAR)) - - @staticmethod - def _fit(image: np.ndarray, tw: int, th: int) -> np.ndarray: - h, w = image.shape[:2] - scale = min(1.0, tw / w, th / h) - return RemoteSession._resize_to(image, int(w * scale), int(h * scale)) - - def _prepare_obs(self, obs: dict[str, Any]) -> dict[str, Any]: - return {key: self._prepare_value(key, value) for key, value in obs.items()} - - def _prepare_value(self, key: str, value: Any) -> Any: - # Client-side codecs (e.g. GR00T) nest images inside dicts/lists, so recurse to reach every - # image array rather than scanning the top level alone. - if isinstance(value, np.ndarray) and value.ndim in (3, 4) and value.shape[-1] == 3: - return self._prepare_image(key, value) - if isinstance(value, cabc.Mapping): - return {k: self._prepare_value(k, v) for k, v in value.items()} - if isinstance(value, list | tuple): - return type(value)(self._prepare_value(key, v) for v in value) - return value - - def _prepare_image(self, key: str, image: np.ndarray) -> np.ndarray | dict[bytes, Any]: - # Resize single RGB frames and temporal stacks of them alike (TemporalStack emits a - # (T, H, W, 3) stack), so a stack of hd720 frames isn't shipped full-resolution. - target = self._image_sizes.get(key, self._default_image_size) - r = self._resize or 0 - tw, th = target or (r, r) - if tw > 0 and th > 0: - image = np.stack([self._fit(f, tw, th) for f in image]) if image.ndim == 4 else self._fit(image, tw, th) - # Optionally JPEG-compress before sending: a raw HD frame — and especially a (T, H, W, 3) - # stack — can exceed the ~2 MB websocket message cap of a Modal-fronted endpoint. Off by default. - if self._compress_images: - image = encode_jpeg(image) - return image def __call__(self, obs: dict[str, Any]) -> list[dict[str, Any]] | None: """Forwards the observation to the remote server and returns the action trajectory. @@ -75,7 +21,7 @@ def __call__(self, obs: dict[str, Any]) -> list[dict[str, Any]] | None: Single-action server responses are wrapped into a 1-element list to honor the ``Session.__call__`` contract (``list[dict] | None``). """ - result = self._session.infer(self._prepare_obs(obs)) + result = self._session.infer(obs) if isinstance(result, dict): return [result] return result @@ -91,10 +37,10 @@ def close(self): class RemotePolicy(Policy): """Policy that creates sessions forwarding observations to a remote inference server. - Images are resized before sending to reduce bandwidth. The server reports - expected sizes via ``image_sizes`` in its metadata (see ``Codec.meta``). - The ``resize`` parameter acts as a fallback when the server does not report - sizes. Server-reported sizes always take precedence. + The underlying ``InferenceSession`` downsizes images before sending to reduce bandwidth, driven by the + ``image_sizes`` the server reports in its metadata (see ``Codec.meta``); ``resize`` is the fallback when the + server does not report sizes. Sessions speak positronic's wire dialect, so roboarm commands round-trip as + objects. ``headers`` / ``secure`` are forwarded to the underlying ``InferenceClient`` for authenticated / TLS-fronted endpoints (e.g. Modal, behind a reverse proxy). @@ -112,11 +58,18 @@ def __init__( infer_timeout: float = DEFAULT_INFER_TIMEOUT, compress_images: bool = False, ): - self._client = InferenceClient(host, port, headers=headers, secure=secure) - self._resize = resize + self._client = InferenceClient( + host, + port, + headers=headers, + secure=secure, + resize=resize, + compress_images=compress_images, + serialise=serialise, + deserialise=deserialise, + ) self._model_id = model_id self._infer_timeout = infer_timeout - self._compress_images = compress_images # Server metadata cached after the first session is created or `meta` # is read. Needed so consumers like ``SampledPolicy._get_keys`` see # ``server.checkpoint_path`` etc. before any session exists. @@ -135,7 +88,7 @@ def new_session(self, context=None) -> RemoteSession: ws_session = self._client.new_session(model_id=self._model_id, infer_timeout=self._infer_timeout) if self._server_meta is None: self._server_meta = dict(ws_session.metadata) - return RemoteSession(ws_session, self._resize, compress_images=self._compress_images) + return RemoteSession(ws_session) @property def meta(self) -> dict[str, Any]: diff --git a/positronic/simulator/molmo_spaces/adapter.py b/positronic/simulator/molmo_spaces/adapter.py index 56614319a..c683ccb65 100644 --- a/positronic/simulator/molmo_spaces/adapter.py +++ b/positronic/simulator/molmo_spaces/adapter.py @@ -2,20 +2,22 @@ MolmoSpaces drives a DROID FR3 + Robotiq rig and expects a ``BasePolicy`` whose ``get_action`` maps its observation dict to a per-move-group action dict. positronic serves pi05_droid behind an ``InferenceServer`` -(FastAPI on port 8000) whose DROID codec consumes the raw positronic observation keys -(``robot_state.q``, ``grip``, ``image.wrist``, ``image.exterior``, ``task``) and returns a chunk of decoded -per-step ``JointDelta`` commands (7 joint velocities already scaled by ``MAX_JOINT_DELTA``) plus a binarized grip. +(FastAPI on port 8000) whose DROID codec consumes the raw positronic observation keys (``positronic_client.keys``) +and returns a chunk of per-step ``JointDelta`` commands (7 joint velocities already scaled by ``MAX_JOINT_DELTA``) +plus a binarized grip. This module holds: - two pure mapping functions — ``molmo_obs_to_positronic`` and ``positronic_action_to_molmo`` — that carry the - whole translation and are import-free of both frameworks so they run under a bare pytest; + whole translation; - ``ChunkBuffer``, which plays one buffered chunk step per policy tick and re-queries when the chunk drains, matching DROID's open-loop horizon; - - ``FakePolicy``, a server-free stand-in honoring the positronic client contract, for smoke runs; + - ``FakePolicy``, a server-free stand-in honoring the inference-client contract, for smoke runs; - ``MolmoSpacesPolicy``, the ``InferencePolicy`` subclass wiring the above into MolmoSpaces (only usable where molmo_spaces is installed; the mapping logic above is not). -MolmoSpaces and positronic are imported behind guards so the pure logic and its tests need neither installed. +The only positronic dependency is the light ``positronic-client`` package (keys + wire client), installable into +molmo's venv without positronic's stack; molmo_spaces itself is imported behind a guard so the mapping logic and +its tests run without it. Actions arrive over the base wire, so command envelopes are read as their wire dicts. """ import collections.abc as cabc @@ -23,6 +25,8 @@ from typing import Any import numpy as np +from positronic_client import keys +from positronic_client.client import InferenceClient try: from molmo_spaces.policy.base_policy import InferencePolicy as _InferencePolicyBase @@ -43,13 +47,6 @@ MOLMO_ARM_GROUP = 'arm' MOLMO_GRIPPER_GROUP = 'gripper' -# positronic raw observation keys the DROID codec (positronic/vendors/openpi/codecs.py:droid_obs) reads. -POS_JOINTS = 'robot_state.q' -POS_GRIP = 'grip' -POS_WRIST_IMAGE = 'image.wrist' -POS_EXTERIOR_IMAGE = 'image.exterior' -POS_TASK = 'task' - NUM_ARM_JOINTS = 7 # Robotiq closure at which the FR3 gripper qpos saturates; the pi baseline normalizes proprio grip by it @@ -99,11 +96,11 @@ def molmo_obs_to_positronic( grip = float(np.clip(grip_qpos / gripper_qpos_closed, 0.0, 1.0)) return { - POS_JOINTS: arm, - POS_GRIP: np.array([grip], dtype=np.float32), - POS_WRIST_IMAGE: _camera_image(env, wrist_key, MOLMO_WRIST_CAMERA, MOLMO_WRIST_CAMERA_VARIANTS), - POS_EXTERIOR_IMAGE: _camera_image(env, exterior_key, MOLMO_EXTERIOR_CAMERA, MOLMO_EXTERIOR_CAMERA_VARIANTS), - POS_TASK: task, + keys.JOINTS: arm, + keys.GRIP: np.array([grip], dtype=np.float32), + keys.WRIST_IMAGE: _camera_image(env, wrist_key, MOLMO_WRIST_CAMERA, MOLMO_WRIST_CAMERA_VARIANTS), + keys.EXTERIOR_IMAGE: _camera_image(env, exterior_key, MOLMO_EXTERIOR_CAMERA, MOLMO_EXTERIOR_CAMERA_VARIANTS), + keys.TASK: task, } @@ -128,7 +125,13 @@ def _wire_get(mapping: cabc.Mapping, name: str, default: Any = None) -> Any: def _joint_delta_velocities(robot_command: Any) -> np.ndarray: - """Read the 7 joint velocities from a decoded positronic ``JointDelta`` (object) or its wire dict.""" + """Read the 7 joint velocities from a ``JointDelta``-shaped object or its wire form. + + On the base wire a command arrives as its ``__cmd__`` envelope around the ``to_wire`` dict — unwrap it first; + a fake session hands a duck-typed object read via ``.velocities``. + """ + if isinstance(robot_command, cabc.Mapping): + robot_command = _wire_get(robot_command, '__cmd__', robot_command) if hasattr(robot_command, 'velocities'): return np.asarray(robot_command.velocities, dtype=np.float32).reshape(-1) if isinstance(robot_command, cabc.Mapping): @@ -177,7 +180,7 @@ def __init__(self, session: Any): def next(self, obs: dict[str, Any]) -> Any: if not self._pending: - chunk = self._session(obs) + chunk = self._session.infer(obs) # The serving layer ends each chunk with a horizon marker carrying only `timestamp` # (droid's action window is horizon=8/15: 8 actions + the window-end stamp). It is not # an action — playing it as one KeyErrors on `robot_command`, so keep only action-bearing entries. @@ -204,7 +207,7 @@ class _FakeJointDelta: class FakeSession: - """Server-free session emitting action chunks in the shape a positronic ``RemoteSession`` returns.""" + """Server-free session emitting action chunks in the shape an ``InferenceSession`` returns.""" def __init__( self, *, chunk_size: int, num_joints: int, max_joint_delta: float, mode: str, rng: np.random.Generator @@ -225,14 +228,14 @@ def _grip(self) -> float: return 0.0 return 1.0 if self._rng.random() > 0.5 else 0.0 - def __call__(self, obs: dict[str, Any]) -> list[dict[str, Any]]: + def infer(self, obs: dict[str, Any]) -> list[dict[str, Any]]: return [ {'robot_command': _FakeJointDelta(self._velocities()), 'target_grip': self._grip()} for _ in range(self._chunk_size) ] @property - def meta(self) -> dict[str, Any]: + def metadata(self) -> dict[str, Any]: return {'type': 'fake'} def close(self) -> None: @@ -240,7 +243,7 @@ def close(self) -> None: class FakePolicy: - """Drop-in for positronic ``RemotePolicy`` that needs no server — random or zero DROID action chunks. + """Drop-in for ``InferenceClient`` that needs no server — random or zero DROID action chunks. ``mode='random'`` draws velocities uniformly in ``[-max_joint_delta, max_joint_delta]`` and a random binary grip; ``mode='zero'`` holds the arm and keeps the gripper open. Sessions are deterministic given ``seed``. @@ -264,7 +267,7 @@ def __init__( self._seed = seed self._session_count = 0 - def new_session(self, context: dict[str, Any] | None = None) -> FakeSession: + def new_session(self, model_id: str | None = None) -> FakeSession: rng = np.random.default_rng(self._seed + self._session_count) self._session_count += 1 return FakeSession( @@ -294,15 +297,14 @@ def make_policy_client( fake_seed: int = 0, fake_chunk_size: int = DROID_CHUNK_STEPS, ) -> Any: - """Return the inference client the policy talks to: a ``FakePolicy`` when ``fake`` else positronic ``RemotePolicy``. + """Return the inference client the policy talks to: a ``FakePolicy`` when ``fake`` else an ``InferenceClient``. - ``RemotePolicy`` is imported lazily so this module (and its tests) load without positronic installed. + The real client speaks the base wire — sessions downsize frames to the server-advertised ``image_sizes``, and + action commands arrive as their wire envelopes (see ``_joint_delta_velocities``). """ if fake: return FakePolicy(mode=fake_mode, seed=fake_seed, chunk_size=fake_chunk_size) - from positronic.policy.remote import RemotePolicy - - return RemotePolicy(host, port=port, model_id=model_id, secure=secure) + return InferenceClient(host, port, model_id=model_id, secure=secure) @dataclass diff --git a/positronic/simulator/molmo_spaces/tests/test_adapter.py b/positronic/simulator/molmo_spaces/tests/test_adapter.py index 917d97cba..76738cd96 100644 --- a/positronic/simulator/molmo_spaces/tests/test_adapter.py +++ b/positronic/simulator/molmo_spaces/tests/test_adapter.py @@ -1,7 +1,8 @@ """Unit tests for the pi05_droid <-> MolmoSpaces adapter mapping logic. -Runs with NEITHER molmo_spaces nor positronic installed: the adapter import-guards both, and every test here -exercises the pure mapping functions, ``ChunkBuffer``, and ``FakePolicy`` — none of which touch either framework. +Runs without molmo_spaces and without positronic's heavy stack: the adapter needs only the light +``positronic-client`` package, and every test here exercises the pure mapping functions, ``ChunkBuffer``, +and ``FakePolicy`` — none of which touch a framework or a server. Run: uv run --locked pytest positronic/simulator/molmo_spaces/tests/test_adapter.py --no-cov """ @@ -10,15 +11,11 @@ import numpy as np import pytest +from positronic_client import keys from positronic.simulator.molmo_spaces import adapter from positronic.simulator.molmo_spaces.adapter import ( NUM_ARM_JOINTS, - POS_EXTERIOR_IMAGE, - POS_GRIP, - POS_JOINTS, - POS_TASK, - POS_WRIST_IMAGE, ROBOTIQ_CLOSED, ROBOTIQ_OPEN, ChunkBuffer, @@ -56,34 +53,34 @@ def test_module_imports_without_frameworks(): def test_obs_mapping_key_set(): obs = molmo_obs_to_positronic(_load_env_obs(), 'pick up the cube') - assert set(obs) == {POS_JOINTS, POS_GRIP, POS_WRIST_IMAGE, POS_EXTERIOR_IMAGE, POS_TASK} + assert set(obs) == {keys.JOINTS, keys.GRIP, keys.WRIST_IMAGE, keys.EXTERIOR_IMAGE, keys.TASK} def test_obs_mapping_shapes_and_dtypes(): env = _load_env_obs() obs = molmo_obs_to_positronic(env, 'Pick up the cube') - assert obs[POS_JOINTS].shape == (NUM_ARM_JOINTS,) and obs[POS_JOINTS].dtype == np.float32 - assert obs[POS_GRIP].shape == (1,) and obs[POS_GRIP].dtype == np.float32 + assert obs[keys.JOINTS].shape == (NUM_ARM_JOINTS,) and obs[keys.JOINTS].dtype == np.float32 + assert obs[keys.GRIP].shape == (1,) and obs[keys.GRIP].dtype == np.float32 # Frames and task text pass through untouched: model preprocessing (resize-with-pad, prompt lowercasing) # belongs to the server codec, wire downsizing to the inference client. - assert np.array_equal(obs[POS_WRIST_IMAGE], env['wrist_camera']) - assert np.array_equal(obs[POS_EXTERIOR_IMAGE], env['exo_camera_1']) - assert obs[POS_TASK] == 'Pick up the cube' + assert np.array_equal(obs[keys.WRIST_IMAGE], env['wrist_camera']) + assert np.array_equal(obs[keys.EXTERIOR_IMAGE], env['exo_camera_1']) + assert obs[keys.TASK] == 'Pick up the cube' def test_obs_mapping_accepts_batch_list_and_single_dict(): env = _load_env_obs() from_dict = molmo_obs_to_positronic(env, 't') from_list = molmo_obs_to_positronic([env], 't') # MolmoSpaces yields a per-env list - assert np.array_equal(from_dict[POS_JOINTS], from_list[POS_JOINTS]) - assert np.array_equal(from_dict[POS_WRIST_IMAGE], from_list[POS_WRIST_IMAGE]) + assert np.array_equal(from_dict[keys.JOINTS], from_list[keys.JOINTS]) + assert np.array_equal(from_dict[keys.WRIST_IMAGE], from_list[keys.WRIST_IMAGE]) def test_obs_mapping_does_not_swap_cameras(): obs = molmo_obs_to_positronic(_load_env_obs(), 't') # Fixture marks the wrist view reddish and the exterior view greenish; a swap would flip the dominant channel. - wrist_mean = obs[POS_WRIST_IMAGE].reshape(-1, 3).mean(axis=0) - exterior_mean = obs[POS_EXTERIOR_IMAGE].reshape(-1, 3).mean(axis=0) + wrist_mean = obs[keys.WRIST_IMAGE].reshape(-1, 3).mean(axis=0) + exterior_mean = obs[keys.EXTERIOR_IMAGE].reshape(-1, 3).mean(axis=0) assert wrist_mean[0] > wrist_mean[1] # wrist: red > green assert exterior_mean[1] > exterior_mean[0] # exterior: green > red @@ -96,8 +93,8 @@ def test_obs_mapping_resolves_benchmark_variant_camera_keys(): env['wrist_camera_zed_mini'] = env.pop('wrist_camera') env['droid_shoulder_light_randomization'] = env.pop('exo_camera_1') obs = molmo_obs_to_positronic(env, 't') - wrist_mean = obs[POS_WRIST_IMAGE].reshape(-1, 3).mean(axis=0) - exterior_mean = obs[POS_EXTERIOR_IMAGE].reshape(-1, 3).mean(axis=0) + wrist_mean = obs[keys.WRIST_IMAGE].reshape(-1, 3).mean(axis=0) + exterior_mean = obs[keys.EXTERIOR_IMAGE].reshape(-1, 3).mean(axis=0) assert wrist_mean[0] > wrist_mean[1] # the reddish wrist view still lands on the wrist key assert exterior_mean[1] > exterior_mean[0] @@ -105,7 +102,7 @@ def test_obs_mapping_resolves_benchmark_variant_camera_keys(): both = _load_env_obs() both['droid_shoulder_light_randomization'] = both['wrist_camera'] # reddish, unlike exo_camera_1 obs = molmo_obs_to_positronic(both, 't') - exterior_mean = obs[POS_EXTERIOR_IMAGE].reshape(-1, 3).mean(axis=0) + exterior_mean = obs[keys.EXTERIOR_IMAGE].reshape(-1, 3).mean(axis=0) assert exterior_mean[0] > exterior_mean[1] # An explicitly configured non-default key is read as-is, never shadowed by a variant. @@ -113,7 +110,7 @@ def test_obs_mapping_resolves_benchmark_variant_camera_keys(): custom['my_cam'] = custom['wrist_camera'] custom['wrist_camera_zed_mini'] = custom['exo_camera_1'] # greenish decoy obs = molmo_obs_to_positronic(custom, 't', wrist_key='my_cam') - wrist_mean = obs[POS_WRIST_IMAGE].reshape(-1, 3).mean(axis=0) + wrist_mean = obs[keys.WRIST_IMAGE].reshape(-1, 3).mean(axis=0) assert wrist_mean[0] > wrist_mean[1] @@ -171,7 +168,7 @@ def test_gripper_proprio_normalization(): def grip_for(qpos_val: float) -> float: env = _load_env_obs() env['qpos']['gripper'] = np.array([qpos_val, qpos_val], dtype=np.float32) - return float(molmo_obs_to_positronic(env, 't')[POS_GRIP][0]) + return float(molmo_obs_to_positronic(env, 't')[keys.GRIP][0]) assert grip_for(0.0) == 0.0 assert abs(grip_for(closed / 2) - 0.5) < 1e-4 @@ -212,6 +209,10 @@ def test_action_reads_velocities_from_object_or_wire_dict(): from_obj = positronic_action_to_molmo({'robot_command': _FakeJointDelta(vel), 'target_grip': 0.0}, current) from_dict = positronic_action_to_molmo({'robot_command': {'velocities': vel}, 'target_grip': 0.0}, current) assert np.allclose(from_obj['arm'], from_dict['arm']) + # The base wire delivers the command as its __cmd__ envelope around the to_wire dict. + wire_cmd = {b'__cmd__': {'type': 'joint_delta', 'velocities': vel}} + from_wire = positronic_action_to_molmo({'robot_command': wire_cmd, 'target_grip': 0.0}, current) + assert np.allclose(from_obj['arm'], from_wire['arm']) # Bytes-keyed wire form (msgpack deserialisation on some client versions keys with bytes). from_bytes = positronic_action_to_molmo({b'robot_command': {b'velocities': vel}, b'target_grip': 1.0}, current) assert np.allclose(from_obj['arm'], from_bytes['arm']) @@ -229,7 +230,7 @@ def test_action_joint_count_mismatch_raises(): def test_fake_policy_chunk_shape_and_range(): policy = FakePolicy(chunk_size=8, seed=3) - chunk = policy.new_session()({POS_TASK: 't'}) + chunk = policy.new_session().infer({keys.TASK: 't'}) assert len(chunk) == 8 for step in chunk: vel = step['robot_command'].velocities @@ -239,15 +240,15 @@ def test_fake_policy_chunk_shape_and_range(): def test_fake_policy_is_deterministic_per_seed(): - a = FakePolicy(seed=7).new_session()({POS_TASK: 't'}) - b = FakePolicy(seed=7).new_session()({POS_TASK: 't'}) + a = FakePolicy(seed=7).new_session().infer({keys.TASK: 't'}) + b = FakePolicy(seed=7).new_session().infer({keys.TASK: 't'}) for sa, sb in zip(a, b, strict=True): assert np.array_equal(sa['robot_command'].velocities, sb['robot_command'].velocities) assert sa['target_grip'] == sb['target_grip'] def test_fake_policy_zero_mode_holds(): - chunk = FakePolicy(mode='zero').new_session()({POS_TASK: 't'}) + chunk = FakePolicy(mode='zero').new_session().infer({keys.TASK: 't'}) for step in chunk: assert np.array_equal(step['robot_command'].velocities, np.zeros(NUM_ARM_JOINTS)) assert step['target_grip'] == 0.0 @@ -261,7 +262,7 @@ def __init__(self, chunk_size: int): self.calls = 0 self._chunk_size = chunk_size - def __call__(self, obs): + def infer(self, obs): self.calls += 1 return [ { @@ -284,7 +285,11 @@ def test_chunk_buffer_replays_one_per_tick_then_requeries(): def test_chunk_buffer_empty_chunk_raises(): - buf = ChunkBuffer(lambda obs: []) + class _Empty: + def infer(self, obs): + return [] + + buf = ChunkBuffer(_Empty()) with pytest.raises(RuntimeError): buf.next({}) @@ -297,11 +302,12 @@ def test_chunk_buffer_drops_trailing_horizon_marker(): chunk.append({'timestamp': 8 / 15}) calls = {'n': 0} - def session(obs): - calls['n'] += 1 - return list(chunk) + class _Session: + def infer(self, obs): + calls['n'] += 1 + return list(chunk) - buf = ChunkBuffer(session) + buf = ChunkBuffer(_Session()) for _ in range(8): assert 'robot_command' in buf.next({}) assert calls['n'] == 1 diff --git a/positronic/utils/serialization.py b/positronic/utils/serialization.py index 5446bdf6c..66820217f 100644 --- a/positronic/utils/serialization.py +++ b/positronic/utils/serialization.py @@ -1,74 +1,29 @@ -"""Wire serialization helpers for numpy arrays, robot commands, and standard Python types. +"""positronic's wire dialect: the base client wire plus roboarm command / robot status envelopes. -Supports: -- built-in scalars: `str`, `int`, `float`, `bool`, `None` -- containers: `dict` / `list` / `tuple` recursively composed of supported values -- numeric numpy values: `numpy.ndarray` and `numpy` scalar types -- robot commands: ``positronic.drivers.roboarm.command.CommandType`` instances — - transparently round-tripped via ``to_wire`` / ``from_wire``. +The base wire (numpy arrays, scalars, containers, JPEG markers) lives in ``positronic_client.serialization``; +this module layers the domain envelopes on top via its extension hooks, so servers and positronic-side clients +round-trip ``CommandType`` / ``RobotStatus`` objects while a bare ``positronic_client`` consumer receives the +same payloads as plain wire dicts. """ -# TODO: This module currently knows about ``roboarm.command`` directly. If we -# accumulate more domain types that need wire treatment (gripper commands, -# observation packets, etc.), replace the inline dispatch with a generic -# registry / ``__to_wire__`` protocol so utils stays domain-agnostic. - -import collections.abc as cabc -import functools -import io from typing import Any -import msgpack -import numpy as np -from PIL import Image as PilImage +from positronic_client.serialization import make_wire from positronic.drivers import roboarm as _roboarm from positronic.drivers.roboarm import command as _roboarm_command -# JPEG quality for images on the wire. A single HD frame — and especially a (T, H, W, 3) stack — is many -# MB raw, over the ~2 MB websocket message cap of a Modal-fronted endpoint. Per-frame JPEG keeps a -# 25-frame two-camera stack around 1-2 MB and cuts upload latency; q=90 is visually lossless here. -_JPEG_QUALITY = 90 - - -def encode_jpeg(image: np.ndarray) -> dict[bytes, Any]: - """JPEG-encode a single ``(H, W, 3)`` image or a ``(T, H, W, 3)`` stack to a compact wire marker. - - Sends one JPEG per frame plus the original ``ndim`` so ``_unpack`` restores the exact shape. - """ - frames = image if image.ndim == 4 else image[None] - bufs = [] - for frame in frames: - buf = io.BytesIO() - PilImage.fromarray(np.ascontiguousarray(frame, dtype=np.uint8)).save(buf, format='JPEG', quality=_JPEG_QUALITY) - bufs.append(buf.getvalue()) - return {b'__jpeg__': True, b'frames': bufs, b'ndim': int(image.ndim)} - - -def _decode_jpeg(marker: dict) -> np.ndarray: - """Inverse of ``encode_jpeg``: decode per-frame JPEGs and restore the original shape.""" - frames = np.stack([np.asarray(PilImage.open(io.BytesIO(buf))) for buf in marker[b'frames']]) - return frames if marker[b'ndim'] == 4 else frames[0] - -def _pack(obj): - if isinstance(obj, cabc.Mapping): - return dict(obj) - if isinstance(obj, np.ndarray | np.generic) and obj.dtype.kind in ('V', 'O', 'c'): - raise ValueError(f'Unsupported dtype: {obj.dtype}') - if isinstance(obj, np.ndarray): - return {b'__ndarray__': True, b'data': obj.tobytes(), b'dtype': obj.dtype.str, b'shape': obj.shape} - if isinstance(obj, np.generic): - return {b'__npgeneric__': True, b'data': obj.item(), b'dtype': obj.dtype.str} +def _pack_domain(obj: Any) -> Any | None: if isinstance(obj, _roboarm_command.CommandType): return {b'__cmd__': _roboarm_command.to_wire(obj)} if isinstance(obj, _roboarm.RobotStatus): # NOTE: str key, unlike the bytes keys above. A pre-PR server that doesn't decode # this type leaves the envelope as a plain dict in the observation; its recorder does # `key.endswith(...)` on dict keys, which TypeErrors on a bytes key but is harmless on - # a str one. New servers reconstruct the enum in `_unpack` below. + # a str one. New servers reconstruct the enum in `_unpack_domain` below. return {'__robotstatus__': obj.value} - return obj + return None # TODO(remove-pre-PR-server-compat): drop once all deployed inference servers @@ -86,13 +41,7 @@ def _pack(obj): }) -def _unpack(obj): - if b'__ndarray__' in obj: - return np.ndarray(buffer=obj[b'data'], dtype=np.dtype(obj[b'dtype']), shape=obj[b'shape']) - if b'__npgeneric__' in obj: - return np.dtype(obj[b'dtype']).type(obj[b'data']) - if b'__jpeg__' in obj: - return _decode_jpeg(obj) +def _unpack_domain(obj: dict) -> Any | None: if b'__cmd__' in obj: inner = obj[b'__cmd__'] # The legacy shim below decodes the inner ``to_wire`` dict to a Command @@ -100,7 +49,7 @@ def _unpack(obj): if isinstance(inner, _roboarm_command.CommandType): return inner return _roboarm_command.from_wire(inner) - # Accept both the str key (current wire form, see _pack) and the bytes key, so the wire + # Accept both the str key (current wire form, see _pack_domain) and the bytes key, so the wire # can later migrate to the bytes form — consistent with the envelopes above — without # breaking any server already deployed against this version. Both round-trip to the enum. if '__robotstatus__' in obj: @@ -110,11 +59,10 @@ def _unpack(obj): # TODO(remove-pre-PR-server-compat): see _LEGACY_COMMAND_TYPES above. if obj.get('type') in _LEGACY_COMMAND_TYPES: return _roboarm_command.from_wire(obj) - return obj + return None -serialise = functools.partial(msgpack.packb, default=_pack) -deserialise = functools.partial(msgpack.unpackb, object_hook=_unpack) +serialise, deserialise = make_wire(pack_hooks=(_pack_domain,), unpack_hooks=(_unpack_domain,)) # Aliases for consistency serialize = serialise diff --git a/positronic/vendors/openpi/codecs.py b/positronic/vendors/openpi/codecs.py index 9e2cc31b6..c2849702f 100644 --- a/positronic/vendors/openpi/codecs.py +++ b/positronic/vendors/openpi/codecs.py @@ -21,6 +21,7 @@ import configuronic as cfn import numpy as np from PIL import Image as PilImage +from positronic_client import keys from positronic import geom from positronic.cfg import codecs @@ -39,8 +40,8 @@ class ObservationCodec(Codec): def __init__( self, state_features: dict[str, int], - exterior_camera: str = 'image.exterior', - wrist_camera: str = 'image.wrist', + exterior_camera: str = keys.EXTERIOR_IMAGE, + wrist_camera: str = keys.WRIST_IMAGE, image_size: tuple[int, int] = (224, 224), ): self._state_features = state_features @@ -52,7 +53,7 @@ def __init__( 'observation.state': self._derive_state, 'observation.images.left': partial(self._derive_image, wrist_camera), 'observation.images.side': partial(self._derive_image, exterior_camera), - 'task': Get('task', ''), + 'task': Get(keys.TASK, ''), } state_dim = sum(state_features.values()) @@ -87,8 +88,8 @@ def encode(self, inputs: dict[str, Any]) -> dict[str, Any]: 'observation/wrist_image': self._encode_image(self._wrist_camera, inputs), 'observation/image': self._encode_image(self._exterior_camera, inputs), } - if 'task' in inputs: - obs['prompt'] = inputs['task'] + if keys.TASK in inputs: + obs['prompt'] = inputs[keys.TASK] return obs def _encode_image(self, input_key: str, inputs: dict[str, Any]) -> np.ndarray: @@ -121,9 +122,9 @@ def training_encoder(self): @cfn.config( - state_features={'robot_state.ee_pose': 7, 'grip': 1}, - exterior_camera='image.exterior', - wrist_camera='image.wrist', + state_features={keys.EE_POSE: 7, keys.GRIP: 1}, + exterior_camera=keys.EXTERIOR_IMAGE, + wrist_camera=keys.WRIST_IMAGE, image_size=(224, 224), ) def observation(state_features: dict[str, int], exterior_camera: str, wrist_camera: str, image_size: tuple[int, int]): @@ -134,7 +135,7 @@ def observation(state_features: dict[str, int], exterior_camera: str, wrist_came ee_obs = observation -ee_joints_obs = observation.override(state_features={'robot_state.ee_pose': 7, 'grip': 1, 'robot_state.q': 7}) +ee_joints_obs = observation.override(state_features={keys.EE_POSE: 7, keys.GRIP: 1, keys.JOINTS: 7}) # Pretrained DROID models read joints and gripper as separate observation keys and the language @@ -142,10 +143,10 @@ def observation(state_features: dict[str, int], exterior_camera: str, wrist_came # trained on lowercased language and MolmoSpaces' Pi baseline normalizes the same way. droid_obs = cfn.Config( GenericObservationCodec, - state={'observation/joint_position': {'robot_state.q': 7}, 'observation/gripper_position': {'grip': 1}}, + state={'observation/joint_position': {keys.JOINTS: 7}, 'observation/gripper_position': {keys.GRIP: 1}}, images={ - 'observation/wrist_image_left': ('image.wrist', (224, 224)), - 'observation/exterior_image_1_left': ('image.exterior', (224, 224)), + 'observation/wrist_image_left': (keys.WRIST_IMAGE, (224, 224)), + 'observation/exterior_image_1_left': (keys.EXTERIOR_IMAGE, (224, 224)), }, task_field='prompt', lowercase_task=True, @@ -154,15 +155,15 @@ def observation(state_features: dict[str, int], exterior_camera: str, wrist_came ee = codecs.compose.override(obs=ee_obs, action=codecs.absolute_pos_action) ee_joints = ee.override(obs=ee_joints_obs) -ee_traj = ee.override(action=codecs.traj_ee_action, binarize_grip=('grip',)) -ee_joints_traj = ee_joints.override(action=codecs.traj_ee_action, binarize_grip=('grip',)) +ee_traj = ee.override(action=codecs.traj_ee_action, binarize_grip=(keys.GRIP,)) +ee_joints_traj = ee_joints.override(action=codecs.traj_ee_action, binarize_grip=(keys.GRIP,)) # Pure joint-based trajectory variant (no commanded joint targets in recordings) -joints_obs = observation.override(state_features={'robot_state.q': 7, 'grip': 1}) +joints_obs = observation.override(state_features={keys.JOINTS: 7, keys.GRIP: 1}) joints_traj = codecs.compose.override( obs=joints_obs, - action=codecs.absolute_joints_action.override(tgt_joints_key='robot_state.q', tgt_grip_key='grip'), - binarize_grip=('grip',), + action=codecs.absolute_joints_action.override(tgt_joints_key=keys.JOINTS, tgt_grip_key=keys.GRIP), + binarize_grip=(keys.GRIP,), ) # IK variants: reconstruct joint targets from recorded EE targets via IK @@ -181,8 +182,8 @@ def observation(state_features: dict[str, int], exterior_camera: str, wrist_came # after the full chunk executes, whatever each variant's length. droid_jointpos = codecs.compose.override( obs=droid_obs, - action=codecs.absolute_joints_action.override(tgt_joints_key='robot_state.q', tgt_grip_key='grip'), - binarize_grip=('grip',), + action=codecs.absolute_joints_action.override(tgt_joints_key=keys.JOINTS, tgt_grip_key=keys.GRIP), + binarize_grip=(keys.GRIP,), ) @@ -233,7 +234,7 @@ class LiberoObservationCodec(Codec): def __init__( self, exterior_camera: str = 'image.agentview', - wrist_camera: str = 'image.wrist', + wrist_camera: str = keys.WRIST_IMAGE, image_size: tuple[int, int] = (224, 224), ): self._exterior_camera = exterior_camera @@ -246,12 +247,12 @@ def encode(self, inputs: dict[str, Any]) -> dict[str, Any]: 'observation/wrist_image': self._encode_image(self._wrist_camera, inputs), 'observation/image': self._encode_image(self._exterior_camera, inputs), } - if 'task' in inputs: - obs['prompt'] = inputs['task'] + if keys.TASK in inputs: + obs['prompt'] = inputs[keys.TASK] return obs def _libero_state(self, inputs: dict[str, Any]) -> np.ndarray: - ee_pose = np.asarray(inputs['robot_state.ee_pose'], dtype=float) + ee_pose = np.asarray(inputs[keys.EE_POSE], dtype=float) hand_rot = geom.Rotation.from_quat(ee_pose[3:7]) * _GRIP_SITE_TO_HAND # Reproduce robosuite's axis-angle branch. Its `robot0_eef_quat` is MuJoCo's `body_xquat`, FK-continuous # from the tool-down home pose and thus consistently in the w<=0 hemisphere (angle >= pi) across the @@ -260,7 +261,7 @@ def _libero_state(self, inputs: dict[str, Any]) -> np.ndarray: quat = np.asarray(hand_rot.to(geom.Rotation.Representation.QUAT)) canonical = geom.Rotation.from_quat(quat if quat[0] <= 0 else -quat) axisangle = np.asarray(canonical.to(geom.Rotation.Representation.ROTVEC)).reshape(3) - closure = 1.0 - float(inputs['grip']) + closure = 1.0 - float(inputs[keys.GRIP]) gripper_qpos = _GRIPPER_QPOS_CLOSED + closure * (_GRIPPER_QPOS_OPEN - _GRIPPER_QPOS_CLOSED) return np.concatenate([ee_pose[:3], axisangle, gripper_qpos]).astype(np.float32) diff --git a/pyproject.toml b/pyproject.toml index d9010f2ae..e7c8a2829 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ # openpi-client: optional, see [project.optional-dependencies] openpi "plotly>=6.5.0", "pos3>=0.3.1", + "positronic-client", "pyarrow", "pydantic", "pyturbojpeg<2", @@ -110,6 +111,7 @@ dev = [ ] [tool.uv] +workspace = { members = ["packages/positronic-client"] } # lerobot_0_3_3 extra pulls in lerobot==0.3.3 which declares rerun-sdk<0.23, # but our usage of lerobot doesn't need rerun — override to unblock resolution. override-dependencies = ["rerun-sdk==0.30.0"] @@ -139,6 +141,7 @@ positronic = false configuronic = false pos3 = false positronic-franka = false +positronic-client = false [tool.uv.sources] # ZED SDK ships one ABI-specific wheel per CPython version; pick by interpreter. @@ -147,10 +150,12 @@ pyzed = [ { url = "https://download.stereolabs.com/zedsdk/5.0/whl/linux_x86_64/pyzed-5.0-cp312-cp312-linux_x86_64.whl", marker = "python_full_version >= '3.12' and python_full_version < '3.13'" }, { url = "https://download.stereolabs.com/zedsdk/5.0/whl/linux_x86_64/pyzed-5.0-cp313-cp313-linux_x86_64.whl", marker = "python_full_version >= '3.13'" }, ] +positronic-client = { workspace = true } openpi-client = { git = "https://github.com/Positronic-Robotics/openpi.git", rev = "main-positronic", subdirectory = "packages/openpi-client" } [tool.pytest.ini_options] testpaths = [ + "packages/positronic-client/positronic_client/tests", "pimm/tests", "positronic/cfg/tests", "positronic/dataset/tests", diff --git a/utilities/validate_server.py b/utilities/validate_server.py index ac00102db..6104b120c 100644 --- a/utilities/validate_server.py +++ b/utilities/validate_server.py @@ -4,8 +4,7 @@ from pathlib import Path import configuronic as cfn - -from positronic.offboard.client import InferenceClient +from positronic_client.client import InferenceClient def _shell_join(command: list[str]) -> str: diff --git a/uv.lock b/uv.lock index 60a476bec..630447af7 100644 --- a/uv.lock +++ b/uv.lock @@ -49,12 +49,17 @@ conflicts = [[ exclude-newer = "2026-06-12T00:00:00Z" [options.exclude-newer-package] +positronic-client = false configuronic = false pos3 = false positronic = false positronic-franka = false [manifest] +members = [ + "positronic", + "positronic-client", +] constraints = [ { name = "cmake", specifier = "<4.3" }, { name = "evdev", specifier = "<1.9" }, @@ -4219,6 +4224,7 @@ dependencies = [ { name = "opencv-python-headless", version = "4.13.0.92", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-10-positronic-lerobot-0-3-3' or extra == 'extra-10-positronic-molmoact2' or extra != 'extra-10-positronic-lerobot'" }, { name = "plotly" }, { name = "pos3" }, + { name = "positronic-client" }, { name = "pyarrow" }, { name = "pydantic" }, { name = "pyturbojpeg" }, @@ -4323,6 +4329,7 @@ requires-dist = [ { name = "placo", marker = "extra == 'hardware'" }, { name = "plotly", specifier = ">=6.5.0" }, { name = "pos3", specifier = ">=0.3.1" }, + { name = "positronic-client", editable = "packages/positronic-client" }, { name = "positronic-franka", marker = "sys_platform == 'linux' and extra == 'hardware'", specifier = ">=0.5.0" }, { name = "pre-commit", marker = "extra == 'dev'" }, { name = "protobuf", marker = "extra == 'molmoact2'" }, @@ -4362,6 +4369,27 @@ requires-dist = [ ] provides-extras = ["openpi", "hardware", "lerobot-0-3-3", "lerobot", "molmoact2", "dreamzero", "lance", "dev"] +[[package]] +name = "positronic-client" +version = "0.1.0" +source = { editable = "packages/positronic-client" } +dependencies = [ + { name = "httpx" }, + { name = "msgpack" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx" }, + { name = "msgpack" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "websockets", specifier = ">=15.0.1" }, +] + [[package]] name = "positronic-franka" version = "0.5.0"