Skip to content

Commit b57f02b

Browse files
committed
Treat 0 as Auto for camera settings
Make 0/None semantics explicit for camera settings and improve OpenCV probing/GUI behavior. - Config: default fps/width/height/exposure/gain now use 0 to mean Auto; add coercion validators and avoid overwriting explicit zeros in apply_defaults. - OpenCV backend: treat Auto resolution as (0,0), attempt FPS set even in fast-start (best-effort), readback FPS/resolution more robustly, add actual_exposure/actual_gain properties (unsupported -> None), and clarify capability support comments. - Factory/tests: stop assuming 30 FPS for probe-created CameraSettings; update tests to expect Auto resolution (0,0). - GUI: show elided device name, expose Auto FPS in UI, reorganize form rows/buttons, add probe/reset flow (quick probe that can apply detected values back to requested settings), adjust preview FPS reconciliation and status logging. These changes enable explicit "Auto" requests, safer probing of device-reported values, and a user-facing Reset action to adopt device defaults.
1 parent f7931df commit b57f02b

5 files changed

Lines changed: 335 additions & 93 deletions

File tree

dlclivegui/cameras/backends/opencv_backend.py

Lines changed: 84 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,12 @@ def static_capabilities(cls) -> dict[str, SupportLevel]:
129129
caps = super().static_capabilities()
130130
caps.update(
131131
{
132-
"set_resolution": SupportLevel.SUPPORTED,
133-
"set_fps": SupportLevel.BEST_EFFORT,
132+
"set_resolution": SupportLevel.BEST_EFFORT, # see tolerance values in OpenCVOptions
133+
"set_fps": SupportLevel.BEST_EFFORT, # ditto
134134
"set_exposure": SupportLevel.UNSUPPORTED,
135135
"set_gain": SupportLevel.UNSUPPORTED,
136-
"device_discovery": SupportLevel.SUPPORTED,
137-
"stable_identity": SupportLevel.SUPPORTED,
136+
"device_discovery": SupportLevel.SUPPORTED, # uses opencv2-enumerate-cameras
137+
"stable_identity": SupportLevel.SUPPORTED, # to get VID/PID/path
138138
}
139139
)
140140
return caps
@@ -245,6 +245,16 @@ def actual_resolution(self) -> tuple[int, int] | None:
245245
return (self._actual_width, self._actual_height)
246246
return None
247247

248+
@property
249+
def actual_exposure(self) -> None:
250+
"""Not supported by OpenCV backend."""
251+
return None
252+
253+
@property
254+
def actual_gain(self) -> None:
255+
"""Not supported by OpenCV backend."""
256+
return None
257+
248258
# ----------------------------
249259
# Internal helpers
250260
# ----------------------------
@@ -280,8 +290,8 @@ def _get_requested_resolution(self) -> tuple[int, int]:
280290
except Exception:
281291
pass
282292

283-
# 3) default
284-
return (720, 540)
293+
# 3) default -> auto (0,0)
294+
return (0, 0)
285295

286296
def _apply_resolution_policy(
287297
self,
@@ -367,76 +377,115 @@ def _configure_capture(self) -> None:
367377
self._codec_str = self._read_codec_string()
368378
logger.info(f"Camera using codec: {self._codec_str}")
369379

370-
# --- Resolution (explicit request) ---
380+
# Requested values
371381
req_w, req_h = self._requested_resolution
372382
enforce_aspect = opt.enforce_aspect
383+
requested_fps = float(self.settings.fps or 0.0)
384+
385+
# -------------------------
386+
# Resolution
387+
# -------------------------
388+
# If Auto (0,0), do NOT set resolution. Just read device defaults.
389+
if req_w <= 0 or req_h <= 0:
390+
# Some backends only populate width/height after a few grabs.
391+
try:
392+
# for _ in range(3):
393+
self._capture.grab()
394+
except Exception:
395+
pass
373396

374-
if not self._fast_start:
375-
# verified, robust path
397+
self._actual_width = int(self._capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
398+
self._actual_height = int(self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
399+
self._actual_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0)
400+
401+
# For clarity in logs
402+
logger.info("Resolution requested=Auto, actual=%sx%s", self._actual_width, self._actual_height)
403+
404+
elif not self._fast_start:
405+
# Verified, robust path (tries candidates + verifies)
376406
result = apply_mode_with_verification(
377407
self._capture,
378408
ModeRequest(
379409
width=req_w,
380410
height=req_h,
381-
fps=float(self.settings.fps or 0.0),
411+
fps=requested_fps,
382412
enforce_aspect=enforce_aspect,
383413
aspect_tol=float(opt.aspect_tol),
384414
area_tol=float(opt.area_tol),
385415
),
386416
)
387417
self._actual_width, self._actual_height, self._actual_fps = result.width, result.height, result.fps
418+
388419
else:
389420
# fast-start: best-effort set (no heavy negotiation)
390-
if req_w > 0 and req_h > 0:
391-
try:
392-
self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, float(req_w))
393-
self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, float(req_h))
394-
except Exception as exc:
395-
logger.debug(f"Fast-start resolution set failed: {exc}")
421+
try:
422+
self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, float(req_w))
423+
self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, float(req_h))
424+
except Exception as exc:
425+
logger.debug(f"Fast-start resolution set failed: {exc}")
396426

397427
self._actual_width = int(self._capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
398428
self._actual_height = int(self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
399429

430+
# Compute actual_res tuple if known
400431
actual_res = None
401432
if (self._actual_width or 0) > 0 and (self._actual_height or 0) > 0:
402433
actual_res = (int(self._actual_width), int(self._actual_height))
403434

404435
logger.info(
405-
"Resolution requested=%sx%s, actual=%s",
406-
req_w,
407-
req_h,
436+
"Resolution requested=%s, actual=%s",
437+
f"{req_w}x{req_h}" if (req_w > 0 and req_h > 0) else "Auto",
408438
f"{actual_res[0]}x{actual_res[1]}" if actual_res else "unknown",
409439
)
410440

411-
# enforce mismatch policy (warn/strict/accept)
412-
self._apply_resolution_policy(
413-
requested=(req_w, req_h),
414-
actual=actual_res,
415-
policy=opt.resolution_policy,
416-
)
441+
# Enforce mismatch policy only if a real request was made
442+
if req_w > 0 and req_h > 0:
443+
self._apply_resolution_policy(
444+
requested=(req_w, req_h),
445+
actual=actual_res,
446+
policy=opt.resolution_policy,
447+
)
417448

418-
# optional persistence of "what worked"
449+
# Optional persistence of "what worked"
419450
if opt.persist_last_applied_resolution and actual_res:
420451
ns = self.settings.properties.setdefault(self.OPTIONS_KEY, {})
421452
ns["last_applied_resolution"] = [actual_res[0], actual_res[1]]
422453

423-
# --- FPS (keep your current logic) ---
424-
requested_fps = float(self.settings.fps or 0.0)
425-
if not self._fast_start and requested_fps > 0.0:
426-
current_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0)
454+
# -------------------------
455+
# FPS (best-effort always)
456+
# -------------------------
457+
# IMPORTANT CHANGE:
458+
# Try to set FPS even in fast_start (best-effort). Many drivers ignore it,
459+
# and CAP_PROP_FPS often reads back 0, but at least we attempt consistently.
460+
if requested_fps > 0.0:
461+
try:
462+
current_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0)
463+
except Exception:
464+
current_fps = 0.0
465+
466+
# Only attempt if clearly different or unknown
427467
if current_fps <= 0.0 or abs(current_fps - requested_fps) > 0.1:
428-
if not self._capture.set(cv2.CAP_PROP_FPS, requested_fps):
429-
logger.debug(f"Device ignored FPS set to {requested_fps:.2f}")
430-
self._actual_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0)
431-
else:
468+
try:
469+
ok = self._capture.set(cv2.CAP_PROP_FPS, float(requested_fps))
470+
if not ok:
471+
logger.debug(f"Device ignored FPS set to {requested_fps:.2f}")
472+
except Exception as exc:
473+
logger.debug(f"FPS set raised: {exc}")
474+
475+
# Read back (may be 0.0 on many backends)
476+
try:
432477
self._actual_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0)
478+
except Exception:
479+
self._actual_fps = 0.0
433480

434481
if self._actual_fps and requested_fps and abs(self._actual_fps - requested_fps) > 0.1:
435482
logger.warning(f"FPS mismatch: requested {requested_fps:.2f}, got {self._actual_fps:.2f}")
436483

437484
logger.info(f"Camera configured with FPS: {self._actual_fps:.2f}")
438485

439-
# --- Extra properties (safe whitelist) ---
486+
# -------------------------
487+
# Extra properties (safe whitelist)
488+
# -------------------------
440489
for prop, value in self.settings.properties.items():
441490
if prop in ("api", "resolution", "fast_start", "alt_index_probe"):
442491
continue

dlclivegui/cameras/factory.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,6 @@ def _canceled() -> bool:
271271
settings = CameraSettings(
272272
name=f"Probe {index}",
273273
index=index,
274-
fps=30.0,
275274
backend=backend,
276275
properties={},
277276
)

dlclivegui/config.py

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,32 +15,68 @@
1515
class CameraSettings(BaseModel):
1616
name: str = "Camera 0"
1717
index: int = 0
18-
fps: float = 25.0
1918
backend: str = "opencv"
20-
width: int = 720
21-
height: int = 540
22-
exposure: int = 500 # 0=auto else µs
23-
gain: float = 10.0 # 0.0=auto else value
19+
20+
# 0.0 = Auto (device default / don't request)
21+
fps: float = 0.0
22+
# 0 = Auto (device default / don't request)
23+
width: int = 0
24+
height: int = 0
25+
26+
exposure: int = 0 # 0=auto else µs
27+
gain: float = 0.0 # 0.0=auto else value
28+
2429
crop_x0: int = 0
2530
crop_y0: int = 0
2631
crop_x1: int = 0
2732
crop_y1: int = 0
33+
2834
max_devices: int = 3
2935
rotation: Rotation = 0
3036
enabled: bool = True
3137
properties: dict[str, Any] = Field(default_factory=dict)
3238

33-
@field_validator("fps")
39+
@field_validator("fps", mode="before")
3440
@classmethod
35-
def _fps_positive(cls, v):
36-
return float(v) if v and v > 0 else 30.0
37-
38-
@field_validator("exposure")
41+
def _coerce_fps(cls, v):
42+
"""
43+
Accept:
44+
- None -> 0.0 (Auto)
45+
- 0 / 0.0 -> Auto
46+
- >0 -> requested fps
47+
"""
48+
if v is None:
49+
return 0.0
50+
try:
51+
fv = float(v)
52+
except Exception:
53+
return 0.0
54+
# clamp negatives to Auto
55+
return fv if fv >= 0.0 else 0.0
56+
57+
@field_validator("width", "height", mode="before")
58+
@classmethod
59+
def _coerce_resolution(cls, v):
60+
"""
61+
Accept:
62+
- None -> 0 (Auto)
63+
- 0 -> Auto
64+
- >0 -> requested dimension
65+
"""
66+
if v is None:
67+
return 0
68+
try:
69+
iv = int(v)
70+
except Exception:
71+
return 0
72+
return iv if iv >= 0 else 0
73+
74+
@field_validator("exposure", mode="before")
3975
@classmethod
4076
def _coerce_exposure(cls, v): # allow None->0 and int
4177
return int(v) if v is not None else 0
4278

43-
@field_validator("gain")
79+
@field_validator("gain", mode="before")
4480
@classmethod
4581
def _coerce_gain(cls, v):
4682
return float(v) if v is not None else 0.0
@@ -69,14 +105,29 @@ def from_defaults(cls) -> CameraSettings:
69105
return cls()
70106

71107
def apply_defaults(self) -> CameraSettings:
108+
"""
109+
IMPORTANT:
110+
0 means "Auto" for fps/width/height/exposure/gain.
111+
So do NOT treat <=0 as "missing" for those fields.
112+
Only fill in defaults when the value is None.
113+
"""
72114
default = self.from_defaults()
115+
116+
# Fields where 0 is meaningful ("Auto"), so we must not replace 0 with defaults.
117+
auto_zero_fields = {"fps", "width", "height", "exposure", "gain"}
118+
73119
for field in CameraSettings.model_fields:
74120
value = getattr(self, field)
75-
if value is None or (isinstance(value, (int, float)) and value <= 0):
76-
# auto means use default value
77-
# TODO @C-Achard
78-
# Consider a more explicit way to represent "use default" vs "explicitly disable/zero out"
121+
122+
# Only replace None with defaults universally
123+
if value is None:
79124
setattr(self, field, getattr(default, field))
125+
continue
126+
127+
# Careful: crop uses 0 legitimately too, though default is also 0
128+
if field not in auto_zero_fields and isinstance(value, (int, float)) and value < 0:
129+
setattr(self, field, getattr(default, field))
130+
80131
return self
81132

82133

0 commit comments

Comments
 (0)