feat(capture): region-of-interest (ROI) crop for screen sampling

Sample only a sub-rectangle of the captured frame instead of the whole display,
so a taskbar, game HUD, or letterbox bars don't pollute the border colours — the
first functional gap a reviewer hits (capture was full-display only).

- New pure crop_screen_capture() returns a numpy view (no copy), fast-paths the
  full-frame case, and clamps degenerate/out-of-range ROIs to >=1px.
- ROI lives on CalibrationConfig (simple mode) as fractions 0..1 with a has_roi
  helper; applied in the picture color-strip stream just before border
  extraction, clamping border_width to the cropped size. Additive + backward
  compatible (full-frame default, omitted from serialization when unset -> no
  migration).
- Round-trips through the calibration schema automatically; frontend adds an
  X/Y/Width/Height (%) 'Capture region' group to the calibration editor with
  i18n (en/ru/zh).

10 unit tests (crop geometry, view-not-copy, clamping, ROI round-trip, legacy
default); full suite green (1946 passed).
This commit is contained in:
2026-06-05 11:58:26 +03:00
parent 4a82595f26
commit ca59546711
10 changed files with 232 additions and 2 deletions
+12
View File
@@ -344,6 +344,18 @@ class Calibration(BaseModel):
border_width: int = Field(
default=10, ge=1, le=100, description="Border width in pixels for edge sampling"
)
roi_x: float = Field(
default=0.0, ge=0.0, le=1.0, description="ROI left edge as a fraction of width (0..1)"
)
roi_y: float = Field(
default=0.0, ge=0.0, le=1.0, description="ROI top edge as a fraction of height (0..1)"
)
roi_width: float = Field(
default=1.0, gt=0.0, le=1.0, description="ROI width as a fraction of width (0..1)"
)
roi_height: float = Field(
default=1.0, gt=0.0, le=1.0, description="ROI height as a fraction of height (0..1)"
)
class CalibrationTestModeRequest(BaseModel):
@@ -113,6 +113,18 @@ class CalibrationConfig:
skip_leds_end: int = 0
# Border width: how many pixels from the screen edge to sample
border_width: int = 10
# Region of interest (simple mode): sample only this sub-rectangle of the
# frame (fractions 0..1). Defaults to the full frame. Lets a user exclude
# HUDs/taskbars/letterboxing from the sampled border colours.
roi_x: float = 0.0
roi_y: float = 0.0
roi_width: float = 1.0
roi_height: float = 1.0
@property
def has_roi(self) -> bool:
"""True when the ROI is narrower than the full frame."""
return self.roi_x > 0.0 or self.roi_y > 0.0 or self.roi_width < 1.0 or self.roi_height < 1.0
def build_segments(self) -> List[CalibrationSegment]:
"""Derive segment list from core parameters."""
@@ -799,6 +811,10 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
skip_leds_start=data.get("skip_leds_start", 0),
skip_leds_end=data.get("skip_leds_end", 0),
border_width=data.get("border_width", 10),
roi_x=data.get("roi_x", 0.0),
roi_y=data.get("roi_y", 0.0),
roi_width=data.get("roi_width", 1.0),
roi_height=data.get("roi_height", 1.0),
)
config.validate()
@@ -870,4 +886,10 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
result["skip_leds_end"] = config.skip_leds_end
if config.border_width != 10:
result["border_width"] = config.border_width
# Include ROI only when it is not the full frame
if config.has_roi:
result["roi_x"] = config.roi_x
result["roi_y"] = config.roi_y
result["roi_width"] = config.roi_width
result["roi_height"] = config.roi_height
return result
@@ -159,6 +159,37 @@ def capture_display(display_index: int = 0) -> ScreenCapture:
raise RuntimeError(f"Screen capture failed: {e}")
def crop_screen_capture(
sc: ScreenCapture,
roi_x: float,
roi_y: float,
roi_width: float,
roi_height: float,
) -> ScreenCapture:
"""Crop a capture to a relative region-of-interest rectangle (fractions 0..1).
Sampling only a sub-rectangle of the frame lets a user exclude HUDs, task
bars, or letterboxing so they don't pollute the border colours. Returns the
original capture unchanged for a full-frame ROI (fast path). The cropped
image is a numpy view (no copy); out-of-range/degenerate ROIs are clamped so
at least a 1x1 region remains.
"""
if roi_x <= 0.0 and roi_y <= 0.0 and roi_width >= 1.0 and roi_height >= 1.0:
return sc
h, w = sc.image.shape[:2]
x0 = max(0, min(w - 1, int(round(roi_x * w))))
y0 = max(0, min(h - 1, int(round(roi_y * h))))
x1 = max(x0 + 1, min(w, int(round((roi_x + roi_width) * w))))
y1 = max(y0 + 1, min(h, int(round((roi_y + roi_height) * h))))
cropped = sc.image[y0:y1, x0:x1]
return ScreenCapture(
image=cropped,
width=x1 - x0,
height=y1 - y0,
display_index=sc.display_index,
)
def extract_border_pixels(screen_capture: ScreenCapture, border_width: int = 10) -> BorderPixels:
"""Extract border pixels from screen capture.
@@ -18,7 +18,7 @@ from ledgrab.core.capture.calibration import (
CalibrationConfig,
create_pixel_mapper,
)
from ledgrab.core.capture.screen_capture import extract_border_pixels
from ledgrab.core.capture.screen_capture import crop_screen_capture, extract_border_pixels
from ledgrab.storage.bindable import bfloat
from ledgrab.utils import get_logger
from ledgrab.utils.frame_limiter import FrameLimiter
@@ -296,7 +296,19 @@ class PictureColorStripStream(ColorStripStream):
t1 = time.perf_counter()
led_colors = mapper.map_lines_to_leds(frames_dict)
else:
border_pixels = extract_border_pixels(frame, calibration.border_width)
src = frame
bw = calibration.border_width
if calibration.has_roi:
src = crop_screen_capture(
frame,
calibration.roi_x,
calibration.roi_y,
calibration.roi_width,
calibration.roi_height,
)
# Border width must stay within the cropped size.
bw = max(1, min(bw, min(src.width, src.height) // 4))
border_pixels = extract_border_pixels(src, bw)
t1 = time.perf_counter()
led_colors = mapper.map_border_to_leds(border_pixels)
t2 = time.perf_counter()
@@ -41,6 +41,10 @@ class CalibrationModal extends Modal {
skip_start: (this.$('cal-skip-start') as HTMLInputElement).value,
skip_end: (this.$('cal-skip-end') as HTMLInputElement).value,
border_width: (this.$('cal-border-width') as HTMLInputElement).value,
roi_x: (this.$('cal-roi-x') as HTMLInputElement)?.value,
roi_y: (this.$('cal-roi-y') as HTMLInputElement)?.value,
roi_width: (this.$('cal-roi-width') as HTMLInputElement)?.value,
roi_height: (this.$('cal-roi-height') as HTMLInputElement)?.value,
led_count: (this.$('cal-css-led-count') as HTMLInputElement).value,
};
}
@@ -173,6 +177,7 @@ export async function showCalibration(deviceId: any) {
updateOffsetSkipLock();
(document.getElementById('cal-border-width') as HTMLInputElement).value = calibration.border_width || 10;
_populateRoiInputs(calibration);
window.edgeSpans = {
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
@@ -319,6 +324,7 @@ export async function showCSSCalibration(cssId: any) {
updateOffsetSkipLock();
(document.getElementById('cal-border-width') as HTMLInputElement).value = String(calibration.border_width || 10);
_populateRoiInputs(calibration);
window.edgeSpans = {
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
@@ -882,6 +888,20 @@ async function clearTestMode(deviceId: any) {
}
}
/** Populate the ROI percentage inputs from a calibration object (fractions 0..1). */
function _populateRoiInputs(calibration: any): void {
const pct = (v: number | undefined, fallback: number) =>
String(Math.round((v ?? fallback) * 100));
const set = (id: string, v: string) => {
const el = document.getElementById(id) as HTMLInputElement | null;
if (el) el.value = v;
};
set('cal-roi-x', pct(calibration.roi_x, 0));
set('cal-roi-y', pct(calibration.roi_y, 0));
set('cal-roi-width', pct(calibration.roi_width, 1));
set('cal-roi-height', pct(calibration.roi_height, 1));
}
export async function saveCalibration() {
const cssMode = _isCSS();
const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value;
@@ -936,6 +956,10 @@ export async function saveCalibration() {
skip_leds_start: parseInt((document.getElementById('cal-skip-start') as HTMLInputElement).value || '0'),
skip_leds_end: parseInt((document.getElementById('cal-skip-end') as HTMLInputElement).value || '0'),
border_width: parseInt((document.getElementById('cal-border-width') as HTMLInputElement).value) || 10,
roi_x: (parseFloat((document.getElementById('cal-roi-x') as HTMLInputElement).value) || 0) / 100,
roi_y: (parseFloat((document.getElementById('cal-roi-y') as HTMLInputElement).value) || 0) / 100,
roi_width: (parseFloat((document.getElementById('cal-roi-width') as HTMLInputElement).value) || 100) / 100,
roi_height: (parseFloat((document.getElementById('cal-roi-height') as HTMLInputElement).value) || 100) / 100,
};
try {
@@ -638,6 +638,12 @@
"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.border_width": "Border (px):",
"calibration.roi": "Capture region (%):",
"calibration.roi.hint": "Sample only this sub-rectangle of the screen so a taskbar, HUD, or black bars don't pollute the border colours. Full frame = X/Y 0, Width/Height 100.",
"calibration.roi.x": "X (%)",
"calibration.roi.y": "Y (%)",
"calibration.roi.width": "Width (%)",
"calibration.roi.height": "Height (%)",
"calibration.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
"calibration.button.cancel": "Cancel",
"calibration.button.save": "Save",
@@ -695,6 +695,12 @@
"calibration.skip_end": "Пропуск LED (конец):",
"calibration.skip_end.hint": "Количество LED, которые будут выключены в конце ленты (0 = нет)",
"calibration.border_width": "Граница (px):",
"calibration.roi": "Область захвата (%):",
"calibration.roi.hint": "Брать пиксели только из этого прямоугольника экрана, чтобы панель задач, интерфейс игры или чёрные полосы не искажали цвета краёв. Весь экран = X/Y 0, Ширина/Высота 100.",
"calibration.roi.x": "X (%)",
"calibration.roi.y": "Y (%)",
"calibration.roi.width": "Ширина (%)",
"calibration.roi.height": "Высота (%)",
"calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
"calibration.button.cancel": "Отмена",
"calibration.button.save": "Сохранить",
@@ -691,6 +691,12 @@
"calibration.skip_end": "跳过 LED(末尾):",
"calibration.skip_end.hint": "灯带末尾端关闭的 LED 数量(0 = 无)",
"calibration.border_width": "边框(像素):",
"calibration.roi": "采集区域(%):",
"calibration.roi.hint": "仅采集屏幕的此子区域,避免任务栏、游戏 HUD 或黑边干扰边缘颜色。全屏 = X/Y 为 0,宽/高为 100。",
"calibration.roi.x": "X (%)",
"calibration.roi.y": "Y (%)",
"calibration.roi.width": "宽度 (%)",
"calibration.roi.height": "高度 (%)",
"calibration.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100",
"calibration.button.cancel": "取消",
"calibration.button.save": "保存",
@@ -170,6 +170,31 @@
<input type="number" id="cal-skip-end" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
</div>
</div>
<div class="form-group" style="margin-top: 16px;">
<div class="label-row">
<label data-i18n="calibration.roi">Capture region (%):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="calibration.roi.hint">Sample only this sub-rectangle of the screen so a taskbar, HUD, or black bars don't pollute the border colours. Full frame = X/Y 0, Width/Height 100.</small>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 12px;">
<div>
<label for="cal-roi-x" class="time-range-label" data-i18n="calibration.roi.x">X (%)</label>
<input type="number" id="cal-roi-x" min="0" max="100" value="0">
</div>
<div>
<label for="cal-roi-y" class="time-range-label" data-i18n="calibration.roi.y">Y (%)</label>
<input type="number" id="cal-roi-y" min="0" max="100" value="0">
</div>
<div>
<label for="cal-roi-width" class="time-range-label" data-i18n="calibration.roi.width">Width (%)</label>
<input type="number" id="cal-roi-width" min="1" max="100" value="100">
</div>
<div>
<label for="cal-roi-height" class="time-range-label" data-i18n="calibration.roi.height">Height (%)</label>
<input type="number" id="cal-roi-height" min="1" max="100" value="100">
</div>
</div>
</div>
</div>
</section>
+86
View File
@@ -0,0 +1,86 @@
"""Tests for capture region-of-interest (ROI) cropping."""
import numpy as np
from ledgrab.core.capture.calibration import (
CalibrationConfig,
calibration_from_dict,
calibration_to_dict,
)
from ledgrab.core.capture.screen_capture import ScreenCapture, crop_screen_capture
def _cfg(**kw) -> CalibrationConfig:
return CalibrationConfig(layout="clockwise", start_position="bottom_left", leds_top=10, **kw)
def _sc(w: int = 100, h: int = 80) -> ScreenCapture:
return ScreenCapture(
image=np.zeros((h, w, 3), dtype=np.uint8), width=w, height=h, display_index=0
)
def test_full_frame_returns_same_object():
sc = _sc()
assert crop_screen_capture(sc, 0.0, 0.0, 1.0, 1.0) is sc
def test_center_crop_dimensions():
out = crop_screen_capture(_sc(100, 80), 0.25, 0.25, 0.5, 0.5)
assert out.width == 50 and out.height == 40
assert out.image.shape[:2] == (40, 50)
assert out.display_index == 0
def test_crop_returns_a_view_of_the_source():
sc = _sc(100, 80)
out = crop_screen_capture(sc, 0.0, 0.0, 0.5, 0.5)
out.image[0, 0] = (9, 9, 9)
assert (sc.image[0, 0] == 9).all() # mutating the view touches the source pixels
def test_partial_width_only_keeps_full_height():
out = crop_screen_capture(_sc(100, 80), 0.1, 0.0, 0.8, 1.0)
assert out.width == 80 and out.height == 80
def test_degenerate_roi_clamped_to_at_least_one_pixel():
out = crop_screen_capture(_sc(100, 80), 0.999, 0.999, 0.0, 0.0)
assert out.width >= 1 and out.height >= 1
def test_out_of_range_roi_is_clamped():
out = crop_screen_capture(_sc(100, 80), -0.5, -0.5, 2.0, 2.0)
# x<=0,y<=0,w>=1,h>=1 hits the full-frame fast path
assert out.width == 100 and out.height == 80
# --- CalibrationConfig ROI serialization ---
def test_has_roi_property():
assert _cfg().has_roi is False
assert _cfg(roi_width=0.5).has_roi is True
assert _cfg(roi_x=0.1).has_roi is True
assert _cfg(roi_height=0.9).has_roi is True
def test_roi_round_trips_through_dict():
cfg = _cfg(roi_x=0.1, roi_y=0.2, roi_width=0.6, roi_height=0.7)
d = calibration_to_dict(cfg)
assert d["roi_x"] == 0.1 and d["roi_width"] == 0.6
back = calibration_from_dict(d)
assert (back.roi_x, back.roi_y, back.roi_width, back.roi_height) == (0.1, 0.2, 0.6, 0.7)
def test_full_frame_roi_omitted_from_dict():
d = calibration_to_dict(_cfg())
assert "roi_x" not in d and "roi_width" not in d
def test_legacy_dict_without_roi_defaults_to_full_frame():
cfg = calibration_from_dict(
{"mode": "simple", "layout": "clockwise", "start_position": "bottom_left", "leds_top": 10}
)
assert cfg.has_roi is False
assert (cfg.roi_x, cfg.roi_y, cfg.roi_width, cfg.roi_height) == (0.0, 0.0, 1.0, 1.0)