Optimize numpy pipeline, add per-stage timing, and auto-sync LED count

- Eliminate 5 numpy↔tuple conversions per frame in processing hot path:
  map_border_to_leds returns ndarray, inline numpy smoothing with integer
  math, send_pixels_fast accepts ndarray directly
- Fix numpy boolean bug in keepalive check (use `is not None`)
- Add per-stage pipeline timing (extract/map/smooth/send) to metrics API
  and UI with color-coded breakdown bar
- Expose device_fps from WLED health check in API schemas
- Auto-sync LED count from WLED device: health check detects changes and
  updates storage, calibration, and active targets automatically
- Use integer math for brightness scaling (uint16 * brightness >> 8)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 17:43:16 +03:00
parent 350dafb1e8
commit ac5c1d0c82
12 changed files with 218 additions and 29 deletions

View File

@@ -115,6 +115,7 @@ class DeviceStateResponse(BaseModel):
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
device_fps: Optional[int] = Field(None, description="Device-reported FPS (WLED internal refresh rate)")
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error")
test_mode: bool = Field(default=False, description="Whether calibration test mode is active")

View File

@@ -128,6 +128,11 @@ class TargetProcessingState(BaseModel):
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
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_extract_ms: Optional[float] = Field(None, description="Border extraction time (ms)")
timing_map_leds_ms: Optional[float] = Field(None, description="LED mapping time (ms)")
timing_smooth_ms: Optional[float] = Field(None, description="Smoothing time (ms)")
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
timing_total_ms: Optional[float] = Field(None, description="Total processing time (ms)")
display_index: int = Field(default=0, description="Current display index")
last_update: Optional[datetime] = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors")
@@ -138,6 +143,7 @@ class TargetProcessingState(BaseModel):
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
device_fps: Optional[int] = Field(None, description="Device-reported FPS (WLED internal refresh rate)")
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error")

View File

@@ -116,10 +116,15 @@ class AdalightClient(LEDClient):
async def send_pixels(
self,
pixels: List[Tuple[int, int, int]],
pixels,
brightness: int = 255,
) -> bool:
"""Send pixel data over serial using Adalight protocol (non-blocking)."""
"""Send pixel data over serial using Adalight protocol (non-blocking).
Args:
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
brightness: Global brightness (0-255)
"""
if not self.is_connected:
return False
@@ -136,9 +141,12 @@ class AdalightClient(LEDClient):
# Serial write is blocking — use async send_pixels path instead
return False
def _build_frame(self, pixels: List[Tuple[int, int, int]], brightness: int) -> bytes:
def _build_frame(self, pixels, brightness: int) -> bytes:
"""Build a complete Adalight frame: header + brightness-scaled RGB data."""
arr = np.array(pixels, dtype=np.uint16)
if isinstance(pixels, np.ndarray):
arr = pixels.astype(np.uint16)
else:
arr = np.array(pixels, dtype=np.uint16)
if brightness < 255:
arr = arr * brightness // 255

View File

@@ -255,14 +255,14 @@ class PixelMapper:
def map_border_to_leds(
self,
border_pixels: BorderPixels
) -> List[Tuple[int, int, int]]:
) -> np.ndarray:
"""Map screen border pixels to LED colors.
Args:
border_pixels: Extracted border pixels from screen
Returns:
List of (R, G, B) tuples for each LED
numpy array of shape (total_leds, 3), dtype uint8
Raises:
ValueError: If border pixels don't match calibration
@@ -338,7 +338,7 @@ class PixelMapper:
elif active_count <= 0:
led_array[:] = 0
return [tuple(c) for c in led_array]
return led_array
else:
if offset > 0:
led_colors = led_colors[total_leds - offset:] + led_colors[:total_leds - offset]
@@ -358,7 +358,7 @@ class PixelMapper:
elif active_count <= 0:
led_colors = [(0, 0, 0)] * total_leds
return led_colors
return np.array(led_colors, dtype=np.uint8)
def test_calibration(self, edge: str, color: Tuple[int, int, int]) -> List[Tuple[int, int, int]]:
"""Generate test pattern to light up specific edge.

View File

@@ -19,6 +19,7 @@ class DeviceHealth:
device_led_count: Optional[int] = None
device_rgbw: Optional[bool] = None
device_led_type: Optional[str] = None
device_fps: Optional[int] = None
error: Optional[str] = None

View File

@@ -19,7 +19,6 @@ from wled_controller.core.calibration import (
from wled_controller.core.capture_engines.base import ScreenCapture
from wled_controller.core.live_stream import LiveStream
from wled_controller.core.live_stream_manager import LiveStreamManager
from wled_controller.core.pixel_processor import smooth_colors
from wled_controller.core.screen_capture import (
calculate_average_color,
calculate_dominant_color,
@@ -42,18 +41,32 @@ DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
def _process_frame(capture, border_width, pixel_mapper, previous_colors, smoothing):
"""All CPU-bound work for one WLED frame (runs in thread pool).
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)
Returns (led_colors, timing_ms) where led_colors is numpy array (N, 3) uint8
and timing_ms is a dict with per-stage timing in milliseconds.
"""
t0 = time.perf_counter()
border_pixels = extract_border_pixels(capture, border_width)
t1 = time.perf_counter()
led_colors = pixel_mapper.map_border_to_leds(border_pixels)
if previous_colors and smoothing > 0:
led_colors = smooth_colors(led_colors, previous_colors, smoothing)
return led_colors
t2 = time.perf_counter()
# Inline numpy smoothing — avoids list↔numpy round-trip
if previous_colors is not None and smoothing > 0 and len(previous_colors) == len(led_colors):
alpha = int(smoothing * 256)
led_colors = (
(256 - alpha) * led_colors.astype(np.uint16)
+ alpha * previous_colors.astype(np.uint16)
) >> 8
led_colors = led_colors.astype(np.uint8)
t3 = time.perf_counter()
timing_ms = {
"extract": (t1 - t0) * 1000,
"map_leds": (t2 - t1) * 1000,
"smooth": (t3 - t2) * 1000,
"total": (t3 - t0) * 1000,
}
return led_colors, timing_ms
def _process_kc_frame(capture, rectangles, calc_fn, previous_colors, smoothing):
@@ -121,6 +134,12 @@ class ProcessingMetrics:
fps_actual: float = 0.0
fps_potential: float = 0.0
fps_current: int = 0
# Per-stage timing (ms), averaged over last 10 frames
timing_extract_ms: float = 0.0
timing_map_leds_ms: float = 0.0
timing_smooth_ms: float = 0.0
timing_send_ms: float = 0.0
timing_total_ms: float = 0.0
@dataclass
@@ -192,7 +211,7 @@ class ProcessorManager:
Targets are registered for processing (streaming sources to devices).
"""
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None):
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None):
"""Initialize processor manager."""
self._devices: Dict[str, DeviceState] = {}
self._targets: Dict[str, TargetState] = {}
@@ -203,6 +222,7 @@ class ProcessorManager:
self._capture_template_store = capture_template_store
self._pp_template_store = pp_template_store
self._pattern_template_store = pattern_template_store
self._device_store = device_store
self._live_stream_manager = LiveStreamManager(
picture_source_store, capture_template_store, pp_template_store
)
@@ -346,6 +366,7 @@ class ProcessorManager:
"device_led_count": h.device_led_count,
"device_rgbw": h.device_rgbw,
"device_led_type": h.device_led_type,
"device_fps": h.device_fps,
"error": h.error,
}
@@ -365,6 +386,7 @@ class ProcessorManager:
"device_led_count": h.device_led_count,
"device_rgbw": h.device_rgbw,
"device_led_type": h.device_led_type,
"device_fps": h.device_fps,
"device_last_checked": h.last_checked,
"device_error": h.error,
"test_mode": ds.test_mode_active,
@@ -648,6 +670,7 @@ class ProcessorManager:
frame_time = 1.0 / target_fps
standby_interval = settings.standby_interval
fps_samples = []
timing_samples: collections.deque = collections.deque(maxlen=10) # per-stage timing
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)
@@ -679,7 +702,7 @@ class ProcessorManager:
# Skip processing + send if the frame hasn't changed
if capture is prev_capture:
# Keepalive: resend last colors to prevent device exiting live mode
if state.previous_colors and (loop_start - last_send_time) >= standby_interval:
if state.previous_colors is not None and (loop_start - last_send_time) >= standby_interval:
if not state.is_running or state.led_client is None:
break
brightness_value = int(led_brightness * 255)
@@ -701,7 +724,7 @@ class ProcessorManager:
prev_capture = capture
# CPU-bound work in thread pool
led_colors = await asyncio.to_thread(
led_colors, frame_timing = await asyncio.to_thread(
_process_frame,
capture, border_width,
state.pixel_mapper, state.previous_colors, smoothing,
@@ -711,17 +734,36 @@ class ProcessorManager:
if not state.is_running or state.led_client is None:
break
brightness_value = int(led_brightness * 255)
t_send_start = time.perf_counter()
if state.led_client.supports_fast_send:
state.led_client.send_pixels_fast(led_colors, brightness=brightness_value)
else:
await state.led_client.send_pixels(led_colors, brightness=brightness_value)
send_ms = (time.perf_counter() - t_send_start) * 1000
last_send_time = time.time()
send_timestamps.append(last_send_time)
# Per-stage timing (rolling average over last 10 frames)
frame_timing["send"] = send_ms
timing_samples.append(frame_timing)
n = len(timing_samples)
state.metrics.timing_extract_ms = sum(s["extract"] for s in timing_samples) / n
state.metrics.timing_map_leds_ms = sum(s["map_leds"] for s in timing_samples) / n
state.metrics.timing_smooth_ms = sum(s["smooth"] for s in timing_samples) / n
state.metrics.timing_send_ms = sum(s["send"] for s in timing_samples) / n
state.metrics.timing_total_ms = sum(s["total"] for s in timing_samples) / n + send_ms
# Update metrics
state.metrics.frames_processed += 1
if state.metrics.frames_processed <= 3 or state.metrics.frames_processed % 100 == 0:
logger.info(f"Frame {state.metrics.frames_processed} sent for target {target_id} ({len(led_colors)} LEDs, bri={brightness_value})")
logger.info(
f"Frame {state.metrics.frames_processed} for {target_id} "
f"({len(led_colors)} LEDs, bri={brightness_value}) — "
f"extract={frame_timing['extract']:.1f}ms "
f"map={frame_timing['map_leds']:.1f}ms "
f"smooth={frame_timing['smooth']:.1f}ms "
f"send={send_ms:.1f}ms"
)
state.metrics.last_update = datetime.utcnow()
state.previous_colors = led_colors
@@ -784,6 +826,7 @@ class ProcessorManager:
"device_led_count": h.device_led_count,
"device_rgbw": h.device_rgbw,
"device_led_type": h.device_led_type,
"device_fps": h.device_fps,
"device_last_checked": h.last_checked,
"device_error": h.error,
}
@@ -798,6 +841,11 @@ class ProcessorManager:
"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,
"timing_extract_ms": round(metrics.timing_extract_ms, 1) if state.is_running else None,
"timing_map_leds_ms": round(metrics.timing_map_leds_ms, 1) if state.is_running else None,
"timing_smooth_ms": round(metrics.timing_smooth_ms, 1) if state.is_running else None,
"timing_send_ms": round(metrics.timing_send_ms, 1) if state.is_running else None,
"timing_total_ms": round(metrics.timing_total_ms, 1) 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 [],
@@ -1034,7 +1082,10 @@ class ProcessorManager:
logger.error(f"Fatal error in health check loop for {device_id}: {e}")
async def _check_device_health(self, device_id: str):
"""Check device health via the LED client abstraction."""
"""Check device health via the LED client abstraction.
Also auto-syncs LED count if the device reports a different value.
"""
state = self._devices.get(device_id)
if not state:
return
@@ -1043,6 +1094,33 @@ class ProcessorManager:
state.device_type, state.device_url, client, state.health,
)
# Auto-sync LED count when device reports a different value
reported = state.health.device_led_count
if reported and reported != state.led_count and self._device_store:
old_count = state.led_count
logger.info(
f"Device {device_id} LED count changed: {old_count}{reported}, "
f"updating calibration"
)
try:
# Update persistent storage (creates new default calibration)
device = self._device_store.update_device(device_id, led_count=reported)
# Sync in-memory state
state.led_count = reported
state.calibration = device.calibration
# Update any active targets using this device
for ts in self._targets.values():
if ts.device_id == device_id:
ts.led_count = reported
ts.calibration = device.calibration
if ts.pixel_mapper:
ts.pixel_mapper = PixelMapper(
device.calibration,
interpolation_mode=ts.settings.interpolation_mode,
)
except Exception as e:
logger.error(f"Failed to sync LED count for {device_id}: {e}")
# ===== KEY COLORS TARGET MANAGEMENT =====
def add_kc_target(self, target_id: str, picture_source_id: str, settings) -> None:

View File

@@ -435,25 +435,28 @@ class WLEDClient(LEDClient):
def send_pixels_fast(
self,
pixels: List[Tuple[int, int, int]],
pixels,
brightness: int = 255,
) -> None:
"""Optimized send for the hot loop — numpy packing + brightness, fire-and-forget DDP.
"""Optimized send for the hot loop — fire-and-forget DDP.
Accepts numpy array (N,3) uint8 directly to avoid conversion overhead.
Synchronous (no await). Only works for DDP path.
Falls back to raising if DDP is not available.
Args:
pixels: List of (R, G, B) tuples
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
brightness: Global brightness (0-255)
"""
if not self.use_ddp or not self._ddp_client:
raise RuntimeError("send_pixels_fast requires DDP; use send_pixels for HTTP")
pixel_array = np.array(pixels, dtype=np.uint8)
if isinstance(pixels, np.ndarray):
pixel_array = pixels
else:
pixel_array = np.array(pixels, dtype=np.uint8)
if brightness < 255:
pixel_array = (pixel_array.astype(np.float32) * (brightness / 255.0)).astype(np.uint8)
pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8)
self._ddp_client.send_pixels_numpy(pixel_array)
@@ -530,6 +533,7 @@ class WLEDClient(LEDClient):
device_led_count=leds_info.get("count"),
device_rgbw=leds_info.get("rgbw", False),
device_led_type=device_led_type,
device_fps=leds_info.get("fps"),
error=None,
)
except Exception as e:
@@ -542,6 +546,7 @@ class WLEDClient(LEDClient):
device_led_count=prev_health.device_led_count if prev_health else None,
device_rgbw=prev_health.device_rgbw if prev_health else None,
device_led_type=prev_health.device_led_type if prev_health else None,
device_fps=prev_health.device_fps if prev_health else None,
error=str(e),
)

View File

@@ -44,6 +44,7 @@ processor_manager = ProcessorManager(
capture_template_store=template_store,
pp_template_store=pp_template_store,
pattern_template_store=pattern_template_store,
device_store=device_store,
)

View File

@@ -4669,6 +4669,26 @@ function createTargetCard(target, deviceMap, sourceMap) {
<div class="metric-value">${metrics.errors_count || 0}</div>
</div>
</div>
${state.timing_total_ms != null ? `
<div class="timing-breakdown">
<div class="timing-header">
<div class="metric-label">${t('device.metrics.timing')}</div>
<div class="timing-total"><strong>${state.timing_total_ms}ms</strong></div>
</div>
<div class="timing-bar">
<span class="timing-seg timing-extract" style="flex:${state.timing_extract_ms}" title="extract ${state.timing_extract_ms}ms"></span>
<span class="timing-seg timing-map" style="flex:${state.timing_map_leds_ms}" title="map ${state.timing_map_leds_ms}ms"></span>
<span class="timing-seg timing-smooth" style="flex:${state.timing_smooth_ms || 0.1}" title="smooth ${state.timing_smooth_ms}ms"></span>
<span class="timing-seg timing-send" style="flex:${state.timing_send_ms}" title="send ${state.timing_send_ms}ms"></span>
</div>
<div class="timing-legend">
<span class="timing-legend-item"><span class="timing-dot timing-extract"></span>extract ${state.timing_extract_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-map"></span>map ${state.timing_map_leds_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-smooth"></span>smooth ${state.timing_smooth_ms}ms</span>
<span class="timing-legend-item"><span class="timing-dot timing-send"></span>send ${state.timing_send_ms}ms</span>
</div>
</div>
` : ''}
` : ''}
</div>
<div class="card-actions">

View File

@@ -150,6 +150,8 @@
"device.metrics.frames_skipped": "Skipped",
"device.metrics.keepalive": "Keepalive",
"device.metrics.errors": "Errors",
"device.metrics.timing": "Pipeline timing:",
"device.metrics.device_fps": "Device refresh rate",
"device.health.online": "Online",
"device.health.offline": "Offline",
"device.health.checking": "Checking...",

View File

@@ -150,6 +150,8 @@
"device.metrics.frames_skipped": "Пропущено",
"device.metrics.keepalive": "Keepalive",
"device.metrics.errors": "Ошибки",
"device.metrics.timing": "Тайминг пайплайна:",
"device.metrics.device_fps": "Частота обновления устройства",
"device.health.online": "Онлайн",
"device.health.offline": "Недоступен",
"device.health.checking": "Проверка...",

View File

@@ -962,6 +962,71 @@ input:-webkit-autofill:focus {
color: #999;
}
/* Timing breakdown bar */
.timing-breakdown {
margin-top: 8px;
padding: 6px 8px;
background: var(--bg-color);
border-radius: 4px;
}
.timing-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.timing-total {
font-size: 0.8rem;
color: var(--primary-color);
}
.timing-bar {
display: flex;
height: 8px;
border-radius: 4px;
overflow: hidden;
margin: 4px 0;
gap: 1px;
}
.timing-seg {
min-width: 2px;
transition: flex 0.3s ease;
}
.timing-extract { background: #4CAF50; }
.timing-map { background: #FF9800; }
.timing-smooth { background: #2196F3; }
.timing-send { background: #E91E63; }
.timing-legend {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 0.75rem;
color: #999;
margin-top: 4px;
}
.timing-legend-item {
display: flex;
align-items: center;
gap: 3px;
}
.timing-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 2px;
}
.timing-dot.timing-extract { background: #4CAF50; }
.timing-dot.timing-map { background: #FF9800; }
.timing-dot.timing-smooth { background: #2196F3; }
.timing-dot.timing-send { background: #E91E63; }
/* Modal Styles */
.modal {
display: none;