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:
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user