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 <noreply@anthropic.com>
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -95,10 +95,6 @@
|
||||
<span id="direction-icon">↻</span> <span id="direction-label">CW</span>
|
||||
</button>
|
||||
<div class="preview-screen-total" onclick="toggleEdgeInputs()" title="Toggle edge LED inputs"><span id="cal-total-leds-inline">0</span> / <span id="cal-device-led-count-inline">0</span></div>
|
||||
<label class="offset-control" title="LED offset from LED 0 to start corner">
|
||||
<span>⊕</span>
|
||||
<input type="number" id="cal-offset" min="0" value="0" oninput="updateCalibrationPreview()">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Edge bars with span controls and LED count inputs -->
|
||||
@@ -167,6 +163,34 @@
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Offset & Skip LEDs -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; padding: 0 24px;">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="cal-offset" data-i18n="calibration.offset">LED Offset:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="calibration.offset.hint">Distance from physical LED 0 to the start corner (along strip direction)</small>
|
||||
<input type="number" id="cal-offset" min="0" value="0" oninput="updateCalibrationPreview()">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="cal-skip-start" data-i18n="calibration.skip_start">Skip LEDs (Start):</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="calibration.skip_start.hint">Number of LEDs to turn off at the beginning of the strip (0 = none)</small>
|
||||
<input type="number" id="cal-skip-start" min="0" value="0" oninput="updateCalibrationPreview()">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="cal-skip-end" data-i18n="calibration.skip_end">Skip LEDs (End):</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="calibration.skip_end.hint">Number of LEDs to turn off at the end of the strip (0 = none)</small>
|
||||
<input type="number" id="cal-skip-end" min="0" value="0" oninput="updateCalibrationPreview()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tutorial Overlay -->
|
||||
<div id="tutorial-overlay" class="tutorial-overlay">
|
||||
<div class="tutorial-backdrop"></div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Калибровка успешно сохранена",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user