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