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_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_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_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_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error") device_error: Optional[str] = Field(None, description="Last health check error")
test_mode: bool = Field(default=False, description="Whether calibration test mode is active") 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_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
frames_keepalive: Optional[int] = Field(None, description="Keepalive frames sent during standby") 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") 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") display_index: int = Field(default=0, description="Current display index")
last_update: Optional[datetime] = Field(None, description="Last successful update") last_update: Optional[datetime] = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors") 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_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_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_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_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error") device_error: Optional[str] = Field(None, description="Last health check error")

View File

@@ -116,10 +116,15 @@ class AdalightClient(LEDClient):
async def send_pixels( async def send_pixels(
self, self,
pixels: List[Tuple[int, int, int]], pixels,
brightness: int = 255, brightness: int = 255,
) -> bool: ) -> 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: if not self.is_connected:
return False return False
@@ -136,8 +141,11 @@ class AdalightClient(LEDClient):
# Serial write is blocking — use async send_pixels path instead # Serial write is blocking — use async send_pixels path instead
return False 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.""" """Build a complete Adalight frame: header + brightness-scaled RGB data."""
if isinstance(pixels, np.ndarray):
arr = pixels.astype(np.uint16)
else:
arr = np.array(pixels, dtype=np.uint16) arr = np.array(pixels, dtype=np.uint16)
if brightness < 255: if brightness < 255:

View File

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

View File

@@ -19,6 +19,7 @@ class DeviceHealth:
device_led_count: Optional[int] = None device_led_count: Optional[int] = None
device_rgbw: Optional[bool] = None device_rgbw: Optional[bool] = None
device_led_type: Optional[str] = None device_led_type: Optional[str] = None
device_fps: Optional[int] = None
error: Optional[str] = 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.capture_engines.base import ScreenCapture
from wled_controller.core.live_stream import LiveStream from wled_controller.core.live_stream import LiveStream
from wled_controller.core.live_stream_manager import LiveStreamManager 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 ( from wled_controller.core.screen_capture import (
calculate_average_color, calculate_average_color,
calculate_dominant_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): def _process_frame(capture, border_width, pixel_mapper, previous_colors, smoothing):
"""All CPU-bound work for one WLED frame (runs in thread pool). """All CPU-bound work for one WLED frame (runs in thread pool).
Args: Returns (led_colors, timing_ms) where led_colors is numpy array (N, 3) uint8
capture: ScreenCapture from live_stream.get_latest_frame() and timing_ms is a dict with per-stage timing in milliseconds.
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)
""" """
t0 = time.perf_counter()
border_pixels = extract_border_pixels(capture, border_width) border_pixels = extract_border_pixels(capture, border_width)
t1 = time.perf_counter()
led_colors = pixel_mapper.map_border_to_leds(border_pixels) led_colors = pixel_mapper.map_border_to_leds(border_pixels)
if previous_colors and smoothing > 0: t2 = time.perf_counter()
led_colors = smooth_colors(led_colors, previous_colors, smoothing)
return led_colors # 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): def _process_kc_frame(capture, rectangles, calc_fn, previous_colors, smoothing):
@@ -121,6 +134,12 @@ class ProcessingMetrics:
fps_actual: float = 0.0 fps_actual: float = 0.0
fps_potential: float = 0.0 fps_potential: float = 0.0
fps_current: int = 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 @dataclass
@@ -192,7 +211,7 @@ class ProcessorManager:
Targets are registered for processing (streaming sources to devices). 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.""" """Initialize processor manager."""
self._devices: Dict[str, DeviceState] = {} self._devices: Dict[str, DeviceState] = {}
self._targets: Dict[str, TargetState] = {} self._targets: Dict[str, TargetState] = {}
@@ -203,6 +222,7 @@ class ProcessorManager:
self._capture_template_store = capture_template_store self._capture_template_store = capture_template_store
self._pp_template_store = pp_template_store self._pp_template_store = pp_template_store
self._pattern_template_store = pattern_template_store self._pattern_template_store = pattern_template_store
self._device_store = device_store
self._live_stream_manager = LiveStreamManager( self._live_stream_manager = LiveStreamManager(
picture_source_store, capture_template_store, pp_template_store picture_source_store, capture_template_store, pp_template_store
) )
@@ -346,6 +366,7 @@ class ProcessorManager:
"device_led_count": h.device_led_count, "device_led_count": h.device_led_count,
"device_rgbw": h.device_rgbw, "device_rgbw": h.device_rgbw,
"device_led_type": h.device_led_type, "device_led_type": h.device_led_type,
"device_fps": h.device_fps,
"error": h.error, "error": h.error,
} }
@@ -365,6 +386,7 @@ class ProcessorManager:
"device_led_count": h.device_led_count, "device_led_count": h.device_led_count,
"device_rgbw": h.device_rgbw, "device_rgbw": h.device_rgbw,
"device_led_type": h.device_led_type, "device_led_type": h.device_led_type,
"device_fps": h.device_fps,
"device_last_checked": h.last_checked, "device_last_checked": h.last_checked,
"device_error": h.error, "device_error": h.error,
"test_mode": ds.test_mode_active, "test_mode": ds.test_mode_active,
@@ -648,6 +670,7 @@ class ProcessorManager:
frame_time = 1.0 / target_fps frame_time = 1.0 / target_fps
standby_interval = settings.standby_interval standby_interval = settings.standby_interval
fps_samples = [] fps_samples = []
timing_samples: collections.deque = collections.deque(maxlen=10) # per-stage timing
prev_frame_time_stamp = time.time() prev_frame_time_stamp = time.time()
prev_capture = None # Track previous ScreenCapture for change detection prev_capture = None # Track previous ScreenCapture for change detection
last_send_time = 0.0 # Timestamp of last DDP send (for keepalive) 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 # Skip processing + send if the frame hasn't changed
if capture is prev_capture: if capture is prev_capture:
# Keepalive: resend last colors to prevent device exiting live mode # 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: if not state.is_running or state.led_client is None:
break break
brightness_value = int(led_brightness * 255) brightness_value = int(led_brightness * 255)
@@ -701,7 +724,7 @@ class ProcessorManager:
prev_capture = capture prev_capture = capture
# CPU-bound work in thread pool # CPU-bound work in thread pool
led_colors = await asyncio.to_thread( led_colors, frame_timing = await asyncio.to_thread(
_process_frame, _process_frame,
capture, border_width, capture, border_width,
state.pixel_mapper, state.previous_colors, smoothing, state.pixel_mapper, state.previous_colors, smoothing,
@@ -711,17 +734,36 @@ class ProcessorManager:
if not state.is_running or state.led_client is None: if not state.is_running or state.led_client is None:
break break
brightness_value = int(led_brightness * 255) brightness_value = int(led_brightness * 255)
t_send_start = time.perf_counter()
if state.led_client.supports_fast_send: if state.led_client.supports_fast_send:
state.led_client.send_pixels_fast(led_colors, brightness=brightness_value) state.led_client.send_pixels_fast(led_colors, brightness=brightness_value)
else: else:
await state.led_client.send_pixels(led_colors, brightness=brightness_value) 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() last_send_time = time.time()
send_timestamps.append(last_send_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 # Update metrics
state.metrics.frames_processed += 1 state.metrics.frames_processed += 1
if state.metrics.frames_processed <= 3 or state.metrics.frames_processed % 100 == 0: 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.metrics.last_update = datetime.utcnow()
state.previous_colors = led_colors state.previous_colors = led_colors
@@ -784,6 +826,7 @@ class ProcessorManager:
"device_led_count": h.device_led_count, "device_led_count": h.device_led_count,
"device_rgbw": h.device_rgbw, "device_rgbw": h.device_rgbw,
"device_led_type": h.device_led_type, "device_led_type": h.device_led_type,
"device_fps": h.device_fps,
"device_last_checked": h.last_checked, "device_last_checked": h.last_checked,
"device_error": h.error, "device_error": h.error,
} }
@@ -798,6 +841,11 @@ class ProcessorManager:
"frames_skipped": metrics.frames_skipped if state.is_running else None, "frames_skipped": metrics.frames_skipped if state.is_running else None,
"frames_keepalive": metrics.frames_keepalive 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, "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, "display_index": state.resolved_display_index if state.resolved_display_index is not None else state.settings.display_index,
"last_update": metrics.last_update, "last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [], "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}") logger.error(f"Fatal error in health check loop for {device_id}: {e}")
async def _check_device_health(self, device_id: str): 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) state = self._devices.get(device_id)
if not state: if not state:
return return
@@ -1043,6 +1094,33 @@ class ProcessorManager:
state.device_type, state.device_url, client, state.health, 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 ===== # ===== KEY COLORS TARGET MANAGEMENT =====
def add_kc_target(self, target_id: str, picture_source_id: str, settings) -> None: 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( def send_pixels_fast(
self, self,
pixels: List[Tuple[int, int, int]], pixels,
brightness: int = 255, brightness: int = 255,
) -> None: ) -> 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. Synchronous (no await). Only works for DDP path.
Falls back to raising if DDP is not available.
Args: 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) brightness: Global brightness (0-255)
""" """
if not self.use_ddp or not self._ddp_client: if not self.use_ddp or not self._ddp_client:
raise RuntimeError("send_pixels_fast requires DDP; use send_pixels for HTTP") raise RuntimeError("send_pixels_fast requires DDP; use send_pixels for HTTP")
if isinstance(pixels, np.ndarray):
pixel_array = pixels
else:
pixel_array = np.array(pixels, dtype=np.uint8) pixel_array = np.array(pixels, dtype=np.uint8)
if brightness < 255: 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) self._ddp_client.send_pixels_numpy(pixel_array)
@@ -530,6 +533,7 @@ class WLEDClient(LEDClient):
device_led_count=leds_info.get("count"), device_led_count=leds_info.get("count"),
device_rgbw=leds_info.get("rgbw", False), device_rgbw=leds_info.get("rgbw", False),
device_led_type=device_led_type, device_led_type=device_led_type,
device_fps=leds_info.get("fps"),
error=None, error=None,
) )
except Exception as e: 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_led_count=prev_health.device_led_count if prev_health else None,
device_rgbw=prev_health.device_rgbw 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_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), error=str(e),
) )

View File

@@ -44,6 +44,7 @@ processor_manager = ProcessorManager(
capture_template_store=template_store, capture_template_store=template_store,
pp_template_store=pp_template_store, pp_template_store=pp_template_store,
pattern_template_store=pattern_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 class="metric-value">${metrics.errors_count || 0}</div>
</div> </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>
<div class="card-actions"> <div class="card-actions">

View File

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

View File

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

View File

@@ -962,6 +962,71 @@ input:-webkit-autofill:focus {
color: #999; 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 Styles */
.modal { .modal {
display: none; display: none;