Simplify calibration model, add pixel preview, and improve UI
Some checks failed
Validate / validate (push) Failing after 9s
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:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user