| """FastAPI server cho demo1 — VLM nav (VLA loop) thuần, KHÔNG Nav2. |
| |
| Định tuyến lệnh từ GUI: |
| - "move/turn ..." (parse_motion) -> MotionController (lệnh tay) |
| - "ngồi/chào/nhảy..." (match_action) -> ActionController (sport API) |
| - còn lại (ngôn ngữ tự nhiên) -> NavLoopAgent (VLA loop) -> VLM suy luận |
| ra hành động từng bước, tự chấp hành qua MotionController, lặp tới khi xong. |
| """ |
| import os |
| import asyncio |
| import threading |
| from pathlib import Path |
|
|
| import numpy as np |
| import cv2 |
| from fastapi import FastAPI, WebSocket, WebSocketDisconnect |
| from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse, FileResponse |
|
|
| from ..common.vlm_engine import encode_frame_jpeg |
| from .motion import parse_motion |
| from .actions import ACTIONS, match_action |
|
|
| WEBUI = Path(__file__).parent.parent / "webui" / "demo1.html" |
| ASSETS = Path(__file__).parent.parent / "webui" / "assets" |
|
|
|
|
| class MockFrameSource: |
| is_mock = True |
| is_connected = False |
|
|
| def __init__(self): |
| f = np.full((480, 640, 3), 245, dtype=np.uint8) |
| cv2.putText(f, "DEMO_MOCK - no robot", (60, 240), |
| cv2.FONT_HERSHEY_SIMPLEX, 1.0, (40, 40, 40), 2) |
| self._f = f |
|
|
| def get_latest_frame(self): |
| return self._f |
|
|
|
|
| class MockMotion: |
| def run(self, cmd, cancel=None): |
| import math |
| if cmd.kind == "move": |
| title = f"Moving {'forward' if cmd.value >= 0 else 'backward'} {abs(cmd.value):.2f} m" |
| else: |
| title = f"Turning {'left' if cmd.value >= 0 else 'right'} {math.degrees(abs(cmd.value)):.0f}°" |
| yield {"type": "step", "id": "motion", "status": "running", "title": title} |
| for d in (0.6, 0.3, 0.1): |
| yield {"type": "nav", "distance_remaining": d} |
| yield {"type": "step", "id": "motion", "status": "done"} |
| yield {"type": "answer", "text": f"Done — {title.lower()}.", "state": "UNKNOWN"} |
|
|
| def estop(self): |
| pass |
|
|
| def _publish(self, vx, wz): |
| pass |
|
|
| def _stop(self): |
| pass |
|
|
| def front_distance(self): |
| return None |
|
|
|
|
| class MockAction: |
| def run(self, act, cancel=None): |
| yield {"type": "step", "id": "action", "status": "running", |
| "title": f"Action: {act['vi']}"} |
| yield {"type": "step", "id": "action", "status": "done"} |
| yield {"type": "answer", "text": f"Đã gửi lệnh: {act['vi']}.", "state": "UNKNOWN"} |
|
|
|
|
| def create_agent_app(frame_source, motion=None, action=None, navloop=None, |
| annotator=None): |
| app = FastAPI() |
| app.state.busy = False |
|
|
| @app.get("/", response_class=HTMLResponse) |
| async def index(): |
| return WEBUI.read_text(encoding="utf-8") |
|
|
| @app.get("/assets/{name}") |
| async def asset(name: str): |
| p = ASSETS / name |
| if not p.is_file(): |
| return JSONResponse({"error": "not found"}, status_code=404) |
| return FileResponse(str(p)) |
|
|
| @app.get("/actions") |
| async def actions(): |
| return JSONResponse(ACTIONS) |
|
|
| @app.get("/status") |
| async def status(): |
| return JSONResponse({ |
| "connected": bool(getattr(frame_source, "is_connected", False)), |
| "mock": bool(getattr(frame_source, "is_mock", False)), |
| }) |
|
|
| @app.get("/debug") |
| async def debug(): |
| """Số liệu tức thời cho dashboard GUI (poll ~5Hz). Tính stateless từ annotator+lidar.""" |
| box = annotator.target_box() if annotator is not None else None |
| h = annotator.frame_height() if annotator is not None else None |
| w = annotator.frame_width() if annotator is not None else None |
| gap = int(h - box[3]) if (box is not None and h) else None |
| offset = None |
| if box is not None and w: |
| cx = (box[0] + box[2]) / 2.0 |
| offset = round(((cx - w / 2.0) / (w / 2.0)) * 45.0, 1) |
| obstacle = None |
| fd = getattr(motion, "front_distance", None) |
| if callable(fd): |
| try: |
| d = fd() |
| obstacle = round(d, 2) if d is not None else None |
| except Exception: |
| obstacle = None |
| return JSONResponse({ |
| "target_detected": box is not None, |
| "label": getattr(annotator, "label", None) if box is not None else None, |
| "yolo_gap_px": gap, |
| "stop_px": getattr(navloop, "stop_bottom_px", None), |
| "center_offset_deg": offset, |
| "center_tol_deg": getattr(navloop, "center_tol_deg", None), |
| "obstacle_m": obstacle, |
| "control": getattr(navloop, "control", None), |
| }) |
|
|
| @app.get("/video_feed") |
| async def video_feed(): |
| async def gen(): |
| last_id = None |
| last_chunk = None |
| while True: |
| frame = frame_source.get_latest_frame() |
| |
| |
| if frame is not None and id(frame) != last_id: |
| last_id = id(frame) |
| out = annotator.render(frame) if annotator is not None else frame |
| jpg = encode_frame_jpeg(out) |
| last_chunk = (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" |
| + jpg + b"\r\n") |
| if last_chunk is not None: |
| yield last_chunk |
| await asyncio.sleep(0.033) |
| return StreamingResponse( |
| gen(), media_type="multipart/x-mixed-replace; boundary=frame") |
|
|
| @app.websocket("/ws") |
| async def ws(websocket: WebSocket): |
| await websocket.accept() |
| |
| job = {"task": None, "cancel": None} |
| try: |
| while True: |
| data = await websocket.receive_json() |
| msg_action = (data or {}).get("action") |
| if msg_action == "stop": |
| if job["cancel"] is not None: |
| job["cancel"].set() |
| continue |
| if msg_action == "estop": |
| |
| if job["cancel"] is not None: |
| job["cancel"].set() |
| est = getattr(motion, "estop", None) |
| if callable(est): |
| try: |
| est() |
| except Exception: |
| pass |
| await websocket.send_json( |
| {"type": "error", "message": "🛑 EMERGENCY STOP — robot halted."}) |
| continue |
| command = (data or {}).get("command", "").strip() |
| if not command: |
| continue |
| |
| if annotator is not None: |
| annotator.set_target_from_text(command) |
| if job["task"] is not None and not job["task"].done(): |
| await websocket.send_json( |
| {"type": "error", "message": "Đang xử lý lệnh khác."}) |
| continue |
| cancel = threading.Event() |
| job["cancel"] = cancel |
| |
| |
| mc = parse_motion(command) |
| ac = match_action(command) |
| if mc is not None and motion is not None: |
| producer = lambda: motion.run(mc, cancel) |
| elif ac is not None and action is not None: |
| producer = lambda: action.run(ac, cancel) |
| elif navloop is not None: |
| producer = lambda: navloop.run(command, cancel=cancel) |
| else: |
| await websocket.send_json( |
| {"type": "error", |
| "message": "VLM nav chưa sẵn sàng (cần VLM + camera + motion)."}) |
| continue |
| job["task"] = asyncio.create_task(_run_job(websocket, producer)) |
| except WebSocketDisconnect: |
| if job["cancel"] is not None: |
| job["cancel"].set() |
| return |
|
|
| return app |
|
|
|
|
| async def _run_job(websocket, producer): |
| """Chạy producer() (generator event) trong executor, đẩy ra WS; có thể cancel.""" |
| loop = asyncio.get_event_loop() |
| q: asyncio.Queue = asyncio.Queue() |
|
|
| def produce(): |
| try: |
| for ev in producer(): |
| asyncio.run_coroutine_threadsafe(q.put(ev), loop) |
| except Exception as e: |
| asyncio.run_coroutine_threadsafe( |
| q.put({"type": "error", "message": str(e)}), loop) |
| finally: |
| asyncio.run_coroutine_threadsafe(q.put(None), loop) |
|
|
| loop.run_in_executor(None, produce) |
| while True: |
| ev = await q.get() |
| if ev is None: |
| break |
| try: |
| await websocket.send_json(ev) |
| except Exception: |
| break |
|
|
|
|
| def main(): |
| import uvicorn |
| from ..common.vlm_engine import VLMEngine |
|
|
| mock = os.getenv("DEMO_MOCK") == "1" |
| engine = VLMEngine() |
| |
| |
| _ext_brain = os.getenv("VLA_BRAIN", "local").lower() in ("navila", "api") |
|
|
| motion = None |
| action = None |
| if mock: |
| frame_source = MockFrameSource() |
| motion = MockMotion() |
| action = MockAction() |
| print("[demo1] DEMO_MOCK=1 -> không cần ROS/robot.") |
| if os.getenv("VLM_SKIP_MODEL") != "1" and not _ext_brain: |
| engine.load() |
| else: |
| import rclpy |
| from rclpy.executors import SingleThreadedExecutor |
| from .ros_frame_source import RosFrameSource |
| import threading |
| rclpy.init() |
| node = rclpy.create_node("strikerobot_demo1") |
| frame_source = RosFrameSource(node) |
| |
| cam_exec = SingleThreadedExecutor() |
| cam_exec.add_node(node) |
| threading.Thread(target=cam_exec.spin, daemon=True).start() |
| try: |
| from .motion import MotionController |
| motion = MotionController(node) |
| print("[demo1] Motion controller (cmd_vel_joy) sẵn sàng.") |
| except Exception as e: |
| print(f"[demo1] Motion controller lỗi ({e}); chế độ lệnh tay tắt.") |
| try: |
| from .actions import ActionController |
| action = ActionController(node) |
| print("[demo1] Action controller (webrtc_req) sẵn sàng.") |
| except Exception as e: |
| print(f"[demo1] Action controller lỗi ({e}); chế độ hành động tắt.") |
| if not _ext_brain: |
| engine.load() |
| else: |
| print(f"[demo1] Não ngoài ({os.getenv('VLA_BRAIN')}) -> bỏ nạp Qwen local (tiết kiệm VRAM).") |
|
|
| detector = None |
| if os.getenv("USE_YOLO", "1") == "1": |
| try: |
| from .yolo_detector import YoloDetector |
| detector = YoloDetector() |
| print(f"[demo1] YOLO detector sẵn sàng ({len(detector.names)} lớp COCO).") |
| except Exception as e: |
| print(f"[demo1] YOLO không dùng được ({e}); bỏ gợi ý vật cản.") |
|
|
| |
| annotator = None |
| if detector is not None: |
| try: |
| from .annotator import LiveAnnotator |
| annotator = LiveAnnotator(frame_source, detector) |
| annotator.start() |
| print("[demo1] Live YOLO annotator sẵn sàng (khoanh vùng theo prompt).") |
| except Exception as e: |
| print(f"[demo1] Annotator không bật được ({e}).") |
|
|
| |
| navloop = None |
| if motion is not None and (engine.loaded or _ext_brain): |
| try: |
| from .navloop import NavLoopAgent, make_brain |
| brain = make_brain(engine if engine.loaded else None) |
| navloop = NavLoopAgent(brain, frame_source, motion, detector=detector, |
| annotator=annotator) |
| print(f"[demo1] VLM nav (VLA loop) sẵn sàng — brain=" |
| f"{os.getenv('VLA_BRAIN', 'local')}.") |
| except Exception as e: |
| print(f"[demo1] VLM nav không bật được ({e}).") |
|
|
| app = create_agent_app(frame_source, motion=motion, action=action, |
| navloop=navloop, annotator=annotator) |
| try: |
| |
| uvicorn.run(app, host="0.0.0.0", port=8001, timeout_graceful_shutdown=3) |
| finally: |
| if not mock: |
| |
| try: |
| cam_exec.shutdown() |
| except Exception: |
| pass |
| try: |
| if rclpy.ok(): |
| rclpy.shutdown() |
| except Exception: |
| pass |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|