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 @@ +