Add screen overlay visualization for LED target testing

Implements transparent on-screen overlay that displays LED calibration data
directly on the target display for easier setup and debugging. Overlay shows
border zones, LED position axes with tick labels, and calibration details.

Features:
- Tkinter-based transparent overlay window with click-through support
- Border zones highlighting pixel sampling areas (colored rectangles)
- LED position axes with numbered tick marks at regular intervals
- Calibration info box showing target name, LED counts, and configuration
- Toggle button (eye icon) in target cards for show/hide
- Localized UI strings (English and Russian)

Implementation:
- New screen_overlay.py module with OverlayWindow and OverlayManager classes
- Overlay runs in background thread with proper asyncio integration
- API endpoints for start/stop/status overlay control
- overlay_active state tracking in processor manager

Known limitation: tkinter threading cleanup causes server restart when overlay
is closed, but functionality works correctly while overlay is active.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 22:33:16 +03:00
parent ac5c1d0c82
commit 4f4d17c44d
7 changed files with 850 additions and 2 deletions

View File

@@ -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:

View File

@@ -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")