Add pluggable postprocessing filter system with collapsible UI

Replace hardcoded gamma/saturation/brightness fields with a flexible
filter pipeline architecture. Templates now contain an ordered list of
filter instances, each with its own options schema. Filters operate on
full images before border extraction.

- Add filter framework: base class, registry, image pool, filter instance
- Implement 6 built-in filters: brightness, saturation, gamma, downscaler, pixelate, auto crop
- Move smoothing from PP templates to device stream settings (temporal, not spatial)
- Add GET /api/v1/filters endpoint for available filter types
- Dynamic filter UI in template modal with add/remove/reorder/collapse
- Replace camera icon with display icon for screen capture streams
- Legacy migration: existing templates auto-convert flat fields to filter list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 11:57:19 +03:00
parent e8cbc73161
commit ebd6cc7d7d
16 changed files with 1115 additions and 192 deletions

View File

@@ -40,6 +40,10 @@ from wled_controller.api.schemas import (
CaptureImage, CaptureImage,
BorderExtraction, BorderExtraction,
PerformanceMetrics, PerformanceMetrics,
FilterInstanceSchema,
FilterOptionDefSchema,
FilterTypeResponse,
FilterTypeListResponse,
PostprocessingTemplateCreate, PostprocessingTemplateCreate,
PostprocessingTemplateUpdate, PostprocessingTemplateUpdate,
PostprocessingTemplateResponse, PostprocessingTemplateResponse,
@@ -61,6 +65,7 @@ from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_stream_store import PictureStreamStore from wled_controller.storage.picture_stream_store import PictureStreamStore
from wled_controller.core.capture_engines import EngineRegistry from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, FilterInstance, ImagePool
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
from wled_controller.core.screen_capture import get_available_displays from wled_controller.core.screen_capture import get_available_displays
@@ -587,6 +592,7 @@ async def get_settings(
border_width=device.settings.border_width, border_width=device.settings.border_width,
interpolation_mode=device.settings.interpolation_mode, interpolation_mode=device.settings.interpolation_mode,
brightness=device.settings.brightness, brightness=device.settings.brightness,
smoothing=device.settings.smoothing,
state_check_interval=device.settings.state_check_interval, state_check_interval=device.settings.state_check_interval,
) )
@@ -620,7 +626,7 @@ async def update_settings(
brightness=settings.color_correction.brightness if settings.color_correction else existing.brightness, brightness=settings.color_correction.brightness if settings.color_correction else existing.brightness,
gamma=settings.color_correction.gamma if settings.color_correction else existing.gamma, gamma=settings.color_correction.gamma if settings.color_correction else existing.gamma,
saturation=settings.color_correction.saturation if settings.color_correction else existing.saturation, saturation=settings.color_correction.saturation if settings.color_correction else existing.saturation,
smoothing=existing.smoothing, smoothing=settings.smoothing,
state_check_interval=settings.state_check_interval, state_check_interval=settings.state_check_interval,
) )
@@ -1106,6 +1112,34 @@ async def test_template(
logger.error(f"Error cleaning up test engine: {e}") logger.error(f"Error cleaning up test engine: {e}")
# ===== FILTER TYPE ENDPOINTS =====
@router.get("/api/v1/filters", response_model=FilterTypeListResponse, tags=["Filters"])
async def list_filter_types(_auth: AuthRequired):
"""List all available postprocessing filter types and their options schemas."""
all_filters = FilterRegistry.get_all()
responses = []
for filter_id, filter_cls in all_filters.items():
schema = filter_cls.get_options_schema()
responses.append(FilterTypeResponse(
filter_id=filter_cls.filter_id,
filter_name=filter_cls.filter_name,
options_schema=[
FilterOptionDefSchema(
key=opt.key,
label=opt.label,
type=opt.option_type,
default=opt.default,
min_value=opt.min_value,
max_value=opt.max_value,
step=opt.step,
)
for opt in schema
],
))
return FilterTypeListResponse(filters=responses, count=len(responses))
# ===== POSTPROCESSING TEMPLATE ENDPOINTS ===== # ===== POSTPROCESSING TEMPLATE ENDPOINTS =====
def _pp_template_to_response(t) -> PostprocessingTemplateResponse: def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
@@ -1113,10 +1147,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
return PostprocessingTemplateResponse( return PostprocessingTemplateResponse(
id=t.id, id=t.id,
name=t.name, name=t.name,
gamma=t.gamma, filters=[FilterInstanceSchema(filter_id=f.filter_id, options=f.options) for f in t.filters],
saturation=t.saturation,
brightness=t.brightness,
smoothing=t.smoothing,
created_at=t.created_at, created_at=t.created_at,
updated_at=t.updated_at, updated_at=t.updated_at,
description=t.description, description=t.description,
@@ -1146,12 +1177,10 @@ async def create_pp_template(
): ):
"""Create a new postprocessing template.""" """Create a new postprocessing template."""
try: try:
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters]
template = store.create_template( template = store.create_template(
name=data.name, name=data.name,
gamma=data.gamma, filters=filters,
saturation=data.saturation,
brightness=data.brightness,
smoothing=data.smoothing,
description=data.description, description=data.description,
) )
return _pp_template_to_response(template) return _pp_template_to_response(template)
@@ -1185,13 +1214,11 @@ async def update_pp_template(
): ):
"""Update a postprocessing template.""" """Update a postprocessing template."""
try: try:
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters] if data.filters is not None else None
template = store.update_template( template = store.update_template(
template_id=template_id, template_id=template_id,
name=data.name, name=data.name,
gamma=data.gamma, filters=filters,
saturation=data.saturation,
brightness=data.brightness,
smoothing=data.smoothing,
description=data.description, description=data.description,
) )
return _pp_template_to_response(template) return _pp_template_to_response(template)
@@ -1477,25 +1504,24 @@ async def test_picture_stream(
thumbnail = pil_image.copy() thumbnail = pil_image.copy()
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS) thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
# Apply postprocessing if this is a processed stream # Apply postprocessing filters if this is a processed stream
pp_template_ids = chain["postprocessing_template_ids"] pp_template_ids = chain["postprocessing_template_ids"]
if pp_template_ids: if pp_template_ids:
try: try:
pp = pp_store.get_template(pp_template_ids[0]) pp_template = pp_store.get_template(pp_template_ids[0])
pool = ImagePool()
def apply_pp(img): def apply_filters(img):
arr = np.array(img, dtype=np.float32) / 255.0 arr = np.array(img)
if pp.brightness != 1.0: for fi in pp_template.filters:
arr *= pp.brightness f = FilterRegistry.create_instance(fi.filter_id, fi.options)
if pp.saturation != 1.0: result = f.process_image(arr, pool)
lum = np.dot(arr[..., :3], [0.299, 0.587, 0.114])[..., np.newaxis] if result is not None:
arr[..., :3] = lum + (arr[..., :3] - lum) * pp.saturation arr = result
if pp.gamma != 1.0: return Image.fromarray(arr)
arr = np.power(np.clip(arr, 0, 1), 1.0 / pp.gamma)
return Image.fromarray(np.clip(arr * 255.0, 0, 255).astype(np.uint8))
thumbnail = apply_pp(thumbnail) thumbnail = apply_filters(thumbnail)
pil_image = apply_pp(pil_image) pil_image = apply_filters(pil_image)
except ValueError: except ValueError:
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview") logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")

View File

@@ -1,7 +1,7 @@
"""Pydantic schemas for API request and response models.""" """Pydantic schemas for API request and response models."""
from datetime import datetime from datetime import datetime
from typing import Dict, List, Literal, Optional from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field, HttpUrl from pydantic import BaseModel, Field, HttpUrl
@@ -84,6 +84,7 @@ class ProcessingSettings(BaseModel):
border_width: int = Field(default=10, description="Border width in pixels", ge=1, le=100) border_width: int = Field(default=10, description="Border width in pixels", ge=1, le=100)
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)") interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0) brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0)
smoothing: float = Field(default=0.3, description="Temporal smoothing factor (0.0=none, 1.0=full)", ge=0.0, le=1.0)
state_check_interval: int = Field( state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600, default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600,
description="Seconds between WLED health checks" description="Seconds between WLED health checks"
@@ -326,16 +327,49 @@ class TemplateTestResponse(BaseModel):
performance: PerformanceMetrics = Field(description="Performance metrics") performance: PerformanceMetrics = Field(description="Performance metrics")
# Filter Schemas
class FilterInstanceSchema(BaseModel):
"""A single filter instance with its configuration."""
filter_id: str = Field(description="Filter type identifier")
options: Dict[str, Any] = Field(default_factory=dict, description="Filter-specific options")
class FilterOptionDefSchema(BaseModel):
"""Describes a configurable option for a filter type."""
key: str = Field(description="Option key")
label: str = Field(description="Display label")
type: str = Field(description="Option type (float or int)")
default: Any = Field(description="Default value")
min_value: Any = Field(description="Minimum value")
max_value: Any = Field(description="Maximum value")
step: Any = Field(description="Step increment")
class FilterTypeResponse(BaseModel):
"""Available filter type with its options schema."""
filter_id: str = Field(description="Filter type identifier")
filter_name: str = Field(description="Display name")
options_schema: List[FilterOptionDefSchema] = Field(description="Configurable options")
class FilterTypeListResponse(BaseModel):
"""List of available filter types."""
filters: List[FilterTypeResponse] = Field(description="Available filter types")
count: int = Field(description="Number of filter types")
# Postprocessing Template Schemas # Postprocessing Template Schemas
class PostprocessingTemplateCreate(BaseModel): class PostprocessingTemplateCreate(BaseModel):
"""Request to create a postprocessing template.""" """Request to create a postprocessing template."""
name: str = Field(description="Template name", min_length=1, max_length=100) name: str = Field(description="Template name", min_length=1, max_length=100)
gamma: float = Field(default=2.2, description="Gamma correction", ge=0.1, le=5.0) filters: List[FilterInstanceSchema] = Field(default_factory=list, description="Ordered list of filter instances")
saturation: float = Field(default=1.0, description="Saturation multiplier", ge=0.0, le=2.0)
brightness: float = Field(default=1.0, description="Brightness multiplier", ge=0.0, le=1.0)
smoothing: float = Field(default=0.3, description="Temporal smoothing factor", ge=0.0, le=1.0)
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
@@ -343,10 +377,7 @@ class PostprocessingTemplateUpdate(BaseModel):
"""Request to update a postprocessing template.""" """Request to update a postprocessing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
gamma: Optional[float] = Field(None, description="Gamma correction", ge=0.1, le=5.0) filters: Optional[List[FilterInstanceSchema]] = Field(None, description="Ordered list of filter instances")
saturation: Optional[float] = Field(None, description="Saturation multiplier", ge=0.0, le=2.0)
brightness: Optional[float] = Field(None, description="Brightness multiplier", ge=0.0, le=1.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing factor", ge=0.0, le=1.0)
description: Optional[str] = Field(None, description="Template description", max_length=500) description: Optional[str] = Field(None, description="Template description", max_length=500)
@@ -355,10 +386,7 @@ class PostprocessingTemplateResponse(BaseModel):
id: str = Field(description="Template ID") id: str = Field(description="Template ID")
name: str = Field(description="Template name") name: str = Field(description="Template name")
gamma: float = Field(description="Gamma correction") filters: List[FilterInstanceSchema] = Field(description="Ordered list of filter instances")
saturation: float = Field(description="Saturation multiplier")
brightness: float = Field(description="Brightness multiplier")
smoothing: float = Field(description="Temporal smoothing factor")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Template description") description: Optional[str] = Field(None, description="Template description")

View File

@@ -0,0 +1,21 @@
"""Postprocessing filter system.
Provides a pluggable filter architecture for image postprocessing.
Import this package to ensure all built-in filters are registered.
"""
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
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
__all__ = [
"FilterOptionDef",
"FilterInstance",
"FilterRegistry",
"ImagePool",
"PostprocessingFilter",
]

View File

@@ -0,0 +1,91 @@
"""Base classes for the postprocessing filter system."""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
import numpy as np
@dataclass
class FilterOptionDef:
"""Describes a single configurable option for a filter."""
key: str
label: str
option_type: str # "float" | "int"
default: Any
min_value: Any
max_value: Any
step: Any
def to_dict(self) -> dict:
return {
"key": self.key,
"label": self.label,
"type": self.option_type,
"default": self.default,
"min_value": self.min_value,
"max_value": self.max_value,
"step": self.step,
}
class PostprocessingFilter(ABC):
"""Base class for all postprocessing filters.
Each filter operates on a full image (np.ndarray H×W×3 uint8).
Filters that preserve dimensions modify in-place and return None.
Filters that change dimensions return a new array from the image pool.
"""
filter_id: str = ""
filter_name: str = ""
def __init__(self, options: Dict[str, Any]):
"""Initialize filter with validated options."""
self.options = self.validate_options(options)
@classmethod
@abstractmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
"""Return the list of configurable options for this filter type."""
...
@abstractmethod
def process_image(self, image: np.ndarray, image_pool: "ImagePool") -> Optional[np.ndarray]:
"""Process image.
Args:
image: Input image as np.ndarray (H, W, 3) dtype=uint8.
image_pool: Shared pool for acquiring new arrays when dimensions change.
Returns:
None if modified in-place (same dimensions).
New np.ndarray from image_pool if dimensions changed.
"""
...
@classmethod
def validate_options(cls, options: dict) -> dict:
"""Validate and clamp options against the schema. Returns cleaned dict."""
schema = cls.get_options_schema()
cleaned = {}
for opt_def in schema:
raw = options.get(opt_def.key, opt_def.default)
if opt_def.option_type == "float":
val = float(raw)
elif opt_def.option_type == "int":
val = int(raw)
else:
val = raw
# Clamp to range
if opt_def.min_value is not None and val < opt_def.min_value:
val = opt_def.min_value
if opt_def.max_value is not None and val > opt_def.max_value:
val = opt_def.max_value
cleaned[opt_def.key] = val
return cleaned
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.options})"

View File

@@ -0,0 +1,282 @@
"""Built-in postprocessing filters."""
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"
@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]:
value = self.options["value"]
if value == 1.0:
return None
# In-place float operation
arr = image.astype(np.float32)
arr *= value
np.clip(arr, 0, 255, out=arr)
np.copyto(image, arr.astype(np.uint8))
return None
@FilterRegistry.register
class SaturationFilter(PostprocessingFilter):
"""Adjusts color saturation via luminance blending."""
filter_id = "saturation"
filter_name = "Saturation"
@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
arr = image.astype(np.float32) / 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 * 255.0, 0, 255, out=arr)
np.copyto(image, arr.astype(np.uint8))
return None
@FilterRegistry.register
class GammaFilter(PostprocessingFilter):
"""Applies gamma correction."""
filter_id = "gamma"
filter_name = "Gamma"
@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]:
value = self.options["value"]
if value == 1.0:
return None
arr = image.astype(np.float32) / 255.0
np.power(arr, 1.0 / value, out=arr)
np.clip(arr * 255.0, 0, 255, out=arr)
np.copyto(image, arr.astype(np.uint8))
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 PIL for high-quality downscaling
from PIL import Image
pil_img = Image.fromarray(image)
pil_img = pil_img.resize((new_w, new_h), Image.Resampling.LANCZOS)
result = image_pool.acquire(new_h, new_w, image.shape[2] if image.ndim == 3 else 3)
np.copyto(result, np.array(pil_img))
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

View File

@@ -0,0 +1,25 @@
"""FilterInstance data model for serializable filter configurations."""
from dataclasses import dataclass, field
from typing import Any, Dict
@dataclass
class FilterInstance:
"""A configured instance of a filter within a postprocessing template."""
filter_id: str
options: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"filter_id": self.filter_id,
"options": dict(self.options),
}
@classmethod
def from_dict(cls, data: dict) -> "FilterInstance":
return cls(
filter_id=data["filter_id"],
options=data.get("options", {}),
)

View File

@@ -0,0 +1,41 @@
"""Reusable numpy array pool to minimize allocation churn during fast image processing."""
from collections import defaultdict
from typing import Dict, List, Tuple
import numpy as np
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class ImagePool:
"""Pool of pre-allocated numpy arrays keyed by shape.
When a filter needs a new array (e.g. for downscaling), it acquires one
from the pool instead of allocating. After use, arrays are released back.
"""
def __init__(self, max_per_shape: int = 4):
self._pool: Dict[Tuple[int, ...], List[np.ndarray]] = defaultdict(list)
self._max_per_shape = max_per_shape
def acquire(self, height: int, width: int, channels: int = 3) -> np.ndarray:
"""Get a pre-allocated array or create a new one."""
shape = (height, width, channels)
bucket = self._pool[shape]
if bucket:
return bucket.pop()
return np.empty(shape, dtype=np.uint8)
def release(self, array: np.ndarray) -> None:
"""Return an array to the pool for reuse."""
shape = array.shape
bucket = self._pool[shape]
if len(bucket) < self._max_per_shape:
bucket.append(array)
def clear(self) -> None:
"""Release all pooled arrays."""
self._pool.clear()

View File

@@ -0,0 +1,53 @@
"""Filter registry for discovering and instantiating postprocessing filters."""
from typing import Dict, List, Type
from wled_controller.core.filters.base import PostprocessingFilter
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class FilterRegistry:
"""Singleton registry of all available postprocessing filter types."""
_filters: Dict[str, Type[PostprocessingFilter]] = {}
@classmethod
def register(cls, filter_cls: Type[PostprocessingFilter]) -> Type[PostprocessingFilter]:
"""Register a filter class. Can be used as a decorator."""
filter_id = filter_cls.filter_id
if not filter_id:
raise ValueError(f"Filter class {filter_cls.__name__} must define filter_id")
if filter_id in cls._filters:
logger.warning(f"Overwriting filter registration for '{filter_id}'")
cls._filters[filter_id] = filter_cls
logger.debug(f"Registered filter: {filter_id} ({filter_cls.__name__})")
return filter_cls
@classmethod
def get(cls, filter_id: str) -> Type[PostprocessingFilter]:
"""Get a filter class by ID.
Raises:
ValueError: If filter_id is not registered.
"""
if filter_id not in cls._filters:
raise ValueError(f"Unknown filter type: '{filter_id}'")
return cls._filters[filter_id]
@classmethod
def get_all(cls) -> Dict[str, Type[PostprocessingFilter]]:
"""Get all registered filter types."""
return dict(cls._filters)
@classmethod
def create_instance(cls, filter_id: str, options: dict) -> PostprocessingFilter:
"""Create a filter instance from a filter_id and options dict."""
filter_cls = cls.get(filter_id)
return filter_cls(options)
@classmethod
def is_registered(cls, filter_id: str) -> bool:
"""Check if a filter ID is registered."""
return filter_id in cls._filters

View File

@@ -14,7 +14,8 @@ from wled_controller.core.calibration import (
create_default_calibration, create_default_calibration,
) )
from wled_controller.core.capture_engines import CaptureEngine, EngineRegistry from wled_controller.core.capture_engines import CaptureEngine, EngineRegistry
from wled_controller.core.pixel_processor import apply_color_correction, smooth_colors from wled_controller.core.filters import FilterInstance, FilterRegistry, ImagePool, PostprocessingFilter
from wled_controller.core.pixel_processor import smooth_colors
from wled_controller.core.screen_capture import extract_border_pixels from wled_controller.core.screen_capture import extract_border_pixels
from wled_controller.core.wled_client import WLEDClient from wled_controller.core.wled_client import WLEDClient
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -106,10 +107,9 @@ class ProcessorState:
resolved_target_fps: Optional[int] = None resolved_target_fps: Optional[int] = None
resolved_engine_type: Optional[str] = None resolved_engine_type: Optional[str] = None
resolved_engine_config: Optional[dict] = None resolved_engine_config: Optional[dict] = None
resolved_gamma: Optional[float] = None resolved_filters: Optional[List[FilterInstance]] = None
resolved_saturation: Optional[float] = None image_pool: Optional[ImagePool] = None
resolved_brightness: Optional[float] = None filter_instances: Optional[List[PostprocessingFilter]] = None
resolved_smoothing: Optional[float] = None
class ProcessorManager: class ProcessorManager:
@@ -298,10 +298,7 @@ class ProcessorManager:
if pp_template_ids and self._pp_template_store: if pp_template_ids and self._pp_template_store:
try: try:
pp = self._pp_template_store.get_template(pp_template_ids[0]) pp = self._pp_template_store.get_template(pp_template_ids[0])
state.resolved_gamma = pp.gamma state.resolved_filters = pp.filters
state.resolved_saturation = pp.saturation
state.resolved_brightness = pp.brightness
state.resolved_smoothing = pp.smoothing
except ValueError: except ValueError:
logger.warning(f"PP template {pp_template_ids[0]} not found, using defaults") logger.warning(f"PP template {pp_template_ids[0]} not found, using defaults")
@@ -314,13 +311,17 @@ class ProcessorManager:
except ValueError as e: except ValueError as e:
logger.warning(f"Failed to resolve stream {state.picture_stream_id}: {e}, falling back to legacy settings") logger.warning(f"Failed to resolve stream {state.picture_stream_id}: {e}, falling back to legacy settings")
# Fallback: use legacy device settings # Fallback: use legacy device settings (construct filters from flat fields)
state.resolved_display_index = state.settings.display_index state.resolved_display_index = state.settings.display_index
state.resolved_target_fps = state.settings.fps state.resolved_target_fps = state.settings.fps
state.resolved_gamma = state.settings.gamma legacy_filters = []
state.resolved_saturation = state.settings.saturation if state.settings.brightness != 1.0:
state.resolved_brightness = state.settings.brightness legacy_filters.append(FilterInstance("brightness", {"value": state.settings.brightness}))
state.resolved_smoothing = state.settings.smoothing if state.settings.saturation != 1.0:
legacy_filters.append(FilterInstance("saturation", {"value": state.settings.saturation}))
if state.settings.gamma != 1.0:
legacy_filters.append(FilterInstance("gamma", {"value": state.settings.gamma}))
state.resolved_filters = legacy_filters
# Resolve engine from legacy capture_template_id # Resolve engine from legacy capture_template_id
if state.capture_template_id and self._capture_template_store: if state.capture_template_id and self._capture_template_store:
@@ -457,23 +458,40 @@ class ProcessorManager:
# Use resolved values (populated by _resolve_stream_settings) # Use resolved values (populated by _resolve_stream_settings)
display_index = state.resolved_display_index or settings.display_index display_index = state.resolved_display_index or settings.display_index
target_fps = state.resolved_target_fps or settings.fps target_fps = state.resolved_target_fps or settings.fps
gamma = state.resolved_gamma if state.resolved_gamma is not None else settings.gamma smoothing = settings.smoothing
saturation = state.resolved_saturation if state.resolved_saturation is not None else settings.saturation
pp_brightness = state.resolved_brightness if state.resolved_brightness is not None else settings.brightness
smoothing = state.resolved_smoothing if state.resolved_smoothing is not None else settings.smoothing
# These always come from device settings (LED projection) # These always come from device settings (LED projection)
border_width = settings.border_width border_width = settings.border_width
wled_brightness = settings.brightness # WLED hardware brightness wled_brightness = settings.brightness # WLED hardware brightness
# Instantiate filter objects once (not per-frame)
resolved_filters = state.resolved_filters or []
image_pool = ImagePool()
state.image_pool = image_pool
filter_objects = []
for fi in resolved_filters:
try:
filter_objects.append(FilterRegistry.create_instance(fi.filter_id, fi.options))
except ValueError as e:
logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}")
state.filter_instances = filter_objects
logger.info( logger.info(
f"Processing loop started for {device_id} " f"Processing loop started for {device_id} "
f"(display={display_index}, fps={target_fps})" f"(display={display_index}, fps={target_fps}, filters={len(filter_objects)})"
) )
frame_time = 1.0 / target_fps frame_time = 1.0 / target_fps
fps_samples = [] fps_samples = []
def _apply_filters(image):
"""Apply all postprocessing filters to the captured image."""
for f in filter_objects:
result = f.process_image(image, image_pool)
if result is not None:
image = result
return image
try: try:
while state.is_running: while state.is_running:
loop_start = time.time() loop_start = time.time()
@@ -490,21 +508,16 @@ class ProcessorManager:
display_index display_index
) )
# Apply postprocessing filters to the full captured image
if filter_objects:
capture.image = await asyncio.to_thread(_apply_filters, capture.image)
# Extract border pixels # Extract border pixels
border_pixels = await asyncio.to_thread(extract_border_pixels, capture, border_width) border_pixels = await asyncio.to_thread(extract_border_pixels, capture, border_width)
# Map to LED colors # Map to LED colors
led_colors = await asyncio.to_thread(state.pixel_mapper.map_border_to_leds, border_pixels) led_colors = await asyncio.to_thread(state.pixel_mapper.map_border_to_leds, border_pixels)
# Apply color correction from postprocessing
led_colors = await asyncio.to_thread(
apply_color_correction,
led_colors,
gamma=gamma,
saturation=saturation,
brightness=pp_brightness,
)
# Apply smoothing from postprocessing # Apply smoothing from postprocessing
if state.previous_colors and smoothing > 0: if state.previous_colors and smoothing > 0:
led_colors = await asyncio.to_thread( led_colors = await asyncio.to_thread(

View File

@@ -2992,6 +2992,7 @@ async function deleteTemplate(templateId) {
let _cachedStreams = []; let _cachedStreams = [];
let _cachedPPTemplates = []; let _cachedPPTemplates = [];
let _availableFilters = []; // Loaded from GET /filters
async function loadPictureStreams() { async function loadPictureStreams() {
try { try {
@@ -3017,7 +3018,7 @@ function renderPictureStreamsList(streams) {
container.innerHTML = ` container.innerHTML = `
<div class="stream-group"> <div class="stream-group">
<div class="stream-group-header"> <div class="stream-group-header">
<span class="stream-group-icon">📷</span> <span class="stream-group-icon">🖥️</span>
<span class="stream-group-title">${t('streams.group.raw')}</span> <span class="stream-group-title">${t('streams.group.raw')}</span>
<span class="stream-group-count">0</span> <span class="stream-group-count">0</span>
</div> </div>
@@ -3045,7 +3046,7 @@ function renderPictureStreamsList(streams) {
} }
const renderCard = (stream) => { const renderCard = (stream) => {
const typeIcon = stream.stream_type === 'raw' ? '📷' : '🎨'; const typeIcon = stream.stream_type === 'raw' ? '🖥️' : '🎨';
const typeBadge = stream.stream_type === 'raw' const typeBadge = stream.stream_type === 'raw'
? `<span class="badge badge-raw">${t('streams.type.raw')}</span>` ? `<span class="badge badge-raw">${t('streams.type.raw')}</span>`
: `<span class="badge badge-processed">${t('streams.type.processed')}</span>`; : `<span class="badge badge-processed">${t('streams.type.processed')}</span>`;
@@ -3102,7 +3103,7 @@ function renderPictureStreamsList(streams) {
// Screen Capture streams section // Screen Capture streams section
html += `<div class="stream-group"> html += `<div class="stream-group">
<div class="stream-group-header"> <div class="stream-group-header">
<span class="stream-group-icon">📷</span> <span class="stream-group-icon">🖥️</span>
<span class="stream-group-title">${t('streams.group.raw')}</span> <span class="stream-group-title">${t('streams.group.raw')}</span>
<span class="stream-group-count">${rawStreams.length}</span> <span class="stream-group-count">${rawStreams.length}</span>
</div> </div>
@@ -3252,7 +3253,7 @@ async function populateStreamModalDropdowns() {
if (s.id === editingId) return; if (s.id === editingId) return;
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = s.id; opt.value = s.id;
const typeLabel = s.stream_type === 'raw' ? '📷' : '🎨'; const typeLabel = s.stream_type === 'raw' ? '🖥️' : '🎨';
opt.textContent = `${typeLabel} ${s.name}`; opt.textContent = `${typeLabel} ${s.name}`;
sourceSelect.appendChild(opt); sourceSelect.appendChild(opt);
}); });
@@ -3423,8 +3424,24 @@ function displayStreamTestResults(result) {
// ===== Processing Templates ===== // ===== Processing Templates =====
async function loadAvailableFilters() {
try {
const response = await fetchWithAuth('/filters');
if (!response.ok) throw new Error(`Failed to load filters: ${response.status}`);
const data = await response.json();
_availableFilters = data.filters || [];
} catch (error) {
console.error('Error loading available filters:', error);
_availableFilters = [];
}
}
async function loadPPTemplates() { async function loadPPTemplates() {
try { try {
// Ensure filters are loaded for rendering
if (_availableFilters.length === 0) {
await loadAvailableFilters();
}
const response = await fetchWithAuth('/postprocessing-templates'); const response = await fetchWithAuth('/postprocessing-templates');
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load templates: ${response.status}`); throw new Error(`Failed to load templates: ${response.status}`);
@@ -3440,6 +3457,17 @@ async function loadPPTemplates() {
} }
} }
function _getFilterName(filterId) {
const key = 'filters.' + filterId;
const translated = t(key);
// Fallback to filter_name from registry if no localization
if (translated === key) {
const def = _availableFilters.find(f => f.filter_id === filterId);
return def ? def.filter_name : filterId;
}
return translated;
}
function renderPPTemplatesList(templates) { function renderPPTemplatesList(templates) {
const container = document.getElementById('pp-templates-list'); const container = document.getElementById('pp-templates-list');
@@ -3452,12 +3480,12 @@ function renderPPTemplatesList(templates) {
} }
const renderCard = (tmpl) => { const renderCard = (tmpl) => {
const configEntries = { // Build config entries from filter list
[t('postprocessing.gamma')]: tmpl.gamma, const filterRows = (tmpl.filters || []).map(fi => {
[t('postprocessing.saturation')]: tmpl.saturation, const filterName = _getFilterName(fi.filter_id);
[t('postprocessing.brightness')]: tmpl.brightness, const optStr = Object.entries(fi.options || {}).map(([k, v]) => `${v}`).join(', ');
[t('postprocessing.smoothing')]: tmpl.smoothing, return `<tr><td class="config-key">${escapeHtml(filterName)}</td><td class="config-value">${escapeHtml(optStr)}</td></tr>`;
}; }).join('');
return ` return `
<div class="template-card" data-pp-template-id="${tmpl.id}"> <div class="template-card" data-pp-template-id="${tmpl.id}">
@@ -3471,12 +3499,7 @@ function renderPPTemplatesList(templates) {
<details class="template-config-details"> <details class="template-config-details">
<summary>${t('postprocessing.config.show')}</summary> <summary>${t('postprocessing.config.show')}</summary>
<table class="config-table"> <table class="config-table">
${Object.entries(configEntries).map(([key, val]) => ` ${filterRows}
<tr>
<td class="config-key">${escapeHtml(key)}</td>
<td class="config-value">${escapeHtml(String(val))}</td>
</tr>
`).join('')}
</table> </table>
</details> </details>
<div class="template-card-actions"> <div class="template-card-actions">
@@ -3497,21 +3520,154 @@ function renderPPTemplatesList(templates) {
container.innerHTML = html; container.innerHTML = html;
} }
// --- Filter list management in PP template modal ---
let _modalFilters = []; // Current filter list being edited in modal
function _populateFilterSelect() {
const select = document.getElementById('pp-add-filter-select');
// Keep first option (placeholder)
select.innerHTML = `<option value="">${t('filters.select_type')}</option>`;
for (const f of _availableFilters) {
const name = _getFilterName(f.filter_id);
select.innerHTML += `<option value="${f.filter_id}">${name}</option>`;
}
}
function renderModalFilterList() {
const container = document.getElementById('pp-filter-list');
if (_modalFilters.length === 0) {
container.innerHTML = `<div class="pp-filter-empty">${t('filters.empty')}</div>`;
return;
}
let html = '';
_modalFilters.forEach((fi, index) => {
const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
const filterName = _getFilterName(fi.filter_id);
const isExpanded = fi._expanded === true;
// Build compact summary of current option values for collapsed state
let summary = '';
if (filterDef && !isExpanded) {
summary = filterDef.options_schema.map(opt => {
const val = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
return val;
}).join(', ');
}
html += `<div class="pp-filter-card${isExpanded ? ' expanded' : ''}" data-filter-index="${index}">
<div class="pp-filter-card-header" onclick="toggleFilterExpand(${index})">
<span class="pp-filter-card-chevron">${isExpanded ? '&#x25BC;' : '&#x25B6;'}</span>
<span class="pp-filter-card-name">${escapeHtml(filterName)}</span>
${summary ? `<span class="pp-filter-card-summary">${escapeHtml(summary)}</span>` : ''}
<div class="pp-filter-card-actions" onclick="event.stopPropagation()">
<button type="button" class="btn-filter-action" onclick="moveFilter(${index}, -1)" title="${t('filters.move_up')}" ${index === 0 ? 'disabled' : ''}>&#x25B2;</button>
<button type="button" class="btn-filter-action" onclick="moveFilter(${index}, 1)" title="${t('filters.move_down')}" ${index === _modalFilters.length - 1 ? 'disabled' : ''}>&#x25BC;</button>
<button type="button" class="btn-filter-action btn-filter-remove" onclick="removeFilter(${index})" title="${t('filters.remove')}">&#x2715;</button>
</div>
</div>
<div class="pp-filter-card-options"${isExpanded ? '' : ' style="display:none"'}>`;
if (filterDef) {
for (const opt of filterDef.options_schema) {
const currentVal = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
const inputId = `filter-${index}-${opt.key}`;
html += `<div class="pp-filter-option">
<label for="${inputId}">
<span>${escapeHtml(opt.label)}:</span>
<span id="${inputId}-display">${currentVal}</span>
</label>
<input type="range" id="${inputId}"
min="${opt.min_value}" max="${opt.max_value}" step="${opt.step}" value="${currentVal}"
oninput="updateFilterOption(${index}, '${opt.key}', this.value); document.getElementById('${inputId}-display').textContent = this.value;">
</div>`;
}
}
html += `</div></div>`;
});
container.innerHTML = html;
}
function addFilterFromSelect() {
const select = document.getElementById('pp-add-filter-select');
const filterId = select.value;
if (!filterId) return;
const filterDef = _availableFilters.find(f => f.filter_id === filterId);
if (!filterDef) return;
// Build default options
const options = {};
for (const opt of filterDef.options_schema) {
options[opt.key] = opt.default;
}
_modalFilters.push({ filter_id: filterId, options, _expanded: true });
select.value = '';
renderModalFilterList();
}
function toggleFilterExpand(index) {
if (_modalFilters[index]) {
_modalFilters[index]._expanded = !_modalFilters[index]._expanded;
renderModalFilterList();
}
}
function removeFilter(index) {
_modalFilters.splice(index, 1);
renderModalFilterList();
}
function moveFilter(index, direction) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= _modalFilters.length) return;
const tmp = _modalFilters[index];
_modalFilters[index] = _modalFilters[newIndex];
_modalFilters[newIndex] = tmp;
renderModalFilterList();
}
function updateFilterOption(filterIndex, optionKey, value) {
if (_modalFilters[filterIndex]) {
// Determine type from schema
const fi = _modalFilters[filterIndex];
const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
if (filterDef) {
const optDef = filterDef.options_schema.find(o => o.key === optionKey);
if (optDef && optDef.type === 'int') {
fi.options[optionKey] = parseInt(value);
} else {
fi.options[optionKey] = parseFloat(value);
}
} else {
fi.options[optionKey] = parseFloat(value);
}
}
}
function collectFilters() {
return _modalFilters.map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
}));
}
async function showAddPPTemplateModal() { async function showAddPPTemplateModal() {
if (_availableFilters.length === 0) await loadAvailableFilters();
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add'); document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add');
document.getElementById('pp-template-form').reset(); document.getElementById('pp-template-form').reset();
document.getElementById('pp-template-id').value = ''; document.getElementById('pp-template-id').value = '';
document.getElementById('pp-template-error').style.display = 'none'; document.getElementById('pp-template-error').style.display = 'none';
// Reset slider displays to defaults _modalFilters = [];
document.getElementById('pp-template-gamma').value = '2.2';
document.getElementById('pp-template-gamma-value').textContent = '2.2'; _populateFilterSelect();
document.getElementById('pp-template-saturation').value = '1.0'; renderModalFilterList();
document.getElementById('pp-template-saturation-value').textContent = '1.0';
document.getElementById('pp-template-brightness').value = '1.0';
document.getElementById('pp-template-brightness-value').textContent = '1.0';
document.getElementById('pp-template-smoothing').value = '0.3';
document.getElementById('pp-template-smoothing-value').textContent = '0.3';
const modal = document.getElementById('pp-template-modal'); const modal = document.getElementById('pp-template-modal');
modal.style.display = 'flex'; modal.style.display = 'flex';
@@ -3521,6 +3677,8 @@ async function showAddPPTemplateModal() {
async function editPPTemplate(templateId) { async function editPPTemplate(templateId) {
try { try {
if (_availableFilters.length === 0) await loadAvailableFilters();
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`); const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`); if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const tmpl = await response.json(); const tmpl = await response.json();
@@ -3531,15 +3689,14 @@ async function editPPTemplate(templateId) {
document.getElementById('pp-template-description').value = tmpl.description || ''; document.getElementById('pp-template-description').value = tmpl.description || '';
document.getElementById('pp-template-error').style.display = 'none'; document.getElementById('pp-template-error').style.display = 'none';
// Set sliders // Load filters from template
document.getElementById('pp-template-gamma').value = tmpl.gamma; _modalFilters = (tmpl.filters || []).map(fi => ({
document.getElementById('pp-template-gamma-value').textContent = tmpl.gamma; filter_id: fi.filter_id,
document.getElementById('pp-template-saturation').value = tmpl.saturation; options: { ...fi.options },
document.getElementById('pp-template-saturation-value').textContent = tmpl.saturation; }));
document.getElementById('pp-template-brightness').value = tmpl.brightness;
document.getElementById('pp-template-brightness-value').textContent = tmpl.brightness; _populateFilterSelect();
document.getElementById('pp-template-smoothing').value = tmpl.smoothing; renderModalFilterList();
document.getElementById('pp-template-smoothing-value').textContent = tmpl.smoothing;
const modal = document.getElementById('pp-template-modal'); const modal = document.getElementById('pp-template-modal');
modal.style.display = 'flex'; modal.style.display = 'flex';
@@ -3564,10 +3721,7 @@ async function savePPTemplate() {
const payload = { const payload = {
name, name,
gamma: parseFloat(document.getElementById('pp-template-gamma').value), filters: collectFilters(),
saturation: parseFloat(document.getElementById('pp-template-saturation').value),
brightness: parseFloat(document.getElementById('pp-template-brightness').value),
smoothing: parseFloat(document.getElementById('pp-template-smoothing').value),
description: description || null, description: description || null,
}; };
@@ -3624,6 +3778,7 @@ async function deletePPTemplate(templateId) {
function closePPTemplateModal() { function closePPTemplateModal() {
document.getElementById('pp-template-modal').style.display = 'none'; document.getElementById('pp-template-modal').style.display = 'none';
_modalFilters = [];
unlockBody(); unlockBody();
} }
@@ -3661,7 +3816,7 @@ async function showStreamSelector(deviceId) {
(data.streams || []).forEach(s => { (data.streams || []).forEach(s => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = s.id; opt.value = s.id;
const typeIcon = s.stream_type === 'raw' ? '📷' : '🎨'; const typeIcon = s.stream_type === 'raw' ? '🖥️' : '🎨';
opt.textContent = `${typeIcon} ${s.name}`; opt.textContent = `${typeIcon} ${s.name}`;
streamSelect.appendChild(opt); streamSelect.appendChild(opt);
}); });
@@ -3672,13 +3827,17 @@ async function showStreamSelector(deviceId) {
// Populate LED projection fields // Populate LED projection fields
const borderWidth = settings.border_width ?? device.settings?.border_width ?? 10; const borderWidth = settings.border_width ?? device.settings?.border_width ?? 10;
const smoothing = settings.smoothing ?? device.settings?.smoothing ?? 0.3;
document.getElementById('stream-selector-border-width').value = borderWidth; document.getElementById('stream-selector-border-width').value = borderWidth;
document.getElementById('stream-selector-interpolation').value = device.settings?.interpolation_mode || 'average'; document.getElementById('stream-selector-interpolation').value = device.settings?.interpolation_mode || 'average';
document.getElementById('stream-selector-smoothing').value = smoothing;
document.getElementById('stream-selector-smoothing-value').textContent = smoothing;
streamSelectorInitialValues = { streamSelectorInitialValues = {
stream: currentStreamId, stream: currentStreamId,
border_width: String(borderWidth), border_width: String(borderWidth),
interpolation: device.settings?.interpolation_mode || 'average', interpolation: device.settings?.interpolation_mode || 'average',
smoothing: String(smoothing),
}; };
document.getElementById('stream-selector-device-id').value = deviceId; document.getElementById('stream-selector-device-id').value = deviceId;
@@ -3735,6 +3894,7 @@ async function saveStreamSelector() {
const pictureStreamId = document.getElementById('stream-selector-stream').value; const pictureStreamId = document.getElementById('stream-selector-stream').value;
const borderWidth = parseInt(document.getElementById('stream-selector-border-width').value) || 10; const borderWidth = parseInt(document.getElementById('stream-selector-border-width').value) || 10;
const interpolation = document.getElementById('stream-selector-interpolation').value; const interpolation = document.getElementById('stream-selector-interpolation').value;
const smoothing = parseFloat(document.getElementById('stream-selector-smoothing').value);
const errorEl = document.getElementById('stream-selector-error'); const errorEl = document.getElementById('stream-selector-error');
try { try {
@@ -3761,7 +3921,7 @@ async function saveStreamSelector() {
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify({ ...currentSettings, border_width: borderWidth, interpolation_mode: interpolation }) body: JSON.stringify({ ...currentSettings, border_width: borderWidth, interpolation_mode: interpolation, smoothing: smoothing })
}); });
if (!settingsResponse.ok) { if (!settingsResponse.ok) {
@@ -3783,7 +3943,8 @@ function isStreamSettingsDirty() {
return ( return (
document.getElementById('stream-selector-stream').value !== streamSelectorInitialValues.stream || document.getElementById('stream-selector-stream').value !== streamSelectorInitialValues.stream ||
document.getElementById('stream-selector-border-width').value !== streamSelectorInitialValues.border_width || document.getElementById('stream-selector-border-width').value !== streamSelectorInitialValues.border_width ||
document.getElementById('stream-selector-interpolation').value !== streamSelectorInitialValues.interpolation document.getElementById('stream-selector-interpolation').value !== streamSelectorInitialValues.interpolation ||
document.getElementById('stream-selector-smoothing').value !== streamSelectorInitialValues.smoothing
); );
} }

View File

@@ -89,7 +89,7 @@
<div class="tab-panel" id="tab-pp-templates"> <div class="tab-panel" id="tab-pp-templates">
<p class="section-tip"> <p class="section-tip">
<span data-i18n="postprocessing.description"> <span data-i18n="postprocessing.description">
Processing templates define color correction and smoothing settings. Assign them to processed picture streams for consistent postprocessing across devices. Processing templates define image filters and color correction. Assign them to processed picture streams for consistent postprocessing across devices.
</span> </span>
</p> </p>
<div id="pp-templates-list" class="templates-grid"> <div id="pp-templates-list" class="templates-grid">
@@ -303,6 +303,15 @@
<small class="input-hint" data-i18n="device.stream_settings.interpolation_hint">How to calculate LED color from sampled pixels</small> <small class="input-hint" data-i18n="device.stream_settings.interpolation_hint">How to calculate LED color from sampled pixels</small>
</div> </div>
<div class="form-group">
<label for="stream-selector-smoothing">
<span data-i18n="device.stream_settings.smoothing">Smoothing:</span>
<span id="stream-selector-smoothing-value">0.3</span>
</label>
<input type="range" id="stream-selector-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('stream-selector-smoothing-value').textContent = this.value">
<small class="input-hint" data-i18n="device.stream_settings.smoothing_hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
</div>
<div id="stream-selector-error" class="error-message" style="display: none;"></div> <div id="stream-selector-error" class="error-message" style="display: none;"></div>
</form> </form>
</div> </div>
@@ -566,36 +575,15 @@
<input type="text" id="pp-template-name" data-i18n-placeholder="postprocessing.name.placeholder" placeholder="My Processing Template" required> <input type="text" id="pp-template-name" data-i18n-placeholder="postprocessing.name.placeholder" placeholder="My Processing Template" required>
</div> </div>
<div class="form-group"> <!-- Dynamic filter list -->
<label for="pp-template-gamma"> <div id="pp-filter-list" class="pp-filter-list"></div>
<span data-i18n="postprocessing.gamma">Gamma:</span>
<span id="pp-template-gamma-value">2.2</span>
</label>
<input type="range" id="pp-template-gamma" min="0.1" max="5.0" step="0.1" value="2.2" oninput="document.getElementById('pp-template-gamma-value').textContent = this.value">
</div>
<div class="form-group"> <!-- Add filter control -->
<label for="pp-template-saturation"> <div class="pp-add-filter-row">
<span data-i18n="postprocessing.saturation">Saturation:</span> <select id="pp-add-filter-select" class="pp-add-filter-select">
<span id="pp-template-saturation-value">1.0</span> <option value="" data-i18n="filters.select_type">Select filter type...</option>
</label> </select>
<input type="range" id="pp-template-saturation" min="0.0" max="2.0" step="0.1" value="1.0" oninput="document.getElementById('pp-template-saturation-value').textContent = this.value"> <button type="button" class="pp-add-filter-btn" onclick="addFilterFromSelect()" title="Add Filter">+</button>
</div>
<div class="form-group">
<label for="pp-template-brightness">
<span data-i18n="postprocessing.brightness">Brightness:</span>
<span id="pp-template-brightness-value">1.0</span>
</label>
<input type="range" id="pp-template-brightness" min="0.0" max="1.0" step="0.05" value="1.0" oninput="document.getElementById('pp-template-brightness-value').textContent = this.value">
</div>
<div class="form-group">
<label for="pp-template-smoothing">
<span data-i18n="postprocessing.smoothing">Smoothing:</span>
<span id="pp-template-smoothing-value">0.3</span>
</label>
<input type="range" id="pp-template-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('pp-template-smoothing-value').textContent = this.value">
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@@ -230,15 +230,23 @@
"streams.test.duration": "Capture Duration (s):", "streams.test.duration": "Capture Duration (s):",
"streams.test.error.failed": "Stream test failed", "streams.test.error.failed": "Stream test failed",
"postprocessing.title": "\uD83C\uDFA8 Processing Templates", "postprocessing.title": "\uD83C\uDFA8 Processing Templates",
"postprocessing.description": "Processing templates define color correction and smoothing settings. Assign them to processed picture streams for consistent postprocessing across devices.", "postprocessing.description": "Processing templates define image filters and color correction. Assign them to processed picture streams for consistent postprocessing across devices.",
"postprocessing.add": "Add Processing Template", "postprocessing.add": "Add Processing Template",
"postprocessing.edit": "Edit Processing Template", "postprocessing.edit": "Edit Processing Template",
"postprocessing.name": "Template Name:", "postprocessing.name": "Template Name:",
"postprocessing.name.placeholder": "My Processing Template", "postprocessing.name.placeholder": "My Processing Template",
"postprocessing.gamma": "Gamma:", "filters.select_type": "Select filter type...",
"postprocessing.saturation": "Saturation:", "filters.add": "Add Filter",
"postprocessing.brightness": "Brightness:", "filters.remove": "Remove",
"postprocessing.smoothing": "Smoothing:", "filters.move_up": "Move Up",
"filters.move_down": "Move Down",
"filters.empty": "No filters added. Use the selector below to add filters.",
"filters.brightness": "Brightness",
"filters.saturation": "Saturation",
"filters.gamma": "Gamma",
"filters.downscaler": "Downscaler",
"filters.pixelate": "Pixelate",
"filters.auto_crop": "Auto Crop",
"postprocessing.description_label": "Description (optional):", "postprocessing.description_label": "Description (optional):",
"postprocessing.description_placeholder": "Describe this template...", "postprocessing.description_placeholder": "Describe this template...",
"postprocessing.created": "Template created successfully", "postprocessing.created": "Template created successfully",
@@ -262,5 +270,7 @@
"device.stream_settings.interpolation.median": "Median", "device.stream_settings.interpolation.median": "Median",
"device.stream_settings.interpolation.dominant": "Dominant", "device.stream_settings.interpolation.dominant": "Dominant",
"device.stream_settings.interpolation_hint": "How to calculate LED color from sampled pixels", "device.stream_settings.interpolation_hint": "How to calculate LED color from sampled pixels",
"device.stream_settings.smoothing": "Smoothing:",
"device.stream_settings.smoothing_hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
"device.tip.stream_selector": "Configure picture stream and LED projection settings for this device" "device.tip.stream_selector": "Configure picture stream and LED projection settings for this device"
} }

View File

@@ -230,15 +230,23 @@
"streams.test.duration": "Длительность Захвата (с):", "streams.test.duration": "Длительность Захвата (с):",
"streams.test.error.failed": "Тест потока не удался", "streams.test.error.failed": "Тест потока не удался",
"postprocessing.title": "\uD83C\uDFA8 Шаблоны Обработки", "postprocessing.title": "\uD83C\uDFA8 Шаблоны Обработки",
"postprocessing.description": "Шаблоны обработки определяют настройки цветокоррекции и сглаживания. Назначайте их обработанным видеопотокам для единообразной постобработки на всех устройствах.", "postprocessing.description": "Шаблоны обработки определяют фильтры изображений и цветокоррекцию. Назначайте их обработанным видеопотокам для единообразной постобработки на всех устройствах.",
"postprocessing.add": "Добавить Шаблон Обработки", "postprocessing.add": "Добавить Шаблон Обработки",
"postprocessing.edit": "Редактировать Шаблон Обработки", "postprocessing.edit": "Редактировать Шаблон Обработки",
"postprocessing.name": "Имя Шаблона:", "postprocessing.name": "Имя Шаблона:",
"postprocessing.name.placeholder": "Мой Шаблон Обработки", "postprocessing.name.placeholder": "Мой Шаблон Обработки",
"postprocessing.gamma": "Гамма:", "filters.select_type": "Выберите тип фильтра...",
"postprocessing.saturation": "Насыщенность:", "filters.add": "Добавить фильтр",
"postprocessing.brightness": "Яркость:", "filters.remove": "Удалить",
"postprocessing.smoothing": "Сглаживание:", "filters.move_up": "Вверх",
"filters.move_down": "Вниз",
"filters.empty": "Фильтры не добавлены. Используйте селектор ниже для добавления.",
"filters.brightness": "Яркость",
"filters.saturation": "Насыщенность",
"filters.gamma": "Гамма",
"filters.downscaler": "Уменьшение",
"filters.pixelate": "Пикселизация",
"filters.auto_crop": "Авто Обрезка",
"postprocessing.description_label": "Описание (необязательно):", "postprocessing.description_label": "Описание (необязательно):",
"postprocessing.description_placeholder": "Опишите этот шаблон...", "postprocessing.description_placeholder": "Опишите этот шаблон...",
"postprocessing.created": "Шаблон успешно создан", "postprocessing.created": "Шаблон успешно создан",
@@ -262,5 +270,7 @@
"device.stream_settings.interpolation.median": "Медиана", "device.stream_settings.interpolation.median": "Медиана",
"device.stream_settings.interpolation.dominant": "Доминантный", "device.stream_settings.interpolation.dominant": "Доминантный",
"device.stream_settings.interpolation_hint": "Как вычислять цвет LED из выбранных пикселей", "device.stream_settings.interpolation_hint": "Как вычислять цвет LED из выбранных пикселей",
"device.stream_settings.smoothing": "Сглаживание:",
"device.stream_settings.smoothing_hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
"device.tip.stream_selector": "Настройки видеопотока и проекции LED для этого устройства" "device.tip.stream_selector": "Настройки видеопотока и проекции LED для этого устройства"
} }

View File

@@ -1926,6 +1926,163 @@ input:-webkit-autofill:focus {
font-size: 13px; font-size: 13px;
} }
/* PP Filter List in Template Modal */
.pp-filter-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.pp-filter-empty {
color: var(--text-secondary);
font-size: 13px;
text-align: center;
padding: 16px;
border: 1px dashed var(--border-color);
border-radius: 8px;
}
.pp-filter-card {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
padding: 10px 12px;
}
.pp-filter-card-header {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
}
.pp-filter-card.expanded .pp-filter-card-header {
margin-bottom: 8px;
}
.pp-filter-card-chevron {
font-size: 10px;
color: var(--text-secondary);
flex-shrink: 0;
width: 12px;
}
.pp-filter-card-name {
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
}
.pp-filter-card-summary {
color: var(--text-secondary);
font-size: 12px;
margin-right: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pp-filter-card-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
margin-left: auto;
}
.btn-filter-action {
background: none;
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
padding: 0;
}
.btn-filter-action:hover:not(:disabled) {
background: var(--border-color);
color: var(--text-primary);
}
.btn-filter-action:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.btn-filter-remove:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.4);
color: #ef4444;
}
.pp-filter-card-options {
display: flex;
flex-direction: column;
gap: 4px;
}
.pp-filter-option {
display: flex;
flex-direction: column;
gap: 2px;
}
.pp-filter-option label {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-secondary);
}
.pp-filter-option input[type="range"] {
width: 100%;
}
.pp-add-filter-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 4px;
}
.pp-add-filter-select {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--card-bg);
color: var(--text-primary);
font-size: 13px;
}
.pp-add-filter-btn {
width: 34px;
height: 34px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--card-bg);
color: var(--text-primary);
font-size: 20px;
cursor: pointer;
padding: 0;
line-height: 1;
}
.pp-add-filter-btn:hover {
background: var(--border-color);
}
/* Template Test Section */ /* Template Test Section */
.template-test-section { .template-test-section {
background: var(--bg-secondary); background: var(--bg-secondary);

View File

@@ -1,20 +1,19 @@
"""Postprocessing template data model.""" """Postprocessing template data model."""
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Optional from typing import List, Optional
from wled_controller.core.filters.filter_instance import FilterInstance
@dataclass @dataclass
class PostprocessingTemplate: class PostprocessingTemplate:
"""Postprocessing settings template for color correction and smoothing.""" """Postprocessing settings template containing an ordered list of filters."""
id: str id: str
name: str name: str
gamma: float filters: List[FilterInstance]
saturation: float
brightness: float
smoothing: float
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
description: Optional[str] = None description: Optional[str] = None
@@ -24,10 +23,7 @@ class PostprocessingTemplate:
return { return {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"gamma": self.gamma, "filters": [f.to_dict() for f in self.filters],
"saturation": self.saturation,
"brightness": self.brightness,
"smoothing": self.smoothing,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
"description": self.description, "description": self.description,
@@ -35,14 +31,30 @@ class PostprocessingTemplate:
@classmethod @classmethod
def from_dict(cls, data: dict) -> "PostprocessingTemplate": def from_dict(cls, data: dict) -> "PostprocessingTemplate":
"""Create template from dictionary.""" """Create template from dictionary.
Supports migration from legacy flat-field format (gamma/saturation/brightness)
to the new filters list format.
"""
if "filters" in data:
filters = [FilterInstance.from_dict(f) for f in data["filters"]]
else:
# Legacy migration: construct filters from flat fields
filters = []
brightness = data.get("brightness", 1.0)
if brightness != 1.0:
filters.append(FilterInstance("brightness", {"value": brightness}))
saturation = data.get("saturation", 1.0)
if saturation != 1.0:
filters.append(FilterInstance("saturation", {"value": saturation}))
gamma = data.get("gamma", 2.2)
if gamma != 2.2:
filters.append(FilterInstance("gamma", {"value": gamma}))
return cls( return cls(
id=data["id"], id=data["id"],
name=data["name"], name=data["name"],
gamma=data.get("gamma", 2.2), filters=filters,
saturation=data.get("saturation", 1.0),
brightness=data.get("brightness", 1.0),
smoothing=data.get("smoothing", 0.3),
created_at=datetime.fromisoformat(data["created_at"]) created_at=datetime.fromisoformat(data["created_at"])
if isinstance(data.get("created_at"), str) if isinstance(data.get("created_at"), str)
else data.get("created_at", datetime.utcnow()), else data.get("created_at", datetime.utcnow()),

View File

@@ -6,6 +6,8 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
from wled_controller.core.filters.filter_instance import FilterInstance
from wled_controller.core.filters.registry import FilterRegistry
from wled_controller.storage.postprocessing_template import PostprocessingTemplate from wled_controller.storage.postprocessing_template import PostprocessingTemplate
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -41,10 +43,11 @@ class PostprocessingTemplateStore:
template = PostprocessingTemplate( template = PostprocessingTemplate(
id=template_id, id=template_id,
name="Default", name="Default",
gamma=2.2, filters=[
saturation=1.0, FilterInstance("brightness", {"value": 1.0}),
brightness=1.0, FilterInstance("saturation", {"value": 1.0}),
smoothing=0.3, FilterInstance("gamma", {"value": 2.2}),
],
created_at=now, created_at=now,
updated_at=now, updated_at=now,
description="Default postprocessing template", description="Default postprocessing template",
@@ -96,7 +99,7 @@ class PostprocessingTemplateStore:
} }
data = { data = {
"version": "1.0.0", "version": "2.0.0",
"postprocessing_templates": templates_dict, "postprocessing_templates": templates_dict,
} }
@@ -124,31 +127,38 @@ class PostprocessingTemplateStore:
def create_template( def create_template(
self, self,
name: str, name: str,
gamma: float = 2.2, filters: Optional[List[FilterInstance]] = None,
saturation: float = 1.0,
brightness: float = 1.0,
smoothing: float = 0.3,
description: Optional[str] = None, description: Optional[str] = None,
) -> PostprocessingTemplate: ) -> PostprocessingTemplate:
"""Create a new postprocessing template. """Create a new postprocessing template.
Args:
name: Template name (must be unique)
filters: Ordered list of filter instances
description: Optional description
Raises: Raises:
ValueError: If template with same name exists ValueError: If template with same name exists or invalid filter_id
""" """
for template in self._templates.values(): for template in self._templates.values():
if template.name == name: if template.name == name:
raise ValueError(f"Postprocessing template with name '{name}' already exists") raise ValueError(f"Postprocessing template with name '{name}' already exists")
if filters is None:
filters = []
# Validate filter IDs
for fi in filters:
if not FilterRegistry.is_registered(fi.filter_id):
raise ValueError(f"Unknown filter type: '{fi.filter_id}'")
template_id = f"pp_{uuid.uuid4().hex[:8]}" template_id = f"pp_{uuid.uuid4().hex[:8]}"
now = datetime.utcnow() now = datetime.utcnow()
template = PostprocessingTemplate( template = PostprocessingTemplate(
id=template_id, id=template_id,
name=name, name=name,
gamma=gamma, filters=filters,
saturation=saturation,
brightness=brightness,
smoothing=smoothing,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
description=description, description=description,
@@ -164,16 +174,13 @@ class PostprocessingTemplateStore:
self, self,
template_id: str, template_id: str,
name: Optional[str] = None, name: Optional[str] = None,
gamma: Optional[float] = None, filters: Optional[List[FilterInstance]] = None,
saturation: Optional[float] = None,
brightness: Optional[float] = None,
smoothing: Optional[float] = None,
description: Optional[str] = None, description: Optional[str] = None,
) -> PostprocessingTemplate: ) -> PostprocessingTemplate:
"""Update an existing postprocessing template. """Update an existing postprocessing template.
Raises: Raises:
ValueError: If template not found ValueError: If template not found or invalid filter_id
""" """
if template_id not in self._templates: if template_id not in self._templates:
raise ValueError(f"Postprocessing template not found: {template_id}") raise ValueError(f"Postprocessing template not found: {template_id}")
@@ -182,14 +189,12 @@ class PostprocessingTemplateStore:
if name is not None: if name is not None:
template.name = name template.name = name
if gamma is not None: if filters is not None:
template.gamma = gamma # Validate filter IDs
if saturation is not None: for fi in filters:
template.saturation = saturation if not FilterRegistry.is_registered(fi.filter_id):
if brightness is not None: raise ValueError(f"Unknown filter type: '{fi.filter_id}'")
template.brightness = brightness template.filters = filters
if smoothing is not None:
template.smoothing = smoothing
if description is not None: if description is not None:
template.description = description template.description = description