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:
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user