From 2b953e2e3ef36602b36b73085383d21a70e73627 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 9 Feb 2026 02:28:36 +0300 Subject: [PATCH] Add partial LED side coverage (edge spans) for calibration Allow LEDs to cover only a fraction of each screen edge via draggable span bars in the calibration UI. Per-edge start/end (0.0-1.0) values control which portion of the screen border is sampled for LED colors. Co-Authored-By: Claude Opus 4.6 --- server/src/wled_controller/api/schemas.py | 9 + .../src/wled_controller/core/calibration.py | 55 +++++- server/src/wled_controller/static/app.js | 163 +++++++++++++++++- server/src/wled_controller/static/index.html | 16 ++ server/src/wled_controller/static/style.css | 112 +++++++++++- 5 files changed, 338 insertions(+), 17 deletions(-) diff --git a/server/src/wled_controller/api/schemas.py b/server/src/wled_controller/api/schemas.py index 8f25811..842837e 100644 --- a/server/src/wled_controller/api/schemas.py +++ b/server/src/wled_controller/api/schemas.py @@ -110,6 +110,15 @@ class Calibration(BaseModel): leds_right: int = Field(default=0, ge=0, description="Number of LEDs on the right edge") leds_bottom: int = Field(default=0, ge=0, description="Number of LEDs on the bottom edge") leds_left: int = Field(default=0, ge=0, description="Number of LEDs on the left edge") + # Per-edge span: fraction of screen side covered by LEDs (0.0–1.0) + span_top_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of top edge coverage") + span_top_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of top edge coverage") + span_right_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of right edge coverage") + span_right_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of right edge coverage") + span_bottom_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of bottom edge coverage") + span_bottom_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of bottom edge coverage") + span_left_start: float = Field(default=0.0, ge=0.0, le=1.0, description="Start of left edge coverage") + span_left_end: float = Field(default=1.0, ge=0.0, le=1.0, description="End of left edge coverage") class CalibrationTestModeRequest(BaseModel): diff --git a/server/src/wled_controller/core/calibration.py b/server/src/wled_controller/core/calibration.py index 908fcce..812b467 100644 --- a/server/src/wled_controller/core/calibration.py +++ b/server/src/wled_controller/core/calibration.py @@ -65,6 +65,15 @@ class CalibrationConfig: leds_right: int = 0 leds_bottom: int = 0 leds_left: int = 0 + # Per-edge span: fraction of screen side covered by LEDs (0.0–1.0) + span_top_start: float = 0.0 + span_top_end: float = 1.0 + span_right_start: float = 0.0 + span_right_end: float = 1.0 + span_bottom_start: float = 0.0 + span_bottom_end: float = 1.0 + span_left_start: float = 0.0 + span_left_end: float = 1.0 def build_segments(self) -> List[CalibrationSegment]: """Derive segment list from core parameters.""" @@ -99,6 +108,13 @@ class CalibrationConfig: """Get derived segment list.""" return self.build_segments() + def get_edge_span(self, edge: str) -> tuple[float, float]: + """Get span (start, end) for a given edge.""" + return ( + getattr(self, f"span_{edge}_start", 0.0), + getattr(self, f"span_{edge}_end", 1.0), + ) + def validate(self) -> bool: """Validate calibration configuration. @@ -117,6 +133,13 @@ class CalibrationConfig: if count < 0: raise ValueError(f"LED count for {edge} must be non-negative, got {count}") + for edge in ["top", "right", "bottom", "left"]: + start, end = self.get_edge_span(edge) + if not (0.0 <= start <= 1.0) or not (0.0 <= end <= 1.0): + raise ValueError(f"Span for {edge} must be in [0.0, 1.0], got ({start}, {end})") + if end <= start: + raise ValueError(f"Span end must be greater than start for {edge}, got ({start}, {end})") + return True def get_total_leds(self) -> int: @@ -202,6 +225,20 @@ class PixelMapper: else: # left edge_pixels = border_pixels.left + # Slice to span region if not full coverage + span_start, span_end = self.calibration.get_edge_span(edge_name) + if span_start > 0.0 or span_end < 1.0: + if edge_name in ("top", "bottom"): + total_w = edge_pixels.shape[1] + s = int(span_start * total_w) + e = int(span_end * total_w) + edge_pixels = edge_pixels[:, s:e, :] + else: + total_h = edge_pixels.shape[0] + s = int(span_start * total_h) + e = int(span_end * total_h) + edge_pixels = edge_pixels[s:e, :, :] + # Divide edge into segments matching LED count try: pixel_segments = get_edge_segments( @@ -333,6 +370,14 @@ def calibration_from_dict(data: dict) -> CalibrationConfig: leds_right=data.get("leds_right", 0), leds_bottom=data.get("leds_bottom", 0), leds_left=data.get("leds_left", 0), + span_top_start=data.get("span_top_start", 0.0), + span_top_end=data.get("span_top_end", 1.0), + span_right_start=data.get("span_right_start", 0.0), + span_right_end=data.get("span_right_end", 1.0), + span_bottom_start=data.get("span_bottom_start", 0.0), + span_bottom_end=data.get("span_bottom_end", 1.0), + span_left_start=data.get("span_left_start", 0.0), + span_left_end=data.get("span_left_end", 1.0), ) config.validate() @@ -355,7 +400,7 @@ def calibration_to_dict(config: CalibrationConfig) -> dict: Returns: Dictionary representation """ - return { + result = { "layout": config.layout, "start_position": config.start_position, "offset": config.offset, @@ -364,3 +409,11 @@ def calibration_to_dict(config: CalibrationConfig) -> dict: "leds_bottom": config.leds_bottom, "leds_left": config.leds_left, } + # Include span fields only when not default (full coverage) + for edge in ["top", "right", "bottom", "left"]: + start = getattr(config, f"span_{edge}_start") + end = getattr(config, f"span_{edge}_end") + if start != 0.0 or end != 1.0: + result[f"span_{edge}_start"] = start + result[f"span_{edge}_end"] = end + return result diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 49956a2..85f0bb2 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -1057,6 +1057,14 @@ async function showCalibration(deviceId) { document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0; document.getElementById('cal-left-leds').value = calibration.leds_left || 0; + // Initialize edge spans + window.edgeSpans = { + top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 }, + right: { start: calibration.span_right_start ?? 0, end: calibration.span_right_end ?? 1 }, + bottom: { start: calibration.span_bottom_start ?? 0, end: calibration.span_bottom_end ?? 1 }, + left: { start: calibration.span_left_start ?? 0, end: calibration.span_left_end ?? 1 }, + }; + // Snapshot initial values for dirty checking calibrationInitialValues = { start_position: calibration.start_position, @@ -1066,6 +1074,7 @@ async function showCalibration(deviceId) { right: String(calibration.leds_right || 0), bottom: String(calibration.leds_bottom || 0), left: String(calibration.leds_left || 0), + spans: JSON.stringify(window.edgeSpans), }; // Initialize test mode state for this device @@ -1079,7 +1088,8 @@ async function showCalibration(deviceId) { modal.style.display = 'flex'; lockBody(); - // Render canvas after layout settles + // Initialize span drag and render canvas after layout settles + initSpanDrag(); requestAnimationFrame(() => renderCalibrationCanvas()); } catch (error) { @@ -1096,7 +1106,8 @@ function isCalibrationDirty() { document.getElementById('cal-top-leds').value !== calibrationInitialValues.top || document.getElementById('cal-right-leds').value !== calibrationInitialValues.right || document.getElementById('cal-bottom-leds').value !== calibrationInitialValues.bottom || - document.getElementById('cal-left-leds').value !== calibrationInitialValues.left + document.getElementById('cal-left-leds').value !== calibrationInitialValues.left || + JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans ); } @@ -1176,7 +1187,8 @@ function updateCalibrationPreview() { } }); - // Render canvas overlay (ticks, arrows, start label) + // Position span bars and render canvas overlay + updateSpanBars(); renderCalibrationCanvas(); } @@ -1237,12 +1249,16 @@ function renderCalibrationCanvas() { const cw = 56; const ch = 36; - // Edge midlines (center of each edge bar) - in canvas coords + // Span-aware edge geometry: ticks/arrows render only within the span region + const spans = window.edgeSpans || {}; + const edgeLenH = cW - 2 * cw; + const edgeLenV = cH - 2 * ch; + const edgeGeometry = { - top: { x1: ox + cw, x2: ox + cW - cw, midY: oy + ch / 2, horizontal: true }, - bottom: { x1: ox + cw, x2: ox + cW - cw, midY: oy + cH - ch / 2, horizontal: true }, - left: { y1: oy + ch, y2: oy + cH - ch, midX: ox + cw / 2, horizontal: false }, - right: { y1: oy + ch, y2: oy + cH - ch, midX: ox + cW - cw / 2, horizontal: false }, + top: { x1: ox + cw + (spans.top?.start || 0) * edgeLenH, x2: ox + cw + (spans.top?.end || 1) * edgeLenH, midY: oy + ch / 2, horizontal: true }, + bottom: { x1: ox + cw + (spans.bottom?.start || 0) * edgeLenH, x2: ox + cw + (spans.bottom?.end || 1) * edgeLenH, midY: oy + cH - ch / 2, horizontal: true }, + left: { y1: oy + ch + (spans.left?.start || 0) * edgeLenV, y2: oy + ch + (spans.left?.end || 1) * edgeLenV, midX: ox + cw / 2, horizontal: false }, + right: { y1: oy + ch + (spans.right?.start || 0) * edgeLenV, y2: oy + ch + (spans.right?.end || 1) * edgeLenV, midX: ox + cW - cw / 2, horizontal: false }, }; // Axis positions for labels (outside the container bounds, in the padding area) @@ -1397,6 +1413,126 @@ function renderCalibrationCanvas() { } +function updateSpanBars() { + const spans = window.edgeSpans || {}; + ['top', 'right', 'bottom', 'left'].forEach(edge => { + const bar = document.querySelector(`.edge-span-bar[data-edge="${edge}"]`); + if (!bar) return; + const span = spans[edge] || { start: 0, end: 1 }; + const edgeEl = bar.parentElement; + const isHorizontal = (edge === 'top' || edge === 'bottom'); + + if (isHorizontal) { + const totalWidth = edgeEl.clientWidth; + bar.style.left = (span.start * totalWidth) + 'px'; + bar.style.width = ((span.end - span.start) * totalWidth) + 'px'; + } else { + const totalHeight = edgeEl.clientHeight; + bar.style.top = (span.start * totalHeight) + 'px'; + bar.style.height = ((span.end - span.start) * totalHeight) + 'px'; + } + }); +} + +function initSpanDrag() { + const MIN_SPAN = 0.05; + + document.querySelectorAll('.edge-span-bar').forEach(bar => { + const edge = bar.dataset.edge; + const isHorizontal = (edge === 'top' || edge === 'bottom'); + + // Prevent edge click-through when interacting with span bar + bar.addEventListener('click', e => e.stopPropagation()); + + // Handle resize via handles + bar.querySelectorAll('.edge-span-handle').forEach(handle => { + handle.addEventListener('mousedown', e => { + e.preventDefault(); + e.stopPropagation(); + const handleType = handle.dataset.handle; + const edgeEl = bar.parentElement; + const rect = edgeEl.getBoundingClientRect(); + + function onMouseMove(ev) { + const span = window.edgeSpans[edge]; + let fraction; + if (isHorizontal) { + fraction = (ev.clientX - rect.left) / rect.width; + } else { + fraction = (ev.clientY - rect.top) / rect.height; + } + fraction = Math.max(0, Math.min(1, fraction)); + + if (handleType === 'start') { + span.start = Math.min(fraction, span.end - MIN_SPAN); + } else { + span.end = Math.max(fraction, span.start + MIN_SPAN); + } + + updateSpanBars(); + renderCalibrationCanvas(); + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + }); + + // Handle body drag (move entire span) + bar.addEventListener('mousedown', e => { + if (e.target.classList.contains('edge-span-handle')) return; + e.preventDefault(); + e.stopPropagation(); + + const edgeEl = bar.parentElement; + const rect = edgeEl.getBoundingClientRect(); + const span = window.edgeSpans[edge]; + const spanWidth = span.end - span.start; + + let startFraction; + if (isHorizontal) { + startFraction = (e.clientX - rect.left) / rect.width; + } else { + startFraction = (e.clientY - rect.top) / rect.height; + } + const offsetInSpan = startFraction - span.start; + + function onMouseMove(ev) { + let fraction; + if (isHorizontal) { + fraction = (ev.clientX - rect.left) / rect.width; + } else { + fraction = (ev.clientY - rect.top) / rect.height; + } + + let newStart = fraction - offsetInSpan; + newStart = Math.max(0, Math.min(1 - spanWidth, newStart)); + span.start = newStart; + span.end = newStart + spanWidth; + + updateSpanBars(); + renderCalibrationCanvas(); + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + }); + + // Initial positioning + updateSpanBars(); +} + function setStartPosition(position) { document.getElementById('cal-start-position').value = position; updateCalibrationPreview(); @@ -1501,6 +1637,7 @@ async function saveCalibration() { const layout = document.getElementById('cal-layout').value; const offset = parseInt(document.getElementById('cal-offset').value || 0); + const spans = window.edgeSpans || {}; const calibration = { layout: layout, start_position: startPosition, @@ -1508,7 +1645,15 @@ async function saveCalibration() { leds_top: topLeds, leds_right: rightLeds, leds_bottom: bottomLeds, - leds_left: leftLeds + leds_left: leftLeds, + span_top_start: spans.top?.start ?? 0, + span_top_end: spans.top?.end ?? 1, + span_right_start: spans.right?.start ?? 0, + span_right_end: spans.right?.end ?? 1, + span_bottom_start: spans.bottom?.start ?? 0, + span_bottom_end: spans.bottom?.end ?? 1, + span_left_start: spans.left?.start ?? 0, + span_left_end: spans.left?.end ?? 1, }; try { diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 7bc7f9f..3dfbf69 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -102,18 +102,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css index 0ed0afc..da1b71a 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -519,10 +519,15 @@ section { .layout-index-label { position: absolute; - bottom: 4px; + bottom: 6px; left: 6px; - font-size: 0.7rem; - color: var(--text-secondary); + font-size: 0.85rem; + font-weight: 600; + color: var(--text-color); + background: rgba(0, 0, 0, 0.4); + padding: 1px 6px; + border-radius: 4px; + letter-spacing: 0.5px; } .layout-display-label { @@ -546,11 +551,12 @@ section { .primary-indicator { position: absolute; - top: 5px; - right: 5px; + top: 2px; + right: 4px; color: var(--primary-color); - font-size: 1.2rem; - text-shadow: 0 0 3px rgba(0, 0, 0, 0.3); + font-size: 1.5rem; + line-height: 1; + text-shadow: 0 0 4px rgba(0, 0, 0, 0.4); } .layout-legend { @@ -1084,6 +1090,98 @@ input:-webkit-autofill:focus { -moz-appearance: textfield; } +/* Edge span bars */ +.edge-span-bar { + position: absolute; + background: rgba(76, 175, 80, 0.3); + border: 1px solid rgba(76, 175, 80, 0.5); + border-radius: 3px; + cursor: grab; + z-index: 1; + transition: background 0.15s; +} + +.edge-span-bar:hover { + background: rgba(76, 175, 80, 0.45); +} + +.edge-span-bar:active { + cursor: grabbing; +} + +/* Horizontal edges: bar spans left-right */ +.edge-top .edge-span-bar, +.edge-bottom .edge-span-bar { + top: 2px; + bottom: 2px; +} + +/* Vertical edges: bar spans top-bottom */ +.edge-left .edge-span-bar, +.edge-right .edge-span-bar { + left: 2px; + right: 2px; +} + +/* Resize handles */ +.edge-span-handle { + position: absolute; + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(76, 175, 80, 0.7); + border-radius: 2px; + z-index: 2; + opacity: 0; + transition: opacity 0.15s; +} + +.edge-span-bar:hover .edge-span-handle { + opacity: 1; +} + +/* Horizontal handles */ +.edge-top .edge-span-handle, +.edge-bottom .edge-span-handle { + top: 2px; + bottom: 2px; + width: 6px; + cursor: ew-resize; +} + +.edge-top .edge-span-handle-start, +.edge-bottom .edge-span-handle-start { + left: 0; +} + +.edge-top .edge-span-handle-end, +.edge-bottom .edge-span-handle-end { + right: 0; +} + +/* Vertical handles */ +.edge-left .edge-span-handle, +.edge-right .edge-span-handle { + left: 2px; + right: 2px; + height: 6px; + cursor: ns-resize; +} + +.edge-left .edge-span-handle-start, +.edge-right .edge-span-handle-start { + top: 0; +} + +.edge-left .edge-span-handle-end, +.edge-right .edge-span-handle-end { + bottom: 0; +} + +/* Ensure LED input is above span bar */ +.edge-led-input { + position: relative; + z-index: 2; +} + /* Corner start-position buttons */ .preview-corner { position: absolute;