Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
13e054e
Move RecorderStats to utils.stats
C-Achard Jun 24, 2026
50c5666
Preserve mono frames & add recorder timing
C-Achard Jun 24, 2026
77ecc7d
Add preserve_mono camera option and UI support
C-Achard Jun 24, 2026
d829a43
Add actual_pixel_format and preserve_mono support
C-Achard Jun 24, 2026
888894b
Basler: track camera pixel format and mono output
C-Achard Jun 24, 2026
77b31c3
Show detected output format & mono option
C-Achard Jun 24, 2026
6dc6cbc
Track actual pixel/output formats in camera backends
C-Achard Jun 24, 2026
50966c3
Use make_two_field_row for Output row
C-Achard Jun 24, 2026
db0f958
Use display labels for multi-camera GUI
C-Achard Jun 24, 2026
afea17b
Update tests: display_id naming and fallback
C-Achard Jun 24, 2026
b31a36f
Remove tiled frame generation and accessors
C-Achard Jun 24, 2026
bd03613
Use runtime FPS for recording; add fallbacks
C-Achard Jun 24, 2026
e99bfd7
Track encoding errors with writer_timing
C-Achard Jun 24, 2026
5b123de
Use _camera_pixel_format in camera backends
C-Achard Jun 24, 2026
158e86a
Add TYPE_CHECKING in stats
C-Achard Jun 24, 2026
c492d22
test: remove duplicate stable camera ID test
C-Achard Jul 14, 2026
44f5144
Fix camera ID fallback and stop state reset
C-Achard Aug 11, 2026
8574e73
Clear camera runtime info during shutdown
C-Achard Aug 11, 2026
d9704ae
Harden frame rate handling in video recorder
C-Achard Aug 11, 2026
4807ab8
Suggest mono preserve instead of auto-enable
C-Achard Aug 11, 2026
30409ef
Stabilize preview rendering GUI test
C-Achard Aug 11, 2026
14b64f8
Refactor WriteGear option handling
C-Achard Aug 12, 2026
22f672b
Clarify docs: Route camera open/probe requests to UI thread
C-Achard Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions dlclivegui/cameras/backends/aravis_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ def __init__(self, settings):
self._actual_width: int | None = None
self._actual_height: int | None = None
self._actual_fps: float | None = None
self._camera_pixel_format: str | None = None
self._actual_output_format: str | None = None

self._camera = None
self._stream = None
Expand All @@ -69,6 +71,16 @@ def actual_fps(self) -> float | None:
"""Return the actual frame rate of the camera after opening."""
return self._actual_fps

@property
def actual_pixel_format(self) -> str | None:
"""Camera/native pixel format requested/reported for Aravis."""
return self._camera_pixel_format or self._pixel_format
Comment thread
C-Achard marked this conversation as resolved.
Comment thread
C-Achard marked this conversation as resolved.

@property
def actual_output_format(self) -> str | None:
"""Current Aravis backend emits BGR uint8 frames."""
return self._actual_output_format or "BGR8"
Comment thread
C-Achard marked this conversation as resolved.

@classmethod
def is_available(cls) -> bool:
"""Check if Aravis is available on this system."""
Expand Down Expand Up @@ -615,10 +627,12 @@ def _configure_pixel_format(self) -> None:

if self._pixel_format in format_map:
self._camera.set_pixel_format(format_map[self._pixel_format])
self._camera_pixel_format = self._pixel_format
LOG.info(f"Pixel format set to '{self._pixel_format}'")
else:
# Try setting as string
self._camera.set_pixel_format_from_string(self._pixel_format)
self._camera_pixel_format = self._pixel_format
LOG.info(f"Pixel format set to '{self._pixel_format}' (from string)")
except Exception as e:
LOG.warning(f"Failed to set pixel format '{self._pixel_format}': {e}")
Expand Down
90 changes: 85 additions & 5 deletions dlclivegui/cameras/backends/basler_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import numpy as np

from ...config import SINGLE_CAMERA_WORKER_DO_LOG_TIMING, CameraTriggerSettings
from ...config import BASLER_DO_LOG_TIMING, CameraTriggerSettings
from ...utils.stats import WorkerTimingStats
from ..base import CameraBackend, SupportLevel, register_backend

Expand Down Expand Up @@ -46,6 +46,11 @@ def __init__(self, settings):
super().__init__(settings)

self._props: dict = settings.properties if isinstance(settings.properties, dict) else {}
self._preserve_mono: bool = bool(
getattr(settings, "preserve_mono", False) or self.ns.get("preserve_mono", False)
)
self._camera_pixel_format: str | None = None
self._logged_first_frame: bool = False

# Optional fast-start hint for probe workers
# (may skip StartGrabbing and converter setup for faster capability probing; not suitable for normal capture)
Expand Down Expand Up @@ -117,7 +122,7 @@ def __init__(self, settings):
timing_id,
logger=LOG,
log_interval=1.0,
enabled=SINGLE_CAMERA_WORKER_DO_LOG_TIMING,
enabled=BASLER_DO_LOG_TIMING,
)

@property
Expand All @@ -138,6 +143,24 @@ def actual_exposure(self) -> float | None:
def actual_gain(self) -> float | None:
return self._actual_gain

@property
def actual_pixel_format(self) -> str | None:
"""Camera/native pixel format reported by Basler, e.g. 'Mono8'."""
return self._camera_pixel_format

@property
def actual_output_format(self) -> str | None:
"""Backend output frame format emitted to the app, e.g. 'Mono8' or 'BGR8'."""
if not self._camera_pixel_format:
return None
return "Mono8" if self._should_output_mono() else "BGR8"

@property
def recommended_preserve_mono(self) -> bool | None:
if not self._camera_pixel_format:
return None
return self._is_camera_mono()

@classmethod
def is_available(cls) -> bool:
return pylon is not None
Expand All @@ -154,6 +177,7 @@ def static_capabilities(cls) -> dict[str, SupportLevel]:
"device_discovery": SupportLevel.BEST_EFFORT,
"stable_identity": SupportLevel.SUPPORTED,
"hardware_trigger": SupportLevel.BEST_EFFORT,
"preserve_mono": SupportLevel.SUPPORTED,
}
)
return caps
Expand All @@ -180,6 +204,17 @@ def _ensure_mutable_ns(self) -> dict:
self.settings.properties[self.OPTIONS_KEY] = ns
return ns

def _read_camera_pixel_format(self) -> str:
pixel_format = self._feature_value(self._feature("PixelFormat"), "")
self._camera_pixel_format = str(pixel_format or "")
return self._camera_pixel_format

def _is_camera_mono(self) -> bool:
return bool(self._camera_pixel_format and self._camera_pixel_format.startswith("Mono"))

def _should_output_mono(self) -> bool:
return bool(self._preserve_mono and self._is_camera_mono())

@classmethod
def _enumerate_devices_cls(cls):
"""Enumerate DeviceInfo entries (unit-testable via monkeypatch)."""
Expand Down Expand Up @@ -463,6 +498,37 @@ def _configure_frame_rate(self) -> None:
except Exception:
self._actual_fps = None

def _configure_converter(self) -> None:
"""Configure pypylon image converter.

Default behavior remains BGR8 for compatibility.

If preserve_mono=True and the camera PixelFormat is Mono*,
return Mono8 frames as 2D arrays to avoid 3x BGR expansion.
"""
if self._camera is None:
return

camera_pixel_format = self._camera_pixel_format or self._read_camera_pixel_format()

self._converter = pylon.ImageFormatConverter()
self._converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned

if self._should_output_mono():
self._converter.OutputPixelFormat = pylon.PixelType_Mono8
LOG.info(
"[Basler] Converter configured for Mono8 output (camera PixelFormat=%s preserve_mono=%s)",
camera_pixel_format,
self._preserve_mono,
)
else:
self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed
LOG.info(
"[Basler] Converter configured for BGR8 output (camera PixelFormat=%s preserve_mono=%s)",
camera_pixel_format,
self._preserve_mono,
)

def open(self) -> None:
if pylon is None:
raise RuntimeError("pypylon is required for the Basler backend but is not installed")
Expand Down Expand Up @@ -548,6 +614,8 @@ def open(self) -> None:
except Exception:
self._actual_gain = None

self._read_camera_pixel_format()

# ----------------------------
# Start acquisition (skip for fast probe)
# ----------------------------
Expand All @@ -560,9 +628,7 @@ def open(self) -> None:
pass

# Converter BEFORE StartGrabbing
self._converter = pylon.ImageFormatConverter()
self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed
self._converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned
self._configure_converter()

# Force stream configuration reset
try:
Expand Down Expand Up @@ -638,6 +704,20 @@ def read(self) -> tuple[np.ndarray, float]:
with self._timing.measure("Basler.get_array"):
frame = image.GetArray()

if not self._logged_first_frame:
self._logged_first_frame = True
LOG.info(
"[Basler] first frame device_id=%s shape=%s dtype=%s nbytes=%.2f MB "
"camera_pixel_format=%s output_format=%s preserve_mono=%s",
self._device_id,
frame.shape,
frame.dtype,
frame.nbytes / (1024 * 1024),
self._camera_pixel_format,
self.actual_output_format,
self._preserve_mono,
)

with self._timing.measure("Basler.release"):
grab_result.Release()
grab_result = None
Expand Down
38 changes: 38 additions & 0 deletions dlclivegui/cameras/backends/gentl_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ def __init__(self, settings):

self._pixel_format: str = ns.get("pixel_format") or props.get("pixel_format", "auto")
self._pixel_format = str(self._pixel_format).strip()
self._camera_pixel_format: str | None = None
self._actual_output_format: str | None = None

self._rotate: int = int(ns.get("rotate", props.get("rotate", 0))) % 360
self._crop: tuple[int, int, int, int] | None = self._parse_crop(ns.get("crop", props.get("crop")))

Expand Down Expand Up @@ -169,6 +172,16 @@ def actual_exposure(self) -> float | None:
def actual_gain(self) -> float | None:
return self._actual_gain

@property
def actual_pixel_format(self) -> str | None:
"""Camera/native pixel format selected on the GenICam PixelFormat node."""
return self._camera_pixel_format or (self._pixel_format if self._pixel_format != "auto" else None)

@property
def actual_output_format(self) -> str | None:
"""Current GenTL backend emits OpenCV-native BGR uint8 frames."""
return self._actual_output_format or "BGR8"

@classmethod
def is_available(cls) -> bool:
return Harvester is not None
Expand Down Expand Up @@ -593,6 +606,21 @@ def waits_for_hardware_trigger(self) -> bool:
role = str(self._trigger_attr(getattr(self, "_trigger", None), "role", "off") or "off").lower()
return role in {"external", "follower"}

@staticmethod
def _output_format_for_frame(frame: np.ndarray) -> str:
if frame.ndim == 2:
if frame.dtype == np.uint8:
return "Mono8"
return f"Mono{frame.dtype}"
if frame.ndim == 3:
channels = frame.shape[2]
if channels == 3 and frame.dtype == np.uint8:
return "BGR8"
if channels == 4 and frame.dtype == np.uint8:
return "BGRA8"
return f"{channels}ch-{frame.dtype}"
return str(frame.dtype)

def read(self) -> tuple[np.ndarray, float]:
if self._acquirer is None:
raise RuntimeError("GenTL image acquirer not initialised")
Expand Down Expand Up @@ -631,6 +659,7 @@ def read(self) -> tuple[np.ndarray, float]:
self._read_telemetry(self._acquirer.remote_device.node_map)
except Exception:
pass
self._actual_output_format = self._output_format_for_frame(frame)

return frame, timestamp

Expand Down Expand Up @@ -1340,11 +1369,14 @@ def _configure_pixel_format(self, node_map) -> None:

pixel_format_node.value = selected
self._pixel_format = str(pixel_format_node.value)
self._camera_pixel_format = self._pixel_format

LOG.debug("GenTL pixel format selected: %s", self._pixel_format)

except Exception as e:
LOG.warning("Failed to configure pixel format '%s': %s", self._pixel_format, e)
if self._pixel_format and self._pixel_format.lower() != "auto":
self._camera_pixel_format = self._pixel_format

def _configure_trigger(self, node_map) -> None:
cfg = self._trigger
Expand Down Expand Up @@ -1811,7 +1843,13 @@ def _read_telemetry(self, node_map) -> None:

pixel_format = self._node_str(node_map, "PixelFormat")
if pixel_format is not None:
self._camera_pixel_format = pixel_format
ns["actual_pixel_format"] = pixel_format
ns["detected_pixel_format"] = pixel_format

output_format = self.actual_output_format
if output_format is not None:
ns["actual_output_format"] = output_format

except Exception:
pass
Expand Down
10 changes: 10 additions & 0 deletions dlclivegui/cameras/backends/opencv_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,16 @@ def actual_gain(self) -> None:
"""Not supported by OpenCV backend."""
return None

@property
def actual_pixel_format(self) -> str | None:
"""OpenCV does not reliably expose native camera pixel format."""
return None

@property
def actual_output_format(self) -> str | None:
"""OpenCV VideoCapture returns BGR frames in this backend."""
return "BGR8"

# ----------------------------
# Internal helpers
# ----------------------------
Expand Down
9 changes: 9 additions & 0 deletions dlclivegui/cameras/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class SupportLevel(str, Enum):
"set_fps": SupportLevel.UNSUPPORTED,
"set_exposure": SupportLevel.UNSUPPORTED,
"set_gain": SupportLevel.UNSUPPORTED,
"preserve_mono": SupportLevel.UNSUPPORTED,
"device_discovery": SupportLevel.UNSUPPORTED,
"stable_identity": SupportLevel.UNSUPPORTED,
"hardware_trigger": SupportLevel.UNSUPPORTED,
Expand Down Expand Up @@ -98,6 +99,14 @@ def static_capabilities(cls) -> dict[str, SupportLevel]:
"""Return a dict describing supported features for UI purposes."""
return DEFAULT_CAPABILITIES

@property
def actual_pixel_format(self) -> str | None:
return None

@property
def recommended_preserve_mono(self) -> bool | None:
return None

@classmethod
def options_key(cls) -> str:
"""Return the key used to store this backend's options in CameraSettings."""
Expand Down
Loading