Frame interpolation, FPS hot-update, timing metrics, KC brightness fixes

- CSS: add frame interpolation option — blends between consecutive captured
  frames on idle ticks so LED output runs at full target FPS even when
  capture rate is lower (e.g. capture 30fps, output 60fps)
- WledTargetProcessor: re-read stream.target_fps each loop tick so FPS
  changes to the CSS source take effect without restarting the target
- WledTargetProcessor: restore per-stage timing metrics on target card by
  pulling extract/map/smooth/total from CSS stream get_last_timing()
- TargetProcessingState schema: add missing timing_extract_ms,
  timing_map_leds_ms, timing_smooth_ms, timing_total_ms fields
- KC targets: add extraction FPS badge to target card props row
- KC targets: fix 500 error when changing brightness — update_fields now
  accepts (and ignores) WLED-specific kwargs
- KC targets: fix partial key_colors_settings update wiping pattern_template_id
  — update route merges only explicitly-set fields using model_dump(exclude_unset=True)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 20:29:22 +03:00
parent be37df4459
commit 55e25b8860
14 changed files with 138 additions and 6 deletions

View File

@@ -70,6 +70,7 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
color=getattr(source, "color", None),
stops=stops,
description=source.description,
frame_interpolation=getattr(source, "frame_interpolation", None),
overlay_active=overlay_active,
created_at=source.created_at,
updated_at=source.updated_at,
@@ -134,6 +135,7 @@ async def create_color_strip_source(
color=data.color,
stops=stops,
description=data.description,
frame_interpolation=data.frame_interpolation,
)
return _css_to_response(source)
@@ -190,6 +192,7 @@ async def update_color_strip_source(
color=data.color,
stops=stops,
description=data.description,
frame_interpolation=data.frame_interpolation,
)
# Hot-reload running stream (no restart needed for in-place param changes)

View File

@@ -214,7 +214,28 @@ async def update_target(
if not device:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
# Build KC settings with partial-update support: only apply fields that were
# explicitly provided in the request body, merging with the existing settings.
kc_settings = None
if data.key_colors_settings is not None:
incoming = data.key_colors_settings.model_dump(exclude_unset=True)
try:
existing_target = target_store.get_target(target_id)
except ValueError:
existing_target = None
if isinstance(existing_target, KeyColorsPictureTarget):
ex = existing_target.settings
merged = KeyColorsSettingsSchema(
fps=incoming.get("fps", ex.fps),
interpolation_mode=incoming.get("interpolation_mode", ex.interpolation_mode),
smoothing=incoming.get("smoothing", ex.smoothing),
pattern_template_id=incoming.get("pattern_template_id", ex.pattern_template_id),
brightness=incoming.get("brightness", ex.brightness),
)
kc_settings = _kc_schema_to_settings(merged)
else:
kc_settings = _kc_schema_to_settings(data.key_colors_settings)
# Update in store
target = target_store.update_target(

View File

@@ -40,6 +40,7 @@ class ColorStripSourceCreate(BaseModel):
# shared
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
frame_interpolation: bool = Field(default=False, description="Blend between consecutive captured frames for smoother output")
class ColorStripSourceUpdate(BaseModel):
@@ -62,6 +63,7 @@ class ColorStripSourceUpdate(BaseModel):
# shared
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames")
class ColorStripSourceResponse(BaseModel):
@@ -86,6 +88,7 @@ class ColorStripSourceResponse(BaseModel):
# shared
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
description: Optional[str] = Field(None, description="Description")
frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames")
overlay_active: bool = Field(False, description="Whether the screen overlay is currently active")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")

View File

@@ -116,6 +116,10 @@ class TargetProcessingState(BaseModel):
frames_keepalive: Optional[int] = Field(None, description="Keepalive frames sent during standby")
fps_current: Optional[int] = Field(None, description="Frames sent in the last second")
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
timing_extract_ms: Optional[float] = Field(None, description="Border pixel extraction time (ms)")
timing_map_leds_ms: Optional[float] = Field(None, description="LED color mapping time (ms)")
timing_smooth_ms: Optional[float] = Field(None, description="Temporal smoothing time (ms)")
timing_total_ms: Optional[float] = Field(None, description="Total processing time per frame (ms)")
timing_calc_colors_ms: Optional[float] = Field(None, description="Color calculation time (ms, KC targets)")
timing_broadcast_ms: Optional[float] = Field(None, description="WebSocket broadcast time (ms, KC targets)")
display_index: Optional[int] = Field(None, description="Current display index")

View File

@@ -151,6 +151,14 @@ class PictureColorStripStream(ColorStripStream):
self._colors_lock = threading.Lock()
self._previous_colors: Optional[np.ndarray] = None
# Frame interpolation state
self._frame_interpolation: bool = source.frame_interpolation
self._interp_from: Optional[np.ndarray] = None
self._interp_to: Optional[np.ndarray] = None
self._interp_start: float = 0.0
self._interp_duration: float = 1.0 / self._fps if self._fps > 0 else 1.0
self._last_capture_time: float = 0.0
self._running = False
self._thread: Optional[threading.Thread] = None
self._last_timing: dict = {}
@@ -194,6 +202,9 @@ class PictureColorStripStream(ColorStripStream):
self._thread = None
self._latest_colors = None
self._previous_colors = None
self._interp_from = None
self._interp_to = None
self._last_capture_time = 0.0
logger.info("PictureColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
@@ -236,6 +247,11 @@ class PictureColorStripStream(ColorStripStream):
)
self._previous_colors = None # Reset smoothing history on calibration change
if source.frame_interpolation != self._frame_interpolation:
self._frame_interpolation = source.frame_interpolation
self._interp_from = None
self._interp_to = None
logger.info("PictureColorStripStream params updated in-place")
def _processing_loop(self) -> None:
@@ -251,10 +267,39 @@ class PictureColorStripStream(ColorStripStream):
frame = self._live_stream.get_latest_frame()
if frame is None or frame is cached_frame:
if (
frame is not None
and self._frame_interpolation
and self._interp_from is not None
and self._interp_to is not None
):
t = min(1.0, (loop_start - self._interp_start) / self._interp_duration)
alpha = int(t * 256)
led_colors = (
(256 - alpha) * self._interp_from.astype(np.uint16)
+ alpha * self._interp_to.astype(np.uint16)
) >> 8
led_colors = led_colors.astype(np.uint8)
if self._saturation != 1.0:
led_colors = _apply_saturation(led_colors, self._saturation)
if self._gamma != 1.0:
led_colors = self._gamma_lut[led_colors]
if self._brightness != 1.0:
led_colors = np.clip(
led_colors.astype(np.float32) * self._brightness, 0, 255
).astype(np.uint8)
with self._colors_lock:
self._latest_colors = led_colors
elapsed = time.perf_counter() - loop_start
time.sleep(max(frame_time - elapsed, 0.001))
continue
interval = (
loop_start - self._last_capture_time
if self._last_capture_time > 0
else frame_time
)
self._last_capture_time = loop_start
cached_frame = frame
t0 = time.perf_counter()
@@ -275,6 +320,13 @@ class PictureColorStripStream(ColorStripStream):
else:
led_colors = led_colors[:target_count]
# Update interpolation buffers (raw colors, before corrections)
if self._frame_interpolation:
self._interp_from = self._interp_to
self._interp_to = led_colors.copy()
self._interp_start = loop_start
self._interp_duration = max(interval, 0.001)
# Temporal smoothing
smoothing = self._smoothing
if (

View File

@@ -234,6 +234,20 @@ class WledTargetProcessor(TargetProcessor):
metrics = self._metrics
fps_target = self._color_strip_stream.target_fps if self._color_strip_stream else None
# Pull per-stage timing from the CSS stream (runs in a background thread)
css_timing: dict = {}
if self._is_running and self._color_strip_stream is not None:
css_timing = self._color_strip_stream.get_last_timing()
send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None
extract_ms = round(css_timing.get("extract_ms", 0), 1) if css_timing else None
map_ms = round(css_timing.get("map_leds_ms", 0), 1) if css_timing else None
smooth_ms = round(css_timing.get("smooth_ms", 0), 1) if css_timing else None
total_ms = (
round(css_timing.get("total_ms", 0) + metrics.timing_send_ms, 1)
if css_timing else None
)
return {
"target_id": self._target_id,
"device_id": self._device_id,
@@ -245,7 +259,11 @@ class WledTargetProcessor(TargetProcessor):
"frames_skipped": metrics.frames_skipped if self._is_running else None,
"frames_keepalive": metrics.frames_keepalive if self._is_running else None,
"fps_current": metrics.fps_current if self._is_running else None,
"timing_send_ms": round(metrics.timing_send_ms, 1) if self._is_running else None,
"timing_send_ms": send_ms,
"timing_extract_ms": extract_ms,
"timing_map_leds_ms": map_ms,
"timing_smooth_ms": smooth_ms,
"timing_total_ms": total_ms,
"display_index": self._resolved_display_index,
"overlay_active": self._overlay_active,
"last_update": metrics.last_update,
@@ -342,8 +360,6 @@ class WledTargetProcessor(TargetProcessor):
async def _processing_loop(self) -> None:
"""Main processing loop — poll ColorStripStream → apply brightness → send."""
stream = self._color_strip_stream
target_fps = self._resolved_target_fps or 30
frame_time = 1.0 / target_fps
standby_interval = self._standby_interval
fps_samples: collections.deque = collections.deque(maxlen=10)
@@ -355,12 +371,15 @@ class WledTargetProcessor(TargetProcessor):
logger.info(
f"Processing loop started for target {self._target_id} "
f"(display={self._resolved_display_index}, fps={target_fps})"
f"(display={self._resolved_display_index}, fps={self._resolved_target_fps})"
)
try:
while self._is_running:
loop_start = now = time.time()
# Re-read target_fps each tick so hot-updates to the CSS source take effect
target_fps = stream.target_fps if stream.target_fps > 0 else 30
frame_time = 1.0 / target_fps
# Re-fetch device info for runtime changes (test mode, brightness)
device_info = self._ctx.get_device_info(self._device_id)

View File

@@ -25,6 +25,7 @@ class CSSEditorModal extends Modal {
saturation: document.getElementById('css-editor-saturation').value,
gamma: document.getElementById('css-editor-gamma').value,
color: document.getElementById('css-editor-color').value,
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
led_count: (type === 'static' || type === 'gradient') ? '0' : document.getElementById('css-editor-led-count').value,
gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]',
};
@@ -194,6 +195,8 @@ export async function showCSSEditor(cssId = null) {
const gamma = css.gamma ?? 1.0;
document.getElementById('css-editor-gamma').value = gamma;
document.getElementById('css-editor-gamma-value').textContent = parseFloat(gamma).toFixed(2);
document.getElementById('css-editor-frame-interpolation').checked = css.frame_interpolation || false;
}
document.getElementById('css-editor-led-count').value = css.led_count ?? 0;
@@ -214,6 +217,7 @@ export async function showCSSEditor(cssId = null) {
document.getElementById('css-editor-saturation-value').textContent = '1.00';
document.getElementById('css-editor-gamma').value = 1.0;
document.getElementById('css-editor-gamma-value').textContent = '1.00';
document.getElementById('css-editor-frame-interpolation').checked = false;
document.getElementById('css-editor-color').value = '#ffffff';
document.getElementById('css-editor-led-count').value = 0;
document.getElementById('css-editor-title').textContent = t('color_strip.add');
@@ -281,6 +285,7 @@ export async function saveCSSEditor() {
brightness: parseFloat(document.getElementById('css-editor-brightness').value),
saturation: parseFloat(document.getElementById('css-editor-saturation').value),
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
};
if (!cssId) payload.source_type = 'picture';

View File

@@ -75,6 +75,7 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap) {
<span class="stream-card-prop" title="${t('kc.source')}">📺 ${escapeHtml(sourceName)}</span>
<span class="stream-card-prop" title="${t('kc.pattern_template')}">📄 ${escapeHtml(patternName)}</span>
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
<span class="stream-card-prop" title="${t('kc.fps')}">⚡ ${kcSettings.fps ?? 10} fps</span>
</div>
<div class="brightness-control" data-kc-brightness-wrap="${target.id}">
<input type="range" class="brightness-slider" min="0" max="255"

View File

@@ -552,6 +552,8 @@
"color_strip.interpolation.dominant": "Dominant",
"color_strip.smoothing": "Smoothing:",
"color_strip.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
"color_strip.frame_interpolation": "Frame Interpolation:",
"color_strip.frame_interpolation.hint": "Blends between consecutive captured frames to produce output at the full target FPS even when capture rate is lower. Reduces visible stepping on slow ambient transitions.",
"color_strip.color_corrections": "Color Corrections",
"color_strip.brightness": "Brightness:",
"color_strip.brightness.hint": "Output brightness multiplier (0=off, 1=unchanged, 2=double). Applied after color extraction.",

View File

@@ -552,6 +552,8 @@
"color_strip.interpolation.dominant": "Доминирующий",
"color_strip.smoothing": "Сглаживание:",
"color_strip.smoothing.hint": "Временное смешивание кадров (0=без смешивания, 1=полное). Уменьшает мерцание.",
"color_strip.frame_interpolation": "Интерполяция кадров:",
"color_strip.frame_interpolation.hint": "Смешивает последовательные захваченные кадры для вывода на полной целевой частоте кадров, даже если скорость захвата ниже. Уменьшает заметные ступеньки при плавных переходах.",
"color_strip.color_corrections": "Цветокоррекция",
"color_strip.brightness": "Яркость:",
"color_strip.brightness.hint": "Множитель яркости (0=выкл, 1=без изменений, 2=двойная). Применяется после извлечения цвета.",

View File

@@ -121,6 +121,7 @@ class ColorStripSource:
interpolation_mode=data.get("interpolation_mode") or "average",
calibration=calibration,
led_count=data.get("led_count") or 0,
frame_interpolation=bool(data.get("frame_interpolation", False)),
)
@@ -143,6 +144,7 @@ class PictureColorStripSource(ColorStripSource):
default_factory=lambda: CalibrationConfig(layout="clockwise", start_position="bottom_left")
)
led_count: int = 0 # explicit LED count; 0 = auto (derived from calibration)
frame_interpolation: bool = False # blend between consecutive captured frames
def to_dict(self) -> dict:
d = super().to_dict()
@@ -155,6 +157,7 @@ class PictureColorStripSource(ColorStripSource):
d["interpolation_mode"] = self.interpolation_mode
d["calibration"] = calibration_to_dict(self.calibration)
d["led_count"] = self.led_count
d["frame_interpolation"] = self.frame_interpolation
return d

View File

@@ -104,6 +104,7 @@ class ColorStripStore:
color: Optional[list] = None,
stops: Optional[list] = None,
description: Optional[str] = None,
frame_interpolation: bool = False,
) -> ColorStripSource:
"""Create a new color strip source.
@@ -165,6 +166,7 @@ class ColorStripStore:
interpolation_mode=interpolation_mode,
calibration=calibration,
led_count=led_count,
frame_interpolation=frame_interpolation,
)
self._sources[source_id] = source
@@ -189,6 +191,7 @@ class ColorStripStore:
color: Optional[list] = None,
stops: Optional[list] = None,
description: Optional[str] = None,
frame_interpolation: Optional[bool] = None,
) -> ColorStripSource:
"""Update an existing color strip source.
@@ -228,6 +231,8 @@ class ColorStripStore:
source.calibration = calibration
if led_count is not None:
source.led_count = led_count
if frame_interpolation is not None:
source.frame_interpolation = frame_interpolation
elif isinstance(source, StaticColorStripSource):
if color is not None:
if isinstance(color, list) and len(color) == 3:

View File

@@ -90,7 +90,10 @@ class KeyColorsPictureTarget(PictureTarget):
manager.update_target_source(self.id, self.picture_source_id)
def update_fields(self, *, name=None, device_id=None, picture_source_id=None,
settings=None, key_colors_settings=None, description=None) -> None:
settings=None, key_colors_settings=None, description=None,
# WledPictureTarget-specific params — accepted but ignored:
color_strip_source_id=None, standby_interval=None,
state_check_interval=None) -> None:
"""Apply mutable field updates for KC targets."""
super().update_fields(name=name, description=description)
if picture_source_id is not None:

View File

@@ -78,6 +78,15 @@
<input type="range" id="css-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('css-editor-smoothing-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-frame-interpolation" data-i18n="color_strip.frame_interpolation">Frame Interpolation:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.frame_interpolation.hint">Blends between consecutive captured frames to produce output at the full target FPS even when capture rate is lower. Reduces visible stepping on slow ambient transitions.</small>
<input type="checkbox" id="css-editor-frame-interpolation">
</div>
<details class="form-collapse">
<summary data-i18n="color_strip.color_corrections">Color Corrections</summary>
<div class="form-collapse-body">