diff --git a/README.md b/README.md index 23cc014..389ee36 100644 --- a/README.md +++ b/README.md @@ -4,89 +4,224 @@ This project lets you capture LED positions from a camera, adjust the layout, an ![showcase](https://github.com/user-attachments/assets/01fee45c-910b-40fe-9166-78a9f6adf9c2) -(randomly dropped lights on floor) +(randomly dropped lights on the floor) + +--- + +## ✨ New Features + +* **Completely redesigned UI** +* **Smooth live webcam feed** (also visible during scanning) +* **Webcam mask overlay** to visualize brightness threshold detection +* **Automatic brightness calibration** (sets a usable default automatically) +* **Improved camera handling** + + * Prevents crashes if the camera is already in use +* **Manual LED placement** + + * Click directly on the camera image if detection fails +* **Live parameter updates** + + * Adjust settings while mapping is running +* **WLED recovery system** + + * Automatically pauses if the connection is lost + * Resume mapping without restarting + +--- ## Prerequisites -- Python 3.10+ -- Camera accessible to the host machine -- WLED controller reachable via HTTP on your network + +* Python 3.10+ +* Camera accessible to the host machine +* WLED controller reachable via HTTP on your network + +--- ## Setup + ```bash python -m venv .venv .venv\\Scripts\\activate # on Windows pip install -r requirements.txt ``` +--- + ## Running the app + ```bash python server.py ``` -Open `http://localhost:8000/` in your browser. The UI provides: -- Mapping controls (WLED host, LED count, segment, camera index, frames, brightness, skip-dim toggle) -- Timing controls (prelight/capture/postlight delays) and WLED transition (`TT` in ms) -- Live progress display while mapping runs -- Grid preview & camera scatter plot -- Buttons to generate/download `ledmap.json` and `2d-gaps.json` (client-side download) + +Open `http://localhost:8000/` in your browser. + +--- + +## UI Overview + +The updated interface provides: + +* **Device** + + * WLED host + * LED count + * Segment + * Camera +* **Capture** + + * Frames per LED + * Transition + * Brightness threshold (auto + manual adjustment) + * Skip-dim toggle + * Prelight / Capture / Postlight delays +* **Live scan view** + + * Brightness mask overlay + * Camera scatter plot +* **Manual interaction** + + * Click to place LEDs if detection fails +* **Live progress display** +* **Grid preview editor** +* **Download tools** + + * `ledmap.json` + * `2d-gaps.json` + +--- ## Usage Guide ### 1) Prepare WLED -- Ensure the controller is reachable (open its UI, e.g., `http://10.0.0.50/`). -- Use a single segment covering the entire strip (start 0, length = LED count). -- Use 1D LED layout in WLED (disable 2D matrix/panels). The mapper addresses LEDs linearly via `/win` using `S/S2`. -- Disable any existing LED map in WLED. Existing remaps will break index-to-LED matching. + +* Ensure the controller is reachable (open its UI, e.g., `http://10.0.0.50/`). +* Use a single segment covering the entire strip (start 0, length = LED count). +* Use a 1D LED layout in WLED (disable 2D matrix/panels). +* Disable any existing LED map in WLED. + +--- ### 2) Map LEDs + 1. Open `http://localhost:8000/`. -2. In Controls, set: - - Host (e.g., `http://10.0.0.50`) - - LEDs (your strip length) - - Segment (usually 0) - - Camera (index for your system) - - Frames/LED + Min Bright (detection tuning); enable **Skip dim LEDs** to move past LEDs that never cross the threshold - - Prelight/Capture/Postlight delays (stability tuning) - - Transition (ms) — WLED `TT`, set 0 for instant changes -3. Click **Start Mapping**. The backend will: - - Turn all LEDs off → light a single LED → off (repeats for each LED) - - Capture camera frames and store positions in `data/mapping.csv` -4. When done, **Reload CSV** if needed to refresh the UI camera/grid views. + +2. Configure: + + * Host (e.g., `10.0.0.50`) + * LEDs (strip length) + * Segment (usually 0) + * Camera + * Frames per LED (2–8 recommended) + * Transition (usually 0) + * Click the "Auto" button next to the brightness slider. Make sure the mask in the preview is enabled and fully covers the image with the LEDs turned off. + * Enable **Skip dim LEDs** if desired (not recommended, as you'll be prompted if detection fails) + * Adjust timing (default is very fast— increase for more accuracy) + +3. Click **Start Mapping** + +#### During Mapping: + +* Live webcam feed stays visible and smooth +* Brightness mask overlay shows detection threshold +* You can: + + * Adjust parameters live + * Manually click LED positions or skip if detection fails for individual LEDs (only if Auto-Skip is disabled) +* If WLED disconnects: + + * Mapping pauses automatically + * Fix the connection and resume without restarting + +4. Mapping data is saved to: + +``` +data/mapping.csv +``` + +--- ### 3) Shape the grid and export -- Adjust Step/Width/Height as needed. -- Click **Apply** to re-compute the grid and update the preview. -- Click **Generate ledmap.json** to download the LED map formatted for WLED (flattened `map` with `width`/`height`). -- Click **Download 2D gaps** to download a `1/-1` grid mask. -Notes: -- `ledmap.json` and `2d-gaps.json` are not saved on the server; they download in your browser. The raw capture stays in `data/mapping.csv`. -- The UI toggle “Wire LEDs (in order)” draws a red line connecting LEDs 0→N in camera space for quick sanity checks. +* Adjust: + + * Step + * Width + * Height + +Export options: + +* **Generate ledmap.json** + + * WLED-compatible map (`map`, `width`, `height`) +* **Download 2D gaps** + + * `1/-1` grid mask + +> Files are downloaded in the browser (not saved server-side) + +--- + +## CLI usage (UNTESTED SINCE LAST UPDATE, MIGHT BE BROKEN) + +Run mapping directly: -## CLI usage -You can run the mapping script directly: ```bash python scripts/map_leds.py http://wled.local 150 --camera-index 1 --output data/mapping.csv ``` -Add `--skip-dim-leds` to continue past LEDs that stay under the minimum brightness threshold. -and convert to JSON: +Optional: + +```bash +--skip-dim-leds +``` + +Convert to JSON: + ```bash python scripts/convert_mapping.py --step 15 --include-meta ``` +--- + ## Troubleshooting -- **ModuleNotFoundError: fastapi** - install dependencies with `pip install -r requirements.txt`. -- **Camera not opened** - verify the camera index and close other apps using it. -- **WLED doesn’t light** - - Confirm host/IP is reachable and WLED UI opens. - - Verify a single segment spans the entire strip. - - Test manual URLs (see above) — ensure `&SS`, `&S/&S2`, `&A`, and `&TT` are present. - - Increase Prelight/Capture/Postlight delays if your camera needs more time. + +### ModuleNotFoundError: fastapi + +```bash +pip install -r requirements.txt +``` + +### Camera not opened + +* Verify camera index +* Close other apps using the camera + +### WLED doesn’t light + +* Verify host/IP is reachable +* Ensure correct segment setup +* Test manual `/win` calls +* Increase timing delays if needed + +### WLED disconnects mid-scan + +* Mapping pauses automatically +* Reconnect/reset WLED and validate connectivity by opening the WLED Web UI +* Resume mapping without losing progress + +--- ## Project structure -- Backend API: `server.py` -- Mapping logic: `scripts/map_leds.py` -- Conversion/trimming logic: `scripts/convert_mapping.py` -- Frontend UI: `ui/led_viewer.html` -Contributions are welcome! Fork, branch, and open a PR. +* Backend API: `server.py` +* Mapping logic: `scripts/map_leds.py` +* Conversion: `scripts/convert_mapping.py` +* Frontend UI: `ui/led_viewer.html` + +--- + +## Contributing + +Contributions are welcome! +Fork the repo, create a branch, and open a PR 🚀 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 +
-
-
- - - - - - - - - - - - - - - - + + +