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,
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")

View File

@@ -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")