Add frame-change detection, keepalive, current FPS, and compact metrics UI
Skip redundant processing/DDP sends when screen is static using object identity comparison. Add configurable standby interval to periodically resend last frame keeping WLED in live mode. Track frames skipped, keepalive count, and current FPS (rolling 1-second send count). Always use DDP regardless of LED count. Compact metrics grid with label-value rows and remove Skipped from UI display. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""Processing manager for coordinating screen capture and WLED updates."""
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
@@ -33,15 +34,16 @@ logger = get_logger(__name__)
|
||||
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
|
||||
|
||||
|
||||
def _process_frame(live_stream, border_width, pixel_mapper, previous_colors, smoothing):
|
||||
def _process_frame(capture, border_width, pixel_mapper, previous_colors, smoothing):
|
||||
"""All CPU-bound work for one WLED frame (runs in thread pool).
|
||||
|
||||
Includes get_latest_frame() because ProcessedLiveStream may apply
|
||||
filters (image copy + processing) which should not block the event loop.
|
||||
Args:
|
||||
capture: ScreenCapture from live_stream.get_latest_frame()
|
||||
border_width: Border pixel width for extraction
|
||||
pixel_mapper: PixelMapper for LED mapping
|
||||
previous_colors: Previous frame colors for smoothing
|
||||
smoothing: Smoothing factor (0-1)
|
||||
"""
|
||||
capture = live_stream.get_latest_frame()
|
||||
if capture is None:
|
||||
return None
|
||||
border_pixels = extract_border_pixels(capture, border_width)
|
||||
led_colors = pixel_mapper.map_border_to_leds(border_pixels)
|
||||
if previous_colors and smoothing > 0:
|
||||
@@ -49,15 +51,16 @@ def _process_frame(live_stream, border_width, pixel_mapper, previous_colors, smo
|
||||
return led_colors
|
||||
|
||||
|
||||
def _process_kc_frame(live_stream, rectangles, calc_fn, previous_colors, smoothing):
|
||||
def _process_kc_frame(capture, rectangles, calc_fn, previous_colors, smoothing):
|
||||
"""All CPU-bound work for one KC frame (runs in thread pool).
|
||||
|
||||
Includes get_latest_frame() because ProcessedLiveStream may apply
|
||||
filters which should not block the event loop.
|
||||
Args:
|
||||
capture: ScreenCapture from live_stream.get_latest_frame()
|
||||
rectangles: List of pattern rectangles to extract colors from
|
||||
calc_fn: Color calculation function (average/median/dominant)
|
||||
previous_colors: Previous frame colors for smoothing
|
||||
smoothing: Smoothing factor (0-1)
|
||||
"""
|
||||
capture = live_stream.get_latest_frame()
|
||||
if capture is None:
|
||||
return None
|
||||
img = capture.image
|
||||
h, w = img.shape[:2]
|
||||
colors = {}
|
||||
@@ -110,6 +113,7 @@ class ProcessingSettings:
|
||||
saturation: float = 1.0
|
||||
smoothing: float = 0.3
|
||||
interpolation_mode: str = "average"
|
||||
standby_interval: float = 1.0 # seconds between keepalive sends when screen is static
|
||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
||||
|
||||
|
||||
@@ -133,12 +137,15 @@ class ProcessingMetrics:
|
||||
"""Metrics for processing performance."""
|
||||
|
||||
frames_processed: int = 0
|
||||
frames_skipped: int = 0
|
||||
frames_keepalive: int = 0
|
||||
errors_count: int = 0
|
||||
last_error: Optional[str] = None
|
||||
last_update: Optional[datetime] = None
|
||||
start_time: Optional[datetime] = None
|
||||
fps_actual: float = 0.0
|
||||
fps_potential: float = 0.0
|
||||
fps_current: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -558,14 +565,11 @@ class ProcessorManager:
|
||||
logger.warning(f"Could not snapshot WLED state: {e}")
|
||||
state.wled_state_before = None
|
||||
|
||||
# Connect to WLED device
|
||||
# Connect to WLED device (always use DDP for low-latency UDP streaming)
|
||||
try:
|
||||
use_ddp = state.led_count > 500
|
||||
state.wled_client = WLEDClient(state.device_url, use_ddp=use_ddp)
|
||||
state.wled_client = WLEDClient(state.device_url, use_ddp=True)
|
||||
await state.wled_client.connect()
|
||||
|
||||
if use_ddp:
|
||||
logger.info(f"Target {target_id} using DDP protocol ({state.led_count} LEDs)")
|
||||
logger.info(f"Target {target_id} using DDP protocol ({state.led_count} LEDs)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to WLED device for target {target_id}: {e}")
|
||||
raise RuntimeError(f"Failed to connect to WLED device: {e}")
|
||||
@@ -672,8 +676,12 @@ class ProcessorManager:
|
||||
)
|
||||
|
||||
frame_time = 1.0 / target_fps
|
||||
standby_interval = settings.standby_interval
|
||||
fps_samples = []
|
||||
prev_frame_time_stamp = time.time()
|
||||
prev_capture = None # Track previous ScreenCapture for change detection
|
||||
last_send_time = 0.0 # Timestamp of last DDP send (for keepalive)
|
||||
send_timestamps: collections.deque = collections.deque() # for fps_current
|
||||
|
||||
# Check if the device has test mode active — skip capture while in test mode
|
||||
device_state = self._devices.get(state.device_id)
|
||||
@@ -688,19 +696,47 @@ class ProcessorManager:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Batch all CPU work (frame read + processing) in a single thread call.
|
||||
led_colors = await asyncio.to_thread(
|
||||
_process_frame,
|
||||
state.live_stream, border_width,
|
||||
state.pixel_mapper, state.previous_colors, smoothing,
|
||||
)
|
||||
# get_latest_frame() is a fast lock read (ProcessedLiveStream
|
||||
# pre-computes in a background thread). Safe on asyncio thread.
|
||||
capture = state.live_stream.get_latest_frame()
|
||||
|
||||
if led_colors is None:
|
||||
if capture is None:
|
||||
if state.metrics.frames_processed == 0:
|
||||
logger.info(f"Capture returned None for target {target_id} (no new frame yet)")
|
||||
await asyncio.sleep(frame_time)
|
||||
continue
|
||||
|
||||
# Skip processing + send if the frame hasn't changed
|
||||
if capture is prev_capture:
|
||||
# Keepalive: resend last colors to prevent WLED exiting live mode
|
||||
if state.previous_colors and (loop_start - last_send_time) >= standby_interval:
|
||||
if not state.is_running or state.wled_client is None:
|
||||
break
|
||||
brightness_value = int(wled_brightness * 255)
|
||||
if state.wled_client.use_ddp:
|
||||
state.wled_client.send_pixels_fast(state.previous_colors, brightness=brightness_value)
|
||||
else:
|
||||
await state.wled_client.send_pixels(state.previous_colors, brightness=brightness_value)
|
||||
last_send_time = time.time()
|
||||
send_timestamps.append(last_send_time)
|
||||
state.metrics.frames_keepalive += 1
|
||||
state.metrics.frames_skipped += 1
|
||||
# Update fps_current: count sends in last 1 second
|
||||
now_ts = time.time()
|
||||
while send_timestamps and send_timestamps[0] < now_ts - 1.0:
|
||||
send_timestamps.popleft()
|
||||
state.metrics.fps_current = len(send_timestamps)
|
||||
await asyncio.sleep(frame_time)
|
||||
continue
|
||||
prev_capture = capture
|
||||
|
||||
# CPU-bound work in thread pool
|
||||
led_colors = await asyncio.to_thread(
|
||||
_process_frame,
|
||||
capture, border_width,
|
||||
state.pixel_mapper, state.previous_colors, smoothing,
|
||||
)
|
||||
|
||||
# Send to WLED with device brightness
|
||||
if not state.is_running or state.wled_client is None:
|
||||
break
|
||||
@@ -709,6 +745,8 @@ class ProcessorManager:
|
||||
state.wled_client.send_pixels_fast(led_colors, brightness=brightness_value)
|
||||
else:
|
||||
await state.wled_client.send_pixels(led_colors, brightness=brightness_value)
|
||||
last_send_time = time.time()
|
||||
send_timestamps.append(last_send_time)
|
||||
|
||||
# Update metrics
|
||||
state.metrics.frames_processed += 1
|
||||
@@ -730,6 +768,11 @@ class ProcessorManager:
|
||||
processing_time = now - loop_start
|
||||
state.metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0
|
||||
|
||||
# Update fps_current: count sends in last 1 second
|
||||
while send_timestamps and send_timestamps[0] < now - 1.0:
|
||||
send_timestamps.popleft()
|
||||
state.metrics.fps_current = len(send_timestamps)
|
||||
|
||||
except Exception as e:
|
||||
state.metrics.errors_count += 1
|
||||
state.metrics.last_error = str(e)
|
||||
@@ -782,6 +825,9 @@ class ProcessorManager:
|
||||
"fps_actual": metrics.fps_actual if state.is_running else None,
|
||||
"fps_potential": metrics.fps_potential if state.is_running else None,
|
||||
"fps_target": state.settings.fps,
|
||||
"frames_skipped": metrics.frames_skipped if state.is_running else None,
|
||||
"frames_keepalive": metrics.frames_keepalive if state.is_running else None,
|
||||
"fps_current": metrics.fps_current if state.is_running else None,
|
||||
"display_index": state.resolved_display_index if state.resolved_display_index is not None else state.settings.display_index,
|
||||
"last_update": metrics.last_update,
|
||||
"errors": [metrics.last_error] if metrics.last_error else [],
|
||||
@@ -877,8 +923,7 @@ class ProcessorManager:
|
||||
if active_client:
|
||||
await active_client.send_pixels(pixels)
|
||||
else:
|
||||
use_ddp = ds.led_count > WLEDClient.HTTP_MAX_LEDS
|
||||
async with WLEDClient(ds.device_url, use_ddp=use_ddp) as wled:
|
||||
async with WLEDClient(ds.device_url, use_ddp=True) as wled:
|
||||
await wled.send_pixels(pixels)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send test pixels for {device_id}: {e}")
|
||||
@@ -898,8 +943,7 @@ class ProcessorManager:
|
||||
if active_client:
|
||||
await active_client.send_pixels(pixels)
|
||||
else:
|
||||
use_ddp = ds.led_count > WLEDClient.HTTP_MAX_LEDS
|
||||
async with WLEDClient(ds.device_url, use_ddp=use_ddp) as wled:
|
||||
async with WLEDClient(ds.device_url, use_ddp=True) as wled:
|
||||
await wled.send_pixels(pixels)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear pixels for {device_id}: {e}")
|
||||
@@ -1197,6 +1241,7 @@ class ProcessorManager:
|
||||
|
||||
frame_time = 1.0 / target_fps
|
||||
fps_samples: List[float] = []
|
||||
prev_capture = None # Track previous ScreenCapture for change detection
|
||||
|
||||
rectangles = state._resolved_rectangles
|
||||
|
||||
@@ -1210,16 +1255,27 @@ class ProcessorManager:
|
||||
loop_start = time.time()
|
||||
|
||||
try:
|
||||
# Batch all CPU work in a single thread call
|
||||
colors = await asyncio.to_thread(
|
||||
_process_kc_frame,
|
||||
state.live_stream, rectangles, calc_fn,
|
||||
state.previous_colors, smoothing,
|
||||
)
|
||||
if colors is None:
|
||||
# get_latest_frame() is a fast lock read — safe on asyncio thread
|
||||
capture = state.live_stream.get_latest_frame()
|
||||
|
||||
if capture is None:
|
||||
await asyncio.sleep(frame_time)
|
||||
continue
|
||||
|
||||
# Skip processing if the frame hasn't changed
|
||||
if capture is prev_capture:
|
||||
state.metrics.frames_skipped += 1
|
||||
await asyncio.sleep(frame_time)
|
||||
continue
|
||||
prev_capture = capture
|
||||
|
||||
# CPU-bound work in thread pool
|
||||
colors = await asyncio.to_thread(
|
||||
_process_kc_frame,
|
||||
capture, rectangles, calc_fn,
|
||||
state.previous_colors, smoothing,
|
||||
)
|
||||
|
||||
state.previous_colors = dict(colors)
|
||||
state.latest_colors = dict(colors)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user