Add static image picture stream type with auto-validating UI

Introduces a new "static_image" stream type that loads a frame from a URL
or local file path, enabling LED testing with known images or displaying
static content. Includes validate-image API endpoint, auto-validation on
blur/enter/paste with caching, capture template names on stream cards,
and conditional test stats display for single-frame results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 19:57:43 +03:00
parent 4f9c30ef06
commit e0877a9b16
10 changed files with 566 additions and 181 deletions

View File

@@ -13,7 +13,10 @@ from wled_controller.core.calibration import (
PixelMapper,
create_default_calibration,
)
import numpy as np
from wled_controller.core.capture_engines import CaptureEngine, EngineRegistry
from wled_controller.core.capture_engines.base import ScreenCapture
from wled_controller.core.filters import FilterInstance, FilterRegistry, ImagePool, PostprocessingFilter
from wled_controller.core.pixel_processor import smooth_colors
from wled_controller.core.screen_capture import extract_border_pixels
@@ -108,6 +111,8 @@ class ProcessorState:
resolved_engine_type: Optional[str] = None
resolved_engine_config: Optional[dict] = None
resolved_filters: Optional[List[FilterInstance]] = None
# Static image: cached frame for static_image streams (no engine needed)
static_image: Optional[np.ndarray] = None
image_pool: Optional[ImagePool] = None
filter_instances: Optional[List[PostprocessingFilter]] = None
@@ -280,19 +285,28 @@ class ProcessorManager:
raw_stream = chain["raw_stream"]
pp_template_ids = chain["postprocessing_template_ids"]
state.resolved_display_index = raw_stream.display_index
state.resolved_target_fps = raw_stream.target_fps
if raw_stream.stream_type == "static_image":
# Static image stream: load image once, no engine needed
state.resolved_display_index = -1
state.resolved_target_fps = 1
state.resolved_engine_type = None
state.resolved_engine_config = None
state.static_image = self._load_static_image(raw_stream.image_source)
else:
# Raw capture stream
state.resolved_display_index = raw_stream.display_index
state.resolved_target_fps = raw_stream.target_fps
# Resolve capture engine from raw stream's capture template
if raw_stream.capture_template_id and self._capture_template_store:
try:
tpl = self._capture_template_store.get_template(raw_stream.capture_template_id)
state.resolved_engine_type = tpl.engine_type
state.resolved_engine_config = tpl.engine_config
except ValueError:
logger.warning(f"Capture template {raw_stream.capture_template_id} not found, using MSS fallback")
state.resolved_engine_type = "mss"
state.resolved_engine_config = {}
# Resolve capture engine from raw stream's capture template
if raw_stream.capture_template_id and self._capture_template_store:
try:
tpl = self._capture_template_store.get_template(raw_stream.capture_template_id)
state.resolved_engine_type = tpl.engine_type
state.resolved_engine_config = tpl.engine_config
except ValueError:
logger.warning(f"Capture template {raw_stream.capture_template_id} not found, using MSS fallback")
state.resolved_engine_type = "mss"
state.resolved_engine_config = {}
# Resolve postprocessing: use first PP template in chain
if pp_template_ids and self._pp_template_store:
@@ -337,6 +351,27 @@ class ProcessorManager:
state.resolved_engine_type = "mss"
state.resolved_engine_config = {}
@staticmethod
def _load_static_image(image_source: str) -> np.ndarray:
"""Load a static image from URL or file path, return as RGB numpy array."""
from io import BytesIO
from pathlib import Path
from PIL import Image
if image_source.startswith(("http://", "https://")):
response = httpx.get(image_source, timeout=15.0, follow_redirects=True)
response.raise_for_status()
pil_image = Image.open(BytesIO(response.content))
else:
path = Path(image_source)
if not path.exists():
raise FileNotFoundError(f"Image file not found: {image_source}")
pil_image = Image.open(path)
pil_image = pil_image.convert("RGB")
return np.array(pil_image)
async def start_processing(self, device_id: str):
"""Start screen processing for a device.
@@ -373,19 +408,22 @@ class ProcessorManager:
logger.error(f"Failed to connect to WLED device {device_id}: {e}")
raise RuntimeError(f"Failed to connect to WLED device: {e}")
# Initialize capture engine from resolved settings
try:
engine_type = state.resolved_engine_type or "mss"
engine_config = state.resolved_engine_config or {}
engine = EngineRegistry.create_engine(engine_type, engine_config)
engine.initialize()
state.capture_engine = engine
logger.info(f"Initialized capture engine for device {device_id}: {engine_type}")
except Exception as e:
logger.error(f"Failed to initialize capture engine for device {device_id}: {e}")
if state.wled_client:
await state.wled_client.disconnect()
raise RuntimeError(f"Failed to initialize capture engine: {e}")
# Initialize capture engine from resolved settings (skip for static_image)
if state.static_image is not None:
logger.info(f"Using static image for device {device_id} ({state.static_image.shape[1]}x{state.static_image.shape[0]})")
else:
try:
engine_type = state.resolved_engine_type or "mss"
engine_config = state.resolved_engine_config or {}
engine = EngineRegistry.create_engine(engine_type, engine_config)
engine.initialize()
state.capture_engine = engine
logger.info(f"Initialized capture engine for device {device_id}: {engine_type}")
except Exception as e:
logger.error(f"Failed to initialize capture engine for device {device_id}: {e}")
if state.wled_client:
await state.wled_client.disconnect()
raise RuntimeError(f"Failed to initialize capture engine: {e}")
# Initialize pixel mapper
state.pixel_mapper = PixelMapper(
@@ -443,6 +481,9 @@ class ProcessorManager:
state.capture_engine.cleanup()
state.capture_engine = None
# Release cached static image
state.static_image = None
logger.info(f"Stopped processing for device {device_id}")
async def _processing_loop(self, device_id: str):
@@ -502,11 +543,17 @@ class ProcessorManager:
continue
try:
# Capture screen using engine
capture = await asyncio.to_thread(
state.capture_engine.capture_display,
display_index
)
# Get frame: static image or live capture
if state.static_image is not None:
h, w = state.static_image.shape[:2]
capture = ScreenCapture(
image=state.static_image.copy(), width=w, height=h, display_index=-1
)
else:
capture = await asyncio.to_thread(
state.capture_engine.capture_display,
display_index
)
# Apply postprocessing filters to the full captured image
if filter_objects: