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(
|
border_width: int = Field(
|
||||||
default=10, ge=1, le=100, description="Border width in pixels for edge sampling"
|
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):
|
class CalibrationTestModeRequest(BaseModel):
|
||||||
|
|||||||
@@ -113,6 +113,18 @@ class CalibrationConfig:
|
|||||||
skip_leds_end: int = 0
|
skip_leds_end: int = 0
|
||||||
# Border width: how many pixels from the screen edge to sample
|
# Border width: how many pixels from the screen edge to sample
|
||||||
border_width: int = 10
|
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]:
|
def build_segments(self) -> List[CalibrationSegment]:
|
||||||
"""Derive segment list from core parameters."""
|
"""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_start=data.get("skip_leds_start", 0),
|
||||||
skip_leds_end=data.get("skip_leds_end", 0),
|
skip_leds_end=data.get("skip_leds_end", 0),
|
||||||
border_width=data.get("border_width", 10),
|
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()
|
config.validate()
|
||||||
@@ -870,4 +886,10 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
|
|||||||
result["skip_leds_end"] = config.skip_leds_end
|
result["skip_leds_end"] = config.skip_leds_end
|
||||||
if config.border_width != 10:
|
if config.border_width != 10:
|
||||||
result["border_width"] = config.border_width
|
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
|
return result
|
||||||
|
|||||||
@@ -159,6 +159,37 @@ def capture_display(display_index: int = 0) -> ScreenCapture:
|
|||||||
raise RuntimeError(f"Screen capture failed: {e}")
|
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:
|
def extract_border_pixels(screen_capture: ScreenCapture, border_width: int = 10) -> BorderPixels:
|
||||||
"""Extract border pixels from screen capture.
|
"""Extract border pixels from screen capture.
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from ledgrab.core.capture.calibration import (
|
|||||||
CalibrationConfig,
|
CalibrationConfig,
|
||||||
create_pixel_mapper,
|
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.storage.bindable import bfloat
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
from ledgrab.utils.frame_limiter import FrameLimiter
|
from ledgrab.utils.frame_limiter import FrameLimiter
|
||||||
@@ -296,7 +296,19 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
t1 = time.perf_counter()
|
t1 = time.perf_counter()
|
||||||
led_colors = mapper.map_lines_to_leds(frames_dict)
|
led_colors = mapper.map_lines_to_leds(frames_dict)
|
||||||
else:
|
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()
|
t1 = time.perf_counter()
|
||||||
led_colors = mapper.map_border_to_leds(border_pixels)
|
led_colors = mapper.map_border_to_leds(border_pixels)
|
||||||
t2 = time.perf_counter()
|
t2 = time.perf_counter()
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ class CalibrationModal extends Modal {
|
|||||||
skip_start: (this.$('cal-skip-start') as HTMLInputElement).value,
|
skip_start: (this.$('cal-skip-start') as HTMLInputElement).value,
|
||||||
skip_end: (this.$('cal-skip-end') as HTMLInputElement).value,
|
skip_end: (this.$('cal-skip-end') as HTMLInputElement).value,
|
||||||
border_width: (this.$('cal-border-width') 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,
|
led_count: (this.$('cal-css-led-count') as HTMLInputElement).value,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -173,6 +177,7 @@ export async function showCalibration(deviceId: any) {
|
|||||||
updateOffsetSkipLock();
|
updateOffsetSkipLock();
|
||||||
|
|
||||||
(document.getElementById('cal-border-width') as HTMLInputElement).value = calibration.border_width || 10;
|
(document.getElementById('cal-border-width') as HTMLInputElement).value = calibration.border_width || 10;
|
||||||
|
_populateRoiInputs(calibration);
|
||||||
|
|
||||||
window.edgeSpans = {
|
window.edgeSpans = {
|
||||||
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
||||||
@@ -319,6 +324,7 @@ export async function showCSSCalibration(cssId: any) {
|
|||||||
updateOffsetSkipLock();
|
updateOffsetSkipLock();
|
||||||
|
|
||||||
(document.getElementById('cal-border-width') as HTMLInputElement).value = String(calibration.border_width || 10);
|
(document.getElementById('cal-border-width') as HTMLInputElement).value = String(calibration.border_width || 10);
|
||||||
|
_populateRoiInputs(calibration);
|
||||||
|
|
||||||
window.edgeSpans = {
|
window.edgeSpans = {
|
||||||
top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 },
|
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() {
|
export async function saveCalibration() {
|
||||||
const cssMode = _isCSS();
|
const cssMode = _isCSS();
|
||||||
const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value;
|
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_start: parseInt((document.getElementById('cal-skip-start') as HTMLInputElement).value || '0'),
|
||||||
skip_leds_end: parseInt((document.getElementById('cal-skip-end') 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,
|
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 {
|
try {
|
||||||
|
|||||||
@@ -638,6 +638,12 @@
|
|||||||
"calibration.skip_end": "Skip LEDs (End):",
|
"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.skip_end.hint": "Number of LEDs to turn off at the end of the strip (0 = none)",
|
||||||
"calibration.border_width": "Border (px):",
|
"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.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
|
||||||
"calibration.button.cancel": "Cancel",
|
"calibration.button.cancel": "Cancel",
|
||||||
"calibration.button.save": "Save",
|
"calibration.button.save": "Save",
|
||||||
|
|||||||
@@ -695,6 +695,12 @@
|
|||||||
"calibration.skip_end": "Пропуск LED (конец):",
|
"calibration.skip_end": "Пропуск LED (конец):",
|
||||||
"calibration.skip_end.hint": "Количество LED, которые будут выключены в конце ленты (0 = нет)",
|
"calibration.skip_end.hint": "Количество LED, которые будут выключены в конце ленты (0 = нет)",
|
||||||
"calibration.border_width": "Граница (px):",
|
"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.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
|
||||||
"calibration.button.cancel": "Отмена",
|
"calibration.button.cancel": "Отмена",
|
||||||
"calibration.button.save": "Сохранить",
|
"calibration.button.save": "Сохранить",
|
||||||
|
|||||||
@@ -691,6 +691,12 @@
|
|||||||
"calibration.skip_end": "跳过 LED(末尾):",
|
"calibration.skip_end": "跳过 LED(末尾):",
|
||||||
"calibration.skip_end.hint": "灯带末尾端关闭的 LED 数量(0 = 无)",
|
"calibration.skip_end.hint": "灯带末尾端关闭的 LED 数量(0 = 无)",
|
||||||
"calibration.border_width": "边框(像素):",
|
"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.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100)",
|
||||||
"calibration.button.cancel": "取消",
|
"calibration.button.cancel": "取消",
|
||||||
"calibration.button.save": "保存",
|
"calibration.button.save": "保存",
|
||||||
|
|||||||
@@ -170,6 +170,31 @@
|
|||||||
<input type="number" id="cal-skip-end" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
|
<input type="number" id="cal-skip-end" min="0" value="0" oninput="updateOffsetSkipLock(); updateCalibrationPreview()">
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</section>
|
</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