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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user