Add EntitySelect/IconSelect UI improvements across modals

- Portal IconSelect popups to document.body with position:fixed to prevent
  clipping by modal overflow-y:auto
- Replace custom scene selectors in automation editor with EntitySelect
  command-palette pickers (main scene + fallback scene)
- Add IconSelect grid for automation deactivation mode (none/revert/fallback)
- Add IconSelect grid for automation condition type and match type
- Replace mapped zone source dropdowns with EntitySelect pickers
- Replace scene target selector with EntityPalette.pick() pattern
- Remove effect palette preview bar from CSS editor
- Remove sensitivity badge from audio color strip source cards
- Clean up unused scene-selector CSS and scene-target-add-row CSS
- Add locale keys for all new UI elements across en/ru/zh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 16:00:30 +03:00
parent 186940124c
commit 2712c6682e
32 changed files with 1204 additions and 391 deletions
@@ -1,7 +1,7 @@
"""Calibration system for mapping screen pixels to LED positions."""
from dataclasses import dataclass
from typing import Dict, List, Literal, Tuple
from dataclasses import dataclass, field
from typing import Dict, List, Literal, Optional, Set, Tuple
import numpy as np
@@ -51,6 +51,19 @@ class CalibrationSegment:
reverse: bool = False
@dataclass
class CalibrationLine:
"""One LED line in advanced calibration — references one picture source edge."""
picture_source_id: str
edge: Literal["top", "right", "bottom", "left"]
led_count: int
span_start: float = 0.0
span_end: float = 1.0
reverse: bool = False
border_width: int = 10
@dataclass
class CalibrationConfig:
"""Complete calibration configuration.
@@ -59,8 +72,14 @@ class CalibrationConfig:
are derived at runtime via the `segments` property.
"""
layout: Literal["clockwise", "counterclockwise"]
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"]
# Mode: "simple" = 4-edge model (backward compat), "advanced" = generic line list
mode: Literal["simple", "advanced"] = "simple"
# Advanced mode: ordered list of CalibrationLine objects (ignored in simple mode)
lines: List[CalibrationLine] = field(default_factory=list)
# Simple mode fields (also used as defaults for CalibrationConfig constructor)
layout: Literal["clockwise", "counterclockwise"] = "clockwise"
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"] = "bottom_left"
offset: int = 0
leds_top: int = 0
leds_right: int = 0
@@ -135,6 +154,20 @@ class CalibrationConfig:
Raises:
ValueError: If configuration is invalid
"""
if self.mode == "advanced":
if not self.lines:
raise ValueError("Advanced calibration must have at least one line")
for i, line in enumerate(self.lines):
if line.led_count <= 0:
raise ValueError(f"Line {i}: LED count must be positive, got {line.led_count}")
if not (0.0 <= line.span_start <= 1.0) or not (0.0 <= line.span_end <= 1.0):
raise ValueError(f"Line {i}: span must be in [0.0, 1.0]")
if line.span_end <= line.span_start:
raise ValueError(f"Line {i}: span_end must be greater than span_start")
if line.border_width < 1:
raise ValueError(f"Line {i}: border_width must be at least 1")
return True
total = self.get_total_leds()
if total <= 0:
raise ValueError("Calibration must have at least one LED")
@@ -154,9 +187,26 @@ class CalibrationConfig:
return True
def get_total_leds(self) -> int:
"""Get total number of LEDs across all edges."""
"""Get total number of LEDs across all edges/lines."""
if self.mode == "advanced":
return sum(line.led_count for line in self.lines)
return self.leds_top + self.leds_right + self.leds_bottom + self.leds_left
def get_required_picture_source_ids(self) -> List[str]:
"""Get deduplicated list of picture source IDs referenced by lines.
Returns empty list for simple mode (the stream provides the source).
"""
if self.mode != "advanced":
return []
seen: Set[str] = set()
result: List[str] = []
for line in self.lines:
if line.picture_source_id not in seen:
seen.add(line.picture_source_id)
result.append(line.picture_source_id)
return result
def get_segment_for_edge(self, edge: str) -> CalibrationSegment | None:
"""Get segment configuration for a specific edge."""
for seg in self.segments:
@@ -408,6 +458,216 @@ class PixelMapper:
return led_colors
class AdvancedPixelMapper:
"""Maps multi-source screen pixels to LED colors for advanced calibration.
Each CalibrationLine references a picture source and an edge, with its own
span and border_width. Frames from multiple sources are passed in as a dict.
"""
def __init__(
self,
calibration: CalibrationConfig,
interpolation_mode: Literal["average", "median", "dominant"] = "average",
):
self.calibration = calibration
self.interpolation_mode = interpolation_mode
calibration.validate()
if interpolation_mode == "average":
self._calc_color = calculate_average_color
elif interpolation_mode == "median":
self._calc_color = calculate_median_color
elif interpolation_mode == "dominant":
self._calc_color = calculate_dominant_color
else:
raise ValueError(f"Invalid interpolation mode: {interpolation_mode}")
total_leds = calibration.get_total_leds()
self._total_leds = total_leds
self._led_buf = np.zeros((total_leds, 3), dtype=np.uint8)
self._use_fast_avg = interpolation_mode == "average"
# Build segment-like metadata from lines (led_start for each line)
offset = calibration.offset % total_leds if total_leds > 0 else 0
self._line_indices: List[np.ndarray] = []
led_start = 0
for line in calibration.lines:
indices = np.arange(led_start, led_start + line.led_count)
if line.reverse:
indices = indices[::-1]
if offset > 0:
indices = (indices + offset) % total_leds
self._line_indices.append(indices)
led_start += line.led_count
# Skip arrays (same logic as PixelMapper)
skip_start = calibration.skip_leds_start
skip_end = calibration.skip_leds_end
self._skip_start = skip_start
self._skip_end = skip_end
self._active_count = max(0, total_leds - skip_start - skip_end)
if 0 < self._active_count < total_leds:
self._skip_src = np.linspace(0, total_leds - 1, self._active_count)
self._skip_x = np.arange(total_leds, dtype=np.float64)
self._skip_float = np.empty((total_leds, 3), dtype=np.float64)
self._skip_resampled = np.empty((self._active_count, 3), dtype=np.uint8)
else:
self._skip_src = self._skip_x = self._skip_float = self._skip_resampled = None
# Per-line edge cache (keyed by line index to avoid collision)
self._edge_cache: Dict[int, tuple] = {}
logger.info(
f"Initialized advanced pixel mapper with {total_leds} LEDs, "
f"{len(calibration.lines)} lines, {interpolation_mode} interpolation"
)
@staticmethod
def _extract_edge_strip(
frame: np.ndarray, edge: str, border_width: int,
span_start: float, span_end: float,
) -> np.ndarray:
"""Extract a border strip from a frame for the given edge and span."""
h, w = frame.shape[:2]
bw = min(border_width, h // 4, w // 4)
if edge == "top":
strip = frame[:bw, :, :]
elif edge == "bottom":
strip = frame[-bw:, :, :]
elif edge == "right":
strip = frame[:, -bw:, :]
else: # left
strip = frame[:, :bw, :]
# Apply span
if span_start > 0.0 or span_end < 1.0:
if edge in ("top", "bottom"):
total_w = strip.shape[1]
s, e = int(span_start * total_w), int(span_end * total_w)
strip = strip[:, s:e, :]
else:
total_h = strip.shape[0]
s, e = int(span_start * total_h), int(span_end * total_h)
strip = strip[s:e, :, :]
return strip
def _map_edge_average(
self, edge_pixels: np.ndarray, edge_name: str, led_count: int,
cache_key: int,
) -> np.ndarray:
"""Vectorized average-color mapping (same algo as PixelMapper)."""
if edge_name in ("top", "bottom"):
axis = 0
edge_len = edge_pixels.shape[1]
else:
axis = 1
edge_len = edge_pixels.shape[0]
cache = self._edge_cache.get(cache_key)
if cache is None or cache[0] != edge_len:
step = edge_len / led_count
boundaries = (np.arange(led_count + 1, dtype=np.float64) * step).astype(np.int64)
boundaries[1:] = np.maximum(boundaries[1:], boundaries[:-1] + 1)
np.minimum(boundaries, edge_len, out=boundaries)
starts = boundaries[:-1]
ends = boundaries[1:]
lengths = (ends - starts).reshape(-1, 1).astype(np.float64)
cumsum_buf = np.empty((edge_len + 1, 3), dtype=np.float64)
edge_1d_buf = np.empty((edge_len, 3), dtype=np.float64)
cache = (edge_len, starts, ends, lengths, cumsum_buf, edge_1d_buf)
self._edge_cache[cache_key] = cache
_, starts, ends, lengths, cumsum_buf, edge_1d_buf = cache
np.mean(edge_pixels, axis=axis, out=edge_1d_buf)
cumsum_buf[0] = 0
np.cumsum(edge_1d_buf, axis=0, out=cumsum_buf[1:])
segment_sums = cumsum_buf[ends] - cumsum_buf[starts]
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8)
def _map_edge_fallback(
self, edge_pixels: np.ndarray, edge_name: str, led_count: int,
) -> np.ndarray:
"""Per-LED color mapping for median/dominant modes."""
if edge_name in ("top", "bottom"):
edge_len = edge_pixels.shape[1]
else:
edge_len = edge_pixels.shape[0]
step = edge_len / led_count
result = np.empty((led_count, 3), dtype=np.uint8)
for i in range(led_count):
start = int(i * step)
end = max(start + 1, int((i + 1) * step))
end = min(end, edge_len)
if edge_name in ("top", "bottom"):
segment = edge_pixels[:, start:end, :]
else:
segment = edge_pixels[start:end, :, :]
result[i] = self._calc_color(segment)
return result
def map_lines_to_leds(self, frames: Dict[str, np.ndarray]) -> np.ndarray:
"""Map multi-source frames to LED colors using calibration lines.
Args:
frames: Dict mapping picture_source_id to captured frame (H, W, 3) uint8
Returns:
numpy array of shape (total_leds, 3), dtype uint8
"""
led_array = self._led_buf
led_array[:] = 0
for i, line in enumerate(self.calibration.lines):
frame = frames.get(line.picture_source_id)
if frame is None:
continue
edge_pixels = self._extract_edge_strip(
frame, line.edge, line.border_width,
line.span_start, line.span_end,
)
if self._use_fast_avg:
colors = self._map_edge_average(
edge_pixels, line.edge, line.led_count, cache_key=i,
)
else:
colors = self._map_edge_fallback(
edge_pixels, line.edge, line.led_count,
)
led_array[self._line_indices[i]] = colors
# Phase 3: Physical skip (same as PixelMapper)
if self._skip_src is not None:
np.copyto(self._skip_float, led_array, casting='unsafe')
for ch in range(3):
self._skip_resampled[:, ch] = np.round(
np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch])
).astype(np.uint8)
led_array[:] = 0
end_idx = self._total_leds - self._skip_end
led_array[self._skip_start:end_idx] = self._skip_resampled
elif self._active_count <= 0:
led_array[:] = 0
return led_array
def create_pixel_mapper(
calibration: CalibrationConfig,
interpolation_mode: str = "average",
):
"""Factory: create the right mapper for the calibration mode."""
if calibration.mode == "advanced":
return AdvancedPixelMapper(calibration, interpolation_mode)
return PixelMapper(calibration, interpolation_mode)
def create_default_calibration(led_count: int) -> CalibrationConfig:
"""Create a default calibration for a rectangular screen.
@@ -464,7 +724,35 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
ValueError: If data is invalid
"""
try:
mode = data.get("mode", "simple")
if mode == "advanced":
lines_data = data.get("lines", [])
lines = [
CalibrationLine(
picture_source_id=ld["picture_source_id"],
edge=ld["edge"],
led_count=ld["led_count"],
span_start=ld.get("span_start", 0.0),
span_end=ld.get("span_end", 1.0),
reverse=ld.get("reverse", False),
border_width=ld.get("border_width", 10),
)
for ld in lines_data
]
config = CalibrationConfig(
mode="advanced",
lines=lines,
offset=data.get("offset", 0),
skip_leds_start=data.get("skip_leds_start", 0),
skip_leds_end=data.get("skip_leds_end", 0),
)
config.validate()
return config
# Simple mode (backward compat — missing "mode" key defaults here)
config = CalibrationConfig(
mode="simple",
layout=data["layout"],
start_position=data["start_position"],
offset=data.get("offset", 0),
@@ -505,7 +793,33 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
Returns:
Dictionary representation
"""
if config.mode == "advanced":
result: dict = {
"mode": "advanced",
"lines": [
{
"picture_source_id": line.picture_source_id,
"edge": line.edge,
"led_count": line.led_count,
"span_start": line.span_start,
"span_end": line.span_end,
"reverse": line.reverse,
"border_width": line.border_width,
}
for line in config.lines
],
}
if config.offset != 0:
result["offset"] = config.offset
if config.skip_leds_start > 0:
result["skip_leds_start"] = config.skip_leds_start
if config.skip_leds_end > 0:
result["skip_leds_end"] = config.skip_leds_end
return result
# Simple mode
result = {
"mode": "simple",
"layout": config.layout,
"start_position": config.start_position,
"offset": config.offset,