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

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