Add partial LED side coverage (edge spans) for calibration
Some checks failed
Validate / validate (push) Failing after 8s

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 02:28:36 +03:00
parent 01114e125e
commit 2b953e2e3e
5 changed files with 338 additions and 17 deletions

View File

@@ -110,6 +110,15 @@ class Calibration(BaseModel):
leds_right: int = Field(default=0, ge=0, description="Number of LEDs on the right edge") 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_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") 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.01.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): class CalibrationTestModeRequest(BaseModel):

View File

@@ -65,6 +65,15 @@ class CalibrationConfig:
leds_right: int = 0 leds_right: int = 0
leds_bottom: int = 0 leds_bottom: int = 0
leds_left: int = 0 leds_left: int = 0
# Per-edge span: fraction of screen side covered by LEDs (0.01.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]: def build_segments(self) -> List[CalibrationSegment]:
"""Derive segment list from core parameters.""" """Derive segment list from core parameters."""
@@ -99,6 +108,13 @@ class CalibrationConfig:
"""Get derived segment list.""" """Get derived segment list."""
return self.build_segments() 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: def validate(self) -> bool:
"""Validate calibration configuration. """Validate calibration configuration.
@@ -117,6 +133,13 @@ class CalibrationConfig:
if count < 0: if count < 0:
raise ValueError(f"LED count for {edge} must be non-negative, got {count}") 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 return True
def get_total_leds(self) -> int: def get_total_leds(self) -> int:
@@ -202,6 +225,20 @@ class PixelMapper:
else: # left else: # left
edge_pixels = border_pixels.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 # Divide edge into segments matching LED count
try: try:
pixel_segments = get_edge_segments( pixel_segments = get_edge_segments(
@@ -333,6 +370,14 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
leds_right=data.get("leds_right", 0), leds_right=data.get("leds_right", 0),
leds_bottom=data.get("leds_bottom", 0), leds_bottom=data.get("leds_bottom", 0),
leds_left=data.get("leds_left", 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() config.validate()
@@ -355,7 +400,7 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
Returns: Returns:
Dictionary representation Dictionary representation
""" """
return { result = {
"layout": config.layout, "layout": config.layout,
"start_position": config.start_position, "start_position": config.start_position,
"offset": config.offset, "offset": config.offset,
@@ -364,3 +409,11 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
"leds_bottom": config.leds_bottom, "leds_bottom": config.leds_bottom,
"leds_left": config.leds_left, "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

View File

@@ -1057,6 +1057,14 @@ async function showCalibration(deviceId) {
document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0; document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0;
document.getElementById('cal-left-leds').value = calibration.leds_left || 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 // Snapshot initial values for dirty checking
calibrationInitialValues = { calibrationInitialValues = {
start_position: calibration.start_position, start_position: calibration.start_position,
@@ -1066,6 +1074,7 @@ async function showCalibration(deviceId) {
right: String(calibration.leds_right || 0), right: String(calibration.leds_right || 0),
bottom: String(calibration.leds_bottom || 0), bottom: String(calibration.leds_bottom || 0),
left: String(calibration.leds_left || 0), left: String(calibration.leds_left || 0),
spans: JSON.stringify(window.edgeSpans),
}; };
// Initialize test mode state for this device // Initialize test mode state for this device
@@ -1079,7 +1088,8 @@ async function showCalibration(deviceId) {
modal.style.display = 'flex'; modal.style.display = 'flex';
lockBody(); lockBody();
// Render canvas after layout settles // Initialize span drag and render canvas after layout settles
initSpanDrag();
requestAnimationFrame(() => renderCalibrationCanvas()); requestAnimationFrame(() => renderCalibrationCanvas());
} catch (error) { } catch (error) {
@@ -1096,7 +1106,8 @@ function isCalibrationDirty() {
document.getElementById('cal-top-leds').value !== calibrationInitialValues.top || document.getElementById('cal-top-leds').value !== calibrationInitialValues.top ||
document.getElementById('cal-right-leds').value !== calibrationInitialValues.right || document.getElementById('cal-right-leds').value !== calibrationInitialValues.right ||
document.getElementById('cal-bottom-leds').value !== calibrationInitialValues.bottom || 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(); renderCalibrationCanvas();
} }
@@ -1237,12 +1249,16 @@ function renderCalibrationCanvas() {
const cw = 56; const cw = 56;
const ch = 36; 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 = { const edgeGeometry = {
top: { x1: ox + cw, x2: ox + cW - cw, midY: oy + ch / 2, horizontal: true }, 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, x2: ox + cW - cw, midY: oy + cH - 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, y2: oy + cH - ch, midX: ox + cw / 2, horizontal: false }, 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, y2: oy + cH - ch, midX: ox + cW - 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) // 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) { function setStartPosition(position) {
document.getElementById('cal-start-position').value = position; document.getElementById('cal-start-position').value = position;
updateCalibrationPreview(); updateCalibrationPreview();
@@ -1501,6 +1637,7 @@ async function saveCalibration() {
const layout = document.getElementById('cal-layout').value; const layout = document.getElementById('cal-layout').value;
const offset = parseInt(document.getElementById('cal-offset').value || 0); const offset = parseInt(document.getElementById('cal-offset').value || 0);
const spans = window.edgeSpans || {};
const calibration = { const calibration = {
layout: layout, layout: layout,
start_position: startPosition, start_position: startPosition,
@@ -1508,7 +1645,15 @@ async function saveCalibration() {
leds_top: topLeds, leds_top: topLeds,
leds_right: rightLeds, leds_right: rightLeds,
leds_bottom: bottomLeds, 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 { try {

View File

@@ -102,18 +102,34 @@
<!-- Clickable edge bars with LED count inputs --> <!-- Clickable edge bars with LED count inputs -->
<div class="preview-edge edge-top" onclick="toggleTestEdge('top')"> <div class="preview-edge edge-top" onclick="toggleTestEdge('top')">
<div class="edge-span-bar" data-edge="top">
<div class="edge-span-handle edge-span-handle-start" data-edge="top" data-handle="start"></div>
<div class="edge-span-handle edge-span-handle-end" data-edge="top" data-handle="end"></div>
</div>
<input type="number" id="cal-top-leds" class="edge-led-input" min="0" value="0" <input type="number" id="cal-top-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()" onclick="event.stopPropagation()"> oninput="updateCalibrationPreview()" onclick="event.stopPropagation()">
</div> </div>
<div class="preview-edge edge-right" onclick="toggleTestEdge('right')"> <div class="preview-edge edge-right" onclick="toggleTestEdge('right')">
<div class="edge-span-bar" data-edge="right">
<div class="edge-span-handle edge-span-handle-start" data-edge="right" data-handle="start"></div>
<div class="edge-span-handle edge-span-handle-end" data-edge="right" data-handle="end"></div>
</div>
<input type="number" id="cal-right-leds" class="edge-led-input" min="0" value="0" <input type="number" id="cal-right-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()" onclick="event.stopPropagation()"> oninput="updateCalibrationPreview()" onclick="event.stopPropagation()">
</div> </div>
<div class="preview-edge edge-bottom" onclick="toggleTestEdge('bottom')"> <div class="preview-edge edge-bottom" onclick="toggleTestEdge('bottom')">
<div class="edge-span-bar" data-edge="bottom">
<div class="edge-span-handle edge-span-handle-start" data-edge="bottom" data-handle="start"></div>
<div class="edge-span-handle edge-span-handle-end" data-edge="bottom" data-handle="end"></div>
</div>
<input type="number" id="cal-bottom-leds" class="edge-led-input" min="0" value="0" <input type="number" id="cal-bottom-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()" onclick="event.stopPropagation()"> oninput="updateCalibrationPreview()" onclick="event.stopPropagation()">
</div> </div>
<div class="preview-edge edge-left" onclick="toggleTestEdge('left')"> <div class="preview-edge edge-left" onclick="toggleTestEdge('left')">
<div class="edge-span-bar" data-edge="left">
<div class="edge-span-handle edge-span-handle-start" data-edge="left" data-handle="start"></div>
<div class="edge-span-handle edge-span-handle-end" data-edge="left" data-handle="end"></div>
</div>
<input type="number" id="cal-left-leds" class="edge-led-input" min="0" value="0" <input type="number" id="cal-left-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()" onclick="event.stopPropagation()"> oninput="updateCalibrationPreview()" onclick="event.stopPropagation()">
</div> </div>

View File

@@ -519,10 +519,15 @@ section {
.layout-index-label { .layout-index-label {
position: absolute; position: absolute;
bottom: 4px; bottom: 6px;
left: 6px; left: 6px;
font-size: 0.7rem; font-size: 0.85rem;
color: var(--text-secondary); 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 { .layout-display-label {
@@ -546,11 +551,12 @@ section {
.primary-indicator { .primary-indicator {
position: absolute; position: absolute;
top: 5px; top: 2px;
right: 5px; right: 4px;
color: var(--primary-color); color: var(--primary-color);
font-size: 1.2rem; font-size: 1.5rem;
text-shadow: 0 0 3px rgba(0, 0, 0, 0.3); line-height: 1;
text-shadow: 0 0 4px rgba(0, 0, 0, 0.4);
} }
.layout-legend { .layout-legend {
@@ -1084,6 +1090,98 @@ input:-webkit-autofill:focus {
-moz-appearance: textfield; -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 */ /* Corner start-position buttons */
.preview-corner { .preview-corner {
position: absolute; position: absolute;