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