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:
@@ -789,3 +789,71 @@ async def target_colors_ws(
|
|||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
manager.remove_kc_ws_client(target_id, websocket)
|
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))
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ class TargetProcessingState(BaseModel):
|
|||||||
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
|
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
|
||||||
timing_total_ms: Optional[float] = Field(None, description="Total processing time (ms)")
|
timing_total_ms: Optional[float] = Field(None, description="Total processing time (ms)")
|
||||||
display_index: int = Field(default=0, description="Current display index")
|
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")
|
last_update: Optional[datetime] = Field(None, description="Last successful update")
|
||||||
errors: List[str] = Field(default_factory=list, description="Recent errors")
|
errors: List[str] = Field(default_factory=list, description="Recent errors")
|
||||||
device_online: bool = Field(default=False, description="Whether device is reachable")
|
device_online: bool = Field(default=False, description="Whether device is reachable")
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ from wled_controller.core.led_client import (
|
|||||||
check_device_health,
|
check_device_health,
|
||||||
create_led_client,
|
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
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -185,6 +187,8 @@ class TargetState:
|
|||||||
live_stream: Optional[LiveStream] = None
|
live_stream: Optional[LiveStream] = None
|
||||||
# Device state snapshot taken before streaming starts (to restore on stop)
|
# Device state snapshot taken before streaming starts (to restore on stop)
|
||||||
device_state_before: Optional[dict] = None
|
device_state_before: Optional[dict] = None
|
||||||
|
# Overlay visualization state
|
||||||
|
overlay_active: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -226,6 +230,7 @@ class ProcessorManager:
|
|||||||
self._live_stream_manager = LiveStreamManager(
|
self._live_stream_manager = LiveStreamManager(
|
||||||
picture_source_store, capture_template_store, pp_template_store
|
picture_source_store, capture_template_store, pp_template_store
|
||||||
)
|
)
|
||||||
|
self._overlay_manager = OverlayManager()
|
||||||
logger.info("Processor manager initialized")
|
logger.info("Processor manager initialized")
|
||||||
|
|
||||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
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_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,
|
"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,
|
"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,
|
"last_update": metrics.last_update,
|
||||||
"errors": [metrics.last_error] if metrics.last_error else [],
|
"errors": [metrics.last_error] if metrics.last_error else [],
|
||||||
**health_info,
|
**health_info,
|
||||||
@@ -897,6 +903,68 @@ class ProcessorManager:
|
|||||||
return ts.target_id
|
return ts.target_id
|
||||||
return None
|
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) =====
|
# ===== CALIBRATION TEST MODE (on device) =====
|
||||||
|
|
||||||
async def set_test_mode(self, device_id: str, edges: Dict[str, List[int]]) -> None:
|
async def set_test_mode(self, device_id: str, edges: Dict[str, List[int]]) -> None:
|
||||||
|
|||||||
640
server/src/wled_controller/core/screen_overlay.py
Normal file
640
server/src/wled_controller/core/screen_overlay.py
Normal 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")
|
||||||
@@ -4704,6 +4704,15 @@ function createTargetCard(target, deviceMap, sourceMap) {
|
|||||||
<button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}">
|
<button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}">
|
||||||
✏️
|
✏️
|
||||||
</button>
|
</button>
|
||||||
|
${state.overlay_active ? `
|
||||||
|
<button class="btn btn-icon btn-warning" onclick="stopTargetOverlay('${target.id}')" title="${t('overlay.button.hide')}">
|
||||||
|
👁️
|
||||||
|
</button>
|
||||||
|
` : `
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="startTargetOverlay('${target.id}')" title="${t('overlay.button.show')}">
|
||||||
|
👁️
|
||||||
|
</button>
|
||||||
|
`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -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) {
|
async function deleteTarget(targetId) {
|
||||||
const confirmed = await showConfirm(t('targets.delete.confirm'));
|
const confirmed = await showConfirm(t('targets.delete.confirm'));
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|||||||
@@ -439,5 +439,17 @@
|
|||||||
"pattern.name.hint": "A descriptive name for this rectangle layout",
|
"pattern.name.hint": "A descriptive name for this rectangle layout",
|
||||||
"pattern.description.hint": "Optional notes about where or how this pattern is used",
|
"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.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -439,5 +439,17 @@
|
|||||||
"pattern.name.hint": "Описательное имя для этой раскладки прямоугольников",
|
"pattern.name.hint": "Описательное имя для этой раскладки прямоугольников",
|
||||||
"pattern.description.hint": "Необязательные заметки о назначении этого паттерна",
|
"pattern.description.hint": "Необязательные заметки о назначении этого паттерна",
|
||||||
"pattern.visual_editor.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": "Не удалось остановить наложение"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user