diff --git a/TODO.md b/TODO.md index 44cb450..d4822ed 100644 --- a/TODO.md +++ b/TODO.md @@ -4,11 +4,11 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort ## Processing Pipeline -- [ ] `P1` **Noise gate** — Suppress small color changes below threshold, preventing shimmer on static content -- [ ] `P1` **Color temperature filter** — Warm/cool shift separate from hue shift (circadian/mood) +- [x] `P1` **Noise gate** — Suppress small color changes below threshold, preventing shimmer on static content +- [x] `P1` **Color temperature filter** — Already covered by existing Color Correction filter (2000-10000K) - [ ] `P1` **Zone grouping** — Merge adjacent LEDs into logical groups sharing one averaged color -- [ ] `P2` **Palette quantization** — Force output to match a user-defined palette -- [ ] `P2` **Drag-and-drop filter ordering** — Reorder postprocessing filter chains visually +- [x] `P2` **Palette quantization** — Force output to match a user-defined palette (preset or custom hex) +- [x] `P2` **Drag-and-drop filter ordering** — Reorder postprocessing filter chains visually - [ ] `P3` **Transition effects** — Crossfade, wipe, or dissolve between sources/profiles instead of instant cut ## Output Targets diff --git a/server/src/wled_controller/api/schemas/filters.py b/server/src/wled_controller/api/schemas/filters.py index bab5045..e06552f 100644 --- a/server/src/wled_controller/api/schemas/filters.py +++ b/server/src/wled_controller/api/schemas/filters.py @@ -17,12 +17,13 @@ class FilterOptionDefSchema(BaseModel): key: str = Field(description="Option key") label: str = Field(description="Display label") - type: str = Field(description="Option type (float, int, bool, or select)") + type: str = Field(description="Option type (float, int, bool, select, or string)") 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") choices: Optional[List[Dict[str, str]]] = Field(default=None, description="Available choices for select type") + max_length: Optional[int] = Field(default=None, description="Maximum string length for string type") class FilterTypeResponse(BaseModel): diff --git a/server/src/wled_controller/core/filters/__init__.py b/server/src/wled_controller/core/filters/__init__.py index bdef2fe..93eeec7 100644 --- a/server/src/wled_controller/core/filters/__init__.py +++ b/server/src/wled_controller/core/filters/__init__.py @@ -20,6 +20,8 @@ import wled_controller.core.filters.flip # noqa: F401 import wled_controller.core.filters.color_correction # noqa: F401 import wled_controller.core.filters.frame_interpolation # noqa: F401 import wled_controller.core.filters.filter_template # noqa: F401 +import wled_controller.core.filters.noise_gate # noqa: F401 +import wled_controller.core.filters.palette_quantization # noqa: F401 __all__ = [ "FilterOptionDef", diff --git a/server/src/wled_controller/core/filters/base.py b/server/src/wled_controller/core/filters/base.py index 2d33010..9c95220 100644 --- a/server/src/wled_controller/core/filters/base.py +++ b/server/src/wled_controller/core/filters/base.py @@ -13,12 +13,13 @@ class FilterOptionDef: key: str label: str - option_type: str # "float" | "int" | "bool" | "select" + option_type: str # "float" | "int" | "bool" | "select" | "string" default: Any min_value: Any max_value: Any step: Any choices: Optional[List[Dict[str, str]]] = None # for "select": [{value, label}] + max_length: Optional[int] = None # for "string" type def to_dict(self) -> dict: d = { @@ -32,6 +33,8 @@ class FilterOptionDef: } if self.choices is not None: d["choices"] = self.choices + if self.max_length is not None: + d["max_length"] = self.max_length return d @@ -86,10 +89,12 @@ class PostprocessingFilter(ABC): val = bool(raw) if not isinstance(raw, bool) else raw elif opt_def.option_type == "select": val = str(raw) if raw is not None else opt_def.default + elif opt_def.option_type == "string": + val = str(raw) if raw is not None else opt_def.default else: val = raw - # Clamp to range (skip for bools and selects) - if opt_def.option_type not in ("bool", "select"): + # Clamp to range (skip for non-numeric types) + if opt_def.option_type not in ("bool", "select", "string"): 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: diff --git a/server/src/wled_controller/core/filters/noise_gate.py b/server/src/wled_controller/core/filters/noise_gate.py new file mode 100644 index 0000000..cbddee1 --- /dev/null +++ b/server/src/wled_controller/core/filters/noise_gate.py @@ -0,0 +1,82 @@ +"""Noise gate postprocessing filter.""" + +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 NoiseGateFilter(PostprocessingFilter): + """Suppresses small per-pixel color changes below threshold. + + Prevents shimmer / flicker on static or near-static content by keeping + the previous pixel value when the max per-channel delta is below the + configured threshold. + """ + + filter_id = "noise_gate" + filter_name = "Noise Gate" + + def __init__(self, options: Dict[str, Any]): + super().__init__(options) + self._prev_frame: Optional[np.ndarray] = None + # Pre-allocated scratch buffers (avoid per-frame allocation) + self._i16_cur: Optional[np.ndarray] = None + self._i16_prev: Optional[np.ndarray] = None + self._buf_shape: Optional[tuple] = None + + @classmethod + def get_options_schema(cls) -> List[FilterOptionDef]: + return [ + FilterOptionDef( + key="threshold", + label="Threshold", + option_type="int", + default=10, + min_value=1, + max_value=50, + step=1, + ), + ] + + def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: + threshold = self.options["threshold"] + h, w, c = image.shape + shape = (h, w, c) + + if self._prev_frame is None or self._prev_frame.shape != shape: + self._prev_frame = image.copy() + self._i16_cur = np.empty(shape, dtype=np.int16) + self._i16_prev = np.empty(shape, dtype=np.int16) + self._buf_shape = shape + return None + + # Reallocate scratch if shape changed + if self._buf_shape != shape: + self._i16_cur = np.empty(shape, dtype=np.int16) + self._i16_prev = np.empty(shape, dtype=np.int16) + self._buf_shape = shape + + # Compute per-channel absolute delta using int16 to avoid uint8 underflow + np.copyto(self._i16_cur, image, casting="unsafe") + np.copyto(self._i16_prev, self._prev_frame, casting="unsafe") + self._i16_cur -= self._i16_prev + np.abs(self._i16_cur, out=self._i16_cur) + + # Per-pixel: max channel delta + max_delta = self._i16_cur.max(axis=2) # shape (h, w) + + # Boolean mask: True where pixel is stable (keep previous) + mask = max_delta < threshold + mask_3d = mask[:, :, np.newaxis] # broadcast to (h, w, 1) + + # Apply: keep previous where stable, otherwise take new + np.where(mask_3d, self._prev_frame, image, out=image) + + # Update stored frame + np.copyto(self._prev_frame, image) + return None diff --git a/server/src/wled_controller/core/filters/palette_quantization.py b/server/src/wled_controller/core/filters/palette_quantization.py new file mode 100644 index 0000000..7833947 --- /dev/null +++ b/server/src/wled_controller/core/filters/palette_quantization.py @@ -0,0 +1,118 @@ +"""Palette quantization postprocessing filter.""" + +import re +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 + +_PRESETS = { + "warm": "#FF4500,#FF6347,#FF8C00,#FFD700,#FFA500,#DC143C", + "cool": "#0000FF,#1E90FF,#00BFFF,#00CED1,#4682B4,#6A5ACD", + "neon": "#FF00FF,#00FFFF,#00FF00,#FFFF00,#FF0000,#0000FF", + "pastel": "#FFB3BA,#FFDFBA,#FFFFBA,#BAFFC9,#BAE1FF,#E8BAFF", + "monochrome": "#000000,#404040,#808080,#C0C0C0,#FFFFFF", + "rgb": "#FF0000,#00FF00,#0000FF", + "rainbow": "#FF0000,#FF7F00,#FFFF00,#00FF00,#0000FF,#4B0082,#8F00FF", + "earth": "#8B4513,#A0522D,#D2691E,#228B22,#006400,#2F4F4F", +} + +_HEX_RE = re.compile(r"^#?([0-9a-fA-F]{6})$") + + +def _parse_hex_colors(color_str: str) -> Optional[np.ndarray]: + """Parse comma-separated hex colors into (N, 3) uint8 array.""" + colors = [] + for part in color_str.split(","): + part = part.strip() + m = _HEX_RE.match(part) + if m: + h = m.group(1) + colors.append((int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))) + if not colors: + return None + return np.array(colors, dtype=np.uint8) + + +@FilterRegistry.register +class PaletteQuantizationFilter(PostprocessingFilter): + """Maps every pixel to the nearest color in a palette.""" + + filter_id = "palette_quantization" + filter_name = "Palette Quantization" + + def __init__(self, options: Dict[str, Any]): + super().__init__(options) + self._palette: Optional[np.ndarray] = None + self._rebuild_palette() + + def _rebuild_palette(self): + mode = self.options.get("mode", "preset") + if mode == "custom": + color_str = self.options.get("colors", "") + self._palette = _parse_hex_colors(color_str) + else: + preset_name = self.options.get("preset", "rainbow") + preset_str = _PRESETS.get(preset_name, _PRESETS["rainbow"]) + self._palette = _parse_hex_colors(preset_str) + + @classmethod + def get_options_schema(cls) -> List[FilterOptionDef]: + return [ + FilterOptionDef( + key="mode", + label="Mode", + option_type="select", + default="preset", + min_value=None, + max_value=None, + step=None, + choices=[ + {"value": "preset", "label": "Preset"}, + {"value": "custom", "label": "Custom"}, + ], + ), + FilterOptionDef( + key="preset", + label="Preset Palette", + option_type="select", + default="rainbow", + min_value=None, + max_value=None, + step=None, + choices=[{"value": k, "label": k.capitalize()} for k in _PRESETS], + ), + FilterOptionDef( + key="colors", + label="Custom Colors (hex, comma-separated)", + option_type="string", + default="#FF0000,#00FF00,#0000FF", + min_value=None, + max_value=None, + step=None, + max_length=500, + ), + ] + + def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: + if self._palette is None or len(self._palette) == 0: + return None + + palette = self._palette.astype(np.int16) + flat = image.reshape(-1, 3) + total = flat.shape[0] + chunk_size = 32768 + + for start in range(0, total, chunk_size): + end = min(start + chunk_size, total) + chunk = flat[start:end].astype(np.int16) + # Squared Euclidean distance: (C, 1, 3) - (1, N, 3) -> (C, N, 3) -> sum -> (C, N) + diff = chunk[:, np.newaxis, :] - palette[np.newaxis, :, :] + dist_sq = (diff * diff).sum(axis=2) + nearest = dist_sq.argmin(axis=1) + flat[start:end] = self._palette[nearest] + + return None diff --git a/server/src/wled_controller/static/css/streams.css b/server/src/wled_controller/static/css/streams.css index 085a086..fb741ad 100644 --- a/server/src/wled_controller/static/css/streams.css +++ b/server/src/wled_controller/static/css/streams.css @@ -345,6 +345,17 @@ width: 100%; } +.pp-filter-text-input { + width: 100%; + padding: 4px 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--card-bg); + color: var(--text-primary); + font-size: 12px; + font-family: monospace; +} + .pp-filter-option-bool label { justify-content: space-between; gap: 8px; @@ -392,6 +403,61 @@ order: 0; } +/* ── PP filter drag-and-drop ── */ + +.pp-filter-drag-handle { + cursor: grab; + opacity: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1; + padding: 2px 4px; + border-radius: 3px; + transition: opacity 0.2s ease; + user-select: none; + touch-action: none; + flex-shrink: 0; +} + +.pp-filter-card:hover .pp-filter-drag-handle { + opacity: 0.5; +} + +.pp-filter-drag-handle:hover { + opacity: 1 !important; + background: var(--border-color); +} + +.pp-filter-drag-handle:active { + cursor: grabbing; +} + +.pp-filter-drag-clone { + position: fixed; + z-index: 9999; + pointer-events: none; + opacity: 0.92; + transform: scale(1.02); + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25); + will-change: top; +} + +.pp-filter-drag-placeholder { + border: 2px dashed var(--primary-color); + border-radius: 8px; + background: rgba(33, 150, 243, 0.04); + min-height: 42px; + transition: height 0.15s ease; +} + +body.pp-filter-dragging .pp-filter-card { + transition: none !important; +} + +body.pp-filter-dragging .pp-filter-drag-handle { + opacity: 0 !important; +} + .pp-add-filter-row { display: flex; gap: 8px; diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index 20514fd..ba03c6b 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -1942,12 +1942,11 @@ export function renderModalFilterList() { html += `
`; + } else if (opt.type === 'string') { + const maxLen = opt.max_length || 500; + html += `