feat: add HSL shift, contrast, and temporal blur CSPT filters
Some checks failed
Lint & Test / test (push) Failing after 29s

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
This commit is contained in:
2026-03-23 21:59:13 +03:00
parent b27ac8783b
commit c4dce19b2e
5 changed files with 278 additions and 0 deletions

View File

@@ -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.noise_gate # noqa: F401
import wled_controller.core.filters.palette_quantization # noqa: F401 import wled_controller.core.filters.palette_quantization # noqa: F401
import wled_controller.core.filters.reverse # 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__ = [ __all__ = [
"FilterOptionDef", "FilterOptionDef",

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -26,6 +26,9 @@ const _FILTER_ICONS = {
noise_gate: P.volume2, noise_gate: P.volume2,
palette_quantization: P.sparkles, palette_quantization: P.sparkles,
css_filter_template: P.fileText, css_filter_template: P.fileText,
hsl_shift: P.rainbow,
contrast: P.slidersHorizontal,
temporal_blur: P.timer,
}; };
export { _FILTER_ICONS }; export { _FILTER_ICONS };