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