live: real-time browser viewer (AICC-client based), sensor-built top-down map shared module, run_live.sh one-command mode
This commit is contained in:
+255
@@ -0,0 +1,255 @@
|
||||
"""Real-time viewer: watch the capsule drive, live in your browser.
|
||||
|
||||
The viewer is itself a plain AICC client: it polls the bridge through the
|
||||
protocol sensors (world_query once, proprioception + vision a few times per
|
||||
second), renders a top-down map from what it observes, and pushes the frames
|
||||
to any number of browser tabs over its own WebSocket.
|
||||
|
||||
Run it, then open http://127.0.0.1:8000 and start the demo in another
|
||||
terminal (scripts/run_demo.sh) — you will see the capsule move in real time.
|
||||
|
||||
Usage:
|
||||
python -m testbed.live [--bridge ws://127.0.0.1:8765] [--http-port 8000]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from aicc.client import AICCClient
|
||||
from aicc.transport.websocket import WebSocketClientTransport
|
||||
|
||||
from testbed.room.mapview import draw_sensor_map
|
||||
|
||||
HTTP_PORT = 8000
|
||||
WS_PORT = 8001
|
||||
POLL_SECONDS = 0.25 # ~4 fps
|
||||
|
||||
HTML = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>AICC capsule — live view</title>
|
||||
<style>
|
||||
body { background:#101016; color:#d8d8e0; font-family: ui-monospace, monospace; margin:0; }
|
||||
header { padding:10px 18px; background:#181820; border-bottom:1px solid #2a2a36;
|
||||
display:flex; gap:24px; align-items:baseline; font-size:13px; }
|
||||
header h1 { font-size:15px; margin:0; color:#8ecbff; }
|
||||
header .meta { color:#9aa0b0; }
|
||||
main { display:flex; gap:14px; padding:14px; flex-wrap:wrap; }
|
||||
.panel { background:#181820; border:1px solid #2a2a36; border-radius:8px; padding:10px; }
|
||||
.panel h2 { margin:0 0 8px; font-size:12px; color:#ffb860; font-weight:600;
|
||||
text-transform:uppercase; letter-spacing:.08em; }
|
||||
img { display:block; image-rendering: pixelated; background:#000; }
|
||||
#view { width: 400px; }
|
||||
#map { width: 400px; }
|
||||
#status { color:#9fe8a0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>AICC capsule — real-time view</h1>
|
||||
<span class="meta" id="status">connecting…</span>
|
||||
</header>
|
||||
<main>
|
||||
<div class="panel"><h2>first person (vision)</h2><img id="view" alt="vision"></div>
|
||||
<div class="panel"><h2>top-down (sensors)</h2><img id="map" alt="map"></div>
|
||||
</main>
|
||||
<script>
|
||||
const view = document.getElementById('view');
|
||||
const map = document.getElementById('map');
|
||||
const status = document.getElementById('status');
|
||||
let lastTick = 0, tickCount = 0, t0 = performance.now();
|
||||
function show(msg, kind) {
|
||||
status.textContent = msg;
|
||||
status.style.color = kind === 'err' ? '#ff9090' : '#9fe8a0';
|
||||
}
|
||||
function connect() {
|
||||
const ws = new WebSocket(`ws://${location.hostname}:${location.port === '8000' ? '8001' : location.port}`);
|
||||
ws.onopen = () => show('connected — waiting for frames');
|
||||
ws.onclose = () => { show('disconnected — retrying…', 'err'); setTimeout(connect, 1000); };
|
||||
ws.onmessage = (ev) => {
|
||||
const m = JSON.parse(ev.data);
|
||||
if (m.type === 'view') view.src = 'data:image/png;base64,' + m.png_b64;
|
||||
if (m.type === 'map') {
|
||||
map.src = 'data:image/png;base64,' + m.png_b64;
|
||||
tickCount++;
|
||||
const fps = tickCount / ((performance.now() - t0) / 1000);
|
||||
show(`tick ${m.tick} pos (${m.pos[0].toFixed(2)}, ${m.pos[1].toFixed(2)}) ` +
|
||||
`yaw ${m.yaw.toFixed(1)}° ${m.dist.toFixed(2)} m to beacon · ${fps.toFixed(1)} fps`);
|
||||
}
|
||||
};
|
||||
}
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def _png_b64(img) -> str:
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
class LiveViewer:
|
||||
"""Polls the bridge via AICC sensors and broadcasts frames to browsers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge_url: str,
|
||||
http_port: int,
|
||||
ws_port: int,
|
||||
poll_seconds: float = POLL_SECONDS,
|
||||
):
|
||||
self.bridge_url = bridge_url
|
||||
self.http_port = http_port
|
||||
self.ws_port = ws_port
|
||||
self.poll = poll_seconds
|
||||
self.clients: set[Any] = set()
|
||||
self.layout: dict[str, Any] | None = None
|
||||
self.path: list[tuple[float, float]] = []
|
||||
|
||||
# -- sensor polling -----------------------------------------------------
|
||||
|
||||
async def _sense(self, client: AICCClient) -> None:
|
||||
while True:
|
||||
try:
|
||||
if self.layout is None:
|
||||
self.layout = (await client.call_tool("world_query", {})).output
|
||||
prop = (await client.call_tool("proprioception", {})).output
|
||||
vision = (await client.call_tool("vision", {})).output
|
||||
pos = prop["position"]
|
||||
yaw = prop["rotation"]["yaw_deg"] % 360.0
|
||||
self.path.append((pos["x"], pos["z"]))
|
||||
if len(self.path) > 4000:
|
||||
self.path = self.path[-2000:]
|
||||
beacon = self.layout.get("beacon", {}) if self.layout else {}
|
||||
bx = beacon.get("x", 12.5)
|
||||
bz = beacon.get("z", 12.5)
|
||||
dist = math.hypot(bx - pos["x"], bz - pos["z"])
|
||||
await self._broadcast(
|
||||
{
|
||||
"type": "view",
|
||||
"png_b64": vision["png_b64"],
|
||||
"tick": vision["tick"],
|
||||
}
|
||||
)
|
||||
map_img = draw_sensor_map(
|
||||
self.layout, (pos["x"], pos["z"]), yaw, self.path, step=prop["tick"]
|
||||
)
|
||||
await self._broadcast(
|
||||
{
|
||||
"type": "map",
|
||||
"png_b64": _png_b64(map_img),
|
||||
"tick": prop["tick"],
|
||||
"pos": [pos["x"], pos["z"]],
|
||||
"yaw": yaw,
|
||||
"dist": dist,
|
||||
}
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - keep polling through transient errors
|
||||
print(f"[live] sense error: {type(exc).__name__}: {exc}")
|
||||
await asyncio.sleep(self.poll)
|
||||
|
||||
async def _broadcast(self, msg: dict[str, Any]) -> None:
|
||||
if not self.clients:
|
||||
return
|
||||
data = json.dumps(msg)
|
||||
for ws in list(self.clients):
|
||||
try:
|
||||
await ws.send(data)
|
||||
except Exception: # noqa: BLE001
|
||||
self.clients.discard(ws)
|
||||
|
||||
# -- servers ------------------------------------------------------------
|
||||
|
||||
async def run(self) -> None:
|
||||
import websockets
|
||||
|
||||
async def handler(ws) -> None:
|
||||
self.clients.add(ws)
|
||||
try:
|
||||
await ws.wait_closed()
|
||||
finally:
|
||||
self.clients.discard(ws)
|
||||
|
||||
async def http_page(
|
||||
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
||||
) -> None:
|
||||
try:
|
||||
await reader.read(4096)
|
||||
body = HTML.encode("utf-8")
|
||||
writer.write(
|
||||
b"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n"
|
||||
+ f"Content-Length: {len(body)}\r\n\r\n".encode()
|
||||
+ body
|
||||
)
|
||||
await writer.drain()
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
async def bridge_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
transport = WebSocketClientTransport(self.bridge_url)
|
||||
async with AICCClient(transport) as client:
|
||||
await client.handshake()
|
||||
print(f"[live] connected to bridge {self.bridge_url}")
|
||||
await self._sense(client)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - wait for the bridge
|
||||
print(f"[live] bridge not reachable: {type(exc).__name__}: {exc}")
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
ws_server = await websockets.serve(handler, "127.0.0.1", self.ws_port)
|
||||
http_server = await asyncio.start_server(http_page, "127.0.0.1", self.http_port)
|
||||
print(
|
||||
f"[live] open http://127.0.0.1:{self.http_port} (frames on ws://127.0.0.1:{self.ws_port})"
|
||||
)
|
||||
print(
|
||||
"[live] start the demo in another terminal: scripts/run_demo.sh --agent scripted"
|
||||
)
|
||||
poll_task = asyncio.create_task(bridge_loop())
|
||||
try:
|
||||
await asyncio.sleep(3600)
|
||||
finally:
|
||||
poll_task.cancel()
|
||||
ws_server.close()
|
||||
http_server.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Real-time browser viewer for the AICC capsule testbed."
|
||||
)
|
||||
parser.add_argument("--bridge", default="ws://127.0.0.1:8765")
|
||||
parser.add_argument("--http-port", type=int, default=HTTP_PORT)
|
||||
parser.add_argument("--ws-port", type=int, default=WS_PORT)
|
||||
parser.add_argument(
|
||||
"--poll",
|
||||
type=float,
|
||||
default=POLL_SECONDS,
|
||||
help="sensor poll interval in seconds",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
viewer = LiveViewer(args.bridge, args.http_port, args.ws_port, args.poll)
|
||||
try:
|
||||
asyncio.run(viewer.run())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user