From ebd6cc7d7d14dc7eb90000e18939847ffeb9559f Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 11 Feb 2026 11:57:19 +0300 Subject: [PATCH] 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 --- server/src/wled_controller/api/routes.py | 80 +++-- server/src/wled_controller/api/schemas.py | 54 +++- .../wled_controller/core/filters/__init__.py | 21 ++ .../src/wled_controller/core/filters/base.py | 91 ++++++ .../wled_controller/core/filters/builtin.py | 282 ++++++++++++++++++ .../core/filters/filter_instance.py | 25 ++ .../core/filters/image_pool.py | 41 +++ .../wled_controller/core/filters/registry.py | 53 ++++ .../wled_controller/core/processor_manager.py | 69 +++-- server/src/wled_controller/static/app.js | 243 ++++++++++++--- server/src/wled_controller/static/index.html | 48 ++- .../wled_controller/static/locales/en.json | 20 +- .../wled_controller/static/locales/ru.json | 20 +- server/src/wled_controller/static/style.css | 157 ++++++++++ .../storage/postprocessing_template.py | 44 ++- .../storage/postprocessing_template_store.py | 59 ++-- 16 files changed, 1115 insertions(+), 192 deletions(-) create mode 100644 server/src/wled_controller/core/filters/__init__.py create mode 100644 server/src/wled_controller/core/filters/base.py create mode 100644 server/src/wled_controller/core/filters/builtin.py create mode 100644 server/src/wled_controller/core/filters/filter_instance.py create mode 100644 server/src/wled_controller/core/filters/image_pool.py create mode 100644 server/src/wled_controller/core/filters/registry.py diff --git a/server/src/wled_controller/api/routes.py b/server/src/wled_controller/api/routes.py index 566898a..d8d140d 100644 --- a/server/src/wled_controller/api/routes.py +++ b/server/src/wled_controller/api/routes.py @@ -40,6 +40,10 @@ from wled_controller.api.schemas import ( CaptureImage, BorderExtraction, PerformanceMetrics, + FilterInstanceSchema, + FilterOptionDefSchema, + FilterTypeResponse, + FilterTypeListResponse, PostprocessingTemplateCreate, PostprocessingTemplateUpdate, 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.picture_stream_store import PictureStreamStore 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.core.screen_capture import get_available_displays @@ -587,6 +592,7 @@ async def get_settings( border_width=device.settings.border_width, interpolation_mode=device.settings.interpolation_mode, brightness=device.settings.brightness, + smoothing=device.settings.smoothing, 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, gamma=settings.color_correction.gamma if settings.color_correction else existing.gamma, 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, ) @@ -1106,6 +1112,34 @@ async def test_template( 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 ===== def _pp_template_to_response(t) -> PostprocessingTemplateResponse: @@ -1113,10 +1147,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse: return PostprocessingTemplateResponse( id=t.id, name=t.name, - gamma=t.gamma, - saturation=t.saturation, - brightness=t.brightness, - smoothing=t.smoothing, + filters=[FilterInstanceSchema(filter_id=f.filter_id, options=f.options) for f in t.filters], created_at=t.created_at, updated_at=t.updated_at, description=t.description, @@ -1146,12 +1177,10 @@ async def create_pp_template( ): """Create a new postprocessing template.""" try: + filters = [FilterInstance(f.filter_id, f.options) for f in data.filters] template = store.create_template( name=data.name, - gamma=data.gamma, - saturation=data.saturation, - brightness=data.brightness, - smoothing=data.smoothing, + filters=filters, description=data.description, ) return _pp_template_to_response(template) @@ -1185,13 +1214,11 @@ async def update_pp_template( ): """Update a postprocessing template.""" 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_id=template_id, name=data.name, - gamma=data.gamma, - saturation=data.saturation, - brightness=data.brightness, - smoothing=data.smoothing, + filters=filters, description=data.description, ) return _pp_template_to_response(template) @@ -1477,25 +1504,24 @@ async def test_picture_stream( thumbnail = pil_image.copy() 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"] if pp_template_ids: 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): - arr = np.array(img, dtype=np.float32) / 255.0 - if pp.brightness != 1.0: - arr *= pp.brightness - if pp.saturation != 1.0: - lum = np.dot(arr[..., :3], [0.299, 0.587, 0.114])[..., np.newaxis] - arr[..., :3] = lum + (arr[..., :3] - lum) * pp.saturation - if pp.gamma != 1.0: - 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)) + def apply_filters(img): + arr = np.array(img) + for fi in pp_template.filters: + f = FilterRegistry.create_instance(fi.filter_id, fi.options) + result = f.process_image(arr, pool) + if result is not None: + arr = result + return Image.fromarray(arr) - thumbnail = apply_pp(thumbnail) - pil_image = apply_pp(pil_image) + thumbnail = apply_filters(thumbnail) + pil_image = apply_filters(pil_image) except ValueError: logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview") diff --git a/server/src/wled_controller/api/schemas.py b/server/src/wled_controller/api/schemas.py index 407d887..26e1ba8 100644 --- a/server/src/wled_controller/api/schemas.py +++ b/server/src/wled_controller/api/schemas.py @@ -1,7 +1,7 @@ """Pydantic schemas for API request and response models.""" 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 @@ -84,6 +84,7 @@ class ProcessingSettings(BaseModel): 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)") 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( default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600, description="Seconds between WLED health checks" @@ -326,16 +327,49 @@ class TemplateTestResponse(BaseModel): 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 class PostprocessingTemplateCreate(BaseModel): """Request to create a postprocessing template.""" 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) - 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) + filters: List[FilterInstanceSchema] = Field(default_factory=list, description="Ordered list of filter instances") description: Optional[str] = Field(None, description="Template description", max_length=500) @@ -343,10 +377,7 @@ class PostprocessingTemplateUpdate(BaseModel): """Request to update a postprocessing template.""" 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) - 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) + filters: Optional[List[FilterInstanceSchema]] = Field(None, description="Ordered list of filter instances") description: Optional[str] = Field(None, description="Template description", max_length=500) @@ -355,10 +386,7 @@ class PostprocessingTemplateResponse(BaseModel): id: str = Field(description="Template ID") name: str = Field(description="Template name") - gamma: float = Field(description="Gamma correction") - saturation: float = Field(description="Saturation multiplier") - brightness: float = Field(description="Brightness multiplier") - smoothing: float = Field(description="Temporal smoothing factor") + filters: List[FilterInstanceSchema] = Field(description="Ordered list of filter instances") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") description: Optional[str] = Field(None, description="Template description") diff --git a/server/src/wled_controller/core/filters/__init__.py b/server/src/wled_controller/core/filters/__init__.py new file mode 100644 index 0000000..590f5a3 --- /dev/null +++ b/server/src/wled_controller/core/filters/__init__.py @@ -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", +] diff --git a/server/src/wled_controller/core/filters/base.py b/server/src/wled_controller/core/filters/base.py new file mode 100644 index 0000000..dc6d18c --- /dev/null +++ b/server/src/wled_controller/core/filters/base.py @@ -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})" diff --git a/server/src/wled_controller/core/filters/builtin.py b/server/src/wled_controller/core/filters/builtin.py new file mode 100644 index 0000000..1036262 --- /dev/null +++ b/server/src/wled_controller/core/filters/builtin.py @@ -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 diff --git a/server/src/wled_controller/core/filters/filter_instance.py b/server/src/wled_controller/core/filters/filter_instance.py new file mode 100644 index 0000000..316dc53 --- /dev/null +++ b/server/src/wled_controller/core/filters/filter_instance.py @@ -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", {}), + ) diff --git a/server/src/wled_controller/core/filters/image_pool.py b/server/src/wled_controller/core/filters/image_pool.py new file mode 100644 index 0000000..ec0df5a --- /dev/null +++ b/server/src/wled_controller/core/filters/image_pool.py @@ -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() diff --git a/server/src/wled_controller/core/filters/registry.py b/server/src/wled_controller/core/filters/registry.py new file mode 100644 index 0000000..130f7f9 --- /dev/null +++ b/server/src/wled_controller/core/filters/registry.py @@ -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 diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index df34132..48bd2bc 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -14,7 +14,8 @@ from wled_controller.core.calibration import ( create_default_calibration, ) 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.wled_client import WLEDClient from wled_controller.utils import get_logger @@ -106,10 +107,9 @@ class ProcessorState: resolved_target_fps: Optional[int] = None resolved_engine_type: Optional[str] = None resolved_engine_config: Optional[dict] = None - resolved_gamma: Optional[float] = None - resolved_saturation: Optional[float] = None - resolved_brightness: Optional[float] = None - resolved_smoothing: Optional[float] = None + resolved_filters: Optional[List[FilterInstance]] = None + image_pool: Optional[ImagePool] = None + filter_instances: Optional[List[PostprocessingFilter]] = None class ProcessorManager: @@ -298,10 +298,7 @@ class ProcessorManager: if pp_template_ids and self._pp_template_store: try: pp = self._pp_template_store.get_template(pp_template_ids[0]) - state.resolved_gamma = pp.gamma - state.resolved_saturation = pp.saturation - state.resolved_brightness = pp.brightness - state.resolved_smoothing = pp.smoothing + state.resolved_filters = pp.filters except ValueError: logger.warning(f"PP template {pp_template_ids[0]} not found, using defaults") @@ -314,13 +311,17 @@ class ProcessorManager: except ValueError as e: 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_target_fps = state.settings.fps - state.resolved_gamma = state.settings.gamma - state.resolved_saturation = state.settings.saturation - state.resolved_brightness = state.settings.brightness - state.resolved_smoothing = state.settings.smoothing + legacy_filters = [] + if state.settings.brightness != 1.0: + legacy_filters.append(FilterInstance("brightness", {"value": state.settings.brightness})) + 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 if state.capture_template_id and self._capture_template_store: @@ -457,23 +458,40 @@ class ProcessorManager: # Use resolved values (populated by _resolve_stream_settings) display_index = state.resolved_display_index or settings.display_index target_fps = state.resolved_target_fps or settings.fps - gamma = state.resolved_gamma if state.resolved_gamma is not None else settings.gamma - 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 + smoothing = settings.smoothing # These always come from device settings (LED projection) border_width = settings.border_width 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( 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 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: while state.is_running: loop_start = time.time() @@ -490,21 +508,16 @@ class ProcessorManager: 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 border_pixels = await asyncio.to_thread(extract_border_pixels, capture, border_width) # Map to LED colors 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 if state.previous_colors and smoothing > 0: led_colors = await asyncio.to_thread( diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 806fa74..47253da 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -2992,6 +2992,7 @@ async function deleteTemplate(templateId) { let _cachedStreams = []; let _cachedPPTemplates = []; +let _availableFilters = []; // Loaded from GET /filters async function loadPictureStreams() { try { @@ -3017,7 +3018,7 @@ function renderPictureStreamsList(streams) { container.innerHTML = `
- 📷 + 🖥️ ${t('streams.group.raw')} 0
@@ -3045,7 +3046,7 @@ function renderPictureStreamsList(streams) { } const renderCard = (stream) => { - const typeIcon = stream.stream_type === 'raw' ? '📷' : '🎨'; + const typeIcon = stream.stream_type === 'raw' ? '🖥️' : '🎨'; const typeBadge = stream.stream_type === 'raw' ? `${t('streams.type.raw')}` : `${t('streams.type.processed')}`; @@ -3102,7 +3103,7 @@ function renderPictureStreamsList(streams) { // Screen Capture streams section html += `
- 📷 + 🖥️ ${t('streams.group.raw')} ${rawStreams.length}
@@ -3252,7 +3253,7 @@ async function populateStreamModalDropdowns() { if (s.id === editingId) return; const opt = document.createElement('option'); opt.value = s.id; - const typeLabel = s.stream_type === 'raw' ? '📷' : '🎨'; + const typeLabel = s.stream_type === 'raw' ? '🖥️' : '🎨'; opt.textContent = `${typeLabel} ${s.name}`; sourceSelect.appendChild(opt); }); @@ -3423,8 +3424,24 @@ function displayStreamTestResults(result) { // ===== 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() { try { + // Ensure filters are loaded for rendering + if (_availableFilters.length === 0) { + await loadAvailableFilters(); + } const response = await fetchWithAuth('/postprocessing-templates'); if (!response.ok) { 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) { const container = document.getElementById('pp-templates-list'); @@ -3452,12 +3480,12 @@ function renderPPTemplatesList(templates) { } const renderCard = (tmpl) => { - const configEntries = { - [t('postprocessing.gamma')]: tmpl.gamma, - [t('postprocessing.saturation')]: tmpl.saturation, - [t('postprocessing.brightness')]: tmpl.brightness, - [t('postprocessing.smoothing')]: tmpl.smoothing, - }; + // Build config entries from filter list + const filterRows = (tmpl.filters || []).map(fi => { + const filterName = _getFilterName(fi.filter_id); + const optStr = Object.entries(fi.options || {}).map(([k, v]) => `${v}`).join(', '); + return `${escapeHtml(filterName)}${escapeHtml(optStr)}`; + }).join(''); return `
@@ -3471,12 +3499,7 @@ function renderPPTemplatesList(templates) {
${t('postprocessing.config.show')} - ${Object.entries(configEntries).map(([key, val]) => ` - - - - - `).join('')} + ${filterRows}
${escapeHtml(key)}${escapeHtml(String(val))}
@@ -3497,21 +3520,154 @@ function renderPPTemplatesList(templates) { 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 = ``; + for (const f of _availableFilters) { + const name = _getFilterName(f.filter_id); + select.innerHTML += ``; + } +} + +function renderModalFilterList() { + const container = document.getElementById('pp-filter-list'); + if (_modalFilters.length === 0) { + container.innerHTML = `
${t('filters.empty')}
`; + 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 += `
+
+ ${isExpanded ? '▼' : '▶'} + ${escapeHtml(filterName)} + ${summary ? `${escapeHtml(summary)}` : ''} +
+ + + +
+
+
`; + }); + + 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() { + if (_availableFilters.length === 0) await loadAvailableFilters(); + document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add'); document.getElementById('pp-template-form').reset(); document.getElementById('pp-template-id').value = ''; document.getElementById('pp-template-error').style.display = 'none'; - // Reset slider displays to defaults - document.getElementById('pp-template-gamma').value = '2.2'; - document.getElementById('pp-template-gamma-value').textContent = '2.2'; - document.getElementById('pp-template-saturation').value = '1.0'; - 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'; + _modalFilters = []; + + _populateFilterSelect(); + renderModalFilterList(); const modal = document.getElementById('pp-template-modal'); modal.style.display = 'flex'; @@ -3521,6 +3677,8 @@ async function showAddPPTemplateModal() { async function editPPTemplate(templateId) { try { + if (_availableFilters.length === 0) await loadAvailableFilters(); + const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`); if (!response.ok) throw new Error(`Failed to load template: ${response.status}`); 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-error').style.display = 'none'; - // Set sliders - document.getElementById('pp-template-gamma').value = tmpl.gamma; - document.getElementById('pp-template-gamma-value').textContent = tmpl.gamma; - document.getElementById('pp-template-saturation').value = tmpl.saturation; - 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; - document.getElementById('pp-template-smoothing').value = tmpl.smoothing; - document.getElementById('pp-template-smoothing-value').textContent = tmpl.smoothing; + // Load filters from template + _modalFilters = (tmpl.filters || []).map(fi => ({ + filter_id: fi.filter_id, + options: { ...fi.options }, + })); + + _populateFilterSelect(); + renderModalFilterList(); const modal = document.getElementById('pp-template-modal'); modal.style.display = 'flex'; @@ -3564,10 +3721,7 @@ async function savePPTemplate() { const payload = { name, - gamma: parseFloat(document.getElementById('pp-template-gamma').value), - saturation: parseFloat(document.getElementById('pp-template-saturation').value), - brightness: parseFloat(document.getElementById('pp-template-brightness').value), - smoothing: parseFloat(document.getElementById('pp-template-smoothing').value), + filters: collectFilters(), description: description || null, }; @@ -3624,6 +3778,7 @@ async function deletePPTemplate(templateId) { function closePPTemplateModal() { document.getElementById('pp-template-modal').style.display = 'none'; + _modalFilters = []; unlockBody(); } @@ -3661,7 +3816,7 @@ async function showStreamSelector(deviceId) { (data.streams || []).forEach(s => { const opt = document.createElement('option'); opt.value = s.id; - const typeIcon = s.stream_type === 'raw' ? '📷' : '🎨'; + const typeIcon = s.stream_type === 'raw' ? '🖥️' : '🎨'; opt.textContent = `${typeIcon} ${s.name}`; streamSelect.appendChild(opt); }); @@ -3672,13 +3827,17 @@ async function showStreamSelector(deviceId) { // Populate LED projection fields 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-interpolation').value = device.settings?.interpolation_mode || 'average'; + document.getElementById('stream-selector-smoothing').value = smoothing; + document.getElementById('stream-selector-smoothing-value').textContent = smoothing; streamSelectorInitialValues = { stream: currentStreamId, border_width: String(borderWidth), interpolation: device.settings?.interpolation_mode || 'average', + smoothing: String(smoothing), }; document.getElementById('stream-selector-device-id').value = deviceId; @@ -3735,6 +3894,7 @@ async function saveStreamSelector() { const pictureStreamId = document.getElementById('stream-selector-stream').value; const borderWidth = parseInt(document.getElementById('stream-selector-border-width').value) || 10; const interpolation = document.getElementById('stream-selector-interpolation').value; + const smoothing = parseFloat(document.getElementById('stream-selector-smoothing').value); const errorEl = document.getElementById('stream-selector-error'); try { @@ -3761,7 +3921,7 @@ async function saveStreamSelector() { const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { method: 'PUT', 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) { @@ -3783,7 +3943,8 @@ function isStreamSettingsDirty() { return ( document.getElementById('stream-selector-stream').value !== streamSelectorInitialValues.stream || 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 ); } diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 2a11607..3c3d490 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -89,7 +89,7 @@

- 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.

@@ -303,6 +303,15 @@ How to calculate LED color from sampled pixels
+
+ + + Temporal blending between frames (0=none, 1=full). Reduces flicker. +
+
@@ -566,36 +575,15 @@
-
- - -
+ +
-
- - -
- -
- - -
- -
- - + +
+ +
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index f4157dc..9c14762 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -230,15 +230,23 @@ "streams.test.duration": "Capture Duration (s):", "streams.test.error.failed": "Stream test failed", "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.edit": "Edit Processing Template", "postprocessing.name": "Template Name:", "postprocessing.name.placeholder": "My Processing Template", - "postprocessing.gamma": "Gamma:", - "postprocessing.saturation": "Saturation:", - "postprocessing.brightness": "Brightness:", - "postprocessing.smoothing": "Smoothing:", + "filters.select_type": "Select filter type...", + "filters.add": "Add Filter", + "filters.remove": "Remove", + "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_placeholder": "Describe this template...", "postprocessing.created": "Template created successfully", @@ -262,5 +270,7 @@ "device.stream_settings.interpolation.median": "Median", "device.stream_settings.interpolation.dominant": "Dominant", "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" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index adeea26..1e711f1 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -230,15 +230,23 @@ "streams.test.duration": "Длительность Захвата (с):", "streams.test.error.failed": "Тест потока не удался", "postprocessing.title": "\uD83C\uDFA8 Шаблоны Обработки", - "postprocessing.description": "Шаблоны обработки определяют настройки цветокоррекции и сглаживания. Назначайте их обработанным видеопотокам для единообразной постобработки на всех устройствах.", + "postprocessing.description": "Шаблоны обработки определяют фильтры изображений и цветокоррекцию. Назначайте их обработанным видеопотокам для единообразной постобработки на всех устройствах.", "postprocessing.add": "Добавить Шаблон Обработки", "postprocessing.edit": "Редактировать Шаблон Обработки", "postprocessing.name": "Имя Шаблона:", "postprocessing.name.placeholder": "Мой Шаблон Обработки", - "postprocessing.gamma": "Гамма:", - "postprocessing.saturation": "Насыщенность:", - "postprocessing.brightness": "Яркость:", - "postprocessing.smoothing": "Сглаживание:", + "filters.select_type": "Выберите тип фильтра...", + "filters.add": "Добавить фильтр", + "filters.remove": "Удалить", + "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_placeholder": "Опишите этот шаблон...", "postprocessing.created": "Шаблон успешно создан", @@ -262,5 +270,7 @@ "device.stream_settings.interpolation.median": "Медиана", "device.stream_settings.interpolation.dominant": "Доминантный", "device.stream_settings.interpolation_hint": "Как вычислять цвет LED из выбранных пикселей", + "device.stream_settings.smoothing": "Сглаживание:", + "device.stream_settings.smoothing_hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.", "device.tip.stream_selector": "Настройки видеопотока и проекции LED для этого устройства" } diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css index 2172241..809252f 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -1926,6 +1926,163 @@ input:-webkit-autofill:focus { 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 { background: var(--bg-secondary); diff --git a/server/src/wled_controller/storage/postprocessing_template.py b/server/src/wled_controller/storage/postprocessing_template.py index 703290b..6d7e51b 100644 --- a/server/src/wled_controller/storage/postprocessing_template.py +++ b/server/src/wled_controller/storage/postprocessing_template.py @@ -1,20 +1,19 @@ """Postprocessing template data model.""" -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime -from typing import Optional +from typing import List, Optional + +from wled_controller.core.filters.filter_instance import FilterInstance @dataclass class PostprocessingTemplate: - """Postprocessing settings template for color correction and smoothing.""" + """Postprocessing settings template containing an ordered list of filters.""" id: str name: str - gamma: float - saturation: float - brightness: float - smoothing: float + filters: List[FilterInstance] created_at: datetime updated_at: datetime description: Optional[str] = None @@ -24,10 +23,7 @@ class PostprocessingTemplate: return { "id": self.id, "name": self.name, - "gamma": self.gamma, - "saturation": self.saturation, - "brightness": self.brightness, - "smoothing": self.smoothing, + "filters": [f.to_dict() for f in self.filters], "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), "description": self.description, @@ -35,14 +31,30 @@ class PostprocessingTemplate: @classmethod 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( id=data["id"], name=data["name"], - gamma=data.get("gamma", 2.2), - saturation=data.get("saturation", 1.0), - brightness=data.get("brightness", 1.0), - smoothing=data.get("smoothing", 0.3), + filters=filters, created_at=datetime.fromisoformat(data["created_at"]) if isinstance(data.get("created_at"), str) else data.get("created_at", datetime.utcnow()), diff --git a/server/src/wled_controller/storage/postprocessing_template_store.py b/server/src/wled_controller/storage/postprocessing_template_store.py index a9ffe2b..798a4aa 100644 --- a/server/src/wled_controller/storage/postprocessing_template_store.py +++ b/server/src/wled_controller/storage/postprocessing_template_store.py @@ -6,6 +6,8 @@ from datetime import datetime from pathlib import Path 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.utils import get_logger @@ -41,10 +43,11 @@ class PostprocessingTemplateStore: template = PostprocessingTemplate( id=template_id, name="Default", - gamma=2.2, - saturation=1.0, - brightness=1.0, - smoothing=0.3, + filters=[ + FilterInstance("brightness", {"value": 1.0}), + FilterInstance("saturation", {"value": 1.0}), + FilterInstance("gamma", {"value": 2.2}), + ], created_at=now, updated_at=now, description="Default postprocessing template", @@ -96,7 +99,7 @@ class PostprocessingTemplateStore: } data = { - "version": "1.0.0", + "version": "2.0.0", "postprocessing_templates": templates_dict, } @@ -124,31 +127,38 @@ class PostprocessingTemplateStore: def create_template( self, name: str, - gamma: float = 2.2, - saturation: float = 1.0, - brightness: float = 1.0, - smoothing: float = 0.3, + filters: Optional[List[FilterInstance]] = None, description: Optional[str] = None, ) -> PostprocessingTemplate: """Create a new postprocessing template. + Args: + name: Template name (must be unique) + filters: Ordered list of filter instances + description: Optional description + 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(): if template.name == name: 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]}" now = datetime.utcnow() template = PostprocessingTemplate( id=template_id, name=name, - gamma=gamma, - saturation=saturation, - brightness=brightness, - smoothing=smoothing, + filters=filters, created_at=now, updated_at=now, description=description, @@ -164,16 +174,13 @@ class PostprocessingTemplateStore: self, template_id: str, name: Optional[str] = None, - gamma: Optional[float] = None, - saturation: Optional[float] = None, - brightness: Optional[float] = None, - smoothing: Optional[float] = None, + filters: Optional[List[FilterInstance]] = None, description: Optional[str] = None, ) -> PostprocessingTemplate: """Update an existing postprocessing template. Raises: - ValueError: If template not found + ValueError: If template not found or invalid filter_id """ if template_id not in self._templates: raise ValueError(f"Postprocessing template not found: {template_id}") @@ -182,14 +189,12 @@ class PostprocessingTemplateStore: if name is not None: template.name = name - if gamma is not None: - template.gamma = gamma - if saturation is not None: - template.saturation = saturation - if brightness is not None: - template.brightness = brightness - if smoothing is not None: - template.smoothing = smoothing + if filters is not None: + # 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.filters = filters if description is not None: template.description = description