TASK 5 — VLM tick logger for debugging and visualization¶
Problem¶
During live runs the VLM tick loop at 2 Hz processes camera frames,
constructs prompts, and produces VLMOutput responses — but nothing is
persisted. When the model gives a wrong answer, explores too long, or
loops on waypoints, the only signal is a final ROS topic message and
whatever was in docker logs. There is no way to:
- See what camera frame the model was looking at on a given tick.
- Read the exact prompt (system + user text) that was sent.
- Compare outputs across ticks to spot drift or hallucination.
- Replay a full question session to understand multi-tick reasoning.
Design¶
Data captured per tick¶
| Field | Source | Storage |
|---|---|---|
tick_id |
VLMInput.tick_id |
JSONL |
tick_time |
VLMInput.tick_time |
JSONL (float seconds) |
question_text |
VLMInput.question.text |
JSONL |
question_type |
VLMInput.question.type |
JSONL |
pose |
VLMInput.pose |
JSONL (position + quat) |
system_prompt |
constructed in QwenResponder |
JSONL |
user_text |
constructed in QwenResponder |
JSONL |
output |
VLMOutput model dump |
JSONL |
inference_ms |
wall-clock around engine.infer |
JSONL |
evidence |
responder evidence log snapshot | JSONL |
image_path |
camera JPEG filename | JSONL (relative path) |
pointclouds |
lidar/terrain sensor paths | JSONL (dict of rel paths) |
| camera frame | VLMInput.image |
JPEG file in images/ |
| registered_scan | VLMInput.registered_scan |
.npy in pointclouds/ |
| sensor_scan | VLMInput.sensor_scan |
.npy in pointclouds/ |
| terrain_local | VLMInput.terrain_local |
.npy in pointclouds/ |
| terrain_ext | VLMInput.terrain_ext |
.npy in pointclouds/ |
File layout¶
Logs are organized per question within each session:
vlm_logs/
session_20260530_143022/
session.json # One-time: config, model, responder, tick_hz
predictions.jsonl # Final answer per question (for offline eval)
q_001_how_many_chairs/
ticks.jsonl # One JSON line per tick for this question
images/
tick_000003.jpg
pointclouds/ # Lidar/terrain arrays per tick
tick_000003_registered.npy
tick_000003_terrain_local.npy
tick_000003_terrain_ext.npy
report.html # Generated HTML report
q_002_find_the_red_cup/
ticks.jsonl
images/
tick_000007.jpg
- Per-question directories — named
q_{NNN}_{slug}where NNN is sequential and slug is derived from the question text. Each question gets its ownticks.jsonl,images/, andpointclouds/directory. - JSONL — one line per tick, cheap to append, easy to load into
pandas (
pd.read_json("ticks.jsonl", lines=True)). - Session JSON — written once at startup, contains the full
QwenConfigdump plus metadata (start time, responder name, tick frequency, model name). - JPEG images — only saved when
VLMInput.imageis notNone. BGR8 → RGB → JPEG quality 90, usingimage_frame_to_pilfromimage_utils.py. Zero-padded tick IDs in filename solssorts correctly. - Point clouds — lidar (registered_scan, sensor_scan) and terrain
(terrain_local, terrain_ext) arrays saved as
.npyfiles (N,4 float32). Only saved when the corresponding sensor is notNone.
Toggle¶
XIAO_HEI_VLM_LOG_DIRenv var → when set, logger is active.- When unset or empty → no logger created, zero overhead.
compose_gpu.ymladds a bind-mount volume mapping a host directory into the container at the configured path.
Integration point¶
QwenResponder.respond() is the only place where both the constructed
prompts (system + user text) and the output are available. The logger
is injected into the responder as an optional dependency:
class QwenResponder:
def __init__(self, engine, config, *, logger: VLMLogger | None = None):
self._logger = logger
The responder calls logger.new_question(text) on the first tick of
each question to open a new subdirectory, then logger.log_tick(...)
after each inference. The logger handles JPEG encoding and file I/O
internally.
Synchronous vs async¶
At 2 Hz with ~2 ms for JPEG encode + ~1 ms for file write, synchronous logging adds < 5 ms per tick — well within the 500 ms budget. No background thread needed. If latency becomes a concern on slower storage, a buffered/async path can be added later without changing the interface.
Phases¶
Phase 1 — VLMLogger core¶
New files:
src/xiao_hei_vln/image_utils.py— sharedimage_frame_to_pilandresize_pilhelpers, extracted fromengine.pyto avoid duplication between engine and logger.src/xiao_hei_vln/logger.py:VLMLogger.__init__(log_dir, config, responder_name, tick_hz)— creates session directory, writessession.json.VLMLogger.new_question(question_text)— opens a new per-question subdirectory (q_001_<slug>/) with its ownticks.jsonlandimages/directory.VLMLogger.log_tick(snapshot, system_prompt, user_text, output, inference_ms, evidence)— appends one JSON line to the current question'sticks.jsonl, saves JPEG if image present, saves lidar/terrain point clouds as.npyfiles if present.VLMLogger.close()— flushes and closes the current file handle.- Helper
_slugify(text)— converts question text to a filesystem- safe slug for directory naming. - Tests with
tmp_pathfixture — 13 logger tests + 5 point cloud tests covering session init, per-question dirs, JSONL records, image saving,.npysaving, multiple questions, edge cases.
Phase 2 — Integration¶
QwenResponder: optionalloggerkwarg; callslogger.new_question()on tick_count == 1, callslogger.log_tick()after inference withtime.perf_counter()timing.app/main.py: readsXIAO_HEI_VLM_LOG_DIR, createsVLMLoggerif set, passes toQwenResponder, closes on shutdown.engine.py: refactored to import fromimage_utils.py.compose_gpu.yml:XIAO_HEI_VLM_LOG_DIR=/vlm_logsenv var + bind-mount../vlm_logs:/vlm_logsonai_module..gitignore: addedvlm_logs/.
Phase 3 — Replay and HTML report¶
Text replay — scripts/replay_session.py:
- Loads session.json and per-question ticks.jsonl files.
- Prints a summary table per question: tick_id, time, output kind,
inference_ms, rationale.
- Shows latency stats (min/max/avg) per question.
- -q flag to filter by question substring.
HTML report generator — scripts/generate_report.py:
- Produces a self-contained HTML file per question with:
- Camera playback (JS slider with play/pause/prev/next)
- Pose trajectory + waypoint overlay (matplotlib)
- Sensor bird's-eye-view scatter plot (terrain cost, lidar, robot)
- Expandable per-tick I/O table (prompts, output JSON, evidence)
- Latency bar chart
- Works on a single question dir, or all questions in a session.
- -q flag to filter by question substring.
- Requires pip install xiao-hei-vln[replay] (matplotlib + pillow).
Files to add / change¶
src/xiao_hei_vln/image_utils.py new — shared image_frame_to_pil + resize_pil
src/xiao_hei_vln/logger.py new — VLMLogger (per-question dirs, images, pointclouds)
src/xiao_hei_vln/qwen/engine.py refactored to use image_utils
src/xiao_hei_vln/qwen/responder.py + optional logger kwarg + timing
src/xiao_hei_vln/app/main.py + logger init from env
docker/compose_gpu.yml + log dir env + bind mount
.gitignore + vlm_logs/
pyproject.toml + [replay] optional dep group
tests/test_vlm_logger.py new — 13 logger unit tests
tests/test_logger_pointclouds.py new — 5 point cloud tests
scripts/replay_session.py new — text session replay CLI
scripts/generate_report.py new — HTML report generator
docker/README.md + VLM tick logging section + env var docs
Verification¶
$ uv run pytest -q
........................................................................ [ 59%]
.................................................. [100%]
122 passed in 0.24s
$ uv run ruff check src tests scripts
All checks passed!
(94 from Tasks 1–4, +13 logger tests, +5 point cloud tests, +10 others.)
How to use¶
# GPU compose — logging is enabled by default (XIAO_HEI_VLM_LOG_DIR=/vlm_logs)
docker compose -f docker/compose_gpu.yml up -d
# After a run, logs appear on the host:
ls vlm_logs/session_*/
# → session.json q_001_how_many_chairs/ q_002_find_the_red_cup/
# Each question dir contains:
ls vlm_logs/session_*/q_001_*/
# → ticks.jsonl images/ pointclouds/
# Text replay:
python scripts/replay_session.py vlm_logs/session_20260530_143022
python scripts/replay_session.py vlm_logs/session_20260530_143022 -q chairs
# HTML report (requires matplotlib):
pip install xiao-hei-vln[replay]
python scripts/generate_report.py vlm_logs/session_20260530_143022/
open vlm_logs/session_20260530_143022/q_001_*/report.html
# Single question report:
python scripts/generate_report.py vlm_logs/session_*/ -q chairs
# Disable logging:
XIAO_HEI_VLM_LOG_DIR="" docker compose -f docker/compose_gpu.yml up -d