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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user