From ca5954671151ebc1b76ec3d9fa78f56d51bb8e55 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 5 Jun 2026 11:58:26 +0300 Subject: [PATCH] feat(capture): region-of-interest (ROI) crop for screen sampling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- server/src/ledgrab/api/schemas/devices.py | 12 +++ .../src/ledgrab/core/capture/calibration.py | 22 +++++ .../ledgrab/core/capture/screen_capture.py | 31 +++++++ .../core/processing/color_strip/picture.py | 16 +++- .../ledgrab/static/js/features/calibration.ts | 24 ++++++ server/src/ledgrab/static/locales/en.json | 6 ++ server/src/ledgrab/static/locales/ru.json | 6 ++ server/src/ledgrab/static/locales/zh.json | 6 ++ .../ledgrab/templates/modals/calibration.html | 25 ++++++ server/tests/test_capture_roi.py | 86 +++++++++++++++++++ 10 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 server/tests/test_capture_roi.py diff --git a/server/src/ledgrab/api/schemas/devices.py b/server/src/ledgrab/api/schemas/devices.py index e5b1ce6..d74e0ea 100644 --- a/server/src/ledgrab/api/schemas/devices.py +++ b/server/src/ledgrab/api/schemas/devices.py @@ -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): diff --git a/server/src/ledgrab/core/capture/calibration.py b/server/src/ledgrab/core/capture/calibration.py index 1beb978..2b04dac 100644 --- a/server/src/ledgrab/core/capture/calibration.py +++ b/server/src/ledgrab/core/capture/calibration.py @@ -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 diff --git a/server/src/ledgrab/core/capture/screen_capture.py b/server/src/ledgrab/core/capture/screen_capture.py index 6f45181..4885229 100644 --- a/server/src/ledgrab/core/capture/screen_capture.py +++ b/server/src/ledgrab/core/capture/screen_capture.py @@ -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. diff --git a/server/src/ledgrab/core/processing/color_strip/picture.py b/server/src/ledgrab/core/processing/color_strip/picture.py index 47d44b0..bf30a29 100644 --- a/server/src/ledgrab/core/processing/color_strip/picture.py +++ b/server/src/ledgrab/core/processing/color_strip/picture.py @@ -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() diff --git a/server/src/ledgrab/static/js/features/calibration.ts b/server/src/ledgrab/static/js/features/calibration.ts index 8f82819..6339a60 100644 --- a/server/src/ledgrab/static/js/features/calibration.ts +++ b/server/src/ledgrab/static/js/features/calibration.ts @@ -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 { diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 53b36f3..07bf665 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -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", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 6ecf88e..8c19af5 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -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": "Сохранить", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index aedc6c3..e7ee323 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -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": "保存", diff --git a/server/src/ledgrab/templates/modals/calibration.html b/server/src/ledgrab/templates/modals/calibration.html index 201e5b1..7151587 100644 --- a/server/src/ledgrab/templates/modals/calibration.html +++ b/server/src/ledgrab/templates/modals/calibration.html @@ -170,6 +170,31 @@ +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
diff --git a/server/tests/test_capture_roi.py b/server/tests/test_capture_roi.py new file mode 100644 index 0000000..4dd5927 --- /dev/null +++ b/server/tests/test_capture_roi.py @@ -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)