Refactor core/ into logical sub-packages and split filter files

Reorganize the flat core/ directory (17 files) into three sub-packages:
- core/devices/ — LED device communication (led_client, wled/adalight clients, providers, DDP)
- core/processing/ — target processing pipeline (processor_manager, target processors, live streams, settings)
- core/capture/ — screen capture & calibration (screen_capture, calibration, pixel_processor, overlay)

Also split the monolithic filters/builtin.py (460 lines, 8 filters) into
individual files: brightness, saturation, gamma, downscaler, pixelate,
auto_crop, flip, color_correction.

Includes the ProcessorManager refactor from target-centric architecture:
ProcessorManager slimmed from ~1600 to ~490 lines with unified
_processors dict replacing duplicate _targets/_kc_targets dicts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 12:03:29 +03:00
parent 77dd342c4c
commit fc779eef39
50 changed files with 2740 additions and 2267 deletions

View File

@@ -1,6 +1,6 @@
"""Core functionality for screen capture and WLED control."""
from .screen_capture import (
from wled_controller.core.capture.screen_capture import (
get_available_displays,
capture_display,
extract_border_pixels,

View File

@@ -0,0 +1,25 @@
"""Screen capture and calibration."""
from wled_controller.core.capture.screen_capture import (
BorderPixels,
ScreenCapture,
capture_display,
extract_border_pixels,
get_available_displays,
)
from wled_controller.core.capture.calibration import (
CalibrationConfig,
PixelMapper,
create_default_calibration,
)
__all__ = [
"BorderPixels",
"CalibrationConfig",
"PixelMapper",
"ScreenCapture",
"capture_display",
"create_default_calibration",
"extract_border_pixels",
"get_available_displays",
]

View File

@@ -5,7 +5,7 @@ from typing import Dict, List, Literal, Tuple
import numpy as np
from wled_controller.core.screen_capture import (
from wled_controller.core.capture.screen_capture import (
BorderPixels,
get_edge_segments,
calculate_average_color,

View File

@@ -8,7 +8,7 @@ import time
import tkinter as tk
from typing import Dict, List, Tuple, Optional
from wled_controller.core.calibration import CalibrationConfig
from wled_controller.core.capture.calibration import CalibrationConfig
from wled_controller.core.capture_engines.base import DisplayInfo
logger = logging.getLogger(__name__)

View File

@@ -0,0 +1,25 @@
"""LED device communication layer."""
from wled_controller.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
LEDDeviceProvider,
check_device_health,
create_led_client,
get_all_providers,
get_device_capabilities,
get_provider,
)
__all__ = [
"DeviceHealth",
"DiscoveredDevice",
"LEDClient",
"LEDDeviceProvider",
"check_device_health",
"create_led_client",
"get_all_providers",
"get_device_capabilities",
"get_provider",
]

View File

@@ -6,7 +6,7 @@ from typing import List, Optional, Tuple
import numpy as np
from wled_controller.core.led_client import DeviceHealth, LEDClient
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -59,7 +59,7 @@ def _build_adalight_header(led_count: int) -> bytes:
class AdalightClient(LEDClient):
"""LED client for Arduino Adalight serial devices."""
def __init__(self, url: str, led_count: int = 0, baud_rate: int = None, **kwargs):
def __init__(self, url: str, led_count: int = 0, baud_rate: Optional[int] = None, **kwargs):
"""Initialize Adalight client.
Args:

View File

@@ -2,7 +2,9 @@
from typing import List
from wled_controller.core.led_client import (
import numpy as np
from wled_controller.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
@@ -28,7 +30,7 @@ class AdalightDeviceProvider(LEDDeviceProvider):
return {"manual_led_count", "power_control", "brightness_control"}
def create_client(self, url: str, **kwargs) -> LEDClient:
from wled_controller.core.adalight_client import AdalightClient
from wled_controller.core.devices.adalight_client import AdalightClient
led_count = kwargs.pop("led_count", 0)
baud_rate = kwargs.pop("baud_rate", None)
@@ -36,7 +38,7 @@ class AdalightDeviceProvider(LEDDeviceProvider):
return AdalightClient(url, led_count=led_count, baud_rate=baud_rate, **kwargs)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
from wled_controller.core.adalight_client import AdalightClient
from wled_controller.core.devices.adalight_client import AdalightClient
return await AdalightClient.check_health(url, http_client, prev_health)
@@ -47,7 +49,7 @@ class AdalightDeviceProvider(LEDDeviceProvider):
Empty dict Adalight devices don't report LED count,
so it must be provided by the user.
"""
from wled_controller.core.adalight_client import parse_adalight_url
from wled_controller.core.devices.adalight_client import parse_adalight_url
port, _baud = parse_adalight_url(url)
@@ -94,11 +96,30 @@ class AdalightDeviceProvider(LEDDeviceProvider):
logger.error(f"Serial port discovery failed: {e}")
return []
async def get_power(self, url: str) -> bool:
async def get_power(self, url: str, **kwargs) -> bool:
# Adalight has no hardware power query; assume on
return True
async def set_power(self, url: str, on: bool) -> None:
# Adalight power control is handled at the API layer via processor manager
# because it needs access to the active serial client or device info.
raise NotImplementedError("Use API-level set_power for Adalight")
async def set_power(self, url: str, on: bool, **kwargs) -> None:
"""Turn Adalight device on/off by sending an all-black frame (off) or no-op (on).
Requires kwargs: led_count (int), baud_rate (int | None).
"""
if on:
return # "on" is a no-op — next processing frame lights LEDs up
led_count = kwargs.get("led_count", 0)
baud_rate = kwargs.get("baud_rate")
if led_count <= 0:
raise ValueError("led_count is required to send black frame to Adalight device")
from wled_controller.core.devices.adalight_client import AdalightClient
client = AdalightClient(url, led_count=led_count, baud_rate=baud_rate)
try:
await client.connect()
black = np.zeros((led_count, 3), dtype=np.uint8)
await client.send_pixels(black, brightness=255)
logger.info(f"Adalight power off: sent black frame to {url}")
finally:
await client.close()

View File

@@ -3,7 +3,9 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional, Tuple, Union
import numpy as np
@dataclass
@@ -72,7 +74,7 @@ class LEDClient(ABC):
@abstractmethod
async def send_pixels(
self,
pixels: List[Tuple[int, int, int]],
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
brightness: int = 255,
) -> bool:
"""Send pixel colors to the LED device (async).
@@ -200,11 +202,11 @@ class LEDDeviceProvider(ABC):
"""Set device brightness (0-255). Override if capabilities include brightness_control."""
raise NotImplementedError
async def get_power(self, url: str) -> bool:
async def get_power(self, url: str, **kwargs) -> bool:
"""Get device power state. Override if capabilities include power_control."""
raise NotImplementedError
async def set_power(self, url: str, on: bool) -> None:
async def set_power(self, url: str, on: bool, **kwargs) -> None:
"""Set device power state. Override if capabilities include power_control."""
raise NotImplementedError
@@ -264,10 +266,10 @@ def get_device_capabilities(device_type: str) -> set:
# ===== AUTO-REGISTER BUILT-IN PROVIDERS =====
def _register_builtin_providers():
from wled_controller.core.wled_provider import WLEDDeviceProvider
from wled_controller.core.devices.wled_provider import WLEDDeviceProvider
register_provider(WLEDDeviceProvider())
from wled_controller.core.adalight_provider import AdalightDeviceProvider
from wled_controller.core.devices.adalight_provider import AdalightDeviceProvider
register_provider(AdalightDeviceProvider())

View File

@@ -11,8 +11,8 @@ import httpx
import numpy as np
from wled_controller.utils import get_logger
from wled_controller.core.ddp_client import BusConfig, DDPClient
from wled_controller.core.led_client import DeviceHealth, LEDClient
from wled_controller.core.devices.ddp_client import BusConfig, DDPClient
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
logger = get_logger(__name__)

View File

@@ -7,7 +7,7 @@ import httpx
from zeroconf import ServiceStateChange
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
from wled_controller.core.led_client import (
from wled_controller.core.devices.led_client import (
DeviceHealth,
DiscoveredDevice,
LEDClient,
@@ -33,13 +33,13 @@ class WLEDDeviceProvider(LEDDeviceProvider):
return {"brightness_control", "power_control", "standby_required"}
def create_client(self, url: str, **kwargs) -> LEDClient:
from wled_controller.core.wled_client import WLEDClient
from wled_controller.core.devices.wled_client import WLEDClient
kwargs.pop("led_count", None)
kwargs.pop("baud_rate", None)
return WLEDClient(url, **kwargs)
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
from wled_controller.core.wled_client import WLEDClient
from wled_controller.core.devices.wled_client import WLEDClient
return await WLEDClient.check_health(url, http_client, prev_health)
async def validate_device(self, url: str) -> dict:
@@ -171,14 +171,14 @@ class WLEDDeviceProvider(LEDDeviceProvider):
)
resp.raise_for_status()
async def get_power(self, url: str) -> bool:
async def get_power(self, url: str, **kwargs) -> bool:
url = url.rstrip("/")
async with httpx.AsyncClient(timeout=5.0) as http_client:
resp = await http_client.get(f"{url}/json/state")
resp.raise_for_status()
return resp.json().get("on", False)
async def set_power(self, url: str, on: bool) -> None:
async def set_power(self, url: str, on: bool, **kwargs) -> None:
url = url.rstrip("/")
async with httpx.AsyncClient(timeout=5.0) as http_client:
resp = await http_client.post(

View File

@@ -9,8 +9,15 @@ from wled_controller.core.filters.filter_instance import FilterInstance
from wled_controller.core.filters.image_pool import ImagePool
from wled_controller.core.filters.registry import FilterRegistry
# Import builtin filters to trigger auto-registration
import wled_controller.core.filters.builtin # noqa: F401
# Import individual filters to trigger auto-registration
import wled_controller.core.filters.brightness # noqa: F401
import wled_controller.core.filters.saturation # noqa: F401
import wled_controller.core.filters.gamma # noqa: F401
import wled_controller.core.filters.downscaler # noqa: F401
import wled_controller.core.filters.pixelate # noqa: F401
import wled_controller.core.filters.auto_crop # noqa: F401
import wled_controller.core.filters.flip # noqa: F401
import wled_controller.core.filters.color_correction # noqa: F401
__all__ = [
"FilterOptionDef",

View File

@@ -0,0 +1,100 @@
"""Auto-crop postprocessing filter."""
from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool
from wled_controller.core.filters.registry import FilterRegistry
@FilterRegistry.register
class AutoCropFilter(PostprocessingFilter):
"""Detects and crops black bars (letterboxing/pillarboxing) from the image."""
filter_id = "auto_crop"
filter_name = "Auto Crop"
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="threshold",
label="Black Threshold",
option_type="int",
default=15,
min_value=0,
max_value=50,
step=1,
),
FilterOptionDef(
key="min_bar_size",
label="Min Bar Size (px)",
option_type="int",
default=20,
min_value=0,
max_value=200,
step=5,
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
threshold = self.options.get("threshold", 15)
min_bar_size = self.options.get("min_bar_size", 20)
h, w = image.shape[:2]
min_h = max(1, h // 10)
min_w = max(1, w // 10)
# Compute max channel value per row and per column (vectorized)
row_max = image.max(axis=(1, 2)) # shape (h,)
col_max = image.max(axis=(0, 2)) # shape (w,)
# Scan from top
top = 0
while top < h and row_max[top] <= threshold:
top += 1
# Scan from bottom
bottom = h
while bottom > top and row_max[bottom - 1] <= threshold:
bottom -= 1
# Scan from left
left = 0
while left < w and col_max[left] <= threshold:
left += 1
# Scan from right
right = w
while right > left and col_max[right - 1] <= threshold:
right -= 1
# Apply min_bar_size: only crop if the detected bar is large enough
if top < min_bar_size:
top = 0
if (h - bottom) < min_bar_size:
bottom = h
if left < min_bar_size:
left = 0
if (w - right) < min_bar_size:
right = w
# Safety: don't crop if remaining content is too small
if (bottom - top) < min_h:
top, bottom = 0, h
if (right - left) < min_w:
left, right = 0, w
# No crop needed
if top == 0 and bottom == h and left == 0 and right == w:
return None
cropped_h = bottom - top
cropped_w = right - left
channels = image.shape[2] if image.ndim == 3 else 3
result = image_pool.acquire(cropped_h, cropped_w, channels)
np.copyto(result, image[top:bottom, left:right])
return result

View File

@@ -0,0 +1,43 @@
"""Brightness postprocessing filter."""
from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool
from wled_controller.core.filters.registry import FilterRegistry
@FilterRegistry.register
class BrightnessFilter(PostprocessingFilter):
"""Adjusts image brightness by multiplying pixel values."""
filter_id = "brightness"
filter_name = "Brightness"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
value = self.options["value"]
lut = np.clip(np.arange(256, dtype=np.float32) * value, 0, 255)
self._lut = lut.astype(np.uint8)
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="value",
label="Brightness",
option_type="float",
default=1.0,
min_value=0.0,
max_value=2.0,
step=0.05,
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
if self.options["value"] == 1.0:
return None
image[:] = self._lut[image]
return None

View File

@@ -1,459 +0,0 @@
"""Built-in postprocessing filters."""
import math
from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool
from wled_controller.core.filters.registry import FilterRegistry
@FilterRegistry.register
class BrightnessFilter(PostprocessingFilter):
"""Adjusts image brightness by multiplying pixel values."""
filter_id = "brightness"
filter_name = "Brightness"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
value = self.options["value"]
lut = np.clip(np.arange(256, dtype=np.float32) * value, 0, 255)
self._lut = lut.astype(np.uint8)
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="value",
label="Brightness",
option_type="float",
default=1.0,
min_value=0.0,
max_value=2.0,
step=0.05,
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
if self.options["value"] == 1.0:
return None
image[:] = self._lut[image]
return None
@FilterRegistry.register
class SaturationFilter(PostprocessingFilter):
"""Adjusts color saturation via luminance blending."""
filter_id = "saturation"
filter_name = "Saturation"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
self._float_buf: Optional[np.ndarray] = None
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="value",
label="Saturation",
option_type="float",
default=1.0,
min_value=0.0,
max_value=2.0,
step=0.1,
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
value = self.options["value"]
if value == 1.0:
return None
h, w, c = image.shape
if self._float_buf is None or self._float_buf.shape != (h, w, c):
self._float_buf = np.empty((h, w, c), dtype=np.float32)
arr = self._float_buf
np.copyto(arr, image)
arr *= (1.0 / 255.0)
lum = np.dot(arr[..., :3], [0.299, 0.587, 0.114])[..., np.newaxis]
arr[..., :3] = lum + (arr[..., :3] - lum) * value
np.clip(arr, 0, 1.0, out=arr)
arr *= 255.0
np.copyto(image, arr, casting='unsafe')
return None
@FilterRegistry.register
class GammaFilter(PostprocessingFilter):
"""Applies gamma correction."""
filter_id = "gamma"
filter_name = "Gamma"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
value = self.options["value"]
lut = np.arange(256, dtype=np.float32) / 255.0
np.power(lut, 1.0 / value, out=lut)
self._lut = np.clip(lut * 255.0, 0, 255).astype(np.uint8)
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="value",
label="Gamma",
option_type="float",
default=2.2,
min_value=0.1,
max_value=5.0,
step=0.1,
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
if self.options["value"] == 1.0:
return None
image[:] = self._lut[image]
return None
@FilterRegistry.register
class DownscalerFilter(PostprocessingFilter):
"""Downscales image by a factor. Returns a new image from the pool."""
filter_id = "downscaler"
filter_name = "Downscaler"
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="factor",
label="Scale Factor",
option_type="float",
default=0.5,
min_value=0.1,
max_value=1.0,
step=0.05,
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
factor = self.options["factor"]
if factor >= 1.0:
return None
h, w = image.shape[:2]
new_h = max(1, int(h * factor))
new_w = max(1, int(w * factor))
if new_h == h and new_w == w:
return None
# Use OpenCV for fast downscaling (10-20x faster than PIL LANCZOS)
# INTER_AREA is optimal for downscaling - high quality and fast
import cv2
downscaled = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
result = image_pool.acquire(new_h, new_w, image.shape[2] if image.ndim == 3 else 3)
np.copyto(result, downscaled)
return result
@FilterRegistry.register
class PixelateFilter(PostprocessingFilter):
"""Pixelates the image by averaging blocks of pixels."""
filter_id = "pixelate"
filter_name = "Pixelate"
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="block_size",
label="Block Size",
option_type="int",
default=8,
min_value=2,
max_value=64,
step=1,
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
block_size = self.options["block_size"]
if block_size <= 1:
return None
h, w = image.shape[:2]
# Process each block: compute mean and fill
for y in range(0, h, block_size):
for x in range(0, w, block_size):
y_end = min(y + block_size, h)
x_end = min(x + block_size, w)
block = image[y:y_end, x:x_end]
mean_color = block.mean(axis=(0, 1)).astype(np.uint8)
image[y:y_end, x:x_end] = mean_color
return None
@FilterRegistry.register
class AutoCropFilter(PostprocessingFilter):
"""Detects and crops black bars (letterboxing/pillarboxing) from the image."""
filter_id = "auto_crop"
filter_name = "Auto Crop"
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="threshold",
label="Black Threshold",
option_type="int",
default=15,
min_value=0,
max_value=50,
step=1,
),
FilterOptionDef(
key="min_bar_size",
label="Min Bar Size (px)",
option_type="int",
default=20,
min_value=0,
max_value=200,
step=5,
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
threshold = self.options.get("threshold", 15)
min_bar_size = self.options.get("min_bar_size", 20)
h, w = image.shape[:2]
min_h = max(1, h // 10)
min_w = max(1, w // 10)
# Compute max channel value per row and per column (vectorized)
row_max = image.max(axis=(1, 2)) # shape (h,)
col_max = image.max(axis=(0, 2)) # shape (w,)
# Scan from top
top = 0
while top < h and row_max[top] <= threshold:
top += 1
# Scan from bottom
bottom = h
while bottom > top and row_max[bottom - 1] <= threshold:
bottom -= 1
# Scan from left
left = 0
while left < w and col_max[left] <= threshold:
left += 1
# Scan from right
right = w
while right > left and col_max[right - 1] <= threshold:
right -= 1
# Apply min_bar_size: only crop if the detected bar is large enough
if top < min_bar_size:
top = 0
if (h - bottom) < min_bar_size:
bottom = h
if left < min_bar_size:
left = 0
if (w - right) < min_bar_size:
right = w
# Safety: don't crop if remaining content is too small
if (bottom - top) < min_h:
top, bottom = 0, h
if (right - left) < min_w:
left, right = 0, w
# No crop needed
if top == 0 and bottom == h and left == 0 and right == w:
return None
cropped_h = bottom - top
cropped_w = right - left
channels = image.shape[2] if image.ndim == 3 else 3
result = image_pool.acquire(cropped_h, cropped_w, channels)
np.copyto(result, image[top:bottom, left:right])
return result
@FilterRegistry.register
class FlipFilter(PostprocessingFilter):
"""Flips the image horizontally and/or vertically."""
filter_id = "flip"
filter_name = "Flip"
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="horizontal",
label="Horizontal",
option_type="bool",
default=False,
min_value=None,
max_value=None,
step=None,
),
FilterOptionDef(
key="vertical",
label="Vertical",
option_type="bool",
default=False,
min_value=None,
max_value=None,
step=None,
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
h = self.options.get("horizontal", False)
v = self.options.get("vertical", False)
if not h and not v:
return None
height, width, c = image.shape
result = image_pool.acquire(height, width, c)
if h and v:
np.copyto(result, image[::-1, ::-1])
elif h:
np.copyto(result, image[:, ::-1])
else:
np.copyto(result, image[::-1])
return result
def _kelvin_to_rgb(kelvin: int) -> tuple:
"""Convert color temperature in Kelvin to normalized RGB multipliers.
Uses Tanner Helland's approximation, normalized so 6500K = (1, 1, 1).
"""
t = kelvin / 100.0
# Red
if t <= 66:
r = 255.0
else:
r = 329.698727446 * ((t - 60) ** -0.1332047592)
# Green
if t <= 66:
g = 99.4708025861 * math.log(t) - 161.1195681661
else:
g = 288.1221695283 * ((t - 60) ** -0.0755148492)
# Blue
if t >= 66:
b = 255.0
elif t <= 19:
b = 0.0
else:
b = 138.5177312231 * math.log(t - 10) - 305.0447927307
r = max(0.0, min(255.0, r))
g = max(0.0, min(255.0, g))
b = max(0.0, min(255.0, b))
return r / 255.0, g / 255.0, b / 255.0
# Pre-compute 6500K reference for normalization
_REF_R, _REF_G, _REF_B = _kelvin_to_rgb(6500)
@FilterRegistry.register
class ColorCorrectionFilter(PostprocessingFilter):
"""Adjusts color temperature and per-channel RGB gains using LUTs."""
filter_id = "color_correction"
filter_name = "Color Correction"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
temp = self.options["temperature"]
rg = self.options["red_gain"]
gg = self.options["green_gain"]
bg = self.options["blue_gain"]
# Color temperature → RGB multipliers, normalized to 6500K = (1,1,1)
tr, tg, tb = _kelvin_to_rgb(temp)
r_mult = (tr / _REF_R) * rg
g_mult = (tg / _REF_G) * gg
b_mult = (tb / _REF_B) * bg
# Build per-channel LUTs
src = np.arange(256, dtype=np.float32)
self._lut_r = np.clip(src * r_mult, 0, 255).astype(np.uint8)
self._lut_g = np.clip(src * g_mult, 0, 255).astype(np.uint8)
self._lut_b = np.clip(src * b_mult, 0, 255).astype(np.uint8)
self._is_neutral = (temp == 6500 and rg == 1.0 and gg == 1.0 and bg == 1.0)
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="temperature",
label="Color Temperature (K)",
option_type="int",
default=6500,
min_value=2000,
max_value=10000,
step=100,
),
FilterOptionDef(
key="red_gain",
label="Red Gain",
option_type="float",
default=1.0,
min_value=0.0,
max_value=2.0,
step=0.05,
),
FilterOptionDef(
key="green_gain",
label="Green Gain",
option_type="float",
default=1.0,
min_value=0.0,
max_value=2.0,
step=0.05,
),
FilterOptionDef(
key="blue_gain",
label="Blue Gain",
option_type="float",
default=1.0,
min_value=0.0,
max_value=2.0,
step=0.05,
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
if self._is_neutral:
return None
image[:, :, 0] = self._lut_r[image[:, :, 0]]
image[:, :, 1] = self._lut_g[image[:, :, 1]]
image[:, :, 2] = self._lut_b[image[:, :, 2]]
return None

View File

@@ -0,0 +1,126 @@
"""Color correction postprocessing filter."""
import math
from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool
from wled_controller.core.filters.registry import FilterRegistry
def _kelvin_to_rgb(kelvin: int) -> tuple:
"""Convert color temperature in Kelvin to normalized RGB multipliers.
Uses Tanner Helland's approximation, normalized so 6500K = (1, 1, 1).
"""
t = kelvin / 100.0
# Red
if t <= 66:
r = 255.0
else:
r = 329.698727446 * ((t - 60) ** -0.1332047592)
# Green
if t <= 66:
g = 99.4708025861 * math.log(t) - 161.1195681661
else:
g = 288.1221695283 * ((t - 60) ** -0.0755148492)
# Blue
if t >= 66:
b = 255.0
elif t <= 19:
b = 0.0
else:
b = 138.5177312231 * math.log(t - 10) - 305.0447927307
r = max(0.0, min(255.0, r))
g = max(0.0, min(255.0, g))
b = max(0.0, min(255.0, b))
return r / 255.0, g / 255.0, b / 255.0
# Pre-compute 6500K reference for normalization
_REF_R, _REF_G, _REF_B = _kelvin_to_rgb(6500)
@FilterRegistry.register
class ColorCorrectionFilter(PostprocessingFilter):
"""Adjusts color temperature and per-channel RGB gains using LUTs."""
filter_id = "color_correction"
filter_name = "Color Correction"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
temp = self.options["temperature"]
rg = self.options["red_gain"]
gg = self.options["green_gain"]
bg = self.options["blue_gain"]
# Color temperature -> RGB multipliers, normalized to 6500K = (1,1,1)
tr, tg, tb = _kelvin_to_rgb(temp)
r_mult = (tr / _REF_R) * rg
g_mult = (tg / _REF_G) * gg
b_mult = (tb / _REF_B) * bg
# Build per-channel LUTs
src = np.arange(256, dtype=np.float32)
self._lut_r = np.clip(src * r_mult, 0, 255).astype(np.uint8)
self._lut_g = np.clip(src * g_mult, 0, 255).astype(np.uint8)
self._lut_b = np.clip(src * b_mult, 0, 255).astype(np.uint8)
self._is_neutral = (temp == 6500 and rg == 1.0 and gg == 1.0 and bg == 1.0)
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="temperature",
label="Color Temperature (K)",
option_type="int",
default=6500,
min_value=2000,
max_value=10000,
step=100,
),
FilterOptionDef(
key="red_gain",
label="Red Gain",
option_type="float",
default=1.0,
min_value=0.0,
max_value=2.0,
step=0.05,
),
FilterOptionDef(
key="green_gain",
label="Green Gain",
option_type="float",
default=1.0,
min_value=0.0,
max_value=2.0,
step=0.05,
),
FilterOptionDef(
key="blue_gain",
label="Blue Gain",
option_type="float",
default=1.0,
min_value=0.0,
max_value=2.0,
step=0.05,
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
if self._is_neutral:
return None
image[:, :, 0] = self._lut_r[image[:, :, 0]]
image[:, :, 1] = self._lut_g[image[:, :, 1]]
image[:, :, 2] = self._lut_b[image[:, :, 2]]
return None

View File

@@ -0,0 +1,50 @@
"""Downscaler postprocessing filter."""
from typing import Any, Dict, List, Optional
import cv2
import numpy as np
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool
from wled_controller.core.filters.registry import FilterRegistry
@FilterRegistry.register
class DownscalerFilter(PostprocessingFilter):
"""Downscales image by a factor. Returns a new image from the pool."""
filter_id = "downscaler"
filter_name = "Downscaler"
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="factor",
label="Scale Factor",
option_type="float",
default=0.5,
min_value=0.1,
max_value=1.0,
step=0.05,
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
factor = self.options["factor"]
if factor >= 1.0:
return None
h, w = image.shape[:2]
new_h = max(1, int(h * factor))
new_w = max(1, int(w * factor))
if new_h == h and new_w == w:
return None
downscaled = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
result = image_pool.acquire(new_h, new_w, image.shape[2] if image.ndim == 3 else 3)
np.copyto(result, downscaled)
return result

View File

@@ -0,0 +1,55 @@
"""Flip postprocessing filter."""
from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool
from wled_controller.core.filters.registry import FilterRegistry
@FilterRegistry.register
class FlipFilter(PostprocessingFilter):
"""Flips the image horizontally and/or vertically."""
filter_id = "flip"
filter_name = "Flip"
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="horizontal",
label="Horizontal",
option_type="bool",
default=False,
min_value=None,
max_value=None,
step=None,
),
FilterOptionDef(
key="vertical",
label="Vertical",
option_type="bool",
default=False,
min_value=None,
max_value=None,
step=None,
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
h = self.options.get("horizontal", False)
v = self.options.get("vertical", False)
if not h and not v:
return None
height, width, c = image.shape
result = image_pool.acquire(height, width, c)
if h and v:
np.copyto(result, image[::-1, ::-1])
elif h:
np.copyto(result, image[:, ::-1])
else:
np.copyto(result, image[::-1])
return result

View File

@@ -0,0 +1,44 @@
"""Gamma correction postprocessing filter."""
from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool
from wled_controller.core.filters.registry import FilterRegistry
@FilterRegistry.register
class GammaFilter(PostprocessingFilter):
"""Applies gamma correction."""
filter_id = "gamma"
filter_name = "Gamma"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
value = self.options["value"]
lut = np.arange(256, dtype=np.float32) / 255.0
np.power(lut, 1.0 / value, out=lut)
self._lut = np.clip(lut * 255.0, 0, 255).astype(np.uint8)
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="value",
label="Gamma",
option_type="float",
default=2.2,
min_value=0.1,
max_value=5.0,
step=0.1,
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
if self.options["value"] == 1.0:
return None
image[:] = self._lut[image]
return None

View File

@@ -0,0 +1,48 @@
"""Pixelate postprocessing filter."""
from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool
from wled_controller.core.filters.registry import FilterRegistry
@FilterRegistry.register
class PixelateFilter(PostprocessingFilter):
"""Pixelates the image by averaging blocks of pixels."""
filter_id = "pixelate"
filter_name = "Pixelate"
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="block_size",
label="Block Size",
option_type="int",
default=8,
min_value=2,
max_value=64,
step=1,
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
block_size = self.options["block_size"]
if block_size <= 1:
return None
h, w = image.shape[:2]
for y in range(0, h, block_size):
for x in range(0, w, block_size):
y_end = min(y + block_size, h)
x_end = min(x + block_size, w)
block = image[y:y_end, x:x_end]
mean_color = block.mean(axis=(0, 1)).astype(np.uint8)
image[y:y_end, x:x_end] = mean_color
return None

View File

@@ -0,0 +1,52 @@
"""Saturation postprocessing filter."""
from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool
from wled_controller.core.filters.registry import FilterRegistry
@FilterRegistry.register
class SaturationFilter(PostprocessingFilter):
"""Adjusts color saturation via luminance blending."""
filter_id = "saturation"
filter_name = "Saturation"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
self._float_buf: Optional[np.ndarray] = None
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="value",
label="Saturation",
option_type="float",
default=1.0,
min_value=0.0,
max_value=2.0,
step=0.1,
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
value = self.options["value"]
if value == 1.0:
return None
h, w, c = image.shape
if self._float_buf is None or self._float_buf.shape != (h, w, c):
self._float_buf = np.empty((h, w, c), dtype=np.float32)
arr = self._float_buf
np.copyto(arr, image)
arr *= (1.0 / 255.0)
lum = np.dot(arr[..., :3], [0.299, 0.587, 0.114])[..., np.newaxis]
arr[..., :3] = lum + (arr[..., :3] - lum) * value
np.clip(arr, 0, 1.0, out=arr)
arr *= 255.0
np.copyto(image, arr, casting='unsafe')
return None

View File

@@ -0,0 +1,23 @@
"""Target processing pipeline."""
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.processing.processing_settings import (
DEFAULT_STATE_CHECK_INTERVAL,
ProcessingSettings,
)
from wled_controller.core.processing.target_processor import (
DeviceInfo,
ProcessingMetrics,
TargetContext,
TargetProcessor,
)
__all__ = [
"DEFAULT_STATE_CHECK_INTERVAL",
"DeviceInfo",
"ProcessingMetrics",
"ProcessingSettings",
"ProcessorManager",
"TargetContext",
"TargetProcessor",
]

View File

@@ -0,0 +1,419 @@
"""Key Colors target processor — extracts dominant colors from screen regions."""
from __future__ import annotations
import asyncio
import collections
import json
import time
from datetime import datetime
from typing import Dict, List, Optional, Tuple
import cv2
import numpy as np
from wled_controller.core.processing.live_stream import LiveStream
from wled_controller.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
)
from wled_controller.core.processing.target_processor import (
ProcessingMetrics,
TargetContext,
TargetProcessor,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
KC_WORK_SIZE = (160, 90) # (width, height) — small enough for fast color calc
# ---------------------------------------------------------------------------
# CPU-bound frame processing (runs in thread pool via asyncio.to_thread)
# ---------------------------------------------------------------------------
def _process_kc_frame(capture, rect_names, rect_bounds, calc_fn, prev_colors_arr, smoothing):
"""All CPU-bound work for one KC frame.
Returns (colors, colors_arr, timing_ms) where:
- colors is a dict {name: (r, g, b)}
- colors_arr is a (N, 3) float64 array for smoothing continuity
- timing_ms is a dict with per-stage timing in milliseconds.
"""
t0 = time.perf_counter()
# Downsample to working resolution — 144x fewer pixels at 1080p
small = cv2.resize(capture.image, KC_WORK_SIZE, interpolation=cv2.INTER_AREA)
# Extract colors for each rectangle from the small image
n = len(rect_names)
colors_arr = np.empty((n, 3), dtype=np.float64)
for i, (y1, y2, x1, x2) in enumerate(rect_bounds):
colors_arr[i] = calc_fn(small[y1:y2, x1:x2])
t1 = time.perf_counter()
# Vectorized smoothing on (N, 3) array
if prev_colors_arr is not None and smoothing > 0:
colors_arr = colors_arr * (1 - smoothing) + prev_colors_arr * smoothing
colors_u8 = np.clip(colors_arr, 0, 255).astype(np.uint8)
t2 = time.perf_counter()
# Build output dict
colors = {rect_names[i]: tuple(int(c) for c in colors_u8[i]) for i in range(n)}
timing_ms = {
"calc_colors": (t1 - t0) * 1000,
"smooth": (t2 - t1) * 1000,
"total": (t2 - t0) * 1000,
}
return colors, colors_arr, timing_ms
# ---------------------------------------------------------------------------
# KCTargetProcessor
# ---------------------------------------------------------------------------
class KCTargetProcessor(TargetProcessor):
"""Extracts key colors from screen capture regions and broadcasts via WebSocket."""
def __init__(
self,
target_id: str,
picture_source_id: str,
settings, # KeyColorsSettings
ctx: TargetContext,
):
super().__init__(target_id, picture_source_id, ctx)
self._settings = settings
# Runtime state
self._live_stream: Optional[LiveStream] = None
self._previous_colors: Optional[Dict[str, Tuple[int, int, int]]] = None
self._latest_colors: Optional[Dict[str, Tuple[int, int, int]]] = None
self._ws_clients: List = []
self._resolved_target_fps: Optional[int] = None
self._resolved_rectangles = None
# ----- Properties -----
@property
def settings(self):
return self._settings
# ----- Lifecycle -----
async def start(self) -> None:
if self._is_running:
logger.debug(f"KC target {self._target_id} is already running")
return
if not self._picture_source_id:
raise ValueError(f"KC target {self._target_id} has no picture source assigned")
if not self._settings.pattern_template_id:
raise ValueError(f"KC target {self._target_id} has no pattern template assigned")
# Resolve pattern template to get rectangles
try:
pattern_template = self._ctx.pattern_template_store.get_template(
self._settings.pattern_template_id
)
except (ValueError, AttributeError):
raise ValueError(
f"Pattern template {self._settings.pattern_template_id} not found"
)
if not pattern_template.rectangles:
raise ValueError(
f"Pattern template {self._settings.pattern_template_id} has no rectangles"
)
self._resolved_rectangles = pattern_template.rectangles
# Acquire live stream
try:
live_stream = await asyncio.to_thread(
self._ctx.live_stream_manager.acquire, self._picture_source_id
)
self._live_stream = live_stream
self._resolved_target_fps = live_stream.target_fps
logger.info(
f"Acquired live stream for KC target {self._target_id} "
f"(picture_source={self._picture_source_id})"
)
except Exception as e:
logger.error(f"Failed to initialize live stream for KC target {self._target_id}: {e}")
raise RuntimeError(f"Failed to initialize live stream: {e}")
# Reset metrics
self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
self._previous_colors = None
self._latest_colors = None
# Start processing task
self._task = asyncio.create_task(self._processing_loop())
self._is_running = True
logger.info(f"Started KC processing for target {self._target_id}")
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": True})
async def stop(self) -> None:
if not self._is_running:
logger.warning(f"KC processing not running for target {self._target_id}")
return
self._is_running = False
# Cancel task
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
# Release live stream
if self._live_stream:
try:
self._ctx.live_stream_manager.release(self._picture_source_id)
except Exception as e:
logger.warning(f"Error releasing live stream for KC target: {e}")
self._live_stream = None
logger.info(f"Stopped KC processing for target {self._target_id}")
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False})
# ----- Settings -----
def update_settings(self, settings) -> None:
self._settings = settings
logger.info(f"Updated KC target settings: {self._target_id}")
# ----- State / Metrics -----
def get_state(self) -> dict:
metrics = self._metrics
return {
"target_id": self._target_id,
"processing": self._is_running,
"fps_actual": round(metrics.fps_actual, 1) if self._is_running else None,
"fps_potential": metrics.fps_potential if self._is_running else None,
"fps_target": self._settings.fps,
"frames_skipped": metrics.frames_skipped if self._is_running else None,
"frames_keepalive": metrics.frames_keepalive if self._is_running else None,
"fps_current": metrics.fps_current if self._is_running else None,
"timing_calc_colors_ms": round(metrics.timing_calc_colors_ms, 1) if self._is_running else None,
"timing_smooth_ms": round(metrics.timing_smooth_ms, 1) if self._is_running else None,
"timing_broadcast_ms": round(metrics.timing_broadcast_ms, 1) if self._is_running else None,
"timing_total_ms": round(metrics.timing_total_ms, 1) if self._is_running else None,
"last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [],
}
def get_metrics(self) -> dict:
metrics = self._metrics
uptime = 0.0
if metrics.start_time and self._is_running:
uptime = (datetime.utcnow() - metrics.start_time).total_seconds()
return {
"target_id": self._target_id,
"processing": self._is_running,
"fps_actual": round(metrics.fps_actual, 1),
"fps_target": self._settings.fps,
"uptime_seconds": round(uptime, 1),
"frames_processed": metrics.frames_processed,
"errors_count": metrics.errors_count,
"last_error": metrics.last_error,
"last_update": metrics.last_update.isoformat() if metrics.last_update else None,
}
# ----- WebSocket -----
def supports_websocket(self) -> bool:
return True
def add_ws_client(self, ws) -> None:
self._ws_clients.append(ws)
def remove_ws_client(self, ws) -> None:
if ws in self._ws_clients:
self._ws_clients.remove(ws)
def get_latest_colors(self) -> Dict[str, Tuple[int, int, int]]:
return self._latest_colors or {}
# ----- Private: processing loop -----
async def _processing_loop(self) -> None:
"""Main processing loop for key-colors extraction."""
settings = self._settings
target_fps = settings.fps
smoothing = settings.smoothing
# Select color calculation function
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
frame_time = 1.0 / target_fps
fps_samples: List[float] = []
timing_samples: collections.deque = collections.deque(maxlen=10)
prev_frame_time_stamp = time.time()
prev_capture = None
last_broadcast_time = 0.0
send_timestamps: collections.deque = collections.deque()
rectangles = self._resolved_rectangles
# Pre-compute pixel bounds at working resolution (160x90)
kc_w, kc_h = KC_WORK_SIZE
rect_names = [r.name for r in rectangles]
rect_bounds = []
for rect in rectangles:
px_x = max(0, int(rect.x * kc_w))
px_y = max(0, int(rect.y * kc_h))
px_w = max(1, int(rect.width * kc_w))
px_h = max(1, int(rect.height * kc_h))
px_x = min(px_x, kc_w - 1)
px_y = min(px_y, kc_h - 1)
px_w = min(px_w, kc_w - px_x)
px_h = min(px_h, kc_h - px_y)
rect_bounds.append((px_y, px_y + px_h, px_x, px_x + px_w))
prev_colors_arr = None
logger.info(
f"KC processing loop started for target {self._target_id} "
f"(fps={target_fps}, rects={len(rectangles)})"
)
try:
while self._is_running:
loop_start = time.time()
try:
capture = self._live_stream.get_latest_frame()
if capture is None:
await asyncio.sleep(frame_time)
continue
# Skip processing if the frame hasn't changed
if capture is prev_capture:
# Keepalive: re-broadcast last colors
if self._latest_colors and (loop_start - last_broadcast_time) >= 1.0:
await self._broadcast_colors(self._latest_colors)
last_broadcast_time = time.time()
send_timestamps.append(last_broadcast_time)
self._metrics.frames_keepalive += 1
self._metrics.frames_skipped += 1
now_ts = time.time()
while send_timestamps and send_timestamps[0] < now_ts - 1.0:
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
await asyncio.sleep(frame_time)
continue
prev_capture = capture
# CPU-bound work in thread pool
colors, colors_arr, frame_timing = await asyncio.to_thread(
_process_kc_frame,
capture, rect_names, rect_bounds, calc_fn,
prev_colors_arr, smoothing,
)
prev_colors_arr = colors_arr
self._latest_colors = dict(colors)
# Broadcast to WebSocket clients
t_broadcast_start = time.perf_counter()
await self._broadcast_colors(colors)
broadcast_ms = (time.perf_counter() - t_broadcast_start) * 1000
last_broadcast_time = time.time()
send_timestamps.append(last_broadcast_time)
# Per-stage timing (rolling average over last 10 frames)
frame_timing["broadcast"] = broadcast_ms
timing_samples.append(frame_timing)
n = len(timing_samples)
self._metrics.timing_calc_colors_ms = sum(s["calc_colors"] for s in timing_samples) / n
self._metrics.timing_smooth_ms = sum(s["smooth"] for s in timing_samples) / n
self._metrics.timing_broadcast_ms = sum(s["broadcast"] for s in timing_samples) / n
self._metrics.timing_total_ms = sum(s["total"] for s in timing_samples) / n + broadcast_ms
# Update metrics
self._metrics.frames_processed += 1
self._metrics.last_update = datetime.utcnow()
# Calculate actual FPS
now = time.time()
interval = now - prev_frame_time_stamp
prev_frame_time_stamp = now
fps_samples.append(1.0 / interval if interval > 0 else 0)
if len(fps_samples) > 10:
fps_samples.pop(0)
self._metrics.fps_actual = sum(fps_samples) / len(fps_samples)
# Potential FPS
processing_time = now - loop_start
self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0
# fps_current
while send_timestamps and send_timestamps[0] < now - 1.0:
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
except Exception as e:
self._metrics.errors_count += 1
self._metrics.last_error = str(e)
logger.error(f"KC processing error for {self._target_id}: {e}", exc_info=True)
# Throttle to target FPS
elapsed = time.time() - loop_start
remaining = frame_time - elapsed
if remaining > 0:
await asyncio.sleep(remaining)
except asyncio.CancelledError:
logger.info(f"KC processing loop cancelled for target {self._target_id}")
raise
except Exception as e:
logger.error(f"Fatal error in KC processing loop for target {self._target_id}: {e}")
self._is_running = False
raise
finally:
logger.info(f"KC processing loop ended for target {self._target_id}")
async def _broadcast_colors(self, colors: Dict[str, Tuple[int, int, int]]) -> None:
"""Broadcast extracted colors to WebSocket clients."""
if not self._ws_clients:
return
message = json.dumps({
"type": "colors_update",
"target_id": self._target_id,
"colors": {
name: {"r": c[0], "g": c[1], "b": c[2]}
for name, c in colors.items()
},
"timestamp": datetime.utcnow().isoformat(),
})
disconnected = []
for ws in self._ws_clients:
try:
await ws.send_text(message)
except Exception:
disconnected.append(ws)
for ws in disconnected:
self._ws_clients.remove(ws)

View File

@@ -16,7 +16,7 @@ import numpy as np
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, PostprocessingFilter
from wled_controller.core.live_stream import (
from wled_controller.core.processing.live_stream import (
LiveStream,
ProcessedLiveStream,
ScreenCaptureLiveStream,

View File

@@ -0,0 +1,20 @@
"""Processing settings shared across target types."""
from dataclasses import dataclass
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
@dataclass
class ProcessingSettings:
"""Settings for screen processing."""
display_index: int = 0
fps: int = 30
brightness: float = 1.0
gamma: float = 2.2
saturation: float = 1.0
smoothing: float = 0.3
interpolation_mode: str = "average"
standby_interval: float = 1.0 # seconds between keepalive sends when screen is static
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL

View File

@@ -0,0 +1,701 @@
"""Processing manager — thin orchestrator for devices and target processors."""
import asyncio
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
import httpx
from wled_controller.core.capture.calibration import (
CalibrationConfig,
create_default_calibration,
)
from wled_controller.core.devices.led_client import (
DeviceHealth,
check_device_health,
create_led_client,
get_provider,
)
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
from wled_controller.core.capture.screen_overlay import OverlayManager
from wled_controller.core.processing.processing_settings import (
DEFAULT_STATE_CHECK_INTERVAL,
ProcessingSettings,
)
from wled_controller.core.processing.target_processor import (
DeviceInfo,
TargetContext,
TargetProcessor,
)
from wled_controller.core.processing.wled_target_processor import WledTargetProcessor
from wled_controller.core.processing.kc_target_processor import KCTargetProcessor
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@dataclass
class DeviceState:
"""State for a registered LED device (health monitoring + calibration)."""
device_id: str
device_url: str
led_count: int
calibration: CalibrationConfig
device_type: str = "wled"
baud_rate: Optional[int] = None
health: DeviceHealth = field(default_factory=DeviceHealth)
health_task: Optional[asyncio.Task] = None
# Software brightness for devices without hardware brightness (e.g. Adalight)
software_brightness: int = 255
# Auto-shutdown: turn off device when server stops
auto_shutdown: bool = False
# Calibration test mode (works independently of target processing)
test_mode_active: bool = False
test_mode_edges: Dict[str, Tuple[int, int, int]] = field(default_factory=dict)
class ProcessorManager:
"""Manages devices and delegates target processing to TargetProcessor instances.
Devices are registered for health monitoring and calibration.
Targets are registered for processing via polymorphic TargetProcessor subclasses.
"""
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None):
"""Initialize processor manager."""
self._devices: Dict[str, DeviceState] = {}
self._processors: Dict[str, TargetProcessor] = {}
self._health_monitoring_active = False
self._http_client: Optional[httpx.AsyncClient] = None
self._picture_source_store = picture_source_store
self._capture_template_store = capture_template_store
self._pp_template_store = pp_template_store
self._pattern_template_store = pattern_template_store
self._device_store = device_store
self._live_stream_manager = LiveStreamManager(
picture_source_store, capture_template_store, pp_template_store
)
self._overlay_manager = OverlayManager()
self._event_queues: List[asyncio.Queue] = []
logger.info("Processor manager initialized")
# ===== SHARED CONTEXT (passed to target processors) =====
def _build_context(self) -> TargetContext:
"""Build a TargetContext for target processors."""
return TargetContext(
live_stream_manager=self._live_stream_manager,
overlay_manager=self._overlay_manager,
picture_source_store=self._picture_source_store,
capture_template_store=self._capture_template_store,
pp_template_store=self._pp_template_store,
pattern_template_store=self._pattern_template_store,
device_store=self._device_store,
fire_event=self._fire_event,
get_device_info=self._get_device_info,
)
def _get_device_info(self, device_id: str) -> Optional[DeviceInfo]:
"""Create a DeviceInfo snapshot from the current device state."""
ds = self._devices.get(device_id)
if ds is None:
return None
return DeviceInfo(
device_id=ds.device_id,
device_url=ds.device_url,
led_count=ds.led_count,
calibration=ds.calibration,
device_type=ds.device_type,
baud_rate=ds.baud_rate,
software_brightness=ds.software_brightness,
test_mode_active=ds.test_mode_active,
)
# ===== EVENT SYSTEM (state change notifications) =====
def subscribe_events(self) -> asyncio.Queue:
"""Subscribe to state change events. Returns queue to read from."""
queue: asyncio.Queue = asyncio.Queue(maxsize=64)
self._event_queues.append(queue)
return queue
def unsubscribe_events(self, queue: asyncio.Queue) -> None:
"""Unsubscribe from events."""
if queue in self._event_queues:
self._event_queues.remove(queue)
def _fire_event(self, event: dict) -> None:
"""Push event to all subscribers (non-blocking)."""
for q in self._event_queues:
try:
q.put_nowait(event)
except asyncio.QueueFull:
pass
async def _get_http_client(self) -> httpx.AsyncClient:
"""Get or create a shared HTTP client for health checks."""
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(timeout=5)
return self._http_client
# ===== DEVICE MANAGEMENT (health monitoring + calibration) =====
def add_device(
self,
device_id: str,
device_url: str,
led_count: int,
calibration: Optional[CalibrationConfig] = None,
device_type: str = "wled",
baud_rate: Optional[int] = None,
software_brightness: int = 255,
auto_shutdown: bool = False,
):
"""Register a device for health monitoring."""
if device_id in self._devices:
raise ValueError(f"Device {device_id} already registered")
if calibration is None:
calibration = create_default_calibration(led_count)
state = DeviceState(
device_id=device_id,
device_url=device_url,
led_count=led_count,
calibration=calibration,
device_type=device_type,
baud_rate=baud_rate,
software_brightness=software_brightness,
auto_shutdown=auto_shutdown,
)
self._devices[device_id] = state
if self._health_monitoring_active:
self._start_device_health_check(device_id)
logger.info(f"Registered device {device_id} with {led_count} LEDs")
def remove_device(self, device_id: str):
"""Unregister a device."""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
# Check if any processor is using this device
for proc in self._processors.values():
if isinstance(proc, WledTargetProcessor) and proc.device_id == device_id:
raise RuntimeError(
f"Cannot remove device {device_id}: target {proc.target_id} is using it"
)
self._stop_device_health_check(device_id)
del self._devices[device_id]
logger.info(f"Unregistered device {device_id}")
def update_device_info(self, device_id: str, device_url: Optional[str] = None, led_count: Optional[int] = None, baud_rate: Optional[int] = None):
"""Update device connection info."""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
ds = self._devices[device_id]
if device_url is not None:
ds.device_url = device_url
if led_count is not None:
ds.led_count = led_count
if baud_rate is not None:
ds.baud_rate = baud_rate
def update_calibration(self, device_id: str, calibration: CalibrationConfig):
"""Update calibration for a device.
Also propagates to any WledTargetProcessor using this device.
"""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
calibration.validate()
ds = self._devices[device_id]
if calibration.get_total_leds() != ds.led_count:
raise ValueError(
f"Calibration LED count ({calibration.get_total_leds()}) "
f"does not match device LED count ({ds.led_count})"
)
ds.calibration = calibration
# Propagate to active WLED processors
for proc in self._processors.values():
if isinstance(proc, WledTargetProcessor) and proc.device_id == device_id:
proc.update_calibration(calibration)
logger.info(f"Updated calibration for device {device_id}")
def get_device_state(self, device_id: str) -> DeviceState:
"""Get device state (for health/calibration info)."""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
return self._devices[device_id]
def get_device_health(self, device_id: str) -> dict:
"""Get health status for a device."""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
h = self._devices[device_id].health
return {
"online": h.online,
"latency_ms": h.latency_ms,
"last_checked": h.last_checked,
"device_name": h.device_name,
"device_version": h.device_version,
"device_led_count": h.device_led_count,
"device_rgbw": h.device_rgbw,
"device_led_type": h.device_led_type,
"device_fps": h.device_fps,
"error": h.error,
}
def get_device_health_dict(self, device_id: str) -> dict:
"""Get device connection/health state as a state response dict."""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
ds = self._devices[device_id]
h = ds.health
return {
"device_id": device_id,
"device_online": h.online,
"device_latency_ms": h.latency_ms,
"device_name": h.device_name,
"device_version": h.device_version,
"device_led_count": h.device_led_count,
"device_rgbw": h.device_rgbw,
"device_led_type": h.device_led_type,
"device_fps": h.device_fps,
"device_last_checked": h.last_checked,
"device_error": h.error,
"test_mode": ds.test_mode_active,
"test_mode_edges": list(ds.test_mode_edges.keys()),
}
def get_all_devices(self) -> List[str]:
"""Get list of all registered device IDs."""
return list(self._devices.keys())
# ===== TARGET REGISTRATION =====
def add_target(
self,
target_id: str,
device_id: str,
settings: Optional[ProcessingSettings] = None,
picture_source_id: str = "",
):
"""Register a WLED target processor."""
if target_id in self._processors:
raise ValueError(f"Target {target_id} already registered")
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not registered")
proc = WledTargetProcessor(
target_id=target_id,
device_id=device_id,
settings=settings or ProcessingSettings(),
picture_source_id=picture_source_id,
ctx=self._build_context(),
)
self._processors[target_id] = proc
logger.info(f"Registered target {target_id} for device {device_id}")
def add_kc_target(self, target_id: str, picture_source_id: str, settings) -> None:
"""Register a key-colors target processor."""
if target_id in self._processors:
raise ValueError(f"KC target {target_id} already registered")
proc = KCTargetProcessor(
target_id=target_id,
picture_source_id=picture_source_id,
settings=settings,
ctx=self._build_context(),
)
self._processors[target_id] = proc
logger.info(f"Registered KC target: {target_id}")
def remove_target(self, target_id: str):
"""Unregister a target (any type)."""
if target_id not in self._processors:
raise ValueError(f"Target {target_id} not found")
proc = self._processors[target_id]
if proc.is_running:
raise RuntimeError(f"Cannot remove target {target_id} while processing")
del self._processors[target_id]
logger.info(f"Unregistered target {target_id}")
# Backward-compat alias
def remove_kc_target(self, target_id: str) -> None:
self.remove_target(target_id)
# ===== UNIFIED TARGET OPERATIONS =====
def update_target_settings(self, target_id: str, settings):
"""Update processing settings for a target (any type)."""
proc = self._get_processor(target_id)
proc.update_settings(settings)
def update_target_source(self, target_id: str, picture_source_id: str):
"""Update the picture source for a target (any type)."""
proc = self._get_processor(target_id)
proc.update_source(picture_source_id)
def update_target_device(self, target_id: str, device_id: str):
"""Update the device for a WLED target."""
proc = self._get_processor(target_id)
if not isinstance(proc, WledTargetProcessor):
raise ValueError(f"Target {target_id} is not a WLED target")
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not registered")
proc.update_device(device_id)
async def start_processing(self, target_id: str):
"""Start processing for a target (any type)."""
proc = self._get_processor(target_id)
# Enforce one-target-per-device for WLED targets
if isinstance(proc, WledTargetProcessor):
for other_id, other in self._processors.items():
if (
other_id != target_id
and isinstance(other, WledTargetProcessor)
and other.device_id == proc.device_id
and other.is_running
):
raise RuntimeError(
f"Device {proc.device_id} is already being processed by target {other_id}"
)
await proc.start()
async def stop_processing(self, target_id: str):
"""Stop processing for a target (any type)."""
proc = self._get_processor(target_id)
await proc.stop()
def get_target_state(self, target_id: str) -> dict:
"""Get current processing state for a target (any type).
For WLED targets, device health info is merged in.
"""
proc = self._get_processor(target_id)
state = proc.get_state()
# Merge device health for WLED targets
if isinstance(proc, WledTargetProcessor) and proc.device_id in self._devices:
h = self._devices[proc.device_id].health
state.update({
"device_online": h.online,
"device_latency_ms": h.latency_ms,
"device_name": h.device_name,
"device_version": h.device_version,
"device_led_count": h.device_led_count,
"device_rgbw": h.device_rgbw,
"device_led_type": h.device_led_type,
"device_fps": h.device_fps,
"device_last_checked": h.last_checked,
"device_error": h.error,
})
return state
def get_target_metrics(self, target_id: str) -> dict:
"""Get detailed metrics for a target (any type)."""
return self._get_processor(target_id).get_metrics()
def is_target_processing(self, target_id: str) -> bool:
"""Check if target is currently processing."""
return self._get_processor(target_id).is_running
def is_device_processing(self, device_id: str) -> bool:
"""Check if any target is processing for a device."""
for proc in self._processors.values():
if isinstance(proc, WledTargetProcessor) and proc.device_id == device_id and proc.is_running:
return True
return False
def get_processing_target_for_device(self, device_id: str) -> Optional[str]:
"""Get the target_id that is currently processing for a device."""
for proc in self._processors.values():
if isinstance(proc, WledTargetProcessor) and proc.device_id == device_id and proc.is_running:
return proc.target_id
return None
# Backward-compat aliases for KC-specific operations
def update_kc_target_settings(self, target_id: str, settings) -> None:
self.update_target_settings(target_id, settings)
def update_kc_target_source(self, target_id: str, picture_source_id: str) -> None:
self.update_target_source(target_id, picture_source_id)
async def start_kc_processing(self, target_id: str) -> None:
await self.start_processing(target_id)
async def stop_kc_processing(self, target_id: str) -> None:
await self.stop_processing(target_id)
def get_kc_target_state(self, target_id: str) -> dict:
return self.get_target_state(target_id)
def get_kc_target_metrics(self, target_id: str) -> dict:
return self.get_target_metrics(target_id)
def is_kc_target(self, target_id: str) -> bool:
"""Check if a target ID belongs to a KC target."""
return isinstance(self._processors.get(target_id), KCTargetProcessor)
# ===== OVERLAY VISUALIZATION (delegates to processor) =====
async def start_overlay(self, target_id: str, target_name: str = None) -> None:
proc = self._get_processor(target_id)
if not proc.supports_overlay():
raise ValueError(f"Target {target_id} does not support overlays")
await proc.start_overlay(target_name)
async def stop_overlay(self, target_id: str) -> None:
proc = self._get_processor(target_id)
await proc.stop_overlay()
def is_overlay_active(self, target_id: str) -> bool:
return self._get_processor(target_id).is_overlay_active()
# ===== WEBSOCKET (delegates to processor) =====
def add_kc_ws_client(self, target_id: str, ws) -> None:
proc = self._get_processor(target_id)
proc.add_ws_client(ws)
def remove_kc_ws_client(self, target_id: str, ws) -> None:
proc = self._processors.get(target_id)
if proc:
proc.remove_ws_client(ws)
def get_kc_latest_colors(self, target_id: str) -> Dict[str, Tuple[int, int, int]]:
proc = self._get_processor(target_id)
return proc.get_latest_colors()
# ===== CALIBRATION TEST MODE (on device) =====
async def set_test_mode(self, device_id: str, edges: Dict[str, List[int]]) -> None:
"""Set or clear calibration test mode for a device."""
if device_id not in self._devices:
raise ValueError(f"Device {device_id} not found")
ds = self._devices[device_id]
if edges:
ds.test_mode_active = True
ds.test_mode_edges = {
edge: tuple(color) for edge, color in edges.items()
}
await self._send_test_pixels(device_id)
else:
ds.test_mode_active = False
ds.test_mode_edges = {}
await self._send_clear_pixels(device_id)
async def _send_test_pixels(self, device_id: str) -> None:
"""Build and send test pixel array for active test edges."""
ds = self._devices[device_id]
pixels = [(0, 0, 0)] * ds.led_count
for edge_name, color in ds.test_mode_edges.items():
for seg in ds.calibration.segments:
if seg.edge == edge_name:
for i in range(seg.led_start, seg.led_start + seg.led_count):
if i < ds.led_count:
pixels[i] = color
break
try:
active_client = self._find_active_led_client(device_id)
if active_client:
await active_client.send_pixels(pixels)
else:
async with create_led_client(ds.device_type, ds.device_url, use_ddp=True, led_count=ds.led_count, baud_rate=ds.baud_rate) as client:
await client.send_pixels(pixels)
except Exception as e:
logger.error(f"Failed to send test pixels for {device_id}: {e}")
async def _send_clear_pixels(self, device_id: str) -> None:
"""Send all-black pixels to clear LED output."""
ds = self._devices[device_id]
pixels = [(0, 0, 0)] * ds.led_count
try:
active_client = self._find_active_led_client(device_id)
if active_client:
await active_client.send_pixels(pixels)
else:
async with create_led_client(ds.device_type, ds.device_url, use_ddp=True, led_count=ds.led_count, baud_rate=ds.baud_rate) as client:
await client.send_pixels(pixels)
except Exception as e:
logger.error(f"Failed to clear pixels for {device_id}: {e}")
def _find_active_led_client(self, device_id: str):
"""Find an active LED client for a device (from a running WLED processor)."""
for proc in self._processors.values():
if isinstance(proc, WledTargetProcessor) and proc.device_id == device_id and proc.is_running and proc.led_client:
return proc.led_client
return None
# ===== DISPLAY LOCK INFO =====
def is_display_locked(self, display_index: int) -> bool:
"""Check if a display is currently being captured by any target."""
for proc in self._processors.values():
if isinstance(proc, WledTargetProcessor) and proc.is_running and proc.settings.display_index == display_index:
return True
return False
def get_display_lock_info(self, display_index: int) -> Optional[str]:
"""Get the device ID that is currently capturing from a display."""
for proc in self._processors.values():
if isinstance(proc, WledTargetProcessor) and proc.is_running and proc.settings.display_index == display_index:
return proc.device_id
return None
# ===== LIFECYCLE =====
async def stop_all(self):
"""Stop processing and health monitoring for all targets and devices."""
await self.stop_health_monitoring()
# Stop all processors
for target_id, proc in list(self._processors.items()):
if proc.is_running:
try:
await proc.stop()
except Exception as e:
logger.error(f"Error stopping target {target_id}: {e}")
# Auto-shutdown devices that have the flag enabled
for device_id, ds in self._devices.items():
if not ds.auto_shutdown:
continue
try:
provider = get_provider(ds.device_type)
await provider.set_power(
ds.device_url, False,
led_count=ds.led_count, baud_rate=ds.baud_rate,
)
logger.info(f"Auto-shutdown: powered off {ds.device_type} device {device_id}")
except Exception as e:
logger.error(f"Auto-shutdown failed for device {device_id}: {e}")
# Safety net: release any remaining managed live streams
self._live_stream_manager.release_all()
# Close shared HTTP client
if self._http_client and not self._http_client.is_closed:
await self._http_client.aclose()
self._http_client = None
logger.info("Stopped all processors")
# ===== HEALTH MONITORING =====
async def start_health_monitoring(self):
"""Start background health checks for all registered devices."""
self._health_monitoring_active = True
for device_id in self._devices:
self._start_device_health_check(device_id)
logger.info("Started health monitoring for all devices")
async def stop_health_monitoring(self):
"""Stop all background health checks."""
self._health_monitoring_active = False
for device_id in list(self._devices.keys()):
self._stop_device_health_check(device_id)
logger.info("Stopped health monitoring for all devices")
def _start_device_health_check(self, device_id: str):
state = self._devices.get(device_id)
if not state:
return
if state.health_task and not state.health_task.done():
return
state.health_task = asyncio.create_task(self._health_check_loop(device_id))
def _stop_device_health_check(self, device_id: str):
state = self._devices.get(device_id)
if not state or not state.health_task:
return
state.health_task.cancel()
state.health_task = None
def _device_is_processing(self, device_id: str) -> bool:
"""Check if any target is actively streaming to this device."""
return any(
isinstance(p, WledTargetProcessor) and p.is_running
for p in self._processors.values()
if isinstance(p, WledTargetProcessor) and p.device_id == device_id
)
async def _health_check_loop(self, device_id: str):
"""Background loop that periodically checks a device."""
state = self._devices.get(device_id)
if not state:
return
check_interval = DEFAULT_STATE_CHECK_INTERVAL
try:
while self._health_monitoring_active:
if not self._device_is_processing(device_id):
await self._check_device_health(device_id)
else:
if state.health:
state.health.online = True
await asyncio.sleep(check_interval)
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Fatal error in health check loop for {device_id}: {e}")
async def _check_device_health(self, device_id: str):
"""Check device health. Also auto-syncs LED count if changed."""
state = self._devices.get(device_id)
if not state:
return
client = await self._get_http_client()
state.health = await check_device_health(
state.device_type, state.device_url, client, state.health,
)
# Auto-sync LED count
reported = state.health.device_led_count
if reported and reported != state.led_count and self._device_store:
old_count = state.led_count
logger.info(
f"Device {device_id} LED count changed: {old_count}{reported}, "
f"updating calibration"
)
try:
device = self._device_store.update_device(device_id, led_count=reported)
state.led_count = reported
state.calibration = device.calibration
# Propagate to WLED processors using this device
for proc in self._processors.values():
if isinstance(proc, WledTargetProcessor) and proc.device_id == device_id:
proc.update_calibration(device.calibration)
except Exception as e:
logger.error(f"Failed to sync LED count for {device_id}: {e}")
# ===== HELPERS =====
def _get_processor(self, target_id: str) -> TargetProcessor:
"""Look up a processor by target_id, raising ValueError if not found."""
proc = self._processors.get(target_id)
if proc is None:
raise ValueError(f"Target {target_id} not found")
return proc

View File

@@ -0,0 +1,204 @@
"""Abstract base class for target processors.
A TargetProcessor encapsulates the processing loop and state for a single
picture target. Concrete subclasses (WledTargetProcessor, KCTargetProcessor)
implement the target-specific capture→process→output pipeline.
ProcessorManager creates and owns TargetProcessor instances, delegating
all target-specific operations through the uniform interface defined here.
"""
from __future__ import annotations
import asyncio
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple
if TYPE_CHECKING:
import httpx
from wled_controller.core.capture.calibration import CalibrationConfig
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
from wled_controller.core.capture.screen_overlay import OverlayManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
# ---------------------------------------------------------------------------
# Shared dataclasses
# ---------------------------------------------------------------------------
@dataclass
class ProcessingMetrics:
"""Metrics for processing performance."""
frames_processed: int = 0
frames_skipped: int = 0
frames_keepalive: int = 0
errors_count: int = 0
last_error: Optional[str] = None
last_update: Optional[datetime] = None
start_time: Optional[datetime] = None
fps_actual: float = 0.0
fps_potential: float = 0.0
fps_current: int = 0
# Per-stage timing (ms), averaged over last 10 frames
# LED targets
timing_extract_ms: float = 0.0
timing_map_leds_ms: float = 0.0
timing_smooth_ms: float = 0.0
timing_send_ms: float = 0.0
timing_total_ms: float = 0.0
# KC targets
timing_calc_colors_ms: float = 0.0
timing_broadcast_ms: float = 0.0
@dataclass
class DeviceInfo:
"""Read-only snapshot of device state, passed to target processors."""
device_id: str
device_url: str
led_count: int
calibration: "CalibrationConfig"
device_type: str = "wled"
baud_rate: Optional[int] = None
software_brightness: int = 255
test_mode_active: bool = False
@dataclass
class TargetContext:
"""Shared infrastructure bag passed to every TargetProcessor.
Avoids circular imports — processors never import ProcessorManager.
"""
live_stream_manager: "LiveStreamManager"
overlay_manager: "OverlayManager"
picture_source_store: Optional["PictureSourceStore"] = None
capture_template_store: Optional["TemplateStore"] = None
pp_template_store: Optional["PostprocessingTemplateStore"] = None
pattern_template_store: Optional["PatternTemplateStore"] = None
device_store: Optional["DeviceStore"] = None
fire_event: Callable[[dict], None] = lambda e: None
get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: None
# ---------------------------------------------------------------------------
# Abstract base class
# ---------------------------------------------------------------------------
class TargetProcessor(ABC):
"""Abstract base class for target processors.
Lifecycle: register → start → (running loop) → stop → unregister
"""
def __init__(self, target_id: str, picture_source_id: str, ctx: TargetContext):
self._target_id = target_id
self._picture_source_id = picture_source_id
self._ctx = ctx
self._is_running = False
self._task: Optional[asyncio.Task] = None
self._metrics = ProcessingMetrics()
# ----- Properties -----
@property
def target_id(self) -> str:
return self._target_id
@property
def picture_source_id(self) -> str:
return self._picture_source_id
@property
def is_running(self) -> bool:
return self._is_running
@property
def metrics(self) -> ProcessingMetrics:
return self._metrics
# ----- Lifecycle (concrete helpers + abstract hook) -----
@abstractmethod
async def start(self) -> None:
"""Start the processing loop.
Implementations should acquire resources, reset metrics,
create the asyncio task, and set _is_running = True.
"""
...
@abstractmethod
async def stop(self) -> None:
"""Stop the processing loop and release resources.
Implementations should set _is_running = False, cancel the task,
release the live stream, and close any connections.
"""
...
# ----- Settings -----
@abstractmethod
def update_settings(self, settings) -> None:
"""Update processing settings (type depends on subclass)."""
...
def update_source(self, picture_source_id: str) -> None:
"""Update the picture source ID."""
self._picture_source_id = picture_source_id
# ----- State / Metrics reporting -----
@abstractmethod
def get_state(self) -> dict:
"""Return current processing state as a JSON-serializable dict."""
...
@abstractmethod
def get_metrics(self) -> dict:
"""Return processing metrics as a JSON-serializable dict."""
...
# ----- Optional capabilities (default no-ops) -----
def supports_overlay(self) -> bool:
"""Whether this target supports screen overlay visualization."""
return False
async def start_overlay(self, target_name: Optional[str] = None) -> None:
"""Start overlay visualization (if supported)."""
raise NotImplementedError(f"{type(self).__name__} does not support overlays")
async def stop_overlay(self) -> None:
"""Stop overlay visualization (if supported)."""
raise NotImplementedError(f"{type(self).__name__} does not support overlays")
def is_overlay_active(self) -> bool:
"""Check if overlay is currently active."""
return False
def supports_websocket(self) -> bool:
"""Whether this target supports WebSocket color streaming."""
return False
def add_ws_client(self, ws) -> None:
"""Add a WebSocket client for live color updates."""
raise NotImplementedError(f"{type(self).__name__} does not support WebSockets")
def remove_ws_client(self, ws) -> None:
"""Remove a WebSocket client."""
raise NotImplementedError(f"{type(self).__name__} does not support WebSockets")
def get_latest_colors(self) -> Dict[str, Tuple[int, int, int]]:
"""Get latest extracted colors (KC targets only)."""
return {}

View File

@@ -0,0 +1,543 @@
"""WLED/LED target processor — captures screen, maps to LEDs, sends via DDP."""
from __future__ import annotations
import asyncio
import collections
import time
from datetime import datetime
from typing import TYPE_CHECKING, Optional
import numpy as np
from wled_controller.core.capture.calibration import CalibrationConfig, PixelMapper
from wled_controller.core.devices.led_client import LEDClient, create_led_client
from wled_controller.core.processing.live_stream import LiveStream
from wled_controller.core.processing.processing_settings import ProcessingSettings
from wled_controller.core.capture.screen_capture import (
extract_border_pixels,
get_available_displays,
)
from wled_controller.core.processing.target_processor import (
DeviceInfo,
ProcessingMetrics,
TargetContext,
TargetProcessor,
)
from wled_controller.utils import get_logger
if TYPE_CHECKING:
from wled_controller.core.capture_engines.base import ScreenCapture
logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# CPU-bound frame processing (runs in thread pool via asyncio.to_thread)
# ---------------------------------------------------------------------------
def _process_frame(capture, border_width, pixel_mapper, previous_colors, smoothing):
"""All CPU-bound work for one WLED frame.
Returns (led_colors, timing_ms) where led_colors is numpy array (N, 3) uint8
and timing_ms is a dict with per-stage timing in milliseconds.
"""
t0 = time.perf_counter()
border_pixels = extract_border_pixels(capture, border_width)
t1 = time.perf_counter()
led_colors = pixel_mapper.map_border_to_leds(border_pixels)
t2 = time.perf_counter()
# Inline numpy smoothing — avoids list↔numpy round-trip
if previous_colors is not None and smoothing > 0 and len(previous_colors) == len(led_colors):
alpha = int(smoothing * 256)
led_colors = (
(256 - alpha) * led_colors.astype(np.uint16)
+ alpha * previous_colors.astype(np.uint16)
) >> 8
led_colors = led_colors.astype(np.uint8)
t3 = time.perf_counter()
timing_ms = {
"extract": (t1 - t0) * 1000,
"map_leds": (t2 - t1) * 1000,
"smooth": (t3 - t2) * 1000,
"total": (t3 - t0) * 1000,
}
return led_colors, timing_ms
# ---------------------------------------------------------------------------
# WledTargetProcessor
# ---------------------------------------------------------------------------
class WledTargetProcessor(TargetProcessor):
"""Processes screen capture frames and streams LED colors to a WLED/LED device."""
def __init__(
self,
target_id: str,
device_id: str,
settings: ProcessingSettings,
picture_source_id: str,
ctx: TargetContext,
):
super().__init__(target_id, picture_source_id, ctx)
self._device_id = device_id
self._settings = settings
# Runtime state (populated on start)
self._led_client: Optional[LEDClient] = None
self._pixel_mapper: Optional[PixelMapper] = None
self._live_stream: Optional[LiveStream] = None
self._previous_colors: Optional[np.ndarray] = None
self._device_state_before: Optional[dict] = None
self._overlay_active = False
# Resolved stream metadata
self._resolved_display_index: Optional[int] = None
self._resolved_target_fps: Optional[int] = None
self._resolved_engine_type: Optional[str] = None
self._resolved_engine_config: Optional[dict] = None
# ----- Properties -----
@property
def device_id(self) -> str:
return self._device_id
@property
def settings(self) -> ProcessingSettings:
return self._settings
@property
def led_client(self) -> Optional[LEDClient]:
return self._led_client
# ----- Lifecycle -----
async def start(self) -> None:
if self._is_running:
logger.debug(f"Processing already running for target {self._target_id}")
return
device_info = self._ctx.get_device_info(self._device_id)
if device_info is None:
raise ValueError(f"Device {self._device_id} not registered")
# Resolve stream settings
self._resolve_stream_settings()
# Connect to LED device
try:
self._led_client = create_led_client(
device_info.device_type, device_info.device_url,
use_ddp=True, led_count=device_info.led_count,
baud_rate=device_info.baud_rate,
)
await self._led_client.connect()
logger.info(
f"Target {self._target_id} connected to {device_info.device_type} "
f"device ({device_info.led_count} LEDs)"
)
# Snapshot device state before streaming
self._device_state_before = await self._led_client.snapshot_device_state()
except Exception as e:
logger.error(f"Failed to connect to LED device for target {self._target_id}: {e}")
raise RuntimeError(f"Failed to connect to LED device: {e}")
# Acquire live stream
try:
live_stream = await asyncio.to_thread(
self._ctx.live_stream_manager.acquire, self._picture_source_id
)
self._live_stream = live_stream
if live_stream.display_index is not None:
self._resolved_display_index = live_stream.display_index
self._resolved_target_fps = live_stream.target_fps
logger.info(
f"Acquired live stream for target {self._target_id} "
f"(picture_source={self._picture_source_id})"
)
except Exception as e:
logger.error(f"Failed to initialize live stream for target {self._target_id}: {e}")
if self._led_client:
await self._led_client.close()
raise RuntimeError(f"Failed to initialize live stream: {e}")
# Initialize pixel mapper from current device calibration
calibration = device_info.calibration
self._pixel_mapper = PixelMapper(
calibration,
interpolation_mode=self._settings.interpolation_mode,
)
# Reset metrics
self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
self._previous_colors = None
# Start processing task
self._task = asyncio.create_task(self._processing_loop())
self._is_running = True
logger.info(f"Started processing for target {self._target_id}")
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": True})
async def stop(self) -> None:
if not self._is_running:
logger.warning(f"Processing not running for target {self._target_id}")
return
self._is_running = False
# Cancel task
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
# Restore device state
if self._led_client and self._device_state_before:
await self._led_client.restore_device_state(self._device_state_before)
self._device_state_before = None
# Close LED connection
if self._led_client:
await self._led_client.close()
self._led_client = None
# Release live stream
if self._live_stream:
try:
self._ctx.live_stream_manager.release(self._picture_source_id)
except Exception as e:
logger.warning(f"Error releasing live stream: {e}")
self._live_stream = None
logger.info(f"Stopped processing for target {self._target_id}")
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False})
# ----- Settings -----
def update_settings(self, settings: ProcessingSettings) -> None:
self._settings = settings
# Recreate pixel mapper if interpolation mode changed
if self._pixel_mapper:
device_info = self._ctx.get_device_info(self._device_id)
if device_info:
self._pixel_mapper = PixelMapper(
device_info.calibration,
interpolation_mode=settings.interpolation_mode,
)
logger.info(f"Updated settings for target {self._target_id}")
def update_device(self, device_id: str) -> None:
"""Update the device this target streams to."""
self._device_id = device_id
def update_calibration(self, calibration: CalibrationConfig) -> None:
"""Update the cached calibration + rebuild pixel mapper."""
if self._pixel_mapper:
self._pixel_mapper = PixelMapper(
calibration,
interpolation_mode=self._settings.interpolation_mode,
)
# ----- State / Metrics -----
def get_state(self) -> dict:
metrics = self._metrics
device_info = self._ctx.get_device_info(self._device_id)
# Include device health info
health_info = {}
if device_info:
# Get full health from the device state (delegate via manager callback)
from wled_controller.core.devices.led_client import DeviceHealth
# We access health through the manager's get_device_health_dict
# For now, return empty — will be populated by manager wrapper
pass
return {
"target_id": self._target_id,
"device_id": self._device_id,
"processing": self._is_running,
"fps_actual": metrics.fps_actual if self._is_running else None,
"fps_potential": metrics.fps_potential if self._is_running else None,
"fps_target": self._settings.fps,
"frames_skipped": metrics.frames_skipped if self._is_running else None,
"frames_keepalive": metrics.frames_keepalive if self._is_running else None,
"fps_current": metrics.fps_current if self._is_running else None,
"timing_extract_ms": round(metrics.timing_extract_ms, 1) if self._is_running else None,
"timing_map_leds_ms": round(metrics.timing_map_leds_ms, 1) if self._is_running else None,
"timing_smooth_ms": round(metrics.timing_smooth_ms, 1) if self._is_running else None,
"timing_send_ms": round(metrics.timing_send_ms, 1) if self._is_running else None,
"timing_total_ms": round(metrics.timing_total_ms, 1) if self._is_running else None,
"display_index": self._resolved_display_index if self._resolved_display_index is not None else self._settings.display_index,
"overlay_active": self._overlay_active,
"last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [],
}
def get_metrics(self) -> dict:
metrics = self._metrics
uptime_seconds = 0.0
if metrics.start_time and self._is_running:
uptime_seconds = (datetime.utcnow() - metrics.start_time).total_seconds()
return {
"target_id": self._target_id,
"device_id": self._device_id,
"processing": self._is_running,
"fps_actual": metrics.fps_actual if self._is_running else None,
"fps_target": self._settings.fps,
"uptime_seconds": uptime_seconds,
"frames_processed": metrics.frames_processed,
"errors_count": metrics.errors_count,
"last_error": metrics.last_error,
"last_update": metrics.last_update,
}
# ----- Overlay -----
def supports_overlay(self) -> bool:
return True
async def start_overlay(self, target_name: Optional[str] = None) -> None:
if self._overlay_active:
raise RuntimeError(f"Overlay already active for {self._target_id}")
device_info = self._ctx.get_device_info(self._device_id)
if device_info is None:
raise ValueError(f"Device {self._device_id} not found")
display_index = self._resolved_display_index or self._settings.display_index
displays = get_available_displays()
if display_index >= len(displays):
raise ValueError(f"Invalid display index {display_index}")
display_info = displays[display_index]
await asyncio.to_thread(
self._ctx.overlay_manager.start_overlay,
self._target_id, display_info, device_info.calibration, target_name,
)
self._overlay_active = True
logger.info(f"Started overlay for target {self._target_id}")
async def stop_overlay(self) -> None:
if not self._overlay_active:
logger.warning(f"Overlay not active for {self._target_id}")
return
await asyncio.to_thread(
self._ctx.overlay_manager.stop_overlay,
self._target_id,
)
self._overlay_active = False
logger.info(f"Stopped overlay for target {self._target_id}")
def is_overlay_active(self) -> bool:
return self._overlay_active
# ----- Private: stream settings resolution -----
def _resolve_stream_settings(self) -> None:
"""Resolve picture source chain to populate resolved_* metadata fields."""
if not self._picture_source_id or not self._ctx.picture_source_store:
raise ValueError(f"Target {self._target_id} has no picture source assigned")
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
chain = self._ctx.picture_source_store.resolve_stream_chain(self._picture_source_id)
raw_stream = chain["raw_stream"]
if isinstance(raw_stream, StaticImagePictureSource):
self._resolved_display_index = -1
self._resolved_target_fps = 1
self._resolved_engine_type = None
self._resolved_engine_config = None
elif isinstance(raw_stream, ScreenCapturePictureSource):
self._resolved_display_index = raw_stream.display_index
self._resolved_target_fps = raw_stream.target_fps
if raw_stream.capture_template_id and self._ctx.capture_template_store:
try:
tpl = self._ctx.capture_template_store.get_template(raw_stream.capture_template_id)
self._resolved_engine_type = tpl.engine_type
self._resolved_engine_config = tpl.engine_config
except ValueError:
logger.warning(
f"Capture template {raw_stream.capture_template_id} not found, "
f"using MSS fallback"
)
self._resolved_engine_type = "mss"
self._resolved_engine_config = {}
logger.info(
f"Resolved stream metadata for target {self._target_id}: "
f"display={self._resolved_display_index}, fps={self._resolved_target_fps}, "
f"engine={self._resolved_engine_type}"
)
# ----- Private: processing loop -----
async def _processing_loop(self) -> None:
"""Main processing loop — capture → extract → map → smooth → send."""
settings = self._settings
device_info = self._ctx.get_device_info(self._device_id)
target_fps = settings.fps
smoothing = settings.smoothing
border_width = device_info.calibration.border_width if device_info else 10
led_brightness = settings.brightness
logger.info(
f"Processing loop started for target {self._target_id} "
f"(display={self._resolved_display_index}, fps={target_fps})"
)
frame_time = 1.0 / target_fps
standby_interval = settings.standby_interval
fps_samples = []
timing_samples: collections.deque = collections.deque(maxlen=10)
prev_frame_time_stamp = time.time()
prev_capture = None
last_send_time = 0.0
send_timestamps: collections.deque = collections.deque()
try:
while self._is_running:
loop_start = time.time()
# Re-fetch device info for runtime changes (test mode, brightness)
device_info = self._ctx.get_device_info(self._device_id)
# Skip capture/send while in calibration test mode
if device_info and device_info.test_mode_active:
await asyncio.sleep(frame_time)
continue
try:
capture = self._live_stream.get_latest_frame()
if capture is None:
if self._metrics.frames_processed == 0:
logger.info(f"Capture returned None for target {self._target_id} (no new frame yet)")
await asyncio.sleep(frame_time)
continue
# Skip processing + send if the frame hasn't changed
if capture is prev_capture:
if self._previous_colors is not None and (loop_start - last_send_time) >= standby_interval:
if not self._is_running or self._led_client is None:
break
brightness_value = int(led_brightness * 255)
if device_info and device_info.software_brightness < 255:
brightness_value = brightness_value * device_info.software_brightness // 255
if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(self._previous_colors, brightness=brightness_value)
else:
await self._led_client.send_pixels(self._previous_colors, brightness=brightness_value)
last_send_time = time.time()
send_timestamps.append(last_send_time)
self._metrics.frames_keepalive += 1
self._metrics.frames_skipped += 1
now_ts = time.time()
while send_timestamps and send_timestamps[0] < now_ts - 1.0:
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
await asyncio.sleep(frame_time)
continue
prev_capture = capture
# CPU-bound work in thread pool
led_colors, frame_timing = await asyncio.to_thread(
_process_frame,
capture, border_width,
self._pixel_mapper, self._previous_colors, smoothing,
)
# Send to LED device with brightness
if not self._is_running or self._led_client is None:
break
brightness_value = int(led_brightness * 255)
if device_info and device_info.software_brightness < 255:
brightness_value = brightness_value * device_info.software_brightness // 255
t_send_start = time.perf_counter()
if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(led_colors, brightness=brightness_value)
else:
await self._led_client.send_pixels(led_colors, brightness=brightness_value)
send_ms = (time.perf_counter() - t_send_start) * 1000
last_send_time = time.time()
send_timestamps.append(last_send_time)
# Per-stage timing (rolling average over last 10 frames)
frame_timing["send"] = send_ms
timing_samples.append(frame_timing)
n = len(timing_samples)
self._metrics.timing_extract_ms = sum(s["extract"] for s in timing_samples) / n
self._metrics.timing_map_leds_ms = sum(s["map_leds"] for s in timing_samples) / n
self._metrics.timing_smooth_ms = sum(s["smooth"] for s in timing_samples) / n
self._metrics.timing_send_ms = sum(s["send"] for s in timing_samples) / n
self._metrics.timing_total_ms = sum(s["total"] for s in timing_samples) / n + send_ms
# Update metrics
self._metrics.frames_processed += 1
if self._metrics.frames_processed <= 3 or self._metrics.frames_processed % 100 == 0:
logger.info(
f"Frame {self._metrics.frames_processed} for {self._target_id} "
f"({len(led_colors)} LEDs, bri={brightness_value}) — "
f"extract={frame_timing['extract']:.1f}ms "
f"map={frame_timing['map_leds']:.1f}ms "
f"smooth={frame_timing['smooth']:.1f}ms "
f"send={send_ms:.1f}ms"
)
self._metrics.last_update = datetime.utcnow()
self._previous_colors = led_colors
# Calculate actual FPS
now = time.time()
interval = now - prev_frame_time_stamp
prev_frame_time_stamp = now
fps_samples.append(1.0 / interval if interval > 0 else 0)
if len(fps_samples) > 10:
fps_samples.pop(0)
self._metrics.fps_actual = sum(fps_samples) / len(fps_samples)
# Potential FPS
processing_time = now - loop_start
self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0
# fps_current: count sends in last 1 second
while send_timestamps and send_timestamps[0] < now - 1.0:
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
except Exception as e:
self._metrics.errors_count += 1
self._metrics.last_error = str(e)
logger.error(f"Processing error for target {self._target_id}: {e}", exc_info=True)
# Throttle to target FPS
elapsed = time.time() - loop_start
remaining = frame_time - elapsed
if remaining > 0:
await asyncio.sleep(remaining)
except asyncio.CancelledError:
logger.info(f"Processing loop cancelled for target {self._target_id}")
raise
except Exception as e:
logger.error(f"Fatal error in processing loop for target {self._target_id}: {e}")
self._is_running = False
raise
finally:
logger.info(f"Processing loop ended for target {self._target_id}")

File diff suppressed because it is too large Load Diff