From afce183f79aaee6a0ef8fd670f68841c6b53d2d0 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 16 Feb 2026 10:55:21 +0300 Subject: [PATCH] Add skip LEDs feature with physical resampling and per-edge tick labels Skip LEDs at the start/end of the strip are blacked out while the full screen perimeter is resampled onto the remaining active LEDs using linear interpolation. Calibration canvas tick labels show per-edge display ranges clipped to the active LED range. Moved LED offset control from inline overlay to a dedicated form row alongside the new skip inputs. Co-Authored-By: Claude Opus 4.6 --- .../wled_controller/api/schemas/devices.py | 3 ++ .../src/wled_controller/core/calibration.py | 51 ++++++++++++++++++- server/src/wled_controller/static/app.js | 39 +++++++++++--- server/src/wled_controller/static/index.html | 32 ++++++++++-- .../wled_controller/static/locales/en.json | 7 +++ .../wled_controller/static/locales/ru.json | 7 +++ server/src/wled_controller/static/style.css | 34 ------------- 7 files changed, 126 insertions(+), 47 deletions(-) diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py index 5af1f02..0656f37 100644 --- a/server/src/wled_controller/api/schemas/devices.py +++ b/server/src/wled_controller/api/schemas/devices.py @@ -50,6 +50,9 @@ class Calibration(BaseModel): 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") + # Skip LEDs at start/end of strip + skip_leds_start: int = Field(default=0, ge=0, description="LEDs to skip (black out) at the start of the strip") + skip_leds_end: int = Field(default=0, ge=0, description="LEDs to skip (black out) at the end of the strip") class CalibrationTestModeRequest(BaseModel): diff --git a/server/src/wled_controller/core/calibration.py b/server/src/wled_controller/core/calibration.py index 1e15ac9..c7a0e83 100644 --- a/server/src/wled_controller/core/calibration.py +++ b/server/src/wled_controller/core/calibration.py @@ -76,6 +76,9 @@ class CalibrationConfig: span_bottom_end: float = 1.0 span_left_start: float = 0.0 span_left_end: float = 1.0 + # Skip LEDs: black out N LEDs at the start/end of the strip + skip_leds_start: int = 0 + skip_leds_end: int = 0 def build_segments(self) -> List[CalibrationSegment]: """Derive segment list from core parameters.""" @@ -263,8 +266,12 @@ class PixelMapper: ValueError: If border pixels don't match calibration """ total_leds = self.calibration.get_total_leds() + skip_start = self.calibration.skip_leds_start + skip_end = self.calibration.skip_leds_end + active_count = max(0, total_leds - skip_start - skip_end) use_fast_avg = self.interpolation_mode == "average" + # Phase 1: Map full perimeter to total_leds positions if use_fast_avg: led_array = np.zeros((total_leds, 3), dtype=np.uint8) else: @@ -304,16 +311,51 @@ class PixelMapper: color = self._calc_color(pixel_segment) led_colors[led_idx] = color + # Phase 2: Offset rotation offset = self.calibration.offset % total_leds if total_leds > 0 else 0 if use_fast_avg: if offset > 0: led_array = np.roll(led_array, offset, axis=0) + + # Phase 3: Physical skip — resample full perimeter to active LEDs + # Maps the entire screen to active_count positions so each active LED + # covers a proportionally larger slice of the perimeter. + if active_count > 0 and active_count < total_leds: + src = np.linspace(0, total_leds - 1, active_count) + full_f = led_array.astype(np.float64) + x = np.arange(total_leds, dtype=np.float64) + resampled = np.empty((active_count, 3), dtype=np.uint8) + for ch in range(3): + resampled[:, ch] = np.round( + np.interp(src, x, full_f[:, ch]) + ).astype(np.uint8) + led_array[:] = 0 + end_idx = total_leds - skip_end + led_array[skip_start:end_idx] = resampled + elif active_count <= 0: + led_array[:] = 0 + return [tuple(c) for c in led_array] else: if offset > 0: led_colors = led_colors[total_leds - offset:] + led_colors[:total_leds - offset] - logger.debug(f"Mapped border pixels to {total_leds} LED colors (offset={offset})") + + # Phase 3: Physical skip — resample full perimeter to active LEDs + if active_count > 0 and active_count < total_leds: + arr = np.array(led_colors, dtype=np.float64) + src = np.linspace(0, total_leds - 1, active_count) + x = np.arange(total_leds, dtype=np.float64) + resampled = np.empty((active_count, 3), dtype=np.float64) + for ch in range(3): + resampled[:, ch] = np.interp(src, x, arr[:, ch]) + led_colors = [(0, 0, 0)] * total_leds + for i in range(active_count): + r, g, b = resampled[i] + led_colors[skip_start + i] = (int(round(r)), int(round(g)), int(round(b))) + elif active_count <= 0: + led_colors = [(0, 0, 0)] * total_leds + return led_colors def test_calibration(self, edge: str, color: Tuple[int, int, int]) -> List[Tuple[int, int, int]]: @@ -419,6 +461,8 @@ def calibration_from_dict(data: dict) -> CalibrationConfig: 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), + skip_leds_start=data.get("skip_leds_start", 0), + skip_leds_end=data.get("skip_leds_end", 0), ) config.validate() @@ -457,4 +501,9 @@ def calibration_to_dict(config: CalibrationConfig) -> dict: if start != 0.0 or end != 1.0: result[f"span_{edge}_start"] = start result[f"span_{edge}_end"] = end + # Include skip fields only when non-default + if config.skip_leds_start > 0: + result["skip_leds_start"] = config.skip_leds_start + if config.skip_leds_end > 0: + result["skip_leds_end"] = config.skip_leds_end return result diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 3104ba8..596082f 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -1057,6 +1057,10 @@ async function showCalibration(deviceId) { document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0; document.getElementById('cal-left-leds').value = calibration.leds_left || 0; + // Set skip LEDs + document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0; + document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0; + // Initialize edge spans window.edgeSpans = { top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 }, @@ -1075,6 +1079,8 @@ async function showCalibration(deviceId) { bottom: String(calibration.leds_bottom || 0), left: String(calibration.leds_left || 0), spans: JSON.stringify(window.edgeSpans), + skip_start: String(calibration.skip_leds_start || 0), + skip_end: String(calibration.skip_leds_end || 0), }; // Initialize test mode state for this device @@ -1123,7 +1129,9 @@ function isCalibrationDirty() { document.getElementById('cal-right-leds').value !== calibrationInitialValues.right || document.getElementById('cal-bottom-leds').value !== calibrationInitialValues.bottom || document.getElementById('cal-left-leds').value !== calibrationInitialValues.left || - JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans + JSON.stringify(window.edgeSpans) !== calibrationInitialValues.spans || + document.getElementById('cal-skip-start').value !== calibrationInitialValues.skip_start || + document.getElementById('cal-skip-end').value !== calibrationInitialValues.skip_end ); } @@ -1260,11 +1268,14 @@ function renderCalibrationCanvas() { leds_bottom: parseInt(document.getElementById('cal-bottom-leds').value || 0), leds_left: parseInt(document.getElementById('cal-left-leds').value || 0), }; + const skipStart = parseInt(document.getElementById('cal-skip-start').value || 0); + const skipEnd = parseInt(document.getElementById('cal-skip-end').value || 0); const segments = buildSegments(calibration); if (segments.length === 0) return; const totalLeds = calibration.leds_top + calibration.leds_right + calibration.leds_bottom + calibration.leds_left; + const hasSkip = (skipStart > 0 || skipEnd > 0) && totalLeds > 1; // Theme-aware colors const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; @@ -1314,6 +1325,16 @@ function renderCalibrationCanvas() { const count = seg.led_count; if (count === 0) return; + // Per-edge display range: clip to active LED range when skip is set + const edgeDisplayStart = hasSkip ? Math.max(seg.led_start, skipStart) : seg.led_start; + const edgeDisplayEnd = hasSkip ? Math.min(seg.led_start + count, totalLeds - skipEnd) : seg.led_start + count - 1; + const edgeDisplayRange = edgeDisplayEnd - edgeDisplayStart; + const toEdgeLabel = (i) => { + if (!hasSkip) return totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i; + if (count <= 1) return edgeDisplayStart; + return Math.round(edgeDisplayStart + i / (count - 1) * edgeDisplayRange); + }; + // Edge boundary ticks (first/last LED on edge) and special ticks (LED 0 position) const edgeBounds = new Set(); edgeBounds.add(0); @@ -1356,8 +1377,7 @@ function renderCalibrationCanvas() { for (let i = 1; i < count - 1; i++) { if (specialTicks.has(i)) continue; - const idx = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i; - if (idx % step === 0) { + if (toEdgeLabel(i) % step === 0) { const px = tickPx(i); if (!placed.some(p => Math.abs(px - p) < minSpacing)) { labelsToShow.add(i); @@ -1393,7 +1413,7 @@ function renderCalibrationCanvas() { labelsToShow.forEach(i => { const fraction = count > 1 ? i / (count - 1) : 0.5; const displayFraction = seg.reverse ? (1 - fraction) : fraction; - const ledIndex = totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i; + const displayLabel = toEdgeLabel(i); const tickLen = edgeBounds.has(i) ? tickLenLong : tickLenShort; if (geo.horizontal) { @@ -1408,7 +1428,7 @@ function renderCalibrationCanvas() { ctx.textAlign = 'center'; ctx.textBaseline = seg.edge === 'top' ? 'bottom' : 'top'; - ctx.fillText(String(ledIndex), tx, axisY - tickDir * 1); + ctx.fillText(String(displayLabel), tx, axisY - tickDir * 1); } else { const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1); const axisX = axisPos[seg.edge]; @@ -1421,7 +1441,7 @@ function renderCalibrationCanvas() { ctx.textBaseline = 'middle'; ctx.textAlign = seg.edge === 'left' ? 'right' : 'left'; - ctx.fillText(String(ledIndex), axisX - tickDir * 1, ty); + ctx.fillText(String(displayLabel), axisX - tickDir * 1, ty); } }); @@ -1752,6 +1772,8 @@ async function saveCalibration() { span_bottom_end: spans.bottom?.end ?? 1, span_left_start: spans.left?.start ?? 0, span_left_end: spans.left?.end ?? 1, + skip_leds_start: parseInt(document.getElementById('cal-skip-start').value || 0), + skip_leds_end: parseInt(document.getElementById('cal-skip-end').value || 0), }; try { @@ -1913,10 +1935,11 @@ const calibrationTutorialSteps = [ { selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' }, { selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' }, { selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' }, - { selector: '.offset-control', textKey: 'calibration.tip.offset', position: 'bottom' }, + { selector: '#cal-offset', textKey: 'calibration.tip.offset', position: 'top' }, { selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' }, { selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' }, - { selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' } + { selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' }, + { selector: '#cal-skip-start', textKey: 'calibration.tip.skip_leds', position: 'top' } ]; const deviceTutorialSteps = [ diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 2cd2dbc..00984e8 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -95,10 +95,6 @@ CW
0 / 0
- @@ -167,6 +163,34 @@ + +
+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 3583799..fcddda9 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -176,6 +176,7 @@ "calibration.tip.span": "Drag green bars to adjust coverage span", "calibration.tip.test": "Click an edge to toggle test LEDs", "calibration.tip.toggle_inputs": "Click total LED count to toggle edge inputs", + "calibration.tip.skip_leds": "Skip LEDs at the start or end of the strip — skipped LEDs stay off", "calibration.tutorial.start": "Start tutorial", "calibration.start_position": "Starting Position:", "calibration.position.bottom_left": "Bottom Left", @@ -189,6 +190,12 @@ "calibration.leds.right": "Right LEDs:", "calibration.leds.bottom": "Bottom LEDs:", "calibration.leds.left": "Left LEDs:", + "calibration.offset": "LED Offset:", + "calibration.offset.hint": "Distance from physical LED 0 to the start corner (along strip direction)", + "calibration.skip_start": "Skip LEDs (Start):", + "calibration.skip_start.hint": "Number of LEDs to turn off at the beginning of the strip (0 = none)", + "calibration.skip_end": "Skip LEDs (End):", + "calibration.skip_end.hint": "Number of LEDs to turn off at the end of the strip (0 = none)", "calibration.button.cancel": "Cancel", "calibration.button.save": "Save", "calibration.saved": "Calibration saved successfully", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index f1117a5..e926a51 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -176,6 +176,7 @@ "calibration.tip.span": "Перетащите зелёные полосы для настройки зоны покрытия", "calibration.tip.test": "Нажмите на край для теста LED", "calibration.tip.toggle_inputs": "Нажмите на общее количество LED для скрытия боковых полей", + "calibration.tip.skip_leds": "Пропуск LED в начале или конце ленты — пропущенные LED остаются выключенными", "calibration.tutorial.start": "Начать обучение", "calibration.start_position": "Начальная Позиция:", "calibration.position.bottom_left": "Нижний Левый", @@ -189,6 +190,12 @@ "calibration.leds.right": "Светодиодов Справа:", "calibration.leds.bottom": "Светодиодов Снизу:", "calibration.leds.left": "Светодиодов Слева:", + "calibration.offset": "Смещение LED:", + "calibration.offset.hint": "Расстояние от физического LED 0 до стартового угла (по направлению ленты)", + "calibration.skip_start": "Пропуск LED (начало):", + "calibration.skip_start.hint": "Количество LED, которые будут выключены в начале ленты (0 = нет)", + "calibration.skip_end": "Пропуск LED (конец):", + "calibration.skip_end.hint": "Количество LED, которые будут выключены в конце ленты (0 = нет)", "calibration.button.cancel": "Отмена", "calibration.button.save": "Сохранить", "calibration.saved": "Калибровка успешно сохранена", diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css index 64476bc..a1c088b 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -1195,40 +1195,6 @@ input:-webkit-autofill:focus { gap: 6px; } -.offset-control { - display: flex; - align-items: center; - gap: 3px; - height: 26px; - padding: 0 10px; - background: rgba(255, 255, 255, 0.15); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 12px; - font-size: 12px; - color: rgba(255, 255, 255, 0.9); - box-sizing: border-box; - cursor: pointer; - user-select: none; -} - -.offset-control input { - width: 36px; - padding: 0; - background: transparent; - border: none; - color: white; - font-size: 12px; - text-align: center; - outline: none; - -moz-appearance: textfield; -} - -.offset-control input::-webkit-outer-spin-button, -.offset-control input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - .preview-edge { position: absolute; display: flex;