Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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.

@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 @@ -45,6 +45,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 @@ -116,7 +121,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 @@ -137,6 +142,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 @@ -153,6 +176,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 @@ -179,6 +203,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 @@ -452,6 +487,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 @@ -537,6 +603,8 @@ def open(self) -> None:
except Exception:
self._actual_gain = None

self._read_camera_pixel_format()

# ----------------------------
# Start acquisition (skip for fast probe)
# ----------------------------
Expand All @@ -549,9 +617,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 @@ -627,6 +693,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 @@ -176,6 +179,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 @@ -587,6 +600,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 @@ -625,6 +653,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 @@ -1312,11 +1341,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 @@ -1783,7 +1815,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
9 changes: 7 additions & 2 deletions dlclivegui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@

## Debug
### Timing logs
SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = True
MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = True
SINGLE_CAMERA_WORKER_DO_LOG_TIMING: bool = False
MULTI_CAMERA_WORKER_DO_LOG_TIMING: bool = False
REC_DO_LOG_TIMING: bool = False
# MAIN_WINDOW_DO_LOG_TIMING: bool = False
#### Backends
BASLER_DO_LOG_TIMING: bool = False


class CameraSettings(BaseModel):
Expand All @@ -42,6 +45,7 @@ class CameraSettings(BaseModel):

exposure: int = 0 # 0=auto else µs
gain: float = 0.0 # 0.0=auto else value
preserve_mono: bool = False # if True, preserve mono images as mono (not BGR) when reading

crop_x0: int = 0
crop_y0: int = 0
Expand All @@ -65,6 +69,7 @@ def pretty(self) -> str:
f" fps={self.fps}, size={self.width or 'auto'}x{self.height or 'auto'}, "
f"exposure={self.exposure or 'auto'}, gain={self.gain or 'auto'}\n"
f" rotation={self.rotation}, crop={crop}\n"
f" preserve_mono={self.preserve_mono}, max_devices={self.max_devices}\n"
f"]"
)

Expand Down
Loading