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