""" ESP32 camera viewer — local (on-device) HSV detection only. Tunes colour / method / perf on the ESP over WebSocket; overlays blob from X-Vision-Data (or /vision in detect-only mode). Usage: .\\.venv\\Scripts\\python.exe viewer.py --ip 192.168.1.178 Arrow keys / WASD also send directions. Enter sends the text box. Commands go over ws://IP/ws (HTTP kept as fallback). """ from __future__ import annotations import argparse import json import threading import time import tkinter as tk from tkinter import colorchooser, scrolledtext, ttk import cv2 import numpy as np import requests from PIL import Image, ImageTk from websocket import WebSocketApp from targets import TargetDetector, create_target from targets.orange_ball import OrangeBallDetector ESP_METHODS = ("contour", "rect") ESP_METHOD_LABELS = {"contour": "Contour", "rect": "Rectangle"} class RobotApp: def __init__( self, ip: str, fps: float, timeout: float, detector: TargetDetector | None, ) -> None: self.ip = ip self.base = f"http://{ip}" self.ws_url = f"ws://{ip}/ws" self.period = 1.0 / fps if fps > 0 else 0.0 self.timeout = timeout self.detector = detector self.session = requests.Session() self.session.headers.update( {"Cache-Control": "no-cache", "Pragma": "no-cache"} ) self._stop = threading.Event() self._photo: ImageTk.PhotoImage | None = None self._ws: WebSocketApp | None = None self._ws_ready = threading.Event() self._ws_lock = threading.Lock() self._last_det_log = 0.0 self._raw_frame: np.ndarray | None = None self._frame_lock = threading.Lock() self._preview_scale = 1.0 self._preview_display_size = (640, 480) self._preview_src_size = (640, 480) self._vision_sync_after_id: str | None = None self._last_vision_payload: str | None = None self._pending_pick: tuple[int, int] | None = None self._applying_remote = False self._vision_settings_loaded = False self.root = tk.Tk() self.root.title(f"linefollower-eye — {ip}") self.root.protocol("WM_DELETE_WINDOW", self.on_close) self.show_mask = tk.BooleanVar(master=self.root, value=False) self.mask_full = tk.BooleanVar(master=self.root, value=False) self.morph_roi = tk.BooleanVar(master=self.root, value=True) self.coarse_stride = tk.IntVar(master=self.root, value=4) self.detect_only = tk.BooleanVar(master=self.root, value=False) self.tol_var = tk.IntVar(master=self.root, value=10) self.eyedropper_on = tk.BooleanVar(master=self.root, value=False) if isinstance(self.detector, OrangeBallDetector): # ESP only supports contour/rect self.detector.set_method("contour") main = ttk.Frame(self.root, padding=8) main.grid(row=0, column=0, sticky="nsew") self.root.rowconfigure(0, weight=1) self.root.columnconfigure(0, weight=1) main.rowconfigure(0, weight=1) main.columnconfigure(0, weight=1) self.video = ttk.Label(main, cursor="arrow") self.video.grid(row=0, column=0, sticky="nsew", padx=(0, 8)) self.video.bind("", self._on_video_click) side = ttk.Frame(main) side.grid(row=0, column=1, sticky="ns") ttk.Label(side, text="Drive").grid(row=0, column=0, columnspan=3, pady=(0, 4)) ttk.Button(side, text="↑", width=4, command=lambda: self.send_cmd("up")).grid( row=1, column=1, padx=2, pady=2 ) ttk.Button(side, text="←", width=4, command=lambda: self.send_cmd("left")).grid( row=2, column=0, padx=2, pady=2 ) ttk.Button(side, text="●", width=4, command=lambda: self.send_cmd("stop")).grid( row=2, column=1, padx=2, pady=2 ) ttk.Button(side, text="→", width=4, command=lambda: self.send_cmd("right")).grid( row=2, column=2, padx=2, pady=2 ) ttk.Button(side, text="↓", width=4, command=lambda: self.send_cmd("down")).grid( row=3, column=1, padx=2, pady=2 ) row = 4 if isinstance(self.detector, OrangeBallDetector): row = self._build_tune_panel(side, start_row=row) ttk.Label(side, text="Terminal").grid( row=row, column=0, columnspan=3, sticky="w", pady=(16, 4) ) self.log = scrolledtext.ScrolledText( side, width=36, height=12, state="disabled", wrap="word" ) self.log.grid(row=row + 1, column=0, columnspan=3, sticky="nsew") side.rowconfigure(row + 1, weight=1) entry_row = ttk.Frame(side) entry_row.grid(row=row + 2, column=0, columnspan=3, sticky="ew", pady=(6, 0)) self.entry = ttk.Entry(entry_row) self.entry.pack(side="left", fill="x", expand=True) self.entry.bind("", lambda _e: self.send_text()) ttk.Button(entry_row, text="Send", command=self.send_text).pack( side="left", padx=(6, 0) ) self.root.bind("", lambda _e: self.send_cmd("up")) self.root.bind("", lambda _e: self.send_cmd("down")) self.root.bind("", lambda _e: self.send_cmd("left")) self.root.bind("", lambda _e: self.send_cmd("right")) self.root.bind("", lambda _e: self.send_cmd("stop")) self.root.bind("w", lambda _e: self.send_cmd("up")) self.root.bind("s", lambda _e: self.send_cmd("down")) self.root.bind("a", lambda _e: self.send_cmd("left")) self.root.bind("d", lambda _e: self.send_cmd("right")) self.append_log(f"HTTP {self.base} WS {self.ws_url}") if self.detector is not None: self.append_log(f"Target: {self.detector.name} (ESP local vision)") if isinstance(self.detector, OrangeBallDetector): self.append_log("Eyedropper / Pick colour → sync HSV to ESP") else: self.append_log("Target: off (drive + stream only)") self._ws_thread = threading.Thread(target=self._ws_loop, daemon=True) self._ws_thread.start() self._video_thread = threading.Thread(target=self._video_loop, daemon=True) self._video_thread.start() def _build_tune_panel(self, parent: ttk.Frame, start_row: int) -> int: if not isinstance(self.detector, OrangeBallDetector): return start_row h_tol, _, _ = self.detector.get_tolerance() self.tol_var.set(h_tol) ttk.Label(parent, text="ESP local vision", font=("", 9, "bold")).grid( row=start_row, column=0, columnspan=3, sticky="w", pady=(16, 4) ) start_row += 1 ttk.Label(parent, text="Target colour").grid( row=start_row, column=0, columnspan=3, sticky="w", pady=(8, 4) ) start_row += 1 row = ttk.Frame(parent) row.grid(row=start_row, column=0, columnspan=3, sticky="ew", pady=2) self.swatch = tk.Canvas(row, width=36, height=24, highlightthickness=1) self.swatch.pack(side="left") ttk.Button(row, text="Pick colour…", command=self._pick_colour).pack( side="left", padx=(8, 4) ) self.eyedrop_btn = ttk.Checkbutton( row, text="Eyedropper", variable=self.eyedropper_on, command=self._on_eyedropper_toggle, ) self.eyedrop_btn.pack(side="left") start_row += 1 method_row = ttk.Frame(parent) method_row.grid(row=start_row, column=0, columnspan=3, sticky="ew", pady=2) ttk.Label(method_row, text="Method", width=11).pack(side="left") self.method_var = tk.StringVar( master=self.root, value=ESP_METHOD_LABELS["contour"], ) self.method_combo = ttk.Combobox( method_row, textvariable=self.method_var, values=[ESP_METHOD_LABELS[m] for m in ESP_METHODS], state="readonly", width=16, ) self.method_combo.pack(side="left", fill="x", expand=True, padx=4) self.method_combo.bind("<>", self._on_method_change) start_row += 1 self.show_mask_btn = ttk.Checkbutton( parent, text="Show mask", variable=self.show_mask, command=self._on_show_mask_change, ) self.show_mask_btn.grid(row=start_row, column=0, columnspan=3, sticky="w") start_row += 1 perf = ttk.LabelFrame(parent, text="ESP32 perf / detect", padding=4) perf.grid(row=start_row, column=0, columnspan=3, sticky="ew", pady=(8, 4)) start_row += 1 stride_row = ttk.Frame(perf) stride_row.pack(fill="x", pady=2) ttk.Label(stride_row, text="Coarse stride", width=12).pack(side="left") self.stride_combo = ttk.Combobox( stride_row, values=("2", "4"), state="readonly", width=4, ) self.stride_combo.set(str(self.coarse_stride.get())) self.stride_combo.pack(side="left", padx=4) self.stride_combo.bind("<>", self._on_esp_perf_change) opt_row = ttk.Frame(perf) opt_row.pack(fill="x", pady=2) ttk.Checkbutton( opt_row, text="Full-frame mask (slow)", variable=self.mask_full, command=self._on_esp_perf_change, ).pack(side="left") ttk.Checkbutton( opt_row, text="ROI morph", variable=self.morph_roi, command=self._on_esp_perf_change, ).pack(side="left", padx=(8, 0)) detect_row = ttk.Frame(perf) detect_row.pack(fill="x", pady=2) ttk.Checkbutton( detect_row, text="Detect only (/vision, no video)", variable=self.detect_only, command=self._on_detect_only_change, ).pack(side="left") self.esp_stats_label = ttk.Label( perf, text="timing: —", wraplength=240, justify="left", ) self.esp_stats_label.pack(fill="x", pady=(4, 0)) tol_row = ttk.Frame(parent) tol_row.grid(row=start_row, column=0, columnspan=3, sticky="ew", pady=2) ttk.Label(tol_row, text="Tolerance", width=11).pack(side="left") self.tol_label = ttk.Label(tol_row, width=4) self.tol_scale = ttk.Scale( tol_row, from_=2, to=40, variable=self.tol_var, orient="horizontal", command=self._on_tol_change, ) self.tol_scale.pack(side="left", fill="x", expand=True, padx=4) self.tol_label.pack(side="left") self._on_tol_change(str(self.tol_var.get())) start_row += 1 self._update_swatch() return start_row def _method_key_from_label(self, label: str) -> str | None: for key, text in ESP_METHOD_LABELS.items(): if text == label: return key return None def _on_esp_perf_change(self, _event: object | None = None) -> None: if self._applying_remote: return try: self.coarse_stride.set(int(self.stride_combo.get())) except ValueError: self.coarse_stride.set(4) self.stride_combo.set("4") self._last_vision_payload = None self._sync_vision_to_esp() def _on_detect_only_change(self) -> None: mode = "detect-only (/vision)" if self.detect_only.get() else "JPEG + overlay" self.append_log(f"Stream mode → {mode}") def _parse_vision_data_str(self, raw: str) -> dict[str, float | int | bool]: out: dict[str, float | int | bool] = {} if not raw or "found=" not in raw: return out if any(ord(ch) < 32 and ch not in "\t\r\n" for ch in raw): return out for part in raw.split(";"): if "=" not in part: continue key, val = part.split("=", 1) try: if key == "found": out["found"] = val == "1" elif key == "area": out[key] = int(float(val)) else: out[key] = float(val) except ValueError: continue return out def _parse_vision_json(self, data: dict) -> dict[str, float | int | bool]: return { "found": bool(data.get("found")), "cx": float(data.get("cx", 0)), "cy": float(data.get("cy", 0)), "r": float(data.get("radius", 0)), "area": int(data.get("area", 0)), "total": float(data.get("total_ms", 0)), "detect": float(data.get("detect_ms", 0)), "coarse": float(data.get("coarse_ms", 0)), "refine": float(data.get("refine_ms", 0)), "morph": float(data.get("morph_ms", 0)), "mask": float(data.get("mask_ms", 0)), "jpeg": float(data.get("jpeg_ms", 0)), } def _format_esp_stats_line(self, stats: dict[str, float | int | bool]) -> str: parts: list[str] = [] for key, label in ( ("total", "total"), ("detect", "det"), ("coarse", "coarse"), ("refine", "refine"), ("morph", "morph"), ("mask", "mask"), ("jpeg", "jpeg"), ): val = stats.get(key) if isinstance(val, (int, float)): if key in ("morph", "mask") and val <= 0.05: continue if key == "total": parts.append(f"{label} {val:.0f}ms") else: parts.append(f"{label} {val:.0f}") blob = "" if stats.get("found"): cx = stats.get("cx", "?") cy = stats.get("cy", "?") r = stats.get("r", stats.get("radius", "?")) blob = f" | blob @({cx},{cy}) r={r}" return "timing: " + (" ".join(parts) if parts else "—") + blob def _annotate_esp_target( self, frame_bgr: np.ndarray, stats: dict[str, float | int | bool] ) -> np.ndarray: if not stats.get("found"): return frame_bgr out = frame_bgr.copy() cx = int(float(stats.get("cx", 0))) cy = int(float(stats.get("cy", 0))) radius = stats.get("r", stats.get("radius", 0)) r = max(4, int(float(radius))) cv2.circle(out, (cx, cy), r, (0, 255, 0), 2) cv2.drawMarker(out, (cx, cy), (0, 255, 0), cv2.MARKER_CROSS, 12, 2) return out def _seed_display_frame(self, capture_url: str) -> bool: """Fetch one JPEG for overlay when detect-only has no cached frame yet.""" try: resp = self.session.get(capture_url, timeout=self._capture_timeout()) resp.raise_for_status() buf = np.frombuffer(resp.content, dtype=np.uint8) frame = cv2.imdecode(buf, cv2.IMREAD_COLOR) if frame is None: return False with self._frame_lock: self._raw_frame = frame.copy() return True except requests.RequestException: return False def _refresh_detect_overlay(self, esp_stats: dict[str, float | int | bool]) -> None: with self._frame_lock: frame = None if self._raw_frame is None else self._raw_frame.copy() if frame is None: return display = self._annotate_esp_target(frame, esp_stats) self.root.after(0, self._show_frame, display) def _apply_esp_stats(self, stats: dict[str, float | int | bool]) -> None: line = self._format_esp_stats_line(stats) self.root.after(0, self._update_esp_stats_label, line) now = time.perf_counter() if stats.get("found") and now - self._last_det_log > 1.0: self.append_log( f"ESP blob @ ({stats.get('cx')},{stats.get('cy')}) " f"r={stats.get('r', stats.get('radius'))} area={stats.get('area')}" ) self._last_det_log = now def _apply_vision_headers(self, headers: object) -> dict[str, float | int | bool] | None: getter = getattr(headers, "get", None) if getter is None: return None raw = getter("X-Vision-Data") or getter("x-vision-data") if not raw: return None stats = self._parse_vision_data_str(str(raw)) if not stats: return None self._apply_esp_stats(stats) return stats def _update_esp_stats_label(self, text: str) -> None: if hasattr(self, "esp_stats_label"): self.esp_stats_label.configure(text=text) def _request_vision_settings(self) -> None: """Pull current ESP vision params without writing (HTTP, WS fallback).""" def _worker() -> None: data: dict | None = None try: resp = self.session.get( f"{self.base}/vision/settings", timeout=self.timeout, ) resp.raise_for_status() parsed = resp.json() if isinstance(parsed, dict) and parsed.get("ok"): data = parsed except (requests.RequestException, ValueError) as exc: self.append_log(f"! vision settings HTTP: {exc}") if data is None: if not self._ws_send({"type": "vision", "get": True}): self.append_log("! vision get failed (WS not connected)") return self.root.after(0, self._apply_vision_from_esp, data) threading.Thread(target=_worker, daemon=True).start() def _build_vision_payload(self, *, consume_pick: bool = True) -> dict | None: if not isinstance(self.detector, OrangeBallDetector): return None if not hasattr(self, "method_var"): return None h, s, v = self.detector.get_hsv_center() h_tol, s_tol, v_tol = self.detector.get_tolerance() key = self._method_key_from_label(self.method_var.get()) or "contour" if key not in ESP_METHODS: key = "contour" payload: dict = { "type": "vision", "local": True, "show_mask": self.show_mask.get(), "method": key, "h": h, "s": s, "v": v, "h_tol": h_tol, "s_tol": s_tol, "v_tol": v_tol, "coarse_stride": int(self.coarse_stride.get()), "min_area": int(self.detector.min_area), "mask_full": self.mask_full.get(), "morph_roi": self.morph_roi.get(), } if consume_pick and self._pending_pick is not None: payload["pick_x"], payload["pick_y"] = self._pending_pick self._pending_pick = None return payload def _set_swatch_bgr(self, b: int, g: int, r: int) -> None: if not hasattr(self, "swatch"): return color = f"#{r:02x}{g:02x}{b:02x}" self.swatch.configure(bg=color) self.swatch.delete("all") self.swatch.create_rectangle(0, 0, 36, 24, fill=color, outline="") def _apply_vision_from_esp(self, data: dict) -> None: """Populate UI + detector from a vision ack JSON.""" if not isinstance(self.detector, OrangeBallDetector): return if not data.get("ok"): err = data.get("error", "unknown") self.append_log(f"! vision settings rejected: {err}") return self._applying_remote = True try: if "h" in data and "s" in data and "v" in data: self.detector.set_hsv_center( int(data["h"]), int(data["s"]), int(data["v"]) ) if "h_tol" in data or "s_tol" in data or "v_tol" in data: self.detector.set_tolerance( h_tol=int(data["h_tol"]) if "h_tol" in data else None, s_tol=int(data["s_tol"]) if "s_tol" in data else None, v_tol=int(data["v_tol"]) if "v_tol" in data else None, ) if "h_tol" in data and hasattr(self, "tol_label"): ht = int(data["h_tol"]) # Avoid Scale command overwriting s_tol/v_tol with h*6 if hasattr(self, "tol_scale"): self.tol_scale.configure(command="") self.tol_var.set(ht) self.tol_label.configure(text=str(ht)) if hasattr(self, "tol_scale"): self.tol_scale.configure(command=self._on_tol_change) if "min_area" in data: self.detector.min_area = float(int(data["min_area"])) method = str(data.get("method", "contour")) if method not in ESP_METHODS: method = "contour" self.detector.set_method(method) if hasattr(self, "method_var"): self.method_var.set(ESP_METHOD_LABELS[method]) if "show_mask" in data: self.show_mask.set(bool(data["show_mask"])) if "mask_full" in data: self.mask_full.set(bool(data["mask_full"])) if "morph_roi" in data: self.morph_roi.set(bool(data["morph_roi"])) if "coarse_stride" in data and hasattr(self, "stride_combo"): stride = int(data["coarse_stride"]) if stride not in (2, 4): stride = 4 self.coarse_stride.set(stride) self.stride_combo.set(str(stride)) # Prefer ESP display BGR (eyedrop pixel / stored swatch) over HSV→BGR if all(k in data for k in ("b", "g", "r")): self._set_swatch_bgr(int(data["b"]), int(data["g"]), int(data["r"])) elif hasattr(self, "swatch"): self._update_swatch() finally: self._applying_remote = False payload = self._build_vision_payload(consume_pick=False) if payload is not None: self._last_vision_payload = json.dumps( payload, separators=(",", ":"), sort_keys=True ) first = not self._vision_settings_loaded self._vision_settings_loaded = True if first: h, s, v = self.detector.get_hsv_center() ht, st, vt = self.detector.get_tolerance() bgr = "" if all(k in data for k in ("b", "g", "r")): bgr = f" BGR=({int(data['b'])},{int(data['g'])},{int(data['r'])})" self.append_log( f"ESP settings loaded: HSV=({h},{s},{v}){bgr} " f"tol=({ht},{st},{vt}) method={self.detector.get_method()} " f"stride={self.coarse_stride.get()} mask={self.show_mask.get()} " f"local={bool(data.get('local'))}" ) def _sync_vision_to_esp(self, delay_ms: int = 150) -> None: if self._applying_remote: return if not self._vision_settings_loaded: return if not isinstance(self.detector, OrangeBallDetector): return if self._vision_sync_after_id is not None: self.root.after_cancel(self._vision_sync_after_id) self._vision_sync_after_id = None def _do() -> None: self._vision_sync_after_id = None if self._applying_remote or not self._vision_settings_loaded: return payload = self._build_vision_payload() if payload is None: return blob = json.dumps(payload, separators=(",", ":"), sort_keys=True) if blob == self._last_vision_payload: return self._last_vision_payload = blob def _worker() -> None: if not self._ws_send(payload): self.append_log("! vision sync failed (WS not connected)") threading.Thread(target=_worker, daemon=True).start() self._vision_sync_after_id = self.root.after(delay_ms, _do) def _capture_timeout(self) -> float: if self.show_mask.get(): return max(self.timeout, 15.0) return max(self.timeout, 6.0) def _capture_period(self) -> float: if self.show_mask.get(): return max(self.period, 0.5) return max(self.period, 0.2) def _on_show_mask_change(self) -> None: if self._applying_remote: return self._last_vision_payload = None self._sync_vision_to_esp() def _on_method_change(self, _event: object | None = None) -> None: if self._applying_remote: return if not isinstance(self.detector, OrangeBallDetector): return label = self.method_var.get() key = self._method_key_from_label(label) if key is None or key not in ESP_METHODS: return self.detector.set_method(key) self.append_log(f"method → {label}") self._sync_vision_to_esp() def _on_eyedropper_toggle(self) -> None: if self.eyedropper_on.get(): self.video.configure(cursor="crosshair") self.append_log("Eyedropper on — click the video") else: self.video.configure(cursor="arrow") def _update_swatch(self) -> None: if not isinstance(self.detector, OrangeBallDetector): return b, g, r = self.detector.get_bgr_center() color = f"#{r:02x}{g:02x}{b:02x}" self.swatch.configure(bg=color) self.swatch.delete("all") self.swatch.create_rectangle(0, 0, 36, 24, fill=color, outline="") def _pick_colour(self) -> None: if not isinstance(self.detector, OrangeBallDetector): return self.eyedropper_on.set(False) self._on_eyedropper_toggle() b, g, r = self.detector.get_bgr_center() result = colorchooser.askcolor( color=(r, g, b), title="Target ball colour", parent=self.root ) if not result or result[0] is None: return r2, g2, b2 = (int(v) for v in result[0]) self.detector.set_from_bgr(b2, g2, r2) self._update_swatch() self.append_log(f"colour set RGB=({r2},{g2},{b2})") self._sync_vision_to_esp() def _on_tol_change(self, _value: str) -> None: if not isinstance(self.detector, OrangeBallDetector): return tol = int(round(float(self.tol_var.get()))) if hasattr(self, "tol_label"): self.tol_label.configure(text=str(tol)) if self._applying_remote: return self.detector.set_tolerance(h_tol=tol, s_tol=tol * 6, v_tol=tol * 6) self._sync_vision_to_esp() def _video_click_to_frame(self, event: tk.Event) -> tuple[int, int] | None: # type: ignore[type-arg] """Map a click on the video label to pixel coords in the source frame.""" scale = self._preview_scale disp_w, disp_h = self._preview_display_size src_w, src_h = self._preview_src_size if scale <= 0 or disp_w <= 0 or disp_h <= 0: return None widget_w = max(1, self.video.winfo_width()) widget_h = max(1, self.video.winfo_height()) offset_x = max(0, (widget_w - disp_w) // 2) offset_y = max(0, (widget_h - disp_h) // 2) ix = event.x - offset_x iy = event.y - offset_y if not (0 <= ix < disp_w and 0 <= iy < disp_h): return None x = int(round(ix / scale)) y = int(round(iy / scale)) x = max(0, min(x, src_w - 1)) y = max(0, min(y, src_h - 1)) return x, y def _on_video_click(self, event: tk.Event) -> None: # type: ignore[type-arg] if not isinstance(self.detector, OrangeBallDetector): return if not self.eyedropper_on.get(): return if self.show_mask.get(): self.append_log("! turn off Show mask to pick colour from live image") return with self._frame_lock: frame = None if self._raw_frame is None else self._raw_frame.copy() if frame is None: return pt = self._video_click_to_frame(event) if pt is None: self.append_log("! eyedropper: click on the video image") return x, y = pt b, g, r = (int(v) for v in frame[y, x]) self.detector.set_from_bgr(b, g, r) self._update_swatch() self.append_log(f"eyedrop @ ({x},{y}) RGB=({r},{g},{b})") self._pending_pick = (x, y) self._last_vision_payload = None self._sync_vision_to_esp(delay_ms=0) self.eyedropper_on.set(False) self._on_eyedropper_toggle() def append_log(self, line: str) -> None: def _do() -> None: self.log.configure(state="normal") self.log.insert("end", line + "\n") self.log.see("end") self.log.configure(state="disabled") self.root.after(0, _do) def _ws_loop(self) -> None: while not self._stop.is_set(): self._ws_ready.clear() def on_open(_ws: WebSocketApp) -> None: self._ws_ready.set() self.append_log("WS connected — fetching ESP vision settings") self._vision_settings_loaded = False self.root.after(200, self._request_vision_settings) def on_message(_ws: WebSocketApp, message: str) -> None: self.append_log(f"< {message}") try: data = json.loads(message) except (TypeError, json.JSONDecodeError): return if isinstance(data, dict) and data.get("type") == "vision": self.root.after(0, self._apply_vision_from_esp, data) def on_error(_ws: WebSocketApp, error: object) -> None: self.append_log(f"! WS error: {error}") def on_close(_ws: WebSocketApp, *_args: object) -> None: self._ws_ready.clear() self.append_log("WS closed") ws = WebSocketApp( self.ws_url, on_open=on_open, on_message=on_message, on_error=on_error, on_close=on_close, ) with self._ws_lock: self._ws = ws try: ws.run_forever(ping_interval=20, ping_timeout=10) except Exception as exc: # noqa: BLE001 self.append_log(f"! WS run failed: {exc}") with self._ws_lock: self._ws = None if not self._stop.is_set(): time.sleep(1.0) def _ws_send(self, payload: dict) -> bool: data = json.dumps(payload, separators=(",", ":")) with self._ws_lock: ws = self._ws ready = self._ws_ready.is_set() if not ws or not ready: return False try: ws.send(data) return True except Exception as exc: # noqa: BLE001 self.append_log(f"! WS send failed: {exc}") return False def send_cmd(self, direction: str) -> None: def _worker() -> None: if self._ws_send({"type": "cmd", "dir": direction}): return try: r = self.session.get( f"{self.base}/cmd", params={"dir": direction}, timeout=self.timeout, ) self.append_log(f"< {r.text.strip()}") except requests.RequestException as exc: self.append_log(f"! cmd {direction} failed: {exc}") threading.Thread(target=_worker, daemon=True).start() def send_text(self) -> None: text = self.entry.get().strip() if not text: return self.entry.delete(0, "end") def _worker() -> None: if self._ws_send({"type": "msg", "text": text}): return try: r = self.session.post( f"{self.base}/msg", data=text.encode("utf-8"), headers={"Content-Type": "text/plain; charset=utf-8"}, timeout=self.timeout, ) self.append_log(f"< {r.text.strip()}") except requests.RequestException as exc: self.append_log(f"! send failed: {exc}") threading.Thread(target=_worker, daemon=True).start() def _show_frame(self, frame_bgr: np.ndarray) -> None: preview_w = 640 h, w = frame_bgr.shape[:2] scale = preview_w / float(w) preview_h = max(1, int(round(h * scale))) self._preview_scale = scale self._preview_display_size = (preview_w, preview_h) self._preview_src_size = (w, h) resized = cv2.resize(frame_bgr, (preview_w, preview_h), interpolation=cv2.INTER_AREA) rgb = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB) image = Image.fromarray(rgb) photo = ImageTk.PhotoImage(image=image) self._photo = photo self.video.configure(image=photo) def _video_loop(self) -> None: capture_url = f"{self.base}/capture" vision_url = f"{self.base}/vision" while not self._stop.is_set(): t0 = time.perf_counter() try: esp_stats: dict[str, float | int | bool] | None = None if self.detect_only.get(): with self._frame_lock: need_seed = self._raw_frame is None if need_seed: self._seed_display_frame(capture_url) resp = self.session.get(vision_url, timeout=self._capture_timeout()) resp.raise_for_status() esp_stats = self._parse_vision_json(resp.json()) self._apply_esp_stats(esp_stats) self._refresh_detect_overlay(esp_stats) else: resp = self.session.get(capture_url, timeout=self._capture_timeout()) resp.raise_for_status() esp_stats = self._apply_vision_headers(resp.headers) buf = np.frombuffer(resp.content, dtype=np.uint8) frame = cv2.imdecode(buf, cv2.IMREAD_COLOR) if frame is not None: with self._frame_lock: self._raw_frame = frame.copy() display = frame if esp_stats is not None: display = self._annotate_esp_target(frame, esp_stats) self.root.after(0, self._show_frame, display) except requests.RequestException as exc: self.append_log(f"! video: {exc}") time.sleep(1.0) period = self._capture_period() if period > 0: elapsed = time.perf_counter() - t0 sleep_s = period - elapsed if sleep_s > 0: time.sleep(sleep_s) def on_close(self) -> None: self._stop.set() with self._ws_lock: if self._ws is not None: try: self._ws.close() except Exception: # noqa: BLE001 pass self.session.close() self.root.destroy() def run(self) -> None: self.root.mainloop() def main() -> int: parser = argparse.ArgumentParser( description="ESP32 camera + local (on-device) vision UI" ) parser.add_argument("--ip", default="192.168.1.178") parser.add_argument("--fps", type=float, default=10.0) parser.add_argument("--timeout", type=float, default=2.0) parser.add_argument( "--target", default="orange_ball", help="Vision target module: orange_ball | none", ) args = parser.parse_args() detector = create_target(args.target) app = RobotApp( ip=args.ip, fps=args.fps, timeout=args.timeout, detector=detector, ) app.run() return 0 if __name__ == "__main__": raise SystemExit(main())