record: observer-based capture (--capture-hz) + --goal beacon; smooth_mission.gif artifact (136 frames, gliding motion verified); README section

This commit is contained in:
opencode
2026-08-09 17:38:19 +03:00
parent d49cf46181
commit b6b5547ffc
3 changed files with 127 additions and 57 deletions
+12
View File
@@ -26,6 +26,18 @@ model saw, and its chat/reasoning:
Regenerate it with `python -m testbed.record_search --provider polza --out search_mission.gif`. Regenerate it with `python -m testbed.record_search --provider polza --out search_mission.gif`.
## Smooth & proactive movement
The same recording setup with a beacon mission and the cruise mode — the
capsule glides with animated `move`/`turn` calls and keeps moving while the
model thinks (`--cruise`). Frames are sampled at ~2.5 Hz by an observer
client, so the GIF shows the actual motion, not step endpoints:
![smooth mission](smooth_mission.gif)
Regenerate with `python -m testbed.record_search --provider polza --goal beacon
--out smooth_mission.gif --capture-hz 2.5 --cruise 1.0`.
## Layout ## Layout
``` ```
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

+115 -57
View File
@@ -139,24 +139,22 @@ async def run(args: argparse.Namespace) -> int:
api_key=args.api_key, api_key=args.api_key,
model=args.model, model=args.model,
) )
from testbed.llm_agent import MISSION
bridge = build_bridge() bridge = build_bridge()
# Fixed marker so the GIF tells a clear story (crate_blue, east face). if args.goal == "search":
bridge.world.marker = { # Fixed marker so the GIF tells a clear story (crate_blue, east face).
"box": "crate_blue", bridge.world.marker = {
"face": "x1", "box": "crate_blue", "face": "x1", "u": 0.5, "v": 0.55,
"u": 0.5, "size": 0.42, "color": (255, 150, 40),
"v": 0.55, }
"size": 0.42, m = bridge.world.marker_world_pos()
"color": (255, 150, 40), print(f"[record] triangle marker on crate_blue east face at {tuple(round(v, 2) for v in m)}")
}
m = bridge.world.marker_world_pos()
print(
f"[record] triangle marker on crate_blue east face at {tuple(round(v, 2) for v in m)}"
)
frames: list[Image.Image] = [] frames: list[Image.Image] = []
chat = ChatPane() chat = ChatPane()
marker_seen = False marker_seen = False
samples: list[tuple[float, float]] = [] # capsule positions per captured frame
def log(role: str, msg: str) -> None: def log(role: str, msg: str) -> None:
print(f" [{role}] {msg}") print(f" [{role}] {msg}")
@@ -168,62 +166,96 @@ async def run(args: argparse.Namespace) -> int:
async with AICCClient(transport) as client: async with AICCClient(transport) as client:
manifest = await client.handshake() manifest = await client.handshake()
ctl = LLMController( ctl = LLMController(
client, client, manifest,
manifest, base_url=base_url, api_key=api_key, model=model,
base_url=base_url, system_prompt=SEARCH_MISSION if args.goal == "search" else MISSION,
api_key=api_key,
model=model,
system_prompt=SEARCH_MISSION,
log=log, log=log,
) )
ctl.messages = [ if args.goal == "search":
ctl.messages[0], ctl.messages = [
{ ctl.messages[0],
"role": "user", {"role": "user", "content": (
"content": (
"MISSION: explore the room, find the orange triangle on the " "MISSION: explore the room, find the orange triangle on the "
"back of a crate, and report it. Keep exploring until found." "back of a crate, and report it. Keep exploring until found."
)},
]
chat.add("mission", "MISSION: find the orange triangle")
else:
ctl.messages.append({
"role": "user",
"content": (
"MISSION: reach the beacon and activate it. "
"Keep calling tools until it is done. Move smoothly (use "
"duration on move/turn). The capsule cruises while you think."
), ),
}, })
] chat.add("mission", "MISSION: reach and activate the beacon")
chat.add("mission", "MISSION: find the orange triangle")
orig_frame = ctl._frame_message
def record_frame(png: str, note: str): if args.goal == "search" and not args.capture_hz:
nonlocal marker_seen # capture at frame-attach events (one frame per step)
view = ( orig_frame = ctl._frame_message
Image.open(io.BytesIO(base64.b64decode(png)))
.convert("RGB")
.resize((PANEL, PANEL))
)
top = _topdown_frame(bridge, ctl.path, len(frames), marker_seen)
frames.append(_compose(top, view, chat.render()))
return orig_frame(png, note)
ctl._frame_message = record_frame # type: ignore[method-assign] def record_frame(png: str, note: str):
nonlocal marker_seen
view = (
Image.open(io.BytesIO(base64.b64decode(png)))
.convert("RGB")
.resize((PANEL, PANEL))
)
top = _topdown_frame(bridge, ctl.path, len(frames), marker_seen)
frames.append(_compose(top, view, chat.render()))
samples.append((bridge.world.capsule.x, bridge.world.capsule.z))
return orig_frame(png, note)
ctl._frame_message = record_frame # type: ignore[method-assign]
def is_success(turn): def is_success(turn):
nonlocal marker_seen nonlocal marker_seen
for c in turn.calls: for c in turn.calls:
if ( if c.name == "report" and c.ok and c.output and c.output.get("verified"):
c.name == "report"
and c.ok
and c.output
and c.output.get("verified")
):
marker_seen = True marker_seen = True
return True return True
return False return False
summary = await run_llm_agent_loop( async def observer(agent_done: asyncio.Event) -> None:
ctl, """Sample the world at a fixed rate so the GIF shows the actual
args.steps, gliding motion (mid-animation frames), not just step endpoints."""
log=log, obs_transport = WebSocketClientTransport("ws://127.0.0.1:8765")
look_every=1, async with AICCClient(obs_transport) as obs:
cruise=args.cruise, await obs.handshake()
autonomous=True, while not agent_done.is_set():
is_success=is_success, try:
) out = (await obs.call_tool("vision", {})).output
except Exception: # noqa: BLE001 - session teardown
break
view = (
Image.open(io.BytesIO(base64.b64decode(out["png_b64"])))
.convert("RGB")
.resize((PANEL, PANEL))
)
samples.append((bridge.world.capsule.x, bridge.world.capsule.z))
top = _topdown_frame(bridge, ctl.path, len(frames), marker_seen)
frames.append(_compose(top, view, chat.render()))
if len(frames) > 500: # keep the GIF bounded
frames[:100] = []
await asyncio.sleep(1.0 / args.capture_hz)
agent_done = asyncio.Event()
obs_task = None
if args.capture_hz:
obs_task = asyncio.create_task(observer(agent_done))
try:
summary = await run_llm_agent_loop(
ctl, args.steps, log=log,
look_every=2 if args.goal == "beacon" else 1,
cruise=args.cruise,
autonomous=args.goal == "search",
is_success=is_success if args.goal == "search" else None,
)
finally:
agent_done.set()
if obs_task is not None:
await obs_task
# Final frame: end state of the map. # Final frame: end state of the map.
if marker_seen: if marker_seen:
@@ -236,6 +268,19 @@ async def run(args: argparse.Namespace) -> int:
) )
frames.append(_compose(top, last_view, chat.render())) frames.append(_compose(top, last_view, chat.render()))
# Smoothness check: mean movement between consecutive captured frames.
if len(samples) > 2:
from itertools import pairwise
deltas = [
((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2) ** 0.5
for a, b in pairwise(samples)
]
moving = [d for d in deltas if d > 0.02]
if moving:
print(f"[record] motion between frames: mean {sum(moving)/len(moving):.2f} m "
f"(max {max(moving):.2f}) over {len(moving)} moving samples")
out = Path(args.out) out = Path(args.out)
if frames: if frames:
frames[0].save( frames[0].save(
@@ -248,21 +293,34 @@ async def run(args: argparse.Namespace) -> int:
frames[-1].save(out.with_suffix(".png")) frames[-1].save(out.with_suffix(".png"))
print(f"[record] wrote {out} ({len(frames)} frames, {args.duration} ms/frame)") print(f"[record] wrote {out} ({len(frames)} frames, {args.duration} ms/frame)")
print(f"[record] mission result: {summary.get('result')}") print(f"[record] mission result: {summary.get('result')}")
return 0 if marker_seen else 1 return 0 if (marker_seen or summary.get("interacted")) else 1
def main() -> int: def main() -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Record a search mission into a three-view GIF." description="Record a mission into a three-view GIF (map | first person | chat)."
) )
parser.add_argument("--out", default="search_mission.gif") parser.add_argument("--out", default="search_mission.gif")
parser.add_argument(
"--goal",
choices=["search", "beacon"],
default="search",
help="which mission to record (default search)",
)
parser.add_argument("--provider", default=None) parser.add_argument("--provider", default=None)
parser.add_argument("--base-url", default=None) parser.add_argument("--base-url", default=None)
parser.add_argument("--api-key", default=None) parser.add_argument("--api-key", default=None)
parser.add_argument("--model", default=None) parser.add_argument("--model", default=None)
parser.add_argument("--steps", type=int, default=45) parser.add_argument("--steps", type=int, default=45)
parser.add_argument("--cruise", type=float, default=0.6) parser.add_argument("--cruise", type=float, default=0.6)
parser.add_argument("--duration", type=int, default=350, help="ms per GIF frame") parser.add_argument("--duration", type=int, default=150, help="ms per GIF frame")
parser.add_argument(
"--capture-hz",
type=float,
default=0.0,
help="sample frames at this rate (Hz) with an observer client to show "
"gliding motion; 0 = one frame per step (search default)",
)
args = parser.parse_args() args = parser.parse_args()
try: try:
return asyncio.run(run(args)) return asyncio.run(run(args))