From c4dce19b2eeb8fe0391428d363fc1c6889fc8dc7 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 23 Mar 2026 21:59:13 +0300 Subject: [PATCH] feat: add HSL shift, contrast, and temporal blur CSPT filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new processing template filters for both picture and color strip sources: - HSL Shift: hue rotation (0-359°) + lightness multiplier via vectorized RGB↔HSL - Contrast: LUT-based contrast adjustment around mid-gray (0.0-3.0) - Temporal Blur: exponential moving average across frames for motion smoothing --- .../wled_controller/core/filters/__init__.py | 3 + .../wled_controller/core/filters/contrast.py | 49 ++++++ .../wled_controller/core/filters/hsl_shift.py | 146 ++++++++++++++++++ .../core/filters/temporal_blur.py | 77 +++++++++ .../static/js/core/filter-list.ts | 3 + 5 files changed, 278 insertions(+) create mode 100644 server/src/wled_controller/core/filters/contrast.py create mode 100644 server/src/wled_controller/core/filters/hsl_shift.py create mode 100644 server/src/wled_controller/core/filters/temporal_blur.py diff --git a/server/src/wled_controller/core/filters/__init__.py b/server/src/wled_controller/core/filters/__init__.py index d911cdc..a395fb9 100644 --- a/server/src/wled_controller/core/filters/__init__.py +++ b/server/src/wled_controller/core/filters/__init__.py @@ -24,6 +24,9 @@ import wled_controller.core.filters.css_filter_template # noqa: F401 import wled_controller.core.filters.noise_gate # noqa: F401 import wled_controller.core.filters.palette_quantization # noqa: F401 import wled_controller.core.filters.reverse # noqa: F401 +import wled_controller.core.filters.hsl_shift # noqa: F401 +import wled_controller.core.filters.contrast # noqa: F401 +import wled_controller.core.filters.temporal_blur # noqa: F401 __all__ = [ "FilterOptionDef", diff --git a/server/src/wled_controller/core/filters/contrast.py b/server/src/wled_controller/core/filters/contrast.py new file mode 100644 index 0000000..5afff7f --- /dev/null +++ b/server/src/wled_controller/core/filters/contrast.py @@ -0,0 +1,49 @@ +"""Contrast 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 ContrastFilter(PostprocessingFilter): + """Adjusts contrast around mid-gray (128) using a lookup table. + + value < 1.0 = reduced contrast (washed out) + value = 1.0 = unchanged + value > 1.0 = increased contrast (punchier) + """ + + filter_id = "contrast" + filter_name = "Contrast" + + def __init__(self, options: Dict[str, Any]): + super().__init__(options) + value = self.options["value"] + # LUT: output = clamp(128 + (input - 128) * value, 0, 255) + lut = np.clip(128.0 + (np.arange(256, dtype=np.float32) - 128.0) * value, 0, 255) + self._lut = lut.astype(np.uint8) + + @classmethod + def get_options_schema(cls) -> List[FilterOptionDef]: + return [ + FilterOptionDef( + key="value", + label="Contrast", + option_type="float", + default=1.0, + min_value=0.0, + max_value=3.0, + step=0.05, + ), + ] + + def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: + if self.options["value"] == 1.0: + return None + image[:] = self._lut[image] + return None diff --git a/server/src/wled_controller/core/filters/hsl_shift.py b/server/src/wled_controller/core/filters/hsl_shift.py new file mode 100644 index 0000000..b237cdf --- /dev/null +++ b/server/src/wled_controller/core/filters/hsl_shift.py @@ -0,0 +1,146 @@ +"""HSL shift postprocessing filter — hue rotation and lightness adjustment.""" + +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 HslShiftFilter(PostprocessingFilter): + """Shifts hue and lightness of all pixels via integer math. + + Hue is rotated by a fixed offset (0-360 degrees). + Lightness is scaled by a multiplier (0.0 = black, 1.0 = unchanged, 2.0 = bright). + """ + + filter_id = "hsl_shift" + filter_name = "HSL Shift" + + def __init__(self, options: Dict[str, Any]): + super().__init__(options) + self._f32_buf: Optional[np.ndarray] = None + + @classmethod + def get_options_schema(cls) -> List[FilterOptionDef]: + return [ + FilterOptionDef( + key="hue", + label="Hue Shift", + option_type="int", + default=0, + min_value=0, + max_value=359, + step=1, + ), + FilterOptionDef( + key="lightness", + label="Lightness", + 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]: + hue_shift = self.options["hue"] + lightness = self.options["lightness"] + if hue_shift == 0 and lightness == 1.0: + return None + + h, w, c = image.shape + n = h * w + + # Flatten to (N, 3) float32 + flat = image.reshape(n, c) + if self._f32_buf is None or self._f32_buf.shape[0] != n: + self._f32_buf = np.empty((n, 3), dtype=np.float32) + buf = self._f32_buf + np.copyto(buf, flat, casting="unsafe") + buf *= (1.0 / 255.0) + + r = buf[:, 0] + g = buf[:, 1] + b = buf[:, 2] + + # RGB -> HSL (vectorized) + cmax = np.maximum(np.maximum(r, g), b) + cmin = np.minimum(np.minimum(r, g), b) + delta = cmax - cmin + light = (cmax + cmin) * 0.5 + + # Hue calculation + hue = np.zeros(n, dtype=np.float32) + mask_nonzero = delta > 1e-6 + if np.any(mask_nonzero): + d = delta[mask_nonzero] + rm = r[mask_nonzero] + gm = g[mask_nonzero] + bm = b[mask_nonzero] + cm = cmax[mask_nonzero] + + h_val = np.zeros_like(d) + mr = cm == rm + mg = (~mr) & (cm == gm) + mb = (~mr) & (~mg) + + h_val[mr] = ((gm[mr] - bm[mr]) / d[mr]) % 6.0 + h_val[mg] = (bm[mg] - rm[mg]) / d[mg] + 2.0 + h_val[mb] = (rm[mb] - gm[mb]) / d[mb] + 4.0 + h_val *= 60.0 + h_val[h_val < 0] += 360.0 + hue[mask_nonzero] = h_val + + # Saturation + sat = np.zeros(n, dtype=np.float32) + mask_sat = mask_nonzero & (light > 1e-6) & (light < 1.0 - 1e-6) + if np.any(mask_sat): + sat[mask_sat] = delta[mask_sat] / (1.0 - np.abs(2.0 * light[mask_sat] - 1.0)) + + # Apply shifts + if hue_shift != 0: + hue = (hue + hue_shift) % 360.0 + if lightness != 1.0: + light = np.clip(light * lightness, 0.0, 1.0) + + # HSL -> RGB (vectorized) + c_val = (1.0 - np.abs(2.0 * light - 1.0)) * sat + x_val = c_val * (1.0 - np.abs((hue / 60.0) % 2.0 - 1.0)) + m_val = light - c_val * 0.5 + + sector = (hue / 60.0).astype(np.int32) % 6 + + ro = np.empty(n, dtype=np.float32) + go = np.empty(n, dtype=np.float32) + bo = np.empty(n, dtype=np.float32) + + for s, rv, gv, bv in ( + (0, c_val, x_val, 0.0), + (1, x_val, c_val, 0.0), + (2, 0.0, c_val, x_val), + (3, 0.0, x_val, c_val), + (4, x_val, 0.0, c_val), + (5, c_val, 0.0, x_val), + ): + mask_s = sector == s + if not np.any(mask_s): + continue + rv_arr = rv[mask_s] if not isinstance(rv, float) else rv + gv_arr = gv[mask_s] if not isinstance(gv, float) else gv + bv_arr = bv[mask_s] if not isinstance(bv, float) else bv + ro[mask_s] = rv_arr + m_val[mask_s] + go[mask_s] = gv_arr + m_val[mask_s] + bo[mask_s] = bv_arr + m_val[mask_s] + + buf[:, 0] = ro + buf[:, 1] = go + buf[:, 2] = bo + np.clip(buf, 0.0, 1.0, out=buf) + buf *= 255.0 + np.copyto(flat, buf, casting="unsafe") + return None diff --git a/server/src/wled_controller/core/filters/temporal_blur.py b/server/src/wled_controller/core/filters/temporal_blur.py new file mode 100644 index 0000000..36d3c99 --- /dev/null +++ b/server/src/wled_controller/core/filters/temporal_blur.py @@ -0,0 +1,77 @@ +"""Temporal blur postprocessing filter — blends current frame with history.""" + +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 TemporalBlurFilter(PostprocessingFilter): + """Blends each frame with a running accumulator for motion smoothing. + + Uses exponential moving average: acc = (1 - strength) * frame + strength * acc + Higher strength = more blur / longer trails. + """ + + filter_id = "temporal_blur" + filter_name = "Temporal Blur" + + def __init__(self, options: Dict[str, Any]): + super().__init__(options) + self._acc: Optional[np.ndarray] = None + + @classmethod + def get_options_schema(cls) -> List[FilterOptionDef]: + return [ + FilterOptionDef( + key="strength", + label="Strength", + option_type="float", + default=0.5, + min_value=0.0, + max_value=0.95, + step=0.05, + ), + ] + + def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: + strength = self.options["strength"] + if strength == 0.0: + self._acc = None + return None + + h, w, c = image.shape + shape = (h, w, c) + + if self._acc is None or self._acc.shape != shape: + self._acc = image.astype(np.float32) + return None + + # EMA: acc = strength * acc + (1 - strength) * current + new_weight = 1.0 - strength + self._acc *= strength + self._acc += new_weight * image + np.copyto(image, self._acc, casting="unsafe") + return None + + def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]: + """Optimized strip path — avoids reshape overhead.""" + strength = self.options["strength"] + if strength == 0.0: + self._acc = None + return None + + shape = strip.shape + if self._acc is None or self._acc.shape != shape: + self._acc = strip.astype(np.float32) + return None + + new_weight = 1.0 - strength + self._acc *= strength + self._acc += new_weight * strip + np.copyto(strip, self._acc, casting="unsafe") + return None diff --git a/server/src/wled_controller/static/js/core/filter-list.ts b/server/src/wled_controller/static/js/core/filter-list.ts index 435a48c..e8c2f92 100644 --- a/server/src/wled_controller/static/js/core/filter-list.ts +++ b/server/src/wled_controller/static/js/core/filter-list.ts @@ -26,6 +26,9 @@ const _FILTER_ICONS = { noise_gate: P.volume2, palette_quantization: P.sparkles, css_filter_template: P.fileText, + hsl_shift: P.rainbow, + contrast: P.slidersHorizontal, + temporal_blur: P.timer, }; export { _FILTER_ICONS };