Add pluggable postprocessing filter system with collapsible UI
Replace hardcoded gamma/saturation/brightness fields with a flexible filter pipeline architecture. Templates now contain an ordered list of filter instances, each with its own options schema. Filters operate on full images before border extraction. - Add filter framework: base class, registry, image pool, filter instance - Implement 6 built-in filters: brightness, saturation, gamma, downscaler, pixelate, auto crop - Move smoothing from PP templates to device stream settings (temporal, not spatial) - Add GET /api/v1/filters endpoint for available filter types - Dynamic filter UI in template modal with add/remove/reorder/collapse - Replace camera icon with display icon for screen capture streams - Legacy migration: existing templates auto-convert flat fields to filter list Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,10 @@ from wled_controller.api.schemas import (
|
||||
CaptureImage,
|
||||
BorderExtraction,
|
||||
PerformanceMetrics,
|
||||
FilterInstanceSchema,
|
||||
FilterOptionDefSchema,
|
||||
FilterTypeResponse,
|
||||
FilterTypeListResponse,
|
||||
PostprocessingTemplateCreate,
|
||||
PostprocessingTemplateUpdate,
|
||||
PostprocessingTemplateResponse,
|
||||
@@ -61,6 +65,7 @@ from wled_controller.storage.template_store import TemplateStore
|
||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||
from wled_controller.storage.picture_stream_store import PictureStreamStore
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
from wled_controller.core.filters import FilterRegistry, FilterInstance, ImagePool
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.core.screen_capture import get_available_displays
|
||||
|
||||
@@ -587,6 +592,7 @@ async def get_settings(
|
||||
border_width=device.settings.border_width,
|
||||
interpolation_mode=device.settings.interpolation_mode,
|
||||
brightness=device.settings.brightness,
|
||||
smoothing=device.settings.smoothing,
|
||||
state_check_interval=device.settings.state_check_interval,
|
||||
)
|
||||
|
||||
@@ -620,7 +626,7 @@ async def update_settings(
|
||||
brightness=settings.color_correction.brightness if settings.color_correction else existing.brightness,
|
||||
gamma=settings.color_correction.gamma if settings.color_correction else existing.gamma,
|
||||
saturation=settings.color_correction.saturation if settings.color_correction else existing.saturation,
|
||||
smoothing=existing.smoothing,
|
||||
smoothing=settings.smoothing,
|
||||
state_check_interval=settings.state_check_interval,
|
||||
)
|
||||
|
||||
@@ -1106,6 +1112,34 @@ async def test_template(
|
||||
logger.error(f"Error cleaning up test engine: {e}")
|
||||
|
||||
|
||||
# ===== FILTER TYPE ENDPOINTS =====
|
||||
|
||||
@router.get("/api/v1/filters", response_model=FilterTypeListResponse, tags=["Filters"])
|
||||
async def list_filter_types(_auth: AuthRequired):
|
||||
"""List all available postprocessing filter types and their options schemas."""
|
||||
all_filters = FilterRegistry.get_all()
|
||||
responses = []
|
||||
for filter_id, filter_cls in all_filters.items():
|
||||
schema = filter_cls.get_options_schema()
|
||||
responses.append(FilterTypeResponse(
|
||||
filter_id=filter_cls.filter_id,
|
||||
filter_name=filter_cls.filter_name,
|
||||
options_schema=[
|
||||
FilterOptionDefSchema(
|
||||
key=opt.key,
|
||||
label=opt.label,
|
||||
type=opt.option_type,
|
||||
default=opt.default,
|
||||
min_value=opt.min_value,
|
||||
max_value=opt.max_value,
|
||||
step=opt.step,
|
||||
)
|
||||
for opt in schema
|
||||
],
|
||||
))
|
||||
return FilterTypeListResponse(filters=responses, count=len(responses))
|
||||
|
||||
|
||||
# ===== POSTPROCESSING TEMPLATE ENDPOINTS =====
|
||||
|
||||
def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
|
||||
@@ -1113,10 +1147,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
|
||||
return PostprocessingTemplateResponse(
|
||||
id=t.id,
|
||||
name=t.name,
|
||||
gamma=t.gamma,
|
||||
saturation=t.saturation,
|
||||
brightness=t.brightness,
|
||||
smoothing=t.smoothing,
|
||||
filters=[FilterInstanceSchema(filter_id=f.filter_id, options=f.options) for f in t.filters],
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
@@ -1146,12 +1177,10 @@ async def create_pp_template(
|
||||
):
|
||||
"""Create a new postprocessing template."""
|
||||
try:
|
||||
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters]
|
||||
template = store.create_template(
|
||||
name=data.name,
|
||||
gamma=data.gamma,
|
||||
saturation=data.saturation,
|
||||
brightness=data.brightness,
|
||||
smoothing=data.smoothing,
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
)
|
||||
return _pp_template_to_response(template)
|
||||
@@ -1185,13 +1214,11 @@ async def update_pp_template(
|
||||
):
|
||||
"""Update a postprocessing template."""
|
||||
try:
|
||||
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters] if data.filters is not None else None
|
||||
template = store.update_template(
|
||||
template_id=template_id,
|
||||
name=data.name,
|
||||
gamma=data.gamma,
|
||||
saturation=data.saturation,
|
||||
brightness=data.brightness,
|
||||
smoothing=data.smoothing,
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
)
|
||||
return _pp_template_to_response(template)
|
||||
@@ -1477,25 +1504,24 @@ async def test_picture_stream(
|
||||
thumbnail = pil_image.copy()
|
||||
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Apply postprocessing if this is a processed stream
|
||||
# Apply postprocessing filters if this is a processed stream
|
||||
pp_template_ids = chain["postprocessing_template_ids"]
|
||||
if pp_template_ids:
|
||||
try:
|
||||
pp = pp_store.get_template(pp_template_ids[0])
|
||||
pp_template = pp_store.get_template(pp_template_ids[0])
|
||||
pool = ImagePool()
|
||||
|
||||
def apply_pp(img):
|
||||
arr = np.array(img, dtype=np.float32) / 255.0
|
||||
if pp.brightness != 1.0:
|
||||
arr *= pp.brightness
|
||||
if pp.saturation != 1.0:
|
||||
lum = np.dot(arr[..., :3], [0.299, 0.587, 0.114])[..., np.newaxis]
|
||||
arr[..., :3] = lum + (arr[..., :3] - lum) * pp.saturation
|
||||
if pp.gamma != 1.0:
|
||||
arr = np.power(np.clip(arr, 0, 1), 1.0 / pp.gamma)
|
||||
return Image.fromarray(np.clip(arr * 255.0, 0, 255).astype(np.uint8))
|
||||
def apply_filters(img):
|
||||
arr = np.array(img)
|
||||
for fi in pp_template.filters:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(arr, pool)
|
||||
if result is not None:
|
||||
arr = result
|
||||
return Image.fromarray(arr)
|
||||
|
||||
thumbnail = apply_pp(thumbnail)
|
||||
pil_image = apply_pp(pil_image)
|
||||
thumbnail = apply_filters(thumbnail)
|
||||
pil_image = apply_filters(pil_image)
|
||||
except ValueError:
|
||||
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Pydantic schemas for API request and response models."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Literal, Optional
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
|
||||
@@ -84,6 +84,7 @@ class ProcessingSettings(BaseModel):
|
||||
border_width: int = Field(default=10, description="Border width in pixels", ge=1, le=100)
|
||||
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
|
||||
brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0)
|
||||
smoothing: float = Field(default=0.3, description="Temporal smoothing factor (0.0=none, 1.0=full)", ge=0.0, le=1.0)
|
||||
state_check_interval: int = Field(
|
||||
default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600,
|
||||
description="Seconds between WLED health checks"
|
||||
@@ -326,16 +327,49 @@ class TemplateTestResponse(BaseModel):
|
||||
performance: PerformanceMetrics = Field(description="Performance metrics")
|
||||
|
||||
|
||||
# Filter Schemas
|
||||
|
||||
class FilterInstanceSchema(BaseModel):
|
||||
"""A single filter instance with its configuration."""
|
||||
|
||||
filter_id: str = Field(description="Filter type identifier")
|
||||
options: Dict[str, Any] = Field(default_factory=dict, description="Filter-specific options")
|
||||
|
||||
|
||||
class FilterOptionDefSchema(BaseModel):
|
||||
"""Describes a configurable option for a filter type."""
|
||||
|
||||
key: str = Field(description="Option key")
|
||||
label: str = Field(description="Display label")
|
||||
type: str = Field(description="Option type (float or int)")
|
||||
default: Any = Field(description="Default value")
|
||||
min_value: Any = Field(description="Minimum value")
|
||||
max_value: Any = Field(description="Maximum value")
|
||||
step: Any = Field(description="Step increment")
|
||||
|
||||
|
||||
class FilterTypeResponse(BaseModel):
|
||||
"""Available filter type with its options schema."""
|
||||
|
||||
filter_id: str = Field(description="Filter type identifier")
|
||||
filter_name: str = Field(description="Display name")
|
||||
options_schema: List[FilterOptionDefSchema] = Field(description="Configurable options")
|
||||
|
||||
|
||||
class FilterTypeListResponse(BaseModel):
|
||||
"""List of available filter types."""
|
||||
|
||||
filters: List[FilterTypeResponse] = Field(description="Available filter types")
|
||||
count: int = Field(description="Number of filter types")
|
||||
|
||||
|
||||
# Postprocessing Template Schemas
|
||||
|
||||
class PostprocessingTemplateCreate(BaseModel):
|
||||
"""Request to create a postprocessing template."""
|
||||
|
||||
name: str = Field(description="Template name", min_length=1, max_length=100)
|
||||
gamma: float = Field(default=2.2, description="Gamma correction", ge=0.1, le=5.0)
|
||||
saturation: float = Field(default=1.0, description="Saturation multiplier", ge=0.0, le=2.0)
|
||||
brightness: float = Field(default=1.0, description="Brightness multiplier", ge=0.0, le=1.0)
|
||||
smoothing: float = Field(default=0.3, description="Temporal smoothing factor", ge=0.0, le=1.0)
|
||||
filters: List[FilterInstanceSchema] = Field(default_factory=list, description="Ordered list of filter instances")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
|
||||
|
||||
@@ -343,10 +377,7 @@ class PostprocessingTemplateUpdate(BaseModel):
|
||||
"""Request to update a postprocessing template."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
|
||||
gamma: Optional[float] = Field(None, description="Gamma correction", ge=0.1, le=5.0)
|
||||
saturation: Optional[float] = Field(None, description="Saturation multiplier", ge=0.0, le=2.0)
|
||||
brightness: Optional[float] = Field(None, description="Brightness multiplier", ge=0.0, le=1.0)
|
||||
smoothing: Optional[float] = Field(None, description="Temporal smoothing factor", ge=0.0, le=1.0)
|
||||
filters: Optional[List[FilterInstanceSchema]] = Field(None, description="Ordered list of filter instances")
|
||||
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||
|
||||
|
||||
@@ -355,10 +386,7 @@ class PostprocessingTemplateResponse(BaseModel):
|
||||
|
||||
id: str = Field(description="Template ID")
|
||||
name: str = Field(description="Template name")
|
||||
gamma: float = Field(description="Gamma correction")
|
||||
saturation: float = Field(description="Saturation multiplier")
|
||||
brightness: float = Field(description="Brightness multiplier")
|
||||
smoothing: float = Field(description="Temporal smoothing factor")
|
||||
filters: List[FilterInstanceSchema] = Field(description="Ordered list of filter instances")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Template description")
|
||||
|
||||
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,
|
||||
)
|
||||
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.wled_client import WLEDClient
|
||||
from wled_controller.utils import get_logger
|
||||
@@ -106,10 +107,9 @@ class ProcessorState:
|
||||
resolved_target_fps: Optional[int] = None
|
||||
resolved_engine_type: Optional[str] = None
|
||||
resolved_engine_config: Optional[dict] = None
|
||||
resolved_gamma: Optional[float] = None
|
||||
resolved_saturation: Optional[float] = None
|
||||
resolved_brightness: Optional[float] = None
|
||||
resolved_smoothing: Optional[float] = None
|
||||
resolved_filters: Optional[List[FilterInstance]] = None
|
||||
image_pool: Optional[ImagePool] = None
|
||||
filter_instances: Optional[List[PostprocessingFilter]] = None
|
||||
|
||||
|
||||
class ProcessorManager:
|
||||
@@ -298,10 +298,7 @@ class ProcessorManager:
|
||||
if pp_template_ids and self._pp_template_store:
|
||||
try:
|
||||
pp = self._pp_template_store.get_template(pp_template_ids[0])
|
||||
state.resolved_gamma = pp.gamma
|
||||
state.resolved_saturation = pp.saturation
|
||||
state.resolved_brightness = pp.brightness
|
||||
state.resolved_smoothing = pp.smoothing
|
||||
state.resolved_filters = pp.filters
|
||||
except ValueError:
|
||||
logger.warning(f"PP template {pp_template_ids[0]} not found, using defaults")
|
||||
|
||||
@@ -314,13 +311,17 @@ class ProcessorManager:
|
||||
except ValueError as e:
|
||||
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_target_fps = state.settings.fps
|
||||
state.resolved_gamma = state.settings.gamma
|
||||
state.resolved_saturation = state.settings.saturation
|
||||
state.resolved_brightness = state.settings.brightness
|
||||
state.resolved_smoothing = state.settings.smoothing
|
||||
legacy_filters = []
|
||||
if state.settings.brightness != 1.0:
|
||||
legacy_filters.append(FilterInstance("brightness", {"value": state.settings.brightness}))
|
||||
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
|
||||
if state.capture_template_id and self._capture_template_store:
|
||||
@@ -457,23 +458,40 @@ class ProcessorManager:
|
||||
# Use resolved values (populated by _resolve_stream_settings)
|
||||
display_index = state.resolved_display_index or settings.display_index
|
||||
target_fps = state.resolved_target_fps or settings.fps
|
||||
gamma = state.resolved_gamma if state.resolved_gamma is not None else settings.gamma
|
||||
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
|
||||
smoothing = settings.smoothing
|
||||
|
||||
# These always come from device settings (LED projection)
|
||||
border_width = settings.border_width
|
||||
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(
|
||||
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
|
||||
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:
|
||||
while state.is_running:
|
||||
loop_start = time.time()
|
||||
@@ -490,21 +508,16 @@ class ProcessorManager:
|
||||
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
|
||||
border_pixels = await asyncio.to_thread(extract_border_pixels, capture, border_width)
|
||||
|
||||
# Map to LED colors
|
||||
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
|
||||
if state.previous_colors and smoothing > 0:
|
||||
led_colors = await asyncio.to_thread(
|
||||
|
||||
@@ -2992,6 +2992,7 @@ async function deleteTemplate(templateId) {
|
||||
|
||||
let _cachedStreams = [];
|
||||
let _cachedPPTemplates = [];
|
||||
let _availableFilters = []; // Loaded from GET /filters
|
||||
|
||||
async function loadPictureStreams() {
|
||||
try {
|
||||
@@ -3017,7 +3018,7 @@ function renderPictureStreamsList(streams) {
|
||||
container.innerHTML = `
|
||||
<div class="stream-group">
|
||||
<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-count">0</span>
|
||||
</div>
|
||||
@@ -3045,7 +3046,7 @@ function renderPictureStreamsList(streams) {
|
||||
}
|
||||
|
||||
const renderCard = (stream) => {
|
||||
const typeIcon = stream.stream_type === 'raw' ? '📷' : '🎨';
|
||||
const typeIcon = stream.stream_type === 'raw' ? '🖥️' : '🎨';
|
||||
const typeBadge = stream.stream_type === 'raw'
|
||||
? `<span class="badge badge-raw">${t('streams.type.raw')}</span>`
|
||||
: `<span class="badge badge-processed">${t('streams.type.processed')}</span>`;
|
||||
@@ -3102,7 +3103,7 @@ function renderPictureStreamsList(streams) {
|
||||
// Screen Capture streams section
|
||||
html += `<div class="stream-group">
|
||||
<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-count">${rawStreams.length}</span>
|
||||
</div>
|
||||
@@ -3252,7 +3253,7 @@ async function populateStreamModalDropdowns() {
|
||||
if (s.id === editingId) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
const typeLabel = s.stream_type === 'raw' ? '📷' : '🎨';
|
||||
const typeLabel = s.stream_type === 'raw' ? '🖥️' : '🎨';
|
||||
opt.textContent = `${typeLabel} ${s.name}`;
|
||||
sourceSelect.appendChild(opt);
|
||||
});
|
||||
@@ -3423,8 +3424,24 @@ function displayStreamTestResults(result) {
|
||||
|
||||
// ===== 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() {
|
||||
try {
|
||||
// Ensure filters are loaded for rendering
|
||||
if (_availableFilters.length === 0) {
|
||||
await loadAvailableFilters();
|
||||
}
|
||||
const response = await fetchWithAuth('/postprocessing-templates');
|
||||
if (!response.ok) {
|
||||
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) {
|
||||
const container = document.getElementById('pp-templates-list');
|
||||
|
||||
@@ -3452,12 +3480,12 @@ function renderPPTemplatesList(templates) {
|
||||
}
|
||||
|
||||
const renderCard = (tmpl) => {
|
||||
const configEntries = {
|
||||
[t('postprocessing.gamma')]: tmpl.gamma,
|
||||
[t('postprocessing.saturation')]: tmpl.saturation,
|
||||
[t('postprocessing.brightness')]: tmpl.brightness,
|
||||
[t('postprocessing.smoothing')]: tmpl.smoothing,
|
||||
};
|
||||
// Build config entries from filter list
|
||||
const filterRows = (tmpl.filters || []).map(fi => {
|
||||
const filterName = _getFilterName(fi.filter_id);
|
||||
const optStr = Object.entries(fi.options || {}).map(([k, v]) => `${v}`).join(', ');
|
||||
return `<tr><td class="config-key">${escapeHtml(filterName)}</td><td class="config-value">${escapeHtml(optStr)}</td></tr>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="template-card" data-pp-template-id="${tmpl.id}">
|
||||
@@ -3471,12 +3499,7 @@ function renderPPTemplatesList(templates) {
|
||||
<details class="template-config-details">
|
||||
<summary>${t('postprocessing.config.show')}</summary>
|
||||
<table class="config-table">
|
||||
${Object.entries(configEntries).map(([key, val]) => `
|
||||
<tr>
|
||||
<td class="config-key">${escapeHtml(key)}</td>
|
||||
<td class="config-value">${escapeHtml(String(val))}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
${filterRows}
|
||||
</table>
|
||||
</details>
|
||||
<div class="template-card-actions">
|
||||
@@ -3497,21 +3520,154 @@ function renderPPTemplatesList(templates) {
|
||||
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() {
|
||||
if (_availableFilters.length === 0) await loadAvailableFilters();
|
||||
|
||||
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add');
|
||||
document.getElementById('pp-template-form').reset();
|
||||
document.getElementById('pp-template-id').value = '';
|
||||
document.getElementById('pp-template-error').style.display = 'none';
|
||||
|
||||
// Reset slider displays to defaults
|
||||
document.getElementById('pp-template-gamma').value = '2.2';
|
||||
document.getElementById('pp-template-gamma-value').textContent = '2.2';
|
||||
document.getElementById('pp-template-saturation').value = '1.0';
|
||||
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';
|
||||
_modalFilters = [];
|
||||
|
||||
_populateFilterSelect();
|
||||
renderModalFilterList();
|
||||
|
||||
const modal = document.getElementById('pp-template-modal');
|
||||
modal.style.display = 'flex';
|
||||
@@ -3521,6 +3677,8 @@ async function showAddPPTemplateModal() {
|
||||
|
||||
async function editPPTemplate(templateId) {
|
||||
try {
|
||||
if (_availableFilters.length === 0) await loadAvailableFilters();
|
||||
|
||||
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
|
||||
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
||||
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-error').style.display = 'none';
|
||||
|
||||
// Set sliders
|
||||
document.getElementById('pp-template-gamma').value = tmpl.gamma;
|
||||
document.getElementById('pp-template-gamma-value').textContent = tmpl.gamma;
|
||||
document.getElementById('pp-template-saturation').value = tmpl.saturation;
|
||||
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;
|
||||
document.getElementById('pp-template-smoothing').value = tmpl.smoothing;
|
||||
document.getElementById('pp-template-smoothing-value').textContent = tmpl.smoothing;
|
||||
// Load filters from template
|
||||
_modalFilters = (tmpl.filters || []).map(fi => ({
|
||||
filter_id: fi.filter_id,
|
||||
options: { ...fi.options },
|
||||
}));
|
||||
|
||||
_populateFilterSelect();
|
||||
renderModalFilterList();
|
||||
|
||||
const modal = document.getElementById('pp-template-modal');
|
||||
modal.style.display = 'flex';
|
||||
@@ -3564,10 +3721,7 @@ async function savePPTemplate() {
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
gamma: parseFloat(document.getElementById('pp-template-gamma').value),
|
||||
saturation: parseFloat(document.getElementById('pp-template-saturation').value),
|
||||
brightness: parseFloat(document.getElementById('pp-template-brightness').value),
|
||||
smoothing: parseFloat(document.getElementById('pp-template-smoothing').value),
|
||||
filters: collectFilters(),
|
||||
description: description || null,
|
||||
};
|
||||
|
||||
@@ -3624,6 +3778,7 @@ async function deletePPTemplate(templateId) {
|
||||
|
||||
function closePPTemplateModal() {
|
||||
document.getElementById('pp-template-modal').style.display = 'none';
|
||||
_modalFilters = [];
|
||||
unlockBody();
|
||||
}
|
||||
|
||||
@@ -3661,7 +3816,7 @@ async function showStreamSelector(deviceId) {
|
||||
(data.streams || []).forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
const typeIcon = s.stream_type === 'raw' ? '📷' : '🎨';
|
||||
const typeIcon = s.stream_type === 'raw' ? '🖥️' : '🎨';
|
||||
opt.textContent = `${typeIcon} ${s.name}`;
|
||||
streamSelect.appendChild(opt);
|
||||
});
|
||||
@@ -3672,13 +3827,17 @@ async function showStreamSelector(deviceId) {
|
||||
|
||||
// Populate LED projection fields
|
||||
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-interpolation').value = device.settings?.interpolation_mode || 'average';
|
||||
document.getElementById('stream-selector-smoothing').value = smoothing;
|
||||
document.getElementById('stream-selector-smoothing-value').textContent = smoothing;
|
||||
|
||||
streamSelectorInitialValues = {
|
||||
stream: currentStreamId,
|
||||
border_width: String(borderWidth),
|
||||
interpolation: device.settings?.interpolation_mode || 'average',
|
||||
smoothing: String(smoothing),
|
||||
};
|
||||
|
||||
document.getElementById('stream-selector-device-id').value = deviceId;
|
||||
@@ -3735,6 +3894,7 @@ async function saveStreamSelector() {
|
||||
const pictureStreamId = document.getElementById('stream-selector-stream').value;
|
||||
const borderWidth = parseInt(document.getElementById('stream-selector-border-width').value) || 10;
|
||||
const interpolation = document.getElementById('stream-selector-interpolation').value;
|
||||
const smoothing = parseFloat(document.getElementById('stream-selector-smoothing').value);
|
||||
const errorEl = document.getElementById('stream-selector-error');
|
||||
|
||||
try {
|
||||
@@ -3761,7 +3921,7 @@ async function saveStreamSelector() {
|
||||
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
||||
method: 'PUT',
|
||||
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) {
|
||||
@@ -3783,7 +3943,8 @@ function isStreamSettingsDirty() {
|
||||
return (
|
||||
document.getElementById('stream-selector-stream').value !== streamSelectorInitialValues.stream ||
|
||||
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">
|
||||
<p class="section-tip">
|
||||
<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>
|
||||
</p>
|
||||
<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>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
@@ -566,36 +575,15 @@
|
||||
<input type="text" id="pp-template-name" data-i18n-placeholder="postprocessing.name.placeholder" placeholder="My Processing Template" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="pp-template-gamma">
|
||||
<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>
|
||||
<!-- Dynamic filter list -->
|
||||
<div id="pp-filter-list" class="pp-filter-list"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="pp-template-saturation">
|
||||
<span data-i18n="postprocessing.saturation">Saturation:</span>
|
||||
<span id="pp-template-saturation-value">1.0</span>
|
||||
</label>
|
||||
<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">
|
||||
</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">
|
||||
<!-- Add filter control -->
|
||||
<div class="pp-add-filter-row">
|
||||
<select id="pp-add-filter-select" class="pp-add-filter-select">
|
||||
<option value="" data-i18n="filters.select_type">Select filter type...</option>
|
||||
</select>
|
||||
<button type="button" class="pp-add-filter-btn" onclick="addFilterFromSelect()" title="Add Filter">+</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -230,15 +230,23 @@
|
||||
"streams.test.duration": "Capture Duration (s):",
|
||||
"streams.test.error.failed": "Stream test failed",
|
||||
"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.edit": "Edit Processing Template",
|
||||
"postprocessing.name": "Template Name:",
|
||||
"postprocessing.name.placeholder": "My Processing Template",
|
||||
"postprocessing.gamma": "Gamma:",
|
||||
"postprocessing.saturation": "Saturation:",
|
||||
"postprocessing.brightness": "Brightness:",
|
||||
"postprocessing.smoothing": "Smoothing:",
|
||||
"filters.select_type": "Select filter type...",
|
||||
"filters.add": "Add Filter",
|
||||
"filters.remove": "Remove",
|
||||
"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_placeholder": "Describe this template...",
|
||||
"postprocessing.created": "Template created successfully",
|
||||
@@ -262,5 +270,7 @@
|
||||
"device.stream_settings.interpolation.median": "Median",
|
||||
"device.stream_settings.interpolation.dominant": "Dominant",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -230,15 +230,23 @@
|
||||
"streams.test.duration": "Длительность Захвата (с):",
|
||||
"streams.test.error.failed": "Тест потока не удался",
|
||||
"postprocessing.title": "\uD83C\uDFA8 Шаблоны Обработки",
|
||||
"postprocessing.description": "Шаблоны обработки определяют настройки цветокоррекции и сглаживания. Назначайте их обработанным видеопотокам для единообразной постобработки на всех устройствах.",
|
||||
"postprocessing.description": "Шаблоны обработки определяют фильтры изображений и цветокоррекцию. Назначайте их обработанным видеопотокам для единообразной постобработки на всех устройствах.",
|
||||
"postprocessing.add": "Добавить Шаблон Обработки",
|
||||
"postprocessing.edit": "Редактировать Шаблон Обработки",
|
||||
"postprocessing.name": "Имя Шаблона:",
|
||||
"postprocessing.name.placeholder": "Мой Шаблон Обработки",
|
||||
"postprocessing.gamma": "Гамма:",
|
||||
"postprocessing.saturation": "Насыщенность:",
|
||||
"postprocessing.brightness": "Яркость:",
|
||||
"postprocessing.smoothing": "Сглаживание:",
|
||||
"filters.select_type": "Выберите тип фильтра...",
|
||||
"filters.add": "Добавить фильтр",
|
||||
"filters.remove": "Удалить",
|
||||
"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_placeholder": "Опишите этот шаблон...",
|
||||
"postprocessing.created": "Шаблон успешно создан",
|
||||
@@ -262,5 +270,7 @@
|
||||
"device.stream_settings.interpolation.median": "Медиана",
|
||||
"device.stream_settings.interpolation.dominant": "Доминантный",
|
||||
"device.stream_settings.interpolation_hint": "Как вычислять цвет LED из выбранных пикселей",
|
||||
"device.stream_settings.smoothing": "Сглаживание:",
|
||||
"device.stream_settings.smoothing_hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
|
||||
"device.tip.stream_selector": "Настройки видеопотока и проекции LED для этого устройства"
|
||||
}
|
||||
|
||||
@@ -1926,6 +1926,163 @@ input:-webkit-autofill:focus {
|
||||
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 {
|
||||
background: var(--bg-secondary);
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
"""Postprocessing template data model."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.core.filters.filter_instance import FilterInstance
|
||||
|
||||
|
||||
@dataclass
|
||||
class PostprocessingTemplate:
|
||||
"""Postprocessing settings template for color correction and smoothing."""
|
||||
"""Postprocessing settings template containing an ordered list of filters."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
gamma: float
|
||||
saturation: float
|
||||
brightness: float
|
||||
smoothing: float
|
||||
filters: List[FilterInstance]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
@@ -24,10 +23,7 @@ class PostprocessingTemplate:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"gamma": self.gamma,
|
||||
"saturation": self.saturation,
|
||||
"brightness": self.brightness,
|
||||
"smoothing": self.smoothing,
|
||||
"filters": [f.to_dict() for f in self.filters],
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"description": self.description,
|
||||
@@ -35,14 +31,30 @@ class PostprocessingTemplate:
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "PostprocessingTemplate":
|
||||
"""Create template from dictionary."""
|
||||
"""Create template from dictionary.
|
||||
|
||||
Supports migration from legacy flat-field format (gamma/saturation/brightness)
|
||||
to the new filters list format.
|
||||
"""
|
||||
if "filters" in data:
|
||||
filters = [FilterInstance.from_dict(f) for f in data["filters"]]
|
||||
else:
|
||||
# Legacy migration: construct filters from flat fields
|
||||
filters = []
|
||||
brightness = data.get("brightness", 1.0)
|
||||
if brightness != 1.0:
|
||||
filters.append(FilterInstance("brightness", {"value": brightness}))
|
||||
saturation = data.get("saturation", 1.0)
|
||||
if saturation != 1.0:
|
||||
filters.append(FilterInstance("saturation", {"value": saturation}))
|
||||
gamma = data.get("gamma", 2.2)
|
||||
if gamma != 2.2:
|
||||
filters.append(FilterInstance("gamma", {"value": gamma}))
|
||||
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
gamma=data.get("gamma", 2.2),
|
||||
saturation=data.get("saturation", 1.0),
|
||||
brightness=data.get("brightness", 1.0),
|
||||
smoothing=data.get("smoothing", 0.3),
|
||||
filters=filters,
|
||||
created_at=datetime.fromisoformat(data["created_at"])
|
||||
if isinstance(data.get("created_at"), str)
|
||||
else data.get("created_at", datetime.utcnow()),
|
||||
|
||||
@@ -6,6 +6,8 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from wled_controller.core.filters.filter_instance import FilterInstance
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
from wled_controller.storage.postprocessing_template import PostprocessingTemplate
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
@@ -41,10 +43,11 @@ class PostprocessingTemplateStore:
|
||||
template = PostprocessingTemplate(
|
||||
id=template_id,
|
||||
name="Default",
|
||||
gamma=2.2,
|
||||
saturation=1.0,
|
||||
brightness=1.0,
|
||||
smoothing=0.3,
|
||||
filters=[
|
||||
FilterInstance("brightness", {"value": 1.0}),
|
||||
FilterInstance("saturation", {"value": 1.0}),
|
||||
FilterInstance("gamma", {"value": 2.2}),
|
||||
],
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description="Default postprocessing template",
|
||||
@@ -96,7 +99,7 @@ class PostprocessingTemplateStore:
|
||||
}
|
||||
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"postprocessing_templates": templates_dict,
|
||||
}
|
||||
|
||||
@@ -124,31 +127,38 @@ class PostprocessingTemplateStore:
|
||||
def create_template(
|
||||
self,
|
||||
name: str,
|
||||
gamma: float = 2.2,
|
||||
saturation: float = 1.0,
|
||||
brightness: float = 1.0,
|
||||
smoothing: float = 0.3,
|
||||
filters: Optional[List[FilterInstance]] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> PostprocessingTemplate:
|
||||
"""Create a new postprocessing template.
|
||||
|
||||
Args:
|
||||
name: Template name (must be unique)
|
||||
filters: Ordered list of filter instances
|
||||
description: Optional description
|
||||
|
||||
Raises:
|
||||
ValueError: If template with same name exists
|
||||
ValueError: If template with same name exists or invalid filter_id
|
||||
"""
|
||||
for template in self._templates.values():
|
||||
if template.name == name:
|
||||
raise ValueError(f"Postprocessing template with name '{name}' already exists")
|
||||
|
||||
if filters is None:
|
||||
filters = []
|
||||
|
||||
# Validate filter IDs
|
||||
for fi in filters:
|
||||
if not FilterRegistry.is_registered(fi.filter_id):
|
||||
raise ValueError(f"Unknown filter type: '{fi.filter_id}'")
|
||||
|
||||
template_id = f"pp_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.utcnow()
|
||||
|
||||
template = PostprocessingTemplate(
|
||||
id=template_id,
|
||||
name=name,
|
||||
gamma=gamma,
|
||||
saturation=saturation,
|
||||
brightness=brightness,
|
||||
smoothing=smoothing,
|
||||
filters=filters,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
@@ -164,16 +174,13 @@ class PostprocessingTemplateStore:
|
||||
self,
|
||||
template_id: str,
|
||||
name: Optional[str] = None,
|
||||
gamma: Optional[float] = None,
|
||||
saturation: Optional[float] = None,
|
||||
brightness: Optional[float] = None,
|
||||
smoothing: Optional[float] = None,
|
||||
filters: Optional[List[FilterInstance]] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> PostprocessingTemplate:
|
||||
"""Update an existing postprocessing template.
|
||||
|
||||
Raises:
|
||||
ValueError: If template not found
|
||||
ValueError: If template not found or invalid filter_id
|
||||
"""
|
||||
if template_id not in self._templates:
|
||||
raise ValueError(f"Postprocessing template not found: {template_id}")
|
||||
@@ -182,14 +189,12 @@ class PostprocessingTemplateStore:
|
||||
|
||||
if name is not None:
|
||||
template.name = name
|
||||
if gamma is not None:
|
||||
template.gamma = gamma
|
||||
if saturation is not None:
|
||||
template.saturation = saturation
|
||||
if brightness is not None:
|
||||
template.brightness = brightness
|
||||
if smoothing is not None:
|
||||
template.smoothing = smoothing
|
||||
if filters is not None:
|
||||
# Validate filter IDs
|
||||
for fi in filters:
|
||||
if not FilterRegistry.is_registered(fi.filter_id):
|
||||
raise ValueError(f"Unknown filter type: '{fi.filter_id}'")
|
||||
template.filters = filters
|
||||
if description is not None:
|
||||
template.description = description
|
||||
|
||||
|
||||
Reference in New Issue
Block a user