feat: add HSL shift, contrast, and temporal blur CSPT filters
Some checks failed
Lint & Test / test (push) Failing after 29s
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:
@@ -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",
|
||||
|
||||
49
server/src/wled_controller/core/filters/contrast.py
Normal file
49
server/src/wled_controller/core/filters/contrast.py
Normal 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
|
||||
146
server/src/wled_controller/core/filters/hsl_shift.py
Normal file
146
server/src/wled_controller/core/filters/hsl_shift.py
Normal 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
|
||||
77
server/src/wled_controller/core/filters/temporal_blur.py
Normal file
77
server/src/wled_controller/core/filters/temporal_blur.py
Normal 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
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user