Simplify calibration model, add pixel preview, and improve UI
Some checks failed
Validate / validate (push) Failing after 9s

- Replace segment-based calibration with core parameters (leds_top/right/bottom/left);
  segments are now derived at runtime via lookup tables
- Fix clockwise/counterclockwise edge traversal order for all 8 start_position/layout
  combinations (e.g. bottom_left+clockwise now correctly goes up-left first)
- Add pixel layout preview overlay with color-coded edges, LED index labels,
  direction arrows, and start position marker
- Move "Add New Device" form into a modal dialog triggered by "+" button
- Add display index selector to device settings modal
- Migrate from requirements.txt to pyproject.toml for dependency management
- Update Dockerfile and docs to use `pip install .`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 03:05:09 +03:00
parent d4261d76d8
commit 7f613df362
15 changed files with 965 additions and 317 deletions

View File

@@ -1,7 +1,7 @@
"""Calibration system for mapping screen pixels to LED positions."""
from dataclasses import dataclass
from typing import List, Literal, Tuple
from dataclasses import dataclass, field
from typing import Dict, List, Literal, Tuple
from wled_controller.core.screen_capture import (
BorderPixels,
@@ -14,6 +14,31 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Edge traversal order for each (start_position, layout) combination.
# Determines which edge comes first when walking around the screen.
EDGE_ORDER: Dict[Tuple[str, str], List[str]] = {
("bottom_left", "clockwise"): ["left", "top", "right", "bottom"],
("bottom_left", "counterclockwise"): ["bottom", "right", "top", "left"],
("bottom_right", "clockwise"): ["bottom", "left", "top", "right"],
("bottom_right", "counterclockwise"): ["right", "top", "left", "bottom"],
("top_left", "clockwise"): ["top", "right", "bottom", "left"],
("top_left", "counterclockwise"): ["left", "bottom", "right", "top"],
("top_right", "clockwise"): ["right", "bottom", "left", "top"],
("top_right", "counterclockwise"): ["top", "left", "bottom", "right"],
}
# Whether LEDs are reversed on each edge for each (start_position, layout) combination.
EDGE_REVERSE: Dict[Tuple[str, str], Dict[str, bool]] = {
("bottom_left", "clockwise"): {"left": True, "top": False, "right": False, "bottom": True},
("bottom_left", "counterclockwise"): {"bottom": False, "right": True, "top": True, "left": False},
("bottom_right", "clockwise"): {"bottom": True, "left": True, "top": False, "right": False},
("bottom_right", "counterclockwise"): {"right": True, "top": True, "left": False, "bottom": False},
("top_left", "clockwise"): {"top": False, "right": False, "bottom": True, "left": True},
("top_left", "counterclockwise"): {"left": False, "bottom": False, "right": True, "top": True},
("top_right", "clockwise"): {"right": False, "bottom": True, "left": True, "top": False},
("top_right", "counterclockwise"): {"top": True, "left": False, "bottom": False, "right": True},
}
@dataclass
class CalibrationSegment:
@@ -27,12 +52,52 @@ class CalibrationSegment:
@dataclass
class CalibrationConfig:
"""Complete calibration configuration."""
"""Complete calibration configuration.
Stores only the core parameters. Segments (with led_start, reverse, edge order)
are derived at runtime via the `segments` property.
"""
layout: Literal["clockwise", "counterclockwise"]
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"]
segments: List[CalibrationSegment]
offset: int = 0 # Physical LED offset from start corner (number of LEDs from LED 0 to start corner)
offset: int = 0
leds_top: int = 0
leds_right: int = 0
leds_bottom: int = 0
leds_left: int = 0
def build_segments(self) -> List[CalibrationSegment]:
"""Derive segment list from core parameters."""
key = (self.start_position, self.layout)
edge_order = EDGE_ORDER.get(key, ["bottom", "right", "top", "left"])
reverse_map = EDGE_REVERSE.get(key, {})
led_counts = {
"top": self.leds_top,
"right": self.leds_right,
"bottom": self.leds_bottom,
"left": self.leds_left,
}
segments = []
led_start = 0
for edge in edge_order:
count = led_counts[edge]
if count > 0:
segments.append(CalibrationSegment(
edge=edge,
led_start=led_start,
led_count=count,
reverse=reverse_map.get(edge, False),
))
led_start += count
return segments
@property
def segments(self) -> List[CalibrationSegment]:
"""Get derived segment list."""
return self.build_segments()
def validate(self) -> bool:
"""Validate calibration configuration.
@@ -43,42 +108,20 @@ class CalibrationConfig:
Raises:
ValueError: If configuration is invalid
"""
if not self.segments:
raise ValueError("Calibration must have at least one segment")
total = self.get_total_leds()
if total <= 0:
raise ValueError("Calibration must have at least one LED")
# Check for duplicate edges
edges = [seg.edge for seg in self.segments]
if len(edges) != len(set(edges)):
raise ValueError("Duplicate edges in calibration segments")
# Validate LED indices don't overlap
led_ranges = []
for seg in self.segments:
led_range = range(seg.led_start, seg.led_start + seg.led_count)
led_ranges.append(led_range)
# Check for overlaps
for i, range1 in enumerate(led_ranges):
for j, range2 in enumerate(led_ranges):
if i != j:
overlap = set(range1) & set(range2)
if overlap:
raise ValueError(
f"LED indices overlap between segments {i} and {j}: {overlap}"
)
# Validate LED counts are positive
for seg in self.segments:
if seg.led_count <= 0:
raise ValueError(f"LED count must be positive, got {seg.led_count}")
if seg.led_start < 0:
raise ValueError(f"LED start must be non-negative, got {seg.led_start}")
for edge, count in [("top", self.leds_top), ("right", self.leds_right),
("bottom", self.leds_bottom), ("left", self.leds_left)]:
if count < 0:
raise ValueError(f"LED count for {edge} must be non-negative, got {count}")
return True
def get_total_leds(self) -> int:
"""Get total number of LEDs across all segments."""
return sum(seg.led_count for seg in self.segments)
"""Get total number of LEDs across all edges."""
return self.leds_top + self.leds_right + self.leds_bottom + self.leds_left
def get_segment_for_edge(self, edge: str) -> CalibrationSegment | None:
"""Get segment configuration for a specific edge."""
@@ -248,37 +291,13 @@ def create_default_calibration(led_count: int) -> CalibrationConfig:
top_count = leds_per_edge + (1 if remainder > 1 else 0)
left_count = leds_per_edge + (1 if remainder > 2 else 0)
segments = [
CalibrationSegment(
edge="bottom",
led_start=0,
led_count=bottom_count,
reverse=False,
),
CalibrationSegment(
edge="right",
led_start=bottom_count,
led_count=right_count,
reverse=False,
),
CalibrationSegment(
edge="top",
led_start=bottom_count + right_count,
led_count=top_count,
reverse=True,
),
CalibrationSegment(
edge="left",
led_start=bottom_count + right_count + top_count,
led_count=left_count,
reverse=True,
),
]
config = CalibrationConfig(
layout="clockwise",
start_position="bottom_left",
segments=segments,
leds_bottom=bottom_count,
leds_right=right_count,
leds_top=top_count,
leds_left=left_count,
)
logger.info(
@@ -293,6 +312,9 @@ def create_default_calibration(led_count: int) -> CalibrationConfig:
def calibration_from_dict(data: dict) -> CalibrationConfig:
"""Create calibration configuration from dictionary.
Supports both new format (leds_top/right/bottom/left) and legacy format
(segments list) for backward compatibility.
Args:
data: Dictionary with calibration data
@@ -303,21 +325,14 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
ValueError: If data is invalid
"""
try:
segments = [
CalibrationSegment(
edge=seg["edge"],
led_start=seg["led_start"],
led_count=seg["led_count"],
reverse=seg.get("reverse", False),
)
for seg in data["segments"]
]
config = CalibrationConfig(
layout=data["layout"],
start_position=data["start_position"],
segments=segments,
offset=data.get("offset", 0),
leds_top=data.get("leds_top", 0),
leds_right=data.get("leds_right", 0),
leds_bottom=data.get("leds_bottom", 0),
leds_left=data.get("leds_left", 0),
)
config.validate()
@@ -326,6 +341,8 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
except KeyError as e:
raise ValueError(f"Missing required calibration field: {e}")
except Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"Invalid calibration data: {e}")
@@ -342,13 +359,8 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
"layout": config.layout,
"start_position": config.start_position,
"offset": config.offset,
"segments": [
{
"edge": seg.edge,
"led_start": seg.led_start,
"led_count": seg.led_count,
"reverse": seg.reverse,
}
for seg in config.segments
],
"leds_top": config.leds_top,
"leds_right": config.leds_right,
"leds_bottom": config.leds_bottom,
"leds_left": config.leds_left,
}