diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index 3d72b9a..ddee169 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -1,17 +1,27 @@ """Picture target routes: CRUD, processing control, settings, state, metrics.""" +import base64 +import io import secrets +import time +import numpy as np from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect +from PIL import Image from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import ( get_device_store, + get_pattern_template_store, + get_picture_source_store, get_picture_target_store, get_processor_manager, + get_template_store, ) from wled_controller.api.schemas.picture_targets import ( ExtractedColorResponse, + KCTestRectangleResponse, + KCTestResponse, KeyColorsResponse, KeyColorsSettingsSchema, PictureTargetCreate, @@ -23,8 +33,18 @@ from wled_controller.api.schemas.picture_targets import ( TargetProcessingState, ) from wled_controller.config import config +from wled_controller.core.capture_engines import EngineRegistry from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings +from wled_controller.core.screen_capture import ( + calculate_average_color, + calculate_dominant_color, + calculate_median_color, +) from wled_controller.storage import DeviceStore +from wled_controller.storage.pattern_template_store import PatternTemplateStore +from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource +from wled_controller.storage.picture_source_store import PictureSourceStore +from wled_controller.storage.template_store import TemplateStore from wled_controller.storage.wled_picture_target import WledPictureTarget from wled_controller.storage.key_colors_picture_target import ( KeyColorsSettings, @@ -531,6 +551,182 @@ async def get_target_colors( raise HTTPException(status_code=404, detail=str(e)) +@router.post("/api/v1/picture-targets/{target_id}/test", response_model=KCTestResponse, tags=["Key Colors"]) +async def test_kc_target( + target_id: str, + _auth: AuthRequired, + target_store: PictureTargetStore = Depends(get_picture_target_store), + source_store: PictureSourceStore = Depends(get_picture_source_store), + template_store: TemplateStore = Depends(get_template_store), + pattern_store: PatternTemplateStore = Depends(get_pattern_template_store), + processor_manager: ProcessorManager = Depends(get_processor_manager), + device_store: DeviceStore = Depends(get_device_store), +): + """Test a key-colors target: capture a frame, extract colors from each rectangle.""" + import httpx + + stream = None + try: + # 1. Load and validate KC target + try: + target = target_store.get_target(target_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + if not isinstance(target, KeyColorsPictureTarget): + raise HTTPException(status_code=400, detail="Target is not a key_colors target") + + settings = target.settings + + # 2. Resolve pattern template + if not settings.pattern_template_id: + raise HTTPException(status_code=400, detail="No pattern template configured") + + try: + pattern_tmpl = pattern_store.get_template(settings.pattern_template_id) + except ValueError: + raise HTTPException(status_code=400, detail=f"Pattern template not found: {settings.pattern_template_id}") + + rectangles = pattern_tmpl.rectangles + if not rectangles: + raise HTTPException(status_code=400, detail="Pattern template has no rectangles") + + # 3. Resolve picture source and capture a frame + if not target.picture_source_id: + raise HTTPException(status_code=400, detail="No picture source configured") + + try: + chain = source_store.resolve_stream_chain(target.picture_source_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + raw_stream = chain["raw_stream"] + + if isinstance(raw_stream, StaticImagePictureSource): + source = raw_stream.image_source + if source.startswith(("http://", "https://")): + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + resp = await client.get(source) + resp.raise_for_status() + pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB") + else: + from pathlib import Path + path = Path(source) + if not path.exists(): + raise HTTPException(status_code=400, detail=f"Image file not found: {source}") + pil_image = Image.open(path).convert("RGB") + + elif isinstance(raw_stream, ScreenCapturePictureSource): + try: + capture_template = template_store.get_template(raw_stream.capture_template_id) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Capture template not found: {raw_stream.capture_template_id}", + ) + + display_index = raw_stream.display_index + + if capture_template.engine_type not in EngineRegistry.get_available_engines(): + raise HTTPException( + status_code=400, + detail=f"Engine '{capture_template.engine_type}' is not available on this system", + ) + + locked_device_id = processor_manager.get_display_lock_info(display_index) + if locked_device_id: + try: + device = device_store.get_device(locked_device_id) + device_name = device.name + except Exception: + device_name = locked_device_id + raise HTTPException( + status_code=409, + detail=f"Display {display_index} is currently being captured by device '{device_name}'. " + f"Please stop the device processing before testing.", + ) + + stream = EngineRegistry.create_stream( + capture_template.engine_type, display_index, capture_template.engine_config + ) + stream.initialize() + + screen_capture = stream.capture_frame() + if screen_capture is None: + raise RuntimeError("No frame captured") + + if isinstance(screen_capture.image, np.ndarray): + pil_image = Image.fromarray(screen_capture.image) + else: + raise ValueError("Unexpected image format from engine") + else: + raise HTTPException(status_code=400, detail="Unsupported picture source type") + + # 4. Extract colors from each rectangle + img_array = np.array(pil_image) + h, w = img_array.shape[:2] + + calc_fns = { + "average": calculate_average_color, + "median": calculate_median_color, + "dominant": calculate_dominant_color, + } + calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color) + + result_rects = [] + for rect in rectangles: + px_x = max(0, int(rect.x * w)) + px_y = max(0, int(rect.y * h)) + px_w = max(1, int(rect.width * w)) + px_h = max(1, int(rect.height * h)) + px_x = min(px_x, w - 1) + px_y = min(px_y, h - 1) + px_w = min(px_w, w - px_x) + px_h = min(px_h, h - px_y) + + sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w] + r, g, b = calc_fn(sub_img) + + result_rects.append(KCTestRectangleResponse( + name=rect.name, + x=rect.x, + y=rect.y, + width=rect.width, + height=rect.height, + color=ExtractedColorResponse(r=r, g=g, b=b, hex=f"#{r:02x}{g:02x}{b:02x}"), + )) + + # 5. Encode frame as base64 JPEG + full_buffer = io.BytesIO() + pil_image.save(full_buffer, format='JPEG', quality=90) + full_buffer.seek(0) + full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8') + image_data_uri = f"data:image/jpeg;base64,{full_b64}" + + return KCTestResponse( + image=image_data_uri, + rectangles=result_rects, + interpolation_mode=settings.interpolation_mode, + pattern_template_name=pattern_tmpl.name, + ) + + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except RuntimeError as e: + raise HTTPException(status_code=500, detail=f"Capture error: {str(e)}") + except Exception as e: + logger.error(f"Failed to test KC target: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + finally: + if stream: + try: + stream.cleanup() + except Exception as e: + logger.error(f"Error cleaning up test stream: {e}") + + @router.websocket("/api/v1/picture-targets/{target_id}/ws") async def target_colors_ws( websocket: WebSocket, diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index 9769271..729b53c 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -151,3 +151,23 @@ class TargetMetricsResponse(BaseModel): errors_count: int = Field(description="Total error count") last_error: Optional[str] = Field(None, description="Last error message") last_update: Optional[datetime] = Field(None, description="Last update timestamp") + + +class KCTestRectangleResponse(BaseModel): + """A rectangle with its extracted color from a KC test.""" + + name: str = Field(description="Rectangle name") + x: float = Field(description="Left edge (0.0-1.0)") + y: float = Field(description="Top edge (0.0-1.0)") + width: float = Field(description="Width (0.0-1.0)") + height: float = Field(description="Height (0.0-1.0)") + color: ExtractedColorResponse = Field(description="Extracted color for this rectangle") + + +class KCTestResponse(BaseModel): + """Response from testing a KC target.""" + + image: str = Field(description="Base64 data URI of the captured frame") + rectangles: List[KCTestRectangleResponse] = Field(description="Rectangles with extracted colors") + interpolation_mode: str = Field(description="Color extraction mode used") + pattern_template_name: str = Field(description="Pattern template name") diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index fe927f4..a073e70 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -85,7 +85,10 @@ function closeLightbox(event) { // Revoke blob URL if one was used if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src); img.src = ''; + img.style.display = ''; document.getElementById('lightbox-stats').style.display = 'none'; + const spinner = lightbox.querySelector('.lightbox-spinner'); + if (spinner) spinner.style.display = 'none'; unlockBody(); } @@ -4152,7 +4155,6 @@ function createTargetCard(target, deviceMap, sourceMap) {
${c.hex}`;
+ statsHtml += `