commit 54a01625cef8d571cbb2123a5aa34ea0f1c1ce19 Author: K. Isom Date: Wed Jun 24 14:09:11 2026 -0700 Initial commit: CloudView generic networked point-cloud viewer Standalone Open3D viewer that listens on a TCP/Unix socket and renders NDJSON point-cloud streams from any producer. Decoupled server/protocol/store layers (no Open3D dependency, testable headless) plus a lazy Open3D render loop. Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70d3e5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.pyc +.cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f68953 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# CloudView + +A small, **generic** point-cloud viewer. It listens on a socket, accepts +newline-delimited JSON (NDJSON) from any number of producers, and renders the +combined cloud in an interactive [Open3D](https://www.open3d.org/) window. It is +not tied to any one robot: anything that can write JSON lines to a socket — a +Crazyflie, a rover, a LIDAR rig, a log replay — gets live 3D visualization for +free. + +RangeView is the first producer (its 2D map stays the at-a-glance live +diagnostic; the full 3D cloud is streamed here), but the wire format is the only +contract, so other systems can feed the same viewer. + +``` + producers (any language) viewer + ┌───────────────────────┐ NDJSON over ┌──────────────────────┐ + │ rangeview ───────────┼── tcp:// ────────▶ CloudView (Open3D) │ + │ rover-2 ───────────┼── unix:// ───────▶ - merges all sources │ + │ lidar-rig ───────────┼──────────────────▶ - colour by height │ + └───────────────────────┘ └──────────────────────┘ +``` + +## Install & run + +```sh +# with uv (resolves from the committed uv.lock) +uv run main.py # listen on tcp://127.0.0.1:9870 +uv run main.py -l tcp://0.0.0.0:9870 # accept from other machines + +# or a plain venv +python -m venv .venv && . .venv/bin/activate +pip install -r requirements.txt # open3d + numpy +python -m cloudview +``` + +Listen addresses (repeatable, TCP and/or Unix): + +```sh +uv run main.py -l tcp://0.0.0.0:9870 -l unix:///tmp/cloud.sock +``` + +Viewer keys: **C** toggle colour (by height / per-source), **R** refit the +camera, **B** cycle background, mouse to orbit/zoom/pan. + +## Wire protocol (NDJSON) + +One JSON object per line over a stream socket. `source` keys a distinct cloud, +so one viewer shows several robots at once. Coordinates are floats in whatever +frame the producer uses. + +| message | fields | effect | +|---------|--------|--------| +| `hello` | `source`, `name` | announce/label a stream | +| `points` | `source`, `points` (`[[x,y,z],...]`), `frame`, `color` (optional `[r,g,b]` 0..1) | append a batch | +| `clear` | `source` | drop that source's cloud | +| `pose` | `source`, `position` `[x,y,z]`, `yaw` (deg) | record robot pose | + +Points are batched per message to keep overhead low. If a source supplies no +`color`, the viewer colours its points by height; otherwise the given colour is +used as a solid. + +### Producing from any language + +It is just JSON lines — test it with `nc`: + +```sh +printf '%s\n' \ + '{"type":"hello","source":"demo","name":"nc test"}' \ + '{"type":"points","source":"demo","points":[[0,0,0],[1,0,0.5],[0,1,1]]}' \ + | nc 127.0.0.1 9870 +``` + +Python producers can reuse RangeView's `cloud_publisher.CloudPublisher` (a +non-blocking client with auto-reconnect and full-cloud replay), or just write +`json.dumps(msg) + "\n"` to a socket. + +## Layout + +- `cloudview/protocol.py` - NDJSON encode/decode + address parsing. +- `cloudview/store.py` - thread-safe per-source cloud store. +- `cloudview/server.py` - TCP/Unix socket server; one reader thread per producer. +- `cloudview/viewer.py` - Open3D render loop (height/per-source colouring). +- `cloudview/app.py` - CLI entry point. + +Networking and rendering are decoupled: the server fills the store from any +producer; the viewer polls the store and rebuilds geometry only when it changes. +The server/protocol layers have no Open3D dependency, so they are testable +headless. diff --git a/cloudview/__init__.py b/cloudview/__init__.py new file mode 100644 index 0000000..d2f7baf --- /dev/null +++ b/cloudview/__init__.py @@ -0,0 +1,3 @@ +"""cloudview: a generic networked point-cloud viewer (NDJSON over a socket).""" + +__version__ = "0.1.0" diff --git a/cloudview/__main__.py b/cloudview/__main__.py new file mode 100644 index 0000000..0399bb8 --- /dev/null +++ b/cloudview/__main__.py @@ -0,0 +1,6 @@ +"""Allow `python -m cloudview`.""" + +from .app import main + +if __name__ == "__main__": + main() diff --git a/cloudview/app.py b/cloudview/app.py new file mode 100644 index 0000000..4098304 --- /dev/null +++ b/cloudview/app.py @@ -0,0 +1,47 @@ +"""Entry point: start the socket server, then run the Open3D viewer. + + python -m cloudview # listen on tcp://127.0.0.1:9870 + python -m cloudview -l tcp://0.0.0.0:9870 # accept from other machines + python -m cloudview -l unix:///tmp/cloud.sock # local Unix socket + python -m cloudview -l tcp://0.0.0.0:9870 -l unix:///tmp/cloud.sock # both +""" + +from __future__ import annotations + +import argparse +import logging + +from .protocol import DEFAULT_TCP +from .server import CloudServer +from .store import CloudStore +from .viewer import run_viewer + + +def main() -> None: + ap = argparse.ArgumentParser( + description="Generic networked point-cloud viewer (NDJSON over a " + "stream socket).") + ap.add_argument("-l", "--listen", action="append", metavar="ADDR", + help="address to listen on (tcp://host:port or " + "unix:///path); repeatable. Default tcp://127.0.0.1:9870") + ap.add_argument("--max-points", type=int, default=500_000, + help="cap per source before oldest points are dropped") + ap.add_argument("-v", "--verbose", action="store_true") + args = ap.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(asctime)s %(levelname).1s %(name)s: %(message)s", + datefmt="%H:%M:%S") + + store = CloudStore(max_points_per_source=args.max_points) + server = CloudServer(store, args.listen or [DEFAULT_TCP]) + server.start() + try: + run_viewer(store) + finally: + server.stop() + + +if __name__ == "__main__": + main() diff --git a/cloudview/protocol.py b/cloudview/protocol.py new file mode 100644 index 0000000..09e04d3 --- /dev/null +++ b/cloudview/protocol.py @@ -0,0 +1,76 @@ +"""NDJSON wire protocol for the generic point-cloud stream. + +One JSON object per line over a stream socket (TCP or Unix domain). Newline +delimited so it is language-agnostic, debuggable with ``nc``, and trivial to +emit from any system. Message types (``source`` keys a distinct cloud, so one +viewer can show several robots at once): + + {"type":"hello","source":"","name":"