Add KC target test button, API docs header link, and UI polish

- Add POST /api/v1/picture-targets/{target_id}/test endpoint for single-frame
  color extraction preview on Key Colors targets
- Add test button on KC target cards that opens lightbox with spinner,
  displays captured frame with rectangle overlays and color swatches
- Add API docs link in WebUI header
- Swap confirm dialog button colors (No=red, Yes=neutral)
- Remove type badges from WLED and KC target cards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 22:10:01 +03:00
parent 8d4dbbcc7f
commit 0da1243fb0
7 changed files with 371 additions and 4 deletions

View File

@@ -1,17 +1,27 @@
"""Picture target routes: CRUD, processing control, settings, state, metrics.""" """Picture target routes: CRUD, processing control, settings, state, metrics."""
import base64
import io
import secrets import secrets
import time
import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from PIL import Image
from wled_controller.api.auth import AuthRequired from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from wled_controller.api.dependencies import (
get_device_store, get_device_store,
get_pattern_template_store,
get_picture_source_store,
get_picture_target_store, get_picture_target_store,
get_processor_manager, get_processor_manager,
get_template_store,
) )
from wled_controller.api.schemas.picture_targets import ( from wled_controller.api.schemas.picture_targets import (
ExtractedColorResponse, ExtractedColorResponse,
KCTestRectangleResponse,
KCTestResponse,
KeyColorsResponse, KeyColorsResponse,
KeyColorsSettingsSchema, KeyColorsSettingsSchema,
PictureTargetCreate, PictureTargetCreate,
@@ -23,8 +33,18 @@ from wled_controller.api.schemas.picture_targets import (
TargetProcessingState, TargetProcessingState,
) )
from wled_controller.config import config 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.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 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.wled_picture_target import WledPictureTarget
from wled_controller.storage.key_colors_picture_target import ( from wled_controller.storage.key_colors_picture_target import (
KeyColorsSettings, KeyColorsSettings,
@@ -531,6 +551,182 @@ async def get_target_colors(
raise HTTPException(status_code=404, detail=str(e)) 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") @router.websocket("/api/v1/picture-targets/{target_id}/ws")
async def target_colors_ws( async def target_colors_ws(
websocket: WebSocket, websocket: WebSocket,

View File

@@ -151,3 +151,23 @@ class TargetMetricsResponse(BaseModel):
errors_count: int = Field(description="Total error count") errors_count: int = Field(description="Total error count")
last_error: Optional[str] = Field(None, description="Last error message") last_error: Optional[str] = Field(None, description="Last error message")
last_update: Optional[datetime] = Field(None, description="Last update timestamp") 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")

View File

@@ -85,7 +85,10 @@ function closeLightbox(event) {
// Revoke blob URL if one was used // Revoke blob URL if one was used
if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src); if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src);
img.src = ''; img.src = '';
img.style.display = '';
document.getElementById('lightbox-stats').style.display = 'none'; document.getElementById('lightbox-stats').style.display = 'none';
const spinner = lightbox.querySelector('.lightbox-spinner');
if (spinner) spinner.style.display = 'none';
unlockBody(); unlockBody();
} }
@@ -4152,7 +4155,6 @@ function createTargetCard(target, deviceMap, sourceMap) {
<div class="card-title"> <div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span> <span class="health-dot ${healthClass}" title="${healthTitle}"></span>
${escapeHtml(target.name)} ${escapeHtml(target.name)}
<span class="badge">${target.target_type.toUpperCase()}</span>
${isProcessing ? `<span class="badge processing">${t('device.status.processing')}</span>` : ''} ${isProcessing ? `<span class="badge processing">${t('device.status.processing')}</span>` : ''}
</div> </div>
</div> </div>
@@ -4295,7 +4297,6 @@ function createKCTargetCard(target, sourceMap, patternTemplateMap) {
<div class="card-header"> <div class="card-header">
<div class="card-title"> <div class="card-title">
${escapeHtml(target.name)} ${escapeHtml(target.name)}
<span class="badge">KEY COLORS</span>
${isProcessing ? `<span class="badge processing">${t('targets.status.processing')}</span>` : ''} ${isProcessing ? `<span class="badge processing">${t('targets.status.processing')}</span>` : ''}
</div> </div>
</div> </div>
@@ -4339,6 +4340,9 @@ function createKCTargetCard(target, sourceMap, patternTemplateMap) {
▶️ ▶️
</button> </button>
`} `}
<button class="btn btn-icon btn-secondary" onclick="testKCTarget('${target.id}')" title="${t('kc.test')}">
🧪
</button>
<button class="btn btn-icon btn-secondary" onclick="showKCEditor('${target.id}')" title="${t('common.edit')}"> <button class="btn btn-icon btn-secondary" onclick="showKCEditor('${target.id}')" title="${t('common.edit')}">
✏️ ✏️
</button> </button>
@@ -4347,6 +4351,131 @@ function createKCTargetCard(target, sourceMap, patternTemplateMap) {
`; `;
} }
// ===== KEY COLORS TEST =====
async function testKCTarget(targetId) {
// Show lightbox immediately with a spinner
const lightbox = document.getElementById('image-lightbox');
const lbImg = document.getElementById('lightbox-image');
const statsEl = document.getElementById('lightbox-stats');
lbImg.style.display = 'none';
lbImg.src = '';
statsEl.style.display = 'none';
// Insert spinner if not already present
let spinner = lightbox.querySelector('.lightbox-spinner');
if (!spinner) {
spinner = document.createElement('div');
spinner.className = 'lightbox-spinner loading-spinner';
lightbox.querySelector('.lightbox-content').prepend(spinner);
}
spinner.style.display = '';
lightbox.classList.add('active');
lockBody();
try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/test`, {
method: 'POST',
headers: getHeaders(),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || response.statusText);
}
const result = await response.json();
displayKCTestResults(result);
} catch (e) {
closeLightbox();
showToast(t('kc.test.error') + ': ' + e.message, 'error');
}
}
function displayKCTestResults(result) {
const srcImg = new window.Image();
srcImg.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = srcImg.width;
canvas.height = srcImg.height;
const ctx = canvas.getContext('2d');
// Draw captured frame
ctx.drawImage(srcImg, 0, 0);
const w = srcImg.width;
const h = srcImg.height;
// Draw each rectangle with extracted color overlay
result.rectangles.forEach((rect, i) => {
const px = rect.x * w;
const py = rect.y * h;
const pw = rect.width * w;
const ph = rect.height * h;
const color = rect.color;
const borderColor = PATTERN_RECT_BORDERS[i % PATTERN_RECT_BORDERS.length];
// Semi-transparent fill with the extracted color
ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 0.3)`;
ctx.fillRect(px, py, pw, ph);
// Border using pattern colors for distinction
ctx.strokeStyle = borderColor;
ctx.lineWidth = 3;
ctx.strokeRect(px, py, pw, ph);
// Color swatch in top-left corner of rect
const swatchSize = Math.max(16, Math.min(32, pw * 0.15));
ctx.fillStyle = color.hex;
ctx.fillRect(px + 4, py + 4, swatchSize, swatchSize);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.strokeRect(px + 4, py + 4, swatchSize, swatchSize);
// Name label with shadow for readability
const fontSize = Math.max(12, Math.min(18, pw * 0.06));
ctx.font = `bold ${fontSize}px sans-serif`;
const labelX = px + swatchSize + 10;
const labelY = py + 4 + swatchSize / 2 + fontSize / 3;
ctx.shadowColor = 'rgba(0,0,0,0.8)';
ctx.shadowBlur = 4;
ctx.fillStyle = '#fff';
ctx.fillText(rect.name, labelX, labelY);
// Hex label below name
ctx.font = `${fontSize - 2}px monospace`;
ctx.fillText(color.hex, labelX, labelY + fontSize + 2);
ctx.shadowBlur = 0;
});
const dataUrl = canvas.toDataURL('image/jpeg', 0.92);
// Build stats HTML
let statsHtml = `<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">`;
statsHtml += `<span style="opacity:0.7;margin-right:8px;">${escapeHtml(result.pattern_template_name)} \u2022 ${escapeHtml(result.interpolation_mode)}</span>`;
result.rectangles.forEach((rect) => {
const c = rect.color;
statsHtml += `<div style="display:flex;align-items:center;gap:4px;">`;
statsHtml += `<div style="width:14px;height:14px;border-radius:3px;border:1px solid rgba(255,255,255,0.4);background:${c.hex};"></div>`;
statsHtml += `<span style="font-size:0.85em;">${escapeHtml(rect.name)} <code>${c.hex}</code></span>`;
statsHtml += `</div>`;
});
statsHtml += `</div>`;
// Hide spinner, show result in the already-open lightbox
const spinner = document.querySelector('.lightbox-spinner');
if (spinner) spinner.style.display = 'none';
const lbImg = document.getElementById('lightbox-image');
const statsEl = document.getElementById('lightbox-stats');
lbImg.src = dataUrl;
lbImg.style.display = '';
statsEl.innerHTML = statsHtml;
statsEl.style.display = '';
};
srcImg.src = result.image;
}
// ===== KEY COLORS EDITOR ===== // ===== KEY COLORS EDITOR =====
let kcEditorInitialValues = {}; let kcEditorInitialValues = {};

View File

@@ -16,6 +16,7 @@
<span id="server-version"><span id="version-number"></span></span> <span id="server-version"><span id="version-number"></span></span>
</div> </div>
<div class="server-info"> <div class="server-info">
<a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a>
<button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="theme.toggle" title="Toggle theme"> <button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="theme.toggle" title="Toggle theme">
<span id="theme-icon">🌙</span> <span id="theme-icon">🌙</span>
</button> </button>
@@ -515,8 +516,8 @@
<p id="confirm-message" class="modal-description"></p> <p id="confirm-message" class="modal-description"></p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" id="confirm-no-btn" onclick="closeConfirmModal(false)">No</button> <button class="btn btn-danger" id="confirm-no-btn" onclick="closeConfirmModal(false)">No</button>
<button class="btn btn-danger" id="confirm-yes-btn" onclick="closeConfirmModal(true)">Yes</button> <button class="btn btn-secondary" id="confirm-yes-btn" onclick="closeConfirmModal(true)">Yes</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
{ {
"app.title": "LED Grab", "app.title": "LED Grab",
"app.version": "Version:", "app.version": "Version:",
"app.api_docs": "API Documentation",
"theme.toggle": "Toggle theme", "theme.toggle": "Toggle theme",
"locale.change": "Change language", "locale.change": "Change language",
"auth.login": "Login", "auth.login": "Login",
@@ -370,6 +371,8 @@
"kc.error.no_pattern": "Please select a pattern template", "kc.error.no_pattern": "Please select a pattern template",
"kc.error.required": "Please fill in all required fields", "kc.error.required": "Please fill in all required fields",
"kc.colors.none": "No colors extracted yet", "kc.colors.none": "No colors extracted yet",
"kc.test": "Test",
"kc.test.error": "Test failed",
"targets.section.pattern_templates": "📄 Pattern Templates", "targets.section.pattern_templates": "📄 Pattern Templates",
"pattern.add": "📄 Add Pattern Template", "pattern.add": "📄 Add Pattern Template",
"pattern.edit": "📄 Edit Pattern Template", "pattern.edit": "📄 Edit Pattern Template",

View File

@@ -1,6 +1,7 @@
{ {
"app.title": "LED Grab", "app.title": "LED Grab",
"app.version": "Версия:", "app.version": "Версия:",
"app.api_docs": "Документация API",
"theme.toggle": "Переключить тему", "theme.toggle": "Переключить тему",
"locale.change": "Изменить язык", "locale.change": "Изменить язык",
"auth.login": "Войти", "auth.login": "Войти",
@@ -370,6 +371,8 @@
"kc.error.no_pattern": "Пожалуйста, выберите шаблон паттерна", "kc.error.no_pattern": "Пожалуйста, выберите шаблон паттерна",
"kc.error.required": "Пожалуйста, заполните все обязательные поля", "kc.error.required": "Пожалуйста, заполните все обязательные поля",
"kc.colors.none": "Цвета пока не извлечены", "kc.colors.none": "Цвета пока не извлечены",
"kc.test": "Тест",
"kc.test.error": "Ошибка теста",
"targets.section.pattern_templates": "📄 Шаблоны Паттернов", "targets.section.pattern_templates": "📄 Шаблоны Паттернов",
"pattern.add": "📄 Добавить Шаблон Паттерна", "pattern.add": "📄 Добавить Шаблон Паттерна",
"pattern.edit": "📄 Редактировать Шаблон Паттерна", "pattern.edit": "📄 Редактировать Шаблон Паттерна",

View File

@@ -91,6 +91,21 @@ h2 {
gap: 15px; gap: 15px;
} }
.header-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.85rem;
font-weight: 500;
padding: 4px 8px;
border-radius: 4px;
transition: color 0.2s, background 0.2s;
}
.header-link:hover {
color: var(--text-color);
background: var(--bg-secondary);
}
#server-version { #server-version {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 400; font-weight: 400;