record: observer-based capture (--capture-hz) + --goal beacon; smooth_mission.gif artifact (136 frames, gliding motion verified); README section
This commit is contained in:
@@ -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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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 |
+95
-37
@@ -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()
|
||||||
|
if args.goal == "search":
|
||||||
# Fixed marker so the GIF tells a clear story (crate_blue, east face).
|
# Fixed marker so the GIF tells a clear story (crate_blue, east face).
|
||||||
bridge.world.marker = {
|
bridge.world.marker = {
|
||||||
"box": "crate_blue",
|
"box": "crate_blue", "face": "x1", "u": 0.5, "v": 0.55,
|
||||||
"face": "x1",
|
"size": 0.42, "color": (255, 150, 40),
|
||||||
"u": 0.5,
|
|
||||||
"v": 0.55,
|
|
||||||
"size": 0.42,
|
|
||||||
"color": (255, 150, 40),
|
|
||||||
}
|
}
|
||||||
m = bridge.world.marker_world_pos()
|
m = bridge.world.marker_world_pos()
|
||||||
print(
|
print(f"[record] triangle marker on crate_blue east face at {tuple(round(v, 2) for v in m)}")
|
||||||
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,25 +166,33 @@ 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,
|
||||||
)
|
)
|
||||||
|
if args.goal == "search":
|
||||||
ctl.messages = [
|
ctl.messages = [
|
||||||
ctl.messages[0],
|
ctl.messages[0],
|
||||||
{
|
{"role": "user", "content": (
|
||||||
"role": "user",
|
|
||||||
"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")
|
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")
|
||||||
|
|
||||||
|
if args.goal == "search" and not args.capture_hz:
|
||||||
|
# capture at frame-attach events (one frame per step)
|
||||||
orig_frame = ctl._frame_message
|
orig_frame = ctl._frame_message
|
||||||
|
|
||||||
def record_frame(png: str, note: str):
|
def record_frame(png: str, note: str):
|
||||||
@@ -198,6 +204,7 @@ async def run(args: argparse.Namespace) -> int:
|
|||||||
)
|
)
|
||||||
top = _topdown_frame(bridge, ctl.path, len(frames), marker_seen)
|
top = _topdown_frame(bridge, ctl.path, len(frames), marker_seen)
|
||||||
frames.append(_compose(top, view, chat.render()))
|
frames.append(_compose(top, view, chat.render()))
|
||||||
|
samples.append((bridge.world.capsule.x, bridge.world.capsule.z))
|
||||||
return orig_frame(png, note)
|
return orig_frame(png, note)
|
||||||
|
|
||||||
ctl._frame_message = record_frame # type: ignore[method-assign]
|
ctl._frame_message = record_frame # type: ignore[method-assign]
|
||||||
@@ -205,25 +212,50 @@ async def run(args: argparse.Namespace) -> int:
|
|||||||
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))
|
||||||
|
|||||||
Reference in New Issue
Block a user