Add Key Colors target type for extracting colors from screen regions

Introduce a new "key_colors" target type alongside WLED targets, enabling
real-time color extraction from configurable screen rectangles with
average/median/dominant modes, temporal smoothing, and WebSocket streaming.

- Split WledPictureTarget into its own module, add KeyColorsPictureTarget
- Add KC target lifecycle to ProcessorManager (register, start/stop, processing loop)
- Extend API routes and schemas for KC targets (CRUD, settings, state, metrics, colors)
- Add WebSocket endpoint for real-time color updates with auth
- Add KC sub-tab in Targets UI with editor modal and live color swatches
- Add EN and RU translations for all key colors strings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 16:43:09 +03:00
parent 3d2393e474
commit 5f9bc9a37e
13 changed files with 1525 additions and 111 deletions

View File

@@ -0,0 +1,97 @@
"""Key colors picture target — extracts key colors from image rectangles."""
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Optional
from wled_controller.storage.picture_target import PictureTarget
@dataclass
class KeyColorRectangle:
"""A named rectangle in relative coordinates (0.0 to 1.0)."""
name: str
x: float
y: float
width: float
height: float
def to_dict(self) -> dict:
return {
"name": self.name,
"x": self.x,
"y": self.y,
"width": self.width,
"height": self.height,
}
@classmethod
def from_dict(cls, data: dict) -> "KeyColorRectangle":
return cls(
name=data["name"],
x=float(data.get("x", 0.0)),
y=float(data.get("y", 0.0)),
width=float(data.get("width", 1.0)),
height=float(data.get("height", 1.0)),
)
@dataclass
class KeyColorsSettings:
"""Settings for key colors extraction."""
fps: int = 10
interpolation_mode: str = "average"
smoothing: float = 0.3
rectangles: List[KeyColorRectangle] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"fps": self.fps,
"interpolation_mode": self.interpolation_mode,
"smoothing": self.smoothing,
"rectangles": [r.to_dict() for r in self.rectangles],
}
@classmethod
def from_dict(cls, data: dict) -> "KeyColorsSettings":
return cls(
fps=data.get("fps", 10),
interpolation_mode=data.get("interpolation_mode", "average"),
smoothing=data.get("smoothing", 0.3),
rectangles=[
KeyColorRectangle.from_dict(r)
for r in data.get("rectangles", [])
],
)
@dataclass
class KeyColorsPictureTarget(PictureTarget):
"""Key colors extractor target — extracts key colors from image rectangles."""
picture_source_id: str = ""
settings: KeyColorsSettings = field(default_factory=KeyColorsSettings)
def to_dict(self) -> dict:
d = super().to_dict()
d["picture_source_id"] = self.picture_source_id
d["settings"] = self.settings.to_dict()
return d
@classmethod
def from_dict(cls, data: dict) -> "KeyColorsPictureTarget":
settings_data = data.get("settings", {})
settings = KeyColorsSettings.from_dict(settings_data)
return cls(
id=data["id"],
name=data["name"],
target_type="key_colors",
picture_source_id=data.get("picture_source_id", ""),
settings=settings,
description=data.get("description"),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)

View File

@@ -1,12 +1,9 @@
"""Picture target data models."""
"""Picture target base data model."""
import uuid
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from wled_controller.core.processor_manager import ProcessingSettings
@dataclass
class PictureTarget:
@@ -14,7 +11,7 @@ class PictureTarget:
id: str
name: str
target_type: str # "wled" (future: "artnet", "e131", ...)
target_type: str # "wled", "key_colors", ...
created_at: datetime
updated_at: datetime
description: Optional[str] = None
@@ -35,62 +32,9 @@ class PictureTarget:
"""Create from dictionary, dispatching to the correct subclass."""
target_type = data.get("target_type", "wled")
if target_type == "wled":
from wled_controller.storage.wled_picture_target import WledPictureTarget
return WledPictureTarget.from_dict(data)
if target_type == "key_colors":
from wled_controller.storage.key_colors_picture_target import KeyColorsPictureTarget
return KeyColorsPictureTarget.from_dict(data)
raise ValueError(f"Unknown target type: {target_type}")
@dataclass
class WledPictureTarget(PictureTarget):
"""WLED picture target — streams a picture source to a WLED device."""
device_id: str = ""
picture_source_id: str = ""
settings: ProcessingSettings = field(default_factory=ProcessingSettings)
def to_dict(self) -> dict:
"""Convert to dictionary."""
d = super().to_dict()
d["device_id"] = self.device_id
d["picture_source_id"] = self.picture_source_id
d["settings"] = {
"display_index": self.settings.display_index,
"fps": self.settings.fps,
"border_width": self.settings.border_width,
"brightness": self.settings.brightness,
"gamma": self.settings.gamma,
"saturation": self.settings.saturation,
"smoothing": self.settings.smoothing,
"interpolation_mode": self.settings.interpolation_mode,
"state_check_interval": self.settings.state_check_interval,
}
return d
@classmethod
def from_dict(cls, data: dict) -> "WledPictureTarget":
"""Create from dictionary."""
from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL
settings_data = data.get("settings", {})
settings = ProcessingSettings(
display_index=settings_data.get("display_index", 0),
fps=settings_data.get("fps", 30),
border_width=settings_data.get("border_width", 10),
brightness=settings_data.get("brightness", 1.0),
gamma=settings_data.get("gamma", 2.2),
saturation=settings_data.get("saturation", 1.0),
smoothing=settings_data.get("smoothing", 0.3),
interpolation_mode=settings_data.get("interpolation_mode", "average"),
state_check_interval=settings_data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
)
return cls(
id=data["id"],
name=data["name"],
target_type=data.get("target_type", "wled"),
device_id=data.get("device_id", ""),
picture_source_id=data.get("picture_source_id", ""),
settings=settings,
description=data.get("description"),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)

View File

@@ -7,7 +7,12 @@ from pathlib import Path
from typing import Dict, List, Optional
from wled_controller.core.processor_manager import ProcessingSettings
from wled_controller.storage.picture_target import PictureTarget, WledPictureTarget
from wled_controller.storage.picture_target import PictureTarget
from wled_controller.storage.wled_picture_target import WledPictureTarget
from wled_controller.storage.key_colors_picture_target import (
KeyColorsSettings,
KeyColorsPictureTarget,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -97,22 +102,24 @@ class PictureTargetStore:
device_id: str = "",
picture_source_id: str = "",
settings: Optional[ProcessingSettings] = None,
key_colors_settings: Optional[KeyColorsSettings] = None,
description: Optional[str] = None,
) -> PictureTarget:
"""Create a new picture target.
Args:
name: Target name
target_type: Target type ("wled")
target_type: Target type ("wled", "key_colors")
device_id: WLED device ID (for wled targets)
picture_source_id: Picture source ID
settings: Processing settings
settings: Processing settings (for wled targets)
key_colors_settings: Key colors settings (for key_colors targets)
description: Optional description
Raises:
ValueError: If validation fails
"""
if target_type not in ("wled",):
if target_type not in ("wled", "key_colors"):
raise ValueError(f"Invalid target type: {target_type}")
# Check for duplicate name
@@ -135,6 +142,17 @@ class PictureTargetStore:
created_at=now,
updated_at=now,
)
elif target_type == "key_colors":
target = KeyColorsPictureTarget(
id=target_id,
name=name,
target_type="key_colors",
picture_source_id=picture_source_id,
settings=key_colors_settings or KeyColorsSettings(),
description=description,
created_at=now,
updated_at=now,
)
else:
raise ValueError(f"Unknown target type: {target_type}")
@@ -151,6 +169,7 @@ class PictureTargetStore:
device_id: Optional[str] = None,
picture_source_id: Optional[str] = None,
settings: Optional[ProcessingSettings] = None,
key_colors_settings: Optional[KeyColorsSettings] = None,
description: Optional[str] = None,
) -> PictureTarget:
"""Update a picture target.
@@ -181,6 +200,12 @@ class PictureTargetStore:
if settings is not None:
target.settings = settings
if isinstance(target, KeyColorsPictureTarget):
if picture_source_id is not None:
target.picture_source_id = picture_source_id
if key_colors_settings is not None:
target.settings = key_colors_settings
target.updated_at = datetime.utcnow()
self._save()
@@ -211,7 +236,7 @@ class PictureTargetStore:
def is_referenced_by_source(self, source_id: str) -> bool:
"""Check if any target references a picture source."""
for target in self._targets.values():
if isinstance(target, WledPictureTarget) and target.picture_source_id == source_id:
if isinstance(target, (WledPictureTarget, KeyColorsPictureTarget)) and target.picture_source_id == source_id:
return True
return False

View File

@@ -0,0 +1,65 @@
"""WLED picture target — streams a picture source to a WLED device."""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from wled_controller.core.processor_manager import ProcessingSettings
from wled_controller.storage.picture_target import PictureTarget
@dataclass
class WledPictureTarget(PictureTarget):
"""WLED picture target — streams a picture source to a WLED device."""
device_id: str = ""
picture_source_id: str = ""
settings: ProcessingSettings = field(default_factory=ProcessingSettings)
def to_dict(self) -> dict:
"""Convert to dictionary."""
d = super().to_dict()
d["device_id"] = self.device_id
d["picture_source_id"] = self.picture_source_id
d["settings"] = {
"display_index": self.settings.display_index,
"fps": self.settings.fps,
"border_width": self.settings.border_width,
"brightness": self.settings.brightness,
"gamma": self.settings.gamma,
"saturation": self.settings.saturation,
"smoothing": self.settings.smoothing,
"interpolation_mode": self.settings.interpolation_mode,
"state_check_interval": self.settings.state_check_interval,
}
return d
@classmethod
def from_dict(cls, data: dict) -> "WledPictureTarget":
"""Create from dictionary."""
from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL
settings_data = data.get("settings", {})
settings = ProcessingSettings(
display_index=settings_data.get("display_index", 0),
fps=settings_data.get("fps", 30),
border_width=settings_data.get("border_width", 10),
brightness=settings_data.get("brightness", 1.0),
gamma=settings_data.get("gamma", 2.2),
saturation=settings_data.get("saturation", 1.0),
smoothing=settings_data.get("smoothing", 0.3),
interpolation_mode=settings_data.get("interpolation_mode", "average"),
state_check_interval=settings_data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
)
return cls(
id=data["id"],
name=data["name"],
target_type=data.get("target_type", "wled"),
device_id=data.get("device_id", ""),
picture_source_id=data.get("picture_source_id", ""),
settings=settings,
description=data.get("description"),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)