diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py
index 50a304e..0b17de1 100644
--- a/server/src/wled_controller/api/routes/picture_targets.py
+++ b/server/src/wled_controller/api/routes/picture_targets.py
@@ -789,3 +789,71 @@ async def target_colors_ws(
pass
finally:
manager.remove_kc_ws_client(target_id, websocket)
+
+
+# ===== OVERLAY VISUALIZATION =====
+
+@router.post("/api/v1/picture-targets/{target_id}/overlay/start", tags=["Visualization"])
+async def start_target_overlay(
+ target_id: str,
+ _auth: AuthRequired,
+ manager: ProcessorManager = Depends(get_processor_manager),
+ target_store: PictureTargetStore = Depends(get_picture_target_store),
+):
+ """Start screen overlay visualization for a target.
+
+ Displays a transparent overlay on the target display showing:
+ - Border sampling zones (colored rectangles)
+ - LED position markers (numbered dots)
+ - Pixel-to-LED mapping ranges (colored segments)
+ - Calibration info text
+ """
+ try:
+ # Get target name from store
+ target = target_store.get_target(target_id)
+ if not target:
+ raise ValueError(f"Target {target_id} not found")
+
+ await manager.start_overlay(target_id, target.name)
+ return {"status": "started", "target_id": target_id}
+
+ except ValueError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+ except RuntimeError as e:
+ raise HTTPException(status_code=409, detail=str(e))
+ except Exception as e:
+ logger.error(f"Failed to start overlay: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/api/v1/picture-targets/{target_id}/overlay/stop", tags=["Visualization"])
+async def stop_target_overlay(
+ target_id: str,
+ _auth: AuthRequired,
+ manager: ProcessorManager = Depends(get_processor_manager),
+):
+ """Stop screen overlay visualization for a target."""
+ try:
+ await manager.stop_overlay(target_id)
+ return {"status": "stopped", "target_id": target_id}
+
+ except ValueError as e:
+ raise HTTPException(status_code=404, detail=str(e))
+ except Exception as e:
+ logger.error(f"Failed to stop overlay: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/api/v1/picture-targets/{target_id}/overlay/status", tags=["Visualization"])
+async def get_overlay_status(
+ target_id: str,
+ _auth: AuthRequired,
+ manager: ProcessorManager = Depends(get_processor_manager),
+):
+ """Check if overlay is active for a target."""
+ try:
+ active = manager.is_overlay_active(target_id)
+ return {"target_id": target_id, "active": active}
+
+ except ValueError as e:
+ raise HTTPException(status_code=404, detail=str(e))
diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py
index 5d2c955..a733742 100644
--- a/server/src/wled_controller/api/schemas/picture_targets.py
+++ b/server/src/wled_controller/api/schemas/picture_targets.py
@@ -134,6 +134,7 @@ class TargetProcessingState(BaseModel):
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
timing_total_ms: Optional[float] = Field(None, description="Total processing time (ms)")
display_index: int = Field(default=0, description="Current display index")
+ overlay_active: bool = Field(default=False, description="Whether visualization overlay is active")
last_update: Optional[datetime] = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors")
device_online: bool = Field(default=False, description="Whether device is reachable")
diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py
index c7b2999..6adfce3 100644
--- a/server/src/wled_controller/core/processor_manager.py
+++ b/server/src/wled_controller/core/processor_manager.py
@@ -31,6 +31,8 @@ from wled_controller.core.led_client import (
check_device_health,
create_led_client,
)
+from wled_controller.core.screen_overlay import OverlayManager
+from wled_controller.core.screen_capture import get_available_displays
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -185,6 +187,8 @@ class TargetState:
live_stream: Optional[LiveStream] = None
# Device state snapshot taken before streaming starts (to restore on stop)
device_state_before: Optional[dict] = None
+ # Overlay visualization state
+ overlay_active: bool = False
@dataclass
@@ -226,6 +230,7 @@ class ProcessorManager:
self._live_stream_manager = LiveStreamManager(
picture_source_store, capture_template_store, pp_template_store
)
+ self._overlay_manager = OverlayManager()
logger.info("Processor manager initialized")
async def _get_http_client(self) -> httpx.AsyncClient:
@@ -847,6 +852,7 @@ class ProcessorManager:
"timing_send_ms": round(metrics.timing_send_ms, 1) if state.is_running else None,
"timing_total_ms": round(metrics.timing_total_ms, 1) if state.is_running else None,
"display_index": state.resolved_display_index if state.resolved_display_index is not None else state.settings.display_index,
+ "overlay_active": state.overlay_active,
"last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [],
**health_info,
@@ -897,6 +903,68 @@ class ProcessorManager:
return ts.target_id
return None
+ # ===== OVERLAY VISUALIZATION =====
+
+ async def start_overlay(self, target_id: str, target_name: str = None) -> None:
+ """Start screen overlay visualization for a target."""
+ if target_id not in self._targets:
+ raise ValueError(f"Target {target_id} not found")
+
+ state = self._targets[target_id]
+
+ if state.overlay_active:
+ raise RuntimeError(f"Overlay already active for {target_id}")
+
+ # Get device for calibration
+ if state.device_id not in self._devices:
+ raise ValueError(f"Device {state.device_id} not found")
+
+ device_state = self._devices[state.device_id]
+ calibration = device_state.calibration
+
+ # Get display info
+ display_index = state.resolved_display_index or state.settings.display_index
+ displays = get_available_displays()
+
+ if display_index >= len(displays):
+ raise ValueError(f"Invalid display index {display_index}")
+
+ display_info = displays[display_index]
+
+ # Start overlay in background thread
+ await asyncio.to_thread(
+ self._overlay_manager.start_overlay,
+ target_id, display_info, calibration, target_name
+ )
+
+ state.overlay_active = True
+ logger.info(f"Started overlay for target {target_id}")
+
+ async def stop_overlay(self, target_id: str) -> None:
+ """Stop screen overlay visualization for a target."""
+ if target_id not in self._targets:
+ raise ValueError(f"Target {target_id} not found")
+
+ state = self._targets[target_id]
+
+ if not state.overlay_active:
+ logger.warning(f"Overlay not active for {target_id}")
+ return
+
+ await asyncio.to_thread(
+ self._overlay_manager.stop_overlay,
+ target_id
+ )
+
+ state.overlay_active = False
+ logger.info(f"Stopped overlay for target {target_id}")
+
+ def is_overlay_active(self, target_id: str) -> bool:
+ """Check if overlay is active for a target."""
+ if target_id not in self._targets:
+ raise ValueError(f"Target {target_id} not found")
+ return self._targets[target_id].overlay_active
+
# ===== CALIBRATION TEST MODE (on device) =====
async def set_test_mode(self, device_id: str, edges: Dict[str, List[int]]) -> None:
diff --git a/server/src/wled_controller/core/screen_overlay.py b/server/src/wled_controller/core/screen_overlay.py
new file mode 100644
index 0000000..a961bc6
--- /dev/null
+++ b/server/src/wled_controller/core/screen_overlay.py
@@ -0,0 +1,640 @@
+"""Screen overlay visualization for LED calibration testing."""
+
+import colorsys
+import logging
+import sys
+import threading
+import time
+import tkinter as tk
+from typing import Dict, List, Tuple, Optional
+
+from wled_controller.core.calibration import CalibrationConfig
+from wled_controller.core.capture_engines.base import DisplayInfo
+
+logger = logging.getLogger(__name__)
+
+
+class OverlayWindow:
+ """Transparent overlay window for LED calibration visualization.
+
+ Runs in a separate thread with its own Tkinter event loop.
+ Draws border sampling zones, LED position markers, pixel mapping ranges,
+ and calibration info text.
+ """
+
+ def __init__(
+ self,
+ display_info: DisplayInfo,
+ calibration: CalibrationConfig,
+ target_id: str,
+ target_name: str = None
+ ):
+ """Initialize overlay for a specific display and calibration.
+
+ Args:
+ display_info: Display geometry (x, y, width, height)
+ calibration: LED calibration configuration
+ target_id: Target ID for logging
+ target_name: Target friendly name for display
+ """
+ self.display_info = display_info
+ self.calibration = calibration
+ self.target_id = target_id
+ self.target_name = target_name or target_id
+ self.root: Optional[tk.Tk] = None
+ self.canvas: Optional[tk.Canvas] = None
+ self.running = False
+ self._thread: Optional[threading.Thread] = None
+ self._stop_event = threading.Event()
+ self._after_id: Optional[str] = None
+
+ def start(self) -> None:
+ """Start overlay in background thread."""
+ if self._thread and self._thread.is_alive():
+ raise RuntimeError("Overlay already running")
+
+ self._stop_event.clear()
+ self._thread = threading.Thread(
+ target=self._run_tkinter_loop,
+ name=f"Overlay-{self.target_id}",
+ daemon=True
+ )
+ self._thread.start()
+
+ def stop(self) -> None:
+ """Stop overlay and clean up thread."""
+ self._stop_event.set()
+ # Wait for the tkinter thread to fully exit
+ if self._thread and self._thread.is_alive():
+ self._thread.join(timeout=3.0)
+ # Give extra time for Tcl interpreter cleanup
+ import time
+ time.sleep(0.2)
+
+ def _run_tkinter_loop(self) -> None:
+ """Tkinter event loop (runs in background thread)."""
+ try:
+ self.root = tk.Tk()
+ self._setup_window()
+ self._draw_visualization()
+
+ # Check stop event periodically
+ def check_stop():
+ if self._stop_event.is_set():
+ # Cancel the pending after callback before quitting
+ if self._after_id:
+ try:
+ self.root.after_cancel(self._after_id)
+ except Exception:
+ pass
+ self.root.quit() # Exit mainloop cleanly
+ else:
+ self._after_id = self.root.after(100, check_stop)
+
+ self._after_id = self.root.after(100, check_stop)
+ self.running = True
+ logger.info(f"Overlay window started for {self.target_id}")
+ self.root.mainloop()
+ except Exception as e:
+ logger.error(f"Overlay error for {self.target_id}: {e}", exc_info=True)
+ finally:
+ self.running = False
+ # Clean up the window properly
+ if self.root:
+ try:
+ # Cancel any remaining after callbacks
+ if self._after_id:
+ self.root.after_cancel(self._after_id)
+ self._after_id = None
+ # Destroy window and clean up Tcl interpreter
+ self.root.destroy()
+ self.root = None
+ except Exception as e:
+ logger.debug(f"Cleanup error: {e}")
+ logger.info(f"Overlay window stopped for {self.target_id}")
+
+ def _setup_window(self) -> None:
+ """Configure transparent, frameless, always-on-top window."""
+ # Position at display coordinates
+ geometry = f"{self.display_info.width}x{self.display_info.height}"
+ geometry += f"+{self.display_info.x}+{self.display_info.y}"
+ self.root.geometry(geometry)
+
+ # Remove window decorations
+ self.root.overrideredirect(True)
+
+ # Transparent background and always on top
+ self.root.attributes('-topmost', True)
+
+ # Create canvas for drawing
+ self.canvas = tk.Canvas(
+ self.root,
+ width=self.display_info.width,
+ height=self.display_info.height,
+ highlightthickness=0,
+ bg='black'
+ )
+ self.canvas.pack()
+
+ # Make canvas itself transparent
+ self.root.wm_attributes('-transparentcolor', 'black')
+ self.root.attributes('-alpha', 0.85) # Semi-transparent overlay
+
+ # Windows-specific: make click-through
+ if sys.platform == 'win32':
+ try:
+ import ctypes
+ # Get window handle
+ hwnd = ctypes.windll.user32.GetParent(self.root.winfo_id())
+ # Set WS_EX_LAYERED | WS_EX_TRANSPARENT extended styles
+ style = ctypes.windll.user32.GetWindowLongW(hwnd, -20) # GWL_EXSTYLE
+ style |= 0x80000 | 0x20 # WS_EX_LAYERED | WS_EX_TRANSPARENT
+ ctypes.windll.user32.SetWindowLongW(hwnd, -20, style)
+ except Exception as e:
+ logger.warning(f"Could not set click-through: {e}")
+
+ def _draw_visualization(self) -> None:
+ """Draw all visualization elements on canvas."""
+ w = self.display_info.width
+ h = self.display_info.height
+ bw = self.calibration.border_width
+
+ # 1. Border sampling zones (colored semi-transparent rectangles)
+ self._draw_border_zones(w, h, bw)
+
+ # 2. LED axes with tick marks
+ self._draw_led_axes(w, h, bw)
+
+ # 3. Calibration info text overlay
+ self._draw_info_text(w, h)
+
+ def _draw_border_zones(self, w: int, h: int, bw: int) -> None:
+ """Draw colored rectangles showing border sampling zones."""
+ # Top zone
+ self.canvas.create_rectangle(
+ 0, 0, w, bw,
+ fill='#FF0000', stipple='gray25', outline='#FF0000', width=2
+ )
+ # Right zone
+ self.canvas.create_rectangle(
+ w - bw, 0, w, h,
+ fill='#00FF00', stipple='gray25', outline='#00FF00', width=2
+ )
+ # Bottom zone
+ self.canvas.create_rectangle(
+ 0, h - bw, w, h,
+ fill='#0000FF', stipple='gray25', outline='#0000FF', width=2
+ )
+ # Left zone
+ self.canvas.create_rectangle(
+ 0, 0, bw, h,
+ fill='#FFFF00', stipple='gray25', outline='#FFFF00', width=2
+ )
+
+ def _draw_led_axes(self, w: int, h: int, bw: int) -> None:
+ """Draw axes with tick marks showing LED positions."""
+ segments = self.calibration.segments
+ total_leds = self.calibration.get_total_leds()
+
+ # Determine tick interval based on total LEDs
+ if total_leds <= 50:
+ tick_interval = 5
+ elif total_leds <= 100:
+ tick_interval = 10
+ elif total_leds <= 200:
+ tick_interval = 20
+ else:
+ tick_interval = 50
+
+ led_index = 0
+ for seg in segments:
+ edge = seg.edge
+ count = seg.led_count
+ reverse = seg.reverse
+
+ # Get span for this edge
+ span_start, span_end = self.calibration.get_edge_span(edge)
+
+ # Draw axis line and tick marks for this edge
+ if edge == 'top':
+ edge_len = w
+ start_px = span_start * edge_len
+ end_px = span_end * edge_len
+ y_axis = bw / 2
+
+ # Draw axis line
+ self.canvas.create_line(
+ start_px, y_axis, end_px, y_axis,
+ fill='white', width=3
+ )
+
+ # Draw tick marks
+ for i in range(count):
+ display_index = (led_index + self.calibration.offset) % total_leds
+
+ if i == 0 or i == count - 1 or display_index % tick_interval == 0:
+ frac = i / count if count > 1 else 0.5
+ x = start_px + frac * (end_px - start_px)
+
+ # Tick mark
+ tick_len = 15 if display_index % tick_interval == 0 else 10
+ self.canvas.create_line(
+ x, y_axis - tick_len, x, y_axis + tick_len,
+ fill='white', width=2
+ )
+
+ # Label with background (positioned INSIDE screen)
+ label_y = bw + 20
+ self.canvas.create_rectangle(
+ x - 30, label_y - 12, x + 30, label_y + 12,
+ fill='white', outline='black', width=2
+ )
+ self.canvas.create_text(
+ x, label_y,
+ text=str(display_index),
+ fill='black',
+ font=('Arial', 13, 'bold')
+ )
+
+ led_index += 1
+
+ elif edge == 'bottom':
+ edge_len = w
+ start_px = span_start * edge_len
+ end_px = span_end * edge_len
+ y_axis = h - bw / 2
+
+ # Draw axis line
+ self.canvas.create_line(
+ start_px, y_axis, end_px, y_axis,
+ fill='white', width=3
+ )
+
+ # Draw tick marks
+ for i in range(count):
+ display_index = (led_index + self.calibration.offset) % total_leds
+
+ if i == 0 or i == count - 1 or display_index % tick_interval == 0:
+ frac = i / count if count > 1 else 0.5
+ x = start_px + frac * (end_px - start_px)
+
+ # Tick mark
+ tick_len = 15 if display_index % tick_interval == 0 else 10
+ self.canvas.create_line(
+ x, y_axis - tick_len, x, y_axis + tick_len,
+ fill='white', width=2
+ )
+
+ # Label with background (positioned INSIDE screen)
+ label_y = h - bw - 20
+ self.canvas.create_rectangle(
+ x - 30, label_y - 12, x + 30, label_y + 12,
+ fill='white', outline='black', width=2
+ )
+ self.canvas.create_text(
+ x, label_y,
+ text=str(display_index),
+ fill='black',
+ font=('Arial', 13, 'bold')
+ )
+
+ led_index += 1
+
+ elif edge == 'left':
+ edge_len = h
+ start_px = span_start * edge_len
+ end_px = span_end * edge_len
+ x_axis = bw / 2
+
+ # Draw axis line
+ self.canvas.create_line(
+ x_axis, start_px, x_axis, end_px,
+ fill='white', width=3
+ )
+
+ # Draw tick marks
+ for i in range(count):
+ display_index = (led_index + self.calibration.offset) % total_leds
+
+ if i == 0 or i == count - 1 or display_index % tick_interval == 0:
+ frac = i / count if count > 1 else 0.5
+ y = start_px + frac * (end_px - start_px)
+
+ # Tick mark
+ tick_len = 15 if display_index % tick_interval == 0 else 10
+ self.canvas.create_line(
+ x_axis - tick_len, y, x_axis + tick_len, y,
+ fill='white', width=2
+ )
+
+ # Label with background (positioned INSIDE screen)
+ label_x = bw + 40
+ self.canvas.create_rectangle(
+ label_x - 30, y - 12, label_x + 30, y + 12,
+ fill='white', outline='black', width=2
+ )
+ self.canvas.create_text(
+ label_x, y,
+ text=str(display_index),
+ fill='black',
+ font=('Arial', 13, 'bold')
+ )
+
+ led_index += 1
+
+ elif edge == 'right':
+ edge_len = h
+ start_px = span_start * edge_len
+ end_px = span_end * edge_len
+ x_axis = w - bw / 2
+
+ # Draw axis line
+ self.canvas.create_line(
+ x_axis, start_px, x_axis, end_px,
+ fill='white', width=3
+ )
+
+ # Draw tick marks
+ for i in range(count):
+ display_index = (led_index + self.calibration.offset) % total_leds
+
+ if i == 0 or i == count - 1 or display_index % tick_interval == 0:
+ frac = i / count if count > 1 else 0.5
+ y = start_px + frac * (end_px - start_px)
+
+ # Tick mark
+ tick_len = 15 if display_index % tick_interval == 0 else 10
+ self.canvas.create_line(
+ x_axis - tick_len, y, x_axis + tick_len, y,
+ fill='white', width=2
+ )
+
+ # Label with background (positioned INSIDE screen)
+ label_x = w - bw - 40
+ self.canvas.create_rectangle(
+ label_x - 30, y - 12, label_x + 30, y + 12,
+ fill='white', outline='black', width=2
+ )
+ self.canvas.create_text(
+ label_x, y,
+ text=str(display_index),
+ fill='black',
+ font=('Arial', 13, 'bold')
+ )
+
+ led_index += 1
+
+ def _calculate_led_positions(
+ self,
+ segment,
+ w: int, h: int, bw: int
+ ) -> List[Tuple[float, float]]:
+ """Calculate (x, y) positions for each LED in a segment."""
+ edge = segment.edge
+ count = segment.led_count
+ reverse = segment.reverse
+
+ # Get span for this edge
+ span_start, span_end = self.calibration.get_edge_span(edge)
+
+ positions = []
+
+ if edge == 'top':
+ edge_len = w
+ start_px = span_start * edge_len
+ end_px = span_end * edge_len
+ y = bw / 2 # Middle of border zone
+
+ for i in range(count):
+ frac = i / count if count > 1 else 0.5
+ x = start_px + frac * (end_px - start_px)
+ positions.append((x, y))
+
+ elif edge == 'bottom':
+ edge_len = w
+ start_px = span_start * edge_len
+ end_px = span_end * edge_len
+ y = h - bw / 2
+
+ for i in range(count):
+ frac = i / count if count > 1 else 0.5
+ x = start_px + frac * (end_px - start_px)
+ positions.append((x, y))
+
+ elif edge == 'left':
+ edge_len = h
+ start_px = span_start * edge_len
+ end_px = span_end * edge_len
+ x = bw / 2
+
+ for i in range(count):
+ frac = i / count if count > 1 else 0.5
+ y = start_px + frac * (end_px - start_px)
+ positions.append((x, y))
+
+ elif edge == 'right':
+ edge_len = h
+ start_px = span_start * edge_len
+ end_px = span_end * edge_len
+ x = w - bw / 2
+
+ for i in range(count):
+ frac = i / count if count > 1 else 0.5
+ y = start_px + frac * (end_px - start_px)
+ positions.append((x, y))
+
+ if reverse:
+ positions.reverse()
+
+ return positions
+
+ def _draw_mapping_ranges(self, w: int, h: int, bw: int) -> None:
+ """Draw colored segments showing pixel ranges for each LED."""
+ segments = self.calibration.segments
+
+ # Generate distinct colors for each LED using HSV
+ total_leds = self.calibration.get_total_leds()
+ colors = []
+ for i in range(total_leds):
+ hue = (i / total_leds) * 360
+ colors.append(self._hsv_to_rgb(hue, 0.7, 0.9))
+
+ led_index = 0
+ for seg in segments:
+ ranges = self._calculate_pixel_ranges(seg, w, h)
+
+ for i, (x1, y1, x2, y2) in enumerate(ranges):
+ color = colors[led_index % len(colors)]
+
+ # Draw line with LED's color
+ self.canvas.create_line(
+ x1, y1, x2, y2,
+ fill=color, width=3, capstyle='round'
+ )
+
+ led_index += 1
+
+ def _calculate_pixel_ranges(
+ self,
+ segment,
+ w: int, h: int
+ ) -> List[Tuple[float, float, float, float]]:
+ """Calculate pixel range boundaries for each LED in segment.
+
+ Returns list of (x1, y1, x2, y2) line coordinates.
+ """
+ edge = segment.edge
+ count = segment.led_count
+ span_start, span_end = self.calibration.get_edge_span(edge)
+
+ ranges = []
+
+ if edge == 'top':
+ edge_len = w
+ start_px = span_start * edge_len
+ end_px = span_end * edge_len
+ segment_len = (end_px - start_px) / count if count > 0 else 0
+
+ for i in range(count):
+ x1 = start_px + i * segment_len
+ x2 = start_px + (i + 1) * segment_len
+ ranges.append((x1, 0, x2, 0))
+
+ elif edge == 'bottom':
+ edge_len = w
+ start_px = span_start * edge_len
+ end_px = span_end * edge_len
+ segment_len = (end_px - start_px) / count if count > 0 else 0
+
+ for i in range(count):
+ x1 = start_px + i * segment_len
+ x2 = start_px + (i + 1) * segment_len
+ ranges.append((x1, h, x2, h))
+
+ elif edge == 'left':
+ edge_len = h
+ start_px = span_start * edge_len
+ end_px = span_end * edge_len
+ segment_len = (end_px - start_px) / count if count > 0 else 0
+
+ for i in range(count):
+ y1 = start_px + i * segment_len
+ y2 = start_px + (i + 1) * segment_len
+ ranges.append((0, y1, 0, y2))
+
+ elif edge == 'right':
+ edge_len = h
+ start_px = span_start * edge_len
+ end_px = span_end * edge_len
+ segment_len = (end_px - start_px) / count if count > 0 else 0
+
+ for i in range(count):
+ y1 = start_px + i * segment_len
+ y2 = start_px + (i + 1) * segment_len
+ ranges.append((w, y1, w, y2))
+
+ return ranges
+
+ def _draw_info_text(self, w: int, h: int) -> None:
+ """Draw calibration info text overlay."""
+ info_lines = [
+ f"Target: {self.target_name}",
+ f"Total LEDs: {self.calibration.get_total_leds()}",
+ f"Start: {self.calibration.start_position.replace('_', ' ').title()}",
+ f"Direction: {self.calibration.layout.title()}",
+ f"Offset: {self.calibration.offset}",
+ f"Border Width: {self.calibration.border_width}px",
+ "",
+ f"Top: {self.calibration.leds_top} LEDs",
+ f"Right: {self.calibration.leds_right} LEDs",
+ f"Bottom: {self.calibration.leds_bottom} LEDs",
+ f"Left: {self.calibration.leds_left} LEDs",
+ ]
+
+ # Draw background box with better readability
+ text_x = 30
+ text_y = 30
+ line_height = 28
+ padding = 20
+ box_width = 320
+ box_height = len(info_lines) * line_height + padding * 2
+
+ # Solid dark background with border
+ self.canvas.create_rectangle(
+ text_x - padding, text_y - padding,
+ text_x + box_width, text_y + box_height,
+ fill='#1a1a1a', outline='white', width=3
+ )
+
+ # Draw text lines with better spacing
+ for i, line in enumerate(info_lines):
+ self.canvas.create_text(
+ text_x, text_y + i * line_height,
+ text=line,
+ anchor='nw',
+ fill='yellow',
+ font=('Arial', 14, 'bold')
+ )
+
+ @staticmethod
+ def _hsv_to_rgb(h: float, s: float, v: float) -> str:
+ """Convert HSV to hex RGB color string."""
+ r, g, b = colorsys.hsv_to_rgb(h / 360, s, v)
+ return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}"
+
+
+class OverlayManager:
+ """Manages overlay windows across multiple targets."""
+
+ def __init__(self):
+ self._overlays: Dict[str, OverlayWindow] = {}
+ self._lock = threading.Lock()
+
+ def start_overlay(
+ self,
+ target_id: str,
+ display_info: DisplayInfo,
+ calibration: CalibrationConfig,
+ target_name: str = None
+ ) -> None:
+ """Start overlay for a target."""
+ with self._lock:
+ if target_id in self._overlays:
+ raise RuntimeError(f"Overlay already running for {target_id}")
+
+ overlay = OverlayWindow(display_info, calibration, target_id, target_name)
+ overlay.start()
+
+ # Wait for overlay to initialize
+ timeout = 3.0
+ start = time.time()
+ while not overlay.running and time.time() - start < timeout:
+ time.sleep(0.1)
+
+ if not overlay.running:
+ overlay.stop()
+ raise RuntimeError("Overlay failed to start within timeout")
+
+ self._overlays[target_id] = overlay
+ logger.info(f"Started overlay for target {target_id}")
+
+ def stop_overlay(self, target_id: str) -> None:
+ """Stop overlay for a target."""
+ with self._lock:
+ overlay = self._overlays.pop(target_id, None)
+ if overlay:
+ overlay.stop()
+ logger.info(f"Stopped overlay for target {target_id}")
+
+ def is_running(self, target_id: str) -> bool:
+ """Check if overlay is running for a target."""
+ with self._lock:
+ return target_id in self._overlays
+
+ def stop_all(self) -> None:
+ """Stop all overlays."""
+ with self._lock:
+ for target_id in list(self._overlays.keys()):
+ overlay = self._overlays.pop(target_id)
+ overlay.stop()
+ logger.info("Stopped all overlays")
diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js
index cbab035..8da24e4 100644
--- a/server/src/wled_controller/static/app.js
+++ b/server/src/wled_controller/static/app.js
@@ -4704,6 +4704,15 @@ function createTargetCard(target, deviceMap, sourceMap) {
+ ${state.overlay_active ? `
+
+ ` : `
+
+ `}
`;
@@ -4747,6 +4756,44 @@ async function stopTargetProcessing(targetId) {
}
}
+async function startTargetOverlay(targetId) {
+ try {
+ const response = await fetch(`${API_BASE}/picture-targets/${targetId}/overlay/start`, {
+ method: 'POST',
+ headers: getHeaders()
+ });
+ if (response.status === 401) { handle401Error(); return; }
+ if (response.ok) {
+ showToast(t('overlay.started'), 'success');
+ loadTargets();
+ } else {
+ const error = await response.json();
+ showToast(t('overlay.error.start') + ': ' + error.detail, 'error');
+ }
+ } catch (error) {
+ showToast(t('overlay.error.start'), 'error');
+ }
+}
+
+async function stopTargetOverlay(targetId) {
+ try {
+ const response = await fetch(`${API_BASE}/picture-targets/${targetId}/overlay/stop`, {
+ method: 'POST',
+ headers: getHeaders()
+ });
+ if (response.status === 401) { handle401Error(); return; }
+ if (response.ok) {
+ showToast(t('overlay.stopped'), 'success');
+ loadTargets();
+ } else {
+ const error = await response.json();
+ showToast(t('overlay.error.stop') + ': ' + error.detail, 'error');
+ }
+ } catch (error) {
+ showToast(t('overlay.error.stop'), 'error');
+ }
+}
+
async function deleteTarget(targetId) {
const confirmed = await showConfirm(t('targets.delete.confirm'));
if (!confirmed) return;
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json
index a9ddab5..e4c2154 100644
--- a/server/src/wled_controller/static/locales/en.json
+++ b/server/src/wled_controller/static/locales/en.json
@@ -439,5 +439,17 @@
"pattern.name.hint": "A descriptive name for this rectangle layout",
"pattern.description.hint": "Optional notes about where or how this pattern is used",
"pattern.visual_editor.hint": "Click + buttons to add rectangles. Drag edges to resize, drag inside to move.",
- "pattern.rectangles.hint": "Fine-tune rectangle positions and sizes with exact coordinates (0.0 to 1.0)"
+ "pattern.rectangles.hint": "Fine-tune rectangle positions and sizes with exact coordinates (0.0 to 1.0)",
+ "overlay": {
+ "button": {
+ "show": "Show overlay visualization",
+ "hide": "Hide overlay visualization"
+ },
+ "started": "Overlay visualization started",
+ "stopped": "Overlay visualization stopped",
+ "error": {
+ "start": "Failed to start overlay",
+ "stop": "Failed to stop overlay"
+ }
+ }
}
diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json
index 1a17137..2f8c8c0 100644
--- a/server/src/wled_controller/static/locales/ru.json
+++ b/server/src/wled_controller/static/locales/ru.json
@@ -439,5 +439,17 @@
"pattern.name.hint": "Описательное имя для этой раскладки прямоугольников",
"pattern.description.hint": "Необязательные заметки о назначении этого паттерна",
"pattern.visual_editor.hint": "Нажмите кнопки + чтобы добавить прямоугольники. Тяните края для изменения размера, тяните внутри для перемещения.",
- "pattern.rectangles.hint": "Точная настройка позиций и размеров прямоугольников в координатах (0.0 до 1.0)"
+ "pattern.rectangles.hint": "Точная настройка позиций и размеров прямоугольников в координатах (0.0 до 1.0)",
+ "overlay": {
+ "button": {
+ "show": "Показать визуализацию наложения",
+ "hide": "Скрыть визуализацию наложения"
+ },
+ "started": "Визуализация наложения запущена",
+ "stopped": "Визуализация наложения остановлена",
+ "error": {
+ "start": "Не удалось запустить наложение",
+ "stop": "Не удалось остановить наложение"
+ }
+ }
}