From 4f40c5aa9c94bc237b361a7ec0d57921d289da12 Mon Sep 17 00:00:00 2001 From: Zango Date: Mon, 30 Mar 2026 01:02:59 +0200 Subject: [PATCH 1/2] MANY CHANGES AND FEATURES - Completely new UI. - Webcam image now very smooth and displays also during scan. - Webcam mask overlay to visualize brightness threshold. - Automatic brightness setting (set the brightness to a useable value automatically, adjust from there if needed) - Fixed camera scan routine so it won't crash the app or hang if a camera is "in use" by another app on the system. - Manual LED placement if "Auto-Skip" is not enabled (you can click on the camera image to set the led location manually if the script can't find it) - Live parameter changes (change settings live during an active scan) - WLED recovery (if WLED looses connection or hangs the app pauses automatically so you can reset / reconnect your WLED device and continue mapping from there without starting all over again) --- scripts/map_leds.py | 102 +++- server.py | 572 ++++++++++++++++++- ui/led_viewer.html | 1311 +++++++++++++++++++++++++++++++++++-------- 3 files changed, 1719 insertions(+), 266 deletions(-) diff --git a/scripts/map_leds.py b/scripts/map_leds.py index 18996c1..ab753db 100644 --- a/scripts/map_leds.py +++ b/scripts/map_leds.py @@ -49,6 +49,16 @@ class MappingConfig: timeout: float = 3.0 sleep_every: int = 25 cooldown: float = 2.0 + # Optional shared frame source: callable () -> np.ndarray | None + # When provided, the mapping skips opening its own camera. + frame_source: Optional[Callable[[], Optional[Any]]] = None + # Optional callback invoked when a LED cannot be located after all retries. + # Receives the LED index; returns (x, y) to place manually, or None to skip. + # When None the mapping raises as before. + manual_override_fn: Optional[Callable[[int], Optional[Tuple[float, float]]]] = None + # Optional callback invoked when a WLED HTTP request fails (timeout/error). + # Called with the exception message; returns True to retry, False to abort. + wled_retry_fn: Optional[Callable[[str], bool]] = None @dataclass @@ -229,12 +239,21 @@ def send_active_led( def collect_brightest_points( - cap: cv2.VideoCapture, camera_index: int, frames: int -) -> Tuple[cv2.VideoCapture, List[Tuple[float, Tuple[int, int]]]]: + cap: Optional[cv2.VideoCapture], + camera_index: int, + frames: int, + frame_source: Optional[Callable[[], Optional[Any]]] = None, +) -> Tuple[Optional[cv2.VideoCapture], List[Tuple[float, Tuple[int, int]]]]: readings: List[Tuple[float, Tuple[int, int]]] = [] current_cap = cap for _ in range(frames): - current_cap, frame = _read_frame(current_cap, camera_index) + if frame_source is not None: + frame = frame_source() + if frame is None: + time.sleep(0.05) + continue + else: + current_cap, frame = _read_frame(current_cap, camera_index) gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) blurred = cv2.GaussianBlur(gray, (9, 9), 0) _, max_val, _, max_loc = cv2.minMaxLoc(blurred) @@ -304,24 +323,77 @@ def _read_frame(cap: cv2.VideoCapture, camera_index: int) -> Tuple[cv2.VideoCapt +def _try_manual_override( + config: "MappingConfig", + led: int, + hook: Optional["ProgressHook"], + results: list, +) -> bool: + """Call ``config.manual_override_fn`` when all retries for *led* are exhausted. + + Returns True if the LED was handled (skip or manual placement) so the caller + can ``break`` out of the retry loop. Returns False when no override function + is configured, signalling the caller should ``raise`` the original exception. + """ + if config.manual_override_fn is None: + return False + override = config.manual_override_fn(led) + if override is None: + LOGGER.info("LED %s skipped via manual override", led) + if hook: + hook(led, "skipped", {"reason": "manual skip"}) + else: + x_ov, y_ov = override + point = { + "led": led, + "x": round(float(x_ov), 2), + "y": round(float(y_ov), 2), + "brightness": 0.0, + } + results.append(point) + LOGGER.info("LED %s placed manually at (%s, %s)", led, x_ov, y_ov) + if hook: + hook(led, "captured", point) + return True + + def run_mapping( config: MappingConfig, hook: Optional[ProgressHook] = None, stop_event: Optional[Event] = None, ) -> MappingResult: state_url = build_state_url(config.host) - cap = _open_camera(config.camera_index) + cap = None if config.frame_source else _open_camera(config.camera_index) results: List[Dict[str, float]] = [] stopped = False + + def _wled_call(fn: Callable) -> None: + """Call fn(); on RequestException block until wled_retry_fn says retry or abort.""" + while True: + try: + fn() + return + except requests.RequestException as exc: + if config.wled_retry_fn is None: + raise + if stop_event and stop_event.is_set(): + raise + LOGGER.warning("WLED request failed: %s – waiting for retry signal", exc) + if hook: + hook(-1, "wled_timeout", {"error": str(exc)}) + should_retry = config.wled_retry_fn(str(exc)) + if not should_retry: + raise + try: - initialize_segment( + _wled_call(lambda: initialize_segment( state_url, config.led_count, config.timeout, config.segment_index, hook, - ) - send_active_led( + )) + _wled_call(lambda: send_active_led( state_url, config.led_count, None, @@ -329,7 +401,7 @@ def run_mapping( "initial-strip", hook, segment_index=config.segment_index, - ) + )) if config.prelight_delay > 0: time.sleep(config.prelight_delay) for led in range(config.led_count): @@ -339,7 +411,7 @@ def run_mapping( attempt = 0 while True: attempt += 1 - send_active_led( + _wled_call(lambda: send_active_led( state_url, config.led_count, led, @@ -347,11 +419,12 @@ def run_mapping( "highlight", hook, segment_index=config.segment_index, - ) + )) time.sleep(config.capture_delay) try: cap, readings = collect_brightest_points( - cap, config.camera_index, config.frames_per_led + cap, config.camera_index, config.frames_per_led, + frame_source=config.frame_source, ) x, y, peak = compute_coordinate( readings, config.top_frame_ratio, config.min_brightness @@ -390,6 +463,8 @@ def run_mapping( {"attempt": attempt, "max": LED_RETRY_ATTEMPTS, "reason": str(exc)}, ) if attempt >= LED_RETRY_ATTEMPTS: + if _try_manual_override(config, led, hook, results): + break raise time.sleep(max(config.prelight_delay, 0.05)) continue @@ -408,6 +483,8 @@ def run_mapping( {"attempt": attempt, "max": LED_RETRY_ATTEMPTS, "reason": str(exc)}, ) if attempt >= LED_RETRY_ATTEMPTS: + if _try_manual_override(config, led, hook, results): + break raise time.sleep(max(config.prelight_delay, 0.05)) continue @@ -421,7 +498,8 @@ def run_mapping( if config.sleep_every and (led + 1) % config.sleep_every == 0: time.sleep(config.cooldown) finally: - cap.release() + if cap is not None: + cap.release() try: send_active_led( state_url, diff --git a/server.py b/server.py index 26b3437..302c297 100644 --- a/server.py +++ b/server.py @@ -1,16 +1,20 @@ """Local web server that exposes mapping controls and conversion helpers.""" import csv +import os import sys import threading import time from pathlib import Path from typing import Dict, List, Optional +os.environ.setdefault("OPENCV_LOG_LEVEL", "SILENT") # suppress noisy backend probe messages +os.environ.setdefault("OPENCV_VIDEOIO_PRIORITY_OBSENSOR", "0") # disable depth-camera plugin that errors on normal indices +os.environ.setdefault("OPENCV_VIDEOIO_PRIORITY_FFMPEG", "0") # disable FFMPEG for capture (avoid DSHOW enumeration warnings) import cv2 from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import RedirectResponse, Response +from fastapi.responses import RedirectResponse, Response, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel @@ -78,7 +82,7 @@ class MapRequest(BaseModel): top_frame_ratio: float = 0.4 - min_brightness: float = 30.0 + min_brightness: float = 200.0 skip_dim_leds: bool = False timeout: float = 3.0 @@ -99,6 +103,33 @@ class ConvertRequest(BaseModel): include_meta: bool = True +class UpdateConfigRequest(BaseModel): + frames_per_led: Optional[int] = None + transition_ms: Optional[int] = None + prelight_delay: Optional[float] = None + capture_delay: Optional[float] = None + postlight_delay: Optional[float] = None + min_brightness: Optional[float] = None + skip_dim_leds: Optional[bool] = None + + +class ResolveLedRequest(BaseModel): + """Body for POST /api/resolve_led. + + Send ``skip=True`` to skip the LED, or leave skip=False and provide + ``norm_x`` / ``norm_y`` (0-1 normalised position inside the camera preview + image: norm_x = horizontal, norm_y = vertical). + """ + + skip: bool = False + norm_x: Optional[float] = None # horizontal 0-1 + norm_y: Optional[float] = None # vertical 0-1 + + +# Live reference to the active MappingConfig so it can be patched mid-run +_ACTIVE_CONFIG: Optional[MappingConfig] = None +_ACTIVE_CONFIG_LOCK = threading.Lock() + STATE = { "status": "idle", "message": "Ready", @@ -107,6 +138,7 @@ class ConvertRequest(BaseModel): "started_at": None, "finished_at": None, "points_captured": 0, + "waiting_led": None, # set when status == "waiting_input" } @@ -116,6 +148,15 @@ class ConvertRequest(BaseModel): LIVE_POINTS: List[Dict[str, float]] = [] STOP_EVENT = threading.Event() +# Manual override machinery — used when skip_dim_leds is False and a LED is lost +_OVERRIDE_LOCK = threading.Lock() +_OVERRIDE_EVENT = threading.Event() +_OVERRIDE_PENDING: Dict[str, object] = {} # {"active": bool, "led": int, "decision": ...} + +# WLED retry machinery — used when the WLED device stops responding mid-mapping +_WLED_RETRY_EVENT = threading.Event() +_WLED_ABORT = False # set True by stop_mapping while in wled_timeout state + def _reset_mapping_csv(): @@ -141,11 +182,129 @@ def _append_mapping_entry(entry: Dict[str, float]): writer.writerow(entry) +_CAMERA_OPEN_TIMEOUT = 3.0 # seconds per camera index before declaring it unavailable + + +class CameraManager: + """Single shared camera instance with a background frame-pump thread. + + Both the MJPEG preview stream and the mapping pipeline read from this, + so the physical camera is opened only once regardless of how many + consumers are active simultaneously. + """ + + def __init__(self, camera_index: int) -> None: + self.camera_index = camera_index + self._lock = threading.Lock() + self._latest: Optional[object] = None + self._stop = threading.Event() + cap = _try_open_camera_timed(camera_index) + if cap is None: + raise RuntimeError(f"Camera {camera_index} could not be opened") + self._cap = cap + self._seq: int = 0 # incremented on every new frame + self._thread = threading.Thread(target=self._pump, daemon=True) + self._thread.start() + + def _pump(self) -> None: + fail_streak = 0 + while not self._stop.is_set(): + # Reopen the camera if it has been closed (e.g. by a competing probe) + if not self._cap.isOpened(): + time.sleep(0.5) + new_cap = _try_open_camera(self.camera_index) + if new_cap is not None: + with self._lock: + self._cap = new_cap + fail_streak = 0 + else: + fail_streak += 1 + if fail_streak >= 6: + break # camera is genuinely gone + continue + try: + ok, frame = self._cap.read() + except Exception: # pylint: disable=broad-except + fail_streak += 1 + if fail_streak >= 10: + with self._lock: + self._cap.release() + # force reopen on next loop iteration + self._cap = cv2.VideoCapture() + fail_streak = 0 + time.sleep(0.1) + continue + if ok and frame is not None: + with self._lock: + self._latest = frame + self._seq += 1 + fail_streak = 0 + else: + fail_streak += 1 + if fail_streak >= 30: + with self._lock: + self._cap.release() + self._cap = cv2.VideoCapture() + fail_streak = 0 + time.sleep(0.005) + + def read_frame(self) -> Optional[object]: + with self._lock: + return self._latest + + def get_seq(self) -> int: + with self._lock: + return self._seq + + def read_frame_after(self, min_seq: int, timeout: float = 3.0): + """Block until a frame with seq > min_seq arrives, then return (frame, seq).""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + with self._lock: + if self._seq > min_seq: + return self._latest, self._seq + time.sleep(0.005) + return None, min_seq + + def get_frame_size(self) -> Optional[tuple]: + """Return (width, height) of the latest captured frame, or None.""" + with self._lock: + if self._latest is not None: + h, w = self._latest.shape[:2] + return w, h + return None + + def is_alive(self) -> bool: + return not self._stop.is_set() and self._cap.isOpened() and self._thread.is_alive() + + def release(self) -> None: + self._stop.set() + self._thread.join(timeout=2.0) # wait for the pump to finish its current read + with self._lock: + self._cap.release() + + +_cam_mgr: Optional[CameraManager] = None +_cam_mgr_lock = threading.Lock() + + +def _get_cam_mgr(camera_index: int) -> CameraManager: + """Return the active CameraManager, (re)creating it if needed.""" + global _cam_mgr + with _cam_mgr_lock: + if _cam_mgr is None or _cam_mgr.camera_index != camera_index or not _cam_mgr.is_alive(): + if _cam_mgr is not None: + _cam_mgr.release() + _cam_mgr = CameraManager(camera_index) + return _cam_mgr + + def _try_open_camera(camera_index: int) -> Optional[cv2.VideoCapture]: + """Open a camera by index, trying each backend. Returns the cap or None.""" if sys.platform.startswith("win"): - backends = [cv2.CAP_MSMF, cv2.CAP_DSHOW, cv2.CAP_ANY] + backends = [cv2.CAP_MSMF, cv2.CAP_DSHOW] else: @@ -170,21 +329,94 @@ def _try_open_camera(camera_index: int) -> Optional[cv2.VideoCapture]: return None -def _list_available_cameras(max_index: int) -> list[int]: +def _try_open_camera_timed(camera_index: int) -> Optional[cv2.VideoCapture]: + """Open camera with a timeout to prevent indefinite hangs. - cameras: list[int] = [] + Returns the opened VideoCapture, or None if unavailable/absent. + Sets the sentinel attribute ``timed_out`` on the returned None to distinguish + a hung camera from an absent one — but since we return None either way for the + preview endpoint, callers that need to distinguish should call + ``_probe_camera_status`` instead. + """ - for idx in range(max(1, max_index)): + result: list = [None] + exc: list = [None] - cap = _try_open_camera(idx) + def _open(): + try: + result[0] = _try_open_camera(camera_index) + except Exception as e: # pylint: disable=broad-except + exc[0] = e - if cap is None: + t = threading.Thread(target=_open, daemon=True) + t.start() + t.join(_CAMERA_OPEN_TIMEOUT) - continue + if t.is_alive(): + # Thread is still blocked — camera is hanging (likely in use or stalled) + return None - cap.release() + return result[0] + + +def _probe_camera_status(camera_index: int) -> str: + """Return 'available', 'unavailable', or 'absent' for a camera index. + + 'available' – opened and a frame can be read + 'unavailable' – appeared to exist but timed out or frame read failed + 'absent' – failed to open quickly, almost certainly no hardware present + + If the CameraManager is already streaming this index we trust it rather than + opening a competing handle, which would corrupt the active capture on Windows. + """ + + # Never compete with an active manager — opening a second handle on Windows + # (MSMF/DSHOW) corrupts the first one and causes the stream to freeze. + with _cam_mgr_lock: + if (_cam_mgr is not None + and _cam_mgr.camera_index == camera_index + and _cam_mgr.is_alive()): + return "available" - cameras.append(idx) + result: list = [None] + + def _open(): + result[0] = _try_open_camera(camera_index) + + t = threading.Thread(target=_open, daemon=True) + t.start() + t.join(_CAMERA_OPEN_TIMEOUT) + + if t.is_alive(): + return "unavailable" + + cap = result[0] + + if cap is None: + return "absent" + + ok, frame = cap.read() + cap.release() + + if not ok or frame is None: + return "unavailable" + + return "available" + + +def _list_available_cameras(max_index: int) -> list[dict]: + """Scan camera indices and return a list of dicts with 'index' and 'available'.""" + + cameras: list[dict] = [] + + for idx in range(max(1, max_index)): + + status = _probe_camera_status(idx) + + if status == "absent": + continue + + cameras.append({"index": idx, "available": status == "available"}) return cameras @@ -249,9 +481,66 @@ def _progress_hook(led_index: int, stage: str, payload): ) +def _manual_override_fn(led_index: int): + """Block the mapping thread and wait for the operator to resolve a lost LED. + + Returns (x, y) if the operator clicked a position, or None to skip the LED. + Also returns None if the stop event fires while waiting. + """ + with _OVERRIDE_LOCK: + _OVERRIDE_PENDING.clear() + _OVERRIDE_PENDING.update({"active": True, "led": led_index, "decision": None}) + _OVERRIDE_EVENT.clear() + + _update_state( + status="waiting_input", + message=f"LED {led_index} not detected – awaiting manual input", + current=led_index, + waiting_led=led_index, + ) + + # Block until the operator responds or mapping is stopped + while not _OVERRIDE_EVENT.wait(timeout=0.5): + if STOP_EVENT.is_set(): + with _OVERRIDE_LOCK: + _OVERRIDE_PENDING["active"] = False + _update_state(status="running", message="Resuming...", waiting_led=None) + return None + + with _OVERRIDE_LOCK: + _OVERRIDE_PENDING["active"] = False + decision = _OVERRIDE_PENDING.get("decision") + + _update_state(status="running", message="Resuming...", waiting_led=None) + return decision # None → skip, (x, y) → manual placement + + +def _wled_retry_fn(error_msg: str) -> bool: + """Block the mapping thread when WLED becomes unresponsive. + + Returns True if the operator clicked Retry, False if Stop was pressed. + """ + global _WLED_ABORT + _WLED_ABORT = False + _WLED_RETRY_EVENT.clear() + + _update_state( + status="wled_timeout", + message=f"WLED not responding: {error_msg}", + ) + + while not _WLED_RETRY_EVENT.wait(timeout=0.5): + if STOP_EVENT.is_set(): + _update_state(status="running", message="Stopping...") + return False + + _update_state(status="running", message="Retrying WLED request...") + return not _WLED_ABORT + + def _mapping_thread(config: MappingConfig, stop_event: threading.Event): - global RESULT + global RESULT, _ACTIVE_CONFIG try: @@ -289,6 +578,8 @@ def _mapping_thread(config: MappingConfig, stop_event: threading.Event): _update_state(status="error", message=str(exc), finished_at=time.time()) finally: stop_event.clear() + with _ACTIVE_CONFIG_LOCK: + _ACTIVE_CONFIG = None @app.post("/api/map") @@ -296,7 +587,7 @@ def start_mapping(req: MapRequest): with STATE_LOCK: - if STATE["status"] == "running": + if STATE["status"] in {"running", "starting", "waiting_input", "wled_timeout"}: raise HTTPException(status_code=409, detail="Mapping already in progress") @@ -315,8 +606,36 @@ def start_mapping(req: MapRequest): LIVE_POINTS.clear() _reset_mapping_csv() STOP_EVENT.clear() + # Reset WLED-retry state so a stale event from a previous session + # cannot cause _wled_retry_fn to exit immediately. + global _WLED_ABORT + _WLED_ABORT = False + _WLED_RETRY_EVENT.clear() - config = MappingConfig(**req.model_dump()) + mapping_data = req.model_dump() + try: + mgr = _get_cam_mgr(mapping_data["camera_index"]) + # Build a stateful closure that always waits for a frame newer than the + # last one it returned, so stale/repeated frames never reach the mapper. + last_seq: List[int] = [mgr.get_seq()] + def _fresh_frame(): + frame, new_seq = mgr.read_frame_after(last_seq[0]) + last_seq[0] = new_seq + return frame + mapping_data["frame_source"] = _fresh_frame + except RuntimeError: + mapping_data["frame_source"] = None # fall back to opening own camera + # Attach the manual-override callback unless the user chose "skip dim LEDs" + # automatically (in that case, no manual intervention is desired). + if not req.skip_dim_leds: + mapping_data["manual_override_fn"] = _manual_override_fn + # Always attach the WLED retry callback so network errors pause gracefully. + mapping_data["wled_retry_fn"] = _wled_retry_fn + config = MappingConfig(**mapping_data) + + global _ACTIVE_CONFIG + with _ACTIVE_CONFIG_LOCK: + _ACTIVE_CONFIG = config thread = threading.Thread( target=_mapping_thread, args=(config, STOP_EVENT), daemon=True @@ -344,13 +663,13 @@ def get_cameras(max_index: int = CAMERA_SCAN_LIMIT): cameras = _list_available_cameras(limit) - return {"cameras": [{"index": idx} for idx in cameras], "scanned": limit} + return {"cameras": cameras, "scanned": limit} @app.get("/api/camera_preview/{camera_index}") def camera_preview(camera_index: int, width: int = 640): - cap = _try_open_camera(camera_index) + cap = _try_open_camera_timed(camera_index) if cap is None: @@ -385,6 +704,135 @@ def camera_preview(camera_index: int, width: int = 640): return Response(content=encoded.tobytes(), media_type="image/jpeg") +_MJPEG_BOUNDARY = b"--mjpegframe" +_MJPEG_MAX_FPS = 30 + + +def _apply_brightness_mask(frame, threshold: int): + """Darken pixels whose brightness is below *threshold* and tint them red. + + Pixels at or above the threshold are returned unchanged so the viewer can + see at a glance which areas will register as bright LEDs. + """ + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + _, bright_mask = cv2.threshold(gray, max(0, threshold - 1), 255, cv2.THRESH_BINARY) + dim_mask = cv2.bitwise_not(bright_mask) + # Build a darkened + red-tinted version for dim pixels + dark = cv2.convertScaleAbs(frame, alpha=0.2, beta=0) + b, g, r = cv2.split(dark) + r = cv2.add(r, 80) + dark = cv2.merge([b, g, r]) + # Composite: original colour where bright, dark+red where dim + bright_part = cv2.bitwise_and(frame, frame, mask=bright_mask) + dim_part = cv2.bitwise_and(dark, dark, mask=dim_mask) + return cv2.add(bright_part, dim_part) + + +def _mjpeg_frames(mgr: "CameraManager", target_width: int, frame_processor=None): + """Sync generator — reads from a CameraManager, yielding MJPEG frames. + + Ends the stream (causing the browser to reconnect) if the pump has been + dead for more than 5 seconds so _get_cam_mgr can create a fresh manager. + An optional *frame_processor* callable is applied to each frame before + encoding (used by the mask-preview stream). + """ + frame_interval = 1.0 / _MJPEG_MAX_FPS + last_seq = -1 + stale_since: Optional[float] = None + while True: + t0 = time.monotonic() + + with mgr._lock: + current_seq = mgr._seq + frame = mgr._latest + + if current_seq == last_seq or frame is None: + # No new frame yet — check whether the pump is still alive + if not mgr.is_alive(): + if stale_since is None: + stale_since = t0 + elif t0 - stale_since > 5.0: + break # pump is gone; end stream so browser reconnects + time.sleep(0.01) + continue + + last_seq = current_seq + stale_since = None + + h, w = frame.shape[:2] + if w != target_width: + scale = target_width / max(1, w) + frame = cv2.resize(frame, (target_width, max(1, int(h * scale)))) + if frame_processor is not None: + frame = frame_processor(frame) + ok, encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 80]) + if not ok: + continue + data = encoded.tobytes() + yield ( + _MJPEG_BOUNDARY + b"\r\n" + + b"Content-Type: image/jpeg\r\n" + + b"Content-Length: " + str(len(data)).encode() + b"\r\n\r\n" + + data + b"\r\n" + ) + elapsed = time.monotonic() - t0 + remaining = frame_interval - elapsed + if remaining > 0: + time.sleep(remaining) + + +@app.get("/api/camera_peak_brightness/{camera_index}") +def camera_peak_brightness(camera_index: int): + """Return the brightest pixel value in the latest camera frame.""" + try: + mgr = _get_cam_mgr(camera_index) + except RuntimeError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + frame = mgr.read_frame() + if frame is None: + raise HTTPException(status_code=503, detail="No frame available") + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + peak = int(gray.max()) + return {"peak": peak} + + +@app.get("/api/camera_stream/{camera_index}") +def camera_stream(camera_index: int, width: int = 640): + try: + mgr = _get_cam_mgr(camera_index) + except RuntimeError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + target_width = max(160, min(width, 1280)) + return StreamingResponse( + _mjpeg_frames(mgr, target_width), + media_type="multipart/x-mixed-replace; boundary=mjpegframe", + headers={"Cache-Control": "no-cache, no-store"}, + ) + + +@app.get("/api/camera_mask_stream/{camera_index}") +def camera_mask_stream(camera_index: int, width: int = 640, threshold: int = 200): + """MJPEG stream identical to camera_stream but with a brightness-mask overlay. + + Pixels whose brightness is below *threshold* are darkened and tinted red so + the operator can see at a glance which areas will register as lit LEDs. + The underlying CameraManager frame buffer is shared with the plain stream and + the mapping pipeline — the mask is applied only to the encoded preview frames. + """ + try: + mgr = _get_cam_mgr(camera_index) + except RuntimeError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + target_width = max(160, min(width, 1280)) + thr = max(0, min(threshold, 255)) + processor = lambda f: _apply_brightness_mask(f, thr) # noqa: E731 + return StreamingResponse( + _mjpeg_frames(mgr, target_width, frame_processor=processor), + media_type="multipart/x-mixed-replace; boundary=mjpegframe", + headers={"Cache-Control": "no-cache, no-store"}, + ) + + @app.get("/api/live_points") def get_live_points(after: int = -1): @@ -408,15 +856,94 @@ def stop_mapping(): with STATE_LOCK: - if STATE["status"] not in {"running", "starting"}: + if STATE["status"] not in {"running", "starting", "waiting_input", "wled_timeout"}: raise HTTPException(status_code=409, detail="No mapping in progress") STOP_EVENT.set() + # Unblock any blocking waits so the mapping thread can exit cleanly + _OVERRIDE_EVENT.set() + global _WLED_ABORT + _WLED_ABORT = True + _WLED_RETRY_EVENT.set() return {"ok": True} +@app.post("/api/update_config") +def update_config(req: UpdateConfigRequest): + """Patch the live MappingConfig while a capture is in progress.""" + with _ACTIVE_CONFIG_LOCK: + if _ACTIVE_CONFIG is None: + raise HTTPException(status_code=409, detail="No mapping in progress") + if req.frames_per_led is not None: + _ACTIVE_CONFIG.frames_per_led = req.frames_per_led + if req.transition_ms is not None: + _ACTIVE_CONFIG.transition_ms = req.transition_ms + if req.prelight_delay is not None: + _ACTIVE_CONFIG.prelight_delay = req.prelight_delay + if req.capture_delay is not None: + _ACTIVE_CONFIG.capture_delay = req.capture_delay + if req.postlight_delay is not None: + _ACTIVE_CONFIG.postlight_delay = req.postlight_delay + if req.min_brightness is not None: + _ACTIVE_CONFIG.min_brightness = req.min_brightness + if req.skip_dim_leds is not None: + _ACTIVE_CONFIG.skip_dim_leds = req.skip_dim_leds + _ACTIVE_CONFIG.manual_override_fn = ( + None if req.skip_dim_leds else _manual_override_fn + ) + return {"ok": True} + + +@app.post("/api/resolve_led") +def resolve_led(req: ResolveLedRequest): + """Resolve a waiting_input pause. + + Send ``{"skip": true}`` to skip the LED, or provide ``click_x``/``click_y`` + (pixel position inside the 640-px-wide MJPEG preview image) to place it. + """ + with _OVERRIDE_LOCK: + if not _OVERRIDE_PENDING.get("active"): + raise HTTPException(status_code=409, detail="No LED override pending") + + if req.skip or req.norm_x is None or req.norm_y is None: + _OVERRIDE_PENDING["decision"] = None # None → skip + else: + # Convert normalised 0-1 click position to the coordinate space that + # Camera Space renders: p.x = item.x (canvas horizontal) and + # p.y = item.y (canvas vertical). The camera preview is displayed + # with horizontal = norm_x and vertical = norm_y, so we map directly: + # item.x ← norm_x * frame_width + # item.y ← norm_y * frame_height + native_w, native_h = 640, 480 # safe defaults + with _cam_mgr_lock: + mgr = _cam_mgr + if mgr is not None: + size = mgr.get_frame_size() + if size is not None: + native_w, native_h = size + _OVERRIDE_PENDING["decision"] = ( + req.norm_x * native_w, # item.x ← horizontal click * width + req.norm_y * native_h, # item.y ← vertical click * height + ) + + _OVERRIDE_EVENT.set() + return {"ok": True} + + +@app.post("/api/retry_wled") +def retry_wled(): + """Resume mapping after the operator has fixed the WLED device.""" + with STATE_LOCK: + if STATE["status"] != "wled_timeout": + raise HTTPException(status_code=409, detail="Not in WLED timeout state") + global _WLED_ABORT + _WLED_ABORT = False + _WLED_RETRY_EVENT.set() + return {"ok": True} + + @app.get("/api/result") def get_result(): @@ -431,12 +958,15 @@ def get_result(): def convert(req: ConvertRequest): if not MAPPING_CSV.exists(): + raise HTTPException(status_code=404, detail="mapping.csv not found — run capture first") + try: + rows = load_mapping(MAPPING_CSV) + except Exception as exc: raise HTTPException( - status_code=404, detail="mapping.csv not found, run capture first" - ) - - rows = load_mapping(MAPPING_CSV) + status_code=404, + detail=f"mapping.csv is empty or damaged — run capture again ({exc})", + ) from exc if req.step is None and req.width is None and req.height is None: diff --git a/ui/led_viewer.html b/ui/led_viewer.html index 86c2904..5071fb5 100644 --- a/ui/led_viewer.html +++ b/ui/led_viewer.html @@ -2,110 +2,647 @@ - WLED Mapping Viewer - - - + WLED Mapper +
-
-
- - - - - - - - - - - - - - - - + + +