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,
|
CaptureImage,
|
||||||
BorderExtraction,
|
BorderExtraction,
|
||||||
PerformanceMetrics,
|
PerformanceMetrics,
|
||||||
|
FilterInstanceSchema,
|
||||||
|
FilterOptionDefSchema,
|
||||||
|
FilterTypeResponse,
|
||||||
|
FilterTypeListResponse,
|
||||||
PostprocessingTemplateCreate,
|
PostprocessingTemplateCreate,
|
||||||
PostprocessingTemplateUpdate,
|
PostprocessingTemplateUpdate,
|
||||||
PostprocessingTemplateResponse,
|
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.postprocessing_template_store import PostprocessingTemplateStore
|
||||||
from wled_controller.storage.picture_stream_store import PictureStreamStore
|
from wled_controller.storage.picture_stream_store import PictureStreamStore
|
||||||
from wled_controller.core.capture_engines import EngineRegistry
|
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.utils import get_logger
|
||||||
from wled_controller.core.screen_capture import get_available_displays
|
from wled_controller.core.screen_capture import get_available_displays
|
||||||
|
|
||||||
@@ -587,6 +592,7 @@ async def get_settings(
|
|||||||
border_width=device.settings.border_width,
|
border_width=device.settings.border_width,
|
||||||
interpolation_mode=device.settings.interpolation_mode,
|
interpolation_mode=device.settings.interpolation_mode,
|
||||||
brightness=device.settings.brightness,
|
brightness=device.settings.brightness,
|
||||||
|
smoothing=device.settings.smoothing,
|
||||||
state_check_interval=device.settings.state_check_interval,
|
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,
|
brightness=settings.color_correction.brightness if settings.color_correction else existing.brightness,
|
||||||
gamma=settings.color_correction.gamma if settings.color_correction else existing.gamma,
|
gamma=settings.color_correction.gamma if settings.color_correction else existing.gamma,
|
||||||
saturation=settings.color_correction.saturation if settings.color_correction else existing.saturation,
|
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,
|
state_check_interval=settings.state_check_interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1106,6 +1112,34 @@ async def test_template(
|
|||||||
logger.error(f"Error cleaning up test engine: {e}")
|
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 =====
|
# ===== POSTPROCESSING TEMPLATE ENDPOINTS =====
|
||||||
|
|
||||||
def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
|
def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
|
||||||
@@ -1113,10 +1147,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
|
|||||||
return PostprocessingTemplateResponse(
|
return PostprocessingTemplateResponse(
|
||||||
id=t.id,
|
id=t.id,
|
||||||
name=t.name,
|
name=t.name,
|
||||||
gamma=t.gamma,
|
filters=[FilterInstanceSchema(filter_id=f.filter_id, options=f.options) for f in t.filters],
|
||||||
saturation=t.saturation,
|
|
||||||
brightness=t.brightness,
|
|
||||||
smoothing=t.smoothing,
|
|
||||||
created_at=t.created_at,
|
created_at=t.created_at,
|
||||||
updated_at=t.updated_at,
|
updated_at=t.updated_at,
|
||||||
description=t.description,
|
description=t.description,
|
||||||
@@ -1146,12 +1177,10 @@ async def create_pp_template(
|
|||||||
):
|
):
|
||||||
"""Create a new postprocessing template."""
|
"""Create a new postprocessing template."""
|
||||||
try:
|
try:
|
||||||
|
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters]
|
||||||
template = store.create_template(
|
template = store.create_template(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
gamma=data.gamma,
|
filters=filters,
|
||||||
saturation=data.saturation,
|
|
||||||
brightness=data.brightness,
|
|
||||||
smoothing=data.smoothing,
|
|
||||||
description=data.description,
|
description=data.description,
|
||||||
)
|
)
|
||||||
return _pp_template_to_response(template)
|
return _pp_template_to_response(template)
|
||||||
@@ -1185,13 +1214,11 @@ async def update_pp_template(
|
|||||||
):
|
):
|
||||||
"""Update a postprocessing template."""
|
"""Update a postprocessing template."""
|
||||||
try:
|
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 = store.update_template(
|
||||||
template_id=template_id,
|
template_id=template_id,
|
||||||
name=data.name,
|
name=data.name,
|
||||||
gamma=data.gamma,
|
filters=filters,
|
||||||
saturation=data.saturation,
|
|
||||||
brightness=data.brightness,
|
|
||||||
smoothing=data.smoothing,
|
|
||||||
description=data.description,
|
description=data.description,
|
||||||
)
|
)
|
||||||
return _pp_template_to_response(template)
|
return _pp_template_to_response(template)
|
||||||
@@ -1477,25 +1504,24 @@ async def test_picture_stream(
|
|||||||
thumbnail = pil_image.copy()
|
thumbnail = pil_image.copy()
|
||||||
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
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"]
|
pp_template_ids = chain["postprocessing_template_ids"]
|
||||||
if pp_template_ids:
|
if pp_template_ids:
|
||||||
try:
|
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):
|
def apply_filters(img):
|
||||||
arr = np.array(img, dtype=np.float32) / 255.0
|
arr = np.array(img)
|
||||||
if pp.brightness != 1.0:
|
for fi in pp_template.filters:
|
||||||
arr *= pp.brightness
|
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||||
if pp.saturation != 1.0:
|
result = f.process_image(arr, pool)
|
||||||
lum = np.dot(arr[..., :3], [0.299, 0.587, 0.114])[..., np.newaxis]
|
if result is not None:
|
||||||
arr[..., :3] = lum + (arr[..., :3] - lum) * pp.saturation
|
arr = result
|
||||||
if pp.gamma != 1.0:
|
return Image.fromarray(arr)
|
||||||
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))
|
|
||||||
|
|
||||||
thumbnail = apply_pp(thumbnail)
|
thumbnail = apply_filters(thumbnail)
|
||||||
pil_image = apply_pp(pil_image)
|
pil_image = apply_filters(pil_image)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
|
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."""
|
"""Pydantic schemas for API request and response models."""
|
||||||
|
|
||||||
from datetime import datetime
|
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
|
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)
|
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)")
|
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)
|
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(
|
state_check_interval: int = Field(
|
||||||
default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600,
|
default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600,
|
||||||
description="Seconds between WLED health checks"
|
description="Seconds between WLED health checks"
|
||||||
@@ -326,16 +327,49 @@ class TemplateTestResponse(BaseModel):
|
|||||||
performance: PerformanceMetrics = Field(description="Performance metrics")
|
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
|
# Postprocessing Template Schemas
|
||||||
|
|
||||||
class PostprocessingTemplateCreate(BaseModel):
|
class PostprocessingTemplateCreate(BaseModel):
|
||||||
"""Request to create a postprocessing template."""
|
"""Request to create a postprocessing template."""
|
||||||
|
|
||||||
name: str = Field(description="Template name", min_length=1, max_length=100)
|
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)
|
filters: List[FilterInstanceSchema] = Field(default_factory=list, description="Ordered list of filter instances")
|
||||||
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)
|
|
||||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||||
|
|
||||||
|
|
||||||
@@ -343,10 +377,7 @@ class PostprocessingTemplateUpdate(BaseModel):
|
|||||||
"""Request to update a postprocessing template."""
|
"""Request to update a postprocessing template."""
|
||||||
|
|
||||||
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
|
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)
|
filters: Optional[List[FilterInstanceSchema]] = Field(None, description="Ordered list of filter instances")
|
||||||
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)
|
|
||||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||||
|
|
||||||
|
|
||||||
@@ -355,10 +386,7 @@ class PostprocessingTemplateResponse(BaseModel):
|
|||||||
|
|
||||||
id: str = Field(description="Template ID")
|
id: str = Field(description="Template ID")
|
||||||
name: str = Field(description="Template name")
|
name: str = Field(description="Template name")
|
||||||
gamma: float = Field(description="Gamma correction")
|
filters: List[FilterInstanceSchema] = Field(description="Ordered list of filter instances")
|
||||||
saturation: float = Field(description="Saturation multiplier")
|
|
||||||
brightness: float = Field(description="Brightness multiplier")
|
|
||||||
smoothing: float = Field(description="Temporal smoothing factor")
|
|
||||||
created_at: datetime = Field(description="Creation timestamp")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
description: Optional[str] = Field(None, description="Template description")
|
description: Optional[str] = Field(None, description="Template description")
|
||||||
|
|||||||
21
server/src/wled_controller/core/filters/__init__.py
Normal file
21
server/src/wled_controller/core/filters/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Postprocessing filter system.
|
||||||
|
|
||||||
|
Provides a pluggable filter architecture for image postprocessing.
|
||||||
|
Import this package to ensure all built-in filters are registered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||||
|
from wled_controller.core.filters.filter_instance import FilterInstance
|
||||||
|
from wled_controller.core.filters.image_pool import ImagePool
|
||||||
|
from wled_controller.core.filters.registry import FilterRegistry
|
||||||
|
|
||||||
|
# Import builtin filters to trigger auto-registration
|
||||||
|
import wled_controller.core.filters.builtin # noqa: F401
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FilterOptionDef",
|
||||||
|
"FilterInstance",
|
||||||
|
"FilterRegistry",
|
||||||
|
"ImagePool",
|
||||||
|
"PostprocessingFilter",
|
||||||
|
]
|
||||||
91
server/src/wled_controller/core/filters/base.py
Normal file
91
server/src/wled_controller/core/filters/base.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""Base classes for the postprocessing filter system."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FilterOptionDef:
|
||||||
|
"""Describes a single configurable option for a filter."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
option_type: str # "float" | "int"
|
||||||
|
default: Any
|
||||||
|
min_value: Any
|
||||||
|
max_value: Any
|
||||||
|
step: Any
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"key": self.key,
|
||||||
|
"label": self.label,
|
||||||
|
"type": self.option_type,
|
||||||
|
"default": self.default,
|
||||||
|
"min_value": self.min_value,
|
||||||
|
"max_value": self.max_value,
|
||||||
|
"step": self.step,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PostprocessingFilter(ABC):
|
||||||
|
"""Base class for all postprocessing filters.
|
||||||
|
|
||||||
|
Each filter operates on a full image (np.ndarray H×W×3 uint8).
|
||||||
|
Filters that preserve dimensions modify in-place and return None.
|
||||||
|
Filters that change dimensions return a new array from the image pool.
|
||||||
|
"""
|
||||||
|
|
||||||
|
filter_id: str = ""
|
||||||
|
filter_name: str = ""
|
||||||
|
|
||||||
|
def __init__(self, options: Dict[str, Any]):
|
||||||
|
"""Initialize filter with validated options."""
|
||||||
|
self.options = self.validate_options(options)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||||
|
"""Return the list of configurable options for this filter type."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def process_image(self, image: np.ndarray, image_pool: "ImagePool") -> Optional[np.ndarray]:
|
||||||
|
"""Process image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image: Input image as np.ndarray (H, W, 3) dtype=uint8.
|
||||||
|
image_pool: Shared pool for acquiring new arrays when dimensions change.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None if modified in-place (same dimensions).
|
||||||
|
New np.ndarray from image_pool if dimensions changed.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate_options(cls, options: dict) -> dict:
|
||||||
|
"""Validate and clamp options against the schema. Returns cleaned dict."""
|
||||||
|
schema = cls.get_options_schema()
|
||||||
|
cleaned = {}
|
||||||
|
for opt_def in schema:
|
||||||
|
raw = options.get(opt_def.key, opt_def.default)
|
||||||
|
if opt_def.option_type == "float":
|
||||||
|
val = float(raw)
|
||||||
|
elif opt_def.option_type == "int":
|
||||||
|
val = int(raw)
|
||||||
|
else:
|
||||||
|
val = raw
|
||||||
|
# Clamp to range
|
||||||
|
if opt_def.min_value is not None and val < opt_def.min_value:
|
||||||
|
val = opt_def.min_value
|
||||||
|
if opt_def.max_value is not None and val > opt_def.max_value:
|
||||||
|
val = opt_def.max_value
|
||||||
|
cleaned[opt_def.key] = val
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"{self.__class__.__name__}({self.options})"
|
||||||
282
server/src/wled_controller/core/filters/builtin.py
Normal file
282
server/src/wled_controller/core/filters/builtin.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
"""Built-in postprocessing filters."""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||||
|
from wled_controller.core.filters.image_pool import ImagePool
|
||||||
|
from wled_controller.core.filters.registry import FilterRegistry
|
||||||
|
|
||||||
|
|
||||||
|
@FilterRegistry.register
|
||||||
|
class BrightnessFilter(PostprocessingFilter):
|
||||||
|
"""Adjusts image brightness by multiplying pixel values."""
|
||||||
|
|
||||||
|
filter_id = "brightness"
|
||||||
|
filter_name = "Brightness"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||||
|
return [
|
||||||
|
FilterOptionDef(
|
||||||
|
key="value",
|
||||||
|
label="Brightness",
|
||||||
|
option_type="float",
|
||||||
|
default=1.0,
|
||||||
|
min_value=0.0,
|
||||||
|
max_value=2.0,
|
||||||
|
step=0.05,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||||
|
value = self.options["value"]
|
||||||
|
if value == 1.0:
|
||||||
|
return None
|
||||||
|
# In-place float operation
|
||||||
|
arr = image.astype(np.float32)
|
||||||
|
arr *= value
|
||||||
|
np.clip(arr, 0, 255, out=arr)
|
||||||
|
np.copyto(image, arr.astype(np.uint8))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@FilterRegistry.register
|
||||||
|
class SaturationFilter(PostprocessingFilter):
|
||||||
|
"""Adjusts color saturation via luminance blending."""
|
||||||
|
|
||||||
|
filter_id = "saturation"
|
||||||
|
filter_name = "Saturation"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||||
|
return [
|
||||||
|
FilterOptionDef(
|
||||||
|
key="value",
|
||||||
|
label="Saturation",
|
||||||
|
option_type="float",
|
||||||
|
default=1.0,
|
||||||
|
min_value=0.0,
|
||||||
|
max_value=2.0,
|
||||||
|
step=0.1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||||
|
value = self.options["value"]
|
||||||
|
if value == 1.0:
|
||||||
|
return None
|
||||||
|
arr = image.astype(np.float32) / 255.0
|
||||||
|
lum = np.dot(arr[..., :3], [0.299, 0.587, 0.114])[..., np.newaxis]
|
||||||
|
arr[..., :3] = lum + (arr[..., :3] - lum) * value
|
||||||
|
np.clip(arr * 255.0, 0, 255, out=arr)
|
||||||
|
np.copyto(image, arr.astype(np.uint8))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@FilterRegistry.register
|
||||||
|
class GammaFilter(PostprocessingFilter):
|
||||||
|
"""Applies gamma correction."""
|
||||||
|
|
||||||
|
filter_id = "gamma"
|
||||||
|
filter_name = "Gamma"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||||
|
return [
|
||||||
|
FilterOptionDef(
|
||||||
|
key="value",
|
||||||
|
label="Gamma",
|
||||||
|
option_type="float",
|
||||||
|
default=2.2,
|
||||||
|
min_value=0.1,
|
||||||
|
max_value=5.0,
|
||||||
|
step=0.1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||||
|
value = self.options["value"]
|
||||||
|
if value == 1.0:
|
||||||
|
return None
|
||||||
|
arr = image.astype(np.float32) / 255.0
|
||||||
|
np.power(arr, 1.0 / value, out=arr)
|
||||||
|
np.clip(arr * 255.0, 0, 255, out=arr)
|
||||||
|
np.copyto(image, arr.astype(np.uint8))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@FilterRegistry.register
|
||||||
|
class DownscalerFilter(PostprocessingFilter):
|
||||||
|
"""Downscales image by a factor. Returns a new image from the pool."""
|
||||||
|
|
||||||
|
filter_id = "downscaler"
|
||||||
|
filter_name = "Downscaler"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||||
|
return [
|
||||||
|
FilterOptionDef(
|
||||||
|
key="factor",
|
||||||
|
label="Scale Factor",
|
||||||
|
option_type="float",
|
||||||
|
default=0.5,
|
||||||
|
min_value=0.1,
|
||||||
|
max_value=1.0,
|
||||||
|
step=0.05,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||||
|
factor = self.options["factor"]
|
||||||
|
if factor >= 1.0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
new_h = max(1, int(h * factor))
|
||||||
|
new_w = max(1, int(w * factor))
|
||||||
|
|
||||||
|
if new_h == h and new_w == w:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Use PIL for high-quality downscaling
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
pil_img = Image.fromarray(image)
|
||||||
|
pil_img = pil_img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
result = image_pool.acquire(new_h, new_w, image.shape[2] if image.ndim == 3 else 3)
|
||||||
|
np.copyto(result, np.array(pil_img))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@FilterRegistry.register
|
||||||
|
class PixelateFilter(PostprocessingFilter):
|
||||||
|
"""Pixelates the image by averaging blocks of pixels."""
|
||||||
|
|
||||||
|
filter_id = "pixelate"
|
||||||
|
filter_name = "Pixelate"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||||
|
return [
|
||||||
|
FilterOptionDef(
|
||||||
|
key="block_size",
|
||||||
|
label="Block Size",
|
||||||
|
option_type="int",
|
||||||
|
default=8,
|
||||||
|
min_value=2,
|
||||||
|
max_value=64,
|
||||||
|
step=1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||||
|
block_size = self.options["block_size"]
|
||||||
|
if block_size <= 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
|
||||||
|
# Process each block: compute mean and fill
|
||||||
|
for y in range(0, h, block_size):
|
||||||
|
for x in range(0, w, block_size):
|
||||||
|
y_end = min(y + block_size, h)
|
||||||
|
x_end = min(x + block_size, w)
|
||||||
|
block = image[y:y_end, x:x_end]
|
||||||
|
mean_color = block.mean(axis=(0, 1)).astype(np.uint8)
|
||||||
|
image[y:y_end, x:x_end] = mean_color
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@FilterRegistry.register
|
||||||
|
class AutoCropFilter(PostprocessingFilter):
|
||||||
|
"""Detects and crops black bars (letterboxing/pillarboxing) from the image."""
|
||||||
|
|
||||||
|
filter_id = "auto_crop"
|
||||||
|
filter_name = "Auto Crop"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||||
|
return [
|
||||||
|
FilterOptionDef(
|
||||||
|
key="threshold",
|
||||||
|
label="Black Threshold",
|
||||||
|
option_type="int",
|
||||||
|
default=15,
|
||||||
|
min_value=0,
|
||||||
|
max_value=50,
|
||||||
|
step=1,
|
||||||
|
),
|
||||||
|
FilterOptionDef(
|
||||||
|
key="min_bar_size",
|
||||||
|
label="Min Bar Size (px)",
|
||||||
|
option_type="int",
|
||||||
|
default=20,
|
||||||
|
min_value=0,
|
||||||
|
max_value=200,
|
||||||
|
step=5,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||||
|
threshold = self.options.get("threshold", 15)
|
||||||
|
min_bar_size = self.options.get("min_bar_size", 20)
|
||||||
|
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
min_h = max(1, h // 10)
|
||||||
|
min_w = max(1, w // 10)
|
||||||
|
|
||||||
|
# Compute max channel value per row and per column (vectorized)
|
||||||
|
row_max = image.max(axis=(1, 2)) # shape (h,)
|
||||||
|
col_max = image.max(axis=(0, 2)) # shape (w,)
|
||||||
|
|
||||||
|
# Scan from top
|
||||||
|
top = 0
|
||||||
|
while top < h and row_max[top] <= threshold:
|
||||||
|
top += 1
|
||||||
|
|
||||||
|
# Scan from bottom
|
||||||
|
bottom = h
|
||||||
|
while bottom > top and row_max[bottom - 1] <= threshold:
|
||||||
|
bottom -= 1
|
||||||
|
|
||||||
|
# Scan from left
|
||||||
|
left = 0
|
||||||
|
while left < w and col_max[left] <= threshold:
|
||||||
|
left += 1
|
||||||
|
|
||||||
|
# Scan from right
|
||||||
|
right = w
|
||||||
|
while right > left and col_max[right - 1] <= threshold:
|
||||||
|
right -= 1
|
||||||
|
|
||||||
|
# Apply min_bar_size: only crop if the detected bar is large enough
|
||||||
|
if top < min_bar_size:
|
||||||
|
top = 0
|
||||||
|
if (h - bottom) < min_bar_size:
|
||||||
|
bottom = h
|
||||||
|
if left < min_bar_size:
|
||||||
|
left = 0
|
||||||
|
if (w - right) < min_bar_size:
|
||||||
|
right = w
|
||||||
|
|
||||||
|
# Safety: don't crop if remaining content is too small
|
||||||
|
if (bottom - top) < min_h:
|
||||||
|
top, bottom = 0, h
|
||||||
|
if (right - left) < min_w:
|
||||||
|
left, right = 0, w
|
||||||
|
|
||||||
|
# No crop needed
|
||||||
|
if top == 0 and bottom == h and left == 0 and right == w:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cropped_h = bottom - top
|
||||||
|
cropped_w = right - left
|
||||||
|
channels = image.shape[2] if image.ndim == 3 else 3
|
||||||
|
|
||||||
|
result = image_pool.acquire(cropped_h, cropped_w, channels)
|
||||||
|
np.copyto(result, image[top:bottom, left:right])
|
||||||
|
return result
|
||||||
25
server/src/wled_controller/core/filters/filter_instance.py
Normal file
25
server/src/wled_controller/core/filters/filter_instance.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""FilterInstance data model for serializable filter configurations."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FilterInstance:
|
||||||
|
"""A configured instance of a filter within a postprocessing template."""
|
||||||
|
|
||||||
|
filter_id: str
|
||||||
|
options: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"filter_id": self.filter_id,
|
||||||
|
"options": dict(self.options),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "FilterInstance":
|
||||||
|
return cls(
|
||||||
|
filter_id=data["filter_id"],
|
||||||
|
options=data.get("options", {}),
|
||||||
|
)
|
||||||
41
server/src/wled_controller/core/filters/image_pool.py
Normal file
41
server/src/wled_controller/core/filters/image_pool.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""Reusable numpy array pool to minimize allocation churn during fast image processing."""
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ImagePool:
|
||||||
|
"""Pool of pre-allocated numpy arrays keyed by shape.
|
||||||
|
|
||||||
|
When a filter needs a new array (e.g. for downscaling), it acquires one
|
||||||
|
from the pool instead of allocating. After use, arrays are released back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, max_per_shape: int = 4):
|
||||||
|
self._pool: Dict[Tuple[int, ...], List[np.ndarray]] = defaultdict(list)
|
||||||
|
self._max_per_shape = max_per_shape
|
||||||
|
|
||||||
|
def acquire(self, height: int, width: int, channels: int = 3) -> np.ndarray:
|
||||||
|
"""Get a pre-allocated array or create a new one."""
|
||||||
|
shape = (height, width, channels)
|
||||||
|
bucket = self._pool[shape]
|
||||||
|
if bucket:
|
||||||
|
return bucket.pop()
|
||||||
|
return np.empty(shape, dtype=np.uint8)
|
||||||
|
|
||||||
|
def release(self, array: np.ndarray) -> None:
|
||||||
|
"""Return an array to the pool for reuse."""
|
||||||
|
shape = array.shape
|
||||||
|
bucket = self._pool[shape]
|
||||||
|
if len(bucket) < self._max_per_shape:
|
||||||
|
bucket.append(array)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Release all pooled arrays."""
|
||||||
|
self._pool.clear()
|
||||||
53
server/src/wled_controller/core/filters/registry.py
Normal file
53
server/src/wled_controller/core/filters/registry.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Filter registry for discovering and instantiating postprocessing filters."""
|
||||||
|
|
||||||
|
from typing import Dict, List, Type
|
||||||
|
|
||||||
|
from wled_controller.core.filters.base import PostprocessingFilter
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FilterRegistry:
|
||||||
|
"""Singleton registry of all available postprocessing filter types."""
|
||||||
|
|
||||||
|
_filters: Dict[str, Type[PostprocessingFilter]] = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register(cls, filter_cls: Type[PostprocessingFilter]) -> Type[PostprocessingFilter]:
|
||||||
|
"""Register a filter class. Can be used as a decorator."""
|
||||||
|
filter_id = filter_cls.filter_id
|
||||||
|
if not filter_id:
|
||||||
|
raise ValueError(f"Filter class {filter_cls.__name__} must define filter_id")
|
||||||
|
if filter_id in cls._filters:
|
||||||
|
logger.warning(f"Overwriting filter registration for '{filter_id}'")
|
||||||
|
cls._filters[filter_id] = filter_cls
|
||||||
|
logger.debug(f"Registered filter: {filter_id} ({filter_cls.__name__})")
|
||||||
|
return filter_cls
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, filter_id: str) -> Type[PostprocessingFilter]:
|
||||||
|
"""Get a filter class by ID.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If filter_id is not registered.
|
||||||
|
"""
|
||||||
|
if filter_id not in cls._filters:
|
||||||
|
raise ValueError(f"Unknown filter type: '{filter_id}'")
|
||||||
|
return cls._filters[filter_id]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_all(cls) -> Dict[str, Type[PostprocessingFilter]]:
|
||||||
|
"""Get all registered filter types."""
|
||||||
|
return dict(cls._filters)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_instance(cls, filter_id: str, options: dict) -> PostprocessingFilter:
|
||||||
|
"""Create a filter instance from a filter_id and options dict."""
|
||||||
|
filter_cls = cls.get(filter_id)
|
||||||
|
return filter_cls(options)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_registered(cls, filter_id: str) -> bool:
|
||||||
|
"""Check if a filter ID is registered."""
|
||||||
|
return filter_id in cls._filters
|
||||||
@@ -14,7 +14,8 @@ from wled_controller.core.calibration import (
|
|||||||
create_default_calibration,
|
create_default_calibration,
|
||||||
)
|
)
|
||||||
from wled_controller.core.capture_engines import CaptureEngine, EngineRegistry
|
from wled_controller.core.capture_engines import CaptureEngine, EngineRegistry
|
||||||
from wled_controller.core.pixel_processor import apply_color_correction, smooth_colors
|
from wled_controller.core.filters import FilterInstance, FilterRegistry, ImagePool, PostprocessingFilter
|
||||||
|
from wled_controller.core.pixel_processor import smooth_colors
|
||||||
from wled_controller.core.screen_capture import extract_border_pixels
|
from wled_controller.core.screen_capture import extract_border_pixels
|
||||||
from wled_controller.core.wled_client import WLEDClient
|
from wled_controller.core.wled_client import WLEDClient
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
@@ -106,10 +107,9 @@ class ProcessorState:
|
|||||||
resolved_target_fps: Optional[int] = None
|
resolved_target_fps: Optional[int] = None
|
||||||
resolved_engine_type: Optional[str] = None
|
resolved_engine_type: Optional[str] = None
|
||||||
resolved_engine_config: Optional[dict] = None
|
resolved_engine_config: Optional[dict] = None
|
||||||
resolved_gamma: Optional[float] = None
|
resolved_filters: Optional[List[FilterInstance]] = None
|
||||||
resolved_saturation: Optional[float] = None
|
image_pool: Optional[ImagePool] = None
|
||||||
resolved_brightness: Optional[float] = None
|
filter_instances: Optional[List[PostprocessingFilter]] = None
|
||||||
resolved_smoothing: Optional[float] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProcessorManager:
|
class ProcessorManager:
|
||||||
@@ -298,10 +298,7 @@ class ProcessorManager:
|
|||||||
if pp_template_ids and self._pp_template_store:
|
if pp_template_ids and self._pp_template_store:
|
||||||
try:
|
try:
|
||||||
pp = self._pp_template_store.get_template(pp_template_ids[0])
|
pp = self._pp_template_store.get_template(pp_template_ids[0])
|
||||||
state.resolved_gamma = pp.gamma
|
state.resolved_filters = pp.filters
|
||||||
state.resolved_saturation = pp.saturation
|
|
||||||
state.resolved_brightness = pp.brightness
|
|
||||||
state.resolved_smoothing = pp.smoothing
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"PP template {pp_template_ids[0]} not found, using defaults")
|
logger.warning(f"PP template {pp_template_ids[0]} not found, using defaults")
|
||||||
|
|
||||||
@@ -314,13 +311,17 @@ class ProcessorManager:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Failed to resolve stream {state.picture_stream_id}: {e}, falling back to legacy settings")
|
logger.warning(f"Failed to resolve stream {state.picture_stream_id}: {e}, falling back to legacy settings")
|
||||||
|
|
||||||
# Fallback: use legacy device settings
|
# Fallback: use legacy device settings (construct filters from flat fields)
|
||||||
state.resolved_display_index = state.settings.display_index
|
state.resolved_display_index = state.settings.display_index
|
||||||
state.resolved_target_fps = state.settings.fps
|
state.resolved_target_fps = state.settings.fps
|
||||||
state.resolved_gamma = state.settings.gamma
|
legacy_filters = []
|
||||||
state.resolved_saturation = state.settings.saturation
|
if state.settings.brightness != 1.0:
|
||||||
state.resolved_brightness = state.settings.brightness
|
legacy_filters.append(FilterInstance("brightness", {"value": state.settings.brightness}))
|
||||||
state.resolved_smoothing = state.settings.smoothing
|
if state.settings.saturation != 1.0:
|
||||||
|
legacy_filters.append(FilterInstance("saturation", {"value": state.settings.saturation}))
|
||||||
|
if state.settings.gamma != 1.0:
|
||||||
|
legacy_filters.append(FilterInstance("gamma", {"value": state.settings.gamma}))
|
||||||
|
state.resolved_filters = legacy_filters
|
||||||
|
|
||||||
# Resolve engine from legacy capture_template_id
|
# Resolve engine from legacy capture_template_id
|
||||||
if state.capture_template_id and self._capture_template_store:
|
if state.capture_template_id and self._capture_template_store:
|
||||||
@@ -457,23 +458,40 @@ class ProcessorManager:
|
|||||||
# Use resolved values (populated by _resolve_stream_settings)
|
# Use resolved values (populated by _resolve_stream_settings)
|
||||||
display_index = state.resolved_display_index or settings.display_index
|
display_index = state.resolved_display_index or settings.display_index
|
||||||
target_fps = state.resolved_target_fps or settings.fps
|
target_fps = state.resolved_target_fps or settings.fps
|
||||||
gamma = state.resolved_gamma if state.resolved_gamma is not None else settings.gamma
|
smoothing = settings.smoothing
|
||||||
saturation = state.resolved_saturation if state.resolved_saturation is not None else settings.saturation
|
|
||||||
pp_brightness = state.resolved_brightness if state.resolved_brightness is not None else settings.brightness
|
|
||||||
smoothing = state.resolved_smoothing if state.resolved_smoothing is not None else settings.smoothing
|
|
||||||
|
|
||||||
# These always come from device settings (LED projection)
|
# These always come from device settings (LED projection)
|
||||||
border_width = settings.border_width
|
border_width = settings.border_width
|
||||||
wled_brightness = settings.brightness # WLED hardware brightness
|
wled_brightness = settings.brightness # WLED hardware brightness
|
||||||
|
|
||||||
|
# Instantiate filter objects once (not per-frame)
|
||||||
|
resolved_filters = state.resolved_filters or []
|
||||||
|
image_pool = ImagePool()
|
||||||
|
state.image_pool = image_pool
|
||||||
|
filter_objects = []
|
||||||
|
for fi in resolved_filters:
|
||||||
|
try:
|
||||||
|
filter_objects.append(FilterRegistry.create_instance(fi.filter_id, fi.options))
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}")
|
||||||
|
state.filter_instances = filter_objects
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Processing loop started for {device_id} "
|
f"Processing loop started for {device_id} "
|
||||||
f"(display={display_index}, fps={target_fps})"
|
f"(display={display_index}, fps={target_fps}, filters={len(filter_objects)})"
|
||||||
)
|
)
|
||||||
|
|
||||||
frame_time = 1.0 / target_fps
|
frame_time = 1.0 / target_fps
|
||||||
fps_samples = []
|
fps_samples = []
|
||||||
|
|
||||||
|
def _apply_filters(image):
|
||||||
|
"""Apply all postprocessing filters to the captured image."""
|
||||||
|
for f in filter_objects:
|
||||||
|
result = f.process_image(image, image_pool)
|
||||||
|
if result is not None:
|
||||||
|
image = result
|
||||||
|
return image
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while state.is_running:
|
while state.is_running:
|
||||||
loop_start = time.time()
|
loop_start = time.time()
|
||||||
@@ -490,21 +508,16 @@ class ProcessorManager:
|
|||||||
display_index
|
display_index
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Apply postprocessing filters to the full captured image
|
||||||
|
if filter_objects:
|
||||||
|
capture.image = await asyncio.to_thread(_apply_filters, capture.image)
|
||||||
|
|
||||||
# Extract border pixels
|
# Extract border pixels
|
||||||
border_pixels = await asyncio.to_thread(extract_border_pixels, capture, border_width)
|
border_pixels = await asyncio.to_thread(extract_border_pixels, capture, border_width)
|
||||||
|
|
||||||
# Map to LED colors
|
# Map to LED colors
|
||||||
led_colors = await asyncio.to_thread(state.pixel_mapper.map_border_to_leds, border_pixels)
|
led_colors = await asyncio.to_thread(state.pixel_mapper.map_border_to_leds, border_pixels)
|
||||||
|
|
||||||
# Apply color correction from postprocessing
|
|
||||||
led_colors = await asyncio.to_thread(
|
|
||||||
apply_color_correction,
|
|
||||||
led_colors,
|
|
||||||
gamma=gamma,
|
|
||||||
saturation=saturation,
|
|
||||||
brightness=pp_brightness,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply smoothing from postprocessing
|
# Apply smoothing from postprocessing
|
||||||
if state.previous_colors and smoothing > 0:
|
if state.previous_colors and smoothing > 0:
|
||||||
led_colors = await asyncio.to_thread(
|
led_colors = await asyncio.to_thread(
|
||||||
|
|||||||
@@ -2992,6 +2992,7 @@ async function deleteTemplate(templateId) {
|
|||||||
|
|
||||||
let _cachedStreams = [];
|
let _cachedStreams = [];
|
||||||
let _cachedPPTemplates = [];
|
let _cachedPPTemplates = [];
|
||||||
|
let _availableFilters = []; // Loaded from GET /filters
|
||||||
|
|
||||||
async function loadPictureStreams() {
|
async function loadPictureStreams() {
|
||||||
try {
|
try {
|
||||||
@@ -3017,7 +3018,7 @@ function renderPictureStreamsList(streams) {
|
|||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="stream-group">
|
<div class="stream-group">
|
||||||
<div class="stream-group-header">
|
<div class="stream-group-header">
|
||||||
<span class="stream-group-icon">📷</span>
|
<span class="stream-group-icon">🖥️</span>
|
||||||
<span class="stream-group-title">${t('streams.group.raw')}</span>
|
<span class="stream-group-title">${t('streams.group.raw')}</span>
|
||||||
<span class="stream-group-count">0</span>
|
<span class="stream-group-count">0</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -3045,7 +3046,7 @@ function renderPictureStreamsList(streams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderCard = (stream) => {
|
const renderCard = (stream) => {
|
||||||
const typeIcon = stream.stream_type === 'raw' ? '📷' : '🎨';
|
const typeIcon = stream.stream_type === 'raw' ? '🖥️' : '🎨';
|
||||||
const typeBadge = stream.stream_type === 'raw'
|
const typeBadge = stream.stream_type === 'raw'
|
||||||
? `<span class="badge badge-raw">${t('streams.type.raw')}</span>`
|
? `<span class="badge badge-raw">${t('streams.type.raw')}</span>`
|
||||||
: `<span class="badge badge-processed">${t('streams.type.processed')}</span>`;
|
: `<span class="badge badge-processed">${t('streams.type.processed')}</span>`;
|
||||||
@@ -3102,7 +3103,7 @@ function renderPictureStreamsList(streams) {
|
|||||||
// Screen Capture streams section
|
// Screen Capture streams section
|
||||||
html += `<div class="stream-group">
|
html += `<div class="stream-group">
|
||||||
<div class="stream-group-header">
|
<div class="stream-group-header">
|
||||||
<span class="stream-group-icon">📷</span>
|
<span class="stream-group-icon">🖥️</span>
|
||||||
<span class="stream-group-title">${t('streams.group.raw')}</span>
|
<span class="stream-group-title">${t('streams.group.raw')}</span>
|
||||||
<span class="stream-group-count">${rawStreams.length}</span>
|
<span class="stream-group-count">${rawStreams.length}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -3252,7 +3253,7 @@ async function populateStreamModalDropdowns() {
|
|||||||
if (s.id === editingId) return;
|
if (s.id === editingId) return;
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = s.id;
|
opt.value = s.id;
|
||||||
const typeLabel = s.stream_type === 'raw' ? '📷' : '🎨';
|
const typeLabel = s.stream_type === 'raw' ? '🖥️' : '🎨';
|
||||||
opt.textContent = `${typeLabel} ${s.name}`;
|
opt.textContent = `${typeLabel} ${s.name}`;
|
||||||
sourceSelect.appendChild(opt);
|
sourceSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
@@ -3423,8 +3424,24 @@ function displayStreamTestResults(result) {
|
|||||||
|
|
||||||
// ===== Processing Templates =====
|
// ===== Processing Templates =====
|
||||||
|
|
||||||
|
async function loadAvailableFilters() {
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/filters');
|
||||||
|
if (!response.ok) throw new Error(`Failed to load filters: ${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
_availableFilters = data.filters || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading available filters:', error);
|
||||||
|
_availableFilters = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPPTemplates() {
|
async function loadPPTemplates() {
|
||||||
try {
|
try {
|
||||||
|
// Ensure filters are loaded for rendering
|
||||||
|
if (_availableFilters.length === 0) {
|
||||||
|
await loadAvailableFilters();
|
||||||
|
}
|
||||||
const response = await fetchWithAuth('/postprocessing-templates');
|
const response = await fetchWithAuth('/postprocessing-templates');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load templates: ${response.status}`);
|
throw new Error(`Failed to load templates: ${response.status}`);
|
||||||
@@ -3440,6 +3457,17 @@ async function loadPPTemplates() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _getFilterName(filterId) {
|
||||||
|
const key = 'filters.' + filterId;
|
||||||
|
const translated = t(key);
|
||||||
|
// Fallback to filter_name from registry if no localization
|
||||||
|
if (translated === key) {
|
||||||
|
const def = _availableFilters.find(f => f.filter_id === filterId);
|
||||||
|
return def ? def.filter_name : filterId;
|
||||||
|
}
|
||||||
|
return translated;
|
||||||
|
}
|
||||||
|
|
||||||
function renderPPTemplatesList(templates) {
|
function renderPPTemplatesList(templates) {
|
||||||
const container = document.getElementById('pp-templates-list');
|
const container = document.getElementById('pp-templates-list');
|
||||||
|
|
||||||
@@ -3452,12 +3480,12 @@ function renderPPTemplatesList(templates) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderCard = (tmpl) => {
|
const renderCard = (tmpl) => {
|
||||||
const configEntries = {
|
// Build config entries from filter list
|
||||||
[t('postprocessing.gamma')]: tmpl.gamma,
|
const filterRows = (tmpl.filters || []).map(fi => {
|
||||||
[t('postprocessing.saturation')]: tmpl.saturation,
|
const filterName = _getFilterName(fi.filter_id);
|
||||||
[t('postprocessing.brightness')]: tmpl.brightness,
|
const optStr = Object.entries(fi.options || {}).map(([k, v]) => `${v}`).join(', ');
|
||||||
[t('postprocessing.smoothing')]: tmpl.smoothing,
|
return `<tr><td class="config-key">${escapeHtml(filterName)}</td><td class="config-value">${escapeHtml(optStr)}</td></tr>`;
|
||||||
};
|
}).join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="template-card" data-pp-template-id="${tmpl.id}">
|
<div class="template-card" data-pp-template-id="${tmpl.id}">
|
||||||
@@ -3471,12 +3499,7 @@ function renderPPTemplatesList(templates) {
|
|||||||
<details class="template-config-details">
|
<details class="template-config-details">
|
||||||
<summary>${t('postprocessing.config.show')}</summary>
|
<summary>${t('postprocessing.config.show')}</summary>
|
||||||
<table class="config-table">
|
<table class="config-table">
|
||||||
${Object.entries(configEntries).map(([key, val]) => `
|
${filterRows}
|
||||||
<tr>
|
|
||||||
<td class="config-key">${escapeHtml(key)}</td>
|
|
||||||
<td class="config-value">${escapeHtml(String(val))}</td>
|
|
||||||
</tr>
|
|
||||||
`).join('')}
|
|
||||||
</table>
|
</table>
|
||||||
</details>
|
</details>
|
||||||
<div class="template-card-actions">
|
<div class="template-card-actions">
|
||||||
@@ -3497,21 +3520,154 @@ function renderPPTemplatesList(templates) {
|
|||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Filter list management in PP template modal ---
|
||||||
|
|
||||||
|
let _modalFilters = []; // Current filter list being edited in modal
|
||||||
|
|
||||||
|
function _populateFilterSelect() {
|
||||||
|
const select = document.getElementById('pp-add-filter-select');
|
||||||
|
// Keep first option (placeholder)
|
||||||
|
select.innerHTML = `<option value="">${t('filters.select_type')}</option>`;
|
||||||
|
for (const f of _availableFilters) {
|
||||||
|
const name = _getFilterName(f.filter_id);
|
||||||
|
select.innerHTML += `<option value="${f.filter_id}">${name}</option>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModalFilterList() {
|
||||||
|
const container = document.getElementById('pp-filter-list');
|
||||||
|
if (_modalFilters.length === 0) {
|
||||||
|
container.innerHTML = `<div class="pp-filter-empty">${t('filters.empty')}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
_modalFilters.forEach((fi, index) => {
|
||||||
|
const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
|
||||||
|
const filterName = _getFilterName(fi.filter_id);
|
||||||
|
const isExpanded = fi._expanded === true;
|
||||||
|
|
||||||
|
// Build compact summary of current option values for collapsed state
|
||||||
|
let summary = '';
|
||||||
|
if (filterDef && !isExpanded) {
|
||||||
|
summary = filterDef.options_schema.map(opt => {
|
||||||
|
const val = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
|
||||||
|
return val;
|
||||||
|
}).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<div class="pp-filter-card${isExpanded ? ' expanded' : ''}" data-filter-index="${index}">
|
||||||
|
<div class="pp-filter-card-header" onclick="toggleFilterExpand(${index})">
|
||||||
|
<span class="pp-filter-card-chevron">${isExpanded ? '▼' : '▶'}</span>
|
||||||
|
<span class="pp-filter-card-name">${escapeHtml(filterName)}</span>
|
||||||
|
${summary ? `<span class="pp-filter-card-summary">${escapeHtml(summary)}</span>` : ''}
|
||||||
|
<div class="pp-filter-card-actions" onclick="event.stopPropagation()">
|
||||||
|
<button type="button" class="btn-filter-action" onclick="moveFilter(${index}, -1)" title="${t('filters.move_up')}" ${index === 0 ? 'disabled' : ''}>▲</button>
|
||||||
|
<button type="button" class="btn-filter-action" onclick="moveFilter(${index}, 1)" title="${t('filters.move_down')}" ${index === _modalFilters.length - 1 ? 'disabled' : ''}>▼</button>
|
||||||
|
<button type="button" class="btn-filter-action btn-filter-remove" onclick="removeFilter(${index})" title="${t('filters.remove')}">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pp-filter-card-options"${isExpanded ? '' : ' style="display:none"'}>`;
|
||||||
|
|
||||||
|
if (filterDef) {
|
||||||
|
for (const opt of filterDef.options_schema) {
|
||||||
|
const currentVal = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
|
||||||
|
const inputId = `filter-${index}-${opt.key}`;
|
||||||
|
html += `<div class="pp-filter-option">
|
||||||
|
<label for="${inputId}">
|
||||||
|
<span>${escapeHtml(opt.label)}:</span>
|
||||||
|
<span id="${inputId}-display">${currentVal}</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" id="${inputId}"
|
||||||
|
min="${opt.min_value}" max="${opt.max_value}" step="${opt.step}" value="${currentVal}"
|
||||||
|
oninput="updateFilterOption(${index}, '${opt.key}', this.value); document.getElementById('${inputId}-display').textContent = this.value;">
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</div></div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFilterFromSelect() {
|
||||||
|
const select = document.getElementById('pp-add-filter-select');
|
||||||
|
const filterId = select.value;
|
||||||
|
if (!filterId) return;
|
||||||
|
|
||||||
|
const filterDef = _availableFilters.find(f => f.filter_id === filterId);
|
||||||
|
if (!filterDef) return;
|
||||||
|
|
||||||
|
// Build default options
|
||||||
|
const options = {};
|
||||||
|
for (const opt of filterDef.options_schema) {
|
||||||
|
options[opt.key] = opt.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
_modalFilters.push({ filter_id: filterId, options, _expanded: true });
|
||||||
|
select.value = '';
|
||||||
|
renderModalFilterList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFilterExpand(index) {
|
||||||
|
if (_modalFilters[index]) {
|
||||||
|
_modalFilters[index]._expanded = !_modalFilters[index]._expanded;
|
||||||
|
renderModalFilterList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFilter(index) {
|
||||||
|
_modalFilters.splice(index, 1);
|
||||||
|
renderModalFilterList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveFilter(index, direction) {
|
||||||
|
const newIndex = index + direction;
|
||||||
|
if (newIndex < 0 || newIndex >= _modalFilters.length) return;
|
||||||
|
const tmp = _modalFilters[index];
|
||||||
|
_modalFilters[index] = _modalFilters[newIndex];
|
||||||
|
_modalFilters[newIndex] = tmp;
|
||||||
|
renderModalFilterList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFilterOption(filterIndex, optionKey, value) {
|
||||||
|
if (_modalFilters[filterIndex]) {
|
||||||
|
// Determine type from schema
|
||||||
|
const fi = _modalFilters[filterIndex];
|
||||||
|
const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
|
||||||
|
if (filterDef) {
|
||||||
|
const optDef = filterDef.options_schema.find(o => o.key === optionKey);
|
||||||
|
if (optDef && optDef.type === 'int') {
|
||||||
|
fi.options[optionKey] = parseInt(value);
|
||||||
|
} else {
|
||||||
|
fi.options[optionKey] = parseFloat(value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fi.options[optionKey] = parseFloat(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFilters() {
|
||||||
|
return _modalFilters.map(fi => ({
|
||||||
|
filter_id: fi.filter_id,
|
||||||
|
options: { ...fi.options },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function showAddPPTemplateModal() {
|
async function showAddPPTemplateModal() {
|
||||||
|
if (_availableFilters.length === 0) await loadAvailableFilters();
|
||||||
|
|
||||||
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add');
|
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add');
|
||||||
document.getElementById('pp-template-form').reset();
|
document.getElementById('pp-template-form').reset();
|
||||||
document.getElementById('pp-template-id').value = '';
|
document.getElementById('pp-template-id').value = '';
|
||||||
document.getElementById('pp-template-error').style.display = 'none';
|
document.getElementById('pp-template-error').style.display = 'none';
|
||||||
|
|
||||||
// Reset slider displays to defaults
|
_modalFilters = [];
|
||||||
document.getElementById('pp-template-gamma').value = '2.2';
|
|
||||||
document.getElementById('pp-template-gamma-value').textContent = '2.2';
|
_populateFilterSelect();
|
||||||
document.getElementById('pp-template-saturation').value = '1.0';
|
renderModalFilterList();
|
||||||
document.getElementById('pp-template-saturation-value').textContent = '1.0';
|
|
||||||
document.getElementById('pp-template-brightness').value = '1.0';
|
|
||||||
document.getElementById('pp-template-brightness-value').textContent = '1.0';
|
|
||||||
document.getElementById('pp-template-smoothing').value = '0.3';
|
|
||||||
document.getElementById('pp-template-smoothing-value').textContent = '0.3';
|
|
||||||
|
|
||||||
const modal = document.getElementById('pp-template-modal');
|
const modal = document.getElementById('pp-template-modal');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
@@ -3521,6 +3677,8 @@ async function showAddPPTemplateModal() {
|
|||||||
|
|
||||||
async function editPPTemplate(templateId) {
|
async function editPPTemplate(templateId) {
|
||||||
try {
|
try {
|
||||||
|
if (_availableFilters.length === 0) await loadAvailableFilters();
|
||||||
|
|
||||||
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
|
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
|
||||||
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
||||||
const tmpl = await response.json();
|
const tmpl = await response.json();
|
||||||
@@ -3531,15 +3689,14 @@ async function editPPTemplate(templateId) {
|
|||||||
document.getElementById('pp-template-description').value = tmpl.description || '';
|
document.getElementById('pp-template-description').value = tmpl.description || '';
|
||||||
document.getElementById('pp-template-error').style.display = 'none';
|
document.getElementById('pp-template-error').style.display = 'none';
|
||||||
|
|
||||||
// Set sliders
|
// Load filters from template
|
||||||
document.getElementById('pp-template-gamma').value = tmpl.gamma;
|
_modalFilters = (tmpl.filters || []).map(fi => ({
|
||||||
document.getElementById('pp-template-gamma-value').textContent = tmpl.gamma;
|
filter_id: fi.filter_id,
|
||||||
document.getElementById('pp-template-saturation').value = tmpl.saturation;
|
options: { ...fi.options },
|
||||||
document.getElementById('pp-template-saturation-value').textContent = tmpl.saturation;
|
}));
|
||||||
document.getElementById('pp-template-brightness').value = tmpl.brightness;
|
|
||||||
document.getElementById('pp-template-brightness-value').textContent = tmpl.brightness;
|
_populateFilterSelect();
|
||||||
document.getElementById('pp-template-smoothing').value = tmpl.smoothing;
|
renderModalFilterList();
|
||||||
document.getElementById('pp-template-smoothing-value').textContent = tmpl.smoothing;
|
|
||||||
|
|
||||||
const modal = document.getElementById('pp-template-modal');
|
const modal = document.getElementById('pp-template-modal');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
@@ -3564,10 +3721,7 @@ async function savePPTemplate() {
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name,
|
name,
|
||||||
gamma: parseFloat(document.getElementById('pp-template-gamma').value),
|
filters: collectFilters(),
|
||||||
saturation: parseFloat(document.getElementById('pp-template-saturation').value),
|
|
||||||
brightness: parseFloat(document.getElementById('pp-template-brightness').value),
|
|
||||||
smoothing: parseFloat(document.getElementById('pp-template-smoothing').value),
|
|
||||||
description: description || null,
|
description: description || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3624,6 +3778,7 @@ async function deletePPTemplate(templateId) {
|
|||||||
|
|
||||||
function closePPTemplateModal() {
|
function closePPTemplateModal() {
|
||||||
document.getElementById('pp-template-modal').style.display = 'none';
|
document.getElementById('pp-template-modal').style.display = 'none';
|
||||||
|
_modalFilters = [];
|
||||||
unlockBody();
|
unlockBody();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3661,7 +3816,7 @@ async function showStreamSelector(deviceId) {
|
|||||||
(data.streams || []).forEach(s => {
|
(data.streams || []).forEach(s => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = s.id;
|
opt.value = s.id;
|
||||||
const typeIcon = s.stream_type === 'raw' ? '📷' : '🎨';
|
const typeIcon = s.stream_type === 'raw' ? '🖥️' : '🎨';
|
||||||
opt.textContent = `${typeIcon} ${s.name}`;
|
opt.textContent = `${typeIcon} ${s.name}`;
|
||||||
streamSelect.appendChild(opt);
|
streamSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
@@ -3672,13 +3827,17 @@ async function showStreamSelector(deviceId) {
|
|||||||
|
|
||||||
// Populate LED projection fields
|
// Populate LED projection fields
|
||||||
const borderWidth = settings.border_width ?? device.settings?.border_width ?? 10;
|
const borderWidth = settings.border_width ?? device.settings?.border_width ?? 10;
|
||||||
|
const smoothing = settings.smoothing ?? device.settings?.smoothing ?? 0.3;
|
||||||
document.getElementById('stream-selector-border-width').value = borderWidth;
|
document.getElementById('stream-selector-border-width').value = borderWidth;
|
||||||
document.getElementById('stream-selector-interpolation').value = device.settings?.interpolation_mode || 'average';
|
document.getElementById('stream-selector-interpolation').value = device.settings?.interpolation_mode || 'average';
|
||||||
|
document.getElementById('stream-selector-smoothing').value = smoothing;
|
||||||
|
document.getElementById('stream-selector-smoothing-value').textContent = smoothing;
|
||||||
|
|
||||||
streamSelectorInitialValues = {
|
streamSelectorInitialValues = {
|
||||||
stream: currentStreamId,
|
stream: currentStreamId,
|
||||||
border_width: String(borderWidth),
|
border_width: String(borderWidth),
|
||||||
interpolation: device.settings?.interpolation_mode || 'average',
|
interpolation: device.settings?.interpolation_mode || 'average',
|
||||||
|
smoothing: String(smoothing),
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('stream-selector-device-id').value = deviceId;
|
document.getElementById('stream-selector-device-id').value = deviceId;
|
||||||
@@ -3735,6 +3894,7 @@ async function saveStreamSelector() {
|
|||||||
const pictureStreamId = document.getElementById('stream-selector-stream').value;
|
const pictureStreamId = document.getElementById('stream-selector-stream').value;
|
||||||
const borderWidth = parseInt(document.getElementById('stream-selector-border-width').value) || 10;
|
const borderWidth = parseInt(document.getElementById('stream-selector-border-width').value) || 10;
|
||||||
const interpolation = document.getElementById('stream-selector-interpolation').value;
|
const interpolation = document.getElementById('stream-selector-interpolation').value;
|
||||||
|
const smoothing = parseFloat(document.getElementById('stream-selector-smoothing').value);
|
||||||
const errorEl = document.getElementById('stream-selector-error');
|
const errorEl = document.getElementById('stream-selector-error');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -3761,7 +3921,7 @@ async function saveStreamSelector() {
|
|||||||
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: getHeaders(),
|
headers: getHeaders(),
|
||||||
body: JSON.stringify({ ...currentSettings, border_width: borderWidth, interpolation_mode: interpolation })
|
body: JSON.stringify({ ...currentSettings, border_width: borderWidth, interpolation_mode: interpolation, smoothing: smoothing })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!settingsResponse.ok) {
|
if (!settingsResponse.ok) {
|
||||||
@@ -3783,7 +3943,8 @@ function isStreamSettingsDirty() {
|
|||||||
return (
|
return (
|
||||||
document.getElementById('stream-selector-stream').value !== streamSelectorInitialValues.stream ||
|
document.getElementById('stream-selector-stream').value !== streamSelectorInitialValues.stream ||
|
||||||
document.getElementById('stream-selector-border-width').value !== streamSelectorInitialValues.border_width ||
|
document.getElementById('stream-selector-border-width').value !== streamSelectorInitialValues.border_width ||
|
||||||
document.getElementById('stream-selector-interpolation').value !== streamSelectorInitialValues.interpolation
|
document.getElementById('stream-selector-interpolation').value !== streamSelectorInitialValues.interpolation ||
|
||||||
|
document.getElementById('stream-selector-smoothing').value !== streamSelectorInitialValues.smoothing
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
<div class="tab-panel" id="tab-pp-templates">
|
<div class="tab-panel" id="tab-pp-templates">
|
||||||
<p class="section-tip">
|
<p class="section-tip">
|
||||||
<span data-i18n="postprocessing.description">
|
<span data-i18n="postprocessing.description">
|
||||||
Processing templates define color correction and smoothing settings. Assign them to processed picture streams for consistent postprocessing across devices.
|
Processing templates define image filters and color correction. Assign them to processed picture streams for consistent postprocessing across devices.
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div id="pp-templates-list" class="templates-grid">
|
<div id="pp-templates-list" class="templates-grid">
|
||||||
@@ -303,6 +303,15 @@
|
|||||||
<small class="input-hint" data-i18n="device.stream_settings.interpolation_hint">How to calculate LED color from sampled pixels</small>
|
<small class="input-hint" data-i18n="device.stream_settings.interpolation_hint">How to calculate LED color from sampled pixels</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="stream-selector-smoothing">
|
||||||
|
<span data-i18n="device.stream_settings.smoothing">Smoothing:</span>
|
||||||
|
<span id="stream-selector-smoothing-value">0.3</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" id="stream-selector-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('stream-selector-smoothing-value').textContent = this.value">
|
||||||
|
<small class="input-hint" data-i18n="device.stream_settings.smoothing_hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="stream-selector-error" class="error-message" style="display: none;"></div>
|
<div id="stream-selector-error" class="error-message" style="display: none;"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -566,36 +575,15 @@
|
|||||||
<input type="text" id="pp-template-name" data-i18n-placeholder="postprocessing.name.placeholder" placeholder="My Processing Template" required>
|
<input type="text" id="pp-template-name" data-i18n-placeholder="postprocessing.name.placeholder" placeholder="My Processing Template" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<!-- Dynamic filter list -->
|
||||||
<label for="pp-template-gamma">
|
<div id="pp-filter-list" class="pp-filter-list"></div>
|
||||||
<span data-i18n="postprocessing.gamma">Gamma:</span>
|
|
||||||
<span id="pp-template-gamma-value">2.2</span>
|
|
||||||
</label>
|
|
||||||
<input type="range" id="pp-template-gamma" min="0.1" max="5.0" step="0.1" value="2.2" oninput="document.getElementById('pp-template-gamma-value').textContent = this.value">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<!-- Add filter control -->
|
||||||
<label for="pp-template-saturation">
|
<div class="pp-add-filter-row">
|
||||||
<span data-i18n="postprocessing.saturation">Saturation:</span>
|
<select id="pp-add-filter-select" class="pp-add-filter-select">
|
||||||
<span id="pp-template-saturation-value">1.0</span>
|
<option value="" data-i18n="filters.select_type">Select filter type...</option>
|
||||||
</label>
|
</select>
|
||||||
<input type="range" id="pp-template-saturation" min="0.0" max="2.0" step="0.1" value="1.0" oninput="document.getElementById('pp-template-saturation-value').textContent = this.value">
|
<button type="button" class="pp-add-filter-btn" onclick="addFilterFromSelect()" title="Add Filter">+</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="pp-template-brightness">
|
|
||||||
<span data-i18n="postprocessing.brightness">Brightness:</span>
|
|
||||||
<span id="pp-template-brightness-value">1.0</span>
|
|
||||||
</label>
|
|
||||||
<input type="range" id="pp-template-brightness" min="0.0" max="1.0" step="0.05" value="1.0" oninput="document.getElementById('pp-template-brightness-value').textContent = this.value">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="pp-template-smoothing">
|
|
||||||
<span data-i18n="postprocessing.smoothing">Smoothing:</span>
|
|
||||||
<span id="pp-template-smoothing-value">0.3</span>
|
|
||||||
</label>
|
|
||||||
<input type="range" id="pp-template-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('pp-template-smoothing-value').textContent = this.value">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -230,15 +230,23 @@
|
|||||||
"streams.test.duration": "Capture Duration (s):",
|
"streams.test.duration": "Capture Duration (s):",
|
||||||
"streams.test.error.failed": "Stream test failed",
|
"streams.test.error.failed": "Stream test failed",
|
||||||
"postprocessing.title": "\uD83C\uDFA8 Processing Templates",
|
"postprocessing.title": "\uD83C\uDFA8 Processing Templates",
|
||||||
"postprocessing.description": "Processing templates define color correction and smoothing settings. Assign them to processed picture streams for consistent postprocessing across devices.",
|
"postprocessing.description": "Processing templates define image filters and color correction. Assign them to processed picture streams for consistent postprocessing across devices.",
|
||||||
"postprocessing.add": "Add Processing Template",
|
"postprocessing.add": "Add Processing Template",
|
||||||
"postprocessing.edit": "Edit Processing Template",
|
"postprocessing.edit": "Edit Processing Template",
|
||||||
"postprocessing.name": "Template Name:",
|
"postprocessing.name": "Template Name:",
|
||||||
"postprocessing.name.placeholder": "My Processing Template",
|
"postprocessing.name.placeholder": "My Processing Template",
|
||||||
"postprocessing.gamma": "Gamma:",
|
"filters.select_type": "Select filter type...",
|
||||||
"postprocessing.saturation": "Saturation:",
|
"filters.add": "Add Filter",
|
||||||
"postprocessing.brightness": "Brightness:",
|
"filters.remove": "Remove",
|
||||||
"postprocessing.smoothing": "Smoothing:",
|
"filters.move_up": "Move Up",
|
||||||
|
"filters.move_down": "Move Down",
|
||||||
|
"filters.empty": "No filters added. Use the selector below to add filters.",
|
||||||
|
"filters.brightness": "Brightness",
|
||||||
|
"filters.saturation": "Saturation",
|
||||||
|
"filters.gamma": "Gamma",
|
||||||
|
"filters.downscaler": "Downscaler",
|
||||||
|
"filters.pixelate": "Pixelate",
|
||||||
|
"filters.auto_crop": "Auto Crop",
|
||||||
"postprocessing.description_label": "Description (optional):",
|
"postprocessing.description_label": "Description (optional):",
|
||||||
"postprocessing.description_placeholder": "Describe this template...",
|
"postprocessing.description_placeholder": "Describe this template...",
|
||||||
"postprocessing.created": "Template created successfully",
|
"postprocessing.created": "Template created successfully",
|
||||||
@@ -262,5 +270,7 @@
|
|||||||
"device.stream_settings.interpolation.median": "Median",
|
"device.stream_settings.interpolation.median": "Median",
|
||||||
"device.stream_settings.interpolation.dominant": "Dominant",
|
"device.stream_settings.interpolation.dominant": "Dominant",
|
||||||
"device.stream_settings.interpolation_hint": "How to calculate LED color from sampled pixels",
|
"device.stream_settings.interpolation_hint": "How to calculate LED color from sampled pixels",
|
||||||
|
"device.stream_settings.smoothing": "Smoothing:",
|
||||||
|
"device.stream_settings.smoothing_hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
|
||||||
"device.tip.stream_selector": "Configure picture stream and LED projection settings for this device"
|
"device.tip.stream_selector": "Configure picture stream and LED projection settings for this device"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,15 +230,23 @@
|
|||||||
"streams.test.duration": "Длительность Захвата (с):",
|
"streams.test.duration": "Длительность Захвата (с):",
|
||||||
"streams.test.error.failed": "Тест потока не удался",
|
"streams.test.error.failed": "Тест потока не удался",
|
||||||
"postprocessing.title": "\uD83C\uDFA8 Шаблоны Обработки",
|
"postprocessing.title": "\uD83C\uDFA8 Шаблоны Обработки",
|
||||||
"postprocessing.description": "Шаблоны обработки определяют настройки цветокоррекции и сглаживания. Назначайте их обработанным видеопотокам для единообразной постобработки на всех устройствах.",
|
"postprocessing.description": "Шаблоны обработки определяют фильтры изображений и цветокоррекцию. Назначайте их обработанным видеопотокам для единообразной постобработки на всех устройствах.",
|
||||||
"postprocessing.add": "Добавить Шаблон Обработки",
|
"postprocessing.add": "Добавить Шаблон Обработки",
|
||||||
"postprocessing.edit": "Редактировать Шаблон Обработки",
|
"postprocessing.edit": "Редактировать Шаблон Обработки",
|
||||||
"postprocessing.name": "Имя Шаблона:",
|
"postprocessing.name": "Имя Шаблона:",
|
||||||
"postprocessing.name.placeholder": "Мой Шаблон Обработки",
|
"postprocessing.name.placeholder": "Мой Шаблон Обработки",
|
||||||
"postprocessing.gamma": "Гамма:",
|
"filters.select_type": "Выберите тип фильтра...",
|
||||||
"postprocessing.saturation": "Насыщенность:",
|
"filters.add": "Добавить фильтр",
|
||||||
"postprocessing.brightness": "Яркость:",
|
"filters.remove": "Удалить",
|
||||||
"postprocessing.smoothing": "Сглаживание:",
|
"filters.move_up": "Вверх",
|
||||||
|
"filters.move_down": "Вниз",
|
||||||
|
"filters.empty": "Фильтры не добавлены. Используйте селектор ниже для добавления.",
|
||||||
|
"filters.brightness": "Яркость",
|
||||||
|
"filters.saturation": "Насыщенность",
|
||||||
|
"filters.gamma": "Гамма",
|
||||||
|
"filters.downscaler": "Уменьшение",
|
||||||
|
"filters.pixelate": "Пикселизация",
|
||||||
|
"filters.auto_crop": "Авто Обрезка",
|
||||||
"postprocessing.description_label": "Описание (необязательно):",
|
"postprocessing.description_label": "Описание (необязательно):",
|
||||||
"postprocessing.description_placeholder": "Опишите этот шаблон...",
|
"postprocessing.description_placeholder": "Опишите этот шаблон...",
|
||||||
"postprocessing.created": "Шаблон успешно создан",
|
"postprocessing.created": "Шаблон успешно создан",
|
||||||
@@ -262,5 +270,7 @@
|
|||||||
"device.stream_settings.interpolation.median": "Медиана",
|
"device.stream_settings.interpolation.median": "Медиана",
|
||||||
"device.stream_settings.interpolation.dominant": "Доминантный",
|
"device.stream_settings.interpolation.dominant": "Доминантный",
|
||||||
"device.stream_settings.interpolation_hint": "Как вычислять цвет LED из выбранных пикселей",
|
"device.stream_settings.interpolation_hint": "Как вычислять цвет LED из выбранных пикселей",
|
||||||
|
"device.stream_settings.smoothing": "Сглаживание:",
|
||||||
|
"device.stream_settings.smoothing_hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
|
||||||
"device.tip.stream_selector": "Настройки видеопотока и проекции LED для этого устройства"
|
"device.tip.stream_selector": "Настройки видеопотока и проекции LED для этого устройства"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1926,6 +1926,163 @@ input:-webkit-autofill:focus {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* PP Filter List in Template Modal */
|
||||||
|
.pp-filter-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-filter-empty {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-filter-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-filter-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-filter-card.expanded .pp-filter-card-header {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-filter-card-chevron {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-filter-card-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-filter-card-summary {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-filter-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-filter-action {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-filter-action:hover:not(:disabled) {
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-filter-action:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-filter-remove:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border-color: rgba(239, 68, 68, 0.4);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-filter-card-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-filter-option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-filter-option label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-filter-option input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-add-filter-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-add-filter-select {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-add-filter-btn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pp-add-filter-btn:hover {
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
/* Template Test Section */
|
/* Template Test Section */
|
||||||
.template-test-section {
|
.template-test-section {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
"""Postprocessing template data model."""
|
"""Postprocessing template data model."""
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from wled_controller.core.filters.filter_instance import FilterInstance
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PostprocessingTemplate:
|
class PostprocessingTemplate:
|
||||||
"""Postprocessing settings template for color correction and smoothing."""
|
"""Postprocessing settings template containing an ordered list of filters."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
gamma: float
|
filters: List[FilterInstance]
|
||||||
saturation: float
|
|
||||||
brightness: float
|
|
||||||
smoothing: float
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
@@ -24,10 +23,7 @@ class PostprocessingTemplate:
|
|||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"gamma": self.gamma,
|
"filters": [f.to_dict() for f in self.filters],
|
||||||
"saturation": self.saturation,
|
|
||||||
"brightness": self.brightness,
|
|
||||||
"smoothing": self.smoothing,
|
|
||||||
"created_at": self.created_at.isoformat(),
|
"created_at": self.created_at.isoformat(),
|
||||||
"updated_at": self.updated_at.isoformat(),
|
"updated_at": self.updated_at.isoformat(),
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
@@ -35,14 +31,30 @@ class PostprocessingTemplate:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "PostprocessingTemplate":
|
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(
|
return cls(
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
gamma=data.get("gamma", 2.2),
|
filters=filters,
|
||||||
saturation=data.get("saturation", 1.0),
|
|
||||||
brightness=data.get("brightness", 1.0),
|
|
||||||
smoothing=data.get("smoothing", 0.3),
|
|
||||||
created_at=datetime.fromisoformat(data["created_at"])
|
created_at=datetime.fromisoformat(data["created_at"])
|
||||||
if isinstance(data.get("created_at"), str)
|
if isinstance(data.get("created_at"), str)
|
||||||
else data.get("created_at", datetime.utcnow()),
|
else data.get("created_at", datetime.utcnow()),
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional
|
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.storage.postprocessing_template import PostprocessingTemplate
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
@@ -41,10 +43,11 @@ class PostprocessingTemplateStore:
|
|||||||
template = PostprocessingTemplate(
|
template = PostprocessingTemplate(
|
||||||
id=template_id,
|
id=template_id,
|
||||||
name="Default",
|
name="Default",
|
||||||
gamma=2.2,
|
filters=[
|
||||||
saturation=1.0,
|
FilterInstance("brightness", {"value": 1.0}),
|
||||||
brightness=1.0,
|
FilterInstance("saturation", {"value": 1.0}),
|
||||||
smoothing=0.3,
|
FilterInstance("gamma", {"value": 2.2}),
|
||||||
|
],
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
description="Default postprocessing template",
|
description="Default postprocessing template",
|
||||||
@@ -96,7 +99,7 @@ class PostprocessingTemplateStore:
|
|||||||
}
|
}
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"postprocessing_templates": templates_dict,
|
"postprocessing_templates": templates_dict,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,31 +127,38 @@ class PostprocessingTemplateStore:
|
|||||||
def create_template(
|
def create_template(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
gamma: float = 2.2,
|
filters: Optional[List[FilterInstance]] = None,
|
||||||
saturation: float = 1.0,
|
|
||||||
brightness: float = 1.0,
|
|
||||||
smoothing: float = 0.3,
|
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
) -> PostprocessingTemplate:
|
) -> PostprocessingTemplate:
|
||||||
"""Create a new postprocessing template.
|
"""Create a new postprocessing template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Template name (must be unique)
|
||||||
|
filters: Ordered list of filter instances
|
||||||
|
description: Optional description
|
||||||
|
|
||||||
Raises:
|
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():
|
for template in self._templates.values():
|
||||||
if template.name == name:
|
if template.name == name:
|
||||||
raise ValueError(f"Postprocessing template with name '{name}' already exists")
|
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]}"
|
template_id = f"pp_{uuid.uuid4().hex[:8]}"
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|
||||||
template = PostprocessingTemplate(
|
template = PostprocessingTemplate(
|
||||||
id=template_id,
|
id=template_id,
|
||||||
name=name,
|
name=name,
|
||||||
gamma=gamma,
|
filters=filters,
|
||||||
saturation=saturation,
|
|
||||||
brightness=brightness,
|
|
||||||
smoothing=smoothing,
|
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
description=description,
|
description=description,
|
||||||
@@ -164,16 +174,13 @@ class PostprocessingTemplateStore:
|
|||||||
self,
|
self,
|
||||||
template_id: str,
|
template_id: str,
|
||||||
name: Optional[str] = None,
|
name: Optional[str] = None,
|
||||||
gamma: Optional[float] = None,
|
filters: Optional[List[FilterInstance]] = None,
|
||||||
saturation: Optional[float] = None,
|
|
||||||
brightness: Optional[float] = None,
|
|
||||||
smoothing: Optional[float] = None,
|
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
) -> PostprocessingTemplate:
|
) -> PostprocessingTemplate:
|
||||||
"""Update an existing postprocessing template.
|
"""Update an existing postprocessing template.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If template not found
|
ValueError: If template not found or invalid filter_id
|
||||||
"""
|
"""
|
||||||
if template_id not in self._templates:
|
if template_id not in self._templates:
|
||||||
raise ValueError(f"Postprocessing template not found: {template_id}")
|
raise ValueError(f"Postprocessing template not found: {template_id}")
|
||||||
@@ -182,14 +189,12 @@ class PostprocessingTemplateStore:
|
|||||||
|
|
||||||
if name is not None:
|
if name is not None:
|
||||||
template.name = name
|
template.name = name
|
||||||
if gamma is not None:
|
if filters is not None:
|
||||||
template.gamma = gamma
|
# Validate filter IDs
|
||||||
if saturation is not None:
|
for fi in filters:
|
||||||
template.saturation = saturation
|
if not FilterRegistry.is_registered(fi.filter_id):
|
||||||
if brightness is not None:
|
raise ValueError(f"Unknown filter type: '{fi.filter_id}'")
|
||||||
template.brightness = brightness
|
template.filters = filters
|
||||||
if smoothing is not None:
|
|
||||||
template.smoothing = smoothing
|
|
||||||
if description is not None:
|
if description is not None:
|
||||||
template.description = description
|
template.description = description
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user