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.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",
|
||||||
|
|||||||
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,
|
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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user