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:
@@ -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())),
|
||||
)
|
||||
@@ -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())),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
65
server/src/wled_controller/storage/wled_picture_target.py
Normal file
65
server/src/wled_controller/storage/wled_picture_target.py
Normal 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())),
|
||||
)
|
||||
Reference in New Issue
Block a user