From 0723c5c68c1fecde78b745935f79dcefa39df5f5 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 24 Mar 2026 17:24:39 +0300 Subject: [PATCH] feat: add overlay, soft light, hard light, difference, exclusion blend modes to composite Integer-math implementations with pre-allocated scratch buffers. IconSelect picker updated with 10 blend modes. i18n for en/ru/zh. --- TODO-css-improvements.md | 2 +- .../core/processing/composite_stream.py | 118 ++++++++++++++++++ .../js/features/color-strips-composite.ts | 23 ++-- .../wled_controller/static/locales/en.json | 10 ++ .../wled_controller/static/locales/ru.json | 10 ++ .../wled_controller/static/locales/zh.json | 10 ++ 6 files changed, 162 insertions(+), 11 deletions(-) diff --git a/TODO-css-improvements.md b/TODO-css-improvements.md index 150ab13..5eccafa 100644 --- a/TODO-css-improvements.md +++ b/TODO-css-improvements.md @@ -70,7 +70,7 @@ Need to research HAOS communication options first (WebSocket API, REST API, MQTT ### `composite` - [ ] Allow nested composites (with cycle detection) -- [ ] More blend modes: overlay, soft light, hard light, difference, exclusion +- [x] More blend modes: overlay, soft light, hard light, difference, exclusion - [x] Per-layer LED range masks (optional start/end/reverse on each composite layer) ### `notification` diff --git a/server/src/wled_controller/core/processing/composite_stream.py b/server/src/wled_controller/core/processing/composite_stream.py index 6007f52..6d511d5 100644 --- a/server/src/wled_controller/core/processing/composite_stream.py +++ b/server/src/wled_controller/core/processing/composite_stream.py @@ -17,6 +17,11 @@ _BLEND_ADD = "add" _BLEND_MULTIPLY = "multiply" _BLEND_SCREEN = "screen" _BLEND_OVERRIDE = "override" +_BLEND_OVERLAY = "overlay" +_BLEND_SOFT_LIGHT = "soft_light" +_BLEND_HARD_LIGHT = "hard_light" +_BLEND_DIFFERENCE = "difference" +_BLEND_EXCLUSION = "exclusion" class CompositeColorStripStream(ColorStripStream): @@ -348,12 +353,125 @@ class CompositeColorStripStream(ColorStripStream): u16a >>= 8 np.copyto(out, u16a, casting="unsafe") + def _blend_overlay(self, bottom: np.ndarray, top: np.ndarray, alpha: int, + out: np.ndarray) -> None: + """Overlay blend: multiply darks, screen lights, then lerp with alpha. + + if bottom < 128: blended = 2*bottom*top >> 8 + else: blended = 255 - 2*(255-bottom)*(255-top) >> 8 + """ + u16a, u16b = self._u16_a, self._u16_b + np.copyto(u16a, bottom, casting="unsafe") + np.copyto(u16b, top, casting="unsafe") + # Multiply path: 2*b*t >> 8 + mul = (u16a * u16b) >> 7 # * 2 >> 8 == >> 7 + # Screen path: 255 - 2*(255-b)*(255-t) >> 8 + scr = 255 - (((255 - u16a) * (255 - u16b)) >> 7) + # Select based on bottom < 128 + mask = u16a < 128 + blended = np.where(mask, mul, scr) + np.clip(blended, 0, 255, out=blended) + # Lerp: result = (bottom * (256-a) + blended * a) >> 8 + np.copyto(u16a, bottom, casting="unsafe") + u16a *= (256 - alpha) + blended *= alpha + u16a += blended + u16a >>= 8 + np.copyto(out, u16a, casting="unsafe") + + def _blend_soft_light(self, bottom: np.ndarray, top: np.ndarray, alpha: int, + out: np.ndarray) -> None: + """Soft light blend (Pegtop formula), then lerp with alpha. + + blended = (1 - 2*t/255) * b*b/255 + 2*t*b/255 + """ + u16a, u16b = self._u16_a, self._u16_b + np.copyto(u16a, bottom, casting="unsafe") + np.copyto(u16b, top, casting="unsafe") + # term1 = (255 - 2*t) * b * b / (255*255) + # term2 = 2 * t * b / 255 + # Use intermediate 32-bit to avoid overflow + b32 = u16a.astype(np.uint32) + t32 = u16b.astype(np.uint32) + blended = ((255 - 2 * t32) * b32 * b32 + 2 * t32 * b32 * 255) // (255 * 255) + np.clip(blended, 0, 255, out=blended) + # Lerp + np.copyto(u16a, bottom, casting="unsafe") + u16a *= (256 - alpha) + blended_u16 = blended.astype(np.uint16) + blended_u16 *= alpha + u16a += blended_u16 + u16a >>= 8 + np.copyto(out, u16a, casting="unsafe") + + def _blend_hard_light(self, bottom: np.ndarray, top: np.ndarray, alpha: int, + out: np.ndarray) -> None: + """Hard light blend: overlay with top/bottom roles swapped. + + if top < 128: blended = 2*bottom*top >> 8 + else: blended = 255 - 2*(255-bottom)*(255-top) >> 8 + """ + u16a, u16b = self._u16_a, self._u16_b + np.copyto(u16a, bottom, casting="unsafe") + np.copyto(u16b, top, casting="unsafe") + mul = (u16a * u16b) >> 7 + scr = 255 - (((255 - u16a) * (255 - u16b)) >> 7) + # Select based on top < 128 (differs from overlay) + mask = u16b < 128 + blended = np.where(mask, mul, scr) + np.clip(blended, 0, 255, out=blended) + # Lerp + np.copyto(u16a, bottom, casting="unsafe") + u16a *= (256 - alpha) + blended *= alpha + u16a += blended + u16a >>= 8 + np.copyto(out, u16a, casting="unsafe") + + def _blend_difference(self, bottom: np.ndarray, top: np.ndarray, alpha: int, + out: np.ndarray) -> None: + """Difference blend: |bottom - top|, then lerp with alpha.""" + u16a, u16b = self._u16_a, self._u16_b + np.copyto(u16a, bottom, casting="unsafe") + np.copyto(u16b, top, casting="unsafe") + # abs diff using signed subtraction + blended = np.abs(u16a.astype(np.int16) - u16b.astype(np.int16)).astype(np.uint16) + # Lerp + np.copyto(u16a, bottom, casting="unsafe") + u16a *= (256 - alpha) + blended *= alpha + u16a += blended + u16a >>= 8 + np.copyto(out, u16a, casting="unsafe") + + def _blend_exclusion(self, bottom: np.ndarray, top: np.ndarray, alpha: int, + out: np.ndarray) -> None: + """Exclusion blend: bottom + top - 2*bottom*top/255, then lerp with alpha.""" + u16a, u16b = self._u16_a, self._u16_b + np.copyto(u16a, bottom, casting="unsafe") + np.copyto(u16b, top, casting="unsafe") + # blended = b + t - 2*b*t/255 + blended = u16a + u16b - ((u16a * u16b) >> 7) + np.clip(blended, 0, 255, out=blended) + # Lerp + np.copyto(u16a, bottom, casting="unsafe") + u16a *= (256 - alpha) + blended *= alpha + u16a += blended + u16a >>= 8 + np.copyto(out, u16a, casting="unsafe") + _BLEND_DISPATCH = { _BLEND_NORMAL: "_blend_normal", _BLEND_ADD: "_blend_add", _BLEND_MULTIPLY: "_blend_multiply", _BLEND_SCREEN: "_blend_screen", _BLEND_OVERRIDE: "_blend_override", + _BLEND_OVERLAY: "_blend_overlay", + _BLEND_SOFT_LIGHT: "_blend_soft_light", + _BLEND_HARD_LIGHT: "_blend_hard_light", + _BLEND_DIFFERENCE: "_blend_difference", + _BLEND_EXCLUSION: "_blend_exclusion", } # ── Processing loop ───────────────────────────────────────── diff --git a/server/src/wled_controller/static/js/features/color-strips-composite.ts b/server/src/wled_controller/static/js/features/color-strips-composite.ts index 2581dd2..8be1803 100644 --- a/server/src/wled_controller/static/js/features/color-strips-composite.ts +++ b/server/src/wled_controller/static/js/features/color-strips-composite.ts @@ -51,11 +51,16 @@ export function compositeDestroyEntitySelects() { function _getCompositeBlendItems() { return [ - { value: 'normal', icon: _icon(P.square), label: t('color_strip.composite.blend_mode.normal'), desc: t('color_strip.composite.blend_mode.normal.desc') }, - { value: 'add', icon: _icon(P.sun), label: t('color_strip.composite.blend_mode.add'), desc: t('color_strip.composite.blend_mode.add.desc') }, - { value: 'multiply', icon: _icon(P.eye), label: t('color_strip.composite.blend_mode.multiply'), desc: t('color_strip.composite.blend_mode.multiply.desc') }, - { value: 'screen', icon: _icon(P.monitor), label: t('color_strip.composite.blend_mode.screen'), desc: t('color_strip.composite.blend_mode.screen.desc') }, - { value: 'override', icon: _icon(P.zap), label: t('color_strip.composite.blend_mode.override'), desc: t('color_strip.composite.blend_mode.override.desc') }, + { value: 'normal', icon: _icon(P.square), label: t('color_strip.composite.blend_mode.normal'), desc: t('color_strip.composite.blend_mode.normal.desc') }, + { value: 'add', icon: _icon(P.sun), label: t('color_strip.composite.blend_mode.add'), desc: t('color_strip.composite.blend_mode.add.desc') }, + { value: 'multiply', icon: _icon(P.moon), label: t('color_strip.composite.blend_mode.multiply'), desc: t('color_strip.composite.blend_mode.multiply.desc') }, + { value: 'screen', icon: _icon(P.monitor), label: t('color_strip.composite.blend_mode.screen'), desc: t('color_strip.composite.blend_mode.screen.desc') }, + { value: 'overlay', icon: _icon(P.copy), label: t('color_strip.composite.blend_mode.overlay'), desc: t('color_strip.composite.blend_mode.overlay.desc') }, + { value: 'soft_light', icon: _icon(P.sunDim), label: t('color_strip.composite.blend_mode.soft_light'), desc: t('color_strip.composite.blend_mode.soft_light.desc') }, + { value: 'hard_light', icon: _icon(P.zap), label: t('color_strip.composite.blend_mode.hard_light'), desc: t('color_strip.composite.blend_mode.hard_light.desc') }, + { value: 'difference', icon: _icon(P.activity), label: t('color_strip.composite.blend_mode.difference'), desc: t('color_strip.composite.blend_mode.difference.desc') }, + { value: 'exclusion', icon: _icon(P.circleOff), label: t('color_strip.composite.blend_mode.exclusion'), desc: t('color_strip.composite.blend_mode.exclusion.desc') }, + { value: 'override', icon: _icon(P.eye), label: t('color_strip.composite.blend_mode.override'), desc: t('color_strip.composite.blend_mode.override.desc') }, ]; } @@ -127,11 +132,9 @@ export function compositeRenderList() {
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index f5f2773..37bb3c0 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -1183,6 +1183,16 @@ "color_strip.composite.blend_mode.screen": "Screen", "color_strip.composite.blend_mode.screen.desc": "Brightens, inverse of multiply", "color_strip.composite.blend_mode.override": "Override", + "color_strip.composite.blend_mode.overlay": "Overlay", + "color_strip.composite.blend_mode.overlay.desc": "Multiply darks, screen lights", + "color_strip.composite.blend_mode.soft_light": "Soft Light", + "color_strip.composite.blend_mode.soft_light.desc": "Gentle contrast adjustment", + "color_strip.composite.blend_mode.hard_light": "Hard Light", + "color_strip.composite.blend_mode.hard_light.desc": "Strong contrast, vivid colors", + "color_strip.composite.blend_mode.difference": "Difference", + "color_strip.composite.blend_mode.difference.desc": "Absolute color difference", + "color_strip.composite.blend_mode.exclusion": "Exclusion", + "color_strip.composite.blend_mode.exclusion.desc": "Like difference, lower contrast", "color_strip.composite.blend_mode.override.desc": "Black = transparent, bright = opaque", "color_strip.composite.opacity": "Opacity", "color_strip.composite.brightness": "Brightness", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 80a4fb9..8770009 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -1148,6 +1148,16 @@ "color_strip.composite.blend_mode.screen": "Экран", "color_strip.composite.blend_mode.screen.desc": "Осветляет, обратное умножение", "color_strip.composite.blend_mode.override": "Замена", + "color_strip.composite.blend_mode.overlay": "Наложение", + "color_strip.composite.blend_mode.overlay.desc": "Умножение тёмных, осветление светлых", + "color_strip.composite.blend_mode.soft_light": "Мягкий свет", + "color_strip.composite.blend_mode.soft_light.desc": "Мягкая коррекция контраста", + "color_strip.composite.blend_mode.hard_light": "Жёсткий свет", + "color_strip.composite.blend_mode.hard_light.desc": "Сильный контраст, яркие цвета", + "color_strip.composite.blend_mode.difference": "Разница", + "color_strip.composite.blend_mode.difference.desc": "Абсолютная разница цветов", + "color_strip.composite.blend_mode.exclusion": "Исключение", + "color_strip.composite.blend_mode.exclusion.desc": "Как разница, но мягче", "color_strip.composite.blend_mode.override.desc": "Чёрный = прозрачный, яркий = непрозрачный", "color_strip.composite.opacity": "Непрозрачность", "color_strip.composite.brightness": "Яркость", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index cfc87ab..01da593 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -1148,6 +1148,16 @@ "color_strip.composite.blend_mode.screen": "滤色", "color_strip.composite.blend_mode.screen.desc": "提亮,正片叠底的反转", "color_strip.composite.blend_mode.override": "覆盖", + "color_strip.composite.blend_mode.overlay": "叠加", + "color_strip.composite.blend_mode.overlay.desc": "暗部相乘,亮部滤色", + "color_strip.composite.blend_mode.soft_light": "柔光", + "color_strip.composite.blend_mode.soft_light.desc": "柔和对比度调整", + "color_strip.composite.blend_mode.hard_light": "强光", + "color_strip.composite.blend_mode.hard_light.desc": "强对比度,鲜艳色彩", + "color_strip.composite.blend_mode.difference": "差值", + "color_strip.composite.blend_mode.difference.desc": "绝对颜色差异", + "color_strip.composite.blend_mode.exclusion": "排除", + "color_strip.composite.blend_mode.exclusion.desc": "类似差值,对比度更低", "color_strip.composite.blend_mode.override.desc": "黑色=透明,亮色=不透明", "color_strip.composite.opacity": "不透明度", "color_strip.composite.brightness": "亮度",