From cc552853624c56c2de633839cfdf05300a791af1 Mon Sep 17 00:00:00 2001 From: Artur <35294812+arturoptophys@users.noreply.github.com> Date: Tue, 21 Oct 2025 11:22:49 +0200 Subject: [PATCH 01/69] Add modular camera backends with Basler and GenTL support --- .gitignore | 2 + README.md | 165 +- dlclivegui/__init__.py | 21 +- dlclivegui/camera/__init__.py | 43 - dlclivegui/camera/aravis.py | 128 -- dlclivegui/camera/basler.py | 104 -- dlclivegui/camera/camera.py | 139 -- dlclivegui/camera/opencv.py | 155 -- dlclivegui/camera/pseye.py | 101 -- dlclivegui/camera/tiscamera_linux.py | 204 --- dlclivegui/camera/tiscamera_windows.py | 129 -- dlclivegui/camera/tisgrabber_windows.py | 781 --------- dlclivegui/camera_controller.py | 120 ++ dlclivegui/camera_process.py | 338 ---- dlclivegui/cameras/__init__.py | 6 + dlclivegui/cameras/base.py | 46 + dlclivegui/cameras/basler_backend.py | 132 ++ dlclivegui/cameras/factory.py | 70 + dlclivegui/cameras/gentl_backend.py | 130 ++ dlclivegui/cameras/opencv_backend.py | 61 + dlclivegui/config.py | 112 ++ dlclivegui/dlc_processor.py | 123 ++ dlclivegui/dlclivegui.py | 1498 ----------------- dlclivegui/gui.py | 542 ++++++ dlclivegui/pose_process.py | 273 --- dlclivegui/processor/__init__.py | 1 - dlclivegui/processor/processor.py | 23 - dlclivegui/processor/teensy_laser/__init__.py | 1 - .../processor/teensy_laser/teensy_laser.ino | 77 - .../processor/teensy_laser/teensy_laser.py | 77 - dlclivegui/queue.py | 208 --- dlclivegui/tkutil.py | 195 --- dlclivegui/video.py | 274 --- dlclivegui/video_recorder.py | 46 + setup.py | 35 +- 35 files changed, 1533 insertions(+), 4827 deletions(-) delete mode 100644 dlclivegui/camera/__init__.py delete mode 100644 dlclivegui/camera/aravis.py delete mode 100644 dlclivegui/camera/basler.py delete mode 100644 dlclivegui/camera/camera.py delete mode 100644 dlclivegui/camera/opencv.py delete mode 100644 dlclivegui/camera/pseye.py delete mode 100644 dlclivegui/camera/tiscamera_linux.py delete mode 100644 dlclivegui/camera/tiscamera_windows.py delete mode 100644 dlclivegui/camera/tisgrabber_windows.py create mode 100644 dlclivegui/camera_controller.py delete mode 100644 dlclivegui/camera_process.py create mode 100644 dlclivegui/cameras/__init__.py create mode 100644 dlclivegui/cameras/base.py create mode 100644 dlclivegui/cameras/basler_backend.py create mode 100644 dlclivegui/cameras/factory.py create mode 100644 dlclivegui/cameras/gentl_backend.py create mode 100644 dlclivegui/cameras/opencv_backend.py create mode 100644 dlclivegui/config.py create mode 100644 dlclivegui/dlc_processor.py delete mode 100644 dlclivegui/dlclivegui.py create mode 100644 dlclivegui/gui.py delete mode 100644 dlclivegui/pose_process.py delete mode 100644 dlclivegui/processor/__init__.py delete mode 100644 dlclivegui/processor/processor.py delete mode 100644 dlclivegui/processor/teensy_laser/__init__.py delete mode 100644 dlclivegui/processor/teensy_laser/teensy_laser.ino delete mode 100644 dlclivegui/processor/teensy_laser/teensy_laser.py delete mode 100644 dlclivegui/queue.py delete mode 100644 dlclivegui/tkutil.py delete mode 100644 dlclivegui/video.py create mode 100644 dlclivegui/video_recorder.py diff --git a/.gitignore b/.gitignore index 208ed2c..1a13ced 100644 --- a/.gitignore +++ b/.gitignore @@ -116,3 +116,5 @@ venv.bak/ # ide files .vscode + +!dlclivegui/config.py diff --git a/README.md b/README.md index acdadf2..a886a98 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,126 @@ -# DeepLabCut-Live! GUI DLC LIVE! GUI - -Code style: black -![PyPI - Python Version](https://img.shields.io/pypi/v/deeplabcut-live-gui) -![PyPI - Downloads](https://img.shields.io/pypi/dm/deeplabcut-live-gui?color=purple) -![Python package](https://github.com/DeepLabCut/DeepLabCut-live/workflows/Python%20package/badge.svg) - -[![License](https://img.shields.io/pypi/l/deeplabcutcore.svg)](https://github.com/DeepLabCut/deeplabcutlive/raw/master/LICENSE) -[![Image.sc forum](https://img.shields.io/badge/dynamic/json.svg?label=forum&url=https%3A%2F%2Fforum.image.sc%2Ftags%2Fdeeplabcut.json&query=%24.topic_list.tags.0.topic_count&colorB=brightgreen&&suffix=%20topics&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAABPklEQVR42m3SyyqFURTA8Y2BER0TDyExZ+aSPIKUlPIITFzKeQWXwhBlQrmFgUzMMFLKZeguBu5y+//17dP3nc5vuPdee6299gohUYYaDGOyyACq4JmQVoFujOMR77hNfOAGM+hBOQqB9TjHD36xhAa04RCuuXeKOvwHVWIKL9jCK2bRiV284QgL8MwEjAneeo9VNOEaBhzALGtoRy02cIcWhE34jj5YxgW+E5Z4iTPkMYpPLCNY3hdOYEfNbKYdmNngZ1jyEzw7h7AIb3fRTQ95OAZ6yQpGYHMMtOTgouktYwxuXsHgWLLl+4x++Kx1FJrjLTagA77bTPvYgw1rRqY56e+w7GNYsqX6JfPwi7aR+Y5SA+BXtKIRfkfJAYgj14tpOF6+I46c4/cAM3UhM3JxyKsxiOIhH0IO6SH/A1Kb1WBeUjbkAAAAAElFTkSuQmCC)](https://forum.image.sc/tags/deeplabcut) -[![Gitter](https://badges.gitter.im/DeepLabCut/community.svg)](https://gitter.im/DeepLabCut/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) -[![Twitter Follow](https://img.shields.io/twitter/follow/DeepLabCut.svg?label=DeepLabCut&style=social)](https://twitter.com/DeepLabCut) - -GUI to run [DeepLabCut-live](https://github.com/DeepLabCut/DeepLabCut-live) on a video feed, record videos, and record external timestamps. - -## [Installation Instructions](docs/install.md) - -## Getting Started - -#### Open DeepLabCut-live-GUI - -In a terminal, activate the conda or virtual environment where DeepLabCut-live-GUI is installed, then run: - -``` -dlclivegui +# DeepLabCut Live GUI + +A modernised PyQt6 GUI for running [DeepLabCut-live](https://github.com/DeepLabCut/DeepLabCut-live) experiments. The application +streams frames from a camera, optionally performs DLCLive inference, and records video using the +[vidgear](https://github.com/abhiTronix/vidgear) toolkit. + +## Features + +- Python 3.11+ compatible codebase with a PyQt6 interface. +- Modular architecture with dedicated modules for camera control, video recording, configuration + management, and DLCLive processing. +- Single JSON configuration file that captures camera settings, DLCLive parameters, and recording + options. All fields can be edited directly within the GUI. +- Optional DLCLive inference with pose visualisation over the live video feed. +- Recording support via vidgear's `WriteGear`, including custom encoder options. + +## Installation + +1. Install the package and its dependencies: + + ```bash + pip install deeplabcut-live-gui + ``` + + The GUI requires additional runtime packages for optional features: + + - [DeepLabCut-live](https://github.com/DeepLabCut/DeepLabCut-live) for pose estimation. + - [vidgear](https://github.com/abhiTronix/vidgear) for video recording. + - [OpenCV](https://opencv.org/) for camera access. + + These libraries are listed in `setup.py` and will be installed automatically when the package is + installed via `pip`. + +2. Launch the GUI: + + ```bash + dlclivegui + ``` + +## Configuration + +The GUI works with a single JSON configuration describing the experiment. The configuration contains +three main sections: + +```json +{ + "camera": { + "index": 0, + "width": 1280, + "height": 720, + "fps": 60.0, + "backend": "opencv", + "properties": {} + }, + "dlc": { + "model_path": "/path/to/exported-model", + "processor": "cpu", + "shuffle": 1, + "trainingsetindex": 0, + "processor_args": {}, + "additional_options": {} + }, + "recording": { + "enabled": true, + "directory": "~/Videos/deeplabcut", + "filename": "session.mp4", + "container": "mp4", + "options": { + "compression_mode": "mp4" + } + } +} ``` +Use **File → Load configuration…** to open an existing configuration, or **File → Save configuration** +to persist the current settings. Every field in the GUI is editable, and values entered in the +interface will be written back to the JSON file. -#### Configurations +### Camera backends +Set `camera.backend` to one of the supported drivers: -First, create a configuration file: select the drop down menu labeled `Config`, and click `Create New Config`. All settings, such as details about cameras, DLC networks, and DLC-live Processors, will be saved into configuration files so that you can close and reopen the GUI without losing all of these details. You can create multiple configuration files on the same system, so that different users can save different camera options, etc on the same computer. To load previous settings from a configuration file, please just select the file from the drop-down menu. Configuration files are stored at `$HOME/Documents/DeepLabCut-live-GUI/config`. These files do not need to be edited manually, they can be entirely created and edited automatically within the GUI. +- `opencv` – standard `cv2.VideoCapture` fallback available on every platform. +- `basler` – uses the Basler Pylon SDK via `pypylon` (install separately). +- `gentl` – uses Aravis for GenTL-compatible cameras (requires `python-gi` bindings). -#### Set Up Cameras +Backend specific parameters can be supplied through the `camera.properties` object. For example: -To setup a new camera, select `Add Camera` from the dropdown menu, and then click `Init Cam`. This will be bring up a new window where you need to select the type of camera (see [Camera Support](docs/camera_support.md)), input a name for the camera, and click `Add Camera`. This will initialize a new `Camera` entry in the drop down menu. Now, select your camera from the dropdown menu and click`Edit Camera Settings` to setup your camera settings (i.e. set the serial number, exposure, cropping parameters, etc; the exact settings depend on the specific type of camera). Once you have set the camera settings, click `Init Cam` to start streaming. To stop streaming data, click `Close Camera`, and to remove a camera from the dropdown menu, click `Remove Camera`. - -#### Processor (optional) - -To write custom `Processors`, please see [here](https://github.com/DeepLabCut/DeepLabCut-live/tree/master/dlclive/processor). The directory that contains your custom `Processor` should be a python module -- this directory must contain an `__init__.py` file that imports your custom `Processor`. For examples of how to structure a custom `Processor` directory, please see [here](https://github.com/DeepLabCut/DeepLabCut-live/tree/master/example_processors). - -To use your processor in the GUI, you must first add your custom `Processor` directory to the dropdown menu: next to the `Processor Dir` label, click `Browse`, and select your custom `Processor` directory. Next, select the desired directory from the `Processor Dir` dropdown menu, then select the `Processor` you would like to use from the `Processor` menu. If you would like to edit the arguments for your processor, please select `Edit Proc Settings`, and finally, to use the processor, click `Set Proc`. If you have previously set a `Processor` and would like to clear it, click `Clear Proc`. - -#### Configure DeepLabCut Network - - - -Select the `DeepLabCut` dropdown menu, and click `Add DLC`. This will bring up a new window to choose a name for the DeepLabCut configuration, choose the path to the exported DeepLabCut model, and set DeepLabCut-live settings, such as the cropping or resize parameters. Once configured, click `Update` to add this DeepLabCut configuration to the dropdown menu. You can edit the settings at any time by clicking `Edit DLC Settings`. Once configured, you can load the network and start performing inference by clicking `Start DLC`. If you would like to view the DeepLabCut pose estimation in real-time, select `Display DLC Keypoints`. You can edit the keypoint display settings (the color scheme, size of points, and the likelihood threshold for display) by selecting `Edit DLC Display Settings`. - -If you want to stop performing inference at any time, just click `Stop DLC`, and if you want to remove a DeepLabCut configuration from the dropdown menu, click `Remove DLC`. +```json +{ + "camera": { + "index": 0, + "backend": "basler", + "properties": { + "serial": "40123456", + "exposure": 15000, + "gain": 6.0 + } + } +} +``` -#### Set Up Session +If optional dependencies are missing, the GUI will show the backend as unavailable in the drop-down +but you can still configure it for a system where the drivers are present. -Sessions are defined by the subject name, the date, and an attempt number. Within the GUI, select a `Subject` from the dropdown menu, or to add a new subject, type the new subject name in to the entry box and click `Add Subject`. Next, select an `Attempt` from the dropdown menu. Then, select the directory that you would like to save data to from the `Directory` dropdown menu. To add a new directory to the dropdown menu, click `Browse`. Finally, click `Set Up Session` to initiate a new recording. This will prepare the GUI to save data. Once you click `Set Up Session`, the `Ready` button should turn blue, indicating a session is ready to record. +## Development -#### Controlling Recording +The core modules of the package are organised as follows: -If the `Ready` button is selected, you can now start a recording by clicking `On`. The `On` button will then turn green indicating a recording is active. To stop a recording, click `Off`. This will cause the `Ready` button to be selected again, as the GUI is prepared to restart the recording and to save the data to the same file. If you're session is complete, click `Save Video` to save all files: the video recording (as .avi file), a numpy file with timestamps for each recorded frame, the DeepLabCut poses as a pandas data frame (hdf5 file) that includes the time of each frame used for pose estimation and the time that each pose was obtained, and if applicable, files saved by the `Processor` in use. These files will be saved into a new directory at `{YOUR_SAVE_DIRECTORY}/{CAMERA NAME}_{SUBJECT}_{DATE}_{ATTEMPT}` +- `dlclivegui.config` – dataclasses for loading, storing, and saving application settings. +- `dlclivegui.cameras` – modular camera backends (OpenCV, Basler, GenTL) and factory helpers. +- `dlclivegui.camera_controller` – camera capture worker running in a dedicated `QThread`. +- `dlclivegui.video_recorder` – wrapper around `WriteGear` for video output. +- `dlclivegui.dlc_processor` – asynchronous DLCLive inference with optional pose overlay. +- `dlclivegui.gui` – PyQt6 user interface and application entry point. -- YOUR_SAVE_DIRECTORY : the directory chosen from the `Directory` dropdown menu. -- CAMERA NAME : the name of selected camera (from the `Camera` dropdown menu). -- SUBJECT : the subject chosen from the `Subject` drowdown menu. -- DATE : the current date of the experiment. -- ATTEMPT : the attempt number chosen from the `Attempt` dropdown. +Run a quick syntax check with: -If you would not like to save the data from the session, please click `Delete Video`, and all data will be discarded. After you click `Save Video` or `Delete Video`, the `Off` button will be selected, indicating you can now set up a new session. +```bash +python -m compileall dlclivegui +``` -#### References: +## License -If you use this code we kindly ask you to you please [cite Kane et al, eLife 2020](https://elifesciences.org/articles/61909). The preprint is available here: https://www.biorxiv.org/content/10.1101/2020.08.04.236422v2 +This project is licensed under the GNU Lesser General Public License v3.0. See the `LICENSE` file for +more information. diff --git a/dlclivegui/__init__.py b/dlclivegui/__init__.py index 1583156..1408486 100644 --- a/dlclivegui/__init__.py +++ b/dlclivegui/__init__.py @@ -1,4 +1,17 @@ -from dlclivegui.camera_process import CameraProcess -from dlclivegui.pose_process import CameraPoseProcess -from dlclivegui.video import create_labeled_video -from dlclivegui.dlclivegui import DLCLiveGUI +"""DeepLabCut Live GUI package.""" +from .config import ( + ApplicationSettings, + CameraSettings, + DLCProcessorSettings, + RecordingSettings, +) +from .gui import MainWindow, main + +__all__ = [ + "ApplicationSettings", + "CameraSettings", + "DLCProcessorSettings", + "RecordingSettings", + "MainWindow", + "main", +] diff --git a/dlclivegui/camera/__init__.py b/dlclivegui/camera/__init__.py deleted file mode 100644 index 2368198..0000000 --- a/dlclivegui/camera/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M. Mathis Labs - -Licensed under GNU Lesser General Public License v3.0 -""" - - -import platform - -from dlclivegui.camera.camera import Camera, CameraError -from dlclivegui.camera.opencv import OpenCVCam - -if platform.system() == "Windows": - try: - from dlclivegui.camera.tiscamera_windows import TISCam - except Exception as e: - pass - -if platform.system() == "Linux": - try: - from dlclivegui.camera.tiscamera_linux import TISCam - except Exception as e: - pass - # print(f"Error importing TISCam on Linux: {e}") - -if platform.system() in ["Darwin", "Linux"]: - try: - from dlclivegui.camera.aravis import AravisCam - except Exception as e: - pass - # print(f"Error importing AravisCam: f{e}") - -if platform.system() == "Darwin": - try: - from dlclivegui.camera.pseye import PSEyeCam - except Exception as e: - pass - -try: - from dlclivegui.camera.basler import BaslerCam -except Exception as e: - pass diff --git a/dlclivegui/camera/aravis.py b/dlclivegui/camera/aravis.py deleted file mode 100644 index 92662e1..0000000 --- a/dlclivegui/camera/aravis.py +++ /dev/null @@ -1,128 +0,0 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M. Mathis Labs - -Licensed under GNU Lesser General Public License v3.0 -""" - - -import ctypes -import numpy as np -import time - -import gi - -gi.require_version("Aravis", "0.6") -from gi.repository import Aravis -import cv2 - -from dlclivegui.camera import Camera - - -class AravisCam(Camera): - @staticmethod - def arg_restrictions(): - - Aravis.update_device_list() - n_cams = Aravis.get_n_devices() - ids = [Aravis.get_device_id(i) for i in range(n_cams)] - return {"id": ids} - - def __init__( - self, - id="", - resolution=[720, 540], - exposure=0.005, - gain=0, - rotate=0, - crop=None, - fps=100, - display=True, - display_resize=1.0, - ): - - super().__init__( - id, - resolution=resolution, - exposure=exposure, - gain=gain, - rotate=rotate, - crop=crop, - fps=fps, - use_tk_display=display, - display_resize=display_resize, - ) - - def set_capture_device(self): - - self.cam = Aravis.Camera.new(self.id) - self.no_auto() - self.set_exposure(self.exposure) - self.set_crop(self.crop) - self.cam.set_frame_rate(self.fps) - - self.stream = self.cam.create_stream() - self.stream.push_buffer(Aravis.Buffer.new_allocate(self.cam.get_payload())) - self.cam.start_acquisition() - - return True - - def no_auto(self): - - self.cam.set_exposure_time_auto(0) - self.cam.set_gain_auto(0) - - def set_exposure(self, val): - - val = 1 if val > 1 else val - val = 0 if val < 0 else val - self.cam.set_exposure_time(val * 1e6) - - def set_crop(self, crop): - - if crop: - left = crop[0] - width = crop[1] - left - top = crop[3] - height = top - crop[2] - self.cam.set_region(left, top, width, height) - self.im_size = (width, height) - - def get_image_on_time(self): - - buffer = None - while buffer is None: - buffer = self.stream.try_pop_buffer() - - frame = self._convert_image_to_numpy(buffer) - self.stream.push_buffer(buffer) - - return frame, time.time() - - def _convert_image_to_numpy(self, buffer): - """ from https://github.com/SintefManufacturing/python-aravis """ - - pixel_format = buffer.get_image_pixel_format() - bits_per_pixel = pixel_format >> 16 & 0xFF - - if bits_per_pixel == 8: - INTP = ctypes.POINTER(ctypes.c_uint8) - else: - INTP = ctypes.POINTER(ctypes.c_uint16) - - addr = buffer.get_data() - ptr = ctypes.cast(addr, INTP) - - frame = np.ctypeslib.as_array( - ptr, (buffer.get_image_height(), buffer.get_image_width()) - ) - frame = frame.copy() - - if frame.ndim < 3: - frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2RGB) - - return frame - - def close_capture_device(): - - self.cam.stop_acquisition() diff --git a/dlclivegui/camera/basler.py b/dlclivegui/camera/basler.py deleted file mode 100644 index 1706208..0000000 --- a/dlclivegui/camera/basler.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M. Mathis Labs - -Licensed under GNU Lesser General Public License v3.0 -""" - -#import pypylon as pylon -from pypylon import pylon -from imutils import rotate_bound -import time - -from dlclivegui.camera import Camera, CameraError -TIMEOUT = 100 - -def get_devices(): - tlFactory = pylon.TlFactory.GetInstance() - devices = tlFactory.EnumerateDevices() - return devices - -class BaslerCam(Camera): - @staticmethod - def arg_restrictions(): - """ Returns a dictionary of arguments restrictions for DLCLiveGUI - """ - devices = get_devices() - device_ids = list(range(len(devices))) - return {"device": device_ids, "display": [True, False]} - - def __init__( - self, - device=0, - resolution=[640, 480], - exposure=15000, - rotate=0, - crop=None, - gain=0.0, - fps=30, - display=True, - display_resize=1.0, - ): - - super().__init__( - device, - resolution=resolution, - exposure=exposure, - rotate=rotate, - crop=crop, - gain=gain, - fps=fps, - use_tk_display=display, - display_resize=display_resize, - ) - - self.display = display - - def set_capture_device(self): - - devices = get_devices() - self.cam = pylon.InstantCamera( - pylon.TlFactory.GetInstance().CreateDevice(devices[self.id]) - ) - self.cam.Open() - - self.cam.Gain.SetValue(self.gain) - self.cam.ExposureTime.SetValue(self.exposure) - self.cam.Width.SetValue(self.im_size[0]) - self.cam.Height.SetValue(self.im_size[1]) - - self.cam.StartGrabbing(pylon.GrabStrategy_LatestImageOnly) - self.converter = pylon.ImageFormatConverter() - self.converter.OutputPixelFormat = pylon.PixelType_BGR8packed - self.converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned - - return True - - def get_image(self): - grabResult = self.cam.RetrieveResult( - TIMEOUT, pylon.TimeoutHandling_ThrowException) - - frame = None - - if grabResult.GrabSucceeded(): - - image = self.converter.Convert(grabResult) - frame = image.GetArray() - - if self.rotate: - frame = rotate_bound(frame, self.rotate) - if self.crop: - frame = frame[self.crop[2]: self.crop[3], - self.crop[0]: self.crop[1]] - - else: - - raise CameraError("Basler Camera did not return an image!") - - grabResult.Release() - - return frame - - def close_capture_device(self): - - self.cam.StopGrabbing() diff --git a/dlclivegui/camera/camera.py b/dlclivegui/camera/camera.py deleted file mode 100644 index e81442f..0000000 --- a/dlclivegui/camera/camera.py +++ /dev/null @@ -1,139 +0,0 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M. Mathis Labs - -Licensed under GNU Lesser General Public License v3.0 -""" - - -import cv2 -import time - - -class CameraError(Exception): - """ - Exception for incorrect use of cameras - """ - - pass - - -class Camera(object): - """ Base camera class. Controls image capture, writing images to video, pose estimation and image display. - - Parameters - ---------- - id : [type] - camera id - exposure : int, optional - exposure time in microseconds, by default None - gain : int, optional - gain value, by default None - rotate : [type], optional - [description], by default None - crop : list, optional - camera cropping parameters: [left, right, top, bottom], by default None - fps : float, optional - frame rate in frames per second, by default None - use_tk_display : bool, optional - flag to use tk image display (if using GUI), by default False - display_resize : float, optional - factor to resize images if using opencv display (display is very slow for large images), by default None - """ - - @staticmethod - def arg_restrictions(): - """ Returns a dictionary of arguments restrictions for DLCLiveGUI - """ - - return {} - - def __init__( - self, - id, - resolution=None, - exposure=None, - gain=None, - rotate=None, - crop=None, - fps=None, - use_tk_display=False, - display_resize=1.0, - ): - """ Constructor method - """ - - self.id = id - self.exposure = exposure - self.gain = gain - self.rotate = rotate - self.crop = [int(c) for c in crop] if crop else None - self.set_im_size(resolution) - self.fps = fps - self.use_tk_display = use_tk_display - self.display_resize = display_resize if display_resize else 1.0 - self.next_frame = 0 - - def set_im_size(self, res): - """[summary] - - Parameters - ---------- - default : [, optional - [description], by default None - - Raises - ------ - DLCLiveCameraError - throws error if resolution is not set - """ - - if not res: - raise CameraError("Resolution is not set!") - - self.im_size = ( - (int(res[0]), int(res[1])) - if self.crop is None - else (self.crop[3] - self.crop[2], self.crop[1] - self.crop[0]) - ) - - def set_capture_device(self): - """ Sets frame capture device with desired properties - """ - - raise NotImplementedError - - def get_image_on_time(self): - """ Gets an image from frame capture device at the appropriate time (according to fps). - - Returns - ------- - `np.ndarray` - image as a numpy array - float - timestamp at which frame was taken, obtained from :func:`time.time` - """ - - frame = None - while frame is None: - cur_time = time.time() - if cur_time > self.next_frame: - frame = self.get_image() - timestamp = cur_time - self.next_frame = max( - self.next_frame + 1.0 / self.fps, cur_time + 0.5 / self.fps - ) - - return frame, timestamp - - def get_image(self): - """ Gets image from frame capture device - """ - - raise NotImplementedError - - def close_capture_device(self): - """ Closes frame capture device - """ - - raise NotImplementedError diff --git a/dlclivegui/camera/opencv.py b/dlclivegui/camera/opencv.py deleted file mode 100644 index 7ac96b2..0000000 --- a/dlclivegui/camera/opencv.py +++ /dev/null @@ -1,155 +0,0 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M. Mathis Labs - -Licensed under GNU Lesser General Public License v3.0 -""" - - -import cv2 -from tkinter import filedialog -from imutils import rotate_bound -import time -import platform - -from dlclivegui.camera import Camera, CameraError - - -class OpenCVCam(Camera): - @staticmethod - def arg_restrictions(): - """ Returns a dictionary of arguments restrictions for DLCLiveGUI - """ - - cap = cv2.VideoCapture() - devs = [-1] - avail = True - while avail: - cur_index = devs[-1] + 1 - avail = cap.open(cur_index) - if avail: - devs.append(cur_index) - cap.release() - - return {"device": devs, "display": [True, False]} - - def __init__( - self, - device=-1, - file="", - resolution=[640, 480], - auto_exposure=0, - exposure=0, - gain=0, - rotate=0, - crop=None, - fps=30, - display=True, - display_resize=1.0, - ): - - if device != -1: - if file: - raise DLCLiveCameraError( - "A device and file were provided to OpenCVCam. Must initialize an OpenCVCam with either a device id or a video file." - ) - - self.video = False - id = int(device) - - else: - if not file: - file = filedialog.askopenfilename( - title="Select video file for DLC-live-GUI" - ) - if not file: - raise DLCLiveCameraError( - "Neither a device nor file were provided to OpenCVCam. Must initialize an OpenCVCam with either a device id or a video file." - ) - - self.video = True - cap = cv2.VideoCapture(file) - resolution = ( - cap.get(cv2.CAP_PROP_FRAME_WIDTH), - cap.get(cv2.CAP_PROP_FRAME_HEIGHT), - ) - fps = cap.get(cv2.CAP_PROP_FPS) - del cap - id = file - - super().__init__( - id, - resolution=resolution, - exposure=exposure, - rotate=rotate, - crop=crop, - fps=fps, - use_tk_display=display, - display_resize=display_resize, - ) - self.auto_exposure = auto_exposure - self.gain = gain - - def set_capture_device(self): - - if not self.video: - - self.cap = ( - cv2.VideoCapture(self.id, cv2.CAP_V4L) - if platform.system() == "Linux" - else cv2.VideoCapture(self.id) - ) - ret, frame = self.cap.read() - - if self.im_size: - self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.im_size[0]) - self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.im_size[1]) - if self.auto_exposure: - self.cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, self.auto_exposure) - if self.exposure: - self.cap.set(cv2.CAP_PROP_EXPOSURE, self.exposure) - if self.gain: - self.cap.set(cv2.CAP_PROP_GAIN, self.gain) - if self.fps: - self.cap.set(cv2.CAP_PROP_FPS, self.fps) - - else: - - self.cap = cv2.VideoCapture(self.id) - - # self.im_size = (self.cap.get(cv2.CAP_PROP_FRAME_WIDTH), self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) - # self.fps = self.cap.get(cv2.CAP_PROP_FPS) - self.last_cap_read = 0 - - self.cv2_color = self.cap.get(cv2.CAP_PROP_MODE) - - return True - - def get_image_on_time(self): - - # if video, wait... - if self.video: - while time.time() - self.last_cap_read < (1.0 / self.fps): - pass - - ret, frame = self.cap.read() - - if ret: - if self.rotate: - frame = rotate_bound(frame, self.rotate) - if self.crop: - frame = frame[self.crop[2] : self.crop[3], self.crop[0] : self.crop[1]] - - if frame.ndim == 3: - if self.cv2_color == 1: - frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) - - self.last_cap_read = time.time() - - return frame, self.last_cap_read - else: - raise CameraError("OpenCV VideoCapture.read did not return an image!") - - def close_capture_device(self): - - self.cap.release() diff --git a/dlclivegui/camera/pseye.py b/dlclivegui/camera/pseye.py deleted file mode 100644 index 4c79065..0000000 --- a/dlclivegui/camera/pseye.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M. Mathis Labs - -Licensed under GNU Lesser General Public License v3.0 -""" - - -import cv2 -from imutils import rotate_bound -import numpy as np -import pseyepy - -from dlclivegui.camera import Camera, CameraError - - -class PSEyeCam(Camera): - @staticmethod - def arg_restrictions(): - - return { - "device": [i for i in range(pseyepy.cam_count())], - "resolution": [[320, 240], [640, 480]], - "fps": [30, 40, 50, 60, 75, 100, 125], - "colour": [True, False], - "auto_whitebalance": [True, False], - } - - def __init__( - self, - device=0, - resolution=[320, 240], - exposure=100, - gain=20, - rotate=0, - crop=None, - fps=60, - colour=False, - auto_whitebalance=False, - red_balance=125, - blue_balance=125, - green_balance=125, - display=True, - display_resize=1.0, - ): - - super().__init__( - device, - resolution=resolution, - exposure=exposure, - gain=gain, - rotate=rotate, - crop=crop, - fps=fps, - use_tk_display=display, - display_resize=display_resize, - ) - self.colour = colour - self.auto_whitebalance = auto_whitebalance - self.red_balance = red_balance - self.blue_balance = blue_balance - self.green_balance = green_balance - - def set_capture_device(self): - - if self.im_size[0] == 320: - res = pseyepy.Camera.RES_SMALL - elif self.im_size[0] == 640: - res = pseyepy.Camera.RES_LARGE - else: - raise CameraError(f"pseye resolution {self.im_size} not supported") - - self.cap = pseyepy.Camera( - self.id, - fps=self.fps, - resolution=res, - exposure=self.exposure, - gain=self.gain, - colour=self.colour, - auto_whitebalance=self.auto_whitebalance, - red_balance=self.red_balance, - blue_balance=self.blue_balance, - green_balance=self.green_balance, - ) - - return True - - def get_image_on_time(self): - - frame, _ = self.cap.read() - - if self.rotate != 0: - frame = rotate_bound(frame, self.rotate) - if self.crop: - frame = frame[self.crop[2] : self.crop[3], self.crop[0] : self.crop[1]] - - return frame - - def close_capture_device(self): - - self.cap.end() diff --git a/dlclivegui/camera/tiscamera_linux.py b/dlclivegui/camera/tiscamera_linux.py deleted file mode 100644 index 5f0afd8..0000000 --- a/dlclivegui/camera/tiscamera_linux.py +++ /dev/null @@ -1,204 +0,0 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M. Mathis Labs - -Licensed under GNU Lesser General Public License v3.0 -""" - - -import warnings -import numpy as np -import time - -import gi - -gi.require_version("Tcam", "0.1") -gi.require_version("Gst", "1.0") -from gi.repository import Tcam, Gst, GLib, GObject - -from dlclivegui.camera import Camera - - -class TISCam(Camera): - - FRAME_RATE_OPTIONS = [15, 30, 60, 120, 240, 480] - FRAME_RATE_FRACTIONS = ["15/1", "30/1", "60/1", "120/1", "5000000/20833", "480/1"] - IM_FORMAT = (720, 540) - ROTATE_OPTIONS = ["identity", "90r", "180", "90l", "horiz", "vert"] - - @staticmethod - def arg_restrictions(): - - if not Gst.is_initialized(): - Gst.init() - - source = Gst.ElementFactory.make("tcambin") - return { - "serial_number": source.get_device_serials(), - "fps": TISCam.FRAME_RATE_OPTIONS, - "rotate": TISCam.ROTATE_OPTIONS, - "color": [True, False], - "display": [True, False], - } - - def __init__( - self, - serial_number="", - resolution=[720, 540], - exposure=0.005, - rotate="identity", - crop=None, - fps=120, - color=False, - display=True, - tk_resize=1.0, - ): - - super().__init__( - serial_number, - resolution=resolution, - exposure=exposure, - rotate=rotate, - crop=crop, - fps=fps, - use_tk_display=(not display), - display_resize=tk_resize, - ) - self.color = color - self.display = display - self.sample_locked = False - self.new_sample = False - - def no_auto(self): - - self.cam.set_tcam_property("Exposure Auto", GObject.Value(bool, False)) - - def set_exposure(self, val): - - val = 1 if val > 1 else val - val = 0 if val < 0 else val - self.cam.set_tcam_property("Exposure", val * 1e6) - - def set_crop(self, crop): - - if crop: - self.gst_crop = self.gst_pipeline.get_by_name("crop") - self.gst_crop.set_property("left", crop[0]) - self.gst_crop.set_property("right", TISCam.IM_FORMAT[0] - crop[1]) - self.gst_crop.set_property("top", crop[2]) - self.gst_crop.set_property("bottom", TISCam.IM_FORMAT[1] - crop[3]) - self.im_size = (crop[3] - crop[2], crop[1] - crop[0]) - - def set_rotation(self, val): - - if val: - self.gst_rotate = self.gst_pipeline.get_by_name("rotate") - self.gst_rotate.set_property("video-direction", val) - - def set_sink(self): - - self.gst_sink = self.gst_pipeline.get_by_name("sink") - self.gst_sink.set_property("max-buffers", 1) - self.gst_sink.set_property("drop", 1) - self.gst_sink.set_property("emit-signals", True) - self.gst_sink.connect("new-sample", self.get_image) - - def setup_gst(self, serial_number, fps): - - if not Gst.is_initialized(): - Gst.init() - - fps_index = np.where( - [int(fps) == int(opt) for opt in TISCam.FRAME_RATE_OPTIONS] - )[0][0] - fps_frac = TISCam.FRAME_RATE_FRACTIONS[fps_index] - fmat = "BGRx" if self.color else "GRAY8" - - pipeline = ( - "tcambin name=cam " - "! videocrop name=crop " - "! videoflip name=rotate " - "! video/x-raw,format={},framerate={} ".format(fmat, fps_frac) - ) - - if self.display: - pipe_sink = ( - "! tee name=t " - "t. ! queue ! videoconvert ! ximagesink " - "t. ! queue ! appsink name=sink" - ) - else: - pipe_sink = "! appsink name=sink" - - pipeline += pipe_sink - - self.gst_pipeline = Gst.parse_launch(pipeline) - - self.cam = self.gst_pipeline.get_by_name("cam") - self.cam.set_property("serial", serial_number) - - self.set_exposure(self.exposure) - self.set_crop(self.crop) - self.set_rotation(self.rotate) - self.set_sink() - - def set_capture_device(self): - - self.setup_gst(self.id, self.fps) - self.gst_pipeline.set_state(Gst.State.PLAYING) - - return True - - def get_image(self, sink): - - # wait for sample to unlock - while self.sample_locked: - pass - - try: - - self.sample = sink.get_property("last-sample") - self._convert_image_to_numpy() - - except GLib.Error as e: - - warnings.warn("Error reading image :: {}".format(e)) - - finally: - - return 0 - - def _convert_image_to_numpy(self): - - self.sample_locked = True - - buffer = self.sample.get_buffer() - struct = self.sample.get_caps().get_structure(0) - - height = struct.get_value("height") - width = struct.get_value("width") - fmat = struct.get_value("format") - dtype = np.uint16 if fmat == "GRAY16_LE" else np.uint8 - ncolors = 1 if "GRAY" in fmat else 4 - - self.frame = np.ndarray( - shape=(height, width, ncolors), - buffer=buffer.extract_dup(0, buffer.get_size()), - dtype=dtype, - ) - - self.sample_locked = False - self.new_sample = True - - def get_image_on_time(self): - - # wait for new sample - while not self.new_sample: - pass - self.new_sample = False - - return self.frame, time.time() - - def close_capture_device(self): - - self.gst_pipeline.set_state(Gst.State.NULL) diff --git a/dlclivegui/camera/tiscamera_windows.py b/dlclivegui/camera/tiscamera_windows.py deleted file mode 100644 index bac6359..0000000 --- a/dlclivegui/camera/tiscamera_windows.py +++ /dev/null @@ -1,129 +0,0 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M. Mathis Labs - -Licensed under GNU Lesser General Public License v3.0 -""" - -import time -import cv2 - -from dlclivegui.camera import Camera, CameraError -from dlclivegui.camera.tisgrabber_windows import TIS_CAM - - -class TISCam(Camera): - @staticmethod - def arg_restrictions(): - - return {"serial_number": TIS_CAM().GetDevices(), "rotate": [0, 90, 180, 270]} - - def __init__( - self, - serial_number="", - resolution=[720, 540], - exposure=0.005, - rotate=0, - crop=None, - fps=100, - display=True, - display_resize=1.0, - ): - """ - Params - ------ - serial_number = string; serial number for imaging source camera - crop = dict; contains ints named top, left, height, width for cropping - default = None, uses default parameters specific to camera - """ - - if (rotate == 90) or (rotate == 270): - resolution = [resolution[1], resolution[0]] - - super().__init__( - serial_number, - resolution=resolution, - exposure=exposure, - rotate=rotate, - crop=crop, - fps=fps, - use_tk_display=display, - display_resize=display_resize, - ) - self.display = display - - def set_exposure(self): - - val = self.exposure - val = 1 if val > 1 else val - val = 0 if val < 0 else val - self.cam.SetPropertyAbsoluteValue("Exposure", "Value", val) - - def get_exposure(self): - - exposure = [0] - self.cam.GetPropertyAbsoluteValue("Exposure", "Value", exposure) - return round(exposure[0], 3) - - # def set_crop(self): - - # crop = self.crop - - # if crop: - # top = int(crop[0]) - # left = int(crop[2]) - # height = int(crop[1]-top) - # width = int(crop[3]-left) - - # if not self.crop_filter: - # self.crop_filter = self.cam.CreateFrameFilter(b'ROI') - # self.cam.AddFrameFilter(self.crop_filter) - - # self.cam.FilterSetParameter(self.crop_filter, b'Top', top) - # self.cam.FilterSetParameter(self.crop_filter, b'Left', left) - # self.cam.FilterSetParameter(self.crop_filter, b'Height', height) - # self.cam.FilterSetParameter(self.crop_filter, b'Width', width) - - def set_rotation(self): - - if not self.rotation_filter: - self.rotation_filter = self.cam.CreateFrameFilter(b"Rotate Flip") - self.cam.AddFrameFilter(self.rotation_filter) - self.cam.FilterSetParameter( - self.rotation_filter, b"Rotation Angle", self.rotate - ) - - def set_fps(self): - - self.cam.SetFrameRate(self.fps) - - def set_capture_device(self): - - self.cam = TIS_CAM() - self.crop_filter = None - self.rotation_filter = None - self.set_rotation() - # self.set_crop() - self.set_fps() - self.next_frame = time.time() - - self.cam.open(self.id) - self.cam.SetContinuousMode(0) - self.cam.StartLive(0) - - self.set_exposure() - - return True - - def get_image(self): - - self.cam.SnapImage() - frame = self.cam.GetImageEx() - frame = cv2.flip(frame, 0) - if self.crop is not None: - frame = frame[self.crop[0] : self.crop[1], self.crop[2] : self.crop[3]] - return frame - - def close_capture_device(self): - - self.cam.StopLive() diff --git a/dlclivegui/camera/tisgrabber_windows.py b/dlclivegui/camera/tisgrabber_windows.py deleted file mode 100644 index 194e18e..0000000 --- a/dlclivegui/camera/tisgrabber_windows.py +++ /dev/null @@ -1,781 +0,0 @@ -""" -Created on Mon Nov 21 09:44:40 2016 - -@author: Daniel Vassmer, Stefan_Geissler -From: https://github.com/TheImagingSource/IC-Imaging-Control-Samples/tree/master/Python - -modified 10/3/2019 by Gary Kane - https://github.com/gkane26 -""" - -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M. Mathis Labs - -Licensed under GNU Lesser General Public License v3.0 -""" - - -from enum import Enum - -import ctypes as C -import os -import sys -import numpy as np - - -class SinkFormats(Enum): - Y800 = 0 - RGB24 = 1 - RGB32 = 2 - UYVY = 3 - Y16 = 4 - - -ImageFileTypes = {"BMP": 0, "JPEG": 1} - - -class GrabberHandle(C.Structure): - pass - - -GrabberHandle._fields_ = [("unused", C.c_int)] - -############################################################################## - -### GK Additions from: https://github.com/morefigs/py-ic-imaging-control - - -class FilterParameter(C.Structure): - pass - - -FilterParameter._fields_ = [("Name", C.c_char * 30), ("Type", C.c_int)] - - -class FrameFilterHandle(C.Structure): - pass - - -FrameFilterHandle._fields_ = [ - ("pFilter", C.c_void_p), - ("bHasDialog", C.c_int), - ("ParameterCount", C.c_int), - ("Parameters", C.POINTER(FilterParameter)), -] - -############################################################################## - - -class TIS_GrabberDLL(object): - if sys.maxsize > 2 ** 32: - __tisgrabber = C.windll.LoadLibrary("tisgrabber_x64.dll") - else: - __tisgrabber = C.windll.LoadLibrary("tisgrabber.dll") - - def __init__(self, **keyargs): - """Initialize the Albatross from the keyword arguments.""" - self.__dict__.update(keyargs) - - GrabberHandlePtr = C.POINTER(GrabberHandle) - - #################################### - - # Initialize the ICImagingControl class library. This function must be called - # only once before any other functions of this library are called. - # @param szLicenseKey IC Imaging Control license key or NULL if only a trial version is available. - # @retval IC_SUCCESS on success. - # @retval IC_ERROR on wrong license key or other errors. - # @sa IC_CloseLibrary - InitLibrary = __tisgrabber.IC_InitLibrary(None) - - # Get the number of the currently available devices. This function creates an - # internal array of all connected video capture devices. With each call to this - # function, this array is rebuild. The name and the unique name can be retrieved - # from the internal array using the functions IC_GetDevice() and IC_GetUniqueNamefromList. - # They are usefull for retrieving device names for opening devices. - # - # @retval >= 0 Success, count of found devices. - # @retval IC_NO_HANDLE Internal Error. - # - # @sa IC_GetDevice - # @sa IC_GetUniqueNamefromList - get_devicecount = __tisgrabber.IC_GetDeviceCount - get_devicecount.restype = C.c_int - get_devicecount.argtypes = None - - # Get unique device name of a device specified by iIndex. The unique device name - # consist from the device name and its serial number. It allows to differ between - # more then one device of the same type connected to the computer. The unique device name - # is passed to the function IC_OpenDevByUniqueName - # - # @param iIndex The number of the device whose name is to be returned. It must be - # in the range from 0 to IC_GetDeviceCount(), - # @return Returns the string representation of the device on success, NULL - # otherwise. - # - # @sa IC_GetDeviceCount - # @sa IC_GetUniqueNamefromList - # @sa IC_OpenDevByUniqueName - - get_unique_name_from_list = __tisgrabber.IC_GetUniqueNamefromList - get_unique_name_from_list.restype = C.c_char_p - get_unique_name_from_list.argtypes = (C.c_int,) - - # Creates a new grabber handle and returns it. A new created grabber should be - # release with a call to IC_ReleaseGrabber if it is no longer needed. - # @sa IC_ReleaseGrabber - create_grabber = __tisgrabber.IC_CreateGrabber - create_grabber.restype = GrabberHandlePtr - create_grabber.argtypes = None - - # Open a video capture by using its UniqueName. Use IC_GetUniqueName() to - # retrieve the unique name of a camera. - # - # @param hGrabber Handle to a grabber object - # @param szDisplayName Memory that will take the display name. - # - # @sa IC_GetUniqueName - # @sa IC_ReleaseGrabber - open_device_by_unique_name = __tisgrabber.IC_OpenDevByUniqueName - open_device_by_unique_name.restype = C.c_int - open_device_by_unique_name.argtypes = (GrabberHandlePtr, C.c_char_p) - - set_videoformat = __tisgrabber.IC_SetVideoFormat - set_videoformat.restype = C.c_int - set_videoformat.argtypes = (GrabberHandlePtr, C.c_char_p) - - set_framerate = __tisgrabber.IC_SetFrameRate - set_framerate.restype = C.c_int - set_framerate.argtypes = (GrabberHandlePtr, C.c_float) - - # Returns the width of the video format. - get_video_format_width = __tisgrabber.IC_GetVideoFormatWidth - get_video_format_width.restype = C.c_int - get_video_format_width.argtypes = (GrabberHandlePtr,) - - # returns the height of the video format. - get_video_format_height = __tisgrabber.IC_GetVideoFormatHeight - get_video_format_height.restype = C.c_int - get_video_format_height.argtypes = (GrabberHandlePtr,) - - # Get the number of the available video formats for the current device. - # A video capture device must have been opened before this call. - # - # @param hGrabber The handle to the grabber object. - # - # @retval >= 0 Success - # @retval IC_NO_DEVICE No video capture device selected. - # @retval IC_NO_HANDLE No handle to the grabber object. - # - # @sa IC_GetVideoFormat - GetVideoFormatCount = __tisgrabber.IC_GetVideoFormatCount - GetVideoFormatCount.restype = C.c_int - GetVideoFormatCount.argtypes = (GrabberHandlePtr,) - - # Get a string representation of the video format specified by iIndex. - # iIndex must be between 0 and IC_GetVideoFormatCount(). - # IC_GetVideoFormatCount() must have been called before this function, - # otherwise it will always fail. - # - # @param hGrabber The handle to the grabber object. - # @param iIndex Number of the video format to be used. - # - # @retval Nonnull The name of the specified video format. - # @retval NULL An error occured. - # @sa IC_GetVideoFormatCount - GetVideoFormat = __tisgrabber.IC_GetVideoFormat - GetVideoFormat.restype = C.c_char_p - GetVideoFormat.argtypes = (GrabberHandlePtr, C.c_int) - - # Get the number of the available input channels for the current device. - # A video capture device must have been opened before this call. - # - # @param hGrabber The handle to the grabber object. - # - # @retval >= 0 Success - # @retval IC_NO_DEVICE No video capture device selected. - # @retval IC_NO_HANDLE No handle to the grabber object. - # - # @sa IC_GetInputChannel - GetInputChannelCount = __tisgrabber.IC_GetInputChannelCount - GetInputChannelCount.restype = C.c_int - GetInputChannelCount.argtypes = (GrabberHandlePtr,) - - # Get a string representation of the input channel specified by iIndex. - # iIndex must be between 0 and IC_GetInputChannelCount(). - # IC_GetInputChannelCount() must have been called before this function, - # otherwise it will always fail. - # @param hGrabber The handle to the grabber object. - # @param iIndex Number of the input channel to be used.. - # - # @retval Nonnull The name of the specified input channel - # @retval NULL An error occured. - # @sa IC_GetInputChannelCount - GetInputChannel = __tisgrabber.IC_GetInputChannel - GetInputChannel.restype = C.c_char_p - GetInputChannel.argtypes = (GrabberHandlePtr, C.c_int) - - # Get the number of the available video norms for the current device. - # A video capture device must have been opened before this call. - # - # @param hGrabber The handle to the grabber object. - # - # @retval >= 0 Success - # @retval IC_NO_DEVICE No video capture device selected. - # @retval IC_NO_HANDLE No handle to the grabber object. - # - # @sa IC_GetVideoNorm - GetVideoNormCount = __tisgrabber.IC_GetVideoNormCount - GetVideoNormCount.restype = C.c_int - GetVideoNormCount.argtypes = (GrabberHandlePtr,) - - # Get a string representation of the video norm specified by iIndex. - # iIndex must be between 0 and IC_GetVideoNormCount(). - # IC_GetVideoNormCount() must have been called before this function, - # otherwise it will always fail. - # - # @param hGrabber The handle to the grabber object. - # @param iIndex Number of the video norm to be used. - # - # @retval Nonnull The name of the specified video norm. - # @retval NULL An error occured. - # @sa IC_GetVideoNormCount - GetVideoNorm = __tisgrabber.IC_GetVideoNorm - GetVideoNorm.restype = C.c_char_p - GetVideoNorm.argtypes = (GrabberHandlePtr, C.c_int) - - SetFormat = __tisgrabber.IC_SetFormat - SetFormat.restype = C.c_int - SetFormat.argtypes = (GrabberHandlePtr, C.c_int) - GetFormat = __tisgrabber.IC_GetFormat - GetFormat.restype = C.c_int - GetFormat.argtypes = (GrabberHandlePtr,) - - # Start the live video. - # @param hGrabber The handle to the grabber object. - # @param iShow The parameter indicates: @li 1 : Show the video @li 0 : Do not show the video, but deliver frames. (For callbacks etc.) - # @retval IC_SUCCESS on success - # @retval IC_ERROR if something went wrong. - # @sa IC_StopLive - - StartLive = __tisgrabber.IC_StartLive - StartLive.restype = C.c_int - StartLive.argtypes = (GrabberHandlePtr, C.c_int) - - StopLive = __tisgrabber.IC_StopLive - StopLive.restype = C.c_int - StopLive.argtypes = (GrabberHandlePtr,) - - SetHWND = __tisgrabber.IC_SetHWnd - SetHWND.restype = C.c_int - SetHWND.argtypes = (GrabberHandlePtr, C.c_int) - - # Snaps an image. The video capture device must be set to live mode and a - # sink type has to be set before this call. The format of the snapped images depend on - # the selected sink type. - # - # @param hGrabber The handle to the grabber object. - # @param iTimeOutMillisek The Timeout time is passed in milli seconds. A value of -1 indicates, that - # no time out is set. - # - # - # @retval IC_SUCCESS if an image has been snapped - # @retval IC_ERROR if something went wrong. - # @retval IC_NOT_IN_LIVEMODE if the live video has not been started. - # - # @sa IC_StartLive - # @sa IC_SetFormat - - SnapImage = __tisgrabber.IC_SnapImage - SnapImage.restype = C.c_int - SnapImage.argtypes = (GrabberHandlePtr, C.c_int) - - # Retrieve the properties of the current video format and sink type - # @param hGrabber The handle to the grabber object. - # @param *lWidth This recieves the width of the image buffer. - # @param *lHeight This recieves the height of the image buffer. - # @param *iBitsPerPixel This recieves the count of bits per pixel. - # @param *format This recieves the current color format. - # @retval IC_SUCCESS on success - # @retval IC_ERROR if something went wrong. - - GetImageDescription = __tisgrabber.IC_GetImageDescription - GetImageDescription.restype = C.c_int - GetImageDescription.argtypes = ( - GrabberHandlePtr, - C.POINTER(C.c_long), - C.POINTER(C.c_long), - C.POINTER(C.c_int), - C.POINTER(C.c_int), - ) - - GetImagePtr = __tisgrabber.IC_GetImagePtr - GetImagePtr.restype = C.c_void_p - GetImagePtr.argtypes = (GrabberHandlePtr,) - - # ############################################################################ - ShowDeviceSelectionDialog = __tisgrabber.IC_ShowDeviceSelectionDialog - ShowDeviceSelectionDialog.restype = GrabberHandlePtr - ShowDeviceSelectionDialog.argtypes = (GrabberHandlePtr,) - - # ############################################################################ - - ShowPropertyDialog = __tisgrabber.IC_ShowPropertyDialog - ShowPropertyDialog.restype = GrabberHandlePtr - ShowPropertyDialog.argtypes = (GrabberHandlePtr,) - - # ############################################################################ - IsDevValid = __tisgrabber.IC_IsDevValid - IsDevValid.restype = C.c_int - IsDevValid.argtypes = (GrabberHandlePtr,) - - # ############################################################################ - - LoadDeviceStateFromFile = __tisgrabber.IC_LoadDeviceStateFromFile - LoadDeviceStateFromFile.restype = GrabberHandlePtr - LoadDeviceStateFromFile.argtypes = (GrabberHandlePtr, C.c_char_p) - - # ############################################################################ - SaveDeviceStateToFile = __tisgrabber.IC_SaveDeviceStateToFile - SaveDeviceStateToFile.restype = C.c_int - SaveDeviceStateToFile.argtypes = (GrabberHandlePtr, C.c_char_p) - - GetCameraProperty = __tisgrabber.IC_GetCameraProperty - GetCameraProperty.restype = C.c_int - GetCameraProperty.argtypes = (GrabberHandlePtr, C.c_int, C.POINTER(C.c_long)) - - SetCameraProperty = __tisgrabber.IC_SetCameraProperty - SetCameraProperty.restype = C.c_int - SetCameraProperty.argtypes = (GrabberHandlePtr, C.c_int, C.c_long) - - SetPropertyValue = __tisgrabber.IC_SetPropertyValue - SetPropertyValue.restype = C.c_int - SetPropertyValue.argtypes = (GrabberHandlePtr, C.c_char_p, C.c_char_p, C.c_int) - - GetPropertyValue = __tisgrabber.IC_GetPropertyValue - GetPropertyValue.restype = C.c_int - GetPropertyValue.argtypes = ( - GrabberHandlePtr, - C.c_char_p, - C.c_char_p, - C.POINTER(C.c_long), - ) - - # ############################################################################ - SetPropertySwitch = __tisgrabber.IC_SetPropertySwitch - SetPropertySwitch.restype = C.c_int - SetPropertySwitch.argtypes = (GrabberHandlePtr, C.c_char_p, C.c_char_p, C.c_int) - - GetPropertySwitch = __tisgrabber.IC_GetPropertySwitch - GetPropertySwitch.restype = C.c_int - GetPropertySwitch.argtypes = ( - GrabberHandlePtr, - C.c_char_p, - C.c_char_p, - C.POINTER(C.c_long), - ) - # ############################################################################ - - IsPropertyAvailable = __tisgrabber.IC_IsPropertyAvailable - IsPropertyAvailable.restype = C.c_int - IsPropertyAvailable.argtypes = (GrabberHandlePtr, C.c_char_p, C.c_char_p) - - PropertyOnePush = __tisgrabber.IC_PropertyOnePush - PropertyOnePush.restype = C.c_int - PropertyOnePush.argtypes = (GrabberHandlePtr, C.c_char_p, C.c_char_p) - - SetPropertyAbsoluteValue = __tisgrabber.IC_SetPropertyAbsoluteValue - SetPropertyAbsoluteValue.restype = C.c_int - SetPropertyAbsoluteValue.argtypes = ( - GrabberHandlePtr, - C.c_char_p, - C.c_char_p, - C.c_float, - ) - - GetPropertyAbsoluteValue = __tisgrabber.IC_GetPropertyAbsoluteValue - GetPropertyAbsoluteValue.restype = C.c_int - GetPropertyAbsoluteValue.argtypes = ( - GrabberHandlePtr, - C.c_char_p, - C.c_char_p, - C.POINTER(C.c_float), - ) - - # definition of the frameready callback - FRAMEREADYCALLBACK = C.CFUNCTYPE( - C.c_void_p, C.c_int, C.POINTER(C.c_ubyte), C.c_ulong, C.py_object - ) - - # set callback function - SetFrameReadyCallback = __tisgrabber.IC_SetFrameReadyCallback - SetFrameReadyCallback.restype = C.c_int - SetFrameReadyCallback.argtypes = [GrabberHandlePtr, FRAMEREADYCALLBACK, C.py_object] - - SetContinuousMode = __tisgrabber.IC_SetContinuousMode - - SaveImage = __tisgrabber.IC_SaveImage - SaveImage.restype = C.c_int - SaveImage.argtypes = [C.c_void_p, C.c_char_p, C.c_int, C.c_int] - - OpenVideoCaptureDevice = __tisgrabber.IC_OpenVideoCaptureDevice - OpenVideoCaptureDevice.restype = C.c_int - OpenVideoCaptureDevice.argtypes = [C.c_void_p, C.c_char_p] - - # ############################################################################ - - ### GK Additions - adding frame filters. Pieces copied from: https://github.com/morefigs/py-ic-imaging-control - - CreateFrameFilter = __tisgrabber.IC_CreateFrameFilter - CreateFrameFilter.restype = C.c_int - CreateFrameFilter.argtypes = (C.c_char_p, C.POINTER(FrameFilterHandle)) - - AddFrameFilter = __tisgrabber.IC_AddFrameFilterToDevice - AddFrameFilter.restype = C.c_int - AddFrameFilter.argtypes = (GrabberHandlePtr, C.POINTER(FrameFilterHandle)) - - FilterGetParameter = __tisgrabber.IC_FrameFilterGetParameter - FilterGetParameter.restype = C.c_int - FilterGetParameter.argtypes = (C.POINTER(FrameFilterHandle), C.c_char_p, C.c_void_p) - - FilterSetParameter = __tisgrabber.IC_FrameFilterSetParameterInt - FilterSetParameter.restype = C.c_int - FilterSetParameter.argtypes = (C.POINTER(FrameFilterHandle), C.c_char_p, C.c_int) - - -# ############################################################################ - - -class TIS_CAM(object): - @property - def callback_registered(self): - return self._callback_registered - - def __init__(self): - - self._handle = C.POINTER(GrabberHandle) - self._handle = TIS_GrabberDLL.create_grabber() - self._callback_registered = False - self._frame = {"num": -1, "ready": False} - - def s(self, strin): - if sys.version[0] == "2": - return strin - if type(strin) == "byte": - return strin - return strin.encode("utf-8") - - def SetFrameReadyCallback(self, CallbackFunction, data): - """ Set a callback function, which is called, when a new frame arrives. - - CallbackFunction : The callback function - - data : a self defined class with user data. - """ - return TIS_GrabberDLL.SetFrameReadyCallback( - self._handle, CallbackFunction, data - ) - - def SetContinuousMode(self, Mode): - """ Determines, whether new frames are automatically copied into memory. - - :param Mode: If 0, all frames are copied automatically into memory. This is recommened, if the camera runs in trigger mode. - If 1, then snapImages must be called to get a frame into memory. - :return: None - """ - return TIS_GrabberDLL.SetContinuousMode(self._handle, Mode) - - def open(self, unique_device_name): - """ Open a device - - unique_device_name : The name and serial number of the device to be opened. The device name and serial number are separated by a space. - """ - test = TIS_GrabberDLL.open_device_by_unique_name( - self._handle, self.s(unique_device_name) - ) - - return test - - def close(self): - TIS_GrabberDLL.close_device(self._handle) - - def ShowDeviceSelectionDialog(self): - self._handle = TIS_GrabberDLL.ShowDeviceSelectionDialog(self._handle) - - def ShowPropertyDialog(self): - self._handle = TIS_GrabberDLL.ShowPropertyDialog(self._handle) - - def IsDevValid(self): - return TIS_GrabberDLL.IsDevValid(self._handle) - - def SetHWND(self, Hwnd): - return TIS_GrabberDLL.SetHWND(self._handle, Hwnd) - - def SaveDeviceStateToFile(self, FileName): - return TIS_GrabberDLL.SaveDeviceStateToFile(self._handle, self.s(FileName)) - - def LoadDeviceStateFromFile(self, FileName): - self._handle = TIS_GrabberDLL.LoadDeviceStateFromFile( - self._handle, self.s(FileName) - ) - - def SetVideoFormat(self, Format): - return TIS_GrabberDLL.set_videoformat(self._handle, self.s(Format)) - - def SetFrameRate(self, FPS): - return TIS_GrabberDLL.set_framerate(self._handle, FPS) - - def get_video_format_width(self): - return TIS_GrabberDLL.get_video_format_width(self._handle) - - def get_video_format_height(self): - return TIS_GrabberDLL.get_video_format_height(self._handle) - - def GetDevices(self): - self._Devices = [] - iDevices = TIS_GrabberDLL.get_devicecount() - for i in range(iDevices): - self._Devices.append(TIS_GrabberDLL.get_unique_name_from_list(i)) - return self._Devices - - def GetVideoFormats(self): - self._Properties = [] - iVideoFormats = TIS_GrabberDLL.GetVideoFormatCount(self._handle) - for i in range(iVideoFormats): - self._Properties.append(TIS_GrabberDLL.GetVideoFormat(self._handle, i)) - return self._Properties - - def GetInputChannels(self): - self.InputChannels = [] - InputChannelscount = TIS_GrabberDLL.GetInputChannelCount(self._handle) - for i in range(InputChannelscount): - self.InputChannels.append(TIS_GrabberDLL.GetInputChannel(self._handle, i)) - return self.InputChannels - - def GetVideoNormCount(self): - self.GetVideoNorm = [] - GetVideoNorm_Count = TIS_GrabberDLL.GetVideoNormCount(self._handle) - for i in range(GetVideoNorm_Count): - self.GetVideoNorm.append(TIS_GrabberDLL.GetVideoNorm(self._handle, i)) - return self.GetVideoNorm - - def SetFormat(self, Format): - """ SetFormat - Sets the pixel format in memory - @param Format Sinkformat enumeration - """ - TIS_GrabberDLL.SetFormat(self._handle, Format.value) - - def GetFormat(self): - val = TIS_GrabberDLL.GetFormat(self._handle) - if val == 0: - return SinkFormats.Y800 - if val == 2: - return SinkFormats.RGB32 - if val == 1: - return SinkFormats.RGB24 - if val == 3: - return SinkFormats.UYVY - if val == 4: - return SinkFormats.Y16 - return SinkFormats.RGB24 - - def StartLive(self, showlive=1): - """ - Start the live video stream. - - showlive: 1 : a live video is shown, 0 : the live video is not shown. - """ - Error = TIS_GrabberDLL.StartLive(self._handle, showlive) - return Error - - def StopLive(self): - """ - Stop the live video. - """ - Error = TIS_GrabberDLL.StopLive(self._handle) - return Error - - def SnapImage(self): - Error = TIS_GrabberDLL.SnapImage(self._handle, 2000) - return Error - - def GetImageDescription(self): - lWidth = C.c_long() - lHeight = C.c_long() - iBitsPerPixel = C.c_int() - COLORFORMAT = C.c_int() - - Error = TIS_GrabberDLL.GetImageDescription( - self._handle, lWidth, lHeight, iBitsPerPixel, COLORFORMAT - ) - return (lWidth.value, lHeight.value, iBitsPerPixel.value, COLORFORMAT.value) - - def GetImagePtr(self): - ImagePtr = TIS_GrabberDLL.GetImagePtr(self._handle) - - return ImagePtr - - def GetImage(self): - BildDaten = self.GetImageDescription()[:4] - lWidth = BildDaten[0] - lHeight = BildDaten[1] - iBitsPerPixel = BildDaten[2] // 8 - - buffer_size = lWidth * lHeight * iBitsPerPixel * C.sizeof(C.c_uint8) - img_ptr = self.GetImagePtr() - - Bild = C.cast(img_ptr, C.POINTER(C.c_ubyte * buffer_size)) - - img = np.ndarray( - buffer=Bild.contents, dtype=np.uint8, shape=(lHeight, lWidth, iBitsPerPixel) - ) - return img - - def GetImageEx(self): - """ Return a numpy array with the image data tyes - If the sink is Y16 or RGB64 (not supported yet), the dtype in the array is uint16, othereise it is uint8 - """ - BildDaten = self.GetImageDescription()[:4] - lWidth = BildDaten[0] - lHeight = BildDaten[1] - iBytesPerPixel = BildDaten[2] // 8 - - buffer_size = lWidth * lHeight * iBytesPerPixel * C.sizeof(C.c_uint8) - img_ptr = self.GetImagePtr() - - Bild = C.cast(img_ptr, C.POINTER(C.c_ubyte * buffer_size)) - - pixeltype = np.uint8 - - if BildDaten[3] == 4: # SinkFormats.Y16: - pixeltype = np.uint16 - iBytesPerPixel = 1 - - img = np.ndarray( - buffer=Bild.contents, - dtype=pixeltype, - shape=(lHeight, lWidth, iBytesPerPixel), - ) - return img - - def GetCameraProperty(self, iProperty): - lFocusPos = C.c_long() - Error = TIS_GrabberDLL.GetCameraProperty(self._handle, iProperty, lFocusPos) - return lFocusPos.value - - def SetCameraProperty(self, iProperty, iValue): - Error = TIS_GrabberDLL.SetCameraProperty(self._handle, iProperty, iValue) - return Error - - def SetPropertyValue(self, Property, Element, Value): - error = TIS_GrabberDLL.SetPropertyValue( - self._handle, self.s(Property), self.s(Element), Value - ) - return error - - def GetPropertyValue(self, Property, Element): - Value = C.c_long() - error = TIS_GrabberDLL.GetPropertyValue( - self._handle, self.s(Property), self.s(Element), Value - ) - return Value.value - - def PropertyAvailable(self, Property): - Null = None - error = TIS_GrabberDLL.IsPropertyAvailable(self._handle, self.s(Property), Null) - return error - - def SetPropertySwitch(self, Property, Element, Value): - error = TIS_GrabberDLL.SetPropertySwitch( - self._handle, self.s(Property), self.s(Element), Value - ) - return error - - def GetPropertySwitch(self, Property, Element, Value): - lValue = C.c_long() - error = TIS_GrabberDLL.GetPropertySwitch( - self._handle, self.s(Property), self.s(Element), lValue - ) - Value[0] = lValue.value - return error - - def PropertyOnePush(self, Property, Element): - error = TIS_GrabberDLL.PropertyOnePush( - self._handle, self.s(Property), self.s(Element) - ) - return error - - def SetPropertyAbsoluteValue(self, Property, Element, Value): - error = TIS_GrabberDLL.SetPropertyAbsoluteValue( - self._handle, self.s(Property), self.s(Element), Value - ) - return error - - def GetPropertyAbsoluteValue(self, Property, Element, Value): - """ Get a property value of absolute values interface, e.g. seconds or dB. - Example code: - ExposureTime=[0] - Camera.GetPropertyAbsoluteValue("Exposure","Value", ExposureTime) - print("Exposure time in secods: ", ExposureTime[0]) - - :param Property: Name of the property, e.g. Gain, Exposure - :param Element: Name of the element, e.g. "Value" - :param Value: Object, that receives the value of the property - :returns: 0 on success - """ - lValue = C.c_float() - error = TIS_GrabberDLL.GetPropertyAbsoluteValue( - self._handle, self.s(Property), self.s(Element), lValue - ) - Value[0] = lValue.value - return error - - def SaveImage(self, FileName, FileType, Quality=75): - """ Saves the last snapped image. Can by of type BMP or JPEG. - :param FileName : Name of the mage file - :param FileType : Determines file type, can be "JPEG" or "BMP" - :param Quality : If file typ is JPEG, the qualitly can be given from 1 to 100. - :return: Error code - """ - return TIS_GrabberDLL.SaveImage( - self._handle, self.s(FileName), IC.ImageFileTypes[self.s(FileType)], Quality - ) - - def openVideoCaptureDevice(self, DeviceName): - """ Open the device specified by DeviceName - :param DeviceName: Name of the device , e.g. "DFK 72AUC02" - :returns: 1 on success, 0 otherwise. - """ - return TIS_GrabberDLL.OpenVideoCaptureDevice(self._handle, self.s(DeviceName)) - - def CreateFrameFilter(self, name): - frame_filter_handle = FrameFilterHandle() - - err = TIS_GrabberDLL.CreateFrameFilter( - C.c_char_p(name), C.byref(frame_filter_handle) - ) - if err != 1: - raise Exception("ERROR CREATING FILTER") - return frame_filter_handle - - def AddFrameFilter(self, frame_filter_handle): - err = TIS_GrabberDLL.AddFrameFilter(self._handle, frame_filter_handle) - return err - - def FilterGetParameter(self, frame_filter_handle, parameter_name): - data = C.c_int() - - err = TIS_GrabberDLL.FilterGetParameter( - frame_filter_handle, parameter_name, C.byref(data) - ) - return data.value - - def FilterSetParameter(self, frame_filter_handle, parameter_name, data): - if type(data) is int: - err = TIS_GrabberDLL.FilterSetParameter( - frame_filter_handle, C.c_char_p(parameter_name), C.c_int(data) - ) - return err - else: - raise Exception("Unknown set parameter type") diff --git a/dlclivegui/camera_controller.py b/dlclivegui/camera_controller.py new file mode 100644 index 0000000..3398566 --- /dev/null +++ b/dlclivegui/camera_controller.py @@ -0,0 +1,120 @@ +"""Camera management for the DLC Live GUI.""" +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Optional + +import numpy as np +from PyQt6.QtCore import QMetaObject, QObject, QThread, Qt, pyqtSignal, pyqtSlot + +from .cameras import CameraFactory +from .cameras.base import CameraBackend +from .config import CameraSettings + + +@dataclass +class FrameData: + """Container for a captured frame.""" + + image: np.ndarray + timestamp: float + + +class CameraWorker(QObject): + """Worker object running inside a :class:`QThread`.""" + + frame_captured = pyqtSignal(object) + error_occurred = pyqtSignal(str) + finished = pyqtSignal() + + def __init__(self, settings: CameraSettings): + super().__init__() + self._settings = settings + self._running = False + self._backend: Optional[CameraBackend] = None + + @pyqtSlot() + def run(self) -> None: + self._running = True + try: + self._backend = CameraFactory.create(self._settings) + self._backend.open() + except Exception as exc: # pragma: no cover - device specific + self.error_occurred.emit(str(exc)) + self.finished.emit() + return + + while self._running: + try: + frame, timestamp = self._backend.read() + except Exception as exc: # pragma: no cover - device specific + self.error_occurred.emit(str(exc)) + break + self.frame_captured.emit(FrameData(frame, timestamp)) + + if self._backend is not None: + try: + self._backend.close() + except Exception as exc: # pragma: no cover - device specific + self.error_occurred.emit(str(exc)) + self._backend = None + self.finished.emit() + + @pyqtSlot() + def stop(self) -> None: + self._running = False + if self._backend is not None: + try: + self._backend.stop() + except Exception: + pass + + +class CameraController(QObject): + """High level controller that manages a camera worker thread.""" + + frame_ready = pyqtSignal(object) + started = pyqtSignal(CameraSettings) + stopped = pyqtSignal() + error = pyqtSignal(str) + + def __init__(self) -> None: + super().__init__() + self._thread: Optional[QThread] = None + self._worker: Optional[CameraWorker] = None + + def is_running(self) -> bool: + return self._thread is not None and self._thread.isRunning() + + def start(self, settings: CameraSettings) -> None: + if self.is_running(): + self.stop() + self._thread = QThread() + self._worker = CameraWorker(settings) + self._worker.moveToThread(self._thread) + self._thread.started.connect(self._worker.run) + self._worker.frame_captured.connect(self.frame_ready) + self._worker.error_occurred.connect(self.error) + self._worker.finished.connect(self._thread.quit) + self._worker.finished.connect(self._worker.deleteLater) + self._thread.finished.connect(self._cleanup) + self._thread.start() + self.started.emit(settings) + + def stop(self) -> None: + if not self.is_running(): + return + assert self._worker is not None + QMetaObject.invokeMethod( + self._worker, "stop", Qt.ConnectionType.QueuedConnection + ) + assert self._thread is not None + self._thread.quit() + self._thread.wait() + + @pyqtSlot() + def _cleanup(self) -> None: + self._thread = None + self._worker = None + self.stopped.emit() diff --git a/dlclivegui/camera_process.py b/dlclivegui/camera_process.py deleted file mode 100644 index 324c8e7..0000000 --- a/dlclivegui/camera_process.py +++ /dev/null @@ -1,338 +0,0 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M. Mathis Labs - -Licensed under GNU Lesser General Public License v3.0 -""" - - -import time -import multiprocess as mp -import ctypes -from dlclivegui.queue import ClearableQueue, ClearableMPQueue -import threading -import cv2 -import numpy as np -import os - - -class CameraProcessError(Exception): - """ - Exception for incorrect use of Cameras - """ - - pass - - -class CameraProcess(object): - """ Camera Process Manager class. Controls image capture and writing images to a video file in a background process. - - Parameters - ---------- - device : :class:`cameracontrol.Camera` - a camera object - ctx : :class:`multiprocess.Context` - multiprocessing context - """ - - def __init__(self, device, ctx=mp.get_context("spawn")): - """ Constructor method - """ - - self.device = device - self.ctx = ctx - - res = self.device.im_size - self.frame_shared = mp.Array(ctypes.c_uint8, res[1] * res[0] * 3) - self.frame = np.frombuffer(self.frame_shared.get_obj(), dtype="uint8").reshape( - res[1], res[0], 3 - ) - self.frame_time_shared = mp.Array(ctypes.c_double, 1) - self.frame_time = np.frombuffer(self.frame_time_shared.get_obj(), dtype="d") - - self.q_to_process = ClearableMPQueue(ctx=self.ctx) - self.q_from_process = ClearableMPQueue(ctx=self.ctx) - self.write_frame_queue = ClearableMPQueue(ctx=self.ctx) - - self.capture_process = None - self.writer_process = None - - def start_capture_process(self, timeout=60): - - cmds = self.q_to_process.read(clear=True, position="all") - if cmds is not None: - for c in cmds: - if c[1] != "capture": - self.q_to_process.write(c) - - self.capture_process = self.ctx.Process( - target=self._run_capture, - args=(self.frame_shared, self.frame_time_shared), - daemon=True, - ) - self.capture_process.start() - - stime = time.time() - while time.time() - stime < timeout: - cmd = self.q_from_process.read() - if cmd is not None: - if (cmd[0] == "capture") and (cmd[1] == "start"): - return cmd[2] - else: - self.q_to_process.write(cmd) - - return True - - def _run_capture(self, frame_shared, frame_time): - - res = self.device.im_size - self.frame = np.frombuffer(frame_shared.get_obj(), dtype="uint8").reshape( - res[1], res[0], 3 - ) - self.frame_time = np.frombuffer(frame_time.get_obj(), dtype="d") - - ret = self.device.set_capture_device() - if not ret: - raise CameraProcessError("Could not start capture device.") - self.q_from_process.write(("capture", "start", ret)) - - self._capture_loop() - - self.device.close_capture_device() - self.q_from_process.write(("capture", "end", True)) - - def _capture_loop(self): - """ Acquires frames from frame capture device in a loop - """ - - run = True - write = False - last_frame_time = time.time() - - while run: - - start_capture = time.time() - - frame, frame_time = self.device.get_image_on_time() - - write_capture = time.time() - - np.copyto(self.frame, frame) - self.frame_time[0] = frame_time - - if write: - ret = self.write_frame_queue.write((frame, frame_time)) - - end_capture = time.time() - - # print("read frame = %0.6f // write to queues = %0.6f" % (write_capture-start_capture, end_capture-write_capture)) - # print("capture rate = %d" % (int(1 / (time.time()-last_frame_time)))) - # print("\n") - - last_frame_time = time.time() - - ### read commands - cmd = self.q_to_process.read() - if cmd is not None: - if cmd[0] == "capture": - if cmd[1] == "write": - write = cmd[2] - self.q_from_process.write(cmd) - elif cmd[1] == "end": - run = False - else: - self.q_to_process.write(cmd) - - def stop_capture_process(self): - - ret = True - if self.capture_process is not None: - if self.capture_process.is_alive(): - self.q_to_process.write(("capture", "end")) - - while True: - cmd = self.q_from_process.read() - if cmd is not None: - if cmd[0] == "capture": - if cmd[1] == "end": - break - else: - self.q_from_process.write(cmd) - - self.capture_process.join(5) - if self.capture_process.is_alive(): - self.capture_process.terminate() - - return True - - def start_writer_process(self, filename, timeout=60): - - cmds = self.q_to_process.read(clear=True, position="all") - if cmds is not None: - for c in cmds: - if c[1] != "writer": - self.q_to_process.write(c) - - self.writer_process = self.ctx.Process( - target=self._run_writer, args=(filename,), daemon=True - ) - self.writer_process.start() - - stime = time.time() - while time.time() - stime < timeout: - cmd = self.q_from_process.read() - if cmd is not None: - if (cmd[0] == "writer") and (cmd[1] == "start"): - return cmd[2] - else: - self.q_to_process.write(cmd) - - return True - - def _run_writer(self, filename): - - ret = self._create_writer(filename) - self.q_from_process.write(("writer", "start", ret)) - - save = self._write_loop() - - ret = self._save_video(not save) - self.q_from_process.write(("writer", "end", ret)) - - def _create_writer(self, filename): - - self.filename = filename - self.video_file = f"{self.filename}_VIDEO.avi" - self.timestamp_file = f"{self.filename}_TS.npy" - - self.video_writer = cv2.VideoWriter( - self.video_file, - cv2.VideoWriter_fourcc(*"DIVX"), - self.device.fps, - self.device.im_size, - ) - self.write_frame_ts = [] - - return True - - def _write_loop(self): - """ read frames from write_frame_queue and write to file - """ - - run = True - new_frame = None - - while run or (new_frame is not None): - - new_frame = self.write_frame_queue.read() - if new_frame is not None: - frame, ts = new_frame - if frame.shape[2] == 1: - frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2RGB) - self.video_writer.write(frame) - self.write_frame_ts.append(ts) - - cmd = self.q_to_process.read() - if cmd is not None: - if cmd[0] == "writer": - if cmd[1] == "end": - run = False - save = cmd[2] - else: - self.q_to_process.write(cmd) - - return save - - def _save_video(self, delete=False): - - ret = False - - self.video_writer.release() - - if (not delete) and (len(self.write_frame_ts) > 0): - np.save(self.timestamp_file, self.write_frame_ts) - ret = True - else: - os.remove(self.video_file) - if os.path.isfile(self.timestamp_file): - os.remove(self.timestamp_file) - - return ret - - def stop_writer_process(self, save=True): - - ret = False - if self.writer_process is not None: - if self.writer_process.is_alive(): - self.q_to_process.write(("writer", "end", save)) - - while True: - cmd = self.q_from_process.read() - if cmd is not None: - if cmd[0] == "writer": - if cmd[1] == "end": - ret = cmd[2] - break - else: - self.q_from_process.write(cmd) - - self.writer_process.join(5) - if self.writer_process.is_alive(): - self.writer_process.terminate() - - return ret - - def start_record(self, timeout=5): - - ret = False - - if (self.capture_process is not None) and (self.writer_process is not None): - if self.capture_process.is_alive() and self.writer_process.is_alive(): - self.q_to_process.write(("capture", "write", True)) - - stime = time.time() - while time.time() - stime < timeout: - cmd = self.q_from_process.read() - if cmd is not None: - if (cmd[0] == "capture") and (cmd[1] == "write"): - ret = cmd[2] - break - else: - self.q_from_process.write(cmd) - - return ret - - def stop_record(self, timeout=5): - - ret = False - - if (self.capture_process is not None) and (self.writer_process is not None): - if (self.capture_process.is_alive()) and (self.writer_process.is_alive()): - self.q_to_process.write(("capture", "write", False)) - - stime = time.time() - while time.time() - stime < timeout: - cmd = self.q_from_process.read() - if cmd is not None: - if (cmd[0] == "capture") and (cmd[1] == "write"): - ret = not cmd[2] - break - else: - self.q_from_process.write(cmd) - - return ret - - def get_display_frame(self): - - frame = self.frame.copy() - if frame is not None: - if self.device.display_resize != 1: - frame = cv2.resize( - frame, - ( - int(frame.shape[1] * self.device.display_resize), - int(frame.shape[0] * self.device.display_resize), - ), - ) - - return frame diff --git a/dlclivegui/cameras/__init__.py b/dlclivegui/cameras/__init__.py new file mode 100644 index 0000000..cf7f488 --- /dev/null +++ b/dlclivegui/cameras/__init__.py @@ -0,0 +1,6 @@ +"""Camera backend implementations and factory helpers.""" +from __future__ import annotations + +from .factory import CameraFactory + +__all__ = ["CameraFactory"] diff --git a/dlclivegui/cameras/base.py b/dlclivegui/cameras/base.py new file mode 100644 index 0000000..6ae79dc --- /dev/null +++ b/dlclivegui/cameras/base.py @@ -0,0 +1,46 @@ +"""Abstract camera backend definitions.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Tuple + +import numpy as np + +from ..config import CameraSettings + + +class CameraBackend(ABC): + """Abstract base class for camera backends.""" + + def __init__(self, settings: CameraSettings): + self.settings = settings + + @classmethod + def name(cls) -> str: + """Return the backend identifier.""" + + return cls.__name__.lower() + + @classmethod + def is_available(cls) -> bool: + """Return whether the backend can be used on this system.""" + + return True + + def stop(self) -> None: + """Request a graceful stop.""" + + # Most backends do not require additional handling, but subclasses may + # override when they need to interrupt blocking reads. + + @abstractmethod + def open(self) -> None: + """Open the capture device.""" + + @abstractmethod + def read(self) -> Tuple[np.ndarray, float]: + """Read a frame and return the image with a timestamp.""" + + @abstractmethod + def close(self) -> None: + """Release the capture device.""" diff --git a/dlclivegui/cameras/basler_backend.py b/dlclivegui/cameras/basler_backend.py new file mode 100644 index 0000000..f9e2a15 --- /dev/null +++ b/dlclivegui/cameras/basler_backend.py @@ -0,0 +1,132 @@ +"""Basler camera backend implemented with :mod:`pypylon`.""" +from __future__ import annotations + +import time +from typing import Optional, Tuple + +import numpy as np + +from .base import CameraBackend + +try: # pragma: no cover - optional dependency + from pypylon import pylon +except Exception: # pragma: no cover - optional dependency + pylon = None # type: ignore + + +class BaslerCameraBackend(CameraBackend): + """Capture frames from Basler cameras using the Pylon SDK.""" + + def __init__(self, settings): + super().__init__(settings) + self._camera: Optional["pylon.InstantCamera"] = None + self._converter: Optional["pylon.ImageFormatConverter"] = None + + @classmethod + def is_available(cls) -> bool: + return pylon is not None + + def open(self) -> None: + if pylon is None: # pragma: no cover - optional dependency + raise RuntimeError( + "pypylon is required for the Basler backend but is not installed" + ) + devices = self._enumerate_devices() + if not devices: + raise RuntimeError("No Basler cameras detected") + device = self._select_device(devices) + self._camera = pylon.InstantCamera(pylon.TlFactory.GetInstance().CreateDevice(device)) + self._camera.Open() + + exposure = self._settings_value("exposure", self.settings.properties) + if exposure is not None: + self._camera.ExposureTime.SetValue(float(exposure)) + gain = self._settings_value("gain", self.settings.properties) + if gain is not None: + self._camera.Gain.SetValue(float(gain)) + width = int(self.settings.properties.get("width", self.settings.width)) + height = int(self.settings.properties.get("height", self.settings.height)) + self._camera.Width.SetValue(width) + self._camera.Height.SetValue(height) + fps = self._settings_value("fps", self.settings.properties, fallback=self.settings.fps) + if fps is not None: + try: + self._camera.AcquisitionFrameRateEnable.SetValue(True) + self._camera.AcquisitionFrameRate.SetValue(float(fps)) + except Exception: + # Some cameras expose different frame-rate features; ignore errors. + pass + + self._camera.StartGrabbing(pylon.GrabStrategy_LatestImageOnly) + self._converter = pylon.ImageFormatConverter() + self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed + self._converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned + + def read(self) -> Tuple[np.ndarray, float]: + if self._camera is None or self._converter is None: + raise RuntimeError("Basler camera not opened") + try: + grab_result = self._camera.RetrieveResult(100, pylon.TimeoutHandling_ThrowException) + except Exception as exc: + raise RuntimeError(f"Failed to retrieve image from Basler camera: {exc}") + if not grab_result.GrabSucceeded(): + grab_result.Release() + raise RuntimeError("Basler camera did not return an image") + image = self._converter.Convert(grab_result) + frame = image.GetArray() + grab_result.Release() + rotate = self._settings_value("rotate", self.settings.properties) + if rotate: + frame = self._rotate(frame, float(rotate)) + crop = self.settings.properties.get("crop") + if isinstance(crop, (list, tuple)) and len(crop) == 4: + left, right, top, bottom = map(int, crop) + frame = frame[top:bottom, left:right] + return frame, time.time() + + def close(self) -> None: + if self._camera is not None: + if self._camera.IsGrabbing(): + self._camera.StopGrabbing() + if self._camera.IsOpen(): + self._camera.Close() + self._camera = None + self._converter = None + + def stop(self) -> None: + if self._camera is not None and self._camera.IsGrabbing(): + try: + self._camera.StopGrabbing() + except Exception: + pass + + def _enumerate_devices(self): + factory = pylon.TlFactory.GetInstance() + return factory.EnumerateDevices() + + def _select_device(self, devices): + serial = self.settings.properties.get("serial") or self.settings.properties.get("serial_number") + if serial: + for device in devices: + if getattr(device, "GetSerialNumber", None) and device.GetSerialNumber() == serial: + return device + index = int(self.settings.index) + if index < 0 or index >= len(devices): + raise RuntimeError( + f"Camera index {index} out of range for {len(devices)} Basler device(s)" + ) + return devices[index] + + def _rotate(self, frame: np.ndarray, angle: float) -> np.ndarray: + try: + from imutils import rotate_bound # pragma: no cover - optional + except Exception as exc: # pragma: no cover - optional dependency + raise RuntimeError( + "Rotation requested for Basler camera but imutils is not installed" + ) from exc + return rotate_bound(frame, angle) + + @staticmethod + def _settings_value(key: str, source: dict, fallback: Optional[float] = None): + value = source.get(key, fallback) + return None if value is None else value diff --git a/dlclivegui/cameras/factory.py b/dlclivegui/cameras/factory.py new file mode 100644 index 0000000..e9704ef --- /dev/null +++ b/dlclivegui/cameras/factory.py @@ -0,0 +1,70 @@ +"""Backend discovery and construction utilities.""" +from __future__ import annotations + +import importlib +from typing import Dict, Iterable, Tuple, Type + +from ..config import CameraSettings +from .base import CameraBackend + + +_BACKENDS: Dict[str, Tuple[str, str]] = { + "opencv": ("dlclivegui.cameras.opencv_backend", "OpenCVCameraBackend"), + "basler": ("dlclivegui.cameras.basler_backend", "BaslerCameraBackend"), + "gentl": ("dlclivegui.cameras.gentl_backend", "GenTLCameraBackend"), +} + + +class CameraFactory: + """Create camera backend instances based on configuration.""" + + @staticmethod + def backend_names() -> Iterable[str]: + """Return the identifiers of all known backends.""" + + return tuple(_BACKENDS.keys()) + + @staticmethod + def available_backends() -> Dict[str, bool]: + """Return a mapping of backend names to availability flags.""" + + availability: Dict[str, bool] = {} + for name in _BACKENDS: + try: + backend_cls = CameraFactory._resolve_backend(name) + except RuntimeError: + availability[name] = False + continue + availability[name] = backend_cls.is_available() + return availability + + @staticmethod + def create(settings: CameraSettings) -> CameraBackend: + """Instantiate a backend for ``settings``.""" + + backend_name = (settings.backend or "opencv").lower() + try: + backend_cls = CameraFactory._resolve_backend(backend_name) + except RuntimeError as exc: # pragma: no cover - runtime configuration + raise RuntimeError(f"Unknown camera backend '{backend_name}': {exc}") from exc + if not backend_cls.is_available(): + raise RuntimeError( + f"Camera backend '{backend_name}' is not available. " + "Ensure the required drivers and Python packages are installed." + ) + return backend_cls(settings) + + @staticmethod + def _resolve_backend(name: str) -> Type[CameraBackend]: + try: + module_name, class_name = _BACKENDS[name] + except KeyError as exc: + raise RuntimeError("backend not registered") from exc + try: + module = importlib.import_module(module_name) + except ImportError as exc: + raise RuntimeError(str(exc)) from exc + backend_cls = getattr(module, class_name) + if not issubclass(backend_cls, CameraBackend): # pragma: no cover - safety + raise RuntimeError(f"Backend '{name}' does not implement CameraBackend") + return backend_cls diff --git a/dlclivegui/cameras/gentl_backend.py b/dlclivegui/cameras/gentl_backend.py new file mode 100644 index 0000000..0d81294 --- /dev/null +++ b/dlclivegui/cameras/gentl_backend.py @@ -0,0 +1,130 @@ +"""Generic GenTL backend implemented with Aravis.""" +from __future__ import annotations + +import ctypes +import time +from typing import Optional, Tuple + +import numpy as np + +from .base import CameraBackend + +try: # pragma: no cover - optional dependency + import gi + + gi.require_version("Aravis", "0.6") + from gi.repository import Aravis +except Exception: # pragma: no cover - optional dependency + gi = None # type: ignore + Aravis = None # type: ignore + + +class GenTLCameraBackend(CameraBackend): + """Capture frames from cameras that expose a GenTL interface.""" + + def __init__(self, settings): + super().__init__(settings) + self._camera = None + self._stream = None + self._payload: Optional[int] = None + + @classmethod + def is_available(cls) -> bool: + return Aravis is not None + + def open(self) -> None: + if Aravis is None: # pragma: no cover - optional dependency + raise RuntimeError( + "Aravis (python-gi bindings) are required for the GenTL backend" + ) + Aravis.update_device_list() + num_devices = Aravis.get_n_devices() + if num_devices == 0: + raise RuntimeError("No GenTL cameras detected") + device_id = self._select_device_id(num_devices) + self._camera = Aravis.Camera.new(device_id) + self._camera.set_exposure_time_auto(0) + self._camera.set_gain_auto(0) + exposure = self.settings.properties.get("exposure") + if exposure is not None: + self._set_exposure(float(exposure)) + crop = self.settings.properties.get("crop") + if isinstance(crop, (list, tuple)) and len(crop) == 4: + self._set_crop(crop) + if self.settings.fps: + try: + self._camera.set_frame_rate(float(self.settings.fps)) + except Exception: + pass + self._stream = self._camera.create_stream() + self._payload = self._camera.get_payload() + self._stream.push_buffer(Aravis.Buffer.new_allocate(self._payload)) + self._camera.start_acquisition() + + def read(self) -> Tuple[np.ndarray, float]: + if self._stream is None: + raise RuntimeError("GenTL stream not initialised") + buffer = None + while buffer is None: + buffer = self._stream.try_pop_buffer() + if buffer is None: + time.sleep(0.01) + frame = self._buffer_to_numpy(buffer) + self._stream.push_buffer(buffer) + return frame, time.time() + + def close(self) -> None: + if self._camera is not None: + try: + self._camera.stop_acquisition() + except Exception: + pass + self._camera = None + self._stream = None + self._payload = None + + def stop(self) -> None: + if self._camera is not None: + try: + self._camera.stop_acquisition() + except Exception: + pass + + def _select_device_id(self, num_devices: int) -> str: + index = int(self.settings.index) + if index < 0 or index >= num_devices: + raise RuntimeError( + f"Camera index {index} out of range for {num_devices} GenTL device(s)" + ) + return Aravis.get_device_id(index) + + def _set_exposure(self, exposure: float) -> None: + if self._camera is None: + return + exposure = max(0.0, min(exposure, 1.0)) + self._camera.set_exposure_time(exposure * 1e6) + + def _set_crop(self, crop) -> None: + if self._camera is None: + return + left, right, top, bottom = map(int, crop) + width = right - left + height = bottom - top + self._camera.set_region(left, top, width, height) + + def _buffer_to_numpy(self, buffer) -> np.ndarray: + pixel_format = buffer.get_image_pixel_format() + bits_per_pixel = (pixel_format >> 16) & 0xFF + if bits_per_pixel == 8: + int_pointer = ctypes.POINTER(ctypes.c_uint8) + else: + int_pointer = ctypes.POINTER(ctypes.c_uint16) + addr = buffer.get_data() + ptr = ctypes.cast(addr, int_pointer) + frame = np.ctypeslib.as_array(ptr, (buffer.get_image_height(), buffer.get_image_width())) + frame = frame.copy() + if frame.ndim < 3: + import cv2 + + frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2RGB) + return frame diff --git a/dlclivegui/cameras/opencv_backend.py b/dlclivegui/cameras/opencv_backend.py new file mode 100644 index 0000000..a64e3f1 --- /dev/null +++ b/dlclivegui/cameras/opencv_backend.py @@ -0,0 +1,61 @@ +"""OpenCV based camera backend.""" +from __future__ import annotations + +import time +from typing import Tuple + +import cv2 +import numpy as np + +from .base import CameraBackend + + +class OpenCVCameraBackend(CameraBackend): + """Fallback backend using :mod:`cv2.VideoCapture`.""" + + def __init__(self, settings): + super().__init__(settings) + self._capture: cv2.VideoCapture | None = None + + def open(self) -> None: + backend_flag = self._resolve_backend(self.settings.properties.get("api")) + self._capture = cv2.VideoCapture(int(self.settings.index), backend_flag) + if not self._capture.isOpened(): + raise RuntimeError( + f"Unable to open camera index {self.settings.index} with OpenCV" + ) + self._configure_capture() + + def read(self) -> Tuple[np.ndarray, float]: + if self._capture is None: + raise RuntimeError("Camera has not been opened") + success, frame = self._capture.read() + if not success: + raise RuntimeError("Failed to read frame from OpenCV camera") + return frame, time.time() + + def close(self) -> None: + if self._capture is not None: + self._capture.release() + self._capture = None + + def _configure_capture(self) -> None: + if self._capture is None: + return + self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, float(self.settings.width)) + self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, float(self.settings.height)) + self._capture.set(cv2.CAP_PROP_FPS, float(self.settings.fps)) + for prop, value in self.settings.properties.items(): + if prop == "api": + continue + try: + prop_id = int(prop) + except (TypeError, ValueError): + continue + self._capture.set(prop_id, float(value)) + + def _resolve_backend(self, backend: str | None) -> int: + if backend is None: + return cv2.CAP_ANY + key = backend.upper() + return getattr(cv2, f"CAP_{key}", cv2.CAP_ANY) diff --git a/dlclivegui/config.py b/dlclivegui/config.py new file mode 100644 index 0000000..da00c5f --- /dev/null +++ b/dlclivegui/config.py @@ -0,0 +1,112 @@ +"""Configuration helpers for the DLC Live GUI.""" +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Dict, Optional +import json + + +@dataclass +class CameraSettings: + """Configuration for a single camera device.""" + + name: str = "Camera 0" + index: int = 0 + width: int = 640 + height: int = 480 + fps: float = 30.0 + backend: str = "opencv" + properties: Dict[str, Any] = field(default_factory=dict) + + def apply_defaults(self) -> "CameraSettings": + """Ensure width, height and fps are positive numbers.""" + + self.width = int(self.width) if self.width else 640 + self.height = int(self.height) if self.height else 480 + self.fps = float(self.fps) if self.fps else 30.0 + return self + + +@dataclass +class DLCProcessorSettings: + """Configuration for DLCLive processing.""" + + model_path: str = "" + shuffle: Optional[int] = None + trainingsetindex: Optional[int] = None + processor: str = "cpu" + processor_args: Dict[str, Any] = field(default_factory=dict) + additional_options: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class RecordingSettings: + """Configuration for video recording.""" + + enabled: bool = False + directory: str = str(Path.home() / "Videos" / "deeplabcut-live") + filename: str = "session.mp4" + container: str = "mp4" + options: Dict[str, Any] = field(default_factory=dict) + + def output_path(self) -> Path: + """Return the absolute output path for recordings.""" + + directory = Path(self.directory).expanduser().resolve() + directory.mkdir(parents=True, exist_ok=True) + name = Path(self.filename) + if name.suffix: + filename = name + else: + filename = name.with_suffix(f".{self.container}") + return directory / filename + + +@dataclass +class ApplicationSettings: + """Top level application configuration.""" + + camera: CameraSettings = field(default_factory=CameraSettings) + dlc: DLCProcessorSettings = field(default_factory=DLCProcessorSettings) + recording: RecordingSettings = field(default_factory=RecordingSettings) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ApplicationSettings": + """Create an :class:`ApplicationSettings` from a dictionary.""" + + camera = CameraSettings(**data.get("camera", {})).apply_defaults() + dlc = DLCProcessorSettings(**data.get("dlc", {})) + recording = RecordingSettings(**data.get("recording", {})) + return cls(camera=camera, dlc=dlc, recording=recording) + + def to_dict(self) -> Dict[str, Any]: + """Serialise the configuration to a dictionary.""" + + return { + "camera": asdict(self.camera), + "dlc": asdict(self.dlc), + "recording": asdict(self.recording), + } + + @classmethod + def load(cls, path: Path | str) -> "ApplicationSettings": + """Load configuration from ``path``.""" + + file_path = Path(path).expanduser() + if not file_path.exists(): + raise FileNotFoundError(f"Configuration file not found: {file_path}") + with file_path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + return cls.from_dict(data) + + def save(self, path: Path | str) -> None: + """Persist configuration to ``path``.""" + + file_path = Path(path).expanduser() + file_path.parent.mkdir(parents=True, exist_ok=True) + with file_path.open("w", encoding="utf-8") as handle: + json.dump(self.to_dict(), handle, indent=2) + + +DEFAULT_CONFIG = ApplicationSettings() diff --git a/dlclivegui/dlc_processor.py b/dlclivegui/dlc_processor.py new file mode 100644 index 0000000..5c199b8 --- /dev/null +++ b/dlclivegui/dlc_processor.py @@ -0,0 +1,123 @@ +"""DLCLive integration helpers.""" +from __future__ import annotations + +import logging +import threading +from concurrent.futures import Future, ThreadPoolExecutor +from dataclasses import dataclass +from typing import Any, Optional + +import numpy as np +from PyQt6.QtCore import QObject, pyqtSignal + +from .config import DLCProcessorSettings + +LOGGER = logging.getLogger(__name__) + +try: # pragma: no cover - optional dependency + from dlclive import DLCLive # type: ignore +except Exception: # pragma: no cover - handled gracefully + DLCLive = None # type: ignore[assignment] + + +@dataclass +class PoseResult: + pose: Optional[np.ndarray] + timestamp: float + + +class DLCLiveProcessor(QObject): + """Background pose estimation using DLCLive.""" + + pose_ready = pyqtSignal(object) + error = pyqtSignal(str) + initialized = pyqtSignal(bool) + + def __init__(self) -> None: + super().__init__() + self._settings = DLCProcessorSettings() + self._executor = ThreadPoolExecutor(max_workers=1) + self._dlc: Optional[DLCLive] = None + self._init_future: Optional[Future[Any]] = None + self._pending: Optional[Future[Any]] = None + self._lock = threading.Lock() + + def configure(self, settings: DLCProcessorSettings) -> None: + self._settings = settings + + def shutdown(self) -> None: + with self._lock: + if self._pending is not None: + self._pending.cancel() + self._pending = None + if self._init_future is not None: + self._init_future.cancel() + self._init_future = None + self._executor.shutdown(wait=False, cancel_futures=True) + self._dlc = None + + def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: + with self._lock: + if self._dlc is None and self._init_future is None: + self._init_future = self._executor.submit( + self._initialise_model, frame.copy(), timestamp + ) + self._init_future.add_done_callback(self._on_initialised) + return + if self._dlc is None: + return + if self._pending is not None and not self._pending.done(): + return + self._pending = self._executor.submit( + self._run_inference, frame.copy(), timestamp + ) + self._pending.add_done_callback(self._on_pose_ready) + + def _initialise_model(self, frame: np.ndarray, timestamp: float) -> bool: + if DLCLive is None: + raise RuntimeError( + "The 'dlclive' package is required for pose estimation. Install it to enable DLCLive support." + ) + if not self._settings.model_path: + raise RuntimeError("No DLCLive model path configured.") + options = { + "model_path": self._settings.model_path, + "processor": self._settings.processor, + } + options.update(self._settings.additional_options) + if self._settings.shuffle is not None: + options["shuffle"] = self._settings.shuffle + if self._settings.trainingsetindex is not None: + options["trainingsetindex"] = self._settings.trainingsetindex + if self._settings.processor_args: + options["processor_config"] = { + "object": self._settings.processor, + **self._settings.processor_args, + } + model = DLCLive(**options) + model.init_inference(frame, frame_time=timestamp, record=False) + self._dlc = model + return True + + def _on_initialised(self, future: Future[Any]) -> None: + try: + result = future.result() + self.initialized.emit(bool(result)) + except Exception as exc: # pragma: no cover - runtime behaviour + LOGGER.exception("Failed to initialise DLCLive", exc_info=exc) + self.error.emit(str(exc)) + + def _run_inference(self, frame: np.ndarray, timestamp: float) -> PoseResult: + if self._dlc is None: + raise RuntimeError("DLCLive model not initialised") + pose = self._dlc.get_pose(frame, frame_time=timestamp, record=False) + return PoseResult(pose=pose, timestamp=timestamp) + + def _on_pose_ready(self, future: Future[Any]) -> None: + try: + result = future.result() + except Exception as exc: # pragma: no cover - runtime behaviour + LOGGER.exception("Pose inference failed", exc_info=exc) + self.error.emit(str(exc)) + return + self.pose_ready.emit(result) diff --git a/dlclivegui/dlclivegui.py b/dlclivegui/dlclivegui.py deleted file mode 100644 index 2a29d7d..0000000 --- a/dlclivegui/dlclivegui.py +++ /dev/null @@ -1,1498 +0,0 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M. Mathis Labs - -Licensed under GNU Lesser General Public License v3.0 -""" - - -from tkinter import ( - Tk, - Toplevel, - Label, - Entry, - Button, - Radiobutton, - Checkbutton, - StringVar, - IntVar, - BooleanVar, - filedialog, - messagebox, - simpledialog, -) -from tkinter.ttk import Combobox -import os -import sys -import glob -import json -import datetime -import inspect -import importlib - -from PIL import Image, ImageTk, ImageDraw -import colorcet as cc - -from dlclivegui import CameraPoseProcess -from dlclivegui import processor -from dlclivegui import camera -from dlclivegui.tkutil import SettingsWindow - - -class DLCLiveGUI(object): - """ GUI to run DLC Live experiment - """ - - def __init__(self): - """ Constructor method - """ - - ### check if documents path exists - - if not os.path.isdir(self.get_docs_path()): - os.mkdir(self.get_docs_path()) - if not os.path.isdir(os.path.dirname(self.get_config_path(""))): - os.mkdir(os.path.dirname(self.get_config_path(""))) - - ### get configuration ### - - self.cfg_list = [ - os.path.splitext(os.path.basename(f))[0] - for f in glob.glob(os.path.dirname(self.get_config_path("")) + "/*.json") - ] - - ### initialize variables - - self.cam_pose_proc = None - self.dlc_proc_params = None - - self.display_window = None - self.display_cmap = None - self.display_colors = None - self.display_radius = None - self.display_lik_thresh = None - - ### create GUI window ### - - self.createGUI() - - def get_docs_path(self): - """ Get path to documents folder - - Returns - ------- - str - path to documents folder - """ - - return os.path.normpath(os.path.expanduser("~/Documents/DeepLabCut-live-GUI")) - - def get_config_path(self, cfg_name): - """ Get path to configuration foler - - Parameters - ---------- - cfg_name : str - name of config file - - Returns - ------- - str - path to configuration file - """ - - return os.path.normpath(self.get_docs_path() + "/config/" + cfg_name + ".json") - - def get_config(self, cfg_name): - """ Read configuration - - Parameters - ---------- - cfg_name : str - name of configuration - """ - - ### read configuration file ### - - self.cfg_file = self.get_config_path(cfg_name) - if os.path.isfile(self.cfg_file): - cfg = json.load(open(self.cfg_file)) - else: - cfg = {} - - ### check config ### - - cfg["cameras"] = {} if "cameras" not in cfg else cfg["cameras"] - cfg["processor_dir"] = ( - [] if "processor_dir" not in cfg else cfg["processor_dir"] - ) - cfg["processor_args"] = ( - {} if "processor_args" not in cfg else cfg["processor_args"] - ) - cfg["dlc_options"] = {} if "dlc_options" not in cfg else cfg["dlc_options"] - cfg["dlc_display_options"] = ( - {} if "dlc_display_options" not in cfg else cfg["dlc_display_options"] - ) - cfg["subjects"] = [] if "subjects" not in cfg else cfg["subjects"] - cfg["directories"] = [] if "directories" not in cfg else cfg["directories"] - - self.cfg = cfg - - def change_config(self, event=None): - """ Change configuration, update GUI menus - - Parameters - ---------- - event : tkinter event, optional - event , by default None - """ - - if self.cfg_name.get() == "Create New Config": - new_name = simpledialog.askstring( - "", "Please enter a name (no special characters).", parent=self.window - ) - self.cfg_name.set(new_name) - self.get_config(self.cfg_name.get()) - - self.camera_entry["values"] = tuple(self.cfg["cameras"].keys()) + ( - "Add Camera", - ) - self.camera_name.set("") - self.dlc_proc_dir_entry["values"] = tuple(self.cfg["processor_dir"]) - self.dlc_proc_dir.set("") - self.dlc_proc_name_entry["values"] = tuple() - self.dlc_proc_name.set("") - self.dlc_options_entry["values"] = tuple(self.cfg["dlc_options"].keys()) + ( - "Add DLC", - ) - self.dlc_option.set("") - self.subject_entry["values"] = tuple(self.cfg["subjects"]) - self.subject.set("") - self.directory_entry["values"] = tuple(self.cfg["directories"]) - self.directory.set("") - - def remove_config(self): - """ Remove configuration - """ - - cfg_name = self.cfg_name.get() - delete_setup = messagebox.askyesnocancel( - "Delete Config Permanently?", - "Would you like to delete the configuration {} permanently (yes),\nremove the setup from the list for this session (no),\nor neither (cancel).".format( - cfg_name - ), - parent=self.window, - ) - if delete_setup is not None: - if delete_setup: - os.remove(self.get_config_path(cfg_name)) - self.cfg_list.remove(cfg_name) - self.cfg_entry["values"] = tuple(self.cfg_list) + ("Create New Setup",) - self.cfg_name.set("") - - def get_camera_names(self): - """ Get camera names from configuration as a tuple - """ - - return tuple(self.cfg["cameras"].keys()) - - def init_cam(self): - """ Initialize camera - """ - - if self.cam_pose_proc is not None: - messagebox.showerror( - "Camera Exists", - "Camera already exists! Please close current camera before initializing a new one.", - ) - return - - this_cam = self.get_current_camera() - - if not this_cam: - - messagebox.showerror( - "No Camera", - "No camera selected. Please select a camera before initializing.", - parent=self.window, - ) - - else: - - if this_cam["type"] == "Add Camera": - - self.add_camera_window() - return - - else: - - self.cam_setup_window = Toplevel(self.window) - self.cam_setup_window.title("Setting up camera...") - Label( - self.cam_setup_window, text="Setting up camera, please wait..." - ).pack() - self.cam_setup_window.update() - - cam_obj = getattr(camera, this_cam["type"]) - cam = cam_obj(**this_cam["params"]) - self.cam_pose_proc = CameraPoseProcess(cam) - ret = self.cam_pose_proc.start_capture_process() - - if cam.use_tk_display: - self.set_display_window() - - self.cam_setup_window.destroy() - - def get_current_camera(self): - """ Get dictionary of the current camera - """ - - if self.camera_name.get(): - if self.camera_name.get() == "Add Camera": - return {"type": "Add Camera"} - else: - return self.cfg["cameras"][self.camera_name.get()] - - def set_camera_param(self, key, value): - """ Set a camera parameter - """ - - self.cfg["cameras"][self.camera_name.get()]["params"][key] = value - - def add_camera_window(self): - """ Create gui to add a camera - """ - - add_cam = Tk() - cur_row = 0 - - Label(add_cam, text="Type: ").grid(sticky="w", row=cur_row, column=0) - self.cam_type = StringVar(add_cam) - - cam_types = [c[0] for c in inspect.getmembers(camera, inspect.isclass)] - cam_types = [c for c in cam_types if (c != "Camera") & ("Error" not in c)] - - type_entry = Combobox(add_cam, textvariable=self.cam_type, state="readonly") - type_entry["values"] = tuple(cam_types) - type_entry.current(0) - type_entry.grid(sticky="nsew", row=cur_row, column=1) - cur_row += 1 - - Label(add_cam, text="Name: ").grid(sticky="w", row=cur_row, column=0) - self.new_cam_name = StringVar(add_cam) - Entry(add_cam, textvariable=self.new_cam_name).grid( - sticky="nsew", row=cur_row, column=1 - ) - cur_row += 1 - - Button( - add_cam, text="Add Camera", command=lambda: self.add_cam_to_list(add_cam) - ).grid(sticky="nsew", row=cur_row, column=1) - cur_row += 1 - - Button(add_cam, text="Cancel", command=add_cam.destroy).grid( - sticky="nsew", row=cur_row, column=1 - ) - - add_cam.mainloop() - - def add_cam_to_list(self, gui): - """ Add new camera to the camera list - """ - - self.cfg["cameras"][self.new_cam_name.get()] = { - "type": self.cam_type.get(), - "params": {}, - } - self.camera_name.set(self.new_cam_name.get()) - self.camera_entry["values"] = self.get_camera_names() + ("Add Camera",) - self.save_config() - # messagebox.showinfo("Camera Added", "Camera has been added to the dropdown menu. Please edit camera settings before initializing the new camera.", parent=gui) - gui.destroy() - - def edit_cam_settings(self): - """ GUI window to edit camera settings - """ - - arg_names, arg_vals, arg_dtypes, arg_restrict = self.get_cam_args() - - settings_window = Toplevel(self.window) - settings_window.title("Camera Settings") - cur_row = 0 - combobox_width = 15 - - entry_vars = [] - for n, v in zip(arg_names, arg_vals): - - Label(settings_window, text=n + ": ").grid(row=cur_row, column=0) - - if type(v) is list: - v = [str(x) if x is not None else "" for x in v] - v = ", ".join(v) - else: - v = v if v is not None else "" - entry_vars.append(StringVar(settings_window, value=str(v))) - - if n in arg_restrict.keys(): - restrict_vals = arg_restrict[n] - if type(restrict_vals[0]) is list: - restrict_vals = [ - ", ".join([str(i) for i in rv]) for rv in restrict_vals - ] - Combobox( - settings_window, - textvariable=entry_vars[-1], - values=restrict_vals, - state="readonly", - width=combobox_width, - ).grid(sticky="nsew", row=cur_row, column=1) - else: - Entry(settings_window, textvariable=entry_vars[-1]).grid( - sticky="nsew", row=cur_row, column=1 - ) - - cur_row += 1 - - cur_row += 1 - Button( - settings_window, - text="Update", - command=lambda: self.update_camera_settings( - arg_names, entry_vars, arg_dtypes, settings_window - ), - ).grid(sticky="nsew", row=cur_row, column=1) - cur_row += 1 - Button(settings_window, text="Cancel", command=settings_window.destroy).grid( - sticky="nsew", row=cur_row, column=1 - ) - - _, row_count = settings_window.grid_size() - for r in range(row_count): - settings_window.grid_rowconfigure(r, minsize=20) - - settings_window.mainloop() - - def get_cam_args(self): - """ Get arguments for the new camera - """ - - this_cam = self.get_current_camera() - cam_obj = getattr(camera, this_cam["type"]) - arg_restrict = cam_obj.arg_restrictions() - - cam_args = inspect.getfullargspec(cam_obj) - n_args = len(cam_args[0][1:]) - n_vals = len(cam_args[3]) - arg_names = [] - arg_vals = [] - arg_dtype = [] - for i in range(n_args): - arg_names.append(cam_args[0][i + 1]) - - if arg_names[i] in this_cam["params"].keys(): - val = this_cam["params"][arg_names[i]] - else: - val = None if i < n_args - n_vals else cam_args[3][n_vals - n_args + i] - arg_vals.append(val) - - dt_val = val if i < n_args - n_vals else cam_args[3][n_vals - n_args + i] - dt = type(dt_val) if type(dt_val) is not list else type(dt_val[0]) - arg_dtype.append(dt) - - return arg_names, arg_vals, arg_dtype, arg_restrict - - def update_camera_settings(self, names, entries, dtypes, gui): - """ Update camera settings from values input in settings GUI - """ - - gui.destroy() - - for name, entry, dt in zip(names, entries, dtypes): - val = entry.get() - val = val.split(",") - val = [v.strip() for v in val] - try: - if dt is bool: - val = [True if v == "True" else False for v in val] - else: - val = [dt(v) if v else None for v in val] - except TypeError: - pass - val = val if len(val) > 1 else val[0] - self.set_camera_param(name, val) - - self.save_config() - - def set_display_window(self): - """ Create a video display window - """ - - self.display_window = Toplevel(self.window) - self.display_frame_label = Label(self.display_window) - self.display_frame_label.pack() - self.display_frame() - - def set_display_colors(self, bodyparts): - """ Set colors for keypoints - - Parameters - ---------- - bodyparts : int - the number of keypoints - """ - - all_colors = getattr(cc, self.display_cmap) - self.display_colors = all_colors[:: int(len(all_colors) / bodyparts)] - - def display_frame(self): - """ Display a frame in display window - """ - - if self.cam_pose_proc and self.display_window: - - frame = self.cam_pose_proc.get_display_frame() - - if frame is not None: - - img = Image.fromarray(frame) - if frame.ndim == 3: - b, g, r = img.split() - img = Image.merge("RGB", (r, g, b)) - - pose = ( - self.cam_pose_proc.get_display_pose() - if self.display_keypoints.get() - else None - ) - - if pose is not None: - - im_size = (frame.shape[1], frame.shape[0]) - - if not self.display_colors: - self.set_display_colors(pose.shape[0]) - - img_draw = ImageDraw.Draw(img) - - for i in range(pose.shape[0]): - if pose[i, 2] > self.display_lik_thresh: - try: - x0 = ( - pose[i, 0] - self.display_radius - if pose[i, 0] - self.display_radius > 0 - else 0 - ) - x1 = ( - pose[i, 0] + self.display_radius - if pose[i, 0] + self.display_radius < im_size[1] - else im_size[1] - ) - y0 = ( - pose[i, 1] - self.display_radius - if pose[i, 1] - self.display_radius > 0 - else 0 - ) - y1 = ( - pose[i, 1] + self.display_radius - if pose[i, 1] + self.display_radius < im_size[0] - else im_size[0] - ) - coords = [x0, y0, x1, y1] - img_draw.ellipse( - coords, - fill=self.display_colors[i], - outline=self.display_colors[i], - ) - except Exception as e: - print(e) - - imgtk = ImageTk.PhotoImage(image=img) - self.display_frame_label.imgtk = imgtk - self.display_frame_label.configure(image=imgtk) - - self.display_frame_label.after(10, self.display_frame) - - def change_display_keypoints(self): - """ Toggle display keypoints. If turning on, set display options. If turning off, destroy display window - """ - - if self.display_keypoints.get(): - - display_options = self.cfg["dlc_display_options"][ - self.dlc_option.get() - ].copy() - self.display_cmap = display_options["cmap"] - self.display_radius = display_options["radius"] - self.display_lik_thresh = display_options["lik_thresh"] - - if not self.display_window: - self.set_display_window() - - else: - - if self.cam_pose_proc is not None: - if not self.cam_pose_proc.device.use_tk_display: - if self.display_window: - self.display_window.destroy() - self.display_window = None - self.display_colors = None - - def edit_dlc_display(self): - - display_options = self.cfg["dlc_display_options"][self.dlc_option.get()] - - dlc_display_settings = { - "color map": { - "value": display_options["cmap"], - "dtype": str, - "restriction": ["bgy", "kbc", "bmw", "bmy", "kgy", "fire"], - }, - "radius": {"value": display_options["radius"], "dtype": int}, - "likelihood threshold": { - "value": display_options["lik_thresh"], - "dtype": float, - }, - } - - dlc_display_gui = SettingsWindow( - title="Edit DLC Display Settings", - settings=dlc_display_settings, - parent=self.window, - ) - - dlc_display_gui.mainloop() - display_settings = dlc_display_gui.get_values() - - display_options["cmap"] = display_settings["color map"] - display_options["radius"] = display_settings["radius"] - display_options["lik_thresh"] = display_settings["likelihood threshold"] - - self.display_cmap = display_options["cmap"] - self.display_radius = display_options["radius"] - self.display_lik_thresh = display_options["lik_thresh"] - - self.cfg["dlc_display_options"][self.dlc_option.get()] = display_options - self.save_config() - - def close_camera(self): - """ Close capture process and display - """ - - if self.cam_pose_proc: - if self.display_window is not None: - self.display_window.destroy() - self.display_window = None - ret = self.cam_pose_proc.stop_capture_process() - - self.cam_pose_proc = None - - def change_dlc_option(self, event=None): - - if self.dlc_option.get() == "Add DLC": - self.edit_dlc_settings(True) - - def edit_dlc_settings(self, new=False): - - if new: - cur_set = self.empty_dlc_settings() - else: - cur_set = self.cfg["dlc_options"][self.dlc_option.get()].copy() - cur_set["name"] = self.dlc_option.get() - cur_set["cropping"] = ( - ", ".join([str(c) for c in cur_set["cropping"]]) - if cur_set["cropping"] - else "" - ) - cur_set["dynamic"] = ", ".join([str(d) for d in cur_set["dynamic"]]) - cur_set["mode"] = ( - "Optimize Latency" if "mode" not in cur_set else cur_set["mode"] - ) - - self.dlc_settings_window = Toplevel(self.window) - self.dlc_settings_window.title("DLC Settings") - cur_row = 0 - - Label(self.dlc_settings_window, text="Name: ").grid( - sticky="w", row=cur_row, column=0 - ) - self.dlc_settings_name = StringVar( - self.dlc_settings_window, value=cur_set["name"] - ) - Entry(self.dlc_settings_window, textvariable=self.dlc_settings_name).grid( - sticky="nsew", row=cur_row, column=1 - ) - cur_row += 1 - - Label(self.dlc_settings_window, text="Model Path: ").grid( - sticky="w", row=cur_row, column=0 - ) - self.dlc_settings_model_path = StringVar( - self.dlc_settings_window, value=cur_set["model_path"] - ) - Entry(self.dlc_settings_window, textvariable=self.dlc_settings_model_path).grid( - sticky="nsew", row=cur_row, column=1 - ) - Button( - self.dlc_settings_window, text="Browse", command=self.browse_dlc_path - ).grid(sticky="nsew", row=cur_row, column=2) - cur_row += 1 - - Label(self.dlc_settings_window, text="Model Type: ").grid( - sticky="w", row=cur_row, column=0 - ) - self.dlc_settings_model_type = StringVar( - self.dlc_settings_window, value=cur_set["model_type"] - ) - Combobox( - self.dlc_settings_window, - textvariable=self.dlc_settings_model_type, - value=["base", "tensorrt", "tflite"], - state="readonly", - ).grid(sticky="nsew", row=cur_row, column=1) - cur_row += 1 - - Label(self.dlc_settings_window, text="Precision: ").grid( - sticky="w", row=cur_row, column=0 - ) - self.dlc_settings_precision = StringVar( - self.dlc_settings_window, value=cur_set["precision"] - ) - Combobox( - self.dlc_settings_window, - textvariable=self.dlc_settings_precision, - value=["FP32", "FP16", "INT8"], - state="readonly", - ).grid(sticky="nsew", row=cur_row, column=1) - cur_row += 1 - - Label(self.dlc_settings_window, text="Cropping: ").grid( - sticky="w", row=cur_row, column=0 - ) - self.dlc_settings_cropping = StringVar( - self.dlc_settings_window, value=cur_set["cropping"] - ) - Entry(self.dlc_settings_window, textvariable=self.dlc_settings_cropping).grid( - sticky="nsew", row=cur_row, column=1 - ) - cur_row += 1 - - Label(self.dlc_settings_window, text="Dynamic: ").grid( - sticky="w", row=cur_row, column=0 - ) - self.dlc_settings_dynamic = StringVar( - self.dlc_settings_window, value=cur_set["dynamic"] - ) - Entry(self.dlc_settings_window, textvariable=self.dlc_settings_dynamic).grid( - sticky="nsew", row=cur_row, column=1 - ) - cur_row += 1 - - Label(self.dlc_settings_window, text="Resize: ").grid( - sticky="w", row=cur_row, column=0 - ) - self.dlc_settings_resize = StringVar( - self.dlc_settings_window, value=cur_set["resize"] - ) - Entry(self.dlc_settings_window, textvariable=self.dlc_settings_resize).grid( - sticky="nsew", row=cur_row, column=1 - ) - cur_row += 1 - - Label(self.dlc_settings_window, text="Mode: ").grid( - sticky="w", row=cur_row, column=0 - ) - self.dlc_settings_mode = StringVar( - self.dlc_settings_window, value=cur_set["mode"] - ) - Combobox( - self.dlc_settings_window, - textvariable=self.dlc_settings_mode, - state="readonly", - values=["Optimize Latency", "Optimize Rate"], - ).grid(sticky="nsew", row=cur_row, column=1) - cur_row += 1 - - Button( - self.dlc_settings_window, text="Update", command=self.update_dlc_settings - ).grid(sticky="nsew", row=cur_row, column=1) - Button( - self.dlc_settings_window, - text="Cancel", - command=self.dlc_settings_window.destroy, - ).grid(sticky="nsew", row=cur_row, column=2) - - def empty_dlc_settings(self): - - return { - "name": "", - "model_path": "", - "model_type": "base", - "precision": "FP32", - "cropping": "", - "dynamic": "False, 0.5, 10", - "resize": "1.0", - "mode": "Optimize Latency", - } - - def browse_dlc_path(self): - """ Open file browser to select DLC exported model directory - """ - - new_dlc_path = filedialog.askdirectory(parent=self.dlc_settings_window) - if new_dlc_path: - self.dlc_settings_model_path.set(new_dlc_path) - - def update_dlc_settings(self): - """ Update DLC settings for the current dlc option from DLC Settings GUI - """ - - precision = ( - self.dlc_settings_precision.get() - if self.dlc_settings_precision.get() - else "FP32" - ) - - crop_warn = False - dlc_crop = self.dlc_settings_cropping.get() - if dlc_crop: - try: - dlc_crop = dlc_crop.split(",") - assert len(dlc_crop) == 4 - dlc_crop = [int(c) for c in dlc_crop] - except Exception: - crop_warn = True - dlc_crop = None - else: - dlc_crop = None - - try: - dlc_dynamic = self.dlc_settings_dynamic.get().replace(" ", "") - dlc_dynamic = dlc_dynamic.split(",") - dlc_dynamic[0] = True if dlc_dynamic[0] == "True" else False - dlc_dynamic[1] = float(dlc_dynamic[1]) - dlc_dynamic[2] = int(dlc_dynamic[2]) - dlc_dynamic = tuple(dlc_dynamic) - dyn_warn = False - except Exception: - dyn_warn = True - dlc_dynamic = (False, 0.5, 10) - - dlc_resize = ( - float(self.dlc_settings_resize.get()) - if self.dlc_settings_resize.get() - else None - ) - dlc_mode = self.dlc_settings_mode.get() - - warn_msg = "" - if crop_warn: - warn_msg += "DLC Cropping was not set properly. Using default cropping parameters...\n" - if dyn_warn: - warn_msg += "DLC Dynamic Cropping was not set properly. Using default dynamic cropping parameters..." - if warn_msg: - messagebox.showerror( - "DLC Settings Error", warn_msg, parent=self.dlc_settings_window - ) - - self.cfg["dlc_options"][self.dlc_settings_name.get()] = { - "model_path": self.dlc_settings_model_path.get(), - "model_type": self.dlc_settings_model_type.get(), - "precision": precision, - "cropping": dlc_crop, - "dynamic": dlc_dynamic, - "resize": dlc_resize, - "mode": dlc_mode, - } - - if self.dlc_settings_name.get() not in self.cfg["dlc_display_options"]: - self.cfg["dlc_display_options"][self.dlc_settings_name.get()] = { - "cmap": "bgy", - "radius": 3, - "lik_thresh": 0.5, - } - - self.save_config() - self.dlc_options_entry["values"] = tuple(self.cfg["dlc_options"].keys()) + ( - "Add DLC", - ) - self.dlc_option.set(self.dlc_settings_name.get()) - self.dlc_settings_window.destroy() - - def remove_dlc_option(self): - """ Delete DLC Option from config - """ - - del self.cfg["dlc_options"][self.dlc_option.get()] - del self.cfg["dlc_display_options"][self.dlc_option.get()] - self.dlc_options_entry["values"] = tuple(self.cfg["dlc_options"].keys()) + ( - "Add DLC", - ) - self.dlc_option.set("") - self.save_config() - - def init_dlc(self): - """ Initialize DLC Live object - """ - - self.stop_pose() - - self.dlc_setup_window = Toplevel(self.window) - self.dlc_setup_window.title("Setting up DLC...") - Label(self.dlc_setup_window, text="Setting up DLC, please wait...").pack() - self.dlc_setup_window.after(10, self.start_pose) - self.dlc_setup_window.mainloop() - - def start_pose(self): - - dlc_params = self.cfg["dlc_options"][self.dlc_option.get()].copy() - dlc_params["processor"] = self.dlc_proc_params - ret = self.cam_pose_proc.start_pose_process(dlc_params) - self.dlc_setup_window.destroy() - - def stop_pose(self): - """ Stop pose process - """ - - if self.cam_pose_proc: - ret = self.cam_pose_proc.stop_pose_process() - - def add_subject(self): - new_sub = self.subject.get() - if new_sub: - if new_sub not in self.cfg["subjects"]: - self.cfg["subjects"].append(new_sub) - self.subject_entry["values"] = tuple(self.cfg["subjects"]) - self.save_config() - - def remove_subject(self): - - self.cfg["subjects"].remove(self.subject.get()) - self.subject_entry["values"] = self.cfg["subjects"] - self.save_config() - self.subject.set("") - - def browse_directory(self): - - new_dir = filedialog.askdirectory(parent=self.window) - if new_dir: - self.directory.set(new_dir) - ask_add_dir = Tk() - Label( - ask_add_dir, - text="Would you like to add this directory to dropdown list?", - ).pack() - Button( - ask_add_dir, text="Yes", command=lambda: self.add_directory(ask_add_dir) - ).pack() - Button(ask_add_dir, text="No", command=ask_add_dir.destroy).pack() - - def add_directory(self, window): - - window.destroy() - if self.directory.get() not in self.cfg["directories"]: - self.cfg["directories"].append(self.directory.get()) - self.directory_entry["values"] = self.cfg["directories"] - self.save_config() - - def save_config(self, notify=False): - - json.dump(self.cfg, open(self.cfg_file, "w")) - if notify: - messagebox.showinfo( - title="Config file saved", - message="Configuration file has been saved...", - parent=self.window, - ) - - def remove_cam_cfg(self): - - if self.camera_name.get() != "Add Camera": - delete = messagebox.askyesno( - title="Delete Camera?", - message="Are you sure you want to delete '%s'?" - % self.camera_name.get(), - parent=self.window, - ) - if delete: - del self.cfg["cameras"][self.camera_name.get()] - self.camera_entry["values"] = self.get_camera_names() + ("Add Camera",) - self.camera_name.set("") - self.save_config() - - def browse_dlc_processor(self): - - new_dir = filedialog.askdirectory(parent=self.window) - if new_dir: - self.dlc_proc_dir.set(new_dir) - self.update_dlc_proc_list() - - if new_dir not in self.cfg["processor_dir"]: - if messagebox.askyesno( - "Add to dropdown", - "Would you like to add this directory to dropdown list?", - ): - self.cfg["processor_dir"].append(new_dir) - self.dlc_proc_dir_entry["values"] = tuple(self.cfg["processor_dir"]) - self.save_config() - - def rem_dlc_proc_dir(self): - - if self.dlc_proc_dir.get() in self.cfg["processor_dir"]: - self.cfg["processor_dir"].remove(self.dlc_proc_dir.get()) - self.save_config() - self.dlc_proc_dir_entry["values"] = tuple(self.cfg["processor_dir"]) - self.dlc_proc_dir.set("") - - def update_dlc_proc_list(self, event=None): - - ### if dlc proc module already initialized, delete module and remove from path ### - - self.processor_list = [] - - if self.dlc_proc_dir.get(): - - if hasattr(self, "dlc_proc_module"): - sys.path.remove(sys.path[0]) - - new_path = os.path.normpath(os.path.dirname(self.dlc_proc_dir.get())) - if new_path not in sys.path: - sys.path.insert(0, new_path) - - new_mod = os.path.basename(self.dlc_proc_dir.get()) - if new_mod in sys.modules: - del sys.modules[new_mod] - - ### load new module ### - - processor_spec = importlib.util.find_spec( - os.path.basename(self.dlc_proc_dir.get()) - ) - try: - self.dlc_proc_module = importlib.util.module_from_spec(processor_spec) - processor_spec.loader.exec_module(self.dlc_proc_module) - # self.processor_list = inspect.getmembers(self.dlc_proc_module, inspect.isclass) - self.processor_list = [ - proc for proc in dir(self.dlc_proc_module) if "__" not in proc - ] - except AttributeError: - if hasattr(self, "window"): - messagebox.showerror( - "Failed to load processors!", - "Failed to load processors from directory = " - + self.dlc_proc_dir.get() - + ".\nPlease select a different directory.", - parent=self.window, - ) - - self.dlc_proc_name_entry["values"] = tuple(self.processor_list) - - def set_proc(self): - - # proc_param_dict = {} - # for i in range(1, len(self.proc_param_names)): - # proc_param_dict[self.proc_param_names[i]] = self.proc_param_default_types[i](self.proc_param_values[i-1].get()) - - # if self.dlc_proc_dir.get() not in self.cfg['processor_args']: - # self.cfg['processor_args'][self.dlc_proc_dir.get()] = {} - # self.cfg['processor_args'][self.dlc_proc_dir.get()][self.dlc_proc_name.get()] = proc_param_dict - # self.save_config() - - # self.dlc_proc = self.proc_object(**proc_param_dict) - proc_object = getattr(self.dlc_proc_module, self.dlc_proc_name.get()) - self.dlc_proc_params = {"object": proc_object} - self.dlc_proc_params.update( - self.cfg["processor_args"][self.dlc_proc_dir.get()][ - self.dlc_proc_name.get() - ] - ) - - def clear_proc(self): - - self.dlc_proc_params = None - - def edit_proc(self): - - ### get default args: load module and read arguments ### - - self.proc_object = getattr(self.dlc_proc_module, self.dlc_proc_name.get()) - def_args = inspect.getargspec(self.proc_object) - self.proc_param_names = def_args[0] - self.proc_param_default_values = def_args[3] - self.proc_param_default_types = [ - type(v) if type(v) is not list else [type(v[0])] for v in def_args[3] - ] - for i in range(len(def_args[0]) - len(def_args[3])): - self.proc_param_default_values = ("",) + self.proc_param_default_values - self.proc_param_default_types = [str] + self.proc_param_default_types - - ### check for existing settings in config ### - - old_args = {} - if self.dlc_proc_dir.get() in self.cfg["processor_args"]: - if ( - self.dlc_proc_name.get() - in self.cfg["processor_args"][self.dlc_proc_dir.get()] - ): - old_args = self.cfg["processor_args"][self.dlc_proc_dir.get()][ - self.dlc_proc_name.get() - ].copy() - else: - self.cfg["processor_args"][self.dlc_proc_dir.get()] = {} - - ### get dictionary of arguments ### - - proc_args_dict = {} - for i in range(1, len(self.proc_param_names)): - - if self.proc_param_names[i] in old_args: - this_value = old_args[self.proc_param_names[i]] - else: - this_value = self.proc_param_default_values[i] - - proc_args_dict[self.proc_param_names[i]] = { - "value": this_value, - "dtype": self.proc_param_default_types[i], - } - - proc_args_gui = SettingsWindow( - title="DLC Processor Settings", settings=proc_args_dict, parent=self.window - ) - proc_args_gui.mainloop() - - self.cfg["processor_args"][self.dlc_proc_dir.get()][ - self.dlc_proc_name.get() - ] = proc_args_gui.get_values() - self.save_config() - - def init_session(self): - - ### check if video is currently open ### - - if self.record_on.get() > -1: - messagebox.showerror( - "Session Open", - "Session is currently open! Please release the current video (click 'Save Video' of 'Delete Video', even if no frames have been recorded) before setting up a new one.", - parent=self.window, - ) - return - - ### check if camera is already set up ### - - if not self.cam_pose_proc: - messagebox.showerror( - "No Camera", - "No camera is found! Please initialize a camera before setting up the video.", - parent=self.window, - ) - return - - ### set up session window - - self.session_setup_window = Toplevel(self.window) - self.session_setup_window.title("Setting up session...") - Label( - self.session_setup_window, text="Setting up session, please wait..." - ).pack() - self.session_setup_window.after(10, self.start_writer) - self.session_setup_window.mainloop() - - def start_writer(self): - - ### set up file name (get date and create directory) - - dt = datetime.datetime.now() - date = f"{dt.year:04d}-{dt.month:02d}-{dt.day:02d}" - self.out_dir = self.directory.get() - if not os.path.isdir(os.path.normpath(self.out_dir)): - os.makedirs(os.path.normpath(self.out_dir)) - - ### create output file names - - self.base_name = os.path.normpath( - f"{self.out_dir}/{self.camera_name.get().replace(' ', '')}_{self.subject.get()}_{date}_{self.attempt.get()}" - ) - # self.vid_file = os.path.normpath(self.out_dir + '/VIDEO_' + self.base_name + '.avi') - # self.ts_file = os.path.normpath(self.out_dir + '/TIMESTAMPS_' + self.base_name + '.pickle') - # self.dlc_file = os.path.normpath(self.out_dir + '/DLC_' + self.base_name + '.h5') - # self.proc_file = os.path.normpath(self.out_dir + '/PROC_' + self.base_name + '.pickle') - - ### check if files already exist - - fs = glob.glob(f"{self.base_name}*") - if len(fs) > 0: - overwrite = messagebox.askyesno( - "Files Exist", - "Files already exist with attempt number = {}. Would you like to overwrite the file?".format( - self.attempt.get() - ), - parent=self.session_setup_window, - ) - if not overwrite: - return - - ### start writer - - ret = self.cam_pose_proc.start_writer_process(self.base_name) - - self.session_setup_window.destroy() - - ### set GUI to Ready - - self.record_on.set(0) - - def start_record(self): - """ Issues command to start recording frames and poses - """ - - ret = False - if self.cam_pose_proc is not None: - ret = self.cam_pose_proc.start_record() - - if not ret: - messagebox.showerror( - "Recording Not Ready", - "Recording has not been set up. Please make sure a camera and session have been initialized.", - parent=self.window, - ) - self.record_on.set(-1) - - def stop_record(self): - """ Issues command to stop recording frames and poses - """ - - if self.cam_pose_proc is not None: - ret = self.cam_pose_proc.stop_record() - self.record_on.set(0) - - def save_vid(self, delete=False): - """ Saves video, timestamp, and DLC files - - Parameters - ---------- - delete : bool, optional - flag to delete created files, by default False - """ - - ### perform checks ### - - if self.cam_pose_proc is None: - messagebox.showwarning( - "No Camera", - "Camera has not yet been initialized, no video recorded.", - parent=self.window, - ) - return - - elif self.record_on.get() == -1: - messagebox.showwarning( - "No Video File", - "Video was not set up, no video recorded.", - parent=self.window, - ) - return - - elif self.record_on.get() == 1: - messagebox.showwarning( - "Active Recording", - "You are currently recording a video. Please stop the video before saving.", - parent=self.window, - ) - return - - elif delete: - delete = messagebox.askokcancel( - "Delete Video?", "Do you wish to delete the video?", parent=self.window - ) - - ### save or delete video ### - - if delete: - ret = self.cam_pose_proc.stop_writer_process(save=False) - messagebox.showinfo( - "Video Deleted", - "Video and timestamp files have been deleted.", - parent=self.window, - ) - else: - ret = self.cam_pose_proc.stop_writer_process(save=True) - ret_pose = self.cam_pose_proc.save_pose(self.base_name) - if ret: - if ret_pose: - messagebox.showinfo( - "Files Saved", - "Video, timestamp, and DLC Files have been saved.", - ) - else: - messagebox.showinfo( - "Files Saved", "Video and timestamp files have been saved." - ) - else: - messagebox.showwarning( - "No Frames Recorded", - "No frames were recorded, video was deleted", - parent=self.window, - ) - - self.record_on.set(-1) - - def closeGUI(self): - - if self.cam_pose_proc: - ret = self.cam_pose_proc.stop_writer_process() - ret = self.cam_pose_proc.stop_pose_process() - ret = self.cam_pose_proc.stop_capture_process() - - self.window.destroy() - - def createGUI(self): - - ### initialize window ### - - self.window = Tk() - self.window.title("DeepLabCut Live") - cur_row = 0 - combobox_width = 15 - - ### select cfg file - if len(self.cfg_list) > 0: - initial_cfg = self.cfg_list[0] - else: - initial_cfg = "" - - Label(self.window, text="Config: ").grid(sticky="w", row=cur_row, column=0) - self.cfg_name = StringVar(self.window, value=initial_cfg) - self.cfg_entry = Combobox( - self.window, textvariable=self.cfg_name, width=combobox_width - ) - self.cfg_entry["values"] = tuple(self.cfg_list) + ("Create New Config",) - self.cfg_entry.bind("<>", self.change_config) - self.cfg_entry.grid(sticky="nsew", row=cur_row, column=1) - Button(self.window, text="Remove Config", command=self.remove_config).grid( - sticky="nsew", row=cur_row, column=2 - ) - - self.get_config(initial_cfg) - - cur_row += 2 - - ### select camera ### - - # camera entry - Label(self.window, text="Camera: ").grid(sticky="w", row=cur_row, column=0) - self.camera_name = StringVar(self.window) - self.camera_entry = Combobox(self.window, textvariable=self.camera_name) - cam_names = self.get_camera_names() - self.camera_entry["values"] = cam_names + ("Add Camera",) - if cam_names: - self.camera_entry.current(0) - self.camera_entry.grid(sticky="nsew", row=cur_row, column=1) - Button(self.window, text="Init Cam", command=self.init_cam).grid( - sticky="nsew", row=cur_row, column=2 - ) - cur_row += 1 - - Button( - self.window, text="Edit Camera Settings", command=self.edit_cam_settings - ).grid(sticky="nsew", row=cur_row, column=1) - Button(self.window, text="Close Camera", command=self.close_camera).grid( - sticky="nsew", row=cur_row, column=2 - ) - cur_row += 1 - Button(self.window, text="Remove Camera", command=self.remove_cam_cfg).grid( - sticky="nsew", row=cur_row, column=2 - ) - - cur_row += 2 - - ### set up proc ### - - Label(self.window, text="Processor Dir: ").grid( - sticky="w", row=cur_row, column=0 - ) - self.dlc_proc_dir = StringVar(self.window) - self.dlc_proc_dir_entry = Combobox(self.window, textvariable=self.dlc_proc_dir) - self.dlc_proc_dir_entry["values"] = tuple(self.cfg["processor_dir"]) - if len(self.cfg["processor_dir"]) > 0: - self.dlc_proc_dir_entry.current(0) - self.dlc_proc_dir_entry.bind("<>", self.update_dlc_proc_list) - self.dlc_proc_dir_entry.grid(sticky="nsew", row=cur_row, column=1) - Button(self.window, text="Browse", command=self.browse_dlc_processor).grid( - sticky="nsew", row=cur_row, column=2 - ) - Button(self.window, text="Remove Proc Dir", command=self.rem_dlc_proc_dir).grid( - sticky="nsew", row=cur_row + 1, column=2 - ) - cur_row += 2 - - Label(self.window, text="Processor: ").grid(sticky="w", row=cur_row, column=0) - self.dlc_proc_name = StringVar(self.window) - self.dlc_proc_name_entry = Combobox( - self.window, textvariable=self.dlc_proc_name - ) - self.update_dlc_proc_list() - # self.dlc_proc_name_entry['values'] = tuple(self.processor_list) # tuple([c[0] for c in inspect.getmembers(processor, inspect.isclass)]) - if len(self.processor_list) > 0: - self.dlc_proc_name_entry.current(0) - self.dlc_proc_name_entry.grid(sticky="nsew", row=cur_row, column=1) - Button(self.window, text="Set Proc", command=self.set_proc).grid( - sticky="nsew", row=cur_row, column=2 - ) - Button(self.window, text="Edit Proc Settings", command=self.edit_proc).grid( - sticky="nsew", row=cur_row + 1, column=1 - ) - Button(self.window, text="Clear Proc", command=self.clear_proc).grid( - sticky="nsew", row=cur_row + 1, column=2 - ) - - cur_row += 3 - - ### set up dlc live ### - - Label(self.window, text="DeepLabCut: ").grid(sticky="w", row=cur_row, column=0) - self.dlc_option = StringVar(self.window) - self.dlc_options_entry = Combobox(self.window, textvariable=self.dlc_option) - self.dlc_options_entry["values"] = tuple(self.cfg["dlc_options"].keys()) + ( - "Add DLC", - ) - self.dlc_options_entry.bind("<>", self.change_dlc_option) - self.dlc_options_entry.grid(sticky="nsew", row=cur_row, column=1) - Button(self.window, text="Init DLC", command=self.init_dlc).grid( - sticky="nsew", row=cur_row, column=2 - ) - cur_row += 1 - - Button( - self.window, text="Edit DLC Settings", command=self.edit_dlc_settings - ).grid(sticky="nsew", row=cur_row, column=1) - Button(self.window, text="Stop DLC", command=self.stop_pose).grid( - sticky="nsew", row=cur_row, column=2 - ) - cur_row += 1 - - self.display_keypoints = BooleanVar(self.window, value=False) - Checkbutton( - self.window, - text="Display DLC Keypoints", - variable=self.display_keypoints, - indicatoron=0, - command=self.change_display_keypoints, - ).grid(sticky="nsew", row=cur_row, column=1) - Button(self.window, text="Remove DLC", command=self.remove_dlc_option).grid( - sticky="nsew", row=cur_row, column=2 - ) - cur_row += 1 - - Button( - self.window, text="Edit DLC Display Settings", command=self.edit_dlc_display - ).grid(sticky="nsew", row=cur_row, column=1) - - cur_row += 2 - - ### set up session ### - - # subject - Label(self.window, text="Subject: ").grid(sticky="w", row=cur_row, column=0) - self.subject = StringVar(self.window) - self.subject_entry = Combobox(self.window, textvariable=self.subject) - self.subject_entry["values"] = self.cfg["subjects"] - self.subject_entry.grid(sticky="nsew", row=cur_row, column=1) - Button(self.window, text="Add Subject", command=self.add_subject).grid( - sticky="nsew", row=cur_row, column=2 - ) - cur_row += 1 - - # attempt - Label(self.window, text="Attempt: ").grid(sticky="w", row=cur_row, column=0) - self.attempt = StringVar(self.window) - self.attempt_entry = Combobox(self.window, textvariable=self.attempt) - self.attempt_entry["values"] = tuple(range(1, 10)) - self.attempt_entry.current(0) - self.attempt_entry.grid(sticky="nsew", row=cur_row, column=1) - Button(self.window, text="Remove Subject", command=self.remove_subject).grid( - sticky="nsew", row=cur_row, column=2 - ) - cur_row += 1 - - # out directory - Label(self.window, text="Directory: ").grid(sticky="w", row=cur_row, column=0) - self.directory = StringVar(self.window) - self.directory_entry = Combobox(self.window, textvariable=self.directory) - if self.cfg["directories"]: - self.directory_entry["values"] = self.cfg["directories"] - self.directory_entry.current(0) - self.directory_entry.grid(sticky="nsew", row=cur_row, column=1) - Button(self.window, text="Browse", command=self.browse_directory).grid( - sticky="nsew", row=cur_row, column=2 - ) - cur_row += 1 - - # set up session - Button(self.window, text="Set Up Session", command=self.init_session).grid( - sticky="nsew", row=cur_row, column=1 - ) - cur_row += 2 - - ### control recording ### - - Label(self.window, text="Record: ").grid(sticky="w", row=cur_row, column=0) - self.record_on = IntVar(value=-1) - Radiobutton( - self.window, - text="Ready", - selectcolor="blue", - indicatoron=0, - variable=self.record_on, - value=0, - state="disabled", - ).grid(stick="nsew", row=cur_row, column=1) - Radiobutton( - self.window, - text="On", - selectcolor="green", - indicatoron=0, - variable=self.record_on, - value=1, - command=self.start_record, - ).grid(sticky="nsew", row=cur_row + 1, column=1) - Radiobutton( - self.window, - text="Off", - selectcolor="red", - indicatoron=0, - variable=self.record_on, - value=-1, - command=self.stop_record, - ).grid(sticky="nsew", row=cur_row + 2, column=1) - Button(self.window, text="Save Video", command=lambda: self.save_vid()).grid( - sticky="nsew", row=cur_row + 1, column=2 - ) - Button( - self.window, text="Delete Video", command=lambda: self.save_vid(delete=True) - ).grid(sticky="nsew", row=cur_row + 2, column=2) - - cur_row += 4 - - ### close program ### - - Button(self.window, text="Close", command=self.closeGUI).grid( - sticky="nsew", row=cur_row, column=0, columnspan=2 - ) - - ### configure size of empty rows - - _, row_count = self.window.grid_size() - for r in range(row_count): - self.window.grid_rowconfigure(r, minsize=20) - - def run(self): - - self.window.mainloop() - - -def main(): - - # import multiprocess as mp - # mp.set_start_method("spawn") - - dlc_live_gui = DLCLiveGUI() - dlc_live_gui.run() diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py new file mode 100644 index 0000000..4eb3971 --- /dev/null +++ b/dlclivegui/gui.py @@ -0,0 +1,542 @@ +"""PyQt6 based GUI for DeepLabCut Live.""" +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Optional + +import cv2 +import numpy as np +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QAction, QCloseEvent, QImage, QPixmap +from PyQt6.QtWidgets import ( + QApplication, + QCheckBox, + QComboBox, + QFileDialog, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QMessageBox, + QPlainTextEdit, + QPushButton, + QSpinBox, + QDoubleSpinBox, + QStatusBar, + QVBoxLayout, + QWidget, +) + +from .camera_controller import CameraController, FrameData +from .cameras import CameraFactory +from .config import ( + ApplicationSettings, + CameraSettings, + DLCProcessorSettings, + RecordingSettings, + DEFAULT_CONFIG, +) +from .dlc_processor import DLCLiveProcessor, PoseResult +from .video_recorder import VideoRecorder + + +class MainWindow(QMainWindow): + """Main application window.""" + + def __init__(self, config: Optional[ApplicationSettings] = None): + super().__init__() + self.setWindowTitle("DeepLabCut Live GUI") + self._config = config or DEFAULT_CONFIG + self._config_path: Optional[Path] = None + self._current_frame: Optional[np.ndarray] = None + self._last_pose: Optional[PoseResult] = None + self._video_recorder: Optional[VideoRecorder] = None + + self.camera_controller = CameraController() + self.dlc_processor = DLCLiveProcessor() + + self._setup_ui() + self._connect_signals() + self._apply_config(self._config) + + # ------------------------------------------------------------------ UI + def _setup_ui(self) -> None: + central = QWidget() + layout = QVBoxLayout(central) + + self.video_label = QLabel("Camera preview not started") + self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.video_label.setMinimumSize(640, 360) + layout.addWidget(self.video_label) + + layout.addWidget(self._build_camera_group()) + layout.addWidget(self._build_dlc_group()) + layout.addWidget(self._build_recording_group()) + + button_bar = QHBoxLayout() + self.preview_button = QPushButton("Start Preview") + self.stop_preview_button = QPushButton("Stop Preview") + self.stop_preview_button.setEnabled(False) + button_bar.addWidget(self.preview_button) + button_bar.addWidget(self.stop_preview_button) + layout.addLayout(button_bar) + + self.setCentralWidget(central) + self.setStatusBar(QStatusBar()) + self._build_menus() + + def _build_menus(self) -> None: + file_menu = self.menuBar().addMenu("&File") + + load_action = QAction("Load configuration…", self) + load_action.triggered.connect(self._action_load_config) + file_menu.addAction(load_action) + + save_action = QAction("Save configuration", self) + save_action.triggered.connect(self._action_save_config) + file_menu.addAction(save_action) + + save_as_action = QAction("Save configuration as…", self) + save_as_action.triggered.connect(self._action_save_config_as) + file_menu.addAction(save_as_action) + + file_menu.addSeparator() + exit_action = QAction("Exit", self) + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + def _build_camera_group(self) -> QGroupBox: + group = QGroupBox("Camera settings") + form = QFormLayout(group) + + self.camera_index = QComboBox() + self.camera_index.setEditable(True) + self.camera_index.addItems([str(i) for i in range(5)]) + form.addRow("Camera index", self.camera_index) + + self.camera_width = QSpinBox() + self.camera_width.setRange(1, 7680) + form.addRow("Width", self.camera_width) + + self.camera_height = QSpinBox() + self.camera_height.setRange(1, 4320) + form.addRow("Height", self.camera_height) + + self.camera_fps = QDoubleSpinBox() + self.camera_fps.setRange(1.0, 240.0) + self.camera_fps.setDecimals(2) + form.addRow("Frame rate", self.camera_fps) + + self.camera_backend = QComboBox() + self.camera_backend.setEditable(True) + availability = CameraFactory.available_backends() + for backend in CameraFactory.backend_names(): + label = backend + if not availability.get(backend, True): + label = f"{backend} (unavailable)" + self.camera_backend.addItem(label, backend) + form.addRow("Backend", self.camera_backend) + + self.camera_properties_edit = QPlainTextEdit() + self.camera_properties_edit.setPlaceholderText( + '{"exposure": 15000, "gain": 0.5, "serial": "123456"}' + ) + self.camera_properties_edit.setFixedHeight(60) + form.addRow("Advanced properties", self.camera_properties_edit) + + return group + + def _build_dlc_group(self) -> QGroupBox: + group = QGroupBox("DLCLive settings") + form = QFormLayout(group) + + path_layout = QHBoxLayout() + self.model_path_edit = QLineEdit() + path_layout.addWidget(self.model_path_edit) + browse_model = QPushButton("Browse…") + browse_model.clicked.connect(self._action_browse_model) + path_layout.addWidget(browse_model) + form.addRow("Model path", path_layout) + + self.shuffle_edit = QLineEdit() + self.shuffle_edit.setPlaceholderText("Optional integer") + form.addRow("Shuffle", self.shuffle_edit) + + self.training_edit = QLineEdit() + self.training_edit.setPlaceholderText("Optional integer") + form.addRow("Training set index", self.training_edit) + + self.processor_combo = QComboBox() + self.processor_combo.setEditable(True) + self.processor_combo.addItems(["cpu", "gpu", "tensorrt"]) + form.addRow("Processor", self.processor_combo) + + self.processor_args_edit = QPlainTextEdit() + self.processor_args_edit.setPlaceholderText('{"device": 0}') + self.processor_args_edit.setFixedHeight(60) + form.addRow("Processor args", self.processor_args_edit) + + self.additional_options_edit = QPlainTextEdit() + self.additional_options_edit.setPlaceholderText('{"allow_growth": true}') + self.additional_options_edit.setFixedHeight(60) + form.addRow("Additional options", self.additional_options_edit) + + self.enable_dlc_checkbox = QCheckBox("Enable pose estimation") + self.enable_dlc_checkbox.setChecked(True) + form.addRow(self.enable_dlc_checkbox) + + return group + + def _build_recording_group(self) -> QGroupBox: + group = QGroupBox("Recording") + form = QFormLayout(group) + + self.recording_enabled_checkbox = QCheckBox("Record video while running") + form.addRow(self.recording_enabled_checkbox) + + dir_layout = QHBoxLayout() + self.output_directory_edit = QLineEdit() + dir_layout.addWidget(self.output_directory_edit) + browse_dir = QPushButton("Browse…") + browse_dir.clicked.connect(self._action_browse_directory) + dir_layout.addWidget(browse_dir) + form.addRow("Output directory", dir_layout) + + self.filename_edit = QLineEdit() + form.addRow("Filename", self.filename_edit) + + self.container_combo = QComboBox() + self.container_combo.setEditable(True) + self.container_combo.addItems(["mp4", "avi", "mov"]) + form.addRow("Container", self.container_combo) + + self.recording_options_edit = QPlainTextEdit() + self.recording_options_edit.setPlaceholderText('{"compression_mode": "mp4"}') + self.recording_options_edit.setFixedHeight(60) + form.addRow("WriteGear options", self.recording_options_edit) + + self.start_record_button = QPushButton("Start recording") + self.stop_record_button = QPushButton("Stop recording") + self.stop_record_button.setEnabled(False) + + buttons = QHBoxLayout() + buttons.addWidget(self.start_record_button) + buttons.addWidget(self.stop_record_button) + form.addRow(buttons) + + return group + + # ------------------------------------------------------------------ signals + def _connect_signals(self) -> None: + self.preview_button.clicked.connect(self._start_preview) + self.stop_preview_button.clicked.connect(self._stop_preview) + self.start_record_button.clicked.connect(self._start_recording) + self.stop_record_button.clicked.connect(self._stop_recording) + + self.camera_controller.frame_ready.connect(self._on_frame_ready) + self.camera_controller.error.connect(self._show_error) + self.camera_controller.stopped.connect(self._on_camera_stopped) + + self.dlc_processor.pose_ready.connect(self._on_pose_ready) + self.dlc_processor.error.connect(self._show_error) + self.dlc_processor.initialized.connect(self._on_dlc_initialised) + + # ------------------------------------------------------------------ config + def _apply_config(self, config: ApplicationSettings) -> None: + camera = config.camera + self.camera_index.setCurrentText(str(camera.index)) + self.camera_width.setValue(int(camera.width)) + self.camera_height.setValue(int(camera.height)) + self.camera_fps.setValue(float(camera.fps)) + backend_name = camera.backend or "opencv" + index = self.camera_backend.findData(backend_name) + if index >= 0: + self.camera_backend.setCurrentIndex(index) + else: + self.camera_backend.setEditText(backend_name) + self.camera_properties_edit.setPlainText( + json.dumps(camera.properties, indent=2) if camera.properties else "" + ) + + dlc = config.dlc + self.model_path_edit.setText(dlc.model_path) + self.shuffle_edit.setText("" if dlc.shuffle is None else str(dlc.shuffle)) + self.training_edit.setText( + "" if dlc.trainingsetindex is None else str(dlc.trainingsetindex) + ) + self.processor_combo.setCurrentText(dlc.processor or "cpu") + self.processor_args_edit.setPlainText(json.dumps(dlc.processor_args, indent=2)) + self.additional_options_edit.setPlainText( + json.dumps(dlc.additional_options, indent=2) + ) + + recording = config.recording + self.recording_enabled_checkbox.setChecked(recording.enabled) + self.output_directory_edit.setText(recording.directory) + self.filename_edit.setText(recording.filename) + self.container_combo.setCurrentText(recording.container) + self.recording_options_edit.setPlainText(json.dumps(recording.options, indent=2)) + + def _current_config(self) -> ApplicationSettings: + return ApplicationSettings( + camera=self._camera_settings_from_ui(), + dlc=self._dlc_settings_from_ui(), + recording=self._recording_settings_from_ui(), + ) + + def _camera_settings_from_ui(self) -> CameraSettings: + index_text = self.camera_index.currentText().strip() or "0" + try: + index = int(index_text) + except ValueError: + raise ValueError("Camera index must be an integer") from None + backend_data = self.camera_backend.currentData() + backend_text = ( + backend_data + if isinstance(backend_data, str) and backend_data + else self.camera_backend.currentText().strip() + ) + properties = self._parse_json(self.camera_properties_edit.toPlainText()) + return CameraSettings( + name=f"Camera {index}", + index=index, + width=self.camera_width.value(), + height=self.camera_height.value(), + fps=self.camera_fps.value(), + backend=backend_text or "opencv", + properties=properties, + ) + + def _parse_optional_int(self, value: str) -> Optional[int]: + text = value.strip() + if not text: + return None + return int(text) + + def _parse_json(self, value: str) -> dict: + text = value.strip() + if not text: + return {} + return json.loads(text) + + def _dlc_settings_from_ui(self) -> DLCProcessorSettings: + return DLCProcessorSettings( + model_path=self.model_path_edit.text().strip(), + shuffle=self._parse_optional_int(self.shuffle_edit.text()), + trainingsetindex=self._parse_optional_int(self.training_edit.text()), + processor=self.processor_combo.currentText().strip() or "cpu", + processor_args=self._parse_json(self.processor_args_edit.toPlainText()), + additional_options=self._parse_json( + self.additional_options_edit.toPlainText() + ), + ) + + def _recording_settings_from_ui(self) -> RecordingSettings: + return RecordingSettings( + enabled=self.recording_enabled_checkbox.isChecked(), + directory=self.output_directory_edit.text().strip(), + filename=self.filename_edit.text().strip() or "session.mp4", + container=self.container_combo.currentText().strip() or "mp4", + options=self._parse_json(self.recording_options_edit.toPlainText()), + ) + + # ------------------------------------------------------------------ actions + def _action_load_config(self) -> None: + file_name, _ = QFileDialog.getOpenFileName( + self, "Load configuration", str(Path.home()), "JSON files (*.json)" + ) + if not file_name: + return + try: + config = ApplicationSettings.load(file_name) + except Exception as exc: # pragma: no cover - GUI interaction + self._show_error(str(exc)) + return + self._config = config + self._config_path = Path(file_name) + self._apply_config(config) + self.statusBar().showMessage(f"Loaded configuration: {file_name}", 5000) + + def _action_save_config(self) -> None: + if self._config_path is None: + self._action_save_config_as() + return + self._save_config_to_path(self._config_path) + + def _action_save_config_as(self) -> None: + file_name, _ = QFileDialog.getSaveFileName( + self, "Save configuration", str(Path.home()), "JSON files (*.json)" + ) + if not file_name: + return + path = Path(file_name) + if path.suffix.lower() != ".json": + path = path.with_suffix(".json") + self._config_path = path + self._save_config_to_path(path) + + def _save_config_to_path(self, path: Path) -> None: + try: + config = self._current_config() + config.save(path) + except Exception as exc: # pragma: no cover - GUI interaction + self._show_error(str(exc)) + return + self.statusBar().showMessage(f"Saved configuration to {path}", 5000) + + def _action_browse_model(self) -> None: + file_name, _ = QFileDialog.getOpenFileName( + self, "Select DLCLive model", str(Path.home()), "All files (*.*)" + ) + if file_name: + self.model_path_edit.setText(file_name) + + def _action_browse_directory(self) -> None: + directory = QFileDialog.getExistingDirectory( + self, "Select output directory", str(Path.home()) + ) + if directory: + self.output_directory_edit.setText(directory) + + # ------------------------------------------------------------------ camera control + def _start_preview(self) -> None: + try: + settings = self._camera_settings_from_ui() + except ValueError as exc: + self._show_error(str(exc)) + return + self.camera_controller.start(settings) + self.preview_button.setEnabled(False) + self.stop_preview_button.setEnabled(True) + self.statusBar().showMessage("Camera preview started", 3000) + if self.enable_dlc_checkbox.isChecked(): + self._configure_dlc() + else: + self._last_pose = None + + def _stop_preview(self) -> None: + self.camera_controller.stop() + self.preview_button.setEnabled(True) + self.stop_preview_button.setEnabled(False) + self._current_frame = None + self._last_pose = None + self.video_label.setPixmap(QPixmap()) + self.video_label.setText("Camera preview not started") + self.statusBar().showMessage("Camera preview stopped", 3000) + + def _on_camera_stopped(self) -> None: + self.preview_button.setEnabled(True) + self.stop_preview_button.setEnabled(False) + + def _configure_dlc(self) -> None: + try: + settings = self._dlc_settings_from_ui() + except (ValueError, json.JSONDecodeError) as exc: + self._show_error(f"Invalid DLCLive settings: {exc}") + self.enable_dlc_checkbox.setChecked(False) + return + self.dlc_processor.configure(settings) + + # ------------------------------------------------------------------ recording + def _start_recording(self) -> None: + if self._video_recorder and self._video_recorder.is_running: + return + try: + recording = self._recording_settings_from_ui() + except json.JSONDecodeError as exc: + self._show_error(f"Invalid recording options: {exc}") + return + if not recording.enabled: + self._show_error("Recording is disabled in the configuration.") + return + output_path = recording.output_path() + self._video_recorder = VideoRecorder(output_path, recording.options) + try: + self._video_recorder.start() + except Exception as exc: # pragma: no cover - runtime error + self._show_error(str(exc)) + self._video_recorder = None + return + self.start_record_button.setEnabled(False) + self.stop_record_button.setEnabled(True) + self.statusBar().showMessage(f"Recording to {output_path}", 5000) + + def _stop_recording(self) -> None: + if not self._video_recorder: + return + self._video_recorder.stop() + self._video_recorder = None + self.start_record_button.setEnabled(True) + self.stop_record_button.setEnabled(False) + self.statusBar().showMessage("Recording stopped", 3000) + + # ------------------------------------------------------------------ frame handling + def _on_frame_ready(self, frame_data: FrameData) -> None: + frame = frame_data.image + self._current_frame = frame + if self._video_recorder and self._video_recorder.is_running: + self._video_recorder.write(frame) + if self.enable_dlc_checkbox.isChecked(): + self.dlc_processor.enqueue_frame(frame, frame_data.timestamp) + self._update_video_display(frame) + + def _on_pose_ready(self, result: PoseResult) -> None: + self._last_pose = result + if self._current_frame is not None: + self._update_video_display(self._current_frame) + + def _update_video_display(self, frame: np.ndarray) -> None: + display_frame = frame + if self._last_pose and self._last_pose.pose is not None: + display_frame = self._draw_pose(frame, self._last_pose.pose) + rgb = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB) + h, w, ch = rgb.shape + bytes_per_line = ch * w + image = QImage(rgb.data, w, h, bytes_per_line, QImage.Format.Format_RGB888) + self.video_label.setPixmap(QPixmap.fromImage(image)) + + def _draw_pose(self, frame: np.ndarray, pose: np.ndarray) -> np.ndarray: + overlay = frame.copy() + for keypoint in np.asarray(pose): + if len(keypoint) < 2: + continue + x, y = keypoint[:2] + if np.isnan(x) or np.isnan(y): + continue + cv2.circle(overlay, (int(x), int(y)), 4, (0, 255, 0), -1) + return overlay + + def _on_dlc_initialised(self, success: bool) -> None: + if success: + self.statusBar().showMessage("DLCLive initialised", 3000) + else: + self.statusBar().showMessage("DLCLive initialisation failed", 3000) + + # ------------------------------------------------------------------ helpers + def _show_error(self, message: str) -> None: + self.statusBar().showMessage(message, 5000) + QMessageBox.critical(self, "Error", message) + + # ------------------------------------------------------------------ Qt overrides + def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI behaviour + if self.camera_controller.is_running(): + self.camera_controller.stop() + if self._video_recorder and self._video_recorder.is_running: + self._video_recorder.stop() + self.dlc_processor.shutdown() + super().closeEvent(event) + + +def main() -> None: + app = QApplication(sys.argv) + window = MainWindow() + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": # pragma: no cover - manual start + main() diff --git a/dlclivegui/pose_process.py b/dlclivegui/pose_process.py deleted file mode 100644 index 7ae4809..0000000 --- a/dlclivegui/pose_process.py +++ /dev/null @@ -1,273 +0,0 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M. Mathis Labs - -Licensed under GNU Lesser General Public License v3.0 -""" - - -import multiprocess as mp -import threading -import time -import pandas as pd -import numpy as np - -from dlclivegui import CameraProcess -from dlclivegui.queue import ClearableQueue, ClearableMPQueue - - -class DLCLiveProcessError(Exception): - """ - Exception for incorrect use of DLC-live-GUI Process Manager - """ - - pass - - -class CameraPoseProcess(CameraProcess): - """ Camera Process Manager class. Controls image capture, pose estimation and writing images to a video file in a background process. - - Parameters - ---------- - device : :class:`cameracontrol.Camera` - a camera object - ctx : :class:`multiprocess.Context` - multiprocessing context - """ - - def __init__(self, device, ctx=mp.get_context("spawn")): - """ Constructor method - """ - - super().__init__(device, ctx) - self.display_pose = None - self.display_pose_queue = ClearableMPQueue(2, ctx=self.ctx) - self.pose_process = None - - def start_pose_process(self, dlc_params, timeout=300): - - self.pose_process = self.ctx.Process( - target=self._run_pose, - args=(self.frame_shared, self.frame_time_shared, dlc_params), - daemon=True, - ) - self.pose_process.start() - - stime = time.time() - while time.time() - stime < timeout: - cmd = self.q_from_process.read() - if cmd is not None: - if (cmd[0] == "pose") and (cmd[1] == "start"): - return cmd[2] - else: - self.q_to_process.write(cmd) - - def _run_pose(self, frame_shared, frame_time, dlc_params): - - res = self.device.im_size - self.frame = np.frombuffer(frame_shared.get_obj(), dtype="uint8").reshape( - res[1], res[0], 3 - ) - self.frame_time = np.frombuffer(frame_time.get_obj(), dtype="d") - - ret = self._open_dlc_live(dlc_params) - self.q_from_process.write(("pose", "start", ret)) - - self._pose_loop() - self.q_from_process.write(("pose", "end")) - - def _open_dlc_live(self, dlc_params): - - from dlclive import DLCLive - - ret = False - - self.opt_rate = True if dlc_params.pop("mode") == "Optimize Rate" else False - - proc_params = dlc_params.pop("processor") - if proc_params is not None: - proc_obj = proc_params.pop("object", None) - if proc_obj is not None: - dlc_params["processor"] = proc_obj(**proc_params) - - self.dlc = DLCLive(**dlc_params) - if self.frame is not None: - self.dlc.init_inference( - self.frame, frame_time=self.frame_time[0], record=False - ) - self.poses = [] - self.pose_times = [] - self.pose_frame_times = [] - ret = True - - return ret - - def _pose_loop(self): - """ Conduct pose estimation using deeplabcut-live in loop - """ - - run = True - write = False - frame_time = 0 - pose_time = 0 - end_time = time.time() - - while run: - - ref_time = frame_time if self.opt_rate else end_time - - if self.frame_time[0] > ref_time: - - frame = self.frame - frame_time = self.frame_time[0] - pose = self.dlc.get_pose(frame, frame_time=frame_time, record=write) - pose_time = time.time() - - self.display_pose_queue.write(pose, clear=True) - - if write: - self.poses.append(pose) - self.pose_times.append(pose_time) - self.pose_frame_times.append(frame_time) - - cmd = self.q_to_process.read() - if cmd is not None: - if cmd[0] == "pose": - if cmd[1] == "write": - write = cmd[2] - self.q_from_process.write(cmd) - elif cmd[1] == "save": - ret = self._save_pose(cmd[2]) - self.q_from_process.write(cmd + (ret,)) - elif cmd[1] == "end": - run = False - else: - self.q_to_process.write(cmd) - - def start_record(self, timeout=5): - - ret = super().start_record(timeout=timeout) - - if (self.pose_process is not None) and (self.writer_process is not None): - if (self.pose_process.is_alive()) and (self.writer_process.is_alive()): - self.q_to_process.write(("pose", "write", True)) - - stime = time.time() - while time.time() - stime < timeout: - cmd = self.q_from_process.read() - if cmd is not None: - if (cmd[0] == "pose") and (cmd[1] == "write"): - ret = cmd[2] - break - else: - self.q_from_process.write(cmd) - - return ret - - def stop_record(self, timeout=5): - - ret = super().stop_record(timeout=timeout) - - if (self.pose_process is not None) and (self.writer_process is not None): - if (self.pose_process.is_alive()) and (self.writer_process.is_alive()): - self.q_to_process.write(("pose", "write", False)) - - stime = time.time() - while time.time() - stime < timeout: - cmd = self.q_from_process.read() - if cmd is not None: - if (cmd[0] == "pose") and (cmd[1] == "write"): - ret = not cmd[2] - break - else: - self.q_from_process.write(cmd) - - return ret - - def stop_pose_process(self): - - ret = True - if self.pose_process is not None: - if self.pose_process.is_alive(): - self.q_to_process.write(("pose", "end")) - - while True: - cmd = self.q_from_process.read() - if cmd is not None: - if cmd[0] == "pose": - if cmd[1] == "end": - break - else: - self.q_from_process.write(cmd) - - self.pose_process.join(5) - if self.pose_process.is_alive(): - self.pose_process.terminate() - - return True - - def save_pose(self, filename, timeout=60): - - ret = False - if self.pose_process is not None: - if self.pose_process.is_alive(): - self.q_to_process.write(("pose", "save", filename)) - - stime = time.time() - while time.time() - stime < timeout: - cmd = self.q_from_process.read() - if cmd is not None: - if (cmd[0] == "pose") and (cmd[1] == "save"): - ret = cmd[3] - break - else: - self.q_from_process.write(cmd) - return ret - - def _save_pose(self, filename): - """ Saves a pandas data frame with pose data collected while recording video - - Returns - ------- - bool - a logical flag indicating whether save was successful - """ - - ret = False - - if len(self.pose_times) > 0: - - dlc_file = f"{filename}_DLC.hdf5" - proc_file = f"{filename}_PROC" - - bodyparts = self.dlc.cfg["all_joints_names"] - poses = np.array(self.poses) - poses = poses.reshape((poses.shape[0], poses.shape[1] * poses.shape[2])) - pdindex = pd.MultiIndex.from_product( - [bodyparts, ["x", "y", "likelihood"]], names=["bodyparts", "coords"] - ) - pose_df = pd.DataFrame(poses, columns=pdindex) - pose_df["frame_time"] = self.pose_frame_times - pose_df["pose_time"] = self.pose_times - - pose_df.to_hdf(dlc_file, key="df_with_missing", mode="w") - if self.dlc.processor is not None: - self.dlc.processor.save(proc_file) - - self.poses = [] - self.pose_times = [] - self.pose_frame_times = [] - - ret = True - - return ret - - def get_display_pose(self): - - pose = self.display_pose_queue.read(clear=True) - if pose is not None: - self.display_pose = pose - if self.device.display_resize != 1: - self.display_pose[:, :2] *= self.device.display_resize - - return self.display_pose diff --git a/dlclivegui/processor/__init__.py b/dlclivegui/processor/__init__.py deleted file mode 100644 index b97a9cc..0000000 --- a/dlclivegui/processor/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .teensy_laser.teensy_laser import TeensyLaser diff --git a/dlclivegui/processor/processor.py b/dlclivegui/processor/processor.py deleted file mode 100644 index 05eb7a8..0000000 --- a/dlclivegui/processor/processor.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M. Mathis Labs - -Licensed under GNU Lesser General Public License v3.0 -""" - -""" -Default processor class. Processors must contain two methods: -i) process: takes in a pose, performs operations, and returns a pose -ii) save: saves any internal data generated by the processor (such as timestamps for commands to external hardware) -""" - - -class Processor(object): - def __init__(self): - pass - - def process(self, pose): - return pose - - def save(self, file=""): - return 0 diff --git a/dlclivegui/processor/teensy_laser/__init__.py b/dlclivegui/processor/teensy_laser/__init__.py deleted file mode 100644 index d2f10ca..0000000 --- a/dlclivegui/processor/teensy_laser/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .teensy_laser import * diff --git a/dlclivegui/processor/teensy_laser/teensy_laser.ino b/dlclivegui/processor/teensy_laser/teensy_laser.ino deleted file mode 100644 index 76a470b..0000000 --- a/dlclivegui/processor/teensy_laser/teensy_laser.ino +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Commands: - * O = opto on; command = O, frequency, width, duration - * X = opto off - * R = reboot - */ - - -const int opto_pin = 0; -unsigned int opto_start = 0, - opto_duty_cycle = 0, - opto_freq = 0, - opto_width = 0, - opto_dur = 0; - -unsigned int read_int16() { - union u_tag { - byte b[2]; - unsigned int val; - } par; - for (int i=0; i<2; i++){ - if ((Serial.available() > 0)) - par.b[i] = Serial.read(); - else - par.b[i] = 0; - } - return par.val; -} - -void setup() { - Serial.begin(115200); - pinMode(opto_pin, OUTPUT); -} - -void loop() { - - unsigned int curr_time = millis(); - - while (Serial.available() > 0) { - - unsigned int cmd = Serial.read(); - - if(cmd == 'O') { - - opto_start = curr_time; - opto_freq = read_int16(); - opto_width = read_int16(); - opto_dur = read_int16(); - if (opto_dur == 0) - opto_dur = 65355; - opto_duty_cycle = opto_width * opto_freq * 4096 / 1000; - analogWriteFrequency(opto_pin, opto_freq); - analogWrite(opto_pin, opto_duty_cycle); - - Serial.print(opto_freq); - Serial.print(','); - Serial.print(opto_width); - Serial.print(','); - Serial.print(opto_dur); - Serial.print('\n'); - Serial.flush(); - - } else if(cmd == 'X') { - - analogWrite(opto_pin, 0); - - } else if(cmd == 'R') { - - _reboot_Teensyduino_(); - - } - } - - if (curr_time > opto_start + opto_dur) - analogWrite(opto_pin, 0); - -} diff --git a/dlclivegui/processor/teensy_laser/teensy_laser.py b/dlclivegui/processor/teensy_laser/teensy_laser.py deleted file mode 100644 index 4535d55..0000000 --- a/dlclivegui/processor/teensy_laser/teensy_laser.py +++ /dev/null @@ -1,77 +0,0 @@ -from ..processor import Processor -import serial -import struct -import time - - -class TeensyLaser(Processor): - def __init__( - self, com, baudrate=115200, pulse_freq=50, pulse_width=5, max_stim_dur=0 - ): - - super().__init__() - self.ser = serial.Serial(com, baudrate) - self.pulse_freq = pulse_freq - self.pulse_width = pulse_width - self.max_stim_dur = ( - max_stim_dur if (max_stim_dur >= 0) and (max_stim_dur < 65356) else 0 - ) - self.stim_on = False - self.stim_on_time = [] - self.stim_off_time = [] - - def close_serial(self): - - self.ser.close() - - def stimulate_on(self): - - # command to activate PWM signal to laser is the letter 'O' followed by three 16 bit integers -- pulse frequency, pulse width, and max stim duration - if not self.stim_on: - self.ser.write( - b"O" - + struct.pack( - "HHH", self.pulse_freq, self.pulse_width, self.max_stim_dur - ) - ) - self.stim_on = True - self.stim_on_time.append(time.time()) - - def stim_off(self): - - # command to turn off PWM signal to laser is the letter 'X' - if self.stim_on: - self.ser.write(b"X") - self.stim_on = False - self.stim_off_time.append(time.time()) - - def process(self, pose): - - # define criteria to stimulate (e.g. if first point is in a corner of the video) - box = [[0, 100], [0, 100]] - if ( - (pose[0][0] > box[0][0]) - and (pose[0][0] < box[0][1]) - and (pose[0][1] > box[1][0]) - and (pose[0][1] < box[1][1]) - ): - self.stimulate_on() - else: - self.stim_off() - - return pose - - def save(self, file=None): - - ### save stim on and stim off times - save_code = 0 - if file: - try: - pickle.dump( - {"stim_on": self.stim_on_time, "stim_off": self.stim_off_time}, - open(file, "wb"), - ) - save_code = 1 - except Exception: - save_code = -1 - return save_code diff --git a/dlclivegui/queue.py b/dlclivegui/queue.py deleted file mode 100644 index 59bc43c..0000000 --- a/dlclivegui/queue.py +++ /dev/null @@ -1,208 +0,0 @@ -import multiprocess as mp -from multiprocess import queues -from queue import Queue, Empty, Full - - -class QueuePositionError(Exception): - """ Error in position argument of queue read """ - - pass - - -class ClearableQueue(Queue): - """ A Queue that provides safe methods for writing to a full queue, reading to an empty queue, and a method to clear the queue """ - - def __init__(self, maxsize=0): - - super().__init__(maxsize) - - def clear(self): - """ Clears queue, returns all objects in a list - - Returns - ------- - list - list of objects from the queue - """ - - objs = [] - - try: - while True: - objs.append(self.get_nowait()) - except Empty: - pass - - return objs - - def write(self, obj, clear=False): - """ Puts an object in the queue, with an option to clear queue before writing. - - Parameters - ---------- - obj : [type] - An object to put in the queue - clear : bool, optional - flag to clear queue before putting, by default False - - Returns - ------- - bool - if write was sucessful, returns True - """ - - if clear: - self.clear() - - try: - self.put_nowait(obj) - success = True - except Full: - success = False - - return success - - def read(self, clear=False, position="last"): - """ Gets an object in the queue, with the option to clear the queue and return the first element, last element, or all elements - - Parameters - ---------- - clear : bool, optional - flag to clear queue before putting, by default False - position : str, optional - If clear is True, returned object depends on position. - If position = "last", returns last object. - If position = "first", returns first object. - If position = "all", returns all objects from the queue. - - Returns - ------- - object - object retrieved from the queue - """ - - obj = None - - if clear: - - objs = self.clear() - - if len(objs) > 0: - if position == "first": - obj = objs[0] - elif position == "last": - obj = objs[-1] - elif position == "all": - obj = objs - else: - raise QueuePositionError( - "Queue read position should be one of 'first', 'last', or 'all'" - ) - else: - - try: - obj = self.get_nowait() - except Empty: - pass - - return obj - - -class ClearableMPQueue(mp.queues.Queue): - """ A multiprocess Queue that provides safe methods for writing to a full queue, reading to an empty queue, and a method to clear the queue """ - - def __init__(self, maxsize=0, ctx=mp.get_context("spawn")): - - super().__init__(maxsize, ctx=ctx) - - def clear(self): - """ Clears queue, returns all objects in a list - - Returns - ------- - list - list of objects from the queue - """ - - objs = [] - - try: - while True: - objs.append(self.get_nowait()) - except Empty: - pass - - return objs - - def write(self, obj, clear=False): - """ Puts an object in the queue, with an option to clear queue before writing. - - Parameters - ---------- - obj : [type] - An object to put in the queue - clear : bool, optional - flag to clear queue before putting, by default False - - Returns - ------- - bool - if write was sucessful, returns True - """ - - if clear: - self.clear() - - try: - self.put_nowait(obj) - success = True - except Full: - success = False - - return success - - def read(self, clear=False, position="last"): - """ Gets an object in the queue, with the option to clear the queue and return the first element, last element, or all elements - - Parameters - ---------- - clear : bool, optional - flag to clear queue before putting, by default False - position : str, optional - If clear is True, returned object depends on position. - If position = "last", returns last object. - If position = "first", returns first object. - If position = "all", returns all objects from the queue. - - Returns - ------- - object - object retrieved from the queue - """ - - obj = None - - if clear: - - objs = self.clear() - - if len(objs) > 0: - if position == "first": - obj = objs[0] - elif position == "last": - obj = objs[-1] - elif position == "all": - obj = objs - else: - raise QueuePositionError( - "Queue read position should be one of 'first', 'last', or 'all'" - ) - - else: - - try: - obj = self.get_nowait() - except Empty: - pass - - return obj diff --git a/dlclivegui/tkutil.py b/dlclivegui/tkutil.py deleted file mode 100644 index 3a5837e..0000000 --- a/dlclivegui/tkutil.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M. Mathis Labs - -Licensed under GNU Lesser General Public License v3.0 -""" - - -import tkinter as tk -from tkinter import ttk -from distutils.util import strtobool - - -class SettingsWindow(tk.Toplevel): - def __init__( - self, - title="Edit Settings", - settings={}, - names=None, - vals=None, - dtypes=None, - restrictions=None, - parent=None, - ): - """ Create a tkinter settings window - - Parameters - ---------- - title : str, optional - title for window - settings : dict, optional - dictionary of settings with keys = setting names. - The value for each setting should be a dictionary with three keys: - value (a default value), - dtype (the data type for the setting), - restriction (a list of possible values the parameter can take on) - names : list, optional - list of setting names, by default None - vals : list, optional - list of default values, by default None - dtypes : list, optional - list of setting data types, by default None - restrictions : dict, optional - dictionary of setting value restrictions, with keys = setting name and value = list of restrictions, by default {} - parent : :class:`tkinter.Tk`, optional - parent window, by default None - - Raises - ------ - ValueError - throws error if neither settings dictionary nor setting names are provided - """ - - super().__init__(parent) - self.title(title) - - if settings: - self.settings = settings - elif not names: - raise ValueError( - "No argument names or settings dictionary. One must be provided to create a SettingsWindow." - ) - else: - self.settings = self.create_settings_dict(names, vals, dtypes, restrictions) - - self.cur_row = 0 - self.combobox_width = 15 - - self.create_window() - - def create_settings_dict(self, names, vals=None, dtypes=None, restrictions=None): - """Create dictionary of settings from names, vals, dtypes, and restrictions - - Parameters - ---------- - names : list - list of setting names - vals : list - list of default setting values - dtypes : list - list of setting dtype - restrictions : dict - dictionary of settting restrictions - - Returns - ------- - dict - settings dictionary with keys = names and value = dictionary with value, dtype, restrictions - """ - - set_dict = {} - for i in range(len(names)): - - dt = dtypes[i] if dtypes is not None else None - - if vals is not None: - val = dt(val) if type(dt) is type else [dt[0](v) for v in val] - else: - val = None - - restrict = restrictions[names[i]] if restrictions is not None else None - - set_dict[names[i]] = {"value": val, "dtype": dt, "restriction": restrict} - - return set_dict - - def create_window(self): - """ Create settings GUI widgets - """ - - self.entry_vars = [] - names = tuple(self.settings.keys()) - for i in range(len(names)): - - this_setting = self.settings[names[i]] - - tk.Label(self, text=names[i] + ": ").grid(row=self.cur_row, column=0) - - v = this_setting["value"] - if type(this_setting["dtype"]) is list: - v = [str(x) if x is not None else "" for x in v] - v = ", ".join(v) - else: - v = str(v) if v is not None else "" - self.entry_vars.append(tk.StringVar(self, value=v)) - - use_restriction = False - if "restriction" in this_setting: - if this_setting["restriction"] is not None: - use_restriction = True - - if use_restriction: - ttk.Combobox( - self, - textvariable=self.entry_vars[-1], - values=this_setting["restriction"], - state="readonly", - width=self.combobox_width, - ).grid(sticky="nsew", row=self.cur_row, column=1) - else: - tk.Entry(self, textvariable=self.entry_vars[-1]).grid( - sticky="nsew", row=self.cur_row, column=1 - ) - - self.cur_row += 1 - - self.cur_row += 1 - tk.Button(self, text="Update", command=self.update_vals).grid( - sticky="nsew", row=self.cur_row, column=1 - ) - self.cur_row += 1 - tk.Button(self, text="Cancel", command=self.destroy).grid( - sticky="nsew", row=self.cur_row, column=1 - ) - - _, row_count = self.grid_size() - for r in range(row_count): - self.grid_rowconfigure(r, minsize=20) - - def update_vals(self): - - names = tuple(self.settings.keys()) - - for i in range(len(self.entry_vars)): - - name = names[i] - val = self.entry_vars[i].get() - dt = ( - self.settings[name]["dtype"] if "dtype" in self.settings[name] else None - ) - - val = [v.strip() for v in val.split(",")] - use_dt = dt if type(dt) is type else dt[0] - use_dt = strtobool if use_dt is bool else use_dt - - try: - val = [use_dt(v) if v else None for v in val] - except TypeError: - pass - - val = val if type(dt) is list else val[0] - - self.settings[name]["value"] = val - - self.quit() - self.destroy() - - def get_values(self): - - val_dict = {} - names = tuple(self.settings.keys()) - for i in range(len(self.settings)): - val_dict[names[i]] = self.settings[names[i]]["value"] - - return val_dict diff --git a/dlclivegui/video.py b/dlclivegui/video.py deleted file mode 100644 index d05f5b8..0000000 --- a/dlclivegui/video.py +++ /dev/null @@ -1,274 +0,0 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M. Mathis Labs - -Licensed under GNU Lesser General Public License v3.0 -""" - - -import os -import numpy as np -import pandas as pd -import cv2 -import colorcet as cc -from PIL import ImageColor -from tqdm import tqdm - - -def create_labeled_video( - data_dir, - out_dir=None, - dlc_online=True, - save_images=False, - cut=(0, np.Inf), - crop=None, - cmap="bmy", - radius=3, - lik_thresh=0.5, - write_ts=False, - write_scale=2, - write_pos="bottom-left", - write_ts_offset=0, - display=False, - progress=True, - label=True, -): - """ Create a labeled video from DeepLabCut-live-GUI recording - - Parameters - ---------- - data_dir : str - path to data directory - dlc_online : bool, optional - flag indicating dlc keypoints from online tracking, using DeepLabCut-live-GUI, or offline tracking, using :func:`dlclive.benchmark_videos` - out_file : str, optional - path for output file. If None, output file will be "'video_file'_LABELED.avi". by default None. If NOn - save_images : bool, optional - boolean flag to save still images in a folder - cut : tuple, optional - time of video to use. Will only save labeled video for time after cut[0] and before cut[1], by default (0, np.Inf) - cmap : str, optional - a :package:`colorcet` colormap, by default 'bmy' - radius : int, optional - radius for keypoints, by default 3 - lik_thresh : float, optional - likelihood threshold to plot keypoints, by default 0.5 - display : bool, optional - boolean flag to display images as video is written, by default False - progress : bool, optional - boolean flag to display progress bar - - Raises - ------ - Exception - if frames cannot be read from the video file - """ - - base_dir = os.path.basename(data_dir) - video_file = os.path.normpath(f"{data_dir}/{base_dir}_VIDEO.avi") - ts_file = os.path.normpath(f"{data_dir}/{base_dir}_TS.npy") - dlc_file = ( - os.path.normpath(f"{data_dir}/{base_dir}_DLC.hdf5") - if dlc_online - else os.path.normpath(f"{data_dir}/{base_dir}_VIDEO_DLCLIVE_POSES.h5") - ) - - cap = cv2.VideoCapture(video_file) - cam_frame_times = np.load(ts_file) - n_frames = cam_frame_times.size - - lab = "LABELED" if label else "UNLABELED" - if out_dir: - out_file = ( - f"{out_dir}/{os.path.splitext(os.path.basename(video_file))[0]}_{lab}.avi" - ) - out_times_file = ( - f"{out_dir}/{os.path.splitext(os.path.basename(ts_file))[0]}_{lab}.npy" - ) - else: - out_file = f"{os.path.splitext(video_file)[0]}_{lab}.avi" - out_times_file = f"{os.path.splitext(ts_file)[0]}_{lab}.npy" - - os.makedirs(os.path.normpath(os.path.dirname(out_file)), exist_ok=True) - - if save_images: - im_dir = os.path.splitext(out_file)[0] - os.makedirs(im_dir, exist_ok=True) - - im_size = ( - int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), - int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), - ) - if crop is not None: - crop[0] = crop[0] if crop[0] > 0 else 0 - crop[1] = crop[1] if crop[1] > 0 else im_size[1] - crop[2] = crop[2] if crop[2] > 0 else 0 - crop[3] = crop[3] if crop[3] > 0 else im_size[0] - im_size = (crop[3] - crop[2], crop[1] - crop[0]) - - fourcc = cv2.VideoWriter_fourcc(*"DIVX") - fps = cap.get(cv2.CAP_PROP_FPS) - vwriter = cv2.VideoWriter(out_file, fourcc, fps, im_size) - label_times = [] - - if write_ts: - ts_font = cv2.FONT_HERSHEY_PLAIN - - if "left" in write_pos: - ts_w = 0 - else: - ts_w = ( - im_size[0] if crop is None else (crop[3] - crop[2]) - (55 * write_scale) - ) - - if "bottom" in write_pos: - ts_h = im_size[1] if crop is None else (crop[1] - crop[0]) - else: - ts_h = 0 if crop is None else crop[0] + (12 * write_scale) - - ts_coord = (ts_w, ts_h) - ts_color = (255, 255, 255) - ts_size = 2 - - poses = pd.read_hdf(dlc_file) - if dlc_online: - pose_times = poses["pose_time"] - else: - poses["frame_time"] = cam_frame_times - poses["pose_time"] = cam_frame_times - poses = poses.melt(id_vars=["frame_time", "pose_time"]) - bodyparts = poses["bodyparts"].unique() - - all_colors = getattr(cc, cmap) - colors = [ - ImageColor.getcolor(c, "RGB")[::-1] - for c in all_colors[:: int(len(all_colors) / bodyparts.size)] - ] - - ind = 0 - vid_time = 0 - while vid_time < cut[0]: - - cur_time = cam_frame_times[ind] - vid_time = cur_time - cam_frame_times[0] - ret, frame = cap.read() - ind += 1 - - if not ret: - raise Exception( - f"Could not read frame = {ind+1} at time = {cur_time-cam_frame_times[0]}." - ) - - frame_times_sub = cam_frame_times[ - (cam_frame_times - cam_frame_times[0] > cut[0]) - & (cam_frame_times - cam_frame_times[0] < cut[1]) - ] - iterator = ( - tqdm(range(ind, ind + frame_times_sub.size)) - if progress - else range(ind, ind + frame_times_sub.size) - ) - this_pose = np.zeros((bodyparts.size, 3)) - - for i in iterator: - - cur_time = cam_frame_times[i] - vid_time = cur_time - cam_frame_times[0] - ret, frame = cap.read() - - if not ret: - raise Exception( - f"Could not read frame = {i+1} at time = {cur_time-cam_frame_times[0]}." - ) - - if dlc_online: - poses_before_index = np.where(pose_times < cur_time)[0] - if poses_before_index.size > 0: - cur_pose_time = pose_times[poses_before_index[-1]] - this_pose = poses[poses["pose_time"] == cur_pose_time] - else: - this_pose = poses[poses["frame_time"] == cur_time] - - if label: - for j in range(bodyparts.size): - this_bp = this_pose[this_pose["bodyparts"] == bodyparts[j]][ - "value" - ].values - if this_bp[2] > lik_thresh: - x = int(this_bp[0]) - y = int(this_bp[1]) - frame = cv2.circle(frame, (x, y), radius, colors[j], thickness=-1) - - if crop is not None: - frame = frame[crop[0] : crop[1], crop[2] : crop[3]] - - if write_ts: - frame = cv2.putText( - frame, - f"{(vid_time-write_ts_offset):0.3f}", - ts_coord, - ts_font, - write_scale, - ts_color, - ts_size, - ) - - if display: - cv2.imshow("DLC Live Labeled Video", frame) - cv2.waitKey(1) - - vwriter.write(frame) - label_times.append(cur_time) - if save_images: - new_file = f"{im_dir}/frame_{i}.png" - cv2.imwrite(new_file, frame) - - if display: - cv2.destroyAllWindows() - - vwriter.release() - np.save(out_times_file, label_times) - - -def main(): - - import argparse - import os - - parser = argparse.ArgumentParser() - parser.add_argument("dir", type=str) - parser.add_argument("-o", "--out-dir", type=str, default=None) - parser.add_argument("--dlc-offline", action="store_true") - parser.add_argument("-s", "--save-images", action="store_true") - parser.add_argument("-u", "--cut", nargs="+", type=float, default=[0, np.Inf]) - parser.add_argument("-c", "--crop", nargs="+", type=int, default=None) - parser.add_argument("-m", "--cmap", type=str, default="bmy") - parser.add_argument("-r", "--radius", type=int, default=3) - parser.add_argument("-l", "--lik-thresh", type=float, default=0.5) - parser.add_argument("-w", "--write-ts", action="store_true") - parser.add_argument("--write-scale", type=int, default=2) - parser.add_argument("--write-pos", type=str, default="bottom-left") - parser.add_argument("--write-ts-offset", type=float, default=0.0) - parser.add_argument("-d", "--display", action="store_true") - parser.add_argument("--no-progress", action="store_false") - parser.add_argument("--no-label", action="store_false") - args = parser.parse_args() - - create_labeled_video( - args.dir, - out_dir=args.out_dir, - dlc_online=(not args.dlc_offline), - save_images=args.save_images, - cut=tuple(args.cut), - crop=args.crop, - cmap=args.cmap, - radius=args.radius, - lik_thresh=args.lik_thresh, - write_ts=args.write_ts, - write_scale=args.write_scale, - write_pos=args.write_pos, - write_ts_offset=args.write_ts_offset, - display=args.display, - progress=args.no_progress, - label=args.no_label, - ) diff --git a/dlclivegui/video_recorder.py b/dlclivegui/video_recorder.py new file mode 100644 index 0000000..e0e3706 --- /dev/null +++ b/dlclivegui/video_recorder.py @@ -0,0 +1,46 @@ +"""Video recording support using the vidgear library.""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Optional + +import numpy as np + +try: + from vidgear.gears import WriteGear +except ImportError: # pragma: no cover - handled at runtime + WriteGear = None # type: ignore[assignment] + + +class VideoRecorder: + """Thin wrapper around :class:`vidgear.gears.WriteGear`.""" + + def __init__(self, output: Path | str, options: Optional[Dict[str, Any]] = None): + self._output = Path(output) + self._options = options or {} + self._writer: Optional[WriteGear] = None + + @property + def is_running(self) -> bool: + return self._writer is not None + + def start(self) -> None: + if WriteGear is None: + raise RuntimeError( + "vidgear is required for video recording. Install it with 'pip install vidgear'." + ) + if self._writer is not None: + return + self._output.parent.mkdir(parents=True, exist_ok=True) + self._writer = WriteGear(output_filename=str(self._output), logging=False, **self._options) + + def write(self, frame: np.ndarray) -> None: + if self._writer is None: + return + self._writer.write(frame) + + def stop(self) -> None: + if self._writer is None: + return + self._writer.close() + self._writer = None diff --git a/setup.py b/setup.py index 28eb6cc..163f8f0 100644 --- a/setup.py +++ b/setup.py @@ -1,36 +1,32 @@ -""" -DeepLabCut Toolbox (deeplabcut.org) -© A. & M. Mathis Labs - -Licensed under GNU Lesser General Public License v3.0 -""" - +"""Setup configuration for the DeepLabCut Live GUI.""" +from __future__ import annotations import setuptools -with open("README.md", "r") as fh: +with open("README.md", "r", encoding="utf-8") as fh: long_description = fh.read() setuptools.setup( name="deeplabcut-live-gui", - version="1.0", + version="2.0", author="A. & M. Mathis Labs", author_email="adim@deeplabcut.org", - description="GUI to run real time deeplabcut experiments", + description="PyQt-based GUI to run real time DeepLabCut experiments", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/DeepLabCut/DeepLabCut-live-GUI", - python_requires=">=3.5, <3.11", + python_requires=">=3.11", install_requires=[ "deeplabcut-live", - "pyserial", - "pandas", - "tables", - "multiprocess", - "imutils", - "pillow", - "tqdm", + "PyQt6", + "numpy", + "opencv-python", + "vidgear[core]", ], + extras_require={ + "basler": ["pypylon"], + "gentl": ["pygobject"], + }, packages=setuptools.find_packages(), include_package_data=True, classifiers=( @@ -40,8 +36,7 @@ ), entry_points={ "console_scripts": [ - "dlclivegui=dlclivegui.dlclivegui:main", - "dlclivegui-video=dlclivegui.video:main", + "dlclivegui=dlclivegui.gui:main", ] }, ) From 893b28ad3e01bf1b29613886b9527b6777e9f22a Mon Sep 17 00:00:00 2001 From: Artur <35294812+arturoptophys@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:55:15 +0200 Subject: [PATCH 02/69] Rearrange UI and improve camera controls --- dlclivegui/camera_controller.py | 17 +- dlclivegui/cameras/base.py | 5 + dlclivegui/cameras/factory.py | 62 ++++++- dlclivegui/cameras/opencv_backend.py | 16 ++ dlclivegui/dlc_processor.py | 20 +++ dlclivegui/gui.py | 255 +++++++++++++++++++++++---- dlclivegui/video_recorder.py | 2 +- 7 files changed, 330 insertions(+), 47 deletions(-) diff --git a/dlclivegui/camera_controller.py b/dlclivegui/camera_controller.py index 3398566..c8a5aaf 100644 --- a/dlclivegui/camera_controller.py +++ b/dlclivegui/camera_controller.py @@ -1,12 +1,12 @@ """Camera management for the DLC Live GUI.""" from __future__ import annotations -import time from dataclasses import dataclass +from threading import Event from typing import Optional import numpy as np -from PyQt6.QtCore import QMetaObject, QObject, QThread, Qt, pyqtSignal, pyqtSlot +from PyQt6.QtCore import QObject, QThread, pyqtSignal, pyqtSlot from .cameras import CameraFactory from .cameras.base import CameraBackend @@ -31,12 +31,12 @@ class CameraWorker(QObject): def __init__(self, settings: CameraSettings): super().__init__() self._settings = settings - self._running = False + self._stop_event = Event() self._backend: Optional[CameraBackend] = None @pyqtSlot() def run(self) -> None: - self._running = True + self._stop_event.clear() try: self._backend = CameraFactory.create(self._settings) self._backend.open() @@ -45,7 +45,7 @@ def run(self) -> None: self.finished.emit() return - while self._running: + while not self._stop_event.is_set(): try: frame, timestamp = self._backend.read() except Exception as exc: # pragma: no cover - device specific @@ -63,7 +63,7 @@ def run(self) -> None: @pyqtSlot() def stop(self) -> None: - self._running = False + self._stop_event.set() if self._backend is not None: try: self._backend.stop() @@ -106,11 +106,8 @@ def stop(self) -> None: if not self.is_running(): return assert self._worker is not None - QMetaObject.invokeMethod( - self._worker, "stop", Qt.ConnectionType.QueuedConnection - ) + self._worker.stop() assert self._thread is not None - self._thread.quit() self._thread.wait() @pyqtSlot() diff --git a/dlclivegui/cameras/base.py b/dlclivegui/cameras/base.py index 6ae79dc..910331c 100644 --- a/dlclivegui/cameras/base.py +++ b/dlclivegui/cameras/base.py @@ -33,6 +33,11 @@ def stop(self) -> None: # Most backends do not require additional handling, but subclasses may # override when they need to interrupt blocking reads. + def device_name(self) -> str: + """Return a human readable name for the device currently in use.""" + + return self.settings.name + @abstractmethod def open(self) -> None: """Open the capture device.""" diff --git a/dlclivegui/cameras/factory.py b/dlclivegui/cameras/factory.py index e9704ef..c67f7bd 100644 --- a/dlclivegui/cameras/factory.py +++ b/dlclivegui/cameras/factory.py @@ -2,12 +2,21 @@ from __future__ import annotations import importlib -from typing import Dict, Iterable, Tuple, Type +from dataclasses import dataclass +from typing import Dict, Iterable, List, Tuple, Type from ..config import CameraSettings from .base import CameraBackend +@dataclass +class DetectedCamera: + """Information about a camera discovered during probing.""" + + index: int + label: str + + _BACKENDS: Dict[str, Tuple[str, str]] = { "opencv": ("dlclivegui.cameras.opencv_backend", "OpenCVCameraBackend"), "basler": ("dlclivegui.cameras.basler_backend", "BaslerCameraBackend"), @@ -38,6 +47,57 @@ def available_backends() -> Dict[str, bool]: availability[name] = backend_cls.is_available() return availability + @staticmethod + def detect_cameras(backend: str, max_devices: int = 10) -> List[DetectedCamera]: + """Probe ``backend`` for available cameras. + + Parameters + ---------- + backend: + The backend identifier, e.g. ``"opencv"``. + max_devices: + Upper bound for the indices that should be probed. + + Returns + ------- + list of :class:`DetectedCamera` + Sorted list of detected cameras with human readable labels. + """ + + try: + backend_cls = CameraFactory._resolve_backend(backend) + except RuntimeError: + return [] + if not backend_cls.is_available(): + return [] + + detected: List[DetectedCamera] = [] + for index in range(max_devices): + settings = CameraSettings( + name=f"Probe {index}", + index=index, + width=640, + height=480, + fps=30.0, + backend=backend, + properties={}, + ) + backend_instance = backend_cls(settings) + try: + backend_instance.open() + except Exception: + continue + else: + label = backend_instance.device_name() + detected.append(DetectedCamera(index=index, label=label)) + finally: + try: + backend_instance.close() + except Exception: + pass + detected.sort(key=lambda camera: camera.index) + return detected + @staticmethod def create(settings: CameraSettings) -> CameraBackend: """Instantiate a backend for ``settings``.""" diff --git a/dlclivegui/cameras/opencv_backend.py b/dlclivegui/cameras/opencv_backend.py index a64e3f1..8497bfa 100644 --- a/dlclivegui/cameras/opencv_backend.py +++ b/dlclivegui/cameras/opencv_backend.py @@ -39,6 +39,22 @@ def close(self) -> None: self._capture.release() self._capture = None + def stop(self) -> None: + if self._capture is not None: + self._capture.release() + self._capture = None + + def device_name(self) -> str: + base_name = "OpenCV" + if self._capture is not None and hasattr(self._capture, "getBackendName"): + try: + backend_name = self._capture.getBackendName() + except Exception: # pragma: no cover - backend specific + backend_name = "" + if backend_name: + base_name = backend_name + return f"{base_name} camera #{self.settings.index}" + def _configure_capture(self) -> None: if self._capture is None: return diff --git a/dlclivegui/dlc_processor.py b/dlclivegui/dlc_processor.py index 5c199b8..0e1ef3e 100644 --- a/dlclivegui/dlc_processor.py +++ b/dlclivegui/dlc_processor.py @@ -45,6 +45,18 @@ def __init__(self) -> None: def configure(self, settings: DLCProcessorSettings) -> None: self._settings = settings + def reset(self) -> None: + """Cancel pending work and drop the current DLCLive instance.""" + + with self._lock: + if self._pending is not None and not self._pending.done(): + self._pending.cancel() + self._pending = None + if self._init_future is not None and not self._init_future.done(): + self._init_future.cancel() + self._init_future = None + self._dlc = None + def shutdown(self) -> None: with self._lock: if self._pending is not None: @@ -106,6 +118,10 @@ def _on_initialised(self, future: Future[Any]) -> None: except Exception as exc: # pragma: no cover - runtime behaviour LOGGER.exception("Failed to initialise DLCLive", exc_info=exc) self.error.emit(str(exc)) + finally: + with self._lock: + if self._init_future is future: + self._init_future = None def _run_inference(self, frame: np.ndarray, timestamp: float) -> PoseResult: if self._dlc is None: @@ -120,4 +136,8 @@ def _on_pose_ready(self, future: Future[Any]) -> None: LOGGER.exception("Pose inference failed", exc_info=exc) self.error.emit(str(exc)) return + finally: + with self._lock: + if self._pending is future: + self._pending = None self.pose_ready.emit(result) diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 4eb3971..7f6bbff 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -24,6 +24,7 @@ QMessageBox, QPlainTextEdit, QPushButton, + QSizePolicy, QSpinBox, QDoubleSpinBox, QStatusBar, @@ -33,6 +34,7 @@ from .camera_controller import CameraController, FrameData from .cameras import CameraFactory +from .cameras.factory import DetectedCamera from .config import ( ApplicationSettings, CameraSettings, @@ -53,8 +55,12 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._config = config or DEFAULT_CONFIG self._config_path: Optional[Path] = None self._current_frame: Optional[np.ndarray] = None + self._raw_frame: Optional[np.ndarray] = None self._last_pose: Optional[PoseResult] = None + self._dlc_active: bool = False self._video_recorder: Optional[VideoRecorder] = None + self._rotation_degrees: int = 0 + self._detected_cameras: list[DetectedCamera] = [] self.camera_controller = CameraController() self.dlc_processor = DLCLiveProcessor() @@ -62,20 +68,25 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._setup_ui() self._connect_signals() self._apply_config(self._config) + self._update_inference_buttons() # ------------------------------------------------------------------ UI def _setup_ui(self) -> None: central = QWidget() - layout = QVBoxLayout(central) + layout = QHBoxLayout(central) self.video_label = QLabel("Camera preview not started") self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.video_label.setMinimumSize(640, 360) - layout.addWidget(self.video_label) + self.video_label.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) - layout.addWidget(self._build_camera_group()) - layout.addWidget(self._build_dlc_group()) - layout.addWidget(self._build_recording_group()) + controls_widget = QWidget() + controls_layout = QVBoxLayout(controls_widget) + controls_layout.addWidget(self._build_camera_group()) + controls_layout.addWidget(self._build_dlc_group()) + controls_layout.addWidget(self._build_recording_group()) button_bar = QHBoxLayout() self.preview_button = QPushButton("Start Preview") @@ -83,7 +94,15 @@ def _setup_ui(self) -> None: self.stop_preview_button.setEnabled(False) button_bar.addWidget(self.preview_button) button_bar.addWidget(self.stop_preview_button) - layout.addLayout(button_bar) + controls_layout.addLayout(button_bar) + controls_layout.addStretch(1) + + preview_layout = QVBoxLayout() + preview_layout.addWidget(self.video_label) + preview_layout.addStretch(1) + + layout.addWidget(controls_widget) + layout.addLayout(preview_layout, stretch=1) self.setCentralWidget(central) self.setStatusBar(QStatusBar()) @@ -113,10 +132,13 @@ def _build_camera_group(self) -> QGroupBox: group = QGroupBox("Camera settings") form = QFormLayout(group) + index_layout = QHBoxLayout() self.camera_index = QComboBox() self.camera_index.setEditable(True) - self.camera_index.addItems([str(i) for i in range(5)]) - form.addRow("Camera index", self.camera_index) + index_layout.addWidget(self.camera_index) + self.refresh_cameras_button = QPushButton("Refresh") + index_layout.addWidget(self.refresh_cameras_button) + form.addRow("Camera", index_layout) self.camera_width = QSpinBox() self.camera_width.setRange(1, 7680) @@ -148,6 +170,13 @@ def _build_camera_group(self) -> QGroupBox: self.camera_properties_edit.setFixedHeight(60) form.addRow("Advanced properties", self.camera_properties_edit) + self.rotation_combo = QComboBox() + self.rotation_combo.addItem("0° (default)", 0) + self.rotation_combo.addItem("90°", 90) + self.rotation_combo.addItem("180°", 180) + self.rotation_combo.addItem("270°", 270) + form.addRow("Rotation", self.rotation_combo) + return group def _build_dlc_group(self) -> QGroupBox: @@ -185,9 +214,18 @@ def _build_dlc_group(self) -> QGroupBox: self.additional_options_edit.setFixedHeight(60) form.addRow("Additional options", self.additional_options_edit) - self.enable_dlc_checkbox = QCheckBox("Enable pose estimation") - self.enable_dlc_checkbox.setChecked(True) - form.addRow(self.enable_dlc_checkbox) + inference_buttons = QHBoxLayout() + self.start_inference_button = QPushButton("Start pose inference") + self.start_inference_button.setEnabled(False) + inference_buttons.addWidget(self.start_inference_button) + self.stop_inference_button = QPushButton("Stop pose inference") + self.stop_inference_button.setEnabled(False) + inference_buttons.addWidget(self.stop_inference_button) + form.addRow(inference_buttons) + + self.show_predictions_checkbox = QCheckBox("Display pose predictions") + self.show_predictions_checkbox.setChecked(True) + form.addRow(self.show_predictions_checkbox) return group @@ -236,19 +274,29 @@ def _connect_signals(self) -> None: self.stop_preview_button.clicked.connect(self._stop_preview) self.start_record_button.clicked.connect(self._start_recording) self.stop_record_button.clicked.connect(self._stop_recording) + self.refresh_cameras_button.clicked.connect( + lambda: self._refresh_camera_indices(keep_current=True) + ) + self.camera_backend.currentIndexChanged.connect(self._on_backend_changed) + self.camera_backend.editTextChanged.connect(self._on_backend_changed) + self.rotation_combo.currentIndexChanged.connect(self._on_rotation_changed) + self.start_inference_button.clicked.connect(self._start_inference) + self.stop_inference_button.clicked.connect(lambda: self._stop_inference()) + self.show_predictions_checkbox.stateChanged.connect( + self._on_show_predictions_changed + ) self.camera_controller.frame_ready.connect(self._on_frame_ready) self.camera_controller.error.connect(self._show_error) self.camera_controller.stopped.connect(self._on_camera_stopped) self.dlc_processor.pose_ready.connect(self._on_pose_ready) - self.dlc_processor.error.connect(self._show_error) + self.dlc_processor.error.connect(self._on_dlc_error) self.dlc_processor.initialized.connect(self._on_dlc_initialised) # ------------------------------------------------------------------ config def _apply_config(self, config: ApplicationSettings) -> None: camera = config.camera - self.camera_index.setCurrentText(str(camera.index)) self.camera_width.setValue(int(camera.width)) self.camera_height.setValue(int(camera.height)) self.camera_fps.setValue(float(camera.fps)) @@ -258,6 +306,10 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.camera_backend.setCurrentIndex(index) else: self.camera_backend.setEditText(backend_name) + self._refresh_camera_indices(keep_current=False) + self._select_camera_by_index( + camera.index, fallback_text=camera.name or str(camera.index) + ) self.camera_properties_edit.setPlainText( json.dumps(camera.properties, indent=2) if camera.properties else "" ) @@ -289,20 +341,14 @@ def _current_config(self) -> ApplicationSettings: ) def _camera_settings_from_ui(self) -> CameraSettings: - index_text = self.camera_index.currentText().strip() or "0" - try: - index = int(index_text) - except ValueError: - raise ValueError("Camera index must be an integer") from None - backend_data = self.camera_backend.currentData() - backend_text = ( - backend_data - if isinstance(backend_data, str) and backend_data - else self.camera_backend.currentText().strip() - ) + index = self._current_camera_index_value() + if index is None: + raise ValueError("Camera selection must provide a numeric index") + backend_text = self._current_backend_name() properties = self._parse_json(self.camera_properties_edit.toPlainText()) + name_text = self.camera_index.currentText().strip() return CameraSettings( - name=f"Camera {index}", + name=name_text or f"Camera {index}", index=index, width=self.camera_width.value(), height=self.camera_height.value(), @@ -311,6 +357,65 @@ def _camera_settings_from_ui(self) -> CameraSettings: properties=properties, ) + def _current_backend_name(self) -> str: + backend_data = self.camera_backend.currentData() + if isinstance(backend_data, str) and backend_data: + return backend_data + text = self.camera_backend.currentText().strip() + return text or "opencv" + + def _refresh_camera_indices( + self, *_args: object, keep_current: bool = True + ) -> None: + backend = self._current_backend_name() + detected = CameraFactory.detect_cameras(backend) + debug_info = [f"{camera.index}:{camera.label}" for camera in detected] + print( + f"[CameraDetection] Available cameras for backend '{backend}': {debug_info}" + ) + self._detected_cameras = detected + previous_index = self._current_camera_index_value() + previous_text = self.camera_index.currentText() + self.camera_index.blockSignals(True) + self.camera_index.clear() + for camera in detected: + self.camera_index.addItem(camera.label, camera.index) + if keep_current and previous_index is not None: + self._select_camera_by_index(previous_index, fallback_text=previous_text) + elif detected: + self.camera_index.setCurrentIndex(0) + else: + if keep_current and previous_text: + self.camera_index.setEditText(previous_text) + else: + self.camera_index.setEditText("") + self.camera_index.blockSignals(False) + + def _select_camera_by_index( + self, index: int, fallback_text: Optional[str] = None + ) -> None: + self.camera_index.blockSignals(True) + for row in range(self.camera_index.count()): + if self.camera_index.itemData(row) == index: + self.camera_index.setCurrentIndex(row) + break + else: + text = fallback_text if fallback_text is not None else str(index) + self.camera_index.setEditText(text) + self.camera_index.blockSignals(False) + + def _current_camera_index_value(self) -> Optional[int]: + data = self.camera_index.currentData() + if isinstance(data, int): + return data + text = self.camera_index.currentText().strip() + if not text: + return None + try: + return int(text) + except ValueError: + return None + def _parse_optional_int(self, value: str) -> Optional[int]: text = value.strip() if not text: @@ -402,6 +507,18 @@ def _action_browse_directory(self) -> None: if directory: self.output_directory_edit.setText(directory) + def _on_backend_changed(self, *_args: object) -> None: + self._refresh_camera_indices(keep_current=False) + + def _on_rotation_changed(self, _index: int) -> None: + data = self.rotation_combo.currentData() + self._rotation_degrees = int(data) if isinstance(data, int) else 0 + if self._raw_frame is not None: + rotated = self._apply_rotation(self._raw_frame) + self._current_frame = rotated + self._last_pose = None + self._update_video_display(rotated) + # ------------------------------------------------------------------ camera control def _start_preview(self) -> None: try: @@ -412,34 +529,77 @@ def _start_preview(self) -> None: self.camera_controller.start(settings) self.preview_button.setEnabled(False) self.stop_preview_button.setEnabled(True) + self._current_frame = None + self._raw_frame = None + self._last_pose = None + self._dlc_active = False self.statusBar().showMessage("Camera preview started", 3000) - if self.enable_dlc_checkbox.isChecked(): - self._configure_dlc() - else: - self._last_pose = None + self._update_inference_buttons() def _stop_preview(self) -> None: self.camera_controller.stop() + self._stop_inference(show_message=False) self.preview_button.setEnabled(True) self.stop_preview_button.setEnabled(False) self._current_frame = None + self._raw_frame = None self._last_pose = None self.video_label.setPixmap(QPixmap()) self.video_label.setText("Camera preview not started") self.statusBar().showMessage("Camera preview stopped", 3000) + self._update_inference_buttons() def _on_camera_stopped(self) -> None: self.preview_button.setEnabled(True) self.stop_preview_button.setEnabled(False) + self._stop_inference(show_message=False) + self._update_inference_buttons() - def _configure_dlc(self) -> None: + def _configure_dlc(self) -> bool: try: settings = self._dlc_settings_from_ui() except (ValueError, json.JSONDecodeError) as exc: self._show_error(f"Invalid DLCLive settings: {exc}") - self.enable_dlc_checkbox.setChecked(False) - return + return False + if not settings.model_path: + self._show_error("Please select a DLCLive model before starting inference.") + return False self.dlc_processor.configure(settings) + return True + + def _update_inference_buttons(self) -> None: + preview_running = self.camera_controller.is_running() + self.start_inference_button.setEnabled(preview_running and not self._dlc_active) + self.stop_inference_button.setEnabled(preview_running and self._dlc_active) + + def _start_inference(self) -> None: + if self._dlc_active: + self.statusBar().showMessage("Pose inference already running", 3000) + return + if not self.camera_controller.is_running(): + self._show_error( + "Start the camera preview before running pose inference." + ) + return + if not self._configure_dlc(): + self._update_inference_buttons() + return + self.dlc_processor.reset() + self._last_pose = None + self._dlc_active = True + self.statusBar().showMessage("Starting pose inference…", 3000) + self._update_inference_buttons() + + def _stop_inference(self, show_message: bool = True) -> None: + was_active = self._dlc_active + self._dlc_active = False + self.dlc_processor.reset() + self._last_pose = None + if self._current_frame is not None: + self._update_video_display(self._current_frame) + if was_active and show_message: + self.statusBar().showMessage("Pose inference stopped", 3000) + self._update_inference_buttons() # ------------------------------------------------------------------ recording def _start_recording(self) -> None: @@ -476,22 +636,34 @@ def _stop_recording(self) -> None: # ------------------------------------------------------------------ frame handling def _on_frame_ready(self, frame_data: FrameData) -> None: - frame = frame_data.image + raw_frame = frame_data.image + self._raw_frame = raw_frame + frame = self._apply_rotation(raw_frame) self._current_frame = frame if self._video_recorder and self._video_recorder.is_running: self._video_recorder.write(frame) - if self.enable_dlc_checkbox.isChecked(): + if self._dlc_active: self.dlc_processor.enqueue_frame(frame, frame_data.timestamp) self._update_video_display(frame) def _on_pose_ready(self, result: PoseResult) -> None: + if not self._dlc_active: + return self._last_pose = result if self._current_frame is not None: self._update_video_display(self._current_frame) + def _on_dlc_error(self, message: str) -> None: + self._stop_inference(show_message=False) + self._show_error(message) + def _update_video_display(self, frame: np.ndarray) -> None: display_frame = frame - if self._last_pose and self._last_pose.pose is not None: + if ( + self.show_predictions_checkbox.isChecked() + and self._last_pose + and self._last_pose.pose is not None + ): display_frame = self._draw_pose(frame, self._last_pose.pose) rgb = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape @@ -499,6 +671,19 @@ def _update_video_display(self, frame: np.ndarray) -> None: image = QImage(rgb.data, w, h, bytes_per_line, QImage.Format.Format_RGB888) self.video_label.setPixmap(QPixmap.fromImage(image)) + def _apply_rotation(self, frame: np.ndarray) -> np.ndarray: + if self._rotation_degrees == 90: + return cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) + if self._rotation_degrees == 180: + return cv2.rotate(frame, cv2.ROTATE_180) + if self._rotation_degrees == 270: + return cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE) + return frame + + def _on_show_predictions_changed(self, _state: int) -> None: + if self._current_frame is not None: + self._update_video_display(self._current_frame) + def _draw_pose(self, frame: np.ndarray, pose: np.ndarray) -> np.ndarray: overlay = frame.copy() for keypoint in np.asarray(pose): diff --git a/dlclivegui/video_recorder.py b/dlclivegui/video_recorder.py index e0e3706..3d15b1c 100644 --- a/dlclivegui/video_recorder.py +++ b/dlclivegui/video_recorder.py @@ -32,7 +32,7 @@ def start(self) -> None: if self._writer is not None: return self._output.parent.mkdir(parents=True, exist_ok=True) - self._writer = WriteGear(output_filename=str(self._output), logging=False, **self._options) + self._writer = WriteGear(output=str(self._output), logging=False, **self._options) def write(self, frame: np.ndarray) -> None: if self._writer is None: From 0b0ac3308e3d70d3a0f22353ed06cdc64d356448 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Tue, 21 Oct 2025 14:57:43 +0200 Subject: [PATCH 03/69] modifyed gentl_backend --- dlclivegui/cameras/gentl_backend.py | 380 +++++++++++++++++++++------- dlclivegui/gui.py | 10 +- 2 files changed, 294 insertions(+), 96 deletions(-) diff --git a/dlclivegui/cameras/gentl_backend.py b/dlclivegui/cameras/gentl_backend.py index 0d81294..bdbc4e8 100644 --- a/dlclivegui/cameras/gentl_backend.py +++ b/dlclivegui/cameras/gentl_backend.py @@ -1,130 +1,328 @@ -"""Generic GenTL backend implemented with Aravis.""" +"""GenTL backend implemented using the Harvesters library.""" from __future__ import annotations -import ctypes +import glob +import os import time -from typing import Optional, Tuple +from typing import Iterable, List, Optional, Tuple +import cv2 import numpy as np from .base import CameraBackend try: # pragma: no cover - optional dependency - import gi - - gi.require_version("Aravis", "0.6") - from gi.repository import Aravis + from harvesters.core import Harvester except Exception: # pragma: no cover - optional dependency - gi = None # type: ignore - Aravis = None # type: ignore + Harvester = None # type: ignore class GenTLCameraBackend(CameraBackend): - """Capture frames from cameras that expose a GenTL interface.""" + """Capture frames from GenTL-compatible devices via Harvesters.""" + + _DEFAULT_CTI_PATTERNS: Tuple[str, ...] = ( + r"C:\\Program Files\\The Imaging Source Europe GmbH\\IC4 GenTL Driver for USB3Vision Devices *\\bin\\*.cti", + r"C:\\Program Files\\The Imaging Source Europe GmbH\\TIS Grabber\\bin\\win64_x64\\*.cti", + r"C:\\Program Files\\The Imaging Source Europe GmbH\\TIS Camera SDK\\bin\\win64_x64\\*.cti", + r"C:\\Program Files (x86)\\The Imaging Source Europe GmbH\\TIS Grabber\\bin\\win64_x64\\*.cti", + ) def __init__(self, settings): super().__init__(settings) - self._camera = None - self._stream = None - self._payload: Optional[int] = None + props = settings.properties + self._cti_file: Optional[str] = props.get("cti_file") + self._serial_number: Optional[str] = props.get("serial_number") or props.get("serial") + self._pixel_format: str = props.get("pixel_format", "Mono8") + self._rotate: int = int(props.get("rotate", 0)) % 360 + self._crop: Optional[Tuple[int, int, int, int]] = self._parse_crop(props.get("crop")) + self._exposure: Optional[float] = props.get("exposure") + self._gain: Optional[float] = props.get("gain") + self._timeout: float = float(props.get("timeout", 2.0)) + self._cti_search_paths: Tuple[str, ...] = self._parse_cti_paths(props.get("cti_search_paths")) + + self._harvester: Optional[Harvester] = None + self._acquirer = None @classmethod def is_available(cls) -> bool: - return Aravis is not None + return Harvester is not None def open(self) -> None: - if Aravis is None: # pragma: no cover - optional dependency + if Harvester is None: # pragma: no cover - optional dependency raise RuntimeError( - "Aravis (python-gi bindings) are required for the GenTL backend" + "The 'harvesters' package is required for the GenTL backend. " + "Install it via 'pip install harvesters'." ) - Aravis.update_device_list() - num_devices = Aravis.get_n_devices() - if num_devices == 0: - raise RuntimeError("No GenTL cameras detected") - device_id = self._select_device_id(num_devices) - self._camera = Aravis.Camera.new(device_id) - self._camera.set_exposure_time_auto(0) - self._camera.set_gain_auto(0) - exposure = self.settings.properties.get("exposure") - if exposure is not None: - self._set_exposure(float(exposure)) - crop = self.settings.properties.get("crop") - if isinstance(crop, (list, tuple)) and len(crop) == 4: - self._set_crop(crop) - if self.settings.fps: - try: - self._camera.set_frame_rate(float(self.settings.fps)) - except Exception: - pass - self._stream = self._camera.create_stream() - self._payload = self._camera.get_payload() - self._stream.push_buffer(Aravis.Buffer.new_allocate(self._payload)) - self._camera.start_acquisition() + + self._harvester = Harvester() + cti_file = self._cti_file or self._find_cti_file() + self._harvester.add_file(cti_file) + self._harvester.update() + + if not self._harvester.device_info_list: + raise RuntimeError("No GenTL cameras detected via Harvesters") + + serial = self._serial_number + index = int(self.settings.index or 0) + if serial: + available = self._available_serials() + matches = [s for s in available if serial in s] + if not matches: + raise RuntimeError( + f"Camera with serial '{serial}' not found. Available cameras: {available}" + ) + serial = matches[0] + else: + device_count = len(self._harvester.device_info_list) + if index < 0 or index >= device_count: + raise RuntimeError( + f"Camera index {index} out of range for {device_count} GenTL device(s)" + ) + + self._acquirer = self._create_acquirer(serial, index) + + remote = self._acquirer.remote_device + node_map = remote.node_map + + self._configure_pixel_format(node_map) + self._configure_resolution(node_map) + self._configure_exposure(node_map) + self._configure_gain(node_map) + self._configure_frame_rate(node_map) + + self._acquirer.start() def read(self) -> Tuple[np.ndarray, float]: - if self._stream is None: - raise RuntimeError("GenTL stream not initialised") - buffer = None - while buffer is None: - buffer = self._stream.try_pop_buffer() - if buffer is None: - time.sleep(0.01) - frame = self._buffer_to_numpy(buffer) - self._stream.push_buffer(buffer) - return frame, time.time() + if self._acquirer is None: + raise RuntimeError("GenTL image acquirer not initialised") - def close(self) -> None: - if self._camera is not None: + with self._acquirer.fetch(timeout=self._timeout) as buffer: + component = buffer.payload.components[0] + channels = 3 if self._pixel_format in {"RGB8", "BGR8"} else 1 + if channels > 1: + frame = component.data.reshape( + component.height, component.width, channels + ).copy() + else: + frame = component.data.reshape(component.height, component.width).copy() + + frame = self._convert_frame(frame) + timestamp = time.time() + return frame, timestamp + + def stop(self) -> None: + if self._acquirer is not None: try: - self._camera.stop_acquisition() + self._acquirer.stop() except Exception: pass - self._camera = None - self._stream = None - self._payload = None - def stop(self) -> None: - if self._camera is not None: + def close(self) -> None: + if self._acquirer is not None: try: - self._camera.stop_acquisition() + self._acquirer.stop() except Exception: pass + try: + destroy = getattr(self._acquirer, "destroy", None) + if destroy is not None: + destroy() + finally: + self._acquirer = None - def _select_device_id(self, num_devices: int) -> str: - index = int(self.settings.index) - if index < 0 or index >= num_devices: - raise RuntimeError( - f"Camera index {index} out of range for {num_devices} GenTL device(s)" + if self._harvester is not None: + try: + self._harvester.reset() + finally: + self._harvester = None + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _parse_cti_paths(self, value) -> Tuple[str, ...]: + if value is None: + return self._DEFAULT_CTI_PATTERNS + if isinstance(value, str): + return (value,) + if isinstance(value, Iterable): + return tuple(str(item) for item in value) + return self._DEFAULT_CTI_PATTERNS + + def _parse_crop(self, crop) -> Optional[Tuple[int, int, int, int]]: + if isinstance(crop, (list, tuple)) and len(crop) == 4: + return tuple(int(v) for v in crop) + return None + + def _find_cti_file(self) -> str: + patterns: List[str] = list(self._cti_search_paths) + for pattern in patterns: + for file_path in glob.glob(pattern): + if os.path.isfile(file_path): + return file_path + raise RuntimeError( + "Could not locate a GenTL producer (.cti) file. Set 'cti_file' in " + "camera.properties or provide search paths via 'cti_search_paths'." + ) + + def _available_serials(self) -> List[str]: + assert self._harvester is not None + serials: List[str] = [] + for info in self._harvester.device_info_list: + serial = getattr(info, "serial_number", "") + if serial: + serials.append(serial) + return serials + + def _create_acquirer(self, serial: Optional[str], index: int): + assert self._harvester is not None + methods = [ + getattr(self._harvester, "create_image_acquirer", None), + getattr(self._harvester, "create", None), + ] + methods = [m for m in methods if m is not None] + errors: List[str] = [] + device_info = None + if not serial: + device_list = self._harvester.device_info_list + if 0 <= index < len(device_list): + device_info = device_list[index] + for create in methods: + try: + if serial: + return create({"serial_number": serial}) + except Exception as exc: + errors.append(f"{create.__name__} serial: {exc}") + for create in methods: + try: + return create(index=index) + except TypeError: + try: + return create(index) + except Exception as exc: + errors.append(f"{create.__name__} index positional: {exc}") + except Exception as exc: + errors.append(f"{create.__name__} index: {exc}") + if device_info is not None: + for create in methods: + try: + return create(device_info) + except Exception as exc: + errors.append(f"{create.__name__} device_info: {exc}") + if not serial and index == 0: + for create in methods: + try: + return create() + except Exception as exc: + errors.append(f"{create.__name__} default: {exc}") + joined = "; ".join(errors) or "no creation methods available" + raise RuntimeError(f"Failed to initialise GenTL image acquirer ({joined})") + + def _configure_pixel_format(self, node_map) -> None: + try: + if self._pixel_format in node_map.PixelFormat.symbolics: + node_map.PixelFormat.value = self._pixel_format + except Exception: + pass + + def _configure_resolution(self, node_map) -> None: + width = int(self.settings.width) + height = int(self.settings.height) + if self._rotate in (90, 270): + width, height = height, width + try: + node_map.Width.value = self._adjust_to_increment( + width, node_map.Width.min, node_map.Width.max, node_map.Width.inc ) - return Aravis.get_device_id(index) + except Exception: + pass + try: + node_map.Height.value = self._adjust_to_increment( + height, node_map.Height.min, node_map.Height.max, node_map.Height.inc + ) + except Exception: + pass - def _set_exposure(self, exposure: float) -> None: - if self._camera is None: + def _configure_exposure(self, node_map) -> None: + if self._exposure is None: return - exposure = max(0.0, min(exposure, 1.0)) - self._camera.set_exposure_time(exposure * 1e6) + for attr in ("ExposureAuto", "ExposureTime", "Exposure"): + try: + node = getattr(node_map, attr) + except AttributeError: + continue + try: + if attr == "ExposureAuto": + node.value = "Off" + else: + node.value = float(self._exposure) + return + except Exception: + continue - def _set_crop(self, crop) -> None: - if self._camera is None: + def _configure_gain(self, node_map) -> None: + if self._gain is None: return - left, right, top, bottom = map(int, crop) - width = right - left - height = bottom - top - self._camera.set_region(left, top, width, height) - - def _buffer_to_numpy(self, buffer) -> np.ndarray: - pixel_format = buffer.get_image_pixel_format() - bits_per_pixel = (pixel_format >> 16) & 0xFF - if bits_per_pixel == 8: - int_pointer = ctypes.POINTER(ctypes.c_uint8) - else: - int_pointer = ctypes.POINTER(ctypes.c_uint16) - addr = buffer.get_data() - ptr = ctypes.cast(addr, int_pointer) - frame = np.ctypeslib.as_array(ptr, (buffer.get_image_height(), buffer.get_image_width())) - frame = frame.copy() - if frame.ndim < 3: - import cv2 - - frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2RGB) - return frame + for attr in ("GainAuto", "Gain"): + try: + node = getattr(node_map, attr) + except AttributeError: + continue + try: + if attr == "GainAuto": + node.value = "Off" + else: + node.value = float(self._gain) + return + except Exception: + continue + + def _configure_frame_rate(self, node_map) -> None: + if not self.settings.fps: + return + try: + node_map.AcquisitionFrameRateEnable.value = True + except Exception: + pass + try: + node_map.AcquisitionFrameRate.value = float(self.settings.fps) + except Exception: + pass + + @staticmethod + def _adjust_to_increment(value: int, minimum: int, maximum: int, increment: int) -> int: + value = max(minimum, min(maximum, value)) + if increment <= 0: + return value + return minimum + ((value - minimum) // increment) * increment + + def _convert_frame(self, frame: np.ndarray) -> np.ndarray: + result = frame.astype(np.float32 if frame.dtype == np.float64 else frame.dtype) + if result.dtype != np.uint8: + max_val = np.max(result) + if max_val > 0: + result = (result / max_val * 255.0).astype(np.uint8) + else: + result = np.zeros_like(result, dtype=np.uint8) + if result.ndim == 2: + result = cv2.cvtColor(result, cv2.COLOR_GRAY2BGR) + elif result.ndim == 3 and result.shape[2] == 3 and self._pixel_format == "RGB8": + result = cv2.cvtColor(result, cv2.COLOR_RGB2BGR) + + if self._rotate == 90: + result = cv2.rotate(result, cv2.ROTATE_90_CLOCKWISE) + elif self._rotate == 180: + result = cv2.rotate(result, cv2.ROTATE_180) + elif self._rotate == 270: + result = cv2.rotate(result, cv2.ROTATE_90_COUNTERCLOCKWISE) + + if self._crop is not None: + top, bottom, left, right = self._crop + height, width = result.shape[:2] + top = max(0, min(height, top)) + bottom = max(top, min(height, bottom)) + left = max(0, min(width, left)) + right = max(left, min(width, right)) + result = result[top:bottom, left:right] + + return result.copy() diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 4eb3971..67baded 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -31,17 +31,17 @@ QWidget, ) -from .camera_controller import CameraController, FrameData -from .cameras import CameraFactory -from .config import ( +from dlclivegui.camera_controller import CameraController, FrameData +from dlclivegui.cameras import CameraFactory +from dlclivegui.config import ( ApplicationSettings, CameraSettings, DLCProcessorSettings, RecordingSettings, DEFAULT_CONFIG, ) -from .dlc_processor import DLCLiveProcessor, PoseResult -from .video_recorder import VideoRecorder +from dlclivegui.dlc_processor import DLCLiveProcessor, PoseResult +from dlclivegui.video_recorder import VideoRecorder class MainWindow(QMainWindow): From 8110085653a1e998939712459ab0961c6ce2174b Mon Sep 17 00:00:00 2001 From: Artur <35294812+arturoptophys@users.noreply.github.com> Date: Tue, 21 Oct 2025 16:12:25 +0200 Subject: [PATCH 04/69] Improve camera control flow and recording alignment --- dlclivegui/camera_controller.py | 18 +- dlclivegui/cameras/base.py | 5 + dlclivegui/cameras/factory.py | 62 ++++- dlclivegui/cameras/opencv_backend.py | 16 ++ dlclivegui/dlc_processor.py | 20 ++ dlclivegui/gui.py | 338 +++++++++++++++++++++++---- dlclivegui/video_recorder.py | 25 +- 7 files changed, 425 insertions(+), 59 deletions(-) diff --git a/dlclivegui/camera_controller.py b/dlclivegui/camera_controller.py index 3398566..2fb706e 100644 --- a/dlclivegui/camera_controller.py +++ b/dlclivegui/camera_controller.py @@ -1,12 +1,12 @@ """Camera management for the DLC Live GUI.""" from __future__ import annotations -import time from dataclasses import dataclass +from threading import Event from typing import Optional import numpy as np -from PyQt6.QtCore import QMetaObject, QObject, QThread, Qt, pyqtSignal, pyqtSlot +from PyQt6.QtCore import QObject, QThread, QMetaObject, Qt, pyqtSignal, pyqtSlot from .cameras import CameraFactory from .cameras.base import CameraBackend @@ -31,12 +31,12 @@ class CameraWorker(QObject): def __init__(self, settings: CameraSettings): super().__init__() self._settings = settings - self._running = False + self._stop_event = Event() self._backend: Optional[CameraBackend] = None @pyqtSlot() def run(self) -> None: - self._running = True + self._stop_event.clear() try: self._backend = CameraFactory.create(self._settings) self._backend.open() @@ -45,7 +45,7 @@ def run(self) -> None: self.finished.emit() return - while self._running: + while not self._stop_event.is_set(): try: frame, timestamp = self._backend.read() except Exception as exc: # pragma: no cover - device specific @@ -63,7 +63,7 @@ def run(self) -> None: @pyqtSlot() def stop(self) -> None: - self._running = False + self._stop_event.set() if self._backend is not None: try: self._backend.stop() @@ -106,10 +106,12 @@ def stop(self) -> None: if not self.is_running(): return assert self._worker is not None + assert self._thread is not None QMetaObject.invokeMethod( - self._worker, "stop", Qt.ConnectionType.QueuedConnection + self._worker, + "stop", + Qt.ConnectionType.QueuedConnection, ) - assert self._thread is not None self._thread.quit() self._thread.wait() diff --git a/dlclivegui/cameras/base.py b/dlclivegui/cameras/base.py index 6ae79dc..910331c 100644 --- a/dlclivegui/cameras/base.py +++ b/dlclivegui/cameras/base.py @@ -33,6 +33,11 @@ def stop(self) -> None: # Most backends do not require additional handling, but subclasses may # override when they need to interrupt blocking reads. + def device_name(self) -> str: + """Return a human readable name for the device currently in use.""" + + return self.settings.name + @abstractmethod def open(self) -> None: """Open the capture device.""" diff --git a/dlclivegui/cameras/factory.py b/dlclivegui/cameras/factory.py index e9704ef..c67f7bd 100644 --- a/dlclivegui/cameras/factory.py +++ b/dlclivegui/cameras/factory.py @@ -2,12 +2,21 @@ from __future__ import annotations import importlib -from typing import Dict, Iterable, Tuple, Type +from dataclasses import dataclass +from typing import Dict, Iterable, List, Tuple, Type from ..config import CameraSettings from .base import CameraBackend +@dataclass +class DetectedCamera: + """Information about a camera discovered during probing.""" + + index: int + label: str + + _BACKENDS: Dict[str, Tuple[str, str]] = { "opencv": ("dlclivegui.cameras.opencv_backend", "OpenCVCameraBackend"), "basler": ("dlclivegui.cameras.basler_backend", "BaslerCameraBackend"), @@ -38,6 +47,57 @@ def available_backends() -> Dict[str, bool]: availability[name] = backend_cls.is_available() return availability + @staticmethod + def detect_cameras(backend: str, max_devices: int = 10) -> List[DetectedCamera]: + """Probe ``backend`` for available cameras. + + Parameters + ---------- + backend: + The backend identifier, e.g. ``"opencv"``. + max_devices: + Upper bound for the indices that should be probed. + + Returns + ------- + list of :class:`DetectedCamera` + Sorted list of detected cameras with human readable labels. + """ + + try: + backend_cls = CameraFactory._resolve_backend(backend) + except RuntimeError: + return [] + if not backend_cls.is_available(): + return [] + + detected: List[DetectedCamera] = [] + for index in range(max_devices): + settings = CameraSettings( + name=f"Probe {index}", + index=index, + width=640, + height=480, + fps=30.0, + backend=backend, + properties={}, + ) + backend_instance = backend_cls(settings) + try: + backend_instance.open() + except Exception: + continue + else: + label = backend_instance.device_name() + detected.append(DetectedCamera(index=index, label=label)) + finally: + try: + backend_instance.close() + except Exception: + pass + detected.sort(key=lambda camera: camera.index) + return detected + @staticmethod def create(settings: CameraSettings) -> CameraBackend: """Instantiate a backend for ``settings``.""" diff --git a/dlclivegui/cameras/opencv_backend.py b/dlclivegui/cameras/opencv_backend.py index a64e3f1..8497bfa 100644 --- a/dlclivegui/cameras/opencv_backend.py +++ b/dlclivegui/cameras/opencv_backend.py @@ -39,6 +39,22 @@ def close(self) -> None: self._capture.release() self._capture = None + def stop(self) -> None: + if self._capture is not None: + self._capture.release() + self._capture = None + + def device_name(self) -> str: + base_name = "OpenCV" + if self._capture is not None and hasattr(self._capture, "getBackendName"): + try: + backend_name = self._capture.getBackendName() + except Exception: # pragma: no cover - backend specific + backend_name = "" + if backend_name: + base_name = backend_name + return f"{base_name} camera #{self.settings.index}" + def _configure_capture(self) -> None: if self._capture is None: return diff --git a/dlclivegui/dlc_processor.py b/dlclivegui/dlc_processor.py index 5c199b8..0e1ef3e 100644 --- a/dlclivegui/dlc_processor.py +++ b/dlclivegui/dlc_processor.py @@ -45,6 +45,18 @@ def __init__(self) -> None: def configure(self, settings: DLCProcessorSettings) -> None: self._settings = settings + def reset(self) -> None: + """Cancel pending work and drop the current DLCLive instance.""" + + with self._lock: + if self._pending is not None and not self._pending.done(): + self._pending.cancel() + self._pending = None + if self._init_future is not None and not self._init_future.done(): + self._init_future.cancel() + self._init_future = None + self._dlc = None + def shutdown(self) -> None: with self._lock: if self._pending is not None: @@ -106,6 +118,10 @@ def _on_initialised(self, future: Future[Any]) -> None: except Exception as exc: # pragma: no cover - runtime behaviour LOGGER.exception("Failed to initialise DLCLive", exc_info=exc) self.error.emit(str(exc)) + finally: + with self._lock: + if self._init_future is future: + self._init_future = None def _run_inference(self, frame: np.ndarray, timestamp: float) -> PoseResult: if self._dlc is None: @@ -120,4 +136,8 @@ def _on_pose_ready(self, future: Future[Any]) -> None: LOGGER.exception("Pose inference failed", exc_info=exc) self.error.emit(str(exc)) return + finally: + with self._lock: + if self._pending is future: + self._pending = None self.pose_ready.emit(result) diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 4eb3971..507b199 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -24,6 +24,7 @@ QMessageBox, QPlainTextEdit, QPushButton, + QSizePolicy, QSpinBox, QDoubleSpinBox, QStatusBar, @@ -33,6 +34,7 @@ from .camera_controller import CameraController, FrameData from .cameras import CameraFactory +from .cameras.factory import DetectedCamera from .config import ( ApplicationSettings, CameraSettings, @@ -53,8 +55,13 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._config = config or DEFAULT_CONFIG self._config_path: Optional[Path] = None self._current_frame: Optional[np.ndarray] = None + self._raw_frame: Optional[np.ndarray] = None self._last_pose: Optional[PoseResult] = None + self._dlc_active: bool = False self._video_recorder: Optional[VideoRecorder] = None + self._rotation_degrees: int = 0 + self._detected_cameras: list[DetectedCamera] = [] + self._active_camera_settings: Optional[CameraSettings] = None self.camera_controller = CameraController() self.dlc_processor = DLCLiveProcessor() @@ -62,20 +69,26 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._setup_ui() self._connect_signals() self._apply_config(self._config) + self._update_inference_buttons() + self._update_camera_controls_enabled() # ------------------------------------------------------------------ UI def _setup_ui(self) -> None: central = QWidget() - layout = QVBoxLayout(central) + layout = QHBoxLayout(central) self.video_label = QLabel("Camera preview not started") self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.video_label.setMinimumSize(640, 360) - layout.addWidget(self.video_label) + self.video_label.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) - layout.addWidget(self._build_camera_group()) - layout.addWidget(self._build_dlc_group()) - layout.addWidget(self._build_recording_group()) + controls_widget = QWidget() + controls_layout = QVBoxLayout(controls_widget) + controls_layout.addWidget(self._build_camera_group()) + controls_layout.addWidget(self._build_dlc_group()) + controls_layout.addWidget(self._build_recording_group()) button_bar = QHBoxLayout() self.preview_button = QPushButton("Start Preview") @@ -83,7 +96,15 @@ def _setup_ui(self) -> None: self.stop_preview_button.setEnabled(False) button_bar.addWidget(self.preview_button) button_bar.addWidget(self.stop_preview_button) - layout.addLayout(button_bar) + controls_layout.addLayout(button_bar) + controls_layout.addStretch(1) + + preview_layout = QVBoxLayout() + preview_layout.addWidget(self.video_label) + preview_layout.addStretch(1) + + layout.addWidget(controls_widget) + layout.addLayout(preview_layout, stretch=1) self.setCentralWidget(central) self.setStatusBar(QStatusBar()) @@ -113,10 +134,23 @@ def _build_camera_group(self) -> QGroupBox: group = QGroupBox("Camera settings") form = QFormLayout(group) + self.camera_backend = QComboBox() + self.camera_backend.setEditable(True) + availability = CameraFactory.available_backends() + for backend in CameraFactory.backend_names(): + label = backend + if not availability.get(backend, True): + label = f"{backend} (unavailable)" + self.camera_backend.addItem(label, backend) + form.addRow("Backend", self.camera_backend) + + index_layout = QHBoxLayout() self.camera_index = QComboBox() self.camera_index.setEditable(True) - self.camera_index.addItems([str(i) for i in range(5)]) - form.addRow("Camera index", self.camera_index) + index_layout.addWidget(self.camera_index) + self.refresh_cameras_button = QPushButton("Refresh") + index_layout.addWidget(self.refresh_cameras_button) + form.addRow("Camera", index_layout) self.camera_width = QSpinBox() self.camera_width.setRange(1, 7680) @@ -131,16 +165,6 @@ def _build_camera_group(self) -> QGroupBox: self.camera_fps.setDecimals(2) form.addRow("Frame rate", self.camera_fps) - self.camera_backend = QComboBox() - self.camera_backend.setEditable(True) - availability = CameraFactory.available_backends() - for backend in CameraFactory.backend_names(): - label = backend - if not availability.get(backend, True): - label = f"{backend} (unavailable)" - self.camera_backend.addItem(label, backend) - form.addRow("Backend", self.camera_backend) - self.camera_properties_edit = QPlainTextEdit() self.camera_properties_edit.setPlaceholderText( '{"exposure": 15000, "gain": 0.5, "serial": "123456"}' @@ -148,6 +172,13 @@ def _build_camera_group(self) -> QGroupBox: self.camera_properties_edit.setFixedHeight(60) form.addRow("Advanced properties", self.camera_properties_edit) + self.rotation_combo = QComboBox() + self.rotation_combo.addItem("0° (default)", 0) + self.rotation_combo.addItem("90°", 90) + self.rotation_combo.addItem("180°", 180) + self.rotation_combo.addItem("270°", 270) + form.addRow("Rotation", self.rotation_combo) + return group def _build_dlc_group(self) -> QGroupBox: @@ -185,9 +216,18 @@ def _build_dlc_group(self) -> QGroupBox: self.additional_options_edit.setFixedHeight(60) form.addRow("Additional options", self.additional_options_edit) - self.enable_dlc_checkbox = QCheckBox("Enable pose estimation") - self.enable_dlc_checkbox.setChecked(True) - form.addRow(self.enable_dlc_checkbox) + inference_buttons = QHBoxLayout() + self.start_inference_button = QPushButton("Start pose inference") + self.start_inference_button.setEnabled(False) + inference_buttons.addWidget(self.start_inference_button) + self.stop_inference_button = QPushButton("Stop pose inference") + self.stop_inference_button.setEnabled(False) + inference_buttons.addWidget(self.stop_inference_button) + form.addRow(inference_buttons) + + self.show_predictions_checkbox = QCheckBox("Display pose predictions") + self.show_predictions_checkbox.setChecked(True) + form.addRow(self.show_predictions_checkbox) return group @@ -236,19 +276,29 @@ def _connect_signals(self) -> None: self.stop_preview_button.clicked.connect(self._stop_preview) self.start_record_button.clicked.connect(self._start_recording) self.stop_record_button.clicked.connect(self._stop_recording) + self.refresh_cameras_button.clicked.connect( + lambda: self._refresh_camera_indices(keep_current=True) + ) + self.camera_backend.currentIndexChanged.connect(self._on_backend_changed) + self.camera_backend.editTextChanged.connect(self._on_backend_changed) + self.rotation_combo.currentIndexChanged.connect(self._on_rotation_changed) + self.start_inference_button.clicked.connect(self._start_inference) + self.stop_inference_button.clicked.connect(lambda: self._stop_inference()) + self.show_predictions_checkbox.stateChanged.connect( + self._on_show_predictions_changed + ) self.camera_controller.frame_ready.connect(self._on_frame_ready) self.camera_controller.error.connect(self._show_error) self.camera_controller.stopped.connect(self._on_camera_stopped) self.dlc_processor.pose_ready.connect(self._on_pose_ready) - self.dlc_processor.error.connect(self._show_error) + self.dlc_processor.error.connect(self._on_dlc_error) self.dlc_processor.initialized.connect(self._on_dlc_initialised) # ------------------------------------------------------------------ config def _apply_config(self, config: ApplicationSettings) -> None: camera = config.camera - self.camera_index.setCurrentText(str(camera.index)) self.camera_width.setValue(int(camera.width)) self.camera_height.setValue(int(camera.height)) self.camera_fps.setValue(float(camera.fps)) @@ -258,9 +308,14 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.camera_backend.setCurrentIndex(index) else: self.camera_backend.setEditText(backend_name) + self._refresh_camera_indices(keep_current=False) + self._select_camera_by_index( + camera.index, fallback_text=camera.name or str(camera.index) + ) self.camera_properties_edit.setPlainText( json.dumps(camera.properties, indent=2) if camera.properties else "" ) + self._active_camera_settings = None dlc = config.dlc self.model_path_edit.setText(dlc.model_path) @@ -289,20 +344,14 @@ def _current_config(self) -> ApplicationSettings: ) def _camera_settings_from_ui(self) -> CameraSettings: - index_text = self.camera_index.currentText().strip() or "0" - try: - index = int(index_text) - except ValueError: - raise ValueError("Camera index must be an integer") from None - backend_data = self.camera_backend.currentData() - backend_text = ( - backend_data - if isinstance(backend_data, str) and backend_data - else self.camera_backend.currentText().strip() - ) + index = self._current_camera_index_value() + if index is None: + raise ValueError("Camera selection must provide a numeric index") + backend_text = self._current_backend_name() properties = self._parse_json(self.camera_properties_edit.toPlainText()) - return CameraSettings( - name=f"Camera {index}", + name_text = self.camera_index.currentText().strip() + settings = CameraSettings( + name=name_text or f"Camera {index}", index=index, width=self.camera_width.value(), height=self.camera_height.value(), @@ -310,6 +359,66 @@ def _camera_settings_from_ui(self) -> CameraSettings: backend=backend_text or "opencv", properties=properties, ) + return settings.apply_defaults() + + def _current_backend_name(self) -> str: + backend_data = self.camera_backend.currentData() + if isinstance(backend_data, str) and backend_data: + return backend_data + text = self.camera_backend.currentText().strip() + return text or "opencv" + + def _refresh_camera_indices( + self, *_args: object, keep_current: bool = True + ) -> None: + backend = self._current_backend_name() + detected = CameraFactory.detect_cameras(backend) + debug_info = [f"{camera.index}:{camera.label}" for camera in detected] + print( + f"[CameraDetection] Available cameras for backend '{backend}': {debug_info}" + ) + self._detected_cameras = detected + previous_index = self._current_camera_index_value() + previous_text = self.camera_index.currentText() + self.camera_index.blockSignals(True) + self.camera_index.clear() + for camera in detected: + self.camera_index.addItem(camera.label, camera.index) + if keep_current and previous_index is not None: + self._select_camera_by_index(previous_index, fallback_text=previous_text) + elif detected: + self.camera_index.setCurrentIndex(0) + else: + if keep_current and previous_text: + self.camera_index.setEditText(previous_text) + else: + self.camera_index.setEditText("") + self.camera_index.blockSignals(False) + + def _select_camera_by_index( + self, index: int, fallback_text: Optional[str] = None + ) -> None: + self.camera_index.blockSignals(True) + for row in range(self.camera_index.count()): + if self.camera_index.itemData(row) == index: + self.camera_index.setCurrentIndex(row) + break + else: + text = fallback_text if fallback_text is not None else str(index) + self.camera_index.setEditText(text) + self.camera_index.blockSignals(False) + + def _current_camera_index_value(self) -> Optional[int]: + data = self.camera_index.currentData() + if isinstance(data, int): + return data + text = self.camera_index.currentText().strip() + if not text: + return None + try: + return int(text) + except ValueError: + return None def _parse_optional_int(self, value: str) -> Optional[int]: text = value.strip() @@ -402,6 +511,18 @@ def _action_browse_directory(self) -> None: if directory: self.output_directory_edit.setText(directory) + def _on_backend_changed(self, *_args: object) -> None: + self._refresh_camera_indices(keep_current=False) + + def _on_rotation_changed(self, _index: int) -> None: + data = self.rotation_combo.currentData() + self._rotation_degrees = int(data) if isinstance(data, int) else 0 + if self._raw_frame is not None: + rotated = self._apply_rotation(self._raw_frame) + self._current_frame = rotated + self._last_pose = None + self._update_video_display(rotated) + # ------------------------------------------------------------------ camera control def _start_preview(self) -> None: try: @@ -409,42 +530,121 @@ def _start_preview(self) -> None: except ValueError as exc: self._show_error(str(exc)) return + self._active_camera_settings = settings self.camera_controller.start(settings) self.preview_button.setEnabled(False) self.stop_preview_button.setEnabled(True) + self._current_frame = None + self._raw_frame = None + self._last_pose = None + self._dlc_active = False self.statusBar().showMessage("Camera preview started", 3000) - if self.enable_dlc_checkbox.isChecked(): - self._configure_dlc() - else: - self._last_pose = None + self._update_inference_buttons() + self._update_camera_controls_enabled() def _stop_preview(self) -> None: self.camera_controller.stop() + self._stop_inference(show_message=False) self.preview_button.setEnabled(True) self.stop_preview_button.setEnabled(False) self._current_frame = None + self._raw_frame = None self._last_pose = None + self._active_camera_settings = None self.video_label.setPixmap(QPixmap()) self.video_label.setText("Camera preview not started") self.statusBar().showMessage("Camera preview stopped", 3000) + self._update_inference_buttons() + self._update_camera_controls_enabled() def _on_camera_stopped(self) -> None: self.preview_button.setEnabled(True) self.stop_preview_button.setEnabled(False) + self._stop_inference(show_message=False) + self._update_inference_buttons() + self._active_camera_settings = None + self._update_camera_controls_enabled() - def _configure_dlc(self) -> None: + def _configure_dlc(self) -> bool: try: settings = self._dlc_settings_from_ui() except (ValueError, json.JSONDecodeError) as exc: self._show_error(f"Invalid DLCLive settings: {exc}") - self.enable_dlc_checkbox.setChecked(False) - return + return False + if not settings.model_path: + self._show_error("Please select a DLCLive model before starting inference.") + return False self.dlc_processor.configure(settings) + return True + + def _update_inference_buttons(self) -> None: + preview_running = self.camera_controller.is_running() + self.start_inference_button.setEnabled(preview_running and not self._dlc_active) + self.stop_inference_button.setEnabled(preview_running and self._dlc_active) + + def _update_camera_controls_enabled(self) -> None: + recording_active = ( + self._video_recorder is not None and self._video_recorder.is_running + ) + allow_changes = ( + not self.camera_controller.is_running() + and not self._dlc_active + and not recording_active + ) + widgets = [ + self.camera_backend, + self.camera_index, + self.refresh_cameras_button, + self.camera_width, + self.camera_height, + self.camera_fps, + self.camera_properties_edit, + self.rotation_combo, + ] + for widget in widgets: + widget.setEnabled(allow_changes) + + def _start_inference(self) -> None: + if self._dlc_active: + self.statusBar().showMessage("Pose inference already running", 3000) + return + if not self.camera_controller.is_running(): + self._show_error( + "Start the camera preview before running pose inference." + ) + return + if not self._configure_dlc(): + self._update_inference_buttons() + return + self.dlc_processor.reset() + self._last_pose = None + self._dlc_active = True + self.statusBar().showMessage("Starting pose inference…", 3000) + self._update_inference_buttons() + self._update_camera_controls_enabled() + + def _stop_inference(self, show_message: bool = True) -> None: + was_active = self._dlc_active + self._dlc_active = False + self.dlc_processor.reset() + self._last_pose = None + if self._current_frame is not None: + self._update_video_display(self._current_frame) + if was_active and show_message: + self.statusBar().showMessage("Pose inference stopped", 3000) + self._update_inference_buttons() + self._update_camera_controls_enabled() # ------------------------------------------------------------------ recording def _start_recording(self) -> None: if self._video_recorder and self._video_recorder.is_running: return + if not self.camera_controller.is_running(): + self._show_error("Start the camera preview before recording.") + return + if self._current_frame is None: + self._show_error("Wait for the first preview frame before recording.") + return try: recording = self._recording_settings_from_ui() except json.JSONDecodeError as exc: @@ -453,8 +653,21 @@ def _start_recording(self) -> None: if not recording.enabled: self._show_error("Recording is disabled in the configuration.") return + frame = self._current_frame + assert frame is not None + height, width = frame.shape[:2] + frame_rate = ( + self._active_camera_settings.fps + if self._active_camera_settings is not None + else self.camera_fps.value() + ) output_path = recording.output_path() - self._video_recorder = VideoRecorder(output_path, recording.options) + self._video_recorder = VideoRecorder( + output_path, + recording.options, + frame_size=(int(width), int(height)), + frame_rate=float(frame_rate), + ) try: self._video_recorder.start() except Exception as exc: # pragma: no cover - runtime error @@ -464,6 +677,7 @@ def _start_recording(self) -> None: self.start_record_button.setEnabled(False) self.stop_record_button.setEnabled(True) self.statusBar().showMessage(f"Recording to {output_path}", 5000) + self._update_camera_controls_enabled() def _stop_recording(self) -> None: if not self._video_recorder: @@ -473,25 +687,42 @@ def _stop_recording(self) -> None: self.start_record_button.setEnabled(True) self.stop_record_button.setEnabled(False) self.statusBar().showMessage("Recording stopped", 3000) + self._update_camera_controls_enabled() # ------------------------------------------------------------------ frame handling def _on_frame_ready(self, frame_data: FrameData) -> None: - frame = frame_data.image + raw_frame = frame_data.image + self._raw_frame = raw_frame + frame = self._apply_rotation(raw_frame) self._current_frame = frame + if self._active_camera_settings is not None: + height, width = frame.shape[:2] + self._active_camera_settings.width = int(width) + self._active_camera_settings.height = int(height) if self._video_recorder and self._video_recorder.is_running: self._video_recorder.write(frame) - if self.enable_dlc_checkbox.isChecked(): + if self._dlc_active: self.dlc_processor.enqueue_frame(frame, frame_data.timestamp) self._update_video_display(frame) def _on_pose_ready(self, result: PoseResult) -> None: + if not self._dlc_active: + return self._last_pose = result if self._current_frame is not None: self._update_video_display(self._current_frame) + def _on_dlc_error(self, message: str) -> None: + self._stop_inference(show_message=False) + self._show_error(message) + def _update_video_display(self, frame: np.ndarray) -> None: display_frame = frame - if self._last_pose and self._last_pose.pose is not None: + if ( + self.show_predictions_checkbox.isChecked() + and self._last_pose + and self._last_pose.pose is not None + ): display_frame = self._draw_pose(frame, self._last_pose.pose) rgb = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape @@ -499,6 +730,19 @@ def _update_video_display(self, frame: np.ndarray) -> None: image = QImage(rgb.data, w, h, bytes_per_line, QImage.Format.Format_RGB888) self.video_label.setPixmap(QPixmap.fromImage(image)) + def _apply_rotation(self, frame: np.ndarray) -> np.ndarray: + if self._rotation_degrees == 90: + return cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) + if self._rotation_degrees == 180: + return cv2.rotate(frame, cv2.ROTATE_180) + if self._rotation_degrees == 270: + return cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE) + return frame + + def _on_show_predictions_changed(self, _state: int) -> None: + if self._current_frame is not None: + self._update_video_display(self._current_frame) + def _draw_pose(self, frame: np.ndarray, pose: np.ndarray) -> np.ndarray: overlay = frame.copy() for keypoint in np.asarray(pose): diff --git a/dlclivegui/video_recorder.py b/dlclivegui/video_recorder.py index e0e3706..c554318 100644 --- a/dlclivegui/video_recorder.py +++ b/dlclivegui/video_recorder.py @@ -2,7 +2,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Tuple import numpy as np @@ -15,10 +15,18 @@ class VideoRecorder: """Thin wrapper around :class:`vidgear.gears.WriteGear`.""" - def __init__(self, output: Path | str, options: Optional[Dict[str, Any]] = None): + def __init__( + self, + output: Path | str, + options: Optional[Dict[str, Any]] = None, + frame_size: Optional[Tuple[int, int]] = None, + frame_rate: Optional[float] = None, + ): self._output = Path(output) self._options = options or {} self._writer: Optional[WriteGear] = None + self._frame_size = frame_size + self._frame_rate = frame_rate @property def is_running(self) -> bool: @@ -31,8 +39,19 @@ def start(self) -> None: ) if self._writer is not None: return + options = dict(self._options) + if self._frame_size and "resolution" not in options: + options["resolution"] = tuple(int(x) for x in self._frame_size) + if self._frame_rate and "frame_rate" not in options: + options["frame_rate"] = float(self._frame_rate) self._output.parent.mkdir(parents=True, exist_ok=True) - self._writer = WriteGear(output_filename=str(self._output), logging=False, **self._options) + self._writer = WriteGear(output=str(self._output), logging=False, **options) + + def configure_stream( + self, frame_size: Tuple[int, int], frame_rate: Optional[float] + ) -> None: + self._frame_size = frame_size + self._frame_rate = frame_rate def write(self, frame: np.ndarray) -> None: if self._writer is None: From efda9d31bd3ccf78a0b66c30b7b077d4268376aa Mon Sep 17 00:00:00 2001 From: Artur <35294812+arturoptophys@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:55:13 +0200 Subject: [PATCH 05/69] Improve camera stop flow and parameter propagation --- dlclivegui/camera_controller.py | 49 ++++++++++++++++++--------- dlclivegui/cameras/basler_backend.py | 9 +++++ dlclivegui/cameras/gentl_backend.py | 5 +++ dlclivegui/cameras/opencv_backend.py | 9 +++++ dlclivegui/gui.py | 50 ++++++++++++++++++++++------ 5 files changed, 97 insertions(+), 25 deletions(-) diff --git a/dlclivegui/camera_controller.py b/dlclivegui/camera_controller.py index 2fb706e..5ebb976 100644 --- a/dlclivegui/camera_controller.py +++ b/dlclivegui/camera_controller.py @@ -25,6 +25,7 @@ class CameraWorker(QObject): """Worker object running inside a :class:`QThread`.""" frame_captured = pyqtSignal(object) + started = pyqtSignal(object) error_occurred = pyqtSignal(str) finished = pyqtSignal() @@ -45,6 +46,8 @@ def run(self) -> None: self.finished.emit() return + self.started.emit(self._settings) + while not self._stop_event.is_set(): try: frame, timestamp = self._backend.read() @@ -75,7 +78,7 @@ class CameraController(QObject): """High level controller that manages a camera worker thread.""" frame_ready = pyqtSignal(object) - started = pyqtSignal(CameraSettings) + started = pyqtSignal(object) stopped = pyqtSignal() error = pyqtSignal(str) @@ -83,40 +86,56 @@ def __init__(self) -> None: super().__init__() self._thread: Optional[QThread] = None self._worker: Optional[CameraWorker] = None + self._pending_settings: Optional[CameraSettings] = None def is_running(self) -> bool: return self._thread is not None and self._thread.isRunning() def start(self, settings: CameraSettings) -> None: if self.is_running(): - self.stop() - self._thread = QThread() - self._worker = CameraWorker(settings) - self._worker.moveToThread(self._thread) - self._thread.started.connect(self._worker.run) - self._worker.frame_captured.connect(self.frame_ready) - self._worker.error_occurred.connect(self.error) - self._worker.finished.connect(self._thread.quit) - self._worker.finished.connect(self._worker.deleteLater) - self._thread.finished.connect(self._cleanup) - self._thread.start() - self.started.emit(settings) + self._pending_settings = settings + self.stop(preserve_pending=True) + return + self._pending_settings = None + self._start_worker(settings) - def stop(self) -> None: + def stop(self, wait: bool = False, *, preserve_pending: bool = False) -> None: if not self.is_running(): + if not preserve_pending: + self._pending_settings = None return assert self._worker is not None assert self._thread is not None + if not preserve_pending: + self._pending_settings = None QMetaObject.invokeMethod( self._worker, "stop", Qt.ConnectionType.QueuedConnection, ) self._thread.quit() - self._thread.wait() + if wait: + self._thread.wait() + + def _start_worker(self, settings: CameraSettings) -> None: + self._thread = QThread() + self._worker = CameraWorker(settings) + self._worker.moveToThread(self._thread) + self._thread.started.connect(self._worker.run) + self._worker.frame_captured.connect(self.frame_ready) + self._worker.started.connect(self.started) + self._worker.error_occurred.connect(self.error) + self._worker.finished.connect(self._thread.quit) + self._worker.finished.connect(self._worker.deleteLater) + self._thread.finished.connect(self._cleanup) + self._thread.start() @pyqtSlot() def _cleanup(self) -> None: self._thread = None self._worker = None self.stopped.emit() + if self._pending_settings is not None: + pending = self._pending_settings + self._pending_settings = None + self.start(pending) diff --git a/dlclivegui/cameras/basler_backend.py b/dlclivegui/cameras/basler_backend.py index f9e2a15..e83185a 100644 --- a/dlclivegui/cameras/basler_backend.py +++ b/dlclivegui/cameras/basler_backend.py @@ -61,6 +61,15 @@ def open(self) -> None: self._converter = pylon.ImageFormatConverter() self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed self._converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned + try: + self.settings.width = int(self._camera.Width.GetValue()) + self.settings.height = int(self._camera.Height.GetValue()) + except Exception: + pass + try: + self.settings.fps = float(self._camera.ResultingFrameRateAbs.GetValue()) + except Exception: + pass def read(self) -> Tuple[np.ndarray, float]: if self._camera is None or self._converter is None: diff --git a/dlclivegui/cameras/gentl_backend.py b/dlclivegui/cameras/gentl_backend.py index 0d81294..3f6e579 100644 --- a/dlclivegui/cameras/gentl_backend.py +++ b/dlclivegui/cameras/gentl_backend.py @@ -123,6 +123,11 @@ def _buffer_to_numpy(self, buffer) -> np.ndarray: ptr = ctypes.cast(addr, int_pointer) frame = np.ctypeslib.as_array(ptr, (buffer.get_image_height(), buffer.get_image_width())) frame = frame.copy() + try: + self.settings.width = int(buffer.get_image_width()) + self.settings.height = int(buffer.get_image_height()) + except Exception: + pass if frame.ndim < 3: import cv2 diff --git a/dlclivegui/cameras/opencv_backend.py b/dlclivegui/cameras/opencv_backend.py index 8497bfa..cbadf73 100644 --- a/dlclivegui/cameras/opencv_backend.py +++ b/dlclivegui/cameras/opencv_backend.py @@ -69,6 +69,15 @@ def _configure_capture(self) -> None: except (TypeError, ValueError): continue self._capture.set(prop_id, float(value)) + actual_width = self._capture.get(cv2.CAP_PROP_FRAME_WIDTH) + actual_height = self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT) + actual_fps = self._capture.get(cv2.CAP_PROP_FPS) + if actual_width: + self.settings.width = int(actual_width) + if actual_height: + self.settings.height = int(actual_height) + if actual_fps: + self.settings.fps = float(actual_fps) def _resolve_backend(self, backend: str | None) -> int: if backend is None: diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 507b199..ff2408e 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -289,6 +289,7 @@ def _connect_signals(self) -> None: ) self.camera_controller.frame_ready.connect(self._on_frame_ready) + self.camera_controller.started.connect(self._on_camera_started) self.camera_controller.error.connect(self._show_error) self.camera_controller.stopped.connect(self._on_camera_stopped) @@ -538,15 +539,52 @@ def _start_preview(self) -> None: self._raw_frame = None self._last_pose = None self._dlc_active = False - self.statusBar().showMessage("Camera preview started", 3000) + self.statusBar().showMessage("Starting camera preview…", 3000) self._update_inference_buttons() self._update_camera_controls_enabled() def _stop_preview(self) -> None: + if not self.camera_controller.is_running(): + return + self.preview_button.setEnabled(False) + self.stop_preview_button.setEnabled(False) + self.start_inference_button.setEnabled(False) + self.stop_inference_button.setEnabled(False) + self.statusBar().showMessage("Stopping camera preview…", 3000) self.camera_controller.stop() self._stop_inference(show_message=False) + + def _on_camera_started(self, settings: CameraSettings) -> None: + self._active_camera_settings = settings + self.preview_button.setEnabled(False) + self.stop_preview_button.setEnabled(True) + self.camera_width.blockSignals(True) + self.camera_width.setValue(int(settings.width)) + self.camera_width.blockSignals(False) + self.camera_height.blockSignals(True) + self.camera_height.setValue(int(settings.height)) + self.camera_height.blockSignals(False) + if getattr(settings, "fps", None): + self.camera_fps.blockSignals(True) + self.camera_fps.setValue(float(settings.fps)) + self.camera_fps.blockSignals(False) + resolution = f"{int(settings.width)}×{int(settings.height)}" + if getattr(settings, "fps", None): + fps_text = f"{float(settings.fps):.2f} FPS" + else: + fps_text = "unknown FPS" + self.statusBar().showMessage( + f"Camera preview started: {resolution} @ {fps_text}", 5000 + ) + self._update_inference_buttons() + self._update_camera_controls_enabled() + + def _on_camera_stopped(self) -> None: + if self._video_recorder and self._video_recorder.is_running: + self._stop_recording() self.preview_button.setEnabled(True) self.stop_preview_button.setEnabled(False) + self._stop_inference(show_message=False) self._current_frame = None self._raw_frame = None self._last_pose = None @@ -557,14 +595,6 @@ def _stop_preview(self) -> None: self._update_inference_buttons() self._update_camera_controls_enabled() - def _on_camera_stopped(self) -> None: - self.preview_button.setEnabled(True) - self.stop_preview_button.setEnabled(False) - self._stop_inference(show_message=False) - self._update_inference_buttons() - self._active_camera_settings = None - self._update_camera_controls_enabled() - def _configure_dlc(self) -> bool: try: settings = self._dlc_settings_from_ui() @@ -768,7 +798,7 @@ def _show_error(self, message: str) -> None: # ------------------------------------------------------------------ Qt overrides def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI behaviour if self.camera_controller.is_running(): - self.camera_controller.stop() + self.camera_controller.stop(wait=True) if self._video_recorder and self._video_recorder.is_running: self._video_recorder.stop() self.dlc_processor.shutdown() From f39c4c466968692a75a71e88dcf50c9c71e6eecd Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Wed, 22 Oct 2025 11:56:53 +0200 Subject: [PATCH 06/69] fixed video recordings (roughly) --- dlclivegui/camera_controller.py | 25 +++-- dlclivegui/cameras/factory.py | 2 + dlclivegui/cameras/gentl_backend.py | 144 +++++++++++++++++++++------- dlclivegui/config.py | 21 +++- dlclivegui/gui.py | 42 +++++--- dlclivegui/video_recorder.py | 46 +++++++-- 6 files changed, 210 insertions(+), 70 deletions(-) diff --git a/dlclivegui/camera_controller.py b/dlclivegui/camera_controller.py index 4965dc7..821a252 100644 --- a/dlclivegui/camera_controller.py +++ b/dlclivegui/camera_controller.py @@ -8,9 +8,9 @@ import numpy as np from PyQt6.QtCore import QObject, QThread, QMetaObject, Qt, pyqtSignal, pyqtSlot -from .cameras import CameraFactory -from .cameras.base import CameraBackend -from .config import CameraSettings +from dlclivegui.cameras import CameraFactory +from dlclivegui.cameras.base import CameraBackend +from dlclivegui.config import CameraSettings @dataclass @@ -51,8 +51,15 @@ def run(self) -> None: while not self._stop_event.is_set(): try: frame, timestamp = self._backend.read() + except TimeoutError: + if self._stop_event.is_set(): + break + continue except Exception as exc: # pragma: no cover - device specific - self.error_occurred.emit(str(exc)) + if not self._stop_event.is_set(): + self.error_occurred.emit(str(exc)) + break + if self._stop_event.is_set(): break self.frame_captured.emit(FrameData(frame, timestamp)) @@ -113,6 +120,7 @@ def stop(self, wait: bool = False, *, preserve_pending: bool = False) -> None: "stop", Qt.ConnectionType.QueuedConnection, ) + self._worker.stop() self._thread.quit() if wait: self._thread.wait() @@ -129,15 +137,6 @@ def _start_worker(self, settings: CameraSettings) -> None: self._worker.finished.connect(self._worker.deleteLater) self._thread.finished.connect(self._cleanup) self._thread.start() - self.started.emit(settings) - - def stop(self) -> None: - if not self.is_running(): - return - assert self._worker is not None - self._worker.stop() - assert self._thread is not None - self._thread.wait() @pyqtSlot() def _cleanup(self) -> None: diff --git a/dlclivegui/cameras/factory.py b/dlclivegui/cameras/factory.py index c67f7bd..1dc33d8 100644 --- a/dlclivegui/cameras/factory.py +++ b/dlclivegui/cameras/factory.py @@ -89,6 +89,8 @@ def detect_cameras(backend: str, max_devices: int = 10) -> List[DetectedCamera]: continue else: label = backend_instance.device_name() + if not label: + label = f"{backend.title()} #{index}" detected.append(DetectedCamera(index=index, label=label)) finally: try: diff --git a/dlclivegui/cameras/gentl_backend.py b/dlclivegui/cameras/gentl_backend.py index 7a15333..dc6ce7e 100644 --- a/dlclivegui/cameras/gentl_backend.py +++ b/dlclivegui/cameras/gentl_backend.py @@ -13,8 +13,13 @@ try: # pragma: no cover - optional dependency from harvesters.core import Harvester + try: + from harvesters.core import HarvesterTimeoutError # type: ignore + except Exception: # pragma: no cover - optional dependency + HarvesterTimeoutError = TimeoutError # type: ignore except Exception: # pragma: no cover - optional dependency Harvester = None # type: ignore + HarvesterTimeoutError = TimeoutError # type: ignore class GenTLCameraBackend(CameraBackend): @@ -40,8 +45,9 @@ def __init__(self, settings): self._timeout: float = float(props.get("timeout", 2.0)) self._cti_search_paths: Tuple[str, ...] = self._parse_cti_paths(props.get("cti_search_paths")) - self._harvester: Optional[Harvester] = None + self._harvester = None self._acquirer = None + self._device_label: Optional[str] = None @classmethod def is_available(cls) -> bool: @@ -84,6 +90,8 @@ def open(self) -> None: remote = self._acquirer.remote_device node_map = remote.node_map + self._device_label = self._resolve_device_label(node_map) + self._configure_pixel_format(node_map) self._configure_resolution(node_map) self._configure_exposure(node_map) @@ -96,15 +104,23 @@ def read(self) -> Tuple[np.ndarray, float]: if self._acquirer is None: raise RuntimeError("GenTL image acquirer not initialised") - with self._acquirer.fetch(timeout=self._timeout) as buffer: - component = buffer.payload.components[0] - channels = 3 if self._pixel_format in {"RGB8", "BGR8"} else 1 - if channels > 1: - frame = component.data.reshape( - component.height, component.width, channels - ).copy() - else: - frame = component.data.reshape(component.height, component.width).copy() + try: + with self._acquirer.fetch(timeout=self._timeout) as buffer: + component = buffer.payload.components[0] + channels = 3 if self._pixel_format in {"RGB8", "BGR8"} else 1 + array = np.asarray(component.data) + expected = component.height * component.width * channels + if array.size != expected: + array = np.frombuffer(bytes(component.data), dtype=array.dtype) + try: + if channels > 1: + frame = array.reshape(component.height, component.width, channels).copy() + else: + frame = array.reshape(component.height, component.width).copy() + except ValueError: + frame = array.copy() + except HarvesterTimeoutError as exc: + raise TimeoutError(str(exc)) from exc frame = self._convert_frame(frame) timestamp = time.time() @@ -136,6 +152,8 @@ def close(self) -> None: finally: self._harvester = None + self._device_label = None + # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ @@ -177,8 +195,8 @@ def _available_serials(self) -> List[str]: def _create_acquirer(self, serial: Optional[str], index: int): assert self._harvester is not None methods = [ - getattr(self._harvester, "create_image_acquirer", None), getattr(self._harvester, "create", None), + getattr(self._harvester, "create_image_acquirer", None), ] methods = [m for m in methods if m is not None] errors: List[str] = [] @@ -280,24 +298,86 @@ def _configure_gain(self, node_map) -> None: def _configure_frame_rate(self, node_map) -> None: if not self.settings.fps: return - left, right, top, bottom = map(int, crop) - width = right - left - height = bottom - top - self._camera.set_region(left, top, width, height) - - def _buffer_to_numpy(self, buffer) -> np.ndarray: - pixel_format = buffer.get_image_pixel_format() - bits_per_pixel = (pixel_format >> 16) & 0xFF - if bits_per_pixel == 8: - int_pointer = ctypes.POINTER(ctypes.c_uint8) - else: - int_pointer = ctypes.POINTER(ctypes.c_uint16) - addr = buffer.get_data() - ptr = ctypes.cast(addr, int_pointer) - frame = np.ctypeslib.as_array(ptr, (buffer.get_image_height(), buffer.get_image_width())) - frame = frame.copy() - if frame.ndim < 3: - import cv2 - - frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2RGB) - return frame + + target = float(self.settings.fps) + for attr in ("AcquisitionFrameRateEnable", "AcquisitionFrameRateControlEnable"): + try: + getattr(node_map, attr).value = True + except Exception: + continue + + for attr in ("AcquisitionFrameRate", "ResultingFrameRate", "AcquisitionFrameRateAbs"): + try: + node = getattr(node_map, attr) + except AttributeError: + continue + try: + node.value = target + return + except Exception: + continue + + def _convert_frame(self, frame: np.ndarray) -> np.ndarray: + if frame.dtype != np.uint8: + max_val = float(frame.max()) if frame.size else 0.0 + scale = 255.0 / max_val if max_val > 0.0 else 1.0 + frame = np.clip(frame * scale, 0, 255).astype(np.uint8) + + if frame.ndim == 2: + frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) + elif frame.ndim == 3 and frame.shape[2] == 3 and self._pixel_format == "RGB8": + frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) + + if self._crop is not None: + top, bottom, left, right = (int(v) for v in self._crop) + top = max(0, top) + left = max(0, left) + bottom = bottom if bottom > 0 else frame.shape[0] + right = right if right > 0 else frame.shape[1] + bottom = min(frame.shape[0], bottom) + right = min(frame.shape[1], right) + frame = frame[top:bottom, left:right] + + if self._rotate in (90, 180, 270): + rotations = { + 90: cv2.ROTATE_90_CLOCKWISE, + 180: cv2.ROTATE_180, + 270: cv2.ROTATE_90_COUNTERCLOCKWISE, + } + frame = cv2.rotate(frame, rotations[self._rotate]) + + return frame.copy() + + def _resolve_device_label(self, node_map) -> Optional[str]: + candidates = [ + ("DeviceModelName", "DeviceSerialNumber"), + ("DeviceDisplayName", "DeviceSerialNumber"), + ] + for name_attr, serial_attr in candidates: + try: + model = getattr(node_map, name_attr).value + except AttributeError: + continue + serial = None + try: + serial = getattr(node_map, serial_attr).value + except AttributeError: + pass + if model: + model_str = str(model) + serial_str = str(serial) if serial else None + return f"{model_str} ({serial_str})" if serial_str else model_str + return None + + def _adjust_to_increment(self, value: int, minimum: int, maximum: int, increment: int) -> int: + value = max(minimum, min(maximum, int(value))) + if increment <= 0: + return value + offset = value - minimum + steps = offset // increment + return minimum + steps * increment + + def device_name(self) -> str: + if self._device_label: + return self._device_label + return super().device_name() diff --git a/dlclivegui/config.py b/dlclivegui/config.py index da00c5f..d57a145 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -16,7 +16,7 @@ class CameraSettings: width: int = 640 height: int = 480 fps: float = 30.0 - backend: str = "opencv" + backend: str = "gentl" properties: Dict[str, Any] = field(default_factory=dict) def apply_defaults(self) -> "CameraSettings": @@ -48,7 +48,8 @@ class RecordingSettings: directory: str = str(Path.home() / "Videos" / "deeplabcut-live") filename: str = "session.mp4" container: str = "mp4" - options: Dict[str, Any] = field(default_factory=dict) + codec: str = "libx264" + crf: int = 23 def output_path(self) -> Path: """Return the absolute output path for recordings.""" @@ -62,6 +63,18 @@ def output_path(self) -> Path: filename = name.with_suffix(f".{self.container}") return directory / filename + def writegear_options(self, fps: float) -> Dict[str, Any]: + """Return compression parameters for WriteGear.""" + + fps_value = float(fps) if fps else 30.0 + codec_value = (self.codec or "libx264").strip() or "libx264" + crf_value = int(self.crf) if self.crf is not None else 23 + return { + "-input_framerate": f"{fps_value:.6f}", + "-vcodec": codec_value, + "-crf": str(crf_value), + } + @dataclass class ApplicationSettings: @@ -77,7 +90,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "ApplicationSettings": camera = CameraSettings(**data.get("camera", {})).apply_defaults() dlc = DLCProcessorSettings(**data.get("dlc", {})) - recording = RecordingSettings(**data.get("recording", {})) + recording_data = dict(data.get("recording", {})) + recording_data.pop("options", None) + recording = RecordingSettings(**recording_data) return cls(camera=camera, dlc=dlc, recording=recording) def to_dict(self) -> Dict[str, Any]: diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index ffa31ec..a6aaae8 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -254,10 +254,15 @@ def _build_recording_group(self) -> QGroupBox: self.container_combo.addItems(["mp4", "avi", "mov"]) form.addRow("Container", self.container_combo) - self.recording_options_edit = QPlainTextEdit() - self.recording_options_edit.setPlaceholderText('{"compression_mode": "mp4"}') - self.recording_options_edit.setFixedHeight(60) - form.addRow("WriteGear options", self.recording_options_edit) + self.codec_combo = QComboBox() + self.codec_combo.addItems(["h264_nvenc", "libx264"]) + self.codec_combo.setCurrentText("libx264") + form.addRow("Codec", self.codec_combo) + + self.crf_spin = QSpinBox() + self.crf_spin.setRange(0, 51) + self.crf_spin.setValue(23) + form.addRow("CRF", self.crf_spin) self.start_record_button = QPushButton("Start recording") self.stop_record_button = QPushButton("Stop recording") @@ -335,7 +340,13 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.output_directory_edit.setText(recording.directory) self.filename_edit.setText(recording.filename) self.container_combo.setCurrentText(recording.container) - self.recording_options_edit.setPlainText(json.dumps(recording.options, indent=2)) + codec_index = self.codec_combo.findText(recording.codec) + if codec_index >= 0: + self.codec_combo.setCurrentIndex(codec_index) + else: + self.codec_combo.addItem(recording.codec) + self.codec_combo.setCurrentIndex(self.codec_combo.count() - 1) + self.crf_spin.setValue(int(recording.crf)) def _current_config(self) -> ApplicationSettings: return ApplicationSettings( @@ -510,7 +521,8 @@ def _recording_settings_from_ui(self) -> RecordingSettings: directory=self.output_directory_edit.text().strip(), filename=self.filename_edit.text().strip() or "session.mp4", container=self.container_combo.currentText().strip() or "mp4", - options=self._parse_json(self.recording_options_edit.toPlainText()), + codec=self.codec_combo.currentText().strip() or "libx264", + crf=int(self.crf_spin.value()), ) # ------------------------------------------------------------------ actions @@ -701,6 +713,8 @@ def _update_camera_controls_enabled(self) -> None: self.camera_fps, self.camera_properties_edit, self.rotation_combo, + self.codec_combo, + self.crf_spin, ] for widget in widgets: widget.setEnabled(allow_changes) @@ -746,11 +760,7 @@ def _start_recording(self) -> None: if self._current_frame is None: self._show_error("Wait for the first preview frame before recording.") return - try: - recording = self._recording_settings_from_ui() - except json.JSONDecodeError as exc: - self._show_error(f"Invalid recording options: {exc}") - return + recording = self._recording_settings_from_ui() if not recording.enabled: self._show_error("Recording is disabled in the configuration.") return @@ -765,9 +775,10 @@ def _start_recording(self) -> None: output_path = recording.output_path() self._video_recorder = VideoRecorder( output_path, - recording.options, frame_size=(int(width), int(height)), frame_rate=float(frame_rate), + codec=recording.codec, + crf=recording.crf, ) try: self._video_recorder.start() @@ -795,13 +806,18 @@ def _on_frame_ready(self, frame_data: FrameData) -> None: raw_frame = frame_data.image self._raw_frame = raw_frame frame = self._apply_rotation(raw_frame) + frame = np.ascontiguousarray(frame) self._current_frame = frame if self._active_camera_settings is not None: height, width = frame.shape[:2] self._active_camera_settings.width = int(width) self._active_camera_settings.height = int(height) if self._video_recorder and self._video_recorder.is_running: - self._video_recorder.write(frame) + try: + self._video_recorder.write(frame) + except RuntimeError as exc: + self._show_error(str(exc)) + self._stop_recording() if self._dlc_active: self.dlc_processor.enqueue_frame(frame, frame_data.timestamp) self._update_video_display(frame) diff --git a/dlclivegui/video_recorder.py b/dlclivegui/video_recorder.py index c554318..ff52fdb 100644 --- a/dlclivegui/video_recorder.py +++ b/dlclivegui/video_recorder.py @@ -18,15 +18,17 @@ class VideoRecorder: def __init__( self, output: Path | str, - options: Optional[Dict[str, Any]] = None, frame_size: Optional[Tuple[int, int]] = None, frame_rate: Optional[float] = None, + codec: str = "libx264", + crf: int = 23, ): self._output = Path(output) - self._options = options or {} self._writer: Optional[WriteGear] = None self._frame_size = frame_size self._frame_rate = frame_rate + self._codec = codec + self._crf = int(crf) @property def is_running(self) -> bool: @@ -39,13 +41,19 @@ def start(self) -> None: ) if self._writer is not None: return - options = dict(self._options) - if self._frame_size and "resolution" not in options: - options["resolution"] = tuple(int(x) for x in self._frame_size) - if self._frame_rate and "frame_rate" not in options: - options["frame_rate"] = float(self._frame_rate) + fps_value = float(self._frame_rate) if self._frame_rate else 30.0 + + writer_kwargs: Dict[str, Any] = { + "compression_mode": True, + "logging": True, + "-input_framerate": fps_value, + "-vcodec": (self._codec or "libx264").strip() or "libx264", + "-crf": int(self._crf), + } + # TODO deal with pixel format + self._output.parent.mkdir(parents=True, exist_ok=True) - self._writer = WriteGear(output=str(self._output), logging=False, **options) + self._writer = WriteGear(output=str(self._output), **writer_kwargs) def configure_stream( self, frame_size: Tuple[int, int], frame_rate: Optional[float] @@ -56,7 +64,27 @@ def configure_stream( def write(self, frame: np.ndarray) -> None: if self._writer is None: return - self._writer.write(frame) + if frame.dtype != np.uint8: + frame_float = frame.astype(np.float32, copy=False) + max_val = float(frame_float.max()) if frame_float.size else 0.0 + scale = 1.0 + if max_val > 0: + scale = 255.0 / max_val if max_val > 255.0 else (255.0 if max_val <= 1.0 else 1.0) + frame = np.clip(frame_float * scale, 0.0, 255.0).astype(np.uint8) + if frame.ndim == 2: + frame = np.repeat(frame[:, :, None], 3, axis=2) + frame = np.ascontiguousarray(frame) + try: + self._writer.write(frame) + except OSError as exc: + writer = self._writer + self._writer = None + if writer is not None: + try: + writer.close() + except Exception: + pass + raise RuntimeError(f"Video encoding failed: {exc}") from exc def stop(self) -> None: if self._writer is None: From 7e7e3b6f8e6218cd40e3410aefeac487aa0b70b2 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Thu, 23 Oct 2025 14:45:00 +0200 Subject: [PATCH 07/69] updates --- dlclivegui/config.py | 17 +- dlclivegui/dlc_processor.py | 282 ++++++++++++++++++++---------- dlclivegui/gui.py | 324 +++++++++++++++++++++++++++-------- dlclivegui/video_recorder.py | 212 +++++++++++++++++++++-- setup.py | 4 +- 5 files changed, 654 insertions(+), 185 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index d57a145..72e3f80 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -15,8 +15,10 @@ class CameraSettings: index: int = 0 width: int = 640 height: int = 480 - fps: float = 30.0 + fps: float = 25.0 backend: str = "gentl" + exposure: int = 500 # 0 = auto, otherwise microseconds + gain: float = 10 # 0.0 = auto, otherwise gain value properties: Dict[str, Any] = field(default_factory=dict) def apply_defaults(self) -> "CameraSettings": @@ -25,6 +27,8 @@ def apply_defaults(self) -> "CameraSettings": self.width = int(self.width) if self.width else 640 self.height = int(self.height) if self.height else 480 self.fps = float(self.fps) if self.fps else 30.0 + self.exposure = int(self.exposure) if self.exposure else 0 + self.gain = float(self.gain) if self.gain else 0.0 return self @@ -33,11 +37,8 @@ class DLCProcessorSettings: """Configuration for DLCLive processing.""" model_path: str = "" - shuffle: Optional[int] = None - trainingsetindex: Optional[int] = None - processor: str = "cpu" - processor_args: Dict[str, Any] = field(default_factory=dict) additional_options: Dict[str, Any] = field(default_factory=dict) + model_type: Optional[str] = "base" @dataclass @@ -89,7 +90,11 @@ def from_dict(cls, data: Dict[str, Any]) -> "ApplicationSettings": """Create an :class:`ApplicationSettings` from a dictionary.""" camera = CameraSettings(**data.get("camera", {})).apply_defaults() - dlc = DLCProcessorSettings(**data.get("dlc", {})) + dlc_data = dict(data.get("dlc", {})) + dlc = DLCProcessorSettings( + model_path=str(dlc_data.get("model_path", "")), + additional_options=dict(dlc_data.get("additional_options", {})), + ) recording_data = dict(data.get("recording", {})) recording_data.pop("options", None) recording = RecordingSettings(**recording_data) diff --git a/dlclivegui/dlc_processor.py b/dlclivegui/dlc_processor.py index 0e1ef3e..201f53c 100644 --- a/dlclivegui/dlc_processor.py +++ b/dlclivegui/dlc_processor.py @@ -2,15 +2,17 @@ from __future__ import annotations import logging +import queue import threading -from concurrent.futures import Future, ThreadPoolExecutor -from dataclasses import dataclass +import time +from collections import deque +from dataclasses import dataclass, field from typing import Any, Optional import numpy as np from PyQt6.QtCore import QObject, pyqtSignal -from .config import DLCProcessorSettings +from dlclivegui.config import DLCProcessorSettings LOGGER = logging.getLogger(__name__) @@ -26,8 +28,23 @@ class PoseResult: timestamp: float +@dataclass +class ProcessorStats: + """Statistics for DLC processor performance.""" + frames_enqueued: int = 0 + frames_processed: int = 0 + frames_dropped: int = 0 + queue_size: int = 0 + processing_fps: float = 0.0 + average_latency: float = 0.0 + last_latency: float = 0.0 + + +_SENTINEL = object() + + class DLCLiveProcessor(QObject): - """Background pose estimation using DLCLive.""" + """Background pose estimation using DLCLive with queue-based threading.""" pose_ready = pyqtSignal(object) error = pyqtSignal(str) @@ -36,108 +53,187 @@ class DLCLiveProcessor(QObject): def __init__(self) -> None: super().__init__() self._settings = DLCProcessorSettings() - self._executor = ThreadPoolExecutor(max_workers=1) - self._dlc: Optional[DLCLive] = None - self._init_future: Optional[Future[Any]] = None - self._pending: Optional[Future[Any]] = None - self._lock = threading.Lock() + self._dlc: Optional[Any] = None + self._queue: Optional[queue.Queue[Any]] = None + self._worker_thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._initialized = False + + # Statistics tracking + self._frames_enqueued = 0 + self._frames_processed = 0 + self._frames_dropped = 0 + self._latencies: deque[float] = deque(maxlen=60) + self._processing_times: deque[float] = deque(maxlen=60) + self._stats_lock = threading.Lock() def configure(self, settings: DLCProcessorSettings) -> None: self._settings = settings def reset(self) -> None: - """Cancel pending work and drop the current DLCLive instance.""" - - with self._lock: - if self._pending is not None and not self._pending.done(): - self._pending.cancel() - self._pending = None - if self._init_future is not None and not self._init_future.done(): - self._init_future.cancel() - self._init_future = None - self._dlc = None + """Stop the worker thread and drop the current DLCLive instance.""" + self._stop_worker() + self._dlc = None + self._initialized = False + with self._stats_lock: + self._frames_enqueued = 0 + self._frames_processed = 0 + self._frames_dropped = 0 + self._latencies.clear() + self._processing_times.clear() def shutdown(self) -> None: - with self._lock: - if self._pending is not None: - self._pending.cancel() - self._pending = None - if self._init_future is not None: - self._init_future.cancel() - self._init_future = None - self._executor.shutdown(wait=False, cancel_futures=True) + self._stop_worker() self._dlc = None + self._initialized = False def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: - with self._lock: - if self._dlc is None and self._init_future is None: - self._init_future = self._executor.submit( - self._initialise_model, frame.copy(), timestamp - ) - self._init_future.add_done_callback(self._on_initialised) - return - if self._dlc is None: - return - if self._pending is not None and not self._pending.done(): - return - self._pending = self._executor.submit( - self._run_inference, frame.copy(), timestamp + if not self._initialized and self._worker_thread is None: + # Start worker thread with initialization + self._start_worker(frame.copy(), timestamp) + return + + if self._queue is not None: + try: + # Non-blocking put - drop frame if queue is full + self._queue.put_nowait((frame.copy(), timestamp, time.perf_counter())) + with self._stats_lock: + self._frames_enqueued += 1 + except queue.Full: + LOGGER.debug("DLC queue full, dropping frame") + with self._stats_lock: + self._frames_dropped += 1 + + def get_stats(self) -> ProcessorStats: + """Get current processing statistics.""" + queue_size = self._queue.qsize() if self._queue is not None else 0 + + with self._stats_lock: + avg_latency = ( + sum(self._latencies) / len(self._latencies) + if self._latencies + else 0.0 ) - self._pending.add_done_callback(self._on_pose_ready) - - def _initialise_model(self, frame: np.ndarray, timestamp: float) -> bool: - if DLCLive is None: - raise RuntimeError( - "The 'dlclive' package is required for pose estimation. Install it to enable DLCLive support." + last_latency = self._latencies[-1] if self._latencies else 0.0 + + # Compute processing FPS from processing times + if len(self._processing_times) >= 2: + duration = self._processing_times[-1] - self._processing_times[0] + processing_fps = (len(self._processing_times) - 1) / duration if duration > 0 else 0.0 + else: + processing_fps = 0.0 + + return ProcessorStats( + frames_enqueued=self._frames_enqueued, + frames_processed=self._frames_processed, + frames_dropped=self._frames_dropped, + queue_size=queue_size, + processing_fps=processing_fps, + average_latency=avg_latency, + last_latency=last_latency, ) - if not self._settings.model_path: - raise RuntimeError("No DLCLive model path configured.") - options = { - "model_path": self._settings.model_path, - "processor": self._settings.processor, - } - options.update(self._settings.additional_options) - if self._settings.shuffle is not None: - options["shuffle"] = self._settings.shuffle - if self._settings.trainingsetindex is not None: - options["trainingsetindex"] = self._settings.trainingsetindex - if self._settings.processor_args: - options["processor_config"] = { - "object": self._settings.processor, - **self._settings.processor_args, - } - model = DLCLive(**options) - model.init_inference(frame, frame_time=timestamp, record=False) - self._dlc = model - return True - def _on_initialised(self, future: Future[Any]) -> None: - try: - result = future.result() - self.initialized.emit(bool(result)) - except Exception as exc: # pragma: no cover - runtime behaviour - LOGGER.exception("Failed to initialise DLCLive", exc_info=exc) - self.error.emit(str(exc)) - finally: - with self._lock: - if self._init_future is future: - self._init_future = None - - def _run_inference(self, frame: np.ndarray, timestamp: float) -> PoseResult: - if self._dlc is None: - raise RuntimeError("DLCLive model not initialised") - pose = self._dlc.get_pose(frame, frame_time=timestamp, record=False) - return PoseResult(pose=pose, timestamp=timestamp) - - def _on_pose_ready(self, future: Future[Any]) -> None: + def _start_worker(self, init_frame: np.ndarray, init_timestamp: float) -> None: + if self._worker_thread is not None and self._worker_thread.is_alive(): + return + + self._queue = queue.Queue(maxsize=5) + self._stop_event.clear() + self._worker_thread = threading.Thread( + target=self._worker_loop, + args=(init_frame, init_timestamp), + name="DLCLiveWorker", + daemon=True, + ) + self._worker_thread.start() + + def _stop_worker(self) -> None: + if self._worker_thread is None: + return + + self._stop_event.set() + if self._queue is not None: + try: + self._queue.put_nowait(_SENTINEL) + except queue.Full: + pass + + self._worker_thread.join(timeout=2.0) + if self._worker_thread.is_alive(): + LOGGER.warning("DLC worker thread did not terminate cleanly") + + self._worker_thread = None + self._queue = None + + def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: try: - result = future.result() - except Exception as exc: # pragma: no cover - runtime behaviour - LOGGER.exception("Pose inference failed", exc_info=exc) + # Initialize model + if DLCLive is None: + raise RuntimeError( + "The 'dlclive' package is required for pose estimation." + ) + if not self._settings.model_path: + raise RuntimeError("No DLCLive model path configured.") + + options = { + "model_path": self._settings.model_path, + "model_type": self._settings.model_type, + "processor": None, + "dynamic": [False,0.5,10], + "resize": 1.0, + } + self._dlc = DLCLive(**options) + self._dlc.init_inference(init_frame) + self._initialized = True + self.initialized.emit(True) + LOGGER.info("DLCLive model initialized successfully") + + # Process the initialization frame + enqueue_time = time.perf_counter() + pose = self._dlc.get_pose(init_frame, frame_time=init_timestamp) + process_time = time.perf_counter() + + with self._stats_lock: + self._frames_enqueued += 1 + self._frames_processed += 1 + self._processing_times.append(process_time) + + self.pose_ready.emit(PoseResult(pose=pose, timestamp=init_timestamp)) + + except Exception as exc: + LOGGER.exception("Failed to initialize DLCLive", exc_info=exc) self.error.emit(str(exc)) + self.initialized.emit(False) return - finally: - with self._lock: - if self._pending is future: - self._pending = None - self.pose_ready.emit(result) + + # Main processing loop + while not self._stop_event.is_set(): + try: + item = self._queue.get(timeout=0.1) + except queue.Empty: + continue + + if item is _SENTINEL: + break + + frame, timestamp, enqueue_time = item + try: + start_process = time.perf_counter() + pose = self._dlc.get_pose(frame, frame_time=timestamp) + end_process = time.perf_counter() + + latency = end_process - enqueue_time + + with self._stats_lock: + self._frames_processed += 1 + self._latencies.append(latency) + self._processing_times.append(end_process) + + self.pose_ready.emit(PoseResult(pose=pose, timestamp=timestamp)) + except Exception as exc: + LOGGER.exception("Pose inference failed", exc_info=exc) + self.error.emit(str(exc)) + finally: + self._queue.task_done() + + LOGGER.info("DLC worker thread exiting") diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index a6aaae8..0328222 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -1,14 +1,18 @@ """PyQt6 based GUI for DeepLabCut Live.""" from __future__ import annotations +import os import json import sys +import time +import logging +from collections import deque from pathlib import Path from typing import Optional import cv2 import numpy as np -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, QTimer from PyQt6.QtGui import QAction, QCloseEvent, QImage, QPixmap from PyQt6.QtWidgets import ( QApplication, @@ -42,10 +46,14 @@ RecordingSettings, DEFAULT_CONFIG, ) -from dlclivegui.dlc_processor import DLCLiveProcessor, PoseResult -from dlclivegui.video_recorder import VideoRecorder +from dlclivegui.dlc_processor import DLCLiveProcessor, PoseResult, ProcessorStats +from dlclivegui.video_recorder import RecorderStats, VideoRecorder +os.environ["CUDA_VISIBLE_DEVICES"] = "0" +logging.basicConfig(level=logging.INFO) + +PATH2MODELS = "C:\\Users\\User\\Repos\\DeepLabCut-live-GUI\\models" class MainWindow(QMainWindow): """Main application window.""" @@ -62,6 +70,12 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._rotation_degrees: int = 0 self._detected_cameras: list[DetectedCamera] = [] self._active_camera_settings: Optional[CameraSettings] = None + self._camera_frame_times: deque[float] = deque(maxlen=240) + self._last_drop_warning = 0.0 + self._last_recorder_summary = "Recorder idle" + self._display_interval = 1.0 / 25.0 + self._last_display_time = 0.0 + self._dlc_initialized = False self.camera_controller = CameraController() self.dlc_processor = DLCLiveProcessor() @@ -71,6 +85,11 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._apply_config(self._config) self._update_inference_buttons() self._update_camera_controls_enabled() + self._metrics_timer = QTimer(self) + self._metrics_timer.setInterval(500) + self._metrics_timer.timeout.connect(self._update_metrics) + self._metrics_timer.start() + self._update_metrics() # ------------------------------------------------------------------ UI def _setup_ui(self) -> None: @@ -165,9 +184,23 @@ def _build_camera_group(self) -> QGroupBox: self.camera_fps.setDecimals(2) form.addRow("Frame rate", self.camera_fps) + self.camera_exposure = QSpinBox() + self.camera_exposure.setRange(0, 1000000) + self.camera_exposure.setValue(0) + self.camera_exposure.setSpecialValueText("Auto") + self.camera_exposure.setSuffix(" μs") + form.addRow("Exposure", self.camera_exposure) + + self.camera_gain = QDoubleSpinBox() + self.camera_gain.setRange(0.0, 100.0) + self.camera_gain.setValue(0.0) + self.camera_gain.setSpecialValueText("Auto") + self.camera_gain.setDecimals(2) + form.addRow("Gain", self.camera_gain) + self.camera_properties_edit = QPlainTextEdit() self.camera_properties_edit.setPlaceholderText( - '{"exposure": 15000, "gain": 0.5, "serial": "123456"}' + '{"other_property": "value"}' ) self.camera_properties_edit.setFixedHeight(60) form.addRow("Advanced properties", self.camera_properties_edit) @@ -179,6 +212,9 @@ def _build_camera_group(self) -> QGroupBox: self.rotation_combo.addItem("270°", 270) form.addRow("Rotation", self.rotation_combo) + self.camera_stats_label = QLabel("Camera idle") + form.addRow("Throughput", self.camera_stats_label) + return group def _build_dlc_group(self) -> QGroupBox: @@ -187,32 +223,21 @@ def _build_dlc_group(self) -> QGroupBox: path_layout = QHBoxLayout() self.model_path_edit = QLineEdit() + self.model_path_edit.setPlaceholderText("/path/to/exported/model") path_layout.addWidget(self.model_path_edit) browse_model = QPushButton("Browse…") browse_model.clicked.connect(self._action_browse_model) path_layout.addWidget(browse_model) - form.addRow("Model path", path_layout) - - self.shuffle_edit = QLineEdit() - self.shuffle_edit.setPlaceholderText("Optional integer") - form.addRow("Shuffle", self.shuffle_edit) - - self.training_edit = QLineEdit() - self.training_edit.setPlaceholderText("Optional integer") - form.addRow("Training set index", self.training_edit) + form.addRow("Model directory", path_layout) - self.processor_combo = QComboBox() - self.processor_combo.setEditable(True) - self.processor_combo.addItems(["cpu", "gpu", "tensorrt"]) - form.addRow("Processor", self.processor_combo) - - self.processor_args_edit = QPlainTextEdit() - self.processor_args_edit.setPlaceholderText('{"device": 0}') - self.processor_args_edit.setFixedHeight(60) - form.addRow("Processor args", self.processor_args_edit) + self.model_type_combo = QComboBox() + self.model_type_combo.addItem("Base (TensorFlow)", "base") + self.model_type_combo.addItem("PyTorch", "pytorch") + self.model_type_combo.setCurrentIndex(0) # Default to base + form.addRow("Model type", self.model_type_combo) self.additional_options_edit = QPlainTextEdit() - self.additional_options_edit.setPlaceholderText('{"allow_growth": true}') + self.additional_options_edit.setPlaceholderText('') self.additional_options_edit.setFixedHeight(60) form.addRow("Additional options", self.additional_options_edit) @@ -229,6 +254,10 @@ def _build_dlc_group(self) -> QGroupBox: self.show_predictions_checkbox.setChecked(True) form.addRow(self.show_predictions_checkbox) + self.dlc_stats_label = QLabel("DLC processor idle") + self.dlc_stats_label.setWordWrap(True) + form.addRow("Performance", self.dlc_stats_label) + return group def _build_recording_group(self) -> QGroupBox: @@ -256,7 +285,7 @@ def _build_recording_group(self) -> QGroupBox: self.codec_combo = QComboBox() self.codec_combo.addItems(["h264_nvenc", "libx264"]) - self.codec_combo.setCurrentText("libx264") + self.codec_combo.setCurrentText("h264_nvenc") form.addRow("Codec", self.codec_combo) self.crf_spin = QSpinBox() @@ -273,6 +302,10 @@ def _build_recording_group(self) -> QGroupBox: buttons.addWidget(self.stop_record_button) form.addRow(buttons) + self.recording_stats_label = QLabel(self._last_recorder_summary) + self.recording_stats_label.setWordWrap(True) + form.addRow("Performance", self.recording_stats_label) + return group # ------------------------------------------------------------------ signals @@ -308,6 +341,11 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.camera_width.setValue(int(camera.width)) self.camera_height.setValue(int(camera.height)) self.camera_fps.setValue(float(camera.fps)) + + # Set exposure and gain from config + self.camera_exposure.setValue(int(camera.exposure)) + self.camera_gain.setValue(float(camera.gain)) + backend_name = camera.backend or "opencv" index = self.camera_backend.findData(backend_name) if index >= 0: @@ -318,19 +356,23 @@ def _apply_config(self, config: ApplicationSettings) -> None: self._select_camera_by_index( camera.index, fallback_text=camera.name or str(camera.index) ) + + # Set advanced properties (exposure and gain are now separate fields) self.camera_properties_edit.setPlainText( json.dumps(camera.properties, indent=2) if camera.properties else "" ) + self._active_camera_settings = None dlc = config.dlc self.model_path_edit.setText(dlc.model_path) - self.shuffle_edit.setText("" if dlc.shuffle is None else str(dlc.shuffle)) - self.training_edit.setText( - "" if dlc.trainingsetindex is None else str(dlc.trainingsetindex) - ) - self.processor_combo.setCurrentText(dlc.processor or "cpu") - self.processor_args_edit.setPlainText(json.dumps(dlc.processor_args, indent=2)) + + # Set model type + model_type = dlc.model_type or "base" + model_type_index = self.model_type_combo.findData(model_type) + if model_type_index >= 0: + self.model_type_combo.setCurrentIndex(model_type_index) + self.additional_options_edit.setPlainText( json.dumps(dlc.additional_options, indent=2) ) @@ -361,6 +403,17 @@ def _camera_settings_from_ui(self) -> CameraSettings: raise ValueError("Camera selection must provide a numeric index") backend_text = self._current_backend_name() properties = self._parse_json(self.camera_properties_edit.toPlainText()) + + # Get exposure and gain from explicit UI fields + exposure = self.camera_exposure.value() + gain = self.camera_gain.value() + + # Also add to properties dict for backward compatibility with camera backends + if exposure > 0: + properties["exposure"] = exposure + if gain > 0.0: + properties["gain"] = gain + name_text = self.camera_index.currentText().strip() settings = CameraSettings( name=name_text or f"Camera {index}", @@ -369,6 +422,8 @@ def _camera_settings_from_ui(self) -> CameraSettings: height=self.camera_height.value(), fps=self.camera_fps.value(), backend=backend_text or "opencv", + exposure=exposure, + gain=gain, properties=properties, ) return settings.apply_defaults() @@ -491,12 +546,6 @@ def _current_camera_index_value(self) -> Optional[int]: except ValueError: return None - def _parse_optional_int(self, value: str) -> Optional[int]: - text = value.strip() - if not text: - return None - return int(text) - def _parse_json(self, value: str) -> dict: text = value.strip() if not text: @@ -504,12 +553,13 @@ def _parse_json(self, value: str) -> dict: return json.loads(text) def _dlc_settings_from_ui(self) -> DLCProcessorSettings: + model_type = self.model_type_combo.currentData() + if not isinstance(model_type, str): + model_type = "base" + return DLCProcessorSettings( model_path=self.model_path_edit.text().strip(), - shuffle=self._parse_optional_int(self.shuffle_edit.text()), - trainingsetindex=self._parse_optional_int(self.training_edit.text()), - processor=self.processor_combo.currentText().strip() or "cpu", - processor_args=self._parse_json(self.processor_args_edit.toPlainText()), + model_type=model_type, additional_options=self._parse_json( self.additional_options_edit.toPlainText() ), @@ -570,11 +620,11 @@ def _save_config_to_path(self, path: Path) -> None: self.statusBar().showMessage(f"Saved configuration to {path}", 5000) def _action_browse_model(self) -> None: - file_name, _ = QFileDialog.getOpenFileName( - self, "Select DLCLive model", str(Path.home()), "All files (*.*)" + directory = QFileDialog.getExistingDirectory( + self, "Select DLCLive model directory", PATH2MODELS ) - if file_name: - self.model_path_edit.setText(file_name) + if directory: + self.model_path_edit.setText(directory) def _action_browse_directory(self) -> None: directory = QFileDialog.getExistingDirectory( @@ -593,19 +643,7 @@ def _on_rotation_changed(self, _index: int) -> None: rotated = self._apply_rotation(self._raw_frame) self._current_frame = rotated self._last_pose = None - self._update_video_display(rotated) - - def _on_backend_changed(self, *_args: object) -> None: - self._refresh_camera_indices(keep_current=False) - - def _on_rotation_changed(self, _index: int) -> None: - data = self.rotation_combo.currentData() - self._rotation_degrees = int(data) if isinstance(data, int) else 0 - if self._raw_frame is not None: - rotated = self._apply_rotation(self._raw_frame) - self._current_frame = rotated - self._last_pose = None - self._update_video_display(rotated) + self._display_frame(rotated, force=False) # ------------------------------------------------------------------ camera control def _start_preview(self) -> None: @@ -622,6 +660,10 @@ def _start_preview(self) -> None: self._raw_frame = None self._last_pose = None self._dlc_active = False + self._camera_frame_times.clear() + self._last_display_time = 0.0 + if hasattr(self, "camera_stats_label"): + self.camera_stats_label.setText("Camera starting…") self.statusBar().showMessage("Starting camera preview…", 3000) self._update_inference_buttons() self._update_camera_controls_enabled() @@ -636,6 +678,10 @@ def _stop_preview(self) -> None: self.statusBar().showMessage("Stopping camera preview…", 3000) self.camera_controller.stop() self._stop_inference(show_message=False) + self._camera_frame_times.clear() + self._last_display_time = 0.0 + if hasattr(self, "camera_stats_label"): + self.camera_stats_label.setText("Camera idle") def _on_camera_started(self, settings: CameraSettings) -> None: self._active_camera_settings = settings @@ -675,6 +721,10 @@ def _on_camera_stopped(self) -> None: self.video_label.setPixmap(QPixmap()) self.video_label.setText("Camera preview not started") self.statusBar().showMessage("Camera preview stopped", 3000) + self._camera_frame_times.clear() + self._last_display_time = 0.0 + if hasattr(self, "camera_stats_label"): + self.camera_stats_label.setText("Camera idle") self._update_inference_buttons() self._update_camera_controls_enabled() @@ -711,6 +761,8 @@ def _update_camera_controls_enabled(self) -> None: self.camera_width, self.camera_height, self.camera_fps, + self.camera_exposure, + self.camera_gain, self.camera_properties_edit, self.rotation_combo, self.codec_combo, @@ -719,6 +771,90 @@ def _update_camera_controls_enabled(self) -> None: for widget in widgets: widget.setEnabled(allow_changes) + def _track_camera_frame(self) -> None: + now = time.perf_counter() + self._camera_frame_times.append(now) + window_seconds = 5.0 + while self._camera_frame_times and now - self._camera_frame_times[0] > window_seconds: + self._camera_frame_times.popleft() + + def _display_frame(self, frame: np.ndarray, *, force: bool = False) -> None: + if frame is None: + return + now = time.perf_counter() + if not force and (now - self._last_display_time) < self._display_interval: + return + self._last_display_time = now + self._update_video_display(frame) + + def _compute_fps(self, times: deque[float]) -> float: + if len(times) < 2: + return 0.0 + duration = times[-1] - times[0] + if duration <= 0: + return 0.0 + return (len(times) - 1) / duration + + def _format_recorder_stats(self, stats: RecorderStats) -> str: + latency_ms = stats.last_latency * 1000.0 + avg_ms = stats.average_latency * 1000.0 + buffer_ms = stats.buffer_seconds * 1000.0 + write_fps = stats.write_fps + enqueue = stats.frames_enqueued + written = stats.frames_written + dropped = stats.dropped_frames + return ( + f"{written}/{enqueue} frames | write {write_fps:.1f} fps | " + f"latency {latency_ms:.1f} ms (avg {avg_ms:.1f} ms) | " + f"queue {stats.queue_size} (~{buffer_ms:.0f} ms) | dropped {dropped}" + ) + + def _format_dlc_stats(self, stats: ProcessorStats) -> str: + """Format DLC processor statistics for display.""" + latency_ms = stats.last_latency * 1000.0 + avg_ms = stats.average_latency * 1000.0 + processing_fps = stats.processing_fps + enqueue = stats.frames_enqueued + processed = stats.frames_processed + dropped = stats.frames_dropped + return ( + f"{processed}/{enqueue} frames | inference {processing_fps:.1f} fps | " + f"latency {latency_ms:.1f} ms (avg {avg_ms:.1f} ms) | " + f"queue {stats.queue_size} | dropped {dropped}" + ) + + def _update_metrics(self) -> None: + if hasattr(self, "camera_stats_label"): + if self.camera_controller.is_running(): + fps = self._compute_fps(self._camera_frame_times) + if fps > 0: + self.camera_stats_label.setText(f"{fps:.1f} fps (last 5 s)") + else: + self.camera_stats_label.setText("Measuring…") + else: + self.camera_stats_label.setText("Camera idle") + + if hasattr(self, "dlc_stats_label"): + if self._dlc_active and self._dlc_initialized: + stats = self.dlc_processor.get_stats() + summary = self._format_dlc_stats(stats) + self.dlc_stats_label.setText(summary) + else: + self.dlc_stats_label.setText("DLC processor idle") + + if hasattr(self, "recording_stats_label"): + if self._video_recorder is not None: + stats = self._video_recorder.get_stats() + if stats is not None: + summary = self._format_recorder_stats(stats) + self._last_recorder_summary = summary + self.recording_stats_label.setText(summary) + elif not self._video_recorder.is_running: + self._last_recorder_summary = "Recorder idle" + self.recording_stats_label.setText(self._last_recorder_summary) + else: + self.recording_stats_label.setText(self._last_recorder_summary) + def _start_inference(self) -> None: if self._dlc_active: self.statusBar().showMessage("Pose inference already running", 3000) @@ -734,17 +870,30 @@ def _start_inference(self) -> None: self.dlc_processor.reset() self._last_pose = None self._dlc_active = True - self.statusBar().showMessage("Starting pose inference…", 3000) - self._update_inference_buttons() + self._dlc_initialized = False + + # Update button to show initializing state + self.start_inference_button.setText("Initializing DLCLive!") + self.start_inference_button.setStyleSheet("background-color: #4A90E2; color: white;") + self.start_inference_button.setEnabled(False) + self.stop_inference_button.setEnabled(True) + + self.statusBar().showMessage("Initializing DLCLive…", 3000) self._update_camera_controls_enabled() def _stop_inference(self, show_message: bool = True) -> None: was_active = self._dlc_active self._dlc_active = False + self._dlc_initialized = False self.dlc_processor.reset() self._last_pose = None + + # Reset button appearance + self.start_inference_button.setText("Start pose inference") + self.start_inference_button.setStyleSheet("") + if self._current_frame is not None: - self._update_video_display(self._current_frame) + self._display_frame(self._current_frame, force=True) if was_active and show_message: self.statusBar().showMessage("Pose inference stopped", 3000) self._update_inference_buttons() @@ -780,6 +929,7 @@ def _start_recording(self) -> None: codec=recording.codec, crf=recording.crf, ) + self._last_drop_warning = 0.0 try: self._video_recorder.start() except Exception as exc: # pragma: no cover - runtime error @@ -788,16 +938,35 @@ def _start_recording(self) -> None: return self.start_record_button.setEnabled(False) self.stop_record_button.setEnabled(True) + if hasattr(self, "recording_stats_label"): + self._last_recorder_summary = "Recorder running…" + self.recording_stats_label.setText(self._last_recorder_summary) self.statusBar().showMessage(f"Recording to {output_path}", 5000) self._update_camera_controls_enabled() def _stop_recording(self) -> None: if not self._video_recorder: return - self._video_recorder.stop() + recorder = self._video_recorder + recorder.stop() + stats = recorder.get_stats() if recorder is not None else None self._video_recorder = None self.start_record_button.setEnabled(True) self.stop_record_button.setEnabled(False) + if hasattr(self, "recording_stats_label"): + if stats is not None: + summary = self._format_recorder_stats(stats) + else: + summary = "Recorder idle" + self._last_recorder_summary = summary + self.recording_stats_label.setText(summary) + else: + self._last_recorder_summary = ( + self._format_recorder_stats(stats) + if stats is not None + else "Recorder idle" + ) + self._last_drop_warning = 0.0 self.statusBar().showMessage("Recording stopped", 3000) self._update_camera_controls_enabled() @@ -808,26 +977,35 @@ def _on_frame_ready(self, frame_data: FrameData) -> None: frame = self._apply_rotation(raw_frame) frame = np.ascontiguousarray(frame) self._current_frame = frame + self._track_camera_frame() if self._active_camera_settings is not None: height, width = frame.shape[:2] self._active_camera_settings.width = int(width) self._active_camera_settings.height = int(height) if self._video_recorder and self._video_recorder.is_running: try: - self._video_recorder.write(frame) + success = self._video_recorder.write(frame) + if not success: + now = time.perf_counter() + if now - self._last_drop_warning > 1.0: + self.statusBar().showMessage( + "Recorder backlog full; dropping frames", 2000 + ) + self._last_drop_warning = now except RuntimeError as exc: self._show_error(str(exc)) self._stop_recording() if self._dlc_active: self.dlc_processor.enqueue_frame(frame, frame_data.timestamp) - self._update_video_display(frame) + self._display_frame(frame) def _on_pose_ready(self, result: PoseResult) -> None: if not self._dlc_active: return self._last_pose = result + logging.info(f"Pose result: {result.pose}, Timestamp: {result.timestamp}") if self._current_frame is not None: - self._update_video_display(self._current_frame) + self._display_frame(self._current_frame, force=True) def _on_dlc_error(self, message: str) -> None: self._stop_inference(show_message=False) @@ -858,7 +1036,7 @@ def _apply_rotation(self, frame: np.ndarray) -> np.ndarray: def _on_show_predictions_changed(self, _state: int) -> None: if self._current_frame is not None: - self._update_video_display(self._current_frame) + self._display_frame(self._current_frame, force=True) def _draw_pose(self, frame: np.ndarray, pose: np.ndarray) -> np.ndarray: overlay = frame.copy() @@ -873,9 +1051,19 @@ def _draw_pose(self, frame: np.ndarray, pose: np.ndarray) -> np.ndarray: def _on_dlc_initialised(self, success: bool) -> None: if success: - self.statusBar().showMessage("DLCLive initialised", 3000) + self._dlc_initialized = True + # Update button to show running state + self.start_inference_button.setText("DLCLive running!") + self.start_inference_button.setStyleSheet("background-color: #4CAF50; color: white;") + self.statusBar().showMessage("DLCLive initialized successfully", 3000) else: - self.statusBar().showMessage("DLCLive initialisation failed", 3000) + self._dlc_initialized = False + # Reset button on failure + self.start_inference_button.setText("Start pose inference") + self.start_inference_button.setStyleSheet("") + self.statusBar().showMessage("DLCLive initialization failed", 5000) + # Stop inference since initialization failed + self._stop_inference(show_message=False) # ------------------------------------------------------------------ helpers def _show_error(self, message: str) -> None: @@ -889,6 +1077,8 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha if self._video_recorder and self._video_recorder.is_running: self._video_recorder.stop() self.dlc_processor.shutdown() + if hasattr(self, "_metrics_timer"): + self._metrics_timer.stop() super().closeEvent(event) diff --git a/dlclivegui/video_recorder.py b/dlclivegui/video_recorder.py index ff52fdb..d729b02 100644 --- a/dlclivegui/video_recorder.py +++ b/dlclivegui/video_recorder.py @@ -1,6 +1,12 @@ """Video recording support using the vidgear library.""" from __future__ import annotations +import logging +import queue +import threading +import time +from collections import deque +from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, Optional, Tuple @@ -12,6 +18,26 @@ WriteGear = None # type: ignore[assignment] +logger = logging.getLogger(__name__) + + +@dataclass +class RecorderStats: + """Snapshot of recorder throughput metrics.""" + + frames_enqueued: int + frames_written: int + dropped_frames: int + queue_size: int + average_latency: float + last_latency: float + write_fps: float + buffer_seconds: float + + +_SENTINEL = object() + + class VideoRecorder: """Thin wrapper around :class:`vidgear.gears.WriteGear`.""" @@ -22,17 +48,31 @@ def __init__( frame_rate: Optional[float] = None, codec: str = "libx264", crf: int = 23, + buffer_size: int = 240, ): self._output = Path(output) - self._writer: Optional[WriteGear] = None + self._writer: Optional[Any] = None self._frame_size = frame_size self._frame_rate = frame_rate self._codec = codec self._crf = int(crf) + self._buffer_size = max(1, int(buffer_size)) + self._queue: Optional[queue.Queue[Any]] = None + self._writer_thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self._stats_lock = threading.Lock() + self._frames_enqueued = 0 + self._frames_written = 0 + self._dropped_frames = 0 + self._total_latency = 0.0 + self._last_latency = 0.0 + self._written_times: deque[float] = deque(maxlen=600) + self._encode_error: Optional[Exception] = None + self._last_log_time = 0.0 @property def is_running(self) -> bool: - return self._writer is not None + return self._writer_thread is not None and self._writer_thread.is_alive() def start(self) -> None: if WriteGear is None: @@ -54,6 +94,21 @@ def start(self) -> None: self._output.parent.mkdir(parents=True, exist_ok=True) self._writer = WriteGear(output=str(self._output), **writer_kwargs) + self._queue = queue.Queue(maxsize=self._buffer_size) + self._frames_enqueued = 0 + self._frames_written = 0 + self._dropped_frames = 0 + self._total_latency = 0.0 + self._last_latency = 0.0 + self._written_times.clear() + self._encode_error = None + self._stop_event.clear() + self._writer_thread = threading.Thread( + target=self._writer_loop, + name="VideoRecorderWriter", + daemon=True, + ) + self._writer_thread.start() def configure_stream( self, frame_size: Tuple[int, int], frame_rate: Optional[float] @@ -61,9 +116,12 @@ def configure_stream( self._frame_size = frame_size self._frame_rate = frame_rate - def write(self, frame: np.ndarray) -> None: - if self._writer is None: - return + def write(self, frame: np.ndarray) -> bool: + if not self.is_running or self._queue is None: + return False + error = self._current_error() + if error is not None: + raise RuntimeError(f"Video encoding failed: {error}") from error if frame.dtype != np.uint8: frame_float = frame.astype(np.float32, copy=False) max_val = float(frame_float.max()) if frame_float.size else 0.0 @@ -75,19 +133,139 @@ def write(self, frame: np.ndarray) -> None: frame = np.repeat(frame[:, :, None], 3, axis=2) frame = np.ascontiguousarray(frame) try: - self._writer.write(frame) - except OSError as exc: - writer = self._writer - self._writer = None - if writer is not None: - try: - writer.close() - except Exception: - pass - raise RuntimeError(f"Video encoding failed: {exc}") from exc + assert self._queue is not None + self._queue.put(frame, block=False) + except queue.Full: + with self._stats_lock: + self._dropped_frames += 1 + queue_size = self._queue.qsize() if self._queue is not None else -1 + logger.warning( + "Video recorder queue full; dropping frame. queue=%d buffer=%d", + queue_size, + self._buffer_size, + ) + return False + with self._stats_lock: + self._frames_enqueued += 1 + return True def stop(self) -> None: - if self._writer is None: + if self._writer is None and not self.is_running: return - self._writer.close() + self._stop_event.set() + if self._queue is not None: + try: + self._queue.put_nowait(_SENTINEL) + except queue.Full: + self._queue.put(_SENTINEL) + if self._writer_thread is not None: + self._writer_thread.join(timeout=5.0) + if self._writer_thread.is_alive(): + logger.warning("Video recorder thread did not terminate cleanly") + if self._writer is not None: + try: + self._writer.close() + except Exception: + logger.exception("Failed to close WriteGear cleanly") + self._writer = None + self._writer_thread = None + self._queue = None + + def get_stats(self) -> Optional[RecorderStats]: + if ( + self._writer is None + and not self.is_running + and self._queue is None + and self._frames_enqueued == 0 + and self._frames_written == 0 + and self._dropped_frames == 0 + ): + return None + queue_size = self._queue.qsize() if self._queue is not None else 0 + with self._stats_lock: + frames_enqueued = self._frames_enqueued + frames_written = self._frames_written + dropped = self._dropped_frames + avg_latency = ( + self._total_latency / self._frames_written + if self._frames_written + else 0.0 + ) + last_latency = self._last_latency + write_fps = self._compute_write_fps_locked() + buffer_seconds = queue_size * avg_latency if avg_latency > 0 else 0.0 + return RecorderStats( + frames_enqueued=frames_enqueued, + frames_written=frames_written, + dropped_frames=dropped, + queue_size=queue_size, + average_latency=avg_latency, + last_latency=last_latency, + write_fps=write_fps, + buffer_seconds=buffer_seconds, + ) + + def _writer_loop(self) -> None: + assert self._queue is not None + while True: + try: + item = self._queue.get(timeout=0.1) + except queue.Empty: + if self._stop_event.is_set(): + break + continue + if item is _SENTINEL: + self._queue.task_done() + break + frame = item + start = time.perf_counter() + try: + assert self._writer is not None + self._writer.write(frame) + except OSError as exc: + with self._stats_lock: + self._encode_error = exc + logger.exception("Video encoding failed while writing frame") + self._queue.task_done() + self._stop_event.set() + break + elapsed = time.perf_counter() - start + now = time.perf_counter() + with self._stats_lock: + self._frames_written += 1 + self._total_latency += elapsed + self._last_latency = elapsed + self._written_times.append(now) + if now - self._last_log_time >= 1.0: + fps = self._compute_write_fps_locked() + queue_size = self._queue.qsize() + logger.info( + "Recorder throughput: %.2f fps, latency %.2f ms, queue=%d", + fps, + elapsed * 1000.0, + queue_size, + ) + self._last_log_time = now + self._queue.task_done() + self._finalize_writer() + + def _finalize_writer(self) -> None: + writer = self._writer self._writer = None + if writer is not None: + try: + writer.close() + except Exception: + logger.exception("Failed to close WriteGear during finalisation") + + def _compute_write_fps_locked(self) -> float: + if len(self._written_times) < 2: + return 0.0 + duration = self._written_times[-1] - self._written_times[0] + if duration <= 0: + return 0.0 + return (len(self._written_times) - 1) / duration + + def _current_error(self) -> Optional[Exception]: + with self._stats_lock: + return self._encode_error diff --git a/setup.py b/setup.py index 163f8f0..a254101 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/DeepLabCut/DeepLabCut-live-GUI", - python_requires=">=3.11", + python_requires=">=3.10", install_requires=[ "deeplabcut-live", "PyQt6", @@ -25,7 +25,7 @@ ], extras_require={ "basler": ["pypylon"], - "gentl": ["pygobject"], + "gentl": ["harvesters"], }, packages=setuptools.find_packages(), include_package_data=True, From 6402566d540354cfb6cc894681a9ffee7af9db1a Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Thu, 23 Oct 2025 16:00:39 +0200 Subject: [PATCH 08/69] Added processors --- dlclivegui/processors/PLUGIN_SYSTEM.md | 191 +++++++ dlclivegui/processors/dlc_processor_socket.py | 509 ++++++++++++++++++ dlclivegui/processors/processor_utils.py | 83 +++ 3 files changed, 783 insertions(+) create mode 100644 dlclivegui/processors/PLUGIN_SYSTEM.md create mode 100644 dlclivegui/processors/dlc_processor_socket.py create mode 100644 dlclivegui/processors/processor_utils.py diff --git a/dlclivegui/processors/PLUGIN_SYSTEM.md b/dlclivegui/processors/PLUGIN_SYSTEM.md new file mode 100644 index 0000000..b02402d --- /dev/null +++ b/dlclivegui/processors/PLUGIN_SYSTEM.md @@ -0,0 +1,191 @@ +# DLC Processor Plugin System + +This folder contains a plugin-style architecture for DLC processors that allows GUI tools to discover and instantiate processors dynamically. + +## Architecture + +### 1. Processor Registry + +Each processor file should define a `PROCESSOR_REGISTRY` dictionary and helper functions: + +```python +# Registry for GUI discovery +PROCESSOR_REGISTRY = {} + +# At end of file, register your processors +PROCESSOR_REGISTRY["MyProcessor_socket"] = MyProcessor_socket +``` + +### 2. Processor Metadata + +Each processor class should define metadata attributes for GUI discovery: + +```python +class MyProcessor_socket(BaseProcessor_socket): + # Metadata for GUI discovery + PROCESSOR_NAME = "Mouse Pose Processor" # Human-readable name + PROCESSOR_DESCRIPTION = "Calculates mouse center, heading, and head angle" + PROCESSOR_PARAMS = { + "bind": { + "type": "tuple", + "default": ("0.0.0.0", 6000), + "description": "Server address (host, port)" + }, + "use_filter": { + "type": "bool", + "default": False, + "description": "Apply One-Euro filter" + }, + # ... more parameters + } +``` + +### 3. Discovery Functions + +Two helper functions enable GUI discovery: + +```python +def get_available_processors(): + """Returns dict of available processors with metadata.""" + +def instantiate_processor(class_name, **kwargs): + """Instantiates a processor by name with given parameters.""" +``` + +## GUI Integration + +### Simple Usage + +```python +from dlc_processor_socket import get_available_processors, instantiate_processor + +# 1. Get available processors +processors = get_available_processors() + +# 2. Display to user (e.g., in dropdown) +for class_name, info in processors.items(): + print(f"{info['name']} - {info['description']}") + +# 3. User selects "MyProcessor_socket" +selected_class = "MyProcessor_socket" + +# 4. Show parameter form based on info['params'] +processor_info = processors[selected_class] +for param_name, param_info in processor_info['params'].items(): + # Create input widget for param_type and default value + pass + +# 5. Instantiate with user's values +processor = instantiate_processor( + selected_class, + bind=("127.0.0.1", 7000), + use_filter=True +) +``` + +### Scanning Multiple Files + +To scan a folder for processor files: + +```python +import importlib.util +from pathlib import Path + +def load_processors_from_file(file_path): + """Load processors from a single file.""" + spec = importlib.util.spec_from_file_location("processors", file_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + if hasattr(module, 'get_available_processors'): + return module.get_available_processors() + return {} + +# Scan folder +for py_file in Path("dlc_processors").glob("*.py"): + processors = load_processors_from_file(py_file) + # Display processors to user +``` + +## Examples + +### 1. Command-line Example + +```bash +python example_gui_usage.py +``` + +This demonstrates: +- Loading processors +- Displaying metadata +- Instantiating with default/custom parameters +- Simulated GUI workflow + +### 2. tkinter GUI + +```bash +python processor_gui_simple.py +``` + +This provides a full GUI with: +- Dropdown to select processor +- Auto-generated parameter form +- Create/Stop buttons +- Status display + +## Adding New Processors + +To make a new processor discoverable: + +1. **Define metadata attributes:** +```python +class MyNewProcessor(BaseProcessor_socket): + PROCESSOR_NAME = "My New Processor" + PROCESSOR_DESCRIPTION = "Does something cool" + PROCESSOR_PARAMS = { + "my_param": { + "type": "bool", + "default": True, + "description": "Enable cool feature" + } + } +``` + +2. **Register in PROCESSOR_REGISTRY:** +```python +PROCESSOR_REGISTRY["MyNewProcessor"] = MyNewProcessor +``` + +3. **Done!** GUI will automatically discover it. + +## Parameter Types + +Supported parameter types in `PROCESSOR_PARAMS`: + +- `"bool"` - Boolean checkbox +- `"int"` - Integer input +- `"float"` - Float input +- `"str"` - String input +- `"bytes"` - String that gets encoded to bytes +- `"tuple"` - Tuple (e.g., `(host, port)`) +- `"dict"` - Dictionary (e.g., filter parameters) +- `"list"` - List + +## Benefits + +1. **No hardcoding** - GUI doesn't need to know about specific processors +2. **Easy extension** - Add new processors without modifying GUI code +3. **Self-documenting** - Parameters include descriptions +4. **Type-safe** - Parameter metadata includes type information +5. **Modular** - Each processor file can be independent + +## File Structure + +``` +dlc_processors/ +├── dlc_processor_socket.py # Base + MyProcessor with registry +├── my_custom_processor.py # Your custom processor (with registry) +├── example_gui_usage.py # Command-line example +├── processor_gui_simple.py # tkinter GUI example +└── PLUGIN_SYSTEM.md # This file +``` diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py new file mode 100644 index 0000000..bd183af --- /dev/null +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -0,0 +1,509 @@ +import logging +import pickle +import time +from collections import deque +from math import acos, atan2, copysign, degrees, pi, sqrt +from multiprocessing.connection import Listener +from threading import Event, Thread + +import numpy as np +from dlclive import Processor + +LOG = logging.getLogger("dlc_processor_socket") +LOG.setLevel(logging.INFO) +_handler = logging.StreamHandler() +_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) +LOG.addHandler(_handler) + + +# Registry for GUI discovery +PROCESSOR_REGISTRY = {} + + +class OneEuroFilter: + def __init__(self, t0, x0, dx0=None, min_cutoff=1.0, beta=0.0, d_cutoff=1.0): + self.min_cutoff = min_cutoff + self.beta = beta + self.d_cutoff = d_cutoff + self.x_prev = x0 + if dx0 is None: + dx0 = np.zeros_like(x0) + self.dx_prev = dx0 + self.t_prev = t0 + + @staticmethod + def smoothing_factor(t_e, cutoff): + r = 2 * pi * cutoff * t_e + return r / (r + 1) + + @staticmethod + def exponential_smoothing(alpha, x, x_prev): + return alpha * x + (1 - alpha) * x_prev + + def __call__(self, t, x): + t_e = t - self.t_prev + + a_d = self.smoothing_factor(t_e, self.d_cutoff) + dx = (x - self.x_prev) / t_e + dx_hat = self.exponential_smoothing(a_d, dx, self.dx_prev) + + cutoff = self.min_cutoff + self.beta * abs(dx_hat) + a = self.smoothing_factor(t_e, cutoff) + x_hat = self.exponential_smoothing(a, x, self.x_prev) + + self.x_prev = x_hat + self.dx_prev = dx_hat + self.t_prev = t + + return x_hat + + +class BaseProcessor_socket(Processor): + """ + Base DLC Processor with multi-client broadcasting support. + + Handles network connections, timing, and data logging. + Subclasses should implement custom pose processing logic. + """ + + # Metadata for GUI discovery + PROCESSOR_NAME = "Base Socket Processor" + PROCESSOR_DESCRIPTION = "Base class for socket-based processors with multi-client support" + PROCESSOR_PARAMS = { + "bind": { + "type": "tuple", + "default": ("0.0.0.0", 6000), + "description": "Server address (host, port)" + }, + "authkey": { + "type": "bytes", + "default": b"secret password", + "description": "Authentication key for clients" + }, + "use_perf_counter": { + "type": "bool", + "default": False, + "description": "Use time.perf_counter() instead of time.time()" + }, + "save_original": { + "type": "bool", + "default": False, + "description": "Save raw pose arrays for analysis" + } + } + + def __init__( + self, + bind=("0.0.0.0", 6000), + authkey=b"secret password", + use_perf_counter=False, + save_original=False, + ): + """ + Initialize base processor with socket server. + + Args: + bind: (host, port) tuple for server binding + authkey: Authentication key for client connections + use_perf_counter: If True, use time.perf_counter() instead of time.time() + save_original: If True, save raw pose arrays for analysis + """ + super().__init__() + + # Network setup + self.address = bind + self.authkey = authkey + self.listener = Listener(bind, authkey=authkey) + self._stop = Event() + self.conns = set() + + # Start accept loop in background + Thread(target=self._accept_loop, name="DLCAccept", daemon=True).start() + + # Timing function + self.timing_func = time.perf_counter if use_perf_counter else time.time + self.start_time = self.timing_func() + + # Data storage + self.time_stamp = deque() + self.step = deque() + self.frame_time = deque() + self.pose_time = deque() + self.original_pose = deque() + + # State + self.curr_step = 0 + self.save_original = save_original + + def _accept_loop(self): + """Background thread to accept new client connections.""" + LOG.info(f"DLC Processor listening on {self.address[0]}:{self.address[1]}") + while not self._stop.is_set(): + try: + c = self.listener.accept() + LOG.info(f"Client connected from {self.listener.last_accepted}") + self.conns.add(c) + # Start RX loop for this connection (in case clients send data) + Thread(target=self._rx_loop, args=(c,), name="DLCRX", daemon=True).start() + except (OSError, EOFError): + break + + def _rx_loop(self, c): + """Background thread to handle receive from a client (detects disconnects).""" + while not self._stop.is_set(): + try: + if c.poll(0.05): + msg = c.recv() + # Optional: handle client messages here + except (EOFError, OSError, BrokenPipeError): + break + try: + c.close() + except Exception: + pass + self.conns.discard(c) + LOG.info("Client disconnected") + + def broadcast(self, payload): + """Send payload to all connected clients.""" + dead = [] + for c in list(self.conns): + try: + c.send(payload) + except (EOFError, OSError, BrokenPipeError): + dead.append(c) + for c in dead: + try: + c.close() + except Exception: + pass + self.conns.discard(c) + + def process(self, pose, **kwargs): + """ + Process pose and broadcast to clients. + + This base implementation just saves original pose and broadcasts it. + Subclasses should override to add custom processing. + + Args: + pose: DLC pose array (N_keypoints x 3) with [x, y, confidence] + **kwargs: Additional metadata (frame_time, pose_time, etc.) + + Returns: + pose: Unmodified pose array + """ + curr_time = self.timing_func() + + # Save original pose if requested + if self.save_original: + self.original_pose.append(pose.copy()) + + # Update step counter + self.curr_step = self.curr_step + 1 + + # Store metadata + self.time_stamp.append(curr_time) + self.step.append(self.curr_step) + self.frame_time.append(kwargs.get("frame_time", -1)) + if "pose_time" in kwargs: + self.pose_time.append(kwargs["pose_time"]) + + # Broadcast raw pose to all connected clients + payload = [curr_time, pose] + self.broadcast(payload) + + return pose + + def stop(self): + """Stop the processor and close all connections.""" + self._stop.set() + try: + self.listener.close() + except Exception: + pass + for c in list(self.conns): + try: + c.close() + except Exception: + pass + self.conns.discard(c) + LOG.info("Processor stopped, all connections closed") + + def save(self, file=None): + """Save logged data to file.""" + save_code = 0 + if file: + LOG.info(f"Saving data to {file}") + try: + save_dict = self.get_data() + pickle.dump(save_dict, open(file, "wb")) + save_code = 1 + except Exception as e: + LOG.error(f"Save failed: {e}") + save_code = -1 + return save_code + + def get_data(self): + """Get logged data as dictionary.""" + save_dict = dict() + if self.save_original: + save_dict["original_pose"] = np.array(self.original_pose) + save_dict["start_time"] = self.start_time + save_dict["time_stamp"] = np.array(self.time_stamp) + save_dict["step"] = np.array(self.step) + save_dict["frame_time"] = np.array(self.frame_time) + save_dict["pose_time"] = np.array(self.pose_time) if self.pose_time else None + save_dict["use_perf_counter"] = self.timing_func == time.perf_counter + return save_dict + + +class MyProcessor_socket(BaseProcessor_socket): + """ + DLC Processor with pose calculations (center, heading, head angle) and optional filtering. + + Calculates: + - center: Weighted average of head keypoints + - heading: Body orientation (degrees) + - head_angle: Head rotation relative to body (radians) + + Broadcasts: [timestamp, center_x, center_y, heading, head_angle] + """ + + # Metadata for GUI discovery + PROCESSOR_NAME = "Mouse Pose Processor" + PROCESSOR_DESCRIPTION = "Calculates mouse center, heading, and head angle with optional One-Euro filtering" + PROCESSOR_PARAMS = { + "bind": { + "type": "tuple", + "default": ("0.0.0.0", 6000), + "description": "Server address (host, port)" + }, + "authkey": { + "type": "bytes", + "default": b"secret password", + "description": "Authentication key for clients" + }, + "use_perf_counter": { + "type": "bool", + "default": False, + "description": "Use time.perf_counter() instead of time.time()" + }, + "use_filter": { + "type": "bool", + "default": False, + "description": "Apply One-Euro filter to calculated values" + }, + "filter_kwargs": { + "type": "dict", + "default": {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0}, + "description": "One-Euro filter parameters (min_cutoff, beta, d_cutoff)" + }, + "save_original": { + "type": "bool", + "default": False, + "description": "Save raw pose arrays for analysis" + } + } + + def __init__( + self, + bind=("0.0.0.0", 6000), + authkey=b"secret password", + use_perf_counter=False, + use_filter=False, + filter_kwargs=None, + save_original=False, + ): + """ + DLC Processor with multi-client broadcasting support. + + Args: + bind: (host, port) tuple for server binding + authkey: Authentication key for client connections + use_perf_counter: If True, use time.perf_counter() instead of time.time() + use_filter: If True, apply One-Euro filter to pose data + filter_kwargs: Dict with OneEuroFilter parameters (min_cutoff, beta, d_cutoff) + save_original: If True, save raw pose arrays + """ + super().__init__( + bind=bind, + authkey=authkey, + use_perf_counter=use_perf_counter, + save_original=save_original, + ) + + # Additional data storage for processed values + self.center_x = deque() + self.center_y = deque() + self.heading_direction = deque() + self.head_angle = deque() + + # Filtering + self.use_filter = use_filter + self.filter_kwargs = filter_kwargs or {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0} + self.filters = None # Will be initialized on first pose + + def _initialize_filters(self, vals): + """Initialize One-Euro filters for each output variable.""" + t0 = self.timing_func() + self.filters = { + "center_x": OneEuroFilter(t0, vals[0], **self.filter_kwargs), + "center_y": OneEuroFilter(t0, vals[1], **self.filter_kwargs), + "heading": OneEuroFilter(t0, vals[2], **self.filter_kwargs), + "head_angle": OneEuroFilter(t0, vals[3], **self.filter_kwargs), + } + LOG.debug(f"Initialized One-Euro filters with parameters: {self.filter_kwargs}") + + def process(self, pose, **kwargs): + """ + Process pose: calculate center/heading/head_angle, optionally filter, and broadcast. + + Args: + pose: DLC pose array (N_keypoints x 3) with [x, y, confidence] + **kwargs: Additional metadata (frame_time, pose_time, etc.) + + Returns: + pose: Unmodified pose array + """ + # Save original pose if requested (from base class) + if self.save_original: + self.original_pose.append(pose.copy()) + + # Extract keypoints and confidence + xy = pose[:, :2] + conf = pose[:, 2] + + # Calculate weighted center from head keypoints + head_xy = xy[[0, 1, 2, 3, 4, 5, 6, 26], :] + head_conf = conf[[0, 1, 2, 3, 4, 5, 6, 26]] + center = np.average(head_xy, axis=0, weights=head_conf) + + # Calculate body axis (tail_base -> neck) + body_axis = xy[7] - xy[13] + body_axis /= sqrt(np.sum(body_axis**2)) + + # Calculate head axis (neck -> nose) + head_axis = xy[0] - xy[7] + head_axis /= sqrt(np.sum(head_axis**2)) + + # Calculate head angle relative to body + cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] + sign = copysign(1, cross) # Positive when looking left + try: + head_angle = acos(body_axis @ head_axis) * sign + except ValueError: + head_angle = 0 + + # Calculate heading (body orientation) + heading = atan2(body_axis[1], body_axis[0]) + heading = degrees(heading) + + # Raw values (heading unwrapped for filtering) + vals = [center[0], center[1], heading, head_angle] + + # Apply filtering if enabled + curr_time = self.timing_func() + if self.use_filter: + if self.filters is None: + self._initialize_filters(vals) + + # Filter each value (heading is filtered in unwrapped space) + filtered_vals = [ + self.filters["center_x"](curr_time, vals[0]), + self.filters["center_y"](curr_time, vals[1]), + self.filters["heading"](curr_time, vals[2]), + self.filters["head_angle"](curr_time, vals[3]), + ] + vals = filtered_vals + + # Wrap heading to [0, 360) after filtering + vals[2] = vals[2] % 360 + + # Update step counter + self.curr_step = self.curr_step + 1 + + # Store processed data + self.center_x.append(vals[0]) + self.center_y.append(vals[1]) + self.heading_direction.append(vals[2]) + self.head_angle.append(vals[3]) + self.time_stamp.append(curr_time) + self.step.append(self.curr_step) + self.frame_time.append(kwargs.get("frame_time", -1)) + if "pose_time" in kwargs: + self.pose_time.append(kwargs["pose_time"]) + + # Broadcast processed values to all connected clients + payload = [curr_time, vals[0], vals[1], vals[2], vals[3]] + self.broadcast(payload) + + return pose + + def get_data(self): + """Get logged data including base class data and processed values.""" + # Get base class data + save_dict = super().get_data() + + # Add processed values + save_dict["x_pos"] = np.array(self.center_x) + save_dict["y_pos"] = np.array(self.center_y) + save_dict["heading_direction"] = np.array(self.heading_direction) + save_dict["head_angle"] = np.array(self.head_angle) + save_dict["use_filter"] = self.use_filter + save_dict["filter_kwargs"] = self.filter_kwargs + + return save_dict + + +# Register processors for GUI discovery +PROCESSOR_REGISTRY["BaseProcessor_socket"] = BaseProcessor_socket +PROCESSOR_REGISTRY["MyProcessor_socket"] = MyProcessor_socket + + +def get_available_processors(): + """ + Get list of available processor classes. + + Returns: + dict: Dictionary mapping class names to processor info: + { + "ClassName": { + "class": ProcessorClass, + "name": "Display Name", + "description": "Description text", + "params": {...} + } + } + """ + processors = {} + for class_name, processor_class in PROCESSOR_REGISTRY.items(): + processors[class_name] = { + "class": processor_class, + "name": getattr(processor_class, "PROCESSOR_NAME", class_name), + "description": getattr(processor_class, "PROCESSOR_DESCRIPTION", ""), + "params": getattr(processor_class, "PROCESSOR_PARAMS", {}) + } + return processors + + +def instantiate_processor(class_name, **kwargs): + """ + Instantiate a processor by class name with given parameters. + + Args: + class_name: Name of the processor class (e.g., "MyProcessor_socket") + **kwargs: Parameters to pass to the processor constructor + + Returns: + Processor instance + + Raises: + ValueError: If class_name is not in registry + """ + if class_name not in PROCESSOR_REGISTRY: + available = ", ".join(PROCESSOR_REGISTRY.keys()) + raise ValueError(f"Unknown processor '{class_name}'. Available: {available}") + + processor_class = PROCESSOR_REGISTRY[class_name] + return processor_class(**kwargs) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py new file mode 100644 index 0000000..728ff87 --- /dev/null +++ b/dlclivegui/processors/processor_utils.py @@ -0,0 +1,83 @@ + +import importlib.util +import inspect +from pathlib import Path + + +def load_processors_from_file(file_path): + """ + Load all processor classes from a Python file. + + Args: + file_path: Path to Python file containing processors + + Returns: + dict: Dictionary of available processors + """ + # Load module from file + spec = importlib.util.spec_from_file_location("processors", file_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # Check if module has get_available_processors function + if hasattr(module, 'get_available_processors'): + return module.get_available_processors() + + # Fallback: scan for Processor subclasses + from dlclive import Processor + processors = {} + for name, obj in inspect.getmembers(module, inspect.isclass): + if issubclass(obj, Processor) and obj != Processor: + processors[name] = { + "class": obj, + "name": getattr(obj, "PROCESSOR_NAME", name), + "description": getattr(obj, "PROCESSOR_DESCRIPTION", ""), + "params": getattr(obj, "PROCESSOR_PARAMS", {}) + } + return processors + + +def scan_processor_folder(folder_path): + """ + Scan a folder for all Python files with processor definitions. + + Args: + folder_path: Path to folder containing processor files + + Returns: + dict: Dictionary mapping file names to their processors + """ + all_processors = {} + folder = Path(folder_path) + + for py_file in folder.glob("*.py"): + if py_file.name.startswith("_"): + continue + elif py_file.name == "processor_utils.py": + continue + try: + processors = load_processors_from_file(py_file) + if processors: + all_processors[py_file.name] = processors + except Exception as e: + print(f"Error loading {py_file}: {e}") + + return all_processors + + +def display_processor_info(processors): + """Display processor information in a user-friendly format.""" + print("\n" + "="*70) + print("AVAILABLE PROCESSORS") + print("="*70) + + for idx, (class_name, info) in enumerate(processors.items(), 1): + print(f"\n[{idx}] {info['name']}") + print(f" Class: {class_name}") + print(f" Description: {info['description']}") + print(f" Parameters:") + for param_name, param_info in info['params'].items(): + print(f" - {param_name} ({param_info['type']})") + print(f" Default: {param_info['default']}") + print(f" {param_info['description']}") + From c81e8972d3285241a36bb314b70191123c058c3b Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Thu, 23 Oct 2025 16:24:42 +0200 Subject: [PATCH 09/69] update processors --- dlclivegui/processors/GUI_INTEGRATION.md | 167 +++++++++++++++++++++++ dlclivegui/processors/processor_utils.py | 56 +++++++- 2 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 dlclivegui/processors/GUI_INTEGRATION.md diff --git a/dlclivegui/processors/GUI_INTEGRATION.md b/dlclivegui/processors/GUI_INTEGRATION.md new file mode 100644 index 0000000..446232e --- /dev/null +++ b/dlclivegui/processors/GUI_INTEGRATION.md @@ -0,0 +1,167 @@ +# GUI Integration Guide + +## Quick Answer + +Here's how to use `scan_processor_folder` in your GUI: + +```python +from example_gui_usage import scan_processor_folder, instantiate_from_scan + +# 1. Scan folder +all_processors = scan_processor_folder("./processors") + +# 2. Populate dropdown with keys (for backend) and display names (for user) +for key, info in all_processors.items(): + # key = "file.py::ClassName" (use this for instantiation) + # display_name = "Human Name (file.py)" (show this to user) + display_name = f"{info['name']} ({info['file']})" + dropdown.add_item(key, display_name) + +# 3. When user selects, get the key from dropdown +selected_key = dropdown.get_selected_value() # e.g., "dlc_processor_socket.py::MyProcessor_socket" + +# 4. Get processor info +processor_info = all_processors[selected_key] + +# 5. Build parameter form from processor_info['params'] +for param_name, param_info in processor_info['params'].items(): + add_input_field(param_name, param_info['type'], param_info['default']) + +# 6. When user clicks Create, instantiate using the key +user_params = get_form_values() +processor = instantiate_from_scan(all_processors, selected_key, **user_params) +``` + +## The Key Insight + +**The key returned by `scan_processor_folder` is what you use to instantiate!** + +```python +# OLD problem: "I have a name, how do I load it?" +# NEW solution: Use the key directly + +all_processors = scan_processor_folder(folder) +# Returns: {"file.py::ClassName": {processor_info}, ...} + +# The KEY "file.py::ClassName" uniquely identifies the processor +# Pass this key to instantiate_from_scan() + +processor = instantiate_from_scan(all_processors, "file.py::ClassName", **params) +``` + +## What's in the returned dict? + +```python +all_processors = { + "dlc_processor_socket.py::MyProcessor_socket": { + "class": , # The actual class + "name": "Mouse Pose Processor", # Human-readable name + "description": "Calculates mouse...", # Description + "params": { # All parameters + "bind": { + "type": "tuple", + "default": ("0.0.0.0", 6000), + "description": "Server address" + }, + # ... more parameters + }, + "file": "dlc_processor_socket.py", # Source file + "class_name": "MyProcessor_socket", # Class name + "file_path": "/full/path/to/file.py" # Full path + } +} +``` + +## GUI Workflow + +### Step 1: Scan Folder +```python +all_processors = scan_processor_folder("./processors") +``` + +### Step 2: Populate Dropdown +```python +# Store keys in order (for mapping dropdown index -> key) +self.processor_keys = list(all_processors.keys()) + +# Create display names for dropdown +display_names = [ + f"{info['name']} ({info['file']})" + for info in all_processors.values() +] +dropdown.set_items(display_names) +``` + +### Step 3: User Selects Processor +```python +def on_processor_selected(dropdown_index): + # Get the key + key = self.processor_keys[dropdown_index] + + # Get processor info + info = all_processors[key] + + # Show description + description_label.text = info['description'] + + # Build parameter form + for param_name, param_info in info['params'].items(): + add_parameter_field( + name=param_name, + type=param_info['type'], + default=param_info['default'], + help_text=param_info['description'] + ) +``` + +### Step 4: User Clicks Create +```python +def on_create_clicked(): + # Get selected key + key = self.processor_keys[dropdown.current_index] + + # Get user's parameter values + user_params = parameter_form.get_values() + + # Instantiate using the key! + self.processor = instantiate_from_scan( + all_processors, + key, + **user_params + ) + + print(f"Created: {self.processor.__class__.__name__}") +``` + +## Why This Works + +1. **Unique Keys**: `"file.py::ClassName"` format ensures uniqueness even if multiple files have same class name + +2. **All Info Included**: Each dict entry has everything needed (class, metadata, parameters) + +3. **Simple Lookup**: Just use the key to get processor info or instantiate + +4. **No Manual Imports**: `scan_processor_folder` handles all module loading + +5. **Type Safety**: Parameter metadata includes types for validation + +## Complete Example + +See `processor_gui.py` for a full working tkinter GUI that demonstrates: +- Folder scanning +- Processor selection +- Parameter form generation +- Instantiation + +Run it with: +```bash +python processor_gui.py +``` + +## Files + +- `dlc_processor_socket.py` - Processors with metadata and registry +- `example_gui_usage.py` - Scanning and instantiation functions + examples +- `processor_gui.py` - Full tkinter GUI +- `GUI_USAGE_GUIDE.py` - Pseudocode and examples +- `README.md` - Documentation on the plugin system diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index 728ff87..dcacaa4 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -11,7 +11,7 @@ def load_processors_from_file(file_path): Args: file_path: Path to Python file containing processors - Returns: + Returns:/home/as153/work_geneva/mice_ar_tasks/mouse_ar/ctrl/dlc_processors/GUI_INTEGRATION.md dict: Dictionary of available processors """ # Load module from file @@ -45,7 +45,17 @@ def scan_processor_folder(folder_path): folder_path: Path to folder containing processor files Returns: - dict: Dictionary mapping file names to their processors + dict: Dictionary mapping unique processor keys to processor info: + { + "file_name.py::ClassName": { + "class": ProcessorClass, + "name": "Display Name", + "description": "...", + "params": {...}, + "file": "file_name.py", + "class_name": "ClassName" + } + } """ all_processors = {} folder = Path(folder_path) @@ -53,18 +63,52 @@ def scan_processor_folder(folder_path): for py_file in folder.glob("*.py"): if py_file.name.startswith("_"): continue - elif py_file.name == "processor_utils.py": - continue + try: processors = load_processors_from_file(py_file) - if processors: - all_processors[py_file.name] = processors + for class_name, processor_info in processors.items(): + # Create unique key: file::class + key = f"{py_file.name}::{class_name}" + # Add file and class name to info + processor_info["file"] = py_file.name + processor_info["class_name"] = class_name + processor_info["file_path"] = str(py_file) + all_processors[key] = processor_info except Exception as e: print(f"Error loading {py_file}: {e}") return all_processors +def instantiate_from_scan(processors_dict, processor_key, **kwargs): + """ + Instantiate a processor from scan_processor_folder results. + + Args: + processors_dict: Dict returned by scan_processor_folder + processor_key: Key like "file.py::ClassName" + **kwargs: Parameters for processor constructor + + Returns: + Processor instance + + Example: + processors = scan_processor_folder("./dlc_processors") + processor = instantiate_from_scan( + processors, + "dlc_processor_socket.py::MyProcessor_socket", + use_filter=True + ) + """ + if processor_key not in processors_dict: + available = ", ".join(processors_dict.keys()) + raise ValueError(f"Unknown processor '{processor_key}'. Available: {available}") + + processor_info = processors_dict[processor_key] + processor_class = processor_info["class"] + return processor_class(**kwargs) + + def display_processor_info(processors): """Display processor information in a user-friendly format.""" print("\n" + "="*70) From c7ee2f11ef26d984612c5f564bf0a207d28b35db Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Thu, 23 Oct 2025 17:54:08 +0200 Subject: [PATCH 10/69] fixing sizes --- dlclivegui/camera_controller.py | 192 +++++++++++++++++++++++++-- dlclivegui/cameras/factory.py | 2 - dlclivegui/cameras/gentl_backend.py | 21 +-- dlclivegui/cameras/opencv_backend.py | 52 +++++--- dlclivegui/config.py | 20 ++- dlclivegui/dlc_processor.py | 2 +- dlclivegui/gui.py | 138 ++++++++++++++----- dlclivegui/video_recorder.py | 86 ++++++++++-- 8 files changed, 415 insertions(+), 98 deletions(-) diff --git a/dlclivegui/camera_controller.py b/dlclivegui/camera_controller.py index 821a252..5618163 100644 --- a/dlclivegui/camera_controller.py +++ b/dlclivegui/camera_controller.py @@ -1,6 +1,8 @@ """Camera management for the DLC Live GUI.""" from __future__ import annotations +import logging +import time from dataclasses import dataclass from threading import Event from typing import Optional @@ -12,6 +14,8 @@ from dlclivegui.cameras.base import CameraBackend from dlclivegui.config import CameraSettings +LOGGER = logging.getLogger(__name__) + @dataclass class FrameData: @@ -27,6 +31,7 @@ class CameraWorker(QObject): frame_captured = pyqtSignal(object) started = pyqtSignal(object) error_occurred = pyqtSignal(str) + warning_occurred = pyqtSignal(str) finished = pyqtSignal() def __init__(self, settings: CameraSettings): @@ -34,42 +39,199 @@ def __init__(self, settings: CameraSettings): self._settings = settings self._stop_event = Event() self._backend: Optional[CameraBackend] = None + + # Error recovery settings + self._max_consecutive_errors = 5 + self._max_reconnect_attempts = 3 + self._retry_delay = 0.1 # seconds + self._reconnect_delay = 1.0 # seconds + + # Frame validation + self._expected_frame_size: Optional[tuple[int, int]] = None # (height, width) @pyqtSlot() def run(self) -> None: self._stop_event.clear() - try: - self._backend = CameraFactory.create(self._settings) - self._backend.open() - except Exception as exc: # pragma: no cover - device specific - self.error_occurred.emit(str(exc)) + + # Initialize camera + if not self._initialize_camera(): self.finished.emit() return self.started.emit(self._settings) + consecutive_errors = 0 + reconnect_attempts = 0 + while not self._stop_event.is_set(): try: frame, timestamp = self._backend.read() - except TimeoutError: + + # Validate frame size + if not self._validate_frame_size(frame): + consecutive_errors += 1 + LOGGER.warning(f"Frame size validation failed ({consecutive_errors}/{self._max_consecutive_errors})") + if consecutive_errors >= self._max_consecutive_errors: + self.error_occurred.emit("Too many frames with incorrect size") + break + time.sleep(self._retry_delay) + continue + + consecutive_errors = 0 # Reset error count on success + reconnect_attempts = 0 # Reset reconnect attempts on success + + except TimeoutError as exc: + consecutive_errors += 1 + LOGGER.warning(f"Camera frame timeout ({consecutive_errors}/{self._max_consecutive_errors}): {exc}") + if self._stop_event.is_set(): break - continue - except Exception as exc: # pragma: no cover - device specific - if not self._stop_event.is_set(): - self.error_occurred.emit(str(exc)) - break + + # Handle timeout with retry logic + if consecutive_errors < self._max_consecutive_errors: + self.warning_occurred.emit(f"Frame timeout (retry {consecutive_errors}/{self._max_consecutive_errors})") + time.sleep(self._retry_delay) + continue + else: + # Too many consecutive errors, try to reconnect + LOGGER.error(f"Too many consecutive timeouts, attempting reconnection...") + if self._attempt_reconnection(): + consecutive_errors = 0 + reconnect_attempts += 1 + self.warning_occurred.emit(f"Camera reconnected (attempt {reconnect_attempts})") + continue + else: + reconnect_attempts += 1 + if reconnect_attempts >= self._max_reconnect_attempts: + self.error_occurred.emit(f"Camera reconnection failed after {reconnect_attempts} attempts") + break + else: + consecutive_errors = 0 # Reset to try again + self.warning_occurred.emit(f"Reconnection attempt {reconnect_attempts} failed, retrying...") + time.sleep(self._reconnect_delay) + continue + + except Exception as exc: + consecutive_errors += 1 + LOGGER.warning(f"Camera read error ({consecutive_errors}/{self._max_consecutive_errors}): {exc}") + + if self._stop_event.is_set(): + break + + # Handle general errors with retry logic + if consecutive_errors < self._max_consecutive_errors: + self.warning_occurred.emit(f"Frame read error (retry {consecutive_errors}/{self._max_consecutive_errors})") + time.sleep(self._retry_delay) + continue + else: + # Too many consecutive errors, try to reconnect + LOGGER.error(f"Too many consecutive errors, attempting reconnection...") + if self._attempt_reconnection(): + consecutive_errors = 0 + reconnect_attempts += 1 + self.warning_occurred.emit(f"Camera reconnected (attempt {reconnect_attempts})") + continue + else: + reconnect_attempts += 1 + if reconnect_attempts >= self._max_reconnect_attempts: + self.error_occurred.emit(f"Camera failed after {reconnect_attempts} reconnection attempts: {exc}") + break + else: + consecutive_errors = 0 # Reset to try again + self.warning_occurred.emit(f"Reconnection attempt {reconnect_attempts} failed, retrying...") + time.sleep(self._reconnect_delay) + continue + if self._stop_event.is_set(): break + self.frame_captured.emit(FrameData(frame, timestamp)) + # Cleanup + self._cleanup_camera() + self.finished.emit() + + def _initialize_camera(self) -> bool: + """Initialize the camera backend. Returns True on success, False on failure.""" + try: + self._backend = CameraFactory.create(self._settings) + self._backend.open() + # Don't set expected frame size - will be established from first frame + self._expected_frame_size = None + LOGGER.info("Camera initialized successfully, frame size will be determined from camera") + return True + except Exception as exc: + LOGGER.exception("Failed to initialize camera", exc_info=exc) + self.error_occurred.emit(f"Failed to initialize camera: {exc}") + return False + + def _validate_frame_size(self, frame: np.ndarray) -> bool: + """Validate that the frame has the expected size. Returns True if valid.""" + if frame is None or frame.size == 0: + LOGGER.warning("Received empty frame") + return False + + actual_size = (frame.shape[0], frame.shape[1]) # (height, width) + + if self._expected_frame_size is None: + # First frame - establish expected size + self._expected_frame_size = actual_size + LOGGER.info(f"Established expected frame size: (h={actual_size[0]}, w={actual_size[1]})") + return True + + if actual_size != self._expected_frame_size: + LOGGER.warning( + f"Frame size mismatch: expected (h={self._expected_frame_size[0]}, w={self._expected_frame_size[1]}), " + f"got (h={actual_size[0]}, w={actual_size[1]}). Camera may have reconnected with different resolution." + ) + # Update expected size for future frames after reconnection + self._expected_frame_size = actual_size + LOGGER.info(f"Updated expected frame size to: (h={actual_size[0]}, w={actual_size[1]})") + # Emit warning so GUI can restart recording if needed + self.warning_occurred.emit( + f"Camera resolution changed to {actual_size[1]}x{actual_size[0]}" + ) + return True # Accept the new size + + return True + + def _attempt_reconnection(self) -> bool: + """Attempt to reconnect to the camera. Returns True on success, False on failure.""" + if self._stop_event.is_set(): + return False + + LOGGER.info("Attempting camera reconnection...") + + # Close existing connection + self._cleanup_camera() + + # Wait longer before reconnecting to let the device fully release + LOGGER.info(f"Waiting {self._reconnect_delay}s before reconnecting...") + time.sleep(self._reconnect_delay) + + if self._stop_event.is_set(): + return False + + # Try to reinitialize (this will also reset expected frame size) + try: + self._backend = CameraFactory.create(self._settings) + self._backend.open() + # Reset expected frame size - will be re-established on first frame + self._expected_frame_size = None + LOGGER.info("Camera reconnection successful, frame size will be determined from camera") + return True + except Exception as exc: + LOGGER.warning(f"Camera reconnection failed: {exc}") + return False + + def _cleanup_camera(self) -> None: + """Clean up camera backend resources.""" if self._backend is not None: try: self._backend.close() - except Exception as exc: # pragma: no cover - device specific - self.error_occurred.emit(str(exc)) + except Exception as exc: + LOGGER.warning(f"Error closing camera: {exc}") self._backend = None - self.finished.emit() @pyqtSlot() def stop(self) -> None: @@ -88,6 +250,7 @@ class CameraController(QObject): started = pyqtSignal(object) stopped = pyqtSignal() error = pyqtSignal(str) + warning = pyqtSignal(str) def __init__(self) -> None: super().__init__() @@ -133,6 +296,7 @@ def _start_worker(self, settings: CameraSettings) -> None: self._worker.frame_captured.connect(self.frame_ready) self._worker.started.connect(self.started) self._worker.error_occurred.connect(self.error) + self._worker.warning_occurred.connect(self.warning) self._worker.finished.connect(self._thread.quit) self._worker.finished.connect(self._worker.deleteLater) self._thread.finished.connect(self._cleanup) diff --git a/dlclivegui/cameras/factory.py b/dlclivegui/cameras/factory.py index 1dc33d8..2e937fd 100644 --- a/dlclivegui/cameras/factory.py +++ b/dlclivegui/cameras/factory.py @@ -76,8 +76,6 @@ def detect_cameras(backend: str, max_devices: int = 10) -> List[DetectedCamera]: settings = CameraSettings( name=f"Probe {index}", index=index, - width=640, - height=480, fps=30.0, backend=backend, properties={}, diff --git a/dlclivegui/cameras/gentl_backend.py b/dlclivegui/cameras/gentl_backend.py index dc6ce7e..ace3f82 100644 --- a/dlclivegui/cameras/gentl_backend.py +++ b/dlclivegui/cameras/gentl_backend.py @@ -120,7 +120,7 @@ def read(self) -> Tuple[np.ndarray, float]: except ValueError: frame = array.copy() except HarvesterTimeoutError as exc: - raise TimeoutError(str(exc)) from exc + raise TimeoutError(str(exc)+ " (GenTL timeout)") from exc frame = self._convert_frame(frame) timestamp = time.time() @@ -244,22 +244,9 @@ def _configure_pixel_format(self, node_map) -> None: pass def _configure_resolution(self, node_map) -> None: - width = int(self.settings.width) - height = int(self.settings.height) - if self._rotate in (90, 270): - width, height = height, width - try: - node_map.Width.value = self._adjust_to_increment( - width, node_map.Width.min, node_map.Width.max, node_map.Width.inc - ) - except Exception: - pass - try: - node_map.Height.value = self._adjust_to_increment( - height, node_map.Height.min, node_map.Height.max, node_map.Height.inc - ) - except Exception: - pass + # Don't configure width/height - use camera's native resolution + # Width and height will be determined from actual frames + pass def _configure_exposure(self, node_map) -> None: if self._exposure is None: diff --git a/dlclivegui/cameras/opencv_backend.py b/dlclivegui/cameras/opencv_backend.py index cbadf73..ca043bf 100644 --- a/dlclivegui/cameras/opencv_backend.py +++ b/dlclivegui/cameras/opencv_backend.py @@ -29,20 +29,43 @@ def open(self) -> None: def read(self) -> Tuple[np.ndarray, float]: if self._capture is None: raise RuntimeError("Camera has not been opened") - success, frame = self._capture.read() - if not success: - raise RuntimeError("Failed to read frame from OpenCV camera") + + # Try grab first - this is non-blocking and helps detect connection issues faster + grabbed = self._capture.grab() + if not grabbed: + # Check if camera is still opened - if not, it's a serious error + if not self._capture.isOpened(): + raise RuntimeError("OpenCV camera connection lost") + # Otherwise treat as temporary frame read failure (timeout-like) + raise TimeoutError("Failed to grab frame from OpenCV camera (temporary)") + + # Now retrieve the frame + success, frame = self._capture.retrieve() + if not success or frame is None: + raise TimeoutError("Failed to retrieve frame from OpenCV camera (temporary)") + return frame, time.time() def close(self) -> None: if self._capture is not None: - self._capture.release() - self._capture = None + try: + # Try to release properly + self._capture.release() + except Exception: + pass + finally: + self._capture = None + # Give the system a moment to fully release the device + time.sleep(0.1) def stop(self) -> None: if self._capture is not None: - self._capture.release() - self._capture = None + try: + self._capture.release() + except Exception: + pass + finally: + self._capture = None def device_name(self) -> str: base_name = "OpenCV" @@ -58,9 +81,11 @@ def device_name(self) -> str: def _configure_capture(self) -> None: if self._capture is None: return - self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, float(self.settings.width)) - self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, float(self.settings.height)) - self._capture.set(cv2.CAP_PROP_FPS, float(self.settings.fps)) + # Don't set width/height - capture at camera's native resolution + # Only set FPS if specified + if self.settings.fps: + self._capture.set(cv2.CAP_PROP_FPS, float(self.settings.fps)) + # Set any additional properties from the properties dict for prop, value in self.settings.properties.items(): if prop == "api": continue @@ -69,13 +94,8 @@ def _configure_capture(self) -> None: except (TypeError, ValueError): continue self._capture.set(prop_id, float(value)) - actual_width = self._capture.get(cv2.CAP_PROP_FRAME_WIDTH) - actual_height = self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT) + # Update actual FPS from camera actual_fps = self._capture.get(cv2.CAP_PROP_FPS) - if actual_width: - self.settings.width = int(actual_width) - if actual_height: - self.settings.height = int(actual_height) if actual_fps: self.settings.fps = float(actual_fps) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 72e3f80..c9be6a4 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -13,23 +13,33 @@ class CameraSettings: name: str = "Camera 0" index: int = 0 - width: int = 640 - height: int = 480 fps: float = 25.0 backend: str = "gentl" exposure: int = 500 # 0 = auto, otherwise microseconds gain: float = 10 # 0.0 = auto, otherwise gain value + crop_x0: int = 0 # Left edge of crop region (0 = no crop) + crop_y0: int = 0 # Top edge of crop region (0 = no crop) + crop_x1: int = 0 # Right edge of crop region (0 = no crop) + crop_y1: int = 0 # Bottom edge of crop region (0 = no crop) properties: Dict[str, Any] = field(default_factory=dict) def apply_defaults(self) -> "CameraSettings": - """Ensure width, height and fps are positive numbers.""" + """Ensure fps is a positive number and validate crop settings.""" - self.width = int(self.width) if self.width else 640 - self.height = int(self.height) if self.height else 480 self.fps = float(self.fps) if self.fps else 30.0 self.exposure = int(self.exposure) if self.exposure else 0 self.gain = float(self.gain) if self.gain else 0.0 + self.crop_x0 = max(0, int(self.crop_x0)) if hasattr(self, 'crop_x0') else 0 + self.crop_y0 = max(0, int(self.crop_y0)) if hasattr(self, 'crop_y0') else 0 + self.crop_x1 = max(0, int(self.crop_x1)) if hasattr(self, 'crop_x1') else 0 + self.crop_y1 = max(0, int(self.crop_y1)) if hasattr(self, 'crop_y1') else 0 return self + + def get_crop_region(self) -> Optional[tuple[int, int, int, int]]: + """Get crop region as (x0, y0, x1, y1) or None if no cropping.""" + if self.crop_x0 == 0 and self.crop_y0 == 0 and self.crop_x1 == 0 and self.crop_y1 == 0: + return None + return (self.crop_x0, self.crop_y0, self.crop_x1, self.crop_y1) @dataclass diff --git a/dlclivegui/dlc_processor.py b/dlclivegui/dlc_processor.py index 201f53c..9dd69ca 100644 --- a/dlclivegui/dlc_processor.py +++ b/dlclivegui/dlc_processor.py @@ -137,7 +137,7 @@ def _start_worker(self, init_frame: np.ndarray, init_timestamp: float) -> None: if self._worker_thread is not None and self._worker_thread.is_alive(): return - self._queue = queue.Queue(maxsize=5) + self._queue = queue.Queue(maxsize=2) self._stop_event.clear() self._worker_thread = threading.Thread( target=self._worker_loop, diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 0328222..5c76269 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -171,14 +171,6 @@ def _build_camera_group(self) -> QGroupBox: index_layout.addWidget(self.refresh_cameras_button) form.addRow("Camera", index_layout) - self.camera_width = QSpinBox() - self.camera_width.setRange(1, 7680) - form.addRow("Width", self.camera_width) - - self.camera_height = QSpinBox() - self.camera_height.setRange(1, 4320) - form.addRow("Height", self.camera_height) - self.camera_fps = QDoubleSpinBox() self.camera_fps.setRange(1.0, 240.0) self.camera_fps.setDecimals(2) @@ -198,6 +190,34 @@ def _build_camera_group(self) -> QGroupBox: self.camera_gain.setDecimals(2) form.addRow("Gain", self.camera_gain) + # Crop settings + crop_layout = QHBoxLayout() + self.crop_x0 = QSpinBox() + self.crop_x0.setRange(0, 7680) + self.crop_x0.setPrefix("x0:") + self.crop_x0.setSpecialValueText("x0:None") + crop_layout.addWidget(self.crop_x0) + + self.crop_y0 = QSpinBox() + self.crop_y0.setRange(0, 4320) + self.crop_y0.setPrefix("y0:") + self.crop_y0.setSpecialValueText("y0:None") + crop_layout.addWidget(self.crop_y0) + + self.crop_x1 = QSpinBox() + self.crop_x1.setRange(0, 7680) + self.crop_x1.setPrefix("x1:") + self.crop_x1.setSpecialValueText("x1:None") + crop_layout.addWidget(self.crop_x1) + + self.crop_y1 = QSpinBox() + self.crop_y1.setRange(0, 4320) + self.crop_y1.setPrefix("y1:") + self.crop_y1.setSpecialValueText("y1:None") + crop_layout.addWidget(self.crop_y1) + + form.addRow("Crop (x0,y0,x1,y1)", crop_layout) + self.camera_properties_edit = QPlainTextEdit() self.camera_properties_edit.setPlaceholderText( '{"other_property": "value"}' @@ -329,6 +349,7 @@ def _connect_signals(self) -> None: self.camera_controller.frame_ready.connect(self._on_frame_ready) self.camera_controller.started.connect(self._on_camera_started) self.camera_controller.error.connect(self._show_error) + self.camera_controller.warning.connect(self._show_warning) self.camera_controller.stopped.connect(self._on_camera_stopped) self.dlc_processor.pose_ready.connect(self._on_pose_ready) @@ -338,14 +359,18 @@ def _connect_signals(self) -> None: # ------------------------------------------------------------------ config def _apply_config(self, config: ApplicationSettings) -> None: camera = config.camera - self.camera_width.setValue(int(camera.width)) - self.camera_height.setValue(int(camera.height)) self.camera_fps.setValue(float(camera.fps)) # Set exposure and gain from config self.camera_exposure.setValue(int(camera.exposure)) self.camera_gain.setValue(float(camera.gain)) + # Set crop settings from config + self.crop_x0.setValue(int(camera.crop_x0) if hasattr(camera, 'crop_x0') else 0) + self.crop_y0.setValue(int(camera.crop_y0) if hasattr(camera, 'crop_y0') else 0) + self.crop_x1.setValue(int(camera.crop_x1) if hasattr(camera, 'crop_x1') else 0) + self.crop_y1.setValue(int(camera.crop_y1) if hasattr(camera, 'crop_y1') else 0) + backend_name = camera.backend or "opencv" index = self.camera_backend.findData(backend_name) if index >= 0: @@ -408,6 +433,12 @@ def _camera_settings_from_ui(self) -> CameraSettings: exposure = self.camera_exposure.value() gain = self.camera_gain.value() + # Get crop settings from UI + crop_x0 = self.crop_x0.value() + crop_y0 = self.crop_y0.value() + crop_x1 = self.crop_x1.value() + crop_y1 = self.crop_y1.value() + # Also add to properties dict for backward compatibility with camera backends if exposure > 0: properties["exposure"] = exposure @@ -418,12 +449,14 @@ def _camera_settings_from_ui(self) -> CameraSettings: settings = CameraSettings( name=name_text or f"Camera {index}", index=index, - width=self.camera_width.value(), - height=self.camera_height.value(), fps=self.camera_fps.value(), backend=backend_text or "opencv", exposure=exposure, gain=gain, + crop_x0=crop_x0, + crop_y0=crop_y0, + crop_x1=crop_x1, + crop_y1=crop_y1, properties=properties, ) return settings.apply_defaults() @@ -441,7 +474,7 @@ def _refresh_camera_indices( backend = self._current_backend_name() detected = CameraFactory.detect_cameras(backend) debug_info = [f"{camera.index}:{camera.label}" for camera in detected] - print( + logging.info( f"[CameraDetection] Available cameras for backend '{backend}': {debug_info}" ) self._detected_cameras = detected @@ -500,7 +533,7 @@ def _refresh_camera_indices( backend = self._current_backend_name() detected = CameraFactory.detect_cameras(backend) debug_info = [f"{camera.index}:{camera.label}" for camera in detected] - print( + logging.info( f"[CameraDetection] Available cameras for backend '{backend}': {debug_info}" ) self._detected_cameras = detected @@ -687,23 +720,17 @@ def _on_camera_started(self, settings: CameraSettings) -> None: self._active_camera_settings = settings self.preview_button.setEnabled(False) self.stop_preview_button.setEnabled(True) - self.camera_width.blockSignals(True) - self.camera_width.setValue(int(settings.width)) - self.camera_width.blockSignals(False) - self.camera_height.blockSignals(True) - self.camera_height.setValue(int(settings.height)) - self.camera_height.blockSignals(False) if getattr(settings, "fps", None): self.camera_fps.blockSignals(True) self.camera_fps.setValue(float(settings.fps)) self.camera_fps.blockSignals(False) - resolution = f"{int(settings.width)}×{int(settings.height)}" + # Resolution will be determined from actual camera frames if getattr(settings, "fps", None): fps_text = f"{float(settings.fps):.2f} FPS" else: fps_text = "unknown FPS" self.statusBar().showMessage( - f"Camera preview started: {resolution} @ {fps_text}", 5000 + f"Camera preview started @ {fps_text}", 5000 ) self._update_inference_buttons() self._update_camera_controls_enabled() @@ -758,11 +785,13 @@ def _update_camera_controls_enabled(self) -> None: self.camera_backend, self.camera_index, self.refresh_cameras_button, - self.camera_width, - self.camera_height, self.camera_fps, self.camera_exposure, self.camera_gain, + self.crop_x0, + self.crop_y0, + self.crop_x1, + self.crop_y1, self.camera_properties_edit, self.rotation_combo, self.codec_combo, @@ -924,7 +953,7 @@ def _start_recording(self) -> None: output_path = recording.output_path() self._video_recorder = VideoRecorder( output_path, - frame_size=(int(width), int(height)), + frame_size=(height, width), # Use numpy convention: (height, width) frame_rate=float(frame_rate), codec=recording.codec, crf=recording.crf, @@ -974,17 +1003,18 @@ def _stop_recording(self) -> None: def _on_frame_ready(self, frame_data: FrameData) -> None: raw_frame = frame_data.image self._raw_frame = raw_frame - frame = self._apply_rotation(raw_frame) + + # Apply cropping before rotation + frame = self._apply_crop(raw_frame) + + # Apply rotation + frame = self._apply_rotation(frame) frame = np.ascontiguousarray(frame) self._current_frame = frame self._track_camera_frame() - if self._active_camera_settings is not None: - height, width = frame.shape[:2] - self._active_camera_settings.width = int(width) - self._active_camera_settings.height = int(height) if self._video_recorder and self._video_recorder.is_running: try: - success = self._video_recorder.write(frame) + success = self._video_recorder.write(frame, timestamp=frame_data.timestamp) if not success: now = time.perf_counter() if now - self._last_drop_warning > 1.0: @@ -993,8 +1023,19 @@ def _on_frame_ready(self, frame_data: FrameData) -> None: ) self._last_drop_warning = now except RuntimeError as exc: - self._show_error(str(exc)) - self._stop_recording() + # Check if it's a frame size error + if "Frame size changed" in str(exc): + self._show_warning(f"Camera resolution changed - restarting recording: {exc}") + self._stop_recording() + # Restart recording with new resolution if enabled + if self.recording_enabled_checkbox.isChecked(): + try: + self._start_recording() + except Exception as restart_exc: + self._show_error(f"Failed to restart recording: {restart_exc}") + else: + self._show_error(str(exc)) + self._stop_recording() if self._dlc_active: self.dlc_processor.enqueue_frame(frame, frame_data.timestamp) self._display_frame(frame) @@ -1003,7 +1044,7 @@ def _on_pose_ready(self, result: PoseResult) -> None: if not self._dlc_active: return self._last_pose = result - logging.info(f"Pose result: {result.pose}, Timestamp: {result.timestamp}") + #logging.info(f"Pose result: {result.pose}, Timestamp: {result.timestamp}") if self._current_frame is not None: self._display_frame(self._current_frame, force=True) @@ -1025,6 +1066,31 @@ def _update_video_display(self, frame: np.ndarray) -> None: image = QImage(rgb.data, w, h, bytes_per_line, QImage.Format.Format_RGB888) self.video_label.setPixmap(QPixmap.fromImage(image)) + def _apply_crop(self, frame: np.ndarray) -> np.ndarray: + """Apply cropping to the frame based on settings.""" + if self._active_camera_settings is None: + return frame + + crop_region = self._active_camera_settings.get_crop_region() + if crop_region is None: + return frame + + x0, y0, x1, y1 = crop_region + height, width = frame.shape[:2] + + # Validate and constrain crop coordinates + x0 = max(0, min(x0, width)) + y0 = max(0, min(y0, height)) + x1 = max(x0, min(x1, width)) if x1 > 0 else width + y1 = max(y0, min(y1, height)) if y1 > 0 else height + + # Apply crop + if x0 < x1 and y0 < y1: + return frame[y0:y1, x0:x1] + else: + # Invalid crop region, return original frame + return frame + def _apply_rotation(self, frame: np.ndarray) -> np.ndarray: if self._rotation_degrees == 90: return cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) @@ -1070,6 +1136,10 @@ def _show_error(self, message: str) -> None: self.statusBar().showMessage(message, 5000) QMessageBox.critical(self, "Error", message) + def _show_warning(self, message: str) -> None: + """Display a warning message in the status bar without blocking.""" + self.statusBar().showMessage(f"⚠ {message}", 3000) + # ------------------------------------------------------------------ Qt overrides def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI behaviour if self.camera_controller.is_running(): diff --git a/dlclivegui/video_recorder.py b/dlclivegui/video_recorder.py index d729b02..2190314 100644 --- a/dlclivegui/video_recorder.py +++ b/dlclivegui/video_recorder.py @@ -1,6 +1,7 @@ """Video recording support using the vidgear library.""" from __future__ import annotations +import json import logging import queue import threading @@ -8,7 +9,7 @@ from collections import deque from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple import numpy as np @@ -69,6 +70,7 @@ def __init__( self._written_times: deque[float] = deque(maxlen=600) self._encode_error: Optional[Exception] = None self._last_log_time = 0.0 + self._frame_timestamps: List[float] = [] @property def is_running(self) -> bool: @@ -85,7 +87,7 @@ def start(self) -> None: writer_kwargs: Dict[str, Any] = { "compression_mode": True, - "logging": True, + "logging": False, "-input_framerate": fps_value, "-vcodec": (self._codec or "libx264").strip() or "libx264", "-crf": int(self._crf), @@ -101,6 +103,7 @@ def start(self) -> None: self._total_latency = 0.0 self._last_latency = 0.0 self._written_times.clear() + self._frame_timestamps.clear() self._encode_error = None self._stop_event.clear() self._writer_thread = threading.Thread( @@ -116,12 +119,20 @@ def configure_stream( self._frame_size = frame_size self._frame_rate = frame_rate - def write(self, frame: np.ndarray) -> bool: + def write(self, frame: np.ndarray, timestamp: Optional[float] = None) -> bool: if not self.is_running or self._queue is None: return False error = self._current_error() if error is not None: raise RuntimeError(f"Video encoding failed: {error}") from error + + # Record timestamp for this frame + if timestamp is None: + timestamp = time.time() + with self._stats_lock: + self._frame_timestamps.append(timestamp) + + # Convert frame to uint8 if needed if frame.dtype != np.uint8: frame_float = frame.astype(np.float32, copy=False) max_val = float(frame_float.max()) if frame_float.size else 0.0 @@ -129,9 +140,31 @@ def write(self, frame: np.ndarray) -> bool: if max_val > 0: scale = 255.0 / max_val if max_val > 255.0 else (255.0 if max_val <= 1.0 else 1.0) frame = np.clip(frame_float * scale, 0.0, 255.0).astype(np.uint8) + + # Convert grayscale to RGB if needed if frame.ndim == 2: frame = np.repeat(frame[:, :, None], 3, axis=2) + + # Ensure contiguous array frame = np.ascontiguousarray(frame) + + # Check if frame size matches expected size + if self._frame_size is not None: + expected_h, expected_w = self._frame_size + actual_h, actual_w = frame.shape[:2] + if (actual_h, actual_w) != (expected_h, expected_w): + logger.warning( + f"Frame size mismatch: expected (h={expected_h}, w={expected_w}), " + f"got (h={actual_h}, w={actual_w}). " + "Stopping recorder to prevent encoding errors." + ) + # Set error to stop recording gracefully + with self._stats_lock: + self._encode_error = ValueError( + f"Frame size changed from (h={expected_h}, w={expected_w}) to (h={actual_h}, w={actual_w})" + ) + return False + try: assert self._queue is not None self._queue.put(frame, block=False) @@ -167,6 +200,10 @@ def stop(self) -> None: self._writer.close() except Exception: logger.exception("Failed to close WriteGear cleanly") + + # Save timestamps to JSON file + self._save_timestamps() + self._writer = None self._writer_thread = None self._queue = None @@ -239,12 +276,12 @@ def _writer_loop(self) -> None: if now - self._last_log_time >= 1.0: fps = self._compute_write_fps_locked() queue_size = self._queue.qsize() - logger.info( - "Recorder throughput: %.2f fps, latency %.2f ms, queue=%d", - fps, - elapsed * 1000.0, - queue_size, - ) + # logger.info( + # "Recorder throughput: %.2f fps, latency %.2f ms, queue=%d", + # fps, + # elapsed * 1000.0, + # queue_size, + # ) self._last_log_time = now self._queue.task_done() self._finalize_writer() @@ -269,3 +306,34 @@ def _compute_write_fps_locked(self) -> float: def _current_error(self) -> Optional[Exception]: with self._stats_lock: return self._encode_error + + def _save_timestamps(self) -> None: + """Save frame timestamps to a JSON file alongside the video.""" + if not self._frame_timestamps: + logger.info("No timestamps to save") + return + + # Create timestamps file path + timestamp_file = self._output.with_suffix('').with_suffix(self._output.suffix + '_timestamps.json') + + try: + with self._stats_lock: + timestamps = self._frame_timestamps.copy() + + # Prepare metadata + data = { + "video_file": str(self._output.name), + "num_frames": len(timestamps), + "timestamps": timestamps, + "start_time": timestamps[0] if timestamps else None, + "end_time": timestamps[-1] if timestamps else None, + "duration_seconds": timestamps[-1] - timestamps[0] if len(timestamps) > 1 else 0.0, + } + + # Write to JSON + with open(timestamp_file, 'w') as f: + json.dump(data, f, indent=2) + + logger.info(f"Saved {len(timestamps)} frame timestamps to {timestamp_file}") + except Exception as exc: + logger.exception(f"Failed to save timestamps to {timestamp_file}: {exc}") From 78238299e6e6551b84bc31df496c00f62825d408 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Thu, 23 Oct 2025 18:47:08 +0200 Subject: [PATCH 11/69] modified the processor to be controllable --- dlclivegui/processors/dlc_processor_socket.py | 99 +++++++-- example_recording_control.py | 194 ++++++++++++++++++ 2 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 example_recording_control.py diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index bd183af..dbb0f90 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -131,9 +131,27 @@ def __init__( self.pose_time = deque() self.original_pose = deque() + self._session_name = "test_session" + self.filename = None + self._recording = Event() # Thread-safe recording flag + # State self.curr_step = 0 self.save_original = save_original + + @property + def recording(self): + """Thread-safe recording flag.""" + return self._recording.is_set() + + @property + def session_name(self): + return self._session_name + + @session_name.setter + def session_name(self, name): + self._session_name = name + self.filename = f"{name}_dlc_processor_data.pkl" def _accept_loop(self): """Background thread to accept new client connections.""" @@ -154,7 +172,8 @@ def _rx_loop(self, c): try: if c.poll(0.05): msg = c.recv() - # Optional: handle client messages here + # Handle control messages from client + self._handle_client_message(msg) except (EOFError, OSError, BrokenPipeError): break try: @@ -163,6 +182,42 @@ def _rx_loop(self, c): pass self.conns.discard(c) LOG.info("Client disconnected") + + def _handle_client_message(self, msg): + """Handle control messages from clients.""" + if not isinstance(msg, dict): + return + + cmd = msg.get("cmd") + if cmd == "set_session_name": + session_name = msg.get("session_name", "default_session") + self.session_name = session_name + LOG.info(f"Session name set to: {session_name}") + + elif cmd == "start_recording": + self._recording.set() + # Clear all data queues + self._clear_data_queues() + self.curr_step = 0 + LOG.info("Recording started, data queues cleared") + + elif cmd == "stop_recording": + self._recording.clear() + LOG.info("Recording stopped") + + elif cmd == "save": + filename = msg.get("filename", self.filename) + save_code = self.save(filename) + LOG.info(f"Save {'successful' if save_code == 1 else 'failed'}: {filename}") + + def _clear_data_queues(self): + """Clear all data storage queues. Override in subclasses to clear additional queues.""" + self.time_stamp.clear() + self.step.clear() + self.frame_time.clear() + self.pose_time.clear() + if self.save_original: + self.original_pose.clear() def broadcast(self, payload): """Send payload to all connected clients.""" @@ -202,12 +257,13 @@ def process(self, pose, **kwargs): # Update step counter self.curr_step = self.curr_step + 1 - # Store metadata - self.time_stamp.append(curr_time) - self.step.append(self.curr_step) - self.frame_time.append(kwargs.get("frame_time", -1)) - if "pose_time" in kwargs: - self.pose_time.append(kwargs["pose_time"]) + # Store metadata (only if recording) + if self.recording: + self.time_stamp.append(curr_time) + self.step.append(self.curr_step) + self.frame_time.append(kwargs.get("frame_time", -1)) + if "pose_time" in kwargs: + self.pose_time.append(kwargs["pose_time"]) # Broadcast raw pose to all connected clients payload = [curr_time, pose] @@ -344,6 +400,14 @@ def __init__( self.filter_kwargs = filter_kwargs or {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0} self.filters = None # Will be initialized on first pose + def _clear_data_queues(self): + """Clear all data storage queues including pose-specific ones.""" + super()._clear_data_queues() + self.center_x.clear() + self.center_y.clear() + self.heading_direction.clear() + self.head_angle.clear() + def _initialize_filters(self, vals): """Initialize One-Euro filters for each output variable.""" t0 = self.timing_func() @@ -423,16 +487,17 @@ def process(self, pose, **kwargs): # Update step counter self.curr_step = self.curr_step + 1 - # Store processed data - self.center_x.append(vals[0]) - self.center_y.append(vals[1]) - self.heading_direction.append(vals[2]) - self.head_angle.append(vals[3]) - self.time_stamp.append(curr_time) - self.step.append(self.curr_step) - self.frame_time.append(kwargs.get("frame_time", -1)) - if "pose_time" in kwargs: - self.pose_time.append(kwargs["pose_time"]) + # Store processed data (only if recording) + if self.recording: + self.center_x.append(vals[0]) + self.center_y.append(vals[1]) + self.heading_direction.append(vals[2]) + self.head_angle.append(vals[3]) + self.time_stamp.append(curr_time) + self.step.append(self.curr_step) + self.frame_time.append(kwargs.get("frame_time", -1)) + if "pose_time" in kwargs: + self.pose_time.append(kwargs["pose_time"]) # Broadcast processed values to all connected clients payload = [curr_time, vals[0], vals[1], vals[2], vals[3]] diff --git a/example_recording_control.py b/example_recording_control.py new file mode 100644 index 0000000..ed426e8 --- /dev/null +++ b/example_recording_control.py @@ -0,0 +1,194 @@ +""" +Example: Recording control with DLCClient and MyProcessor_socket + +This demonstrates: +1. Starting a processor +2. Connecting a client +3. Controlling recording (start/stop/save) from the client +4. Session name management +""" + +import time +import sys +from pathlib import Path + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from mouse_ar.ctrl.dlc_client import DLCClient + + +def example_recording_workflow(): + """Complete workflow: processor + client with recording control.""" + + print("\n" + "="*70) + print("EXAMPLE: Recording Control Workflow") + print("="*70) + + # NOTE: This example assumes MyProcessor_socket is already running + # Start it separately with: + # from dlc_processor_socket import MyProcessor_socket + # processor = MyProcessor_socket(bind=("localhost", 6000)) + # # Then run DLCLive with this processor + + print("\n[CLIENT] Connecting to processor at localhost:6000...") + client = DLCClient(address=("localhost", 6000)) + + try: + # Start the client (connects and begins receiving data) + client.start() + print("[CLIENT] Connected!") + time.sleep(0.5) # Wait for connection to stabilize + + # Set session name + print("\n[CLIENT] Setting session name to 'experiment_001'...") + client.set_session_name("experiment_001") + time.sleep(0.2) + + # Start recording + print("[CLIENT] Starting recording (clears processor data queues)...") + client.start_recording() + time.sleep(0.2) + + # Receive some data + print("\n[CLIENT] Receiving data for 5 seconds...") + for i in range(5): + data = client.read() + if data: + vals = data["vals"] + print(f" t={vals[0]:.2f}, x={vals[1]:.1f}, y={vals[2]:.1f}, " + f"heading={vals[3]:.1f}°, head_angle={vals[4]:.2f}rad") + time.sleep(1.0) + + # Stop recording + print("\n[CLIENT] Stopping recording...") + client.stop_recording() + time.sleep(0.2) + + # Trigger save + print("[CLIENT] Triggering save on processor...") + client.trigger_save() # Uses processor's default filename + # OR specify custom filename: + # client.trigger_save(filename="my_custom_data.pkl") + time.sleep(0.5) + + print("\n[CLIENT] ✓ Workflow complete!") + + except Exception as e: + print(f"\n[ERROR] {e}") + print("\nMake sure MyProcessor_socket is running!") + print("Example:") + print(" from dlc_processor_socket import MyProcessor_socket") + print(" processor = MyProcessor_socket()") + print(" # Then run DLCLive with this processor") + + finally: + print("\n[CLIENT] Closing connection...") + client.close() + + +def example_multiple_sessions(): + """Example: Recording multiple sessions with the same processor.""" + + print("\n" + "="*70) + print("EXAMPLE: Multiple Sessions") + print("="*70) + + client = DLCClient(address=("localhost", 6000)) + + try: + client.start() + print("[CLIENT] Connected!") + time.sleep(0.5) + + # Session 1 + print("\n--- SESSION 1 ---") + client.set_session_name("trial_001") + client.start_recording() + print("Recording session 'trial_001' for 3 seconds...") + time.sleep(3.0) + client.stop_recording() + client.trigger_save() # Saves as "trial_001_dlc_processor_data.pkl" + print("Session 1 saved!") + + time.sleep(1.0) + + # Session 2 + print("\n--- SESSION 2 ---") + client.set_session_name("trial_002") + client.start_recording() + print("Recording session 'trial_002' for 3 seconds...") + time.sleep(3.0) + client.stop_recording() + client.trigger_save() # Saves as "trial_002_dlc_processor_data.pkl" + print("Session 2 saved!") + + print("\n✓ Multiple sessions recorded successfully!") + + except Exception as e: + print(f"\n[ERROR] {e}") + + finally: + client.close() + + +def example_command_api(): + """Example: Using the low-level command API.""" + + print("\n" + "="*70) + print("EXAMPLE: Low-level Command API") + print("="*70) + + client = DLCClient(address=("localhost", 6000)) + + try: + client.start() + time.sleep(0.5) + + # Using send_command directly + print("\n[CLIENT] Using send_command()...") + + # Set session name + client.send_command("set_session_name", session_name="custom_session") + print(" ✓ Sent: set_session_name") + time.sleep(0.2) + + # Start recording + client.send_command("start_recording") + print(" ✓ Sent: start_recording") + time.sleep(2.0) + + # Stop recording + client.send_command("stop_recording") + print(" ✓ Sent: stop_recording") + time.sleep(0.2) + + # Save with custom filename + client.send_command("save", filename="my_data.pkl") + print(" ✓ Sent: save") + time.sleep(0.5) + + print("\n✓ Commands sent successfully!") + + except Exception as e: + print(f"\n[ERROR] {e}") + + finally: + client.close() + + +if __name__ == "__main__": + print("\n" + "="*70) + print("DLC PROCESSOR RECORDING CONTROL EXAMPLES") + print("="*70) + print("\nNOTE: These examples require MyProcessor_socket to be running.") + print("Start it separately before running these examples.") + print("="*70) + + # Uncomment the example you want to run: + + # example_recording_workflow() + # example_multiple_sessions() + # example_command_api() + + print("\nUncomment an example in the script to run it.") From 184e87b0b2672fd67cdf8984c4959b5f42f0564b Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Fri, 24 Oct 2025 15:01:07 +0200 Subject: [PATCH 12/69] Add GenTL device count retrieval, bounding box settings, and processor integration - Implemented `get_device_count` method in `GenTLCameraBackend` to retrieve the number of GenTL devices detected. - Added `max_devices` configuration option in `CameraSettings` to limit device probing. - Introduced `BoundingBoxSettings` for bounding box visualization, integrated into the main GUI. - Enhanced `DLCLiveProcessor` to accept a processor instance during configuration. - Updated GUI to support processor selection and auto-recording based on processor commands. - Refactored camera properties handling and removed deprecated advanced properties editor. - Improved error handling and logging for processor connections and recording states. --- dlclivegui/cameras/factory.py | 13 +- dlclivegui/cameras/gentl_backend.py | 82 +++- dlclivegui/config.py | 17 +- dlclivegui/dlc_processor.py | 11 +- dlclivegui/gui.py | 412 +++++++++++++++--- dlclivegui/processors/dlc_processor_socket.py | 17 +- example_recording_control.py | 194 --------- 7 files changed, 477 insertions(+), 269 deletions(-) delete mode 100644 example_recording_control.py diff --git a/dlclivegui/cameras/factory.py b/dlclivegui/cameras/factory.py index 2e937fd..540e352 100644 --- a/dlclivegui/cameras/factory.py +++ b/dlclivegui/cameras/factory.py @@ -57,6 +57,7 @@ def detect_cameras(backend: str, max_devices: int = 10) -> List[DetectedCamera]: The backend identifier, e.g. ``"opencv"``. max_devices: Upper bound for the indices that should be probed. + For GenTL backend, the actual device count is queried if available. Returns ------- @@ -71,8 +72,18 @@ def detect_cameras(backend: str, max_devices: int = 10) -> List[DetectedCamera]: if not backend_cls.is_available(): return [] + # For GenTL backend, try to get actual device count + num_devices = max_devices + if hasattr(backend_cls, 'get_device_count'): + try: + actual_count = backend_cls.get_device_count() + if actual_count >= 0: + num_devices = actual_count + except Exception: + pass + detected: List[DetectedCamera] = [] - for index in range(max_devices): + for index in range(num_devices): settings = CameraSettings( name=f"Probe {index}", index=index, diff --git a/dlclivegui/cameras/gentl_backend.py b/dlclivegui/cameras/gentl_backend.py index ace3f82..943cf4a 100644 --- a/dlclivegui/cameras/gentl_backend.py +++ b/dlclivegui/cameras/gentl_backend.py @@ -53,6 +53,36 @@ def __init__(self, settings): def is_available(cls) -> bool: return Harvester is not None + @classmethod + def get_device_count(cls) -> int: + """Get the actual number of GenTL devices detected by Harvester. + + Returns the number of devices found, or -1 if detection fails. + """ + if Harvester is None: + return -1 + + harvester = None + try: + harvester = Harvester() + # Use the static helper to find CTI file with default patterns + cti_file = cls._search_cti_file(cls._DEFAULT_CTI_PATTERNS) + + if not cti_file: + return -1 + + harvester.add_file(cti_file) + harvester.update() + return len(harvester.device_info_list) + except Exception: + return -1 + finally: + if harvester is not None: + try: + harvester.reset() + except Exception: + pass + def open(self) -> None: if Harvester is None: # pragma: no cover - optional dependency raise RuntimeError( @@ -90,6 +120,32 @@ def open(self) -> None: remote = self._acquirer.remote_device node_map = remote.node_map + #print(dir(node_map)) + """ + ['AcquisitionBurstFrameCount', 'AcquisitionControl', 'AcquisitionFrameRate', 'AcquisitionMode', + 'AcquisitionStart', 'AcquisitionStop', 'AnalogControl', 'AutoFunctionsROI', 'AutoFunctionsROIEnable', + 'AutoFunctionsROIHeight', 'AutoFunctionsROILeft', 'AutoFunctionsROIPreset', 'AutoFunctionsROITop', + 'AutoFunctionsROIWidth', 'BinningHorizontal', 'BinningVertical', 'BlackLevel', 'CameraRegisterAddress', + 'CameraRegisterAddressSpace', 'CameraRegisterControl', 'CameraRegisterRead', 'CameraRegisterValue', + 'CameraRegisterWrite', 'Contrast', 'DecimationHorizontal', 'DecimationVertical', 'Denoise', + 'DeviceControl', 'DeviceFirmwareVersion', 'DeviceModelName', 'DeviceReset', 'DeviceSFNCVersionMajor', + 'DeviceSFNCVersionMinor', 'DeviceSFNCVersionSubMinor', 'DeviceScanType', 'DeviceSerialNumber', + 'DeviceTLType', 'DeviceTLVersionMajor', 'DeviceTLVersionMinor', 'DeviceTLVersionSubMinor', + 'DeviceTemperature', 'DeviceTemperatureSelector', 'DeviceType', 'DeviceUserID', 'DeviceVendorName', + 'DigitalIO', 'ExposureAuto', 'ExposureAutoHighlightReduction', 'ExposureAutoLowerLimit', + 'ExposureAutoReference', 'ExposureAutoUpperLimit', 'ExposureAutoUpperLimitAuto', 'ExposureTime', + 'GPIn', 'GPOut', 'Gain', 'GainAuto', 'GainAutoLowerLimit', 'GainAutoUpperLimit', 'Gamma', 'Height', + 'HeightMax', 'IMXLowLatencyTriggerMode', 'ImageFormatControl', 'OffsetAutoCenter', 'OffsetX', 'OffsetY', + 'PayloadSize', 'PixelFormat', 'ReverseX', 'ReverseY', 'Root', 'SensorHeight', 'SensorWidth', 'Sharpness', + 'ShowOverlay', 'SoftwareAnalogControl', 'SoftwareTransformControl', 'SoftwareTransformEnable', + 'StrobeDelay', 'StrobeDuration', 'StrobeEnable', 'StrobeOperation', 'StrobePolarity', 'TLParamsLocked', + 'TestControl', 'TestPendingAck', 'TimestampLatch', 'TimestampLatchValue', 'TimestampReset', 'ToneMappingAuto', + 'ToneMappingControl', 'ToneMappingEnable', 'ToneMappingGlobalBrightness', 'ToneMappingIntensity', + 'TransportLayerControl', 'TriggerActivation', 'TriggerDebouncer', 'TriggerDelay', 'TriggerDenoise', + 'TriggerMask', 'TriggerMode', 'TriggerOverlap', 'TriggerSelector', 'TriggerSoftware', 'TriggerSource', + 'UserSetControl', 'UserSetDefault', 'UserSetLoad', 'UserSetSave', 'UserSetSelector', 'Width', 'WidthMax'] + """ + self._device_label = self._resolve_device_label(node_map) self._configure_pixel_format(node_map) @@ -172,16 +228,30 @@ def _parse_crop(self, crop) -> Optional[Tuple[int, int, int, int]]: return tuple(int(v) for v in crop) return None - def _find_cti_file(self) -> str: - patterns: List[str] = list(self._cti_search_paths) + @staticmethod + def _search_cti_file(patterns: Tuple[str, ...]) -> Optional[str]: + """Search for a CTI file using the given patterns. + + Returns the first CTI file found, or None if none found. + """ for pattern in patterns: for file_path in glob.glob(pattern): if os.path.isfile(file_path): return file_path - raise RuntimeError( - "Could not locate a GenTL producer (.cti) file. Set 'cti_file' in " - "camera.properties or provide search paths via 'cti_search_paths'." - ) + return None + + def _find_cti_file(self) -> str: + """Find a CTI file using configured or default search paths. + + Raises RuntimeError if no CTI file is found. + """ + cti_file = self._search_cti_file(self._cti_search_paths) + if cti_file is None: + raise RuntimeError( + "Could not locate a GenTL producer (.cti) file. Set 'cti_file' in " + "camera.properties or provide search paths via 'cti_search_paths'." + ) + return cti_file def _available_serials(self) -> List[str]: assert self._harvester is not None diff --git a/dlclivegui/config.py b/dlclivegui/config.py index c9be6a4..ca1f3e5 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -21,6 +21,7 @@ class CameraSettings: crop_y0: int = 0 # Top edge of crop region (0 = no crop) crop_x1: int = 0 # Right edge of crop region (0 = no crop) crop_y1: int = 0 # Bottom edge of crop region (0 = no crop) + max_devices: int = 3 # Maximum number of devices to probe during detection properties: Dict[str, Any] = field(default_factory=dict) def apply_defaults(self) -> "CameraSettings": @@ -51,6 +52,17 @@ class DLCProcessorSettings: model_type: Optional[str] = "base" +@dataclass +class BoundingBoxSettings: + """Configuration for bounding box visualization.""" + + enabled: bool = False + x0: int = 0 + y0: int = 0 + x1: int = 200 + y1: int = 100 + + @dataclass class RecordingSettings: """Configuration for video recording.""" @@ -94,6 +106,7 @@ class ApplicationSettings: camera: CameraSettings = field(default_factory=CameraSettings) dlc: DLCProcessorSettings = field(default_factory=DLCProcessorSettings) recording: RecordingSettings = field(default_factory=RecordingSettings) + bbox: BoundingBoxSettings = field(default_factory=BoundingBoxSettings) @classmethod def from_dict(cls, data: Dict[str, Any]) -> "ApplicationSettings": @@ -108,7 +121,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "ApplicationSettings": recording_data = dict(data.get("recording", {})) recording_data.pop("options", None) recording = RecordingSettings(**recording_data) - return cls(camera=camera, dlc=dlc, recording=recording) + bbox = BoundingBoxSettings(**data.get("bbox", {})) + return cls(camera=camera, dlc=dlc, recording=recording, bbox=bbox) def to_dict(self) -> Dict[str, Any]: """Serialise the configuration to a dictionary.""" @@ -117,6 +131,7 @@ def to_dict(self) -> Dict[str, Any]: "camera": asdict(self.camera), "dlc": asdict(self.dlc), "recording": asdict(self.recording), + "bbox": asdict(self.bbox), } @classmethod diff --git a/dlclivegui/dlc_processor.py b/dlclivegui/dlc_processor.py index 9dd69ca..6358c74 100644 --- a/dlclivegui/dlc_processor.py +++ b/dlclivegui/dlc_processor.py @@ -54,6 +54,7 @@ def __init__(self) -> None: super().__init__() self._settings = DLCProcessorSettings() self._dlc: Optional[Any] = None + self._processor: Optional[Any] = None self._queue: Optional[queue.Queue[Any]] = None self._worker_thread: Optional[threading.Thread] = None self._stop_event = threading.Event() @@ -67,8 +68,9 @@ def __init__(self) -> None: self._processing_times: deque[float] = deque(maxlen=60) self._stats_lock = threading.Lock() - def configure(self, settings: DLCProcessorSettings) -> None: + def configure(self, settings: DLCProcessorSettings, processor: Optional[Any] = None) -> None: self._settings = settings + self._processor = processor def reset(self) -> None: """Stop the worker thread and drop the current DLCLive instance.""" @@ -93,6 +95,10 @@ def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: self._start_worker(frame.copy(), timestamp) return + # Don't count dropped frames until processor is initialized + if not self._initialized: + return + if self._queue is not None: try: # Non-blocking put - drop frame if queue is full @@ -178,10 +184,11 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: options = { "model_path": self._settings.model_path, "model_type": self._settings.model_type, - "processor": None, + "processor": self._processor, "dynamic": [False,0.5,10], "resize": 1.0, } + # todo expose more parameters from settings self._dlc = DLCLive(**options) self._dlc.init_inference(init_frame) self._initialized = True diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 5c76269..66db93c 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -41,12 +41,14 @@ from dlclivegui.cameras.factory import DetectedCamera from dlclivegui.config import ( ApplicationSettings, + BoundingBoxSettings, CameraSettings, DLCProcessorSettings, RecordingSettings, DEFAULT_CONFIG, ) from dlclivegui.dlc_processor import DLCLiveProcessor, PoseResult, ProcessorStats +from dlclivegui.processors.processor_utils import scan_processor_folder, instantiate_from_scan from dlclivegui.video_recorder import RecorderStats, VideoRecorder os.environ["CUDA_VISIBLE_DEVICES"] = "0" @@ -76,6 +78,15 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._display_interval = 1.0 / 25.0 self._last_display_time = 0.0 self._dlc_initialized = False + self._scanned_processors: dict = {} + self._processor_keys: list = [] + self._last_processor_vid_recording = False + self._auto_record_session_name: Optional[str] = None + self._bbox_x0 = 0 + self._bbox_y0 = 0 + self._bbox_x1 = 0 + self._bbox_y1 = 0 + self._bbox_enabled = False self.camera_controller = CameraController() self.dlc_processor = DLCLiveProcessor() @@ -83,6 +94,7 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._setup_ui() self._connect_signals() self._apply_config(self._config) + self._refresh_processors() # Scan and populate processor dropdown self._update_inference_buttons() self._update_camera_controls_enabled() self._metrics_timer = QTimer(self) @@ -96,6 +108,7 @@ def _setup_ui(self) -> None: central = QWidget() layout = QHBoxLayout(central) + # Video display widget self.video_label = QLabel("Camera preview not started") self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.video_label.setMinimumSize(640, 360) @@ -103,27 +116,36 @@ def _setup_ui(self) -> None: QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding ) + # Controls panel with fixed width to prevent shifting controls_widget = QWidget() + controls_widget.setMaximumWidth(500) + controls_widget.setSizePolicy( + QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding + ) controls_layout = QVBoxLayout(controls_widget) + controls_layout.setContentsMargins(5, 5, 5, 5) controls_layout.addWidget(self._build_camera_group()) controls_layout.addWidget(self._build_dlc_group()) controls_layout.addWidget(self._build_recording_group()) + controls_layout.addWidget(self._build_bbox_group()) - button_bar = QHBoxLayout() + # Preview/Stop buttons at bottom of controls - wrap in widget + button_bar_widget = QWidget() + button_bar = QHBoxLayout(button_bar_widget) + button_bar.setContentsMargins(0, 5, 0, 5) self.preview_button = QPushButton("Start Preview") + self.preview_button.setMinimumWidth(150) self.stop_preview_button = QPushButton("Stop Preview") self.stop_preview_button.setEnabled(False) + self.stop_preview_button.setMinimumWidth(150) button_bar.addWidget(self.preview_button) button_bar.addWidget(self.stop_preview_button) - controls_layout.addLayout(button_bar) + controls_layout.addWidget(button_bar_widget) controls_layout.addStretch(1) - preview_layout = QVBoxLayout() - preview_layout.addWidget(self.video_label) - preview_layout.addStretch(1) - - layout.addWidget(controls_widget) - layout.addLayout(preview_layout, stretch=1) + # Add controls and video to main layout + layout.addWidget(controls_widget, stretch=0) + layout.addWidget(self.video_label, stretch=1) self.setCentralWidget(central) self.setStatusBar(QStatusBar()) @@ -154,7 +176,6 @@ def _build_camera_group(self) -> QGroupBox: form = QFormLayout(group) self.camera_backend = QComboBox() - self.camera_backend.setEditable(True) availability = CameraFactory.available_backends() for backend in CameraFactory.backend_names(): label = backend @@ -218,13 +239,6 @@ def _build_camera_group(self) -> QGroupBox: form.addRow("Crop (x0,y0,x1,y1)", crop_layout) - self.camera_properties_edit = QPlainTextEdit() - self.camera_properties_edit.setPlaceholderText( - '{"other_property": "value"}' - ) - self.camera_properties_edit.setFixedHeight(60) - form.addRow("Advanced properties", self.camera_properties_edit) - self.rotation_combo = QComboBox() self.rotation_combo.addItem("0° (default)", 0) self.rotation_combo.addItem("90°", 90) @@ -245,9 +259,9 @@ def _build_dlc_group(self) -> QGroupBox: self.model_path_edit = QLineEdit() self.model_path_edit.setPlaceholderText("/path/to/exported/model") path_layout.addWidget(self.model_path_edit) - browse_model = QPushButton("Browse…") - browse_model.clicked.connect(self._action_browse_model) - path_layout.addWidget(browse_model) + self.browse_model_button = QPushButton("Browse…") + self.browse_model_button.clicked.connect(self._action_browse_model) + path_layout.addWidget(self.browse_model_button) form.addRow("Model directory", path_layout) self.model_type_combo = QComboBox() @@ -256,24 +270,57 @@ def _build_dlc_group(self) -> QGroupBox: self.model_type_combo.setCurrentIndex(0) # Default to base form.addRow("Model type", self.model_type_combo) + # Processor selection + processor_path_layout = QHBoxLayout() + self.processor_folder_edit = QLineEdit() + self.processor_folder_edit.setText(str(Path(__file__).parent.joinpath("processors"))) + processor_path_layout.addWidget(self.processor_folder_edit) + + self.browse_processor_folder_button = QPushButton("Browse...") + self.browse_processor_folder_button.clicked.connect(self._action_browse_processor_folder) + processor_path_layout.addWidget(self.browse_processor_folder_button) + + self.refresh_processors_button = QPushButton("Refresh") + self.refresh_processors_button.clicked.connect(self._refresh_processors) + processor_path_layout.addWidget(self.refresh_processors_button) + form.addRow("Processor folder", processor_path_layout) + + self.processor_combo = QComboBox() + self.processor_combo.addItem("No Processor", None) + form.addRow("Processor", self.processor_combo) + self.additional_options_edit = QPlainTextEdit() self.additional_options_edit.setPlaceholderText('') - self.additional_options_edit.setFixedHeight(60) + self.additional_options_edit.setFixedHeight(40) form.addRow("Additional options", self.additional_options_edit) - inference_buttons = QHBoxLayout() + # Wrap inference buttons in a widget to prevent shifting + inference_button_widget = QWidget() + inference_buttons = QHBoxLayout(inference_button_widget) + inference_buttons.setContentsMargins(0, 0, 0, 0) self.start_inference_button = QPushButton("Start pose inference") self.start_inference_button.setEnabled(False) + self.start_inference_button.setMinimumWidth(150) inference_buttons.addWidget(self.start_inference_button) self.stop_inference_button = QPushButton("Stop pose inference") self.stop_inference_button.setEnabled(False) + self.stop_inference_button.setMinimumWidth(150) inference_buttons.addWidget(self.stop_inference_button) - form.addRow(inference_buttons) + form.addRow(inference_button_widget) self.show_predictions_checkbox = QCheckBox("Display pose predictions") self.show_predictions_checkbox.setChecked(True) form.addRow(self.show_predictions_checkbox) + self.auto_record_checkbox = QCheckBox("Auto-record video on processor command") + self.auto_record_checkbox.setChecked(False) + self.auto_record_checkbox.setToolTip("Automatically start/stop video recording when processor receives video recording commands") + form.addRow(self.auto_record_checkbox) + + self.processor_status_label = QLabel("Processor: No clients | Recording: No") + self.processor_status_label.setWordWrap(True) + form.addRow("Processor Status", self.processor_status_label) + self.dlc_stats_label = QLabel("DLC processor idle") self.dlc_stats_label.setWordWrap(True) form.addRow("Performance", self.dlc_stats_label) @@ -284,9 +331,6 @@ def _build_recording_group(self) -> QGroupBox: group = QGroupBox("Recording") form = QFormLayout(group) - self.recording_enabled_checkbox = QCheckBox("Record video while running") - form.addRow(self.recording_enabled_checkbox) - dir_layout = QHBoxLayout() self.output_directory_edit = QLineEdit() dir_layout.addWidget(self.output_directory_edit) @@ -313,14 +357,18 @@ def _build_recording_group(self) -> QGroupBox: self.crf_spin.setValue(23) form.addRow("CRF", self.crf_spin) + # Wrap recording buttons in a widget to prevent shifting + recording_button_widget = QWidget() + buttons = QHBoxLayout(recording_button_widget) + buttons.setContentsMargins(0, 0, 0, 0) self.start_record_button = QPushButton("Start recording") + self.start_record_button.setMinimumWidth(150) + buttons.addWidget(self.start_record_button) self.stop_record_button = QPushButton("Stop recording") self.stop_record_button.setEnabled(False) - - buttons = QHBoxLayout() - buttons.addWidget(self.start_record_button) + self.stop_record_button.setMinimumWidth(150) buttons.addWidget(self.stop_record_button) - form.addRow(buttons) + form.addRow(recording_button_widget) self.recording_stats_label = QLabel(self._last_recorder_summary) self.recording_stats_label.setWordWrap(True) @@ -328,6 +376,45 @@ def _build_recording_group(self) -> QGroupBox: return group + def _build_bbox_group(self) -> QGroupBox: + """Build bounding box visualization controls.""" + group = QGroupBox("Bounding Box Visualization") + form = QFormLayout(group) + + self.bbox_enabled_checkbox = QCheckBox("Show bounding box") + self.bbox_enabled_checkbox.setChecked(False) + form.addRow(self.bbox_enabled_checkbox) + + bbox_layout = QHBoxLayout() + + self.bbox_x0_spin = QSpinBox() + self.bbox_x0_spin.setRange(0, 7680) + self.bbox_x0_spin.setPrefix("x0:") + self.bbox_x0_spin.setValue(0) + bbox_layout.addWidget(self.bbox_x0_spin) + + self.bbox_y0_spin = QSpinBox() + self.bbox_y0_spin.setRange(0, 4320) + self.bbox_y0_spin.setPrefix("y0:") + self.bbox_y0_spin.setValue(0) + bbox_layout.addWidget(self.bbox_y0_spin) + + self.bbox_x1_spin = QSpinBox() + self.bbox_x1_spin.setRange(0, 7680) + self.bbox_x1_spin.setPrefix("x1:") + self.bbox_x1_spin.setValue(100) + bbox_layout.addWidget(self.bbox_x1_spin) + + self.bbox_y1_spin = QSpinBox() + self.bbox_y1_spin.setRange(0, 4320) + self.bbox_y1_spin.setPrefix("y1:") + self.bbox_y1_spin.setValue(100) + bbox_layout.addWidget(self.bbox_y1_spin) + + form.addRow("Coordinates", bbox_layout) + + return group + # ------------------------------------------------------------------ signals def _connect_signals(self) -> None: self.preview_button.clicked.connect(self._start_preview) @@ -338,13 +425,20 @@ def _connect_signals(self) -> None: lambda: self._refresh_camera_indices(keep_current=True) ) self.camera_backend.currentIndexChanged.connect(self._on_backend_changed) - self.camera_backend.editTextChanged.connect(self._on_backend_changed) + self.camera_backend.currentIndexChanged.connect(self._update_backend_specific_controls) self.rotation_combo.currentIndexChanged.connect(self._on_rotation_changed) self.start_inference_button.clicked.connect(self._start_inference) self.stop_inference_button.clicked.connect(lambda: self._stop_inference()) self.show_predictions_checkbox.stateChanged.connect( self._on_show_predictions_changed ) + + # Connect bounding box controls + self.bbox_enabled_checkbox.stateChanged.connect(self._on_bbox_changed) + self.bbox_x0_spin.valueChanged.connect(self._on_bbox_changed) + self.bbox_y0_spin.valueChanged.connect(self._on_bbox_changed) + self.bbox_x1_spin.valueChanged.connect(self._on_bbox_changed) + self.bbox_y1_spin.valueChanged.connect(self._on_bbox_changed) self.camera_controller.frame_ready.connect(self._on_frame_ready) self.camera_controller.started.connect(self._on_camera_started) @@ -372,22 +466,20 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.crop_y1.setValue(int(camera.crop_y1) if hasattr(camera, 'crop_y1') else 0) backend_name = camera.backend or "opencv" + self.camera_backend.blockSignals(True) index = self.camera_backend.findData(backend_name) if index >= 0: self.camera_backend.setCurrentIndex(index) else: self.camera_backend.setEditText(backend_name) + self.camera_backend.blockSignals(False) self._refresh_camera_indices(keep_current=False) self._select_camera_by_index( camera.index, fallback_text=camera.name or str(camera.index) ) - # Set advanced properties (exposure and gain are now separate fields) - self.camera_properties_edit.setPlainText( - json.dumps(camera.properties, indent=2) if camera.properties else "" - ) - self._active_camera_settings = None + self._update_backend_specific_controls() dlc = config.dlc self.model_path_edit.setText(dlc.model_path) @@ -403,7 +495,6 @@ def _apply_config(self, config: ApplicationSettings) -> None: ) recording = config.recording - self.recording_enabled_checkbox.setChecked(recording.enabled) self.output_directory_edit.setText(recording.directory) self.filename_edit.setText(recording.filename) self.container_combo.setCurrentText(recording.container) @@ -415,11 +506,20 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.codec_combo.setCurrentIndex(self.codec_combo.count() - 1) self.crf_spin.setValue(int(recording.crf)) + # Set bounding box settings from config + bbox = config.bbox + self.bbox_enabled_checkbox.setChecked(bbox.enabled) + self.bbox_x0_spin.setValue(bbox.x0) + self.bbox_y0_spin.setValue(bbox.y0) + self.bbox_x1_spin.setValue(bbox.x1) + self.bbox_y1_spin.setValue(bbox.y1) + def _current_config(self) -> ApplicationSettings: return ApplicationSettings( camera=self._camera_settings_from_ui(), dlc=self._dlc_settings_from_ui(), recording=self._recording_settings_from_ui(), + bbox=self._bbox_settings_from_ui(), ) def _camera_settings_from_ui(self) -> CameraSettings: @@ -427,7 +527,6 @@ def _camera_settings_from_ui(self) -> CameraSettings: if index is None: raise ValueError("Camera selection must provide a numeric index") backend_text = self._current_backend_name() - properties = self._parse_json(self.camera_properties_edit.toPlainText()) # Get exposure and gain from explicit UI fields exposure = self.camera_exposure.value() @@ -439,12 +538,6 @@ def _camera_settings_from_ui(self) -> CameraSettings: crop_x1 = self.crop_x1.value() crop_y1 = self.crop_y1.value() - # Also add to properties dict for backward compatibility with camera backends - if exposure > 0: - properties["exposure"] = exposure - if gain > 0.0: - properties["gain"] = gain - name_text = self.camera_index.currentText().strip() settings = CameraSettings( name=name_text or f"Camera {index}", @@ -457,7 +550,7 @@ def _camera_settings_from_ui(self) -> CameraSettings: crop_y0=crop_y0, crop_x1=crop_x1, crop_y1=crop_y1, - properties=properties, + properties={}, ) return settings.apply_defaults() @@ -472,7 +565,9 @@ def _refresh_camera_indices( self, *_args: object, keep_current: bool = True ) -> None: backend = self._current_backend_name() - detected = CameraFactory.detect_cameras(backend) + # Get max_devices from config, default to 3 + max_devices = self._config.camera.max_devices if hasattr(self._config.camera, 'max_devices') else 3 + detected = CameraFactory.detect_cameras(backend, max_devices=max_devices) debug_info = [f"{camera.index}:{camera.label}" for camera in detected] logging.info( f"[CameraDetection] Available cameras for backend '{backend}': {debug_info}" @@ -519,19 +614,6 @@ def _current_camera_index_value(self) -> Optional[int]: return int(text) except ValueError: return None - - def _current_backend_name(self) -> str: - backend_data = self.camera_backend.currentData() - if isinstance(backend_data, str) and backend_data: - return backend_data - text = self.camera_backend.currentText().strip() - return text or "opencv" - - def _refresh_camera_indices( - self, *_args: object, keep_current: bool = True - ) -> None: - backend = self._current_backend_name() - detected = CameraFactory.detect_cameras(backend) debug_info = [f"{camera.index}:{camera.label}" for camera in detected] logging.info( f"[CameraDetection] Available cameras for backend '{backend}': {debug_info}" @@ -600,7 +682,7 @@ def _dlc_settings_from_ui(self) -> DLCProcessorSettings: def _recording_settings_from_ui(self) -> RecordingSettings: return RecordingSettings( - enabled=self.recording_enabled_checkbox.isChecked(), + enabled=True, # Always enabled - recording controlled by button directory=self.output_directory_edit.text().strip(), filename=self.filename_edit.text().strip() or "session.mp4", container=self.container_combo.currentText().strip() or "mp4", @@ -608,6 +690,15 @@ def _recording_settings_from_ui(self) -> RecordingSettings: crf=int(self.crf_spin.value()), ) + def _bbox_settings_from_ui(self) -> BoundingBoxSettings: + return BoundingBoxSettings( + enabled=self.bbox_enabled_checkbox.isChecked(), + x0=self.bbox_x0_spin.value(), + y0=self.bbox_y0_spin.value(), + x1=self.bbox_x1_spin.value(), + y1=self.bbox_y1_spin.value(), + ) + # ------------------------------------------------------------------ actions def _action_load_config(self) -> None: file_name, _ = QFileDialog.getOpenFileName( @@ -666,9 +757,66 @@ def _action_browse_directory(self) -> None: if directory: self.output_directory_edit.setText(directory) + def _action_browse_processor_folder(self) -> None: + """Browse for processor folder.""" + current_path = self.processor_folder_edit.text() or "./processors" + directory = QFileDialog.getExistingDirectory( + self, "Select processor folder", current_path + ) + if directory: + self.processor_folder_edit.setText(directory) + self._refresh_processors() + + def _refresh_processors(self) -> None: + """Scan processor folder and populate dropdown.""" + folder_path = self.processor_folder_edit.text() or "./processors" + + # Clear existing items (keep "No Processor") + self.processor_combo.clear() + self.processor_combo.addItem("No Processor", None) + + # Scan folder + try: + self._scanned_processors = scan_processor_folder(folder_path) + self._processor_keys = list(self._scanned_processors.keys()) + + # Populate dropdown + for key in self._processor_keys: + info = self._scanned_processors[key] + display_name = f"{info['name']} ({info['file']})" + self.processor_combo.addItem(display_name, key) + + status_msg = f"Found {len(self._processor_keys)} processor(s) in {folder_path}" + self.statusBar().showMessage(status_msg, 3000) + + except Exception as e: + error_msg = f"Error scanning processors: {e}" + self.statusBar().showMessage(error_msg, 5000) + logging.error(error_msg) + self._scanned_processors = {} + self._processor_keys = [] + def _on_backend_changed(self, *_args: object) -> None: self._refresh_camera_indices(keep_current=False) + def _update_backend_specific_controls(self) -> None: + """Enable/disable controls based on selected backend.""" + backend = self._current_backend_name() + is_opencv = backend.lower() == "opencv" + + # Disable exposure and gain controls for OpenCV backend + self.camera_exposure.setEnabled(not is_opencv) + self.camera_gain.setEnabled(not is_opencv) + + # Set tooltip to explain why controls are disabled + if is_opencv: + tooltip = "Exposure and gain control not supported with OpenCV backend" + self.camera_exposure.setToolTip(tooltip) + self.camera_gain.setToolTip(tooltip) + else: + self.camera_exposure.setToolTip("") + self.camera_gain.setToolTip("") + def _on_rotation_changed(self, _index: int) -> None: data = self.rotation_combo.currentData() self._rotation_degrees = int(data) if isinstance(data, int) else 0 @@ -764,7 +912,25 @@ def _configure_dlc(self) -> bool: if not settings.model_path: self._show_error("Please select a DLCLive model before starting inference.") return False - self.dlc_processor.configure(settings) + + # Instantiate processor if selected + processor = None + selected_key = self.processor_combo.currentData() + if selected_key is not None and self._scanned_processors: + try: + # For now, instantiate with no parameters + # TODO: Add parameter dialog for processors that need params + # or pass kwargs from config ? + processor = instantiate_from_scan(self._scanned_processors, selected_key) + processor_name = self._scanned_processors[selected_key]['name'] + self.statusBar().showMessage(f"Loaded processor: {processor_name}", 3000) + except Exception as e: + error_msg = f"Failed to instantiate processor: {e}" + self._show_error(error_msg) + logging.error(error_msg) + return False + + self.dlc_processor.configure(settings, processor=processor) return True def _update_inference_buttons(self) -> None: @@ -772,6 +938,22 @@ def _update_inference_buttons(self) -> None: self.start_inference_button.setEnabled(preview_running and not self._dlc_active) self.stop_inference_button.setEnabled(preview_running and self._dlc_active) + def _update_dlc_controls_enabled(self) -> None: + """Enable/disable DLC settings based on inference state.""" + allow_changes = not self._dlc_active + widgets = [ + self.model_path_edit, + self.browse_model_button, + self.model_type_combo, + self.processor_folder_edit, + self.browse_processor_folder_button, + self.refresh_processors_button, + self.processor_combo, + self.additional_options_edit, + ] + for widget in widgets: + widget.setEnabled(allow_changes) + def _update_camera_controls_enabled(self) -> None: recording_active = ( self._video_recorder is not None and self._video_recorder.is_running @@ -792,7 +974,6 @@ def _update_camera_controls_enabled(self) -> None: self.crop_y0, self.crop_x1, self.crop_y1, - self.camera_properties_edit, self.rotation_combo, self.codec_combo, self.crf_spin, @@ -871,6 +1052,10 @@ def _update_metrics(self) -> None: else: self.dlc_stats_label.setText("DLC processor idle") + # Update processor status (connection and recording state) + if hasattr(self, "processor_status_label"): + self._update_processor_status() + if hasattr(self, "recording_stats_label"): if self._video_recorder is not None: stats = self._video_recorder.get_stats() @@ -884,6 +1069,62 @@ def _update_metrics(self) -> None: else: self.recording_stats_label.setText(self._last_recorder_summary) + def _update_processor_status(self) -> None: + """Update processor connection and recording status, handle auto-recording.""" + if not self._dlc_active or not self._dlc_initialized: + self.processor_status_label.setText("Processor: Not active") + return + + # Get processor instance from dlc_processor + processor = self.dlc_processor._processor + + if processor is None: + self.processor_status_label.setText("Processor: None loaded") + return + + # Check if processor has the required attributes (socket-based processors) + if not hasattr(processor, 'conns') or not hasattr(processor, '_recording'): + self.processor_status_label.setText("Processor: No status info") + return + + # Get connection count and recording state + num_clients = len(processor.conns) + is_recording = processor.recording if hasattr(processor, 'recording') else False + + # Format status message + client_str = f"{num_clients} client{'s' if num_clients != 1 else ''}" + recording_str = "Yes" if is_recording else "No" + self.processor_status_label.setText(f"Clients: {client_str} | Recording: {recording_str}") + + # Handle auto-recording based on processor's video recording flag + if hasattr(processor, '_vid_recording') and self.auto_record_checkbox.isChecked(): + current_vid_recording = processor.video_recording + + # Check if video recording state changed + if current_vid_recording != self._last_processor_vid_recording: + if current_vid_recording: + # Start video recording + if not self._video_recorder or not self._video_recorder.is_running: + # Get session name from processor + session_name = getattr(processor, 'session_name', 'auto_session') + self._auto_record_session_name = session_name + + # Update filename with session name + original_filename = self.filename_edit.text() + self.filename_edit.setText(f"{session_name}.mp4") + + self._start_recording() + self.statusBar().showMessage(f"Auto-started recording: {session_name}", 3000) + logging.info(f"Auto-recording started for session: {session_name}") + else: + # Stop video recording + if self._video_recorder and self._video_recorder.is_running: + self._stop_recording() + self.statusBar().showMessage("Auto-stopped recording", 3000) + logging.info("Auto-recording stopped") + + self._last_processor_vid_recording = current_vid_recording + def _start_inference(self) -> None: if self._dlc_active: self.statusBar().showMessage("Pose inference already running", 3000) @@ -909,6 +1150,7 @@ def _start_inference(self) -> None: self.statusBar().showMessage("Initializing DLCLive…", 3000) self._update_camera_controls_enabled() + self._update_dlc_controls_enabled() def _stop_inference(self, show_message: bool = True) -> None: was_active = self._dlc_active @@ -916,6 +1158,8 @@ def _stop_inference(self, show_message: bool = True) -> None: self._dlc_initialized = False self.dlc_processor.reset() self._last_pose = None + self._last_processor_vid_recording = False + self._auto_record_session_name = None # Reset button appearance self.start_inference_button.setText("Start pose inference") @@ -927,6 +1171,7 @@ def _stop_inference(self, show_message: bool = True) -> None: self.statusBar().showMessage("Pose inference stopped", 3000) self._update_inference_buttons() self._update_camera_controls_enabled() + self._update_dlc_controls_enabled() # ------------------------------------------------------------------ recording def _start_recording(self) -> None: @@ -1026,9 +1271,10 @@ def _on_frame_ready(self, frame_data: FrameData) -> None: # Check if it's a frame size error if "Frame size changed" in str(exc): self._show_warning(f"Camera resolution changed - restarting recording: {exc}") + was_recording = self._video_recorder and self._video_recorder.is_running self._stop_recording() - # Restart recording with new resolution if enabled - if self.recording_enabled_checkbox.isChecked(): + # Restart recording with new resolution if it was already running + if was_recording: try: self._start_recording() except Exception as restart_exc: @@ -1060,6 +1306,11 @@ def _update_video_display(self, frame: np.ndarray) -> None: and self._last_pose.pose is not None ): display_frame = self._draw_pose(frame, self._last_pose.pose) + + # Draw bounding box if enabled + if self._bbox_enabled: + display_frame = self._draw_bbox(display_frame) + rgb = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape bytes_per_line = ch * w @@ -1104,6 +1355,41 @@ def _on_show_predictions_changed(self, _state: int) -> None: if self._current_frame is not None: self._display_frame(self._current_frame, force=True) + def _on_bbox_changed(self, _value: int = 0) -> None: + """Handle bounding box parameter changes.""" + self._bbox_enabled = self.bbox_enabled_checkbox.isChecked() + self._bbox_x0 = self.bbox_x0_spin.value() + self._bbox_y0 = self.bbox_y0_spin.value() + self._bbox_x1 = self.bbox_x1_spin.value() + self._bbox_y1 = self.bbox_y1_spin.value() + + # Force redraw if preview is running + if self._current_frame is not None: + self._display_frame(self._current_frame, force=True) + + def _draw_bbox(self, frame: np.ndarray) -> np.ndarray: + """Draw bounding box on frame with red lines.""" + overlay = frame.copy() + x0 = self._bbox_x0 + y0 = self._bbox_y0 + x1 = self._bbox_x1 + y1 = self._bbox_y1 + + # Validate coordinates + if x0 >= x1 or y0 >= y1: + return overlay + + height, width = frame.shape[:2] + x0 = max(0, min(x0, width - 1)) + y0 = max(0, min(y0, height - 1)) + x1 = max(x0 + 1, min(x1, width)) + y1 = max(y0 + 1, min(y1, height)) + + # Draw red rectangle (BGR format: red is (0, 0, 255)) + cv2.rectangle(overlay, (x0, y0), (x1, y1), (0, 0, 255), 2) + + return overlay + def _draw_pose(self, frame: np.ndarray, pose: np.ndarray) -> np.ndarray: overlay = frame.copy() for keypoint in np.asarray(pose): diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index dbb0f90..3f5b951 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -134,6 +134,7 @@ def __init__( self._session_name = "test_session" self.filename = None self._recording = Event() # Thread-safe recording flag + self._vid_recording = Event() # Thread-safe video recording flag # State self.curr_step = 0 @@ -144,6 +145,11 @@ def recording(self): """Thread-safe recording flag.""" return self._recording.is_set() + @property + def video_recording(self): + """Thread-safe video recording flag.""" + return self._vid_recording.is_set() + @property def session_name(self): return self._session_name @@ -155,11 +161,11 @@ def session_name(self, name): def _accept_loop(self): """Background thread to accept new client connections.""" - LOG.info(f"DLC Processor listening on {self.address[0]}:{self.address[1]}") + LOG.debug(f"DLC Processor listening on {self.address[0]}:{self.address[1]}") while not self._stop.is_set(): try: c = self.listener.accept() - LOG.info(f"Client connected from {self.listener.last_accepted}") + LOG.debug(f"Client connected from {self.listener.last_accepted}") self.conns.add(c) # Start RX loop for this connection (in case clients send data) Thread(target=self._rx_loop, args=(c,), name="DLCRX", daemon=True).start() @@ -195,6 +201,7 @@ def _handle_client_message(self, msg): LOG.info(f"Session name set to: {session_name}") elif cmd == "start_recording": + self._vid_recording.set() self._recording.set() # Clear all data queues self._clear_data_queues() @@ -203,12 +210,18 @@ def _handle_client_message(self, msg): elif cmd == "stop_recording": self._recording.clear() + self._vid_recording.clear() LOG.info("Recording stopped") elif cmd == "save": filename = msg.get("filename", self.filename) save_code = self.save(filename) LOG.info(f"Save {'successful' if save_code == 1 else 'failed'}: {filename}") + + elif cmd == "start_video": + # Placeholder for video recording start + self._vid_recording.set() + LOG.info("Start video recording command received") def _clear_data_queues(self): """Clear all data storage queues. Override in subclasses to clear additional queues.""" diff --git a/example_recording_control.py b/example_recording_control.py deleted file mode 100644 index ed426e8..0000000 --- a/example_recording_control.py +++ /dev/null @@ -1,194 +0,0 @@ -""" -Example: Recording control with DLCClient and MyProcessor_socket - -This demonstrates: -1. Starting a processor -2. Connecting a client -3. Controlling recording (start/stop/save) from the client -4. Session name management -""" - -import time -import sys -from pathlib import Path - -# Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) - -from mouse_ar.ctrl.dlc_client import DLCClient - - -def example_recording_workflow(): - """Complete workflow: processor + client with recording control.""" - - print("\n" + "="*70) - print("EXAMPLE: Recording Control Workflow") - print("="*70) - - # NOTE: This example assumes MyProcessor_socket is already running - # Start it separately with: - # from dlc_processor_socket import MyProcessor_socket - # processor = MyProcessor_socket(bind=("localhost", 6000)) - # # Then run DLCLive with this processor - - print("\n[CLIENT] Connecting to processor at localhost:6000...") - client = DLCClient(address=("localhost", 6000)) - - try: - # Start the client (connects and begins receiving data) - client.start() - print("[CLIENT] Connected!") - time.sleep(0.5) # Wait for connection to stabilize - - # Set session name - print("\n[CLIENT] Setting session name to 'experiment_001'...") - client.set_session_name("experiment_001") - time.sleep(0.2) - - # Start recording - print("[CLIENT] Starting recording (clears processor data queues)...") - client.start_recording() - time.sleep(0.2) - - # Receive some data - print("\n[CLIENT] Receiving data for 5 seconds...") - for i in range(5): - data = client.read() - if data: - vals = data["vals"] - print(f" t={vals[0]:.2f}, x={vals[1]:.1f}, y={vals[2]:.1f}, " - f"heading={vals[3]:.1f}°, head_angle={vals[4]:.2f}rad") - time.sleep(1.0) - - # Stop recording - print("\n[CLIENT] Stopping recording...") - client.stop_recording() - time.sleep(0.2) - - # Trigger save - print("[CLIENT] Triggering save on processor...") - client.trigger_save() # Uses processor's default filename - # OR specify custom filename: - # client.trigger_save(filename="my_custom_data.pkl") - time.sleep(0.5) - - print("\n[CLIENT] ✓ Workflow complete!") - - except Exception as e: - print(f"\n[ERROR] {e}") - print("\nMake sure MyProcessor_socket is running!") - print("Example:") - print(" from dlc_processor_socket import MyProcessor_socket") - print(" processor = MyProcessor_socket()") - print(" # Then run DLCLive with this processor") - - finally: - print("\n[CLIENT] Closing connection...") - client.close() - - -def example_multiple_sessions(): - """Example: Recording multiple sessions with the same processor.""" - - print("\n" + "="*70) - print("EXAMPLE: Multiple Sessions") - print("="*70) - - client = DLCClient(address=("localhost", 6000)) - - try: - client.start() - print("[CLIENT] Connected!") - time.sleep(0.5) - - # Session 1 - print("\n--- SESSION 1 ---") - client.set_session_name("trial_001") - client.start_recording() - print("Recording session 'trial_001' for 3 seconds...") - time.sleep(3.0) - client.stop_recording() - client.trigger_save() # Saves as "trial_001_dlc_processor_data.pkl" - print("Session 1 saved!") - - time.sleep(1.0) - - # Session 2 - print("\n--- SESSION 2 ---") - client.set_session_name("trial_002") - client.start_recording() - print("Recording session 'trial_002' for 3 seconds...") - time.sleep(3.0) - client.stop_recording() - client.trigger_save() # Saves as "trial_002_dlc_processor_data.pkl" - print("Session 2 saved!") - - print("\n✓ Multiple sessions recorded successfully!") - - except Exception as e: - print(f"\n[ERROR] {e}") - - finally: - client.close() - - -def example_command_api(): - """Example: Using the low-level command API.""" - - print("\n" + "="*70) - print("EXAMPLE: Low-level Command API") - print("="*70) - - client = DLCClient(address=("localhost", 6000)) - - try: - client.start() - time.sleep(0.5) - - # Using send_command directly - print("\n[CLIENT] Using send_command()...") - - # Set session name - client.send_command("set_session_name", session_name="custom_session") - print(" ✓ Sent: set_session_name") - time.sleep(0.2) - - # Start recording - client.send_command("start_recording") - print(" ✓ Sent: start_recording") - time.sleep(2.0) - - # Stop recording - client.send_command("stop_recording") - print(" ✓ Sent: stop_recording") - time.sleep(0.2) - - # Save with custom filename - client.send_command("save", filename="my_data.pkl") - print(" ✓ Sent: save") - time.sleep(0.5) - - print("\n✓ Commands sent successfully!") - - except Exception as e: - print(f"\n[ERROR] {e}") - - finally: - client.close() - - -if __name__ == "__main__": - print("\n" + "="*70) - print("DLC PROCESSOR RECORDING CONTROL EXAMPLES") - print("="*70) - print("\nNOTE: These examples require MyProcessor_socket to be running.") - print("Start it separately before running these examples.") - print("="*70) - - # Uncomment the example you want to run: - - # example_recording_workflow() - # example_multiple_sessions() - # example_command_api() - - print("\nUncomment an example in the script to run it.") From f1ab8b02c28e3553097598fe451cae37bb58f901 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Fri, 24 Oct 2025 15:12:44 +0200 Subject: [PATCH 13/69] formatting and precommit workflow --- .github/workflows/format.yml | 23 ++ .gitignore | 1 - .pre-commit-config.yaml | 23 ++ dlclivegui/__init__.py | 8 +- dlclivegui/camera_controller.py | 99 ++++--- dlclivegui/cameras/__init__.py | 1 + dlclivegui/cameras/base.py | 1 + dlclivegui/cameras/basler_backend.py | 9 +- dlclivegui/cameras/factory.py | 3 +- dlclivegui/cameras/gentl_backend.py | 46 ++-- dlclivegui/cameras/opencv_backend.py | 11 +- dlclivegui/config.py | 13 +- dlclivegui/dlc_processor.py | 62 +++-- dlclivegui/gui.py | 243 ++++++++---------- dlclivegui/processors/GUI_INTEGRATION.md | 12 +- dlclivegui/processors/PLUGIN_SYSTEM.md | 4 +- dlclivegui/processors/dlc_processor_socket.py | 62 ++--- dlclivegui/processors/processor_utils.py | 43 ++-- dlclivegui/video_recorder.py | 41 ++- setup.py | 1 + 20 files changed, 378 insertions(+), 328 deletions(-) create mode 100644 .github/workflows/format.yml create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 0000000..a226ae0 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,23 @@ +name: pre-commit-format + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + pre_commit_checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + ref: ${{ github.head_ref }} + + - uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - run: pip install pre-commit + - run: pre-commit run --all-files diff --git a/.gitignore b/.gitignore index 1a13ced..d2ee717 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ ### DLC Live Specific ##################### -*config* **test* ################### diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..09dc3cd --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-added-large-files + - id: check-yaml + - id: end-of-file-fixer + - id: name-tests-test + args: [--pytest-test-first] + - id: trailing-whitespace + - id: check-merge-conflict + + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + args: ["--profile", "black", "--line-length", "100", "--atomic"] + + - repo: https://github.com/psf/black + rev: 24.4.2 + hooks: + - id: black + args: ["--line-length=100"] diff --git a/dlclivegui/__init__.py b/dlclivegui/__init__.py index 1408486..d91f23b 100644 --- a/dlclivegui/__init__.py +++ b/dlclivegui/__init__.py @@ -1,10 +1,6 @@ """DeepLabCut Live GUI package.""" -from .config import ( - ApplicationSettings, - CameraSettings, - DLCProcessorSettings, - RecordingSettings, -) + +from .config import ApplicationSettings, CameraSettings, DLCProcessorSettings, RecordingSettings from .gui import MainWindow, main __all__ = [ diff --git a/dlclivegui/camera_controller.py b/dlclivegui/camera_controller.py index 5618163..7c80df1 100644 --- a/dlclivegui/camera_controller.py +++ b/dlclivegui/camera_controller.py @@ -1,4 +1,5 @@ """Camera management for the DLC Live GUI.""" + from __future__ import annotations import logging @@ -8,7 +9,7 @@ from typing import Optional import numpy as np -from PyQt6.QtCore import QObject, QThread, QMetaObject, Qt, pyqtSignal, pyqtSlot +from PyQt6.QtCore import QMetaObject, QObject, Qt, QThread, pyqtSignal, pyqtSlot from dlclivegui.cameras import CameraFactory from dlclivegui.cameras.base import CameraBackend @@ -39,20 +40,20 @@ def __init__(self, settings: CameraSettings): self._settings = settings self._stop_event = Event() self._backend: Optional[CameraBackend] = None - + # Error recovery settings self._max_consecutive_errors = 5 self._max_reconnect_attempts = 3 self._retry_delay = 0.1 # seconds self._reconnect_delay = 1.0 # seconds - + # Frame validation self._expected_frame_size: Optional[tuple[int, int]] = None # (height, width) @pyqtSlot() def run(self) -> None: self._stop_event.clear() - + # Initialize camera if not self._initialize_camera(): self.finished.emit() @@ -66,30 +67,36 @@ def run(self) -> None: while not self._stop_event.is_set(): try: frame, timestamp = self._backend.read() - + # Validate frame size if not self._validate_frame_size(frame): consecutive_errors += 1 - LOGGER.warning(f"Frame size validation failed ({consecutive_errors}/{self._max_consecutive_errors})") + LOGGER.warning( + f"Frame size validation failed ({consecutive_errors}/{self._max_consecutive_errors})" + ) if consecutive_errors >= self._max_consecutive_errors: self.error_occurred.emit("Too many frames with incorrect size") break time.sleep(self._retry_delay) continue - + consecutive_errors = 0 # Reset error count on success reconnect_attempts = 0 # Reset reconnect attempts on success - + except TimeoutError as exc: consecutive_errors += 1 - LOGGER.warning(f"Camera frame timeout ({consecutive_errors}/{self._max_consecutive_errors}): {exc}") - + LOGGER.warning( + f"Camera frame timeout ({consecutive_errors}/{self._max_consecutive_errors}): {exc}" + ) + if self._stop_event.is_set(): break - + # Handle timeout with retry logic if consecutive_errors < self._max_consecutive_errors: - self.warning_occurred.emit(f"Frame timeout (retry {consecutive_errors}/{self._max_consecutive_errors})") + self.warning_occurred.emit( + f"Frame timeout (retry {consecutive_errors}/{self._max_consecutive_errors})" + ) time.sleep(self._retry_delay) continue else: @@ -98,29 +105,39 @@ def run(self) -> None: if self._attempt_reconnection(): consecutive_errors = 0 reconnect_attempts += 1 - self.warning_occurred.emit(f"Camera reconnected (attempt {reconnect_attempts})") + self.warning_occurred.emit( + f"Camera reconnected (attempt {reconnect_attempts})" + ) continue else: reconnect_attempts += 1 if reconnect_attempts >= self._max_reconnect_attempts: - self.error_occurred.emit(f"Camera reconnection failed after {reconnect_attempts} attempts") + self.error_occurred.emit( + f"Camera reconnection failed after {reconnect_attempts} attempts" + ) break else: consecutive_errors = 0 # Reset to try again - self.warning_occurred.emit(f"Reconnection attempt {reconnect_attempts} failed, retrying...") + self.warning_occurred.emit( + f"Reconnection attempt {reconnect_attempts} failed, retrying..." + ) time.sleep(self._reconnect_delay) continue - + except Exception as exc: consecutive_errors += 1 - LOGGER.warning(f"Camera read error ({consecutive_errors}/{self._max_consecutive_errors}): {exc}") - + LOGGER.warning( + f"Camera read error ({consecutive_errors}/{self._max_consecutive_errors}): {exc}" + ) + if self._stop_event.is_set(): break - + # Handle general errors with retry logic if consecutive_errors < self._max_consecutive_errors: - self.warning_occurred.emit(f"Frame read error (retry {consecutive_errors}/{self._max_consecutive_errors})") + self.warning_occurred.emit( + f"Frame read error (retry {consecutive_errors}/{self._max_consecutive_errors})" + ) time.sleep(self._retry_delay) continue else: @@ -129,22 +146,28 @@ def run(self) -> None: if self._attempt_reconnection(): consecutive_errors = 0 reconnect_attempts += 1 - self.warning_occurred.emit(f"Camera reconnected (attempt {reconnect_attempts})") + self.warning_occurred.emit( + f"Camera reconnected (attempt {reconnect_attempts})" + ) continue else: reconnect_attempts += 1 if reconnect_attempts >= self._max_reconnect_attempts: - self.error_occurred.emit(f"Camera failed after {reconnect_attempts} reconnection attempts: {exc}") + self.error_occurred.emit( + f"Camera failed after {reconnect_attempts} reconnection attempts: {exc}" + ) break else: consecutive_errors = 0 # Reset to try again - self.warning_occurred.emit(f"Reconnection attempt {reconnect_attempts} failed, retrying...") + self.warning_occurred.emit( + f"Reconnection attempt {reconnect_attempts} failed, retrying..." + ) time.sleep(self._reconnect_delay) continue - + if self._stop_event.is_set(): break - + self.frame_captured.emit(FrameData(frame, timestamp)) # Cleanup @@ -158,7 +181,9 @@ def _initialize_camera(self) -> bool: self._backend.open() # Don't set expected frame size - will be established from first frame self._expected_frame_size = None - LOGGER.info("Camera initialized successfully, frame size will be determined from camera") + LOGGER.info( + "Camera initialized successfully, frame size will be determined from camera" + ) return True except Exception as exc: LOGGER.exception("Failed to initialize camera", exc_info=exc) @@ -170,15 +195,17 @@ def _validate_frame_size(self, frame: np.ndarray) -> bool: if frame is None or frame.size == 0: LOGGER.warning("Received empty frame") return False - + actual_size = (frame.shape[0], frame.shape[1]) # (height, width) - + if self._expected_frame_size is None: # First frame - establish expected size self._expected_frame_size = actual_size - LOGGER.info(f"Established expected frame size: (h={actual_size[0]}, w={actual_size[1]})") + LOGGER.info( + f"Established expected frame size: (h={actual_size[0]}, w={actual_size[1]})" + ) return True - + if actual_size != self._expected_frame_size: LOGGER.warning( f"Frame size mismatch: expected (h={self._expected_frame_size[0]}, w={self._expected_frame_size[1]}), " @@ -192,26 +219,26 @@ def _validate_frame_size(self, frame: np.ndarray) -> bool: f"Camera resolution changed to {actual_size[1]}x{actual_size[0]}" ) return True # Accept the new size - + return True def _attempt_reconnection(self) -> bool: """Attempt to reconnect to the camera. Returns True on success, False on failure.""" if self._stop_event.is_set(): return False - + LOGGER.info("Attempting camera reconnection...") - + # Close existing connection self._cleanup_camera() - + # Wait longer before reconnecting to let the device fully release LOGGER.info(f"Waiting {self._reconnect_delay}s before reconnecting...") time.sleep(self._reconnect_delay) - + if self._stop_event.is_set(): return False - + # Try to reinitialize (this will also reset expected frame size) try: self._backend = CameraFactory.create(self._settings) diff --git a/dlclivegui/cameras/__init__.py b/dlclivegui/cameras/__init__.py index cf7f488..7aa4621 100644 --- a/dlclivegui/cameras/__init__.py +++ b/dlclivegui/cameras/__init__.py @@ -1,4 +1,5 @@ """Camera backend implementations and factory helpers.""" + from __future__ import annotations from .factory import CameraFactory diff --git a/dlclivegui/cameras/base.py b/dlclivegui/cameras/base.py index 910331c..f060d8b 100644 --- a/dlclivegui/cameras/base.py +++ b/dlclivegui/cameras/base.py @@ -1,4 +1,5 @@ """Abstract camera backend definitions.""" + from __future__ import annotations from abc import ABC, abstractmethod diff --git a/dlclivegui/cameras/basler_backend.py b/dlclivegui/cameras/basler_backend.py index e83185a..ec23806 100644 --- a/dlclivegui/cameras/basler_backend.py +++ b/dlclivegui/cameras/basler_backend.py @@ -1,4 +1,5 @@ """Basler camera backend implemented with :mod:`pypylon`.""" + from __future__ import annotations import time @@ -28,9 +29,7 @@ def is_available(cls) -> bool: def open(self) -> None: if pylon is None: # pragma: no cover - optional dependency - raise RuntimeError( - "pypylon is required for the Basler backend but is not installed" - ) + raise RuntimeError("pypylon is required for the Basler backend but is not installed") devices = self._enumerate_devices() if not devices: raise RuntimeError("No Basler cameras detected") @@ -114,7 +113,9 @@ def _enumerate_devices(self): return factory.EnumerateDevices() def _select_device(self, devices): - serial = self.settings.properties.get("serial") or self.settings.properties.get("serial_number") + serial = self.settings.properties.get("serial") or self.settings.properties.get( + "serial_number" + ) if serial: for device in devices: if getattr(device, "GetSerialNumber", None) and device.GetSerialNumber() == serial: diff --git a/dlclivegui/cameras/factory.py b/dlclivegui/cameras/factory.py index 540e352..eca4f58 100644 --- a/dlclivegui/cameras/factory.py +++ b/dlclivegui/cameras/factory.py @@ -1,4 +1,5 @@ """Backend discovery and construction utilities.""" + from __future__ import annotations import importlib @@ -74,7 +75,7 @@ def detect_cameras(backend: str, max_devices: int = 10) -> List[DetectedCamera]: # For GenTL backend, try to get actual device count num_devices = max_devices - if hasattr(backend_cls, 'get_device_count'): + if hasattr(backend_cls, "get_device_count"): try: actual_count = backend_cls.get_device_count() if actual_count >= 0: diff --git a/dlclivegui/cameras/gentl_backend.py b/dlclivegui/cameras/gentl_backend.py index 943cf4a..701d4fd 100644 --- a/dlclivegui/cameras/gentl_backend.py +++ b/dlclivegui/cameras/gentl_backend.py @@ -1,4 +1,5 @@ """GenTL backend implemented using the Harvesters library.""" + from __future__ import annotations import glob @@ -13,6 +14,7 @@ try: # pragma: no cover - optional dependency from harvesters.core import Harvester + try: from harvesters.core import HarvesterTimeoutError # type: ignore except Exception: # pragma: no cover - optional dependency @@ -43,7 +45,9 @@ def __init__(self, settings): self._exposure: Optional[float] = props.get("exposure") self._gain: Optional[float] = props.get("gain") self._timeout: float = float(props.get("timeout", 2.0)) - self._cti_search_paths: Tuple[str, ...] = self._parse_cti_paths(props.get("cti_search_paths")) + self._cti_search_paths: Tuple[str, ...] = self._parse_cti_paths( + props.get("cti_search_paths") + ) self._harvester = None self._acquirer = None @@ -56,21 +60,21 @@ def is_available(cls) -> bool: @classmethod def get_device_count(cls) -> int: """Get the actual number of GenTL devices detected by Harvester. - + Returns the number of devices found, or -1 if detection fails. """ if Harvester is None: return -1 - + harvester = None try: harvester = Harvester() # Use the static helper to find CTI file with default patterns cti_file = cls._search_cti_file(cls._DEFAULT_CTI_PATTERNS) - + if not cti_file: return -1 - + harvester.add_file(cti_file) harvester.update() return len(harvester.device_info_list) @@ -120,28 +124,28 @@ def open(self) -> None: remote = self._acquirer.remote_device node_map = remote.node_map - #print(dir(node_map)) + # print(dir(node_map)) """ - ['AcquisitionBurstFrameCount', 'AcquisitionControl', 'AcquisitionFrameRate', 'AcquisitionMode', + ['AcquisitionBurstFrameCount', 'AcquisitionControl', 'AcquisitionFrameRate', 'AcquisitionMode', 'AcquisitionStart', 'AcquisitionStop', 'AnalogControl', 'AutoFunctionsROI', 'AutoFunctionsROIEnable', 'AutoFunctionsROIHeight', 'AutoFunctionsROILeft', 'AutoFunctionsROIPreset', 'AutoFunctionsROITop', 'AutoFunctionsROIWidth', 'BinningHorizontal', 'BinningVertical', 'BlackLevel', 'CameraRegisterAddress', - 'CameraRegisterAddressSpace', 'CameraRegisterControl', 'CameraRegisterRead', 'CameraRegisterValue', + 'CameraRegisterAddressSpace', 'CameraRegisterControl', 'CameraRegisterRead', 'CameraRegisterValue', 'CameraRegisterWrite', 'Contrast', 'DecimationHorizontal', 'DecimationVertical', 'Denoise', - 'DeviceControl', 'DeviceFirmwareVersion', 'DeviceModelName', 'DeviceReset', 'DeviceSFNCVersionMajor', - 'DeviceSFNCVersionMinor', 'DeviceSFNCVersionSubMinor', 'DeviceScanType', 'DeviceSerialNumber', - 'DeviceTLType', 'DeviceTLVersionMajor', 'DeviceTLVersionMinor', 'DeviceTLVersionSubMinor', - 'DeviceTemperature', 'DeviceTemperatureSelector', 'DeviceType', 'DeviceUserID', 'DeviceVendorName', - 'DigitalIO', 'ExposureAuto', 'ExposureAutoHighlightReduction', 'ExposureAutoLowerLimit', - 'ExposureAutoReference', 'ExposureAutoUpperLimit', 'ExposureAutoUpperLimitAuto', 'ExposureTime', + 'DeviceControl', 'DeviceFirmwareVersion', 'DeviceModelName', 'DeviceReset', 'DeviceSFNCVersionMajor', + 'DeviceSFNCVersionMinor', 'DeviceSFNCVersionSubMinor', 'DeviceScanType', 'DeviceSerialNumber', + 'DeviceTLType', 'DeviceTLVersionMajor', 'DeviceTLVersionMinor', 'DeviceTLVersionSubMinor', + 'DeviceTemperature', 'DeviceTemperatureSelector', 'DeviceType', 'DeviceUserID', 'DeviceVendorName', + 'DigitalIO', 'ExposureAuto', 'ExposureAutoHighlightReduction', 'ExposureAutoLowerLimit', + 'ExposureAutoReference', 'ExposureAutoUpperLimit', 'ExposureAutoUpperLimitAuto', 'ExposureTime', 'GPIn', 'GPOut', 'Gain', 'GainAuto', 'GainAutoLowerLimit', 'GainAutoUpperLimit', 'Gamma', 'Height', 'HeightMax', 'IMXLowLatencyTriggerMode', 'ImageFormatControl', 'OffsetAutoCenter', 'OffsetX', 'OffsetY', 'PayloadSize', 'PixelFormat', 'ReverseX', 'ReverseY', 'Root', 'SensorHeight', 'SensorWidth', 'Sharpness', 'ShowOverlay', 'SoftwareAnalogControl', 'SoftwareTransformControl', 'SoftwareTransformEnable', 'StrobeDelay', 'StrobeDuration', 'StrobeEnable', 'StrobeOperation', 'StrobePolarity', 'TLParamsLocked', - 'TestControl', 'TestPendingAck', 'TimestampLatch', 'TimestampLatchValue', 'TimestampReset', 'ToneMappingAuto', - 'ToneMappingControl', 'ToneMappingEnable', 'ToneMappingGlobalBrightness', 'ToneMappingIntensity', - 'TransportLayerControl', 'TriggerActivation', 'TriggerDebouncer', 'TriggerDelay', 'TriggerDenoise', + 'TestControl', 'TestPendingAck', 'TimestampLatch', 'TimestampLatchValue', 'TimestampReset', 'ToneMappingAuto', + 'ToneMappingControl', 'ToneMappingEnable', 'ToneMappingGlobalBrightness', 'ToneMappingIntensity', + 'TransportLayerControl', 'TriggerActivation', 'TriggerDebouncer', 'TriggerDelay', 'TriggerDenoise', 'TriggerMask', 'TriggerMode', 'TriggerOverlap', 'TriggerSelector', 'TriggerSoftware', 'TriggerSource', 'UserSetControl', 'UserSetDefault', 'UserSetLoad', 'UserSetSave', 'UserSetSelector', 'Width', 'WidthMax'] """ @@ -176,7 +180,7 @@ def read(self) -> Tuple[np.ndarray, float]: except ValueError: frame = array.copy() except HarvesterTimeoutError as exc: - raise TimeoutError(str(exc)+ " (GenTL timeout)") from exc + raise TimeoutError(str(exc) + " (GenTL timeout)") from exc frame = self._convert_frame(frame) timestamp = time.time() @@ -231,7 +235,7 @@ def _parse_crop(self, crop) -> Optional[Tuple[int, int, int, int]]: @staticmethod def _search_cti_file(patterns: Tuple[str, ...]) -> Optional[str]: """Search for a CTI file using the given patterns. - + Returns the first CTI file found, or None if none found. """ for pattern in patterns: @@ -242,7 +246,7 @@ def _search_cti_file(patterns: Tuple[str, ...]) -> Optional[str]: def _find_cti_file(self) -> str: """Find a CTI file using configured or default search paths. - + Raises RuntimeError if no CTI file is found. """ cti_file = self._search_cti_file(self._cti_search_paths) @@ -266,7 +270,7 @@ def _create_acquirer(self, serial: Optional[str], index: int): assert self._harvester is not None methods = [ getattr(self._harvester, "create", None), - getattr(self._harvester, "create_image_acquirer", None), + getattr(self._harvester, "create_image_acquirer", None), ] methods = [m for m in methods if m is not None] errors: List[str] = [] diff --git a/dlclivegui/cameras/opencv_backend.py b/dlclivegui/cameras/opencv_backend.py index ca043bf..f4ee01a 100644 --- a/dlclivegui/cameras/opencv_backend.py +++ b/dlclivegui/cameras/opencv_backend.py @@ -1,4 +1,5 @@ """OpenCV based camera backend.""" + from __future__ import annotations import time @@ -21,15 +22,13 @@ def open(self) -> None: backend_flag = self._resolve_backend(self.settings.properties.get("api")) self._capture = cv2.VideoCapture(int(self.settings.index), backend_flag) if not self._capture.isOpened(): - raise RuntimeError( - f"Unable to open camera index {self.settings.index} with OpenCV" - ) + raise RuntimeError(f"Unable to open camera index {self.settings.index} with OpenCV") self._configure_capture() def read(self) -> Tuple[np.ndarray, float]: if self._capture is None: raise RuntimeError("Camera has not been opened") - + # Try grab first - this is non-blocking and helps detect connection issues faster grabbed = self._capture.grab() if not grabbed: @@ -38,12 +37,12 @@ def read(self) -> Tuple[np.ndarray, float]: raise RuntimeError("OpenCV camera connection lost") # Otherwise treat as temporary frame read failure (timeout-like) raise TimeoutError("Failed to grab frame from OpenCV camera (temporary)") - + # Now retrieve the frame success, frame = self._capture.retrieve() if not success or frame is None: raise TimeoutError("Failed to retrieve frame from OpenCV camera (temporary)") - + return frame, time.time() def close(self) -> None: diff --git a/dlclivegui/config.py b/dlclivegui/config.py index ca1f3e5..126eb13 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -1,10 +1,11 @@ """Configuration helpers for the DLC Live GUI.""" + from __future__ import annotations +import json from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any, Dict, Optional -import json @dataclass @@ -30,12 +31,12 @@ def apply_defaults(self) -> "CameraSettings": self.fps = float(self.fps) if self.fps else 30.0 self.exposure = int(self.exposure) if self.exposure else 0 self.gain = float(self.gain) if self.gain else 0.0 - self.crop_x0 = max(0, int(self.crop_x0)) if hasattr(self, 'crop_x0') else 0 - self.crop_y0 = max(0, int(self.crop_y0)) if hasattr(self, 'crop_y0') else 0 - self.crop_x1 = max(0, int(self.crop_x1)) if hasattr(self, 'crop_x1') else 0 - self.crop_y1 = max(0, int(self.crop_y1)) if hasattr(self, 'crop_y1') else 0 + self.crop_x0 = max(0, int(self.crop_x0)) if hasattr(self, "crop_x0") else 0 + self.crop_y0 = max(0, int(self.crop_y0)) if hasattr(self, "crop_y0") else 0 + self.crop_x1 = max(0, int(self.crop_x1)) if hasattr(self, "crop_x1") else 0 + self.crop_y1 = max(0, int(self.crop_y1)) if hasattr(self, "crop_y1") else 0 return self - + def get_crop_region(self) -> Optional[tuple[int, int, int, int]]: """Get crop region as (x0, y0, x1, y1) or None if no cropping.""" if self.crop_x0 == 0 and self.crop_y0 == 0 and self.crop_x1 == 0 and self.crop_y1 == 0: diff --git a/dlclivegui/dlc_processor.py b/dlclivegui/dlc_processor.py index 6358c74..80944d8 100644 --- a/dlclivegui/dlc_processor.py +++ b/dlclivegui/dlc_processor.py @@ -1,4 +1,5 @@ """DLCLive integration helpers.""" + from __future__ import annotations import logging @@ -31,6 +32,7 @@ class PoseResult: @dataclass class ProcessorStats: """Statistics for DLC processor performance.""" + frames_enqueued: int = 0 frames_processed: int = 0 frames_dropped: int = 0 @@ -59,7 +61,7 @@ def __init__(self) -> None: self._worker_thread: Optional[threading.Thread] = None self._stop_event = threading.Event() self._initialized = False - + # Statistics tracking self._frames_enqueued = 0 self._frames_processed = 0 @@ -94,11 +96,11 @@ def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: # Start worker thread with initialization self._start_worker(frame.copy(), timestamp) return - + # Don't count dropped frames until processor is initialized if not self._initialized: return - + if self._queue is not None: try: # Non-blocking put - drop frame if queue is full @@ -113,22 +115,20 @@ def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: def get_stats(self) -> ProcessorStats: """Get current processing statistics.""" queue_size = self._queue.qsize() if self._queue is not None else 0 - + with self._stats_lock: - avg_latency = ( - sum(self._latencies) / len(self._latencies) - if self._latencies - else 0.0 - ) + avg_latency = sum(self._latencies) / len(self._latencies) if self._latencies else 0.0 last_latency = self._latencies[-1] if self._latencies else 0.0 - + # Compute processing FPS from processing times if len(self._processing_times) >= 2: duration = self._processing_times[-1] - self._processing_times[0] - processing_fps = (len(self._processing_times) - 1) / duration if duration > 0 else 0.0 + processing_fps = ( + (len(self._processing_times) - 1) / duration if duration > 0 else 0.0 + ) else: processing_fps = 0.0 - + return ProcessorStats( frames_enqueued=self._frames_enqueued, frames_processed=self._frames_processed, @@ -142,7 +142,7 @@ def get_stats(self) -> ProcessorStats: def _start_worker(self, init_frame: np.ndarray, init_timestamp: float) -> None: if self._worker_thread is not None and self._worker_thread.is_alive(): return - + self._queue = queue.Queue(maxsize=2) self._stop_event.clear() self._worker_thread = threading.Thread( @@ -156,18 +156,18 @@ def _start_worker(self, init_frame: np.ndarray, init_timestamp: float) -> None: def _stop_worker(self) -> None: if self._worker_thread is None: return - + self._stop_event.set() if self._queue is not None: try: self._queue.put_nowait(_SENTINEL) except queue.Full: pass - + self._worker_thread.join(timeout=2.0) if self._worker_thread.is_alive(): LOGGER.warning("DLC worker thread did not terminate cleanly") - + self._worker_thread = None self._queue = None @@ -175,17 +175,15 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: try: # Initialize model if DLCLive is None: - raise RuntimeError( - "The 'dlclive' package is required for pose estimation." - ) + raise RuntimeError("The 'dlclive' package is required for pose estimation.") if not self._settings.model_path: raise RuntimeError("No DLCLive model path configured.") - + options = { "model_path": self._settings.model_path, "model_type": self._settings.model_type, "processor": self._processor, - "dynamic": [False,0.5,10], + "dynamic": [False, 0.5, 10], "resize": 1.0, } # todo expose more parameters from settings @@ -194,53 +192,53 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: self._initialized = True self.initialized.emit(True) LOGGER.info("DLCLive model initialized successfully") - + # Process the initialization frame enqueue_time = time.perf_counter() pose = self._dlc.get_pose(init_frame, frame_time=init_timestamp) process_time = time.perf_counter() - + with self._stats_lock: self._frames_enqueued += 1 self._frames_processed += 1 self._processing_times.append(process_time) - + self.pose_ready.emit(PoseResult(pose=pose, timestamp=init_timestamp)) - + except Exception as exc: LOGGER.exception("Failed to initialize DLCLive", exc_info=exc) self.error.emit(str(exc)) self.initialized.emit(False) return - + # Main processing loop while not self._stop_event.is_set(): try: item = self._queue.get(timeout=0.1) except queue.Empty: continue - + if item is _SENTINEL: break - + frame, timestamp, enqueue_time = item try: start_process = time.perf_counter() pose = self._dlc.get_pose(frame, frame_time=timestamp) end_process = time.perf_counter() - + latency = end_process - enqueue_time - + with self._stats_lock: self._frames_processed += 1 self._latencies.append(latency) self._processing_times.append(end_process) - + self.pose_ready.emit(PoseResult(pose=pose, timestamp=timestamp)) except Exception as exc: LOGGER.exception("Pose inference failed", exc_info=exc) self.error.emit(str(exc)) finally: self._queue.task_done() - + LOGGER.info("DLC worker thread exiting") diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 66db93c..bd3bee7 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -1,11 +1,12 @@ """PyQt6 based GUI for DeepLabCut Live.""" + from __future__ import annotations -import os import json +import logging +import os import sys import time -import logging from collections import deque from pathlib import Path from typing import Optional @@ -18,6 +19,7 @@ QApplication, QCheckBox, QComboBox, + QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, @@ -30,7 +32,6 @@ QPushButton, QSizePolicy, QSpinBox, - QDoubleSpinBox, QStatusBar, QVBoxLayout, QWidget, @@ -40,22 +41,24 @@ from dlclivegui.cameras import CameraFactory from dlclivegui.cameras.factory import DetectedCamera from dlclivegui.config import ( + DEFAULT_CONFIG, ApplicationSettings, BoundingBoxSettings, CameraSettings, DLCProcessorSettings, RecordingSettings, - DEFAULT_CONFIG, ) from dlclivegui.dlc_processor import DLCLiveProcessor, PoseResult, ProcessorStats -from dlclivegui.processors.processor_utils import scan_processor_folder, instantiate_from_scan +from dlclivegui.processors.processor_utils import instantiate_from_scan, scan_processor_folder from dlclivegui.video_recorder import RecorderStats, VideoRecorder -os.environ["CUDA_VISIBLE_DEVICES"] = "0" +os.environ["CUDA_VISIBLE_DEVICES"] = "0" logging.basicConfig(level=logging.INFO) PATH2MODELS = "C:\\Users\\User\\Repos\\DeepLabCut-live-GUI\\models" + + class MainWindow(QMainWindow): """Main application window.""" @@ -112,16 +115,12 @@ def _setup_ui(self) -> None: self.video_label = QLabel("Camera preview not started") self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.video_label.setMinimumSize(640, 360) - self.video_label.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding - ) + self.video_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # Controls panel with fixed width to prevent shifting controls_widget = QWidget() controls_widget.setMaximumWidth(500) - controls_widget.setSizePolicy( - QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding - ) + controls_widget.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) controls_layout = QVBoxLayout(controls_widget) controls_layout.setContentsMargins(5, 5, 5, 5) controls_layout.addWidget(self._build_camera_group()) @@ -218,25 +217,25 @@ def _build_camera_group(self) -> QGroupBox: self.crop_x0.setPrefix("x0:") self.crop_x0.setSpecialValueText("x0:None") crop_layout.addWidget(self.crop_x0) - + self.crop_y0 = QSpinBox() self.crop_y0.setRange(0, 4320) self.crop_y0.setPrefix("y0:") self.crop_y0.setSpecialValueText("y0:None") crop_layout.addWidget(self.crop_y0) - + self.crop_x1 = QSpinBox() self.crop_x1.setRange(0, 7680) self.crop_x1.setPrefix("x1:") self.crop_x1.setSpecialValueText("x1:None") crop_layout.addWidget(self.crop_x1) - + self.crop_y1 = QSpinBox() self.crop_y1.setRange(0, 4320) self.crop_y1.setPrefix("y1:") self.crop_y1.setSpecialValueText("y1:None") crop_layout.addWidget(self.crop_y1) - + form.addRow("Crop (x0,y0,x1,y1)", crop_layout) self.rotation_combo = QComboBox() @@ -275,22 +274,22 @@ def _build_dlc_group(self) -> QGroupBox: self.processor_folder_edit = QLineEdit() self.processor_folder_edit.setText(str(Path(__file__).parent.joinpath("processors"))) processor_path_layout.addWidget(self.processor_folder_edit) - + self.browse_processor_folder_button = QPushButton("Browse...") self.browse_processor_folder_button.clicked.connect(self._action_browse_processor_folder) processor_path_layout.addWidget(self.browse_processor_folder_button) - + self.refresh_processors_button = QPushButton("Refresh") self.refresh_processors_button.clicked.connect(self._refresh_processors) processor_path_layout.addWidget(self.refresh_processors_button) form.addRow("Processor folder", processor_path_layout) - + self.processor_combo = QComboBox() self.processor_combo.addItem("No Processor", None) form.addRow("Processor", self.processor_combo) self.additional_options_edit = QPlainTextEdit() - self.additional_options_edit.setPlaceholderText('') + self.additional_options_edit.setPlaceholderText("") self.additional_options_edit.setFixedHeight(40) form.addRow("Additional options", self.additional_options_edit) @@ -314,7 +313,9 @@ def _build_dlc_group(self) -> QGroupBox: self.auto_record_checkbox = QCheckBox("Auto-record video on processor command") self.auto_record_checkbox.setChecked(False) - self.auto_record_checkbox.setToolTip("Automatically start/stop video recording when processor receives video recording commands") + self.auto_record_checkbox.setToolTip( + "Automatically start/stop video recording when processor receives video recording commands" + ) form.addRow(self.auto_record_checkbox) self.processor_status_label = QLabel("Processor: No clients | Recording: No") @@ -386,31 +387,31 @@ def _build_bbox_group(self) -> QGroupBox: form.addRow(self.bbox_enabled_checkbox) bbox_layout = QHBoxLayout() - + self.bbox_x0_spin = QSpinBox() self.bbox_x0_spin.setRange(0, 7680) self.bbox_x0_spin.setPrefix("x0:") self.bbox_x0_spin.setValue(0) bbox_layout.addWidget(self.bbox_x0_spin) - + self.bbox_y0_spin = QSpinBox() self.bbox_y0_spin.setRange(0, 4320) self.bbox_y0_spin.setPrefix("y0:") self.bbox_y0_spin.setValue(0) bbox_layout.addWidget(self.bbox_y0_spin) - + self.bbox_x1_spin = QSpinBox() self.bbox_x1_spin.setRange(0, 7680) self.bbox_x1_spin.setPrefix("x1:") self.bbox_x1_spin.setValue(100) bbox_layout.addWidget(self.bbox_x1_spin) - + self.bbox_y1_spin = QSpinBox() self.bbox_y1_spin.setRange(0, 4320) self.bbox_y1_spin.setPrefix("y1:") self.bbox_y1_spin.setValue(100) bbox_layout.addWidget(self.bbox_y1_spin) - + form.addRow("Coordinates", bbox_layout) return group @@ -429,10 +430,8 @@ def _connect_signals(self) -> None: self.rotation_combo.currentIndexChanged.connect(self._on_rotation_changed) self.start_inference_button.clicked.connect(self._start_inference) self.stop_inference_button.clicked.connect(lambda: self._stop_inference()) - self.show_predictions_checkbox.stateChanged.connect( - self._on_show_predictions_changed - ) - + self.show_predictions_checkbox.stateChanged.connect(self._on_show_predictions_changed) + # Connect bounding box controls self.bbox_enabled_checkbox.stateChanged.connect(self._on_bbox_changed) self.bbox_x0_spin.valueChanged.connect(self._on_bbox_changed) @@ -454,17 +453,17 @@ def _connect_signals(self) -> None: def _apply_config(self, config: ApplicationSettings) -> None: camera = config.camera self.camera_fps.setValue(float(camera.fps)) - + # Set exposure and gain from config self.camera_exposure.setValue(int(camera.exposure)) self.camera_gain.setValue(float(camera.gain)) - + # Set crop settings from config - self.crop_x0.setValue(int(camera.crop_x0) if hasattr(camera, 'crop_x0') else 0) - self.crop_y0.setValue(int(camera.crop_y0) if hasattr(camera, 'crop_y0') else 0) - self.crop_x1.setValue(int(camera.crop_x1) if hasattr(camera, 'crop_x1') else 0) - self.crop_y1.setValue(int(camera.crop_y1) if hasattr(camera, 'crop_y1') else 0) - + self.crop_x0.setValue(int(camera.crop_x0) if hasattr(camera, "crop_x0") else 0) + self.crop_y0.setValue(int(camera.crop_y0) if hasattr(camera, "crop_y0") else 0) + self.crop_x1.setValue(int(camera.crop_x1) if hasattr(camera, "crop_x1") else 0) + self.crop_y1.setValue(int(camera.crop_y1) if hasattr(camera, "crop_y1") else 0) + backend_name = camera.backend or "opencv" self.camera_backend.blockSignals(True) index = self.camera_backend.findData(backend_name) @@ -474,25 +473,21 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.camera_backend.setEditText(backend_name) self.camera_backend.blockSignals(False) self._refresh_camera_indices(keep_current=False) - self._select_camera_by_index( - camera.index, fallback_text=camera.name or str(camera.index) - ) - + self._select_camera_by_index(camera.index, fallback_text=camera.name or str(camera.index)) + self._active_camera_settings = None self._update_backend_specific_controls() dlc = config.dlc self.model_path_edit.setText(dlc.model_path) - + # Set model type model_type = dlc.model_type or "base" model_type_index = self.model_type_combo.findData(model_type) if model_type_index >= 0: self.model_type_combo.setCurrentIndex(model_type_index) - - self.additional_options_edit.setPlainText( - json.dumps(dlc.additional_options, indent=2) - ) + + self.additional_options_edit.setPlainText(json.dumps(dlc.additional_options, indent=2)) recording = config.recording self.output_directory_edit.setText(recording.directory) @@ -527,17 +522,17 @@ def _camera_settings_from_ui(self) -> CameraSettings: if index is None: raise ValueError("Camera selection must provide a numeric index") backend_text = self._current_backend_name() - + # Get exposure and gain from explicit UI fields exposure = self.camera_exposure.value() gain = self.camera_gain.value() - + # Get crop settings from UI crop_x0 = self.crop_x0.value() crop_y0 = self.crop_y0.value() crop_x1 = self.crop_x1.value() crop_y1 = self.crop_y1.value() - + name_text = self.camera_index.currentText().strip() settings = CameraSettings( name=name_text or f"Camera {index}", @@ -561,17 +556,15 @@ def _current_backend_name(self) -> str: text = self.camera_backend.currentText().strip() return text or "opencv" - def _refresh_camera_indices( - self, *_args: object, keep_current: bool = True - ) -> None: + def _refresh_camera_indices(self, *_args: object, keep_current: bool = True) -> None: backend = self._current_backend_name() # Get max_devices from config, default to 3 - max_devices = self._config.camera.max_devices if hasattr(self._config.camera, 'max_devices') else 3 + max_devices = ( + self._config.camera.max_devices if hasattr(self._config.camera, "max_devices") else 3 + ) detected = CameraFactory.detect_cameras(backend, max_devices=max_devices) debug_info = [f"{camera.index}:{camera.label}" for camera in detected] - logging.info( - f"[CameraDetection] Available cameras for backend '{backend}': {debug_info}" - ) + logging.info(f"[CameraDetection] Available cameras for backend '{backend}': {debug_info}") self._detected_cameras = detected previous_index = self._current_camera_index_value() previous_text = self.camera_index.currentText() @@ -590,9 +583,7 @@ def _refresh_camera_indices( self.camera_index.setEditText("") self.camera_index.blockSignals(False) - def _select_camera_by_index( - self, index: int, fallback_text: Optional[str] = None - ) -> None: + def _select_camera_by_index(self, index: int, fallback_text: Optional[str] = None) -> None: self.camera_index.blockSignals(True) for row in range(self.camera_index.count()): if self.camera_index.itemData(row) == index: @@ -615,9 +606,7 @@ def _current_camera_index_value(self) -> Optional[int]: except ValueError: return None debug_info = [f"{camera.index}:{camera.label}" for camera in detected] - logging.info( - f"[CameraDetection] Available cameras for backend '{backend}': {debug_info}" - ) + logging.info(f"[CameraDetection] Available cameras for backend '{backend}': {debug_info}") self._detected_cameras = detected previous_index = self._current_camera_index_value() previous_text = self.camera_index.currentText() @@ -636,9 +625,7 @@ def _current_camera_index_value(self) -> Optional[int]: self.camera_index.setEditText("") self.camera_index.blockSignals(False) - def _select_camera_by_index( - self, index: int, fallback_text: Optional[str] = None - ) -> None: + def _select_camera_by_index(self, index: int, fallback_text: Optional[str] = None) -> None: self.camera_index.blockSignals(True) for row in range(self.camera_index.count()): if self.camera_index.itemData(row) == index: @@ -671,13 +658,11 @@ def _dlc_settings_from_ui(self) -> DLCProcessorSettings: model_type = self.model_type_combo.currentData() if not isinstance(model_type, str): model_type = "base" - + return DLCProcessorSettings( model_path=self.model_path_edit.text().strip(), model_type=model_type, - additional_options=self._parse_json( - self.additional_options_edit.toPlainText() - ), + additional_options=self._parse_json(self.additional_options_edit.toPlainText()), ) def _recording_settings_from_ui(self) -> RecordingSettings: @@ -760,9 +745,7 @@ def _action_browse_directory(self) -> None: def _action_browse_processor_folder(self) -> None: """Browse for processor folder.""" current_path = self.processor_folder_edit.text() or "./processors" - directory = QFileDialog.getExistingDirectory( - self, "Select processor folder", current_path - ) + directory = QFileDialog.getExistingDirectory(self, "Select processor folder", current_path) if directory: self.processor_folder_edit.setText(directory) self._refresh_processors() @@ -770,25 +753,25 @@ def _action_browse_processor_folder(self) -> None: def _refresh_processors(self) -> None: """Scan processor folder and populate dropdown.""" folder_path = self.processor_folder_edit.text() or "./processors" - + # Clear existing items (keep "No Processor") self.processor_combo.clear() self.processor_combo.addItem("No Processor", None) - + # Scan folder try: self._scanned_processors = scan_processor_folder(folder_path) self._processor_keys = list(self._scanned_processors.keys()) - + # Populate dropdown for key in self._processor_keys: info = self._scanned_processors[key] display_name = f"{info['name']} ({info['file']})" self.processor_combo.addItem(display_name, key) - + status_msg = f"Found {len(self._processor_keys)} processor(s) in {folder_path}" self.statusBar().showMessage(status_msg, 3000) - + except Exception as e: error_msg = f"Error scanning processors: {e}" self.statusBar().showMessage(error_msg, 5000) @@ -803,11 +786,11 @@ def _update_backend_specific_controls(self) -> None: """Enable/disable controls based on selected backend.""" backend = self._current_backend_name() is_opencv = backend.lower() == "opencv" - + # Disable exposure and gain controls for OpenCV backend self.camera_exposure.setEnabled(not is_opencv) self.camera_gain.setEnabled(not is_opencv) - + # Set tooltip to explain why controls are disabled if is_opencv: tooltip = "Exposure and gain control not supported with OpenCV backend" @@ -877,9 +860,7 @@ def _on_camera_started(self, settings: CameraSettings) -> None: fps_text = f"{float(settings.fps):.2f} FPS" else: fps_text = "unknown FPS" - self.statusBar().showMessage( - f"Camera preview started @ {fps_text}", 5000 - ) + self.statusBar().showMessage(f"Camera preview started @ {fps_text}", 5000) self._update_inference_buttons() self._update_camera_controls_enabled() @@ -912,7 +893,7 @@ def _configure_dlc(self) -> bool: if not settings.model_path: self._show_error("Please select a DLCLive model before starting inference.") return False - + # Instantiate processor if selected processor = None selected_key = self.processor_combo.currentData() @@ -920,16 +901,16 @@ def _configure_dlc(self) -> bool: try: # For now, instantiate with no parameters # TODO: Add parameter dialog for processors that need params - # or pass kwargs from config ? + # or pass kwargs from config ? processor = instantiate_from_scan(self._scanned_processors, selected_key) - processor_name = self._scanned_processors[selected_key]['name'] + processor_name = self._scanned_processors[selected_key]["name"] self.statusBar().showMessage(f"Loaded processor: {processor_name}", 3000) except Exception as e: error_msg = f"Failed to instantiate processor: {e}" self._show_error(error_msg) logging.error(error_msg) return False - + self.dlc_processor.configure(settings, processor=processor) return True @@ -955,9 +936,7 @@ def _update_dlc_controls_enabled(self) -> None: widget.setEnabled(allow_changes) def _update_camera_controls_enabled(self) -> None: - recording_active = ( - self._video_recorder is not None and self._video_recorder.is_running - ) + recording_active = self._video_recorder is not None and self._video_recorder.is_running allow_changes = ( not self.camera_controller.is_running() and not self._dlc_active @@ -1043,7 +1022,7 @@ def _update_metrics(self) -> None: self.camera_stats_label.setText("Measuring…") else: self.camera_stats_label.setText("Camera idle") - + if hasattr(self, "dlc_stats_label"): if self._dlc_active and self._dlc_initialized: stats = self.dlc_processor.get_stats() @@ -1051,11 +1030,11 @@ def _update_metrics(self) -> None: self.dlc_stats_label.setText(summary) else: self.dlc_stats_label.setText("DLC processor idle") - + # Update processor status (connection and recording state) if hasattr(self, "processor_status_label"): self._update_processor_status() - + if hasattr(self, "recording_stats_label"): if self._video_recorder is not None: stats = self._video_recorder.get_stats() @@ -1074,47 +1053,49 @@ def _update_processor_status(self) -> None: if not self._dlc_active or not self._dlc_initialized: self.processor_status_label.setText("Processor: Not active") return - + # Get processor instance from dlc_processor processor = self.dlc_processor._processor - + if processor is None: self.processor_status_label.setText("Processor: None loaded") return - + # Check if processor has the required attributes (socket-based processors) - if not hasattr(processor, 'conns') or not hasattr(processor, '_recording'): + if not hasattr(processor, "conns") or not hasattr(processor, "_recording"): self.processor_status_label.setText("Processor: No status info") return - + # Get connection count and recording state num_clients = len(processor.conns) - is_recording = processor.recording if hasattr(processor, 'recording') else False - + is_recording = processor.recording if hasattr(processor, "recording") else False + # Format status message client_str = f"{num_clients} client{'s' if num_clients != 1 else ''}" recording_str = "Yes" if is_recording else "No" self.processor_status_label.setText(f"Clients: {client_str} | Recording: {recording_str}") - + # Handle auto-recording based on processor's video recording flag - if hasattr(processor, '_vid_recording') and self.auto_record_checkbox.isChecked(): + if hasattr(processor, "_vid_recording") and self.auto_record_checkbox.isChecked(): current_vid_recording = processor.video_recording - + # Check if video recording state changed if current_vid_recording != self._last_processor_vid_recording: if current_vid_recording: # Start video recording if not self._video_recorder or not self._video_recorder.is_running: # Get session name from processor - session_name = getattr(processor, 'session_name', 'auto_session') + session_name = getattr(processor, "session_name", "auto_session") self._auto_record_session_name = session_name - + # Update filename with session name original_filename = self.filename_edit.text() self.filename_edit.setText(f"{session_name}.mp4") - + self._start_recording() - self.statusBar().showMessage(f"Auto-started recording: {session_name}", 3000) + self.statusBar().showMessage( + f"Auto-started recording: {session_name}", 3000 + ) logging.info(f"Auto-recording started for session: {session_name}") else: # Stop video recording @@ -1122,7 +1103,7 @@ def _update_processor_status(self) -> None: self._stop_recording() self.statusBar().showMessage("Auto-stopped recording", 3000) logging.info("Auto-recording stopped") - + self._last_processor_vid_recording = current_vid_recording def _start_inference(self) -> None: @@ -1130,9 +1111,7 @@ def _start_inference(self) -> None: self.statusBar().showMessage("Pose inference already running", 3000) return if not self.camera_controller.is_running(): - self._show_error( - "Start the camera preview before running pose inference." - ) + self._show_error("Start the camera preview before running pose inference.") return if not self._configure_dlc(): self._update_inference_buttons() @@ -1141,13 +1120,13 @@ def _start_inference(self) -> None: self._last_pose = None self._dlc_active = True self._dlc_initialized = False - + # Update button to show initializing state self.start_inference_button.setText("Initializing DLCLive!") self.start_inference_button.setStyleSheet("background-color: #4A90E2; color: white;") self.start_inference_button.setEnabled(False) self.stop_inference_button.setEnabled(True) - + self.statusBar().showMessage("Initializing DLCLive…", 3000) self._update_camera_controls_enabled() self._update_dlc_controls_enabled() @@ -1160,11 +1139,11 @@ def _stop_inference(self, show_message: bool = True) -> None: self._last_pose = None self._last_processor_vid_recording = False self._auto_record_session_name = None - + # Reset button appearance self.start_inference_button.setText("Start pose inference") self.start_inference_button.setStyleSheet("") - + if self._current_frame is not None: self._display_frame(self._current_frame, force=True) if was_active and show_message: @@ -1236,9 +1215,7 @@ def _stop_recording(self) -> None: self.recording_stats_label.setText(summary) else: self._last_recorder_summary = ( - self._format_recorder_stats(stats) - if stats is not None - else "Recorder idle" + self._format_recorder_stats(stats) if stats is not None else "Recorder idle" ) self._last_drop_warning = 0.0 self.statusBar().showMessage("Recording stopped", 3000) @@ -1248,10 +1225,10 @@ def _stop_recording(self) -> None: def _on_frame_ready(self, frame_data: FrameData) -> None: raw_frame = frame_data.image self._raw_frame = raw_frame - + # Apply cropping before rotation frame = self._apply_crop(raw_frame) - + # Apply rotation frame = self._apply_rotation(frame) frame = np.ascontiguousarray(frame) @@ -1263,9 +1240,7 @@ def _on_frame_ready(self, frame_data: FrameData) -> None: if not success: now = time.perf_counter() if now - self._last_drop_warning > 1.0: - self.statusBar().showMessage( - "Recorder backlog full; dropping frames", 2000 - ) + self.statusBar().showMessage("Recorder backlog full; dropping frames", 2000) self._last_drop_warning = now except RuntimeError as exc: # Check if it's a frame size error @@ -1290,7 +1265,7 @@ def _on_pose_ready(self, result: PoseResult) -> None: if not self._dlc_active: return self._last_pose = result - #logging.info(f"Pose result: {result.pose}, Timestamp: {result.timestamp}") + # logging.info(f"Pose result: {result.pose}, Timestamp: {result.timestamp}") if self._current_frame is not None: self._display_frame(self._current_frame, force=True) @@ -1306,11 +1281,11 @@ def _update_video_display(self, frame: np.ndarray) -> None: and self._last_pose.pose is not None ): display_frame = self._draw_pose(frame, self._last_pose.pose) - + # Draw bounding box if enabled if self._bbox_enabled: display_frame = self._draw_bbox(display_frame) - + rgb = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape bytes_per_line = ch * w @@ -1321,20 +1296,20 @@ def _apply_crop(self, frame: np.ndarray) -> np.ndarray: """Apply cropping to the frame based on settings.""" if self._active_camera_settings is None: return frame - + crop_region = self._active_camera_settings.get_crop_region() if crop_region is None: return frame - + x0, y0, x1, y1 = crop_region height, width = frame.shape[:2] - + # Validate and constrain crop coordinates x0 = max(0, min(x0, width)) y0 = max(0, min(y0, height)) x1 = max(x0, min(x1, width)) if x1 > 0 else width y1 = max(y0, min(y1, height)) if y1 > 0 else height - + # Apply crop if x0 < x1 and y0 < y1: return frame[y0:y1, x0:x1] @@ -1362,7 +1337,7 @@ def _on_bbox_changed(self, _value: int = 0) -> None: self._bbox_y0 = self.bbox_y0_spin.value() self._bbox_x1 = self.bbox_x1_spin.value() self._bbox_y1 = self.bbox_y1_spin.value() - + # Force redraw if preview is running if self._current_frame is not None: self._display_frame(self._current_frame, force=True) @@ -1374,20 +1349,20 @@ def _draw_bbox(self, frame: np.ndarray) -> np.ndarray: y0 = self._bbox_y0 x1 = self._bbox_x1 y1 = self._bbox_y1 - + # Validate coordinates if x0 >= x1 or y0 >= y1: return overlay - + height, width = frame.shape[:2] x0 = max(0, min(x0, width - 1)) y0 = max(0, min(y0, height - 1)) x1 = max(x0 + 1, min(x1, width)) y1 = max(y0 + 1, min(y1, height)) - + # Draw red rectangle (BGR format: red is (0, 0, 255)) cv2.rectangle(overlay, (x0, y0), (x1, y1), (0, 0, 255), 2) - + return overlay def _draw_pose(self, frame: np.ndarray, pose: np.ndarray) -> np.ndarray: diff --git a/dlclivegui/processors/GUI_INTEGRATION.md b/dlclivegui/processors/GUI_INTEGRATION.md index 446232e..6e504f1 100644 --- a/dlclivegui/processors/GUI_INTEGRATION.md +++ b/dlclivegui/processors/GUI_INTEGRATION.md @@ -97,13 +97,13 @@ dropdown.set_items(display_names) def on_processor_selected(dropdown_index): # Get the key key = self.processor_keys[dropdown_index] - + # Get processor info info = all_processors[key] - + # Show description description_label.text = info['description'] - + # Build parameter form for param_name, param_info in info['params'].items(): add_parameter_field( @@ -119,17 +119,17 @@ def on_processor_selected(dropdown_index): def on_create_clicked(): # Get selected key key = self.processor_keys[dropdown.current_index] - + # Get user's parameter values user_params = parameter_form.get_values() - + # Instantiate using the key! self.processor = instantiate_from_scan( all_processors, key, **user_params ) - + print(f"Created: {self.processor.__class__.__name__}") ``` diff --git a/dlclivegui/processors/PLUGIN_SYSTEM.md b/dlclivegui/processors/PLUGIN_SYSTEM.md index b02402d..fc9cab4 100644 --- a/dlclivegui/processors/PLUGIN_SYSTEM.md +++ b/dlclivegui/processors/PLUGIN_SYSTEM.md @@ -47,7 +47,7 @@ Two helper functions enable GUI discovery: ```python def get_available_processors(): """Returns dict of available processors with metadata.""" - + def instantiate_processor(class_name, **kwargs): """Instantiates a processor by name with given parameters.""" ``` @@ -96,7 +96,7 @@ def load_processors_from_file(file_path): spec = importlib.util.spec_from_file_location("processors", file_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - + if hasattr(module, 'get_available_processors'): return module.get_available_processors() return {} diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 3f5b951..fb6522c 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -65,7 +65,7 @@ class BaseProcessor_socket(Processor): Handles network connections, timing, and data logging. Subclasses should implement custom pose processing logic. """ - + # Metadata for GUI discovery PROCESSOR_NAME = "Base Socket Processor" PROCESSOR_DESCRIPTION = "Base class for socket-based processors with multi-client support" @@ -73,23 +73,23 @@ class BaseProcessor_socket(Processor): "bind": { "type": "tuple", "default": ("0.0.0.0", 6000), - "description": "Server address (host, port)" + "description": "Server address (host, port)", }, "authkey": { "type": "bytes", "default": b"secret password", - "description": "Authentication key for clients" + "description": "Authentication key for clients", }, "use_perf_counter": { "type": "bool", "default": False, - "description": "Use time.perf_counter() instead of time.time()" + "description": "Use time.perf_counter() instead of time.time()", }, "save_original": { "type": "bool", "default": False, - "description": "Save raw pose arrays for analysis" - } + "description": "Save raw pose arrays for analysis", + }, } def __init__( @@ -139,12 +139,12 @@ def __init__( # State self.curr_step = 0 self.save_original = save_original - + @property def recording(self): """Thread-safe recording flag.""" return self._recording.is_set() - + @property def video_recording(self): """Thread-safe video recording flag.""" @@ -153,7 +153,7 @@ def video_recording(self): @property def session_name(self): return self._session_name - + @session_name.setter def session_name(self, name): self._session_name = name @@ -188,18 +188,18 @@ def _rx_loop(self, c): pass self.conns.discard(c) LOG.info("Client disconnected") - + def _handle_client_message(self, msg): """Handle control messages from clients.""" if not isinstance(msg, dict): return - + cmd = msg.get("cmd") if cmd == "set_session_name": session_name = msg.get("session_name", "default_session") self.session_name = session_name LOG.info(f"Session name set to: {session_name}") - + elif cmd == "start_recording": self._vid_recording.set() self._recording.set() @@ -207,12 +207,12 @@ def _handle_client_message(self, msg): self._clear_data_queues() self.curr_step = 0 LOG.info("Recording started, data queues cleared") - + elif cmd == "stop_recording": self._recording.clear() self._vid_recording.clear() LOG.info("Recording stopped") - + elif cmd == "save": filename = msg.get("filename", self.filename) save_code = self.save(filename) @@ -222,7 +222,7 @@ def _handle_client_message(self, msg): # Placeholder for video recording start self._vid_recording.set() LOG.info("Start video recording command received") - + def _clear_data_queues(self): """Clear all data storage queues. Override in subclasses to clear additional queues.""" self.time_stamp.clear() @@ -338,41 +338,43 @@ class MyProcessor_socket(BaseProcessor_socket): Broadcasts: [timestamp, center_x, center_y, heading, head_angle] """ - + # Metadata for GUI discovery PROCESSOR_NAME = "Mouse Pose Processor" - PROCESSOR_DESCRIPTION = "Calculates mouse center, heading, and head angle with optional One-Euro filtering" + PROCESSOR_DESCRIPTION = ( + "Calculates mouse center, heading, and head angle with optional One-Euro filtering" + ) PROCESSOR_PARAMS = { "bind": { "type": "tuple", "default": ("0.0.0.0", 6000), - "description": "Server address (host, port)" + "description": "Server address (host, port)", }, "authkey": { "type": "bytes", "default": b"secret password", - "description": "Authentication key for clients" + "description": "Authentication key for clients", }, "use_perf_counter": { "type": "bool", "default": False, - "description": "Use time.perf_counter() instead of time.time()" + "description": "Use time.perf_counter() instead of time.time()", }, "use_filter": { "type": "bool", "default": False, - "description": "Apply One-Euro filter to calculated values" + "description": "Apply One-Euro filter to calculated values", }, "filter_kwargs": { "type": "dict", "default": {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0}, - "description": "One-Euro filter parameters (min_cutoff, beta, d_cutoff)" + "description": "One-Euro filter parameters (min_cutoff, beta, d_cutoff)", }, "save_original": { "type": "bool", "default": False, - "description": "Save raw pose arrays for analysis" - } + "description": "Save raw pose arrays for analysis", + }, } def __init__( @@ -542,7 +544,7 @@ def get_data(self): def get_available_processors(): """ Get list of available processor classes. - + Returns: dict: Dictionary mapping class names to processor info: { @@ -560,7 +562,7 @@ def get_available_processors(): "class": processor_class, "name": getattr(processor_class, "PROCESSOR_NAME", class_name), "description": getattr(processor_class, "PROCESSOR_DESCRIPTION", ""), - "params": getattr(processor_class, "PROCESSOR_PARAMS", {}) + "params": getattr(processor_class, "PROCESSOR_PARAMS", {}), } return processors @@ -568,20 +570,20 @@ def get_available_processors(): def instantiate_processor(class_name, **kwargs): """ Instantiate a processor by class name with given parameters. - + Args: class_name: Name of the processor class (e.g., "MyProcessor_socket") **kwargs: Parameters to pass to the processor constructor - + Returns: Processor instance - + Raises: ValueError: If class_name is not in registry """ if class_name not in PROCESSOR_REGISTRY: available = ", ".join(PROCESSOR_REGISTRY.keys()) raise ValueError(f"Unknown processor '{class_name}'. Available: {available}") - + processor_class = PROCESSOR_REGISTRY[class_name] return processor_class(**kwargs) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index dcacaa4..b69b3a7 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -1,4 +1,3 @@ - import importlib.util import inspect from pathlib import Path @@ -7,10 +6,10 @@ def load_processors_from_file(file_path): """ Load all processor classes from a Python file. - + Args: file_path: Path to Python file containing processors - + Returns:/home/as153/work_geneva/mice_ar_tasks/mouse_ar/ctrl/dlc_processors/GUI_INTEGRATION.md dict: Dictionary of available processors """ @@ -18,13 +17,14 @@ def load_processors_from_file(file_path): spec = importlib.util.spec_from_file_location("processors", file_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - + # Check if module has get_available_processors function - if hasattr(module, 'get_available_processors'): + if hasattr(module, "get_available_processors"): return module.get_available_processors() - + # Fallback: scan for Processor subclasses from dlclive import Processor + processors = {} for name, obj in inspect.getmembers(module, inspect.isclass): if issubclass(obj, Processor) and obj != Processor: @@ -32,7 +32,7 @@ def load_processors_from_file(file_path): "class": obj, "name": getattr(obj, "PROCESSOR_NAME", name), "description": getattr(obj, "PROCESSOR_DESCRIPTION", ""), - "params": getattr(obj, "PROCESSOR_PARAMS", {}) + "params": getattr(obj, "PROCESSOR_PARAMS", {}), } return processors @@ -40,10 +40,10 @@ def load_processors_from_file(file_path): def scan_processor_folder(folder_path): """ Scan a folder for all Python files with processor definitions. - + Args: folder_path: Path to folder containing processor files - + Returns: dict: Dictionary mapping unique processor keys to processor info: { @@ -59,11 +59,11 @@ def scan_processor_folder(folder_path): """ all_processors = {} folder = Path(folder_path) - + for py_file in folder.glob("*.py"): if py_file.name.startswith("_"): continue - + try: processors = load_processors_from_file(py_file) for class_name, processor_info in processors.items(): @@ -76,26 +76,26 @@ def scan_processor_folder(folder_path): all_processors[key] = processor_info except Exception as e: print(f"Error loading {py_file}: {e}") - + return all_processors def instantiate_from_scan(processors_dict, processor_key, **kwargs): """ Instantiate a processor from scan_processor_folder results. - + Args: processors_dict: Dict returned by scan_processor_folder processor_key: Key like "file.py::ClassName" **kwargs: Parameters for processor constructor - + Returns: Processor instance - + Example: processors = scan_processor_folder("./dlc_processors") processor = instantiate_from_scan( - processors, + processors, "dlc_processor_socket.py::MyProcessor_socket", use_filter=True ) @@ -103,7 +103,7 @@ def instantiate_from_scan(processors_dict, processor_key, **kwargs): if processor_key not in processors_dict: available = ", ".join(processors_dict.keys()) raise ValueError(f"Unknown processor '{processor_key}'. Available: {available}") - + processor_info = processors_dict[processor_key] processor_class = processor_info["class"] return processor_class(**kwargs) @@ -111,17 +111,16 @@ def instantiate_from_scan(processors_dict, processor_key, **kwargs): def display_processor_info(processors): """Display processor information in a user-friendly format.""" - print("\n" + "="*70) + print("\n" + "=" * 70) print("AVAILABLE PROCESSORS") - print("="*70) - + print("=" * 70) + for idx, (class_name, info) in enumerate(processors.items(), 1): print(f"\n[{idx}] {info['name']}") print(f" Class: {class_name}") print(f" Description: {info['description']}") print(f" Parameters:") - for param_name, param_info in info['params'].items(): + for param_name, param_info in info["params"].items(): print(f" - {param_name} ({param_info['type']})") print(f" Default: {param_info['default']}") print(f" {param_info['description']}") - diff --git a/dlclivegui/video_recorder.py b/dlclivegui/video_recorder.py index 2190314..a40ed28 100644 --- a/dlclivegui/video_recorder.py +++ b/dlclivegui/video_recorder.py @@ -1,4 +1,5 @@ """Video recording support using the vidgear library.""" + from __future__ import annotations import json @@ -113,9 +114,7 @@ def start(self) -> None: ) self._writer_thread.start() - def configure_stream( - self, frame_size: Tuple[int, int], frame_rate: Optional[float] - ) -> None: + def configure_stream(self, frame_size: Tuple[int, int], frame_rate: Optional[float]) -> None: self._frame_size = frame_size self._frame_rate = frame_rate @@ -125,13 +124,13 @@ def write(self, frame: np.ndarray, timestamp: Optional[float] = None) -> bool: error = self._current_error() if error is not None: raise RuntimeError(f"Video encoding failed: {error}") from error - + # Record timestamp for this frame if timestamp is None: timestamp = time.time() with self._stats_lock: self._frame_timestamps.append(timestamp) - + # Convert frame to uint8 if needed if frame.dtype != np.uint8: frame_float = frame.astype(np.float32, copy=False) @@ -140,14 +139,14 @@ def write(self, frame: np.ndarray, timestamp: Optional[float] = None) -> bool: if max_val > 0: scale = 255.0 / max_val if max_val > 255.0 else (255.0 if max_val <= 1.0 else 1.0) frame = np.clip(frame_float * scale, 0.0, 255.0).astype(np.uint8) - + # Convert grayscale to RGB if needed if frame.ndim == 2: frame = np.repeat(frame[:, :, None], 3, axis=2) - + # Ensure contiguous array frame = np.ascontiguousarray(frame) - + # Check if frame size matches expected size if self._frame_size is not None: expected_h, expected_w = self._frame_size @@ -164,7 +163,7 @@ def write(self, frame: np.ndarray, timestamp: Optional[float] = None) -> bool: f"Frame size changed from (h={expected_h}, w={expected_w}) to (h={actual_h}, w={actual_w})" ) return False - + try: assert self._queue is not None self._queue.put(frame, block=False) @@ -200,10 +199,10 @@ def stop(self) -> None: self._writer.close() except Exception: logger.exception("Failed to close WriteGear cleanly") - + # Save timestamps to JSON file self._save_timestamps() - + self._writer = None self._writer_thread = None self._queue = None @@ -224,9 +223,7 @@ def get_stats(self) -> Optional[RecorderStats]: frames_written = self._frames_written dropped = self._dropped_frames avg_latency = ( - self._total_latency / self._frames_written - if self._frames_written - else 0.0 + self._total_latency / self._frames_written if self._frames_written else 0.0 ) last_latency = self._last_latency write_fps = self._compute_write_fps_locked() @@ -312,14 +309,16 @@ def _save_timestamps(self) -> None: if not self._frame_timestamps: logger.info("No timestamps to save") return - + # Create timestamps file path - timestamp_file = self._output.with_suffix('').with_suffix(self._output.suffix + '_timestamps.json') - + timestamp_file = self._output.with_suffix("").with_suffix( + self._output.suffix + "_timestamps.json" + ) + try: with self._stats_lock: timestamps = self._frame_timestamps.copy() - + # Prepare metadata data = { "video_file": str(self._output.name), @@ -329,11 +328,11 @@ def _save_timestamps(self) -> None: "end_time": timestamps[-1] if timestamps else None, "duration_seconds": timestamps[-1] - timestamps[0] if len(timestamps) > 1 else 0.0, } - + # Write to JSON - with open(timestamp_file, 'w') as f: + with open(timestamp_file, "w") as f: json.dump(data, f, indent=2) - + logger.info(f"Saved {len(timestamps)} frame timestamps to {timestamp_file}") except Exception as exc: logger.exception(f"Failed to save timestamps to {timestamp_file}: {exc}") diff --git a/setup.py b/setup.py index a254101..02954f2 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,5 @@ """Setup configuration for the DeepLabCut Live GUI.""" + from __future__ import annotations import setuptools From 39be1b20b2ed1102237612398816e3970ac7a2fc Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Fri, 24 Oct 2025 16:20:00 +0200 Subject: [PATCH 14/69] documentations --- .pre-commit-config.yaml | 2 +- README.md | 371 ++++++++++++--- dlclivegui/cameras/aravis_backend.py | 323 +++++++++++++ dlclivegui/cameras/factory.py | 3 +- docs/README.md | 262 +++++++++++ docs/aravis_backend.md | 202 +++++++++ docs/camera_support.md | 84 +++- docs/features.md | 653 +++++++++++++++++++++++++++ docs/install.md | 2 +- docs/timestamp_format.md | 79 ++++ docs/user_guide.md | 633 ++++++++++++++++++++++++++ 11 files changed, 2544 insertions(+), 70 deletions(-) create mode 100644 dlclivegui/cameras/aravis_backend.py create mode 100644 docs/README.md create mode 100644 docs/aravis_backend.md create mode 100644 docs/features.md create mode 100644 docs/timestamp_format.md create mode 100644 docs/user_guide.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 09dc3cd..0178c8e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: - id: check-yaml - id: end-of-file-fixer - id: name-tests-test - args: [--pytest-test-first] + args: [--pytest-test-first] - id: trailing-whitespace - id: check-merge-conflict diff --git a/README.md b/README.md index a886a98..34a2682 100644 --- a/README.md +++ b/README.md @@ -1,126 +1,375 @@ # DeepLabCut Live GUI -A modernised PyQt6 GUI for running [DeepLabCut-live](https://github.com/DeepLabCut/DeepLabCut-live) experiments. The application -streams frames from a camera, optionally performs DLCLive inference, and records video using the -[vidgear](https://github.com/abhiTronix/vidgear) toolkit. +A modern PyQt6 GUI for running [DeepLabCut-live](https://github.com/DeepLabCut/DeepLabCut-live) experiments with real-time pose estimation. The application streams frames from industrial or consumer cameras, performs DLCLive inference, and records high-quality video with synchronized pose data. ## Features -- Python 3.11+ compatible codebase with a PyQt6 interface. -- Modular architecture with dedicated modules for camera control, video recording, configuration - management, and DLCLive processing. -- Single JSON configuration file that captures camera settings, DLCLive parameters, and recording - options. All fields can be edited directly within the GUI. -- Optional DLCLive inference with pose visualisation over the live video feed. -- Recording support via vidgear's `WriteGear`, including custom encoder options. +### Core Functionality +- **Modern Python Stack**: Python 3.10+ compatible codebase with PyQt6 interface +- **Multi-Backend Camera Support**: OpenCV, GenTL (Harvesters), Aravis, and Basler (pypylon) +- **Real-Time Pose Estimation**: Live DLCLive inference with configurable models (TensorFlow, PyTorch) +- **High-Performance Recording**: Hardware-accelerated video encoding via FFmpeg +- **Flexible Configuration**: Single JSON file for all settings with GUI editing + +### Camera Features +- **Multiple Backends**: + - OpenCV - Universal webcam support + - GenTL - Industrial cameras via Harvesters (Windows/Linux) + - Aravis - GenICam/GigE cameras (Linux/macOS) + - Basler - Basler cameras via pypylon +- **Smart Device Detection**: Automatic camera enumeration without unnecessary probing +- **Camera Controls**: Exposure time, gain, frame rate, and ROI cropping +- **Live Preview**: Real-time camera feed with rotation support (0°, 90°, 180°, 270°) + +### DLCLive Features +- **Model Support**: TensorFlow (base) and PyTorch models +- **Processor System**: Plugin architecture for custom pose processing +- **Auto-Recording**: Automatic video recording triggered by processor commands +- **Performance Metrics**: Real-time FPS, latency, and queue monitoring +- **Pose Visualization**: Optional overlay of detected keypoints on live feed + +### Recording Features +- **Hardware Encoding**: NVENC (NVIDIA GPU) and software codecs (libx264, libx265) +- **Configurable Quality**: CRF-based quality control +- **Multiple Formats**: MP4, AVI, MOV containers +- **Timestamp Support**: Frame-accurate timestamps for synchronization +- **Performance Monitoring**: Write FPS, buffer status, and dropped frame tracking + +### User Interface +- **Intuitive Layout**: Organized control panels with clear separation of concerns +- **Configuration Management**: Load/save settings, support for multiple configurations +- **Status Indicators**: Real-time feedback on camera, inference, and recording status +- **Bounding Box Tool**: Visual overlay for ROI definition ## Installation -1. Install the package and its dependencies: +### Basic Installation - ```bash - pip install deeplabcut-live-gui - ``` +```bash +pip install deeplabcut-live-gui +``` + +This installs the core package with OpenCV camera support. + +### Full Installation with Optional Dependencies + +```bash +# Install with gentl support +pip install deeplabcut-live-gui[gentl] +``` + +### Platform-Specific Camera Backend Setup + +#### Windows (GenTL for Industrial Cameras) +1. Install camera vendor drivers and SDK +2. Ensure GenTL producer (.cti) files are accessible +3. Common locations: + - `C:\Program Files\The Imaging Source Europe GmbH\IC4 GenTL Driver\bin\` + - Check vendor documentation for CTI file location + +#### Linux (Aravis for GenICam Cameras - Recommended) +NOT tested +```bash +# Ubuntu/Debian +sudo apt-get install gir1.2-aravis-0.8 python3-gi + +# Fedora +sudo dnf install aravis python3-gobject +``` + +#### macOS (Aravis) +NOT tested +```bash +brew install aravis +pip install pygobject +``` - The GUI requires additional runtime packages for optional features: +#### Basler Cameras (All Platforms) +NOT tested +```bash +# Install Pylon SDK from Basler website +# Then install pypylon +pip install pypylon +``` - - [DeepLabCut-live](https://github.com/DeepLabCut/DeepLabCut-live) for pose estimation. - - [vidgear](https://github.com/abhiTronix/vidgear) for video recording. - - [OpenCV](https://opencv.org/) for camera access. +### Hardware Acceleration (Optional) - These libraries are listed in `setup.py` and will be installed automatically when the package is - installed via `pip`. +For NVIDIA GPU encoding (highly recommended for high-resolution/high-FPS recording): +```bash +# Ensure NVIDIA drivers are installed +# FFmpeg with NVENC support will be used automatically +``` -2. Launch the GUI: +## Quick Start +1. **Launch the GUI**: ```bash dlclivegui ``` +2. **Select Camera Backend**: Choose from the dropdown (opencv, gentl, aravis, basler) + +3. **Configure Camera**: Set FPS, exposure, gain, and other parameters + +4. **Start Preview**: Click "Start Preview" to begin camera streaming + +5. **Optional - Load DLC Model**: Browse to your exported DLCLive model directory + +6. **Optional - Start Inference**: Click "Start pose inference" for real-time tracking + +7. **Optional - Record Video**: Configure output path and click "Start recording" + ## Configuration -The GUI works with a single JSON configuration describing the experiment. The configuration contains -three main sections: +The GUI uses a single JSON configuration file containing all experiment settings: ```json { "camera": { + "name": "Camera 0", "index": 0, - "width": 1280, - "height": 720, "fps": 60.0, - "backend": "opencv", + "backend": "gentl", + "exposure": 10000, + "gain": 5.0, + "crop_x0": 0, + "crop_y0": 0, + "crop_x1": 0, + "crop_y1": 0, + "max_devices": 3, "properties": {} }, "dlc": { "model_path": "/path/to/exported-model", - "processor": "cpu", - "shuffle": 1, - "trainingsetindex": 0, - "processor_args": {}, - "additional_options": {} + "model_type": "base", + "additional_options": { + "resize": 0.5, + "processor": "cpu" + } }, "recording": { "enabled": true, - "directory": "~/Videos/deeplabcut", + "directory": "~/Videos/deeplabcut-live", "filename": "session.mp4", "container": "mp4", - "options": { - "compression_mode": "mp4" - } + "codec": "h264_nvenc", + "crf": 23 + }, + "bbox": { + "enabled": false, + "x0": 0, + "y0": 0, + "x1": 200, + "y1": 100 } } ``` -Use **File → Load configuration…** to open an existing configuration, or **File → Save configuration** -to persist the current settings. Every field in the GUI is editable, and values entered in the -interface will be written back to the JSON file. +### Configuration Management -### Camera backends +- **Load**: File → Load configuration… (or Ctrl+O) +- **Save**: File → Save configuration (or Ctrl+S) +- **Save As**: File → Save configuration as… (or Ctrl+Shift+S) -Set `camera.backend` to one of the supported drivers: +All GUI fields are automatically synchronized with the configuration file. -- `opencv` – standard `cv2.VideoCapture` fallback available on every platform. -- `basler` – uses the Basler Pylon SDK via `pypylon` (install separately). -- `gentl` – uses Aravis for GenTL-compatible cameras (requires `python-gi` bindings). +## Camera Backends -Backend specific parameters can be supplied through the `camera.properties` object. For example: +### Backend Selection Guide +| Backend | Platform | Use Case | Auto-Detection | +|---------|----------|----------|----------------| +| **opencv** | All | Webcams, simple USB cameras | Basic | +| **gentl** | Windows, Linux | Industrial cameras via CTI files | Yes | +| **aravis** | Linux, macOS | GenICam/GigE cameras | Yes | +| **basler** | All | Basler cameras specifically | Yes | + +### Backend-Specific Configuration + +#### OpenCV +```json +{ + "camera": { + "backend": "opencv", + "index": 0, + "fps": 30.0 + } +} +``` +**Note**: Exposure and gain controls are disabled for OpenCV backend due to limited driver support. + +#### GenTL (Harvesters) ```json { "camera": { + "backend": "gentl", "index": 0, - "backend": "basler", + "fps": 60.0, + "exposure": 15000, + "gain": 8.0, "properties": { - "serial": "40123456", - "exposure": 15000, - "gain": 6.0 + "cti_file": "C:\\Path\\To\\Producer.cti", + "serial_number": "12345678", + "pixel_format": "Mono8" } } } ``` -If optional dependencies are missing, the GUI will show the backend as unavailable in the drop-down -but you can still configure it for a system where the drivers are present. +#### Aravis +```json +{ + "camera": { + "backend": "aravis", + "index": 0, + "fps": 60.0, + "exposure": 10000, + "gain": 5.0, + "properties": { + "camera_id": "TheImagingSource-12345678", + "pixel_format": "Mono8", + "n_buffers": 10, + "timeout": 2000000 + } + } +} +``` + +See [Camera Backend Documentation](docs/camera_support.md) for detailed setup instructions. + +## DLCLive Integration -## Development +### Model Types -The core modules of the package are organised as follows: +The GUI supports both TensorFlow and PyTorch DLCLive models: -- `dlclivegui.config` – dataclasses for loading, storing, and saving application settings. -- `dlclivegui.cameras` – modular camera backends (OpenCV, Basler, GenTL) and factory helpers. -- `dlclivegui.camera_controller` – camera capture worker running in a dedicated `QThread`. -- `dlclivegui.video_recorder` – wrapper around `WriteGear` for video output. -- `dlclivegui.dlc_processor` – asynchronous DLCLive inference with optional pose overlay. -- `dlclivegui.gui` – PyQt6 user interface and application entry point. +1. **Base (TensorFlow)**: Original DLC models exported for live inference +2. **PyTorch**: PyTorch-based models (requires PyTorch installation) -Run a quick syntax check with: +Select the model type from the dropdown before starting inference. + +### Processor System + +The GUI includes a plugin system for custom pose processing: + +```python +# Example processor +class MyProcessor: + def process(self, pose, timestamp): + # Custom processing logic + x, y = pose[0, :2] # First keypoint + print(f"Position: ({x}, {y})") + def save(self): + pass +``` + +Place processors in `dlclivegui/processors/` and refresh to load them. + +See [Processor Plugin Documentation](docs/PLUGIN_SYSTEM.md) for details. + +### Auto-Recording Feature + +Enable "Auto-record video on processor command" to automatically start/stop recording based on processor signals. Useful for event-triggered recording in behavioral experiments. + +## Performance Optimization + +### High-Speed Camera Tips + +1. **Use Hardware Encoding**: Select `h264_nvenc` codec for NVIDIA GPUs +2. **Adjust Buffer Count**: Increase buffers for GenTL/Aravis backends + ```json + "properties": {"n_buffers": 20} + ``` +3. **Optimize CRF**: Lower CRF = higher quality but larger files (default: 23) +4. **Disable Visualization**: Uncheck "Display pose predictions" during recording +5. **Crop Region**: Use cropping to reduce frame size before inference + +### Recommended Settings by FPS + +| FPS Range | Codec | CRF | Buffers | Notes | +|-----------|-------|-----|---------|-------| +| 30-60 | libx264 | 23 | 10 | Standard quality | +| 60-120 | h264_nvenc | 23 | 15 | GPU encoding | +| 120-200 | h264_nvenc | 28 | 20 | Higher compression | +| 200+ | h264_nvenc | 30 | 30 | Max performance | + +### Project Structure + +``` +dlclivegui/ +├── __init__.py +├── gui.py # Main PyQt6 application +├── config.py # Configuration dataclasses +├── camera_controller.py # Camera capture thread +├── dlc_processor.py # DLCLive inference thread +├── video_recorder.py # Video encoding thread +├── cameras/ # Camera backend modules +│ ├── base.py # Abstract base class +│ ├── factory.py # Backend registry and detection +│ ├── opencv_backend.py +│ ├── gentl_backend.py +│ ├── aravis_backend.py +│ └── basler_backend.py +└── processors/ # Pose processor plugins + ├── processor_utils.py + └── dlc_processor_socket.py +``` + +### Running Tests ```bash +# Syntax check python -m compileall dlclivegui + +# Type checking (optional) +mypy dlclivegui + ``` +### Adding New Camera Backends + +1. Create new backend inheriting from `CameraBackend` +2. Implement required methods: `open()`, `read()`, `close()` +3. Optional: Implement `get_device_count()` for smart detection +4. Register in `cameras/factory.py` + +See [Camera Backend Development](docs/camera_support.md) for detailed instructions. + + +## Documentation + +- [Camera Support](docs/camera_support.md) - All camera backends and setup +- [Aravis Backend](docs/aravis_backend.md) - GenICam camera setup (Linux/macOS) +- [Processor Plugins](docs/PLUGIN_SYSTEM.md) - Custom pose processing +- [Installation Guide](docs/install.md) - Detailed setup instructions +- [Timestamp Format](docs/timestamp_format.md) - Timestamp synchronization + +## System Requirements + + +### Recommended +- Python 3.10+ +- 8 GB RAM +- NVIDIA GPU with CUDA support (for DLCLive inference and video encoding) +- USB 3.0 or GigE network (for industrial cameras) +- SSD storage (for high-speed recording) + +### Tested Platforms +- Windows 11 + ## License -This project is licensed under the GNU Lesser General Public License v3.0. See the `LICENSE` file for -more information. +This project is licensed under the GNU Lesser General Public License v3.0. See the [LICENSE](LICENSE) file for more information. + +## Citation + +Cite the original DeepLabCut-live paper: +```bibtex +@article{Kane2020, + title={Real-time, low-latency closed-loop feedback using markerless posture tracking}, + author={Kane, Gary A and Lopes, Gonçalo and Saunders, Jonny L and Mathis, Alexander and Mathis, Mackenzie W}, + journal={eLife}, + year={2020}, + doi={10.7554/eLife.61909} +} +``` diff --git a/dlclivegui/cameras/aravis_backend.py b/dlclivegui/cameras/aravis_backend.py new file mode 100644 index 0000000..e033096 --- /dev/null +++ b/dlclivegui/cameras/aravis_backend.py @@ -0,0 +1,323 @@ +"""Aravis backend for GenICam cameras.""" + +from __future__ import annotations + +import time +from typing import Optional, Tuple + +import cv2 +import numpy as np + +from .base import CameraBackend + +try: # pragma: no cover - optional dependency + import gi + + gi.require_version("Aravis", "0.8") + from gi.repository import Aravis + + ARAVIS_AVAILABLE = True +except Exception: # pragma: no cover - optional dependency + Aravis = None # type: ignore + ARAVIS_AVAILABLE = False + + +class AravisCameraBackend(CameraBackend): + """Capture frames from GenICam-compatible devices via Aravis.""" + + def __init__(self, settings): + super().__init__(settings) + props = settings.properties + self._camera_id: Optional[str] = props.get("camera_id") + self._pixel_format: str = props.get("pixel_format", "Mono8") + self._timeout: int = int(props.get("timeout", 2000000)) # microseconds + self._n_buffers: int = int(props.get("n_buffers", 10)) + + self._camera = None + self._stream = None + self._device_label: Optional[str] = None + + @classmethod + def is_available(cls) -> bool: + """Check if Aravis is available on this system.""" + return ARAVIS_AVAILABLE + + @classmethod + def get_device_count(cls) -> int: + """Get the actual number of Aravis devices detected. + + Returns the number of devices found, or -1 if detection fails. + """ + if not ARAVIS_AVAILABLE: + return -1 + + try: + Aravis.update_device_list() + return Aravis.get_n_devices() + except Exception: + return -1 + + def open(self) -> None: + """Open the Aravis camera device.""" + if not ARAVIS_AVAILABLE: # pragma: no cover - optional dependency + raise RuntimeError( + "The 'aravis' library is required for the Aravis backend. " + "Install it via your system package manager (e.g., 'sudo apt install gir1.2-aravis-0.8' on Ubuntu)." + ) + + # Update device list + Aravis.update_device_list() + n_devices = Aravis.get_n_devices() + + if n_devices == 0: + raise RuntimeError("No Aravis cameras detected") + + # Open camera by ID or index + if self._camera_id: + self._camera = Aravis.Camera.new(self._camera_id) + if self._camera is None: + raise RuntimeError(f"Failed to open camera with ID '{self._camera_id}'") + else: + index = int(self.settings.index or 0) + if index < 0 or index >= n_devices: + raise RuntimeError( + f"Camera index {index} out of range for {n_devices} Aravis device(s)" + ) + camera_id = Aravis.get_device_id(index) + self._camera = Aravis.Camera.new(camera_id) + if self._camera is None: + raise RuntimeError(f"Failed to open camera at index {index}") + + # Get device information for label + self._device_label = self._resolve_device_label() + + # Configure camera + self._configure_pixel_format() + self._configure_exposure() + self._configure_gain() + self._configure_frame_rate() + + # Create stream + self._stream = self._camera.create_stream(None, None) + if self._stream is None: + raise RuntimeError("Failed to create Aravis stream") + + # Push buffers to stream + payload_size = self._camera.get_payload() + for _ in range(self._n_buffers): + self._stream.push_buffer(Aravis.Buffer.new_allocate(payload_size)) + + # Start acquisition + self._camera.start_acquisition() + + def read(self) -> Tuple[np.ndarray, float]: + """Read a frame from the camera.""" + if self._camera is None or self._stream is None: + raise RuntimeError("Aravis camera not initialized") + + # Pop buffer from stream + buffer = self._stream.timeout_pop_buffer(self._timeout) + + if buffer is None: + raise TimeoutError("Failed to grab frame from Aravis camera (timeout)") + + # Check buffer status + status = buffer.get_status() + if status != Aravis.BufferStatus.SUCCESS: + self._stream.push_buffer(buffer) + raise TimeoutError(f"Aravis buffer status error: {status}") + + # Get image data + try: + # Get buffer data as numpy array + data = buffer.get_data() + width = buffer.get_image_width() + height = buffer.get_image_height() + pixel_format = buffer.get_image_pixel_format() + + # Convert to numpy array + if pixel_format == Aravis.PIXEL_FORMAT_MONO_8: + frame = np.frombuffer(data, dtype=np.uint8).reshape((height, width)) + frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) + elif pixel_format == Aravis.PIXEL_FORMAT_RGB_8_PACKED: + frame = np.frombuffer(data, dtype=np.uint8).reshape((height, width, 3)) + frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) + elif pixel_format == Aravis.PIXEL_FORMAT_BGR_8_PACKED: + frame = np.frombuffer(data, dtype=np.uint8).reshape((height, width, 3)) + elif pixel_format in (Aravis.PIXEL_FORMAT_MONO_12, Aravis.PIXEL_FORMAT_MONO_16): + # Handle 12-bit and 16-bit mono + frame = np.frombuffer(data, dtype=np.uint16).reshape((height, width)) + # Scale to 8-bit + max_val = float(frame.max()) if frame.size else 0.0 + scale = 255.0 / max_val if max_val > 0.0 else 1.0 + frame = np.clip(frame * scale, 0, 255).astype(np.uint8) + frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) + else: + # Fallback for unknown formats - try to interpret as mono8 + frame = np.frombuffer(data, dtype=np.uint8).reshape((height, width)) + frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) + + frame = frame.copy() + timestamp = time.time() + + finally: + # Always push buffer back to stream + self._stream.push_buffer(buffer) + + return frame, timestamp + + def stop(self) -> None: + """Stop camera acquisition.""" + if self._camera is not None: + try: + self._camera.stop_acquisition() + except Exception: + pass + + def close(self) -> None: + """Release the camera and stream.""" + if self._camera is not None: + try: + self._camera.stop_acquisition() + except Exception: + pass + + # Clear stream buffers + if self._stream is not None: + try: + # Flush remaining buffers + while True: + buffer = self._stream.try_pop_buffer() + if buffer is None: + break + except Exception: + pass + self._stream = None + + # Release camera + try: + del self._camera + except Exception: + pass + finally: + self._camera = None + + self._device_label = None + + def device_name(self) -> str: + """Return a human-readable device name.""" + if self._device_label: + return self._device_label + return super().device_name() + + # ------------------------------------------------------------------ + # Configuration helpers + # ------------------------------------------------------------------ + + def _configure_pixel_format(self) -> None: + """Configure the camera pixel format.""" + if self._camera is None: + return + + try: + # Map common format names to Aravis pixel formats + format_map = { + "Mono8": Aravis.PIXEL_FORMAT_MONO_8, + "Mono12": Aravis.PIXEL_FORMAT_MONO_12, + "Mono16": Aravis.PIXEL_FORMAT_MONO_16, + "RGB8": Aravis.PIXEL_FORMAT_RGB_8_PACKED, + "BGR8": Aravis.PIXEL_FORMAT_BGR_8_PACKED, + } + + if self._pixel_format in format_map: + self._camera.set_pixel_format(format_map[self._pixel_format]) + else: + # Try setting as string + self._camera.set_pixel_format_from_string(self._pixel_format) + except Exception: + # If pixel format setting fails, continue with default + pass + + def _configure_exposure(self) -> None: + """Configure camera exposure time.""" + if self._camera is None: + return + + # Get exposure from settings + exposure = None + if hasattr(self.settings, "exposure") and self.settings.exposure > 0: + exposure = float(self.settings.exposure) + + if exposure is None: + return + + try: + # Disable auto exposure + try: + self._camera.set_exposure_time_auto(Aravis.Auto.OFF) + except Exception: + pass + + # Set exposure time (in microseconds) + self._camera.set_exposure_time(exposure) + except Exception: + pass + + def _configure_gain(self) -> None: + """Configure camera gain.""" + if self._camera is None: + return + + # Get gain from settings + gain = None + if hasattr(self.settings, "gain") and self.settings.gain > 0.0: + gain = float(self.settings.gain) + + if gain is None: + return + + try: + # Disable auto gain + try: + self._camera.set_gain_auto(Aravis.Auto.OFF) + except Exception: + pass + + # Set gain value + self._camera.set_gain(gain) + except Exception: + pass + + def _configure_frame_rate(self) -> None: + """Configure camera frame rate.""" + if self._camera is None or not self.settings.fps: + return + + try: + target_fps = float(self.settings.fps) + self._camera.set_frame_rate(target_fps) + except Exception: + pass + + def _resolve_device_label(self) -> Optional[str]: + """Get a human-readable device label.""" + if self._camera is None: + return None + + try: + model = self._camera.get_model_name() + vendor = self._camera.get_vendor_name() + serial = self._camera.get_device_serial_number() + + if model and serial: + if vendor: + return f"{vendor} {model} ({serial})" + return f"{model} ({serial})" + elif model: + return model + elif serial: + return f"Camera {serial}" + except Exception: + pass + + return None diff --git a/dlclivegui/cameras/factory.py b/dlclivegui/cameras/factory.py index eca4f58..3261ba5 100644 --- a/dlclivegui/cameras/factory.py +++ b/dlclivegui/cameras/factory.py @@ -22,6 +22,7 @@ class DetectedCamera: "opencv": ("dlclivegui.cameras.opencv_backend", "OpenCVCameraBackend"), "basler": ("dlclivegui.cameras.basler_backend", "BaslerCameraBackend"), "gentl": ("dlclivegui.cameras.gentl_backend", "GenTLCameraBackend"), + "aravis": ("dlclivegui.cameras.aravis_backend", "AravisCameraBackend"), } @@ -58,7 +59,7 @@ def detect_cameras(backend: str, max_devices: int = 10) -> List[DetectedCamera]: The backend identifier, e.g. ``"opencv"``. max_devices: Upper bound for the indices that should be probed. - For GenTL backend, the actual device count is queried if available. + For backends with get_device_count (GenTL, Aravis), the actual device count is queried. Returns ------- diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..3cc524f --- /dev/null +++ b/docs/README.md @@ -0,0 +1,262 @@ +# DeepLabCut-live-GUI Documentation Index + +Welcome to the DeepLabCut-live-GUI documentation! This index will help you find the information you need. + +## Getting Started + +### New Users +1. **[README](../README.md)** - Project overview, installation, and quick start +2. **[User Guide](user_guide.md)** - Step-by-step walkthrough of all features +3. **[Installation Guide](install.md)** - Detailed installation instructions + +### Quick References +- **[ARAVIS_QUICK_REF](../ARAVIS_QUICK_REF.md)** - Aravis backend quick reference +- **[Features Overview](features.md)** - Complete feature documentation + +## Core Documentation + +### Camera Setup +- **[Camera Support](camera_support.md)** - Overview of all camera backends +- **[Aravis Backend](aravis_backend.md)** - Linux/macOS GenICam camera setup +- Platform-specific guides for industrial cameras + +### Application Features +- **[Features Documentation](features.md)** - Detailed feature descriptions: + - Camera control and backends + - Real-time pose estimation + - Video recording + - Configuration management + - Processor system + - User interface + - Performance monitoring + - Advanced features + +### User Guide +- **[User Guide](user_guide.md)** - Complete usage walkthrough: + - Getting started + - Camera setup + - DLCLive configuration + - Recording videos + - Configuration management + - Common workflows + - Tips and best practices + - Troubleshooting + +## Advanced Topics + +### Processor System +- **[Processor Plugins](PLUGIN_SYSTEM.md)** - Custom pose processing +- **[Processor Auto-Recording](processor_auto_recording.md)** - Event-triggered recording +- Socket processor documentation + +### Technical Details +- **[Timestamp Format](timestamp_format.md)** - Synchronization and timing +- **[ARAVIS_BACKEND_SUMMARY](../ARAVIS_BACKEND_SUMMARY.md)** - Implementation details + +## By Use Case + +### I want to... + +#### Set up a camera +→ [Camera Support](camera_support.md) → Select backend → Follow setup guide + +**By Platform**: +- **Windows**: [README](../README.md#windows-gentl-for-industrial-cameras) → GenTL setup +- **Linux**: [Aravis Backend](aravis_backend.md) → Installation for Ubuntu/Debian +- **macOS**: [Aravis Backend](aravis_backend.md) → Installation via Homebrew + +**By Camera Type**: +- **Webcam**: [User Guide](user_guide.md#camera-setup) → OpenCV backend +- **Industrial Camera**: [Camera Support](camera_support.md) → GenTL/Aravis +- **Basler Camera**: [Camera Support](camera_support.md#basler-cameras) → pypylon setup +- **The Imaging Source**: [Aravis Backend](aravis_backend.md) or GenTL + +#### Run pose estimation +→ [User Guide](user_guide.md#dlclive-configuration) → Load model → Start inference + +#### Record high-speed video +→ [Features](features.md#video-recording) → Hardware encoding → GPU setup +→ [User Guide](user_guide.md#high-speed-recording-60-fps) → Optimization tips + +#### Create custom processor +→ [Processor Plugins](PLUGIN_SYSTEM.md) → Plugin architecture → Examples + +#### Trigger recording remotely +→ [Features](features.md#auto-recording-feature) → Auto-recording setup +→ Socket processor documentation + +#### Optimize performance +→ [Features](features.md#performance-optimization) → Metrics → Adjustments +→ [User Guide](user_guide.md#tips-and-best-practices) → Best practices + +## By Topic + +### Camera Backends +| Backend | Documentation | Platform | +|---------|---------------|----------| +| OpenCV | [User Guide](user_guide.md#step-1-select-camera-backend) | All | +| GenTL | [Camera Support](camera_support.md) | Windows, Linux | +| Aravis | [Aravis Backend](aravis_backend.md) | Linux, macOS | +| Basler | [Camera Support](camera_support.md#basler-cameras) | All | + +### Configuration +- **Basics**: [README](../README.md#configuration) +- **Management**: [User Guide](user_guide.md#working-with-configurations) +- **Templates**: [User Guide](user_guide.md#configuration-templates) +- **Details**: [Features](features.md#configuration-management) + +### Recording +- **Quick Start**: [User Guide](user_guide.md#recording-videos) +- **Features**: [Features](features.md#video-recording) +- **Optimization**: [README](../README.md#performance-optimization) +- **Auto-Recording**: [Features](features.md#auto-recording-feature) + +### DLCLive +- **Setup**: [User Guide](user_guide.md#dlclive-configuration) +- **Models**: [Features](features.md#model-support) +- **Performance**: [Features](features.md#performance-metrics) +- **Visualization**: [Features](features.md#pose-visualization) + +## Troubleshooting + +### Quick Fixes +1. **Camera not detected** → [User Guide](user_guide.md#troubleshooting-guide) +2. **Slow inference** → [Features](features.md#performance-optimization) +3. **Dropped frames** → [README](../README.md#troubleshooting) +4. **Recording issues** → [User Guide](user_guide.md#troubleshooting-guide) + +### Detailed Troubleshooting +- [User Guide - Troubleshooting Section](user_guide.md#troubleshooting-guide) +- [README - Troubleshooting](../README.md#troubleshooting) +- [Aravis Backend - Troubleshooting](aravis_backend.md#troubleshooting) + +## Development + +### Architecture +- **Project Structure**: [README](../README.md#development) +- **Backend Development**: [Camera Support](camera_support.md#contributing-new-camera-types) +- **Processor Development**: [Processor Plugins](PLUGIN_SYSTEM.md) + +### Implementation Details +- **Aravis Backend**: [ARAVIS_BACKEND_SUMMARY](../ARAVIS_BACKEND_SUMMARY.md) +- **Thread Safety**: [Features](features.md#thread-safety) +- **Resource Management**: [Features](features.md#resource-management) + +## Reference + +### Configuration Schema +```json +{ + "camera": { + "name": "string", + "index": "number", + "fps": "number", + "backend": "opencv|gentl|aravis|basler", + "exposure": "number (μs, 0=auto)", + "gain": "number (0.0=auto)", + "crop_x0/y0/x1/y1": "number", + "max_devices": "number", + "properties": "object" + }, + "dlc": { + "model_path": "string", + "model_type": "base|pytorch", + "additional_options": "object" + }, + "recording": { + "enabled": "boolean", + "directory": "string", + "filename": "string", + "container": "mp4|avi|mov", + "codec": "h264_nvenc|libx264|hevc_nvenc", + "crf": "number (0-51)" + }, + "bbox": { + "enabled": "boolean", + "x0/y0/x1/y1": "number" + } +} +``` + +### Performance Metrics +- **Camera FPS**: [Features](features.md#camera-metrics) +- **DLC Metrics**: [Features](features.md#dlc-metrics) +- **Recording Metrics**: [Features](features.md#recording-metrics) + +### Keyboard Shortcuts +| Action | Shortcut | +|--------|----------| +| Load configuration | Ctrl+O | +| Save configuration | Ctrl+S | +| Save as | Ctrl+Shift+S | +| Quit | Ctrl+Q | + +## External Resources + +### DeepLabCut +- [DeepLabCut](http://www.mackenziemathislab.org/deeplabcut) +- [DeepLabCut-live](https://github.com/DeepLabCut/DeepLabCut-live) +- [DeepLabCut Documentation](http://deeplabcut.github.io/DeepLabCut/docs/intro.html) + +### Camera Libraries +- [Aravis Project](https://github.com/AravisProject/aravis) +- [Harvesters (GenTL)](https://github.com/genicam/harvesters) +- [pypylon (Basler)](https://github.com/basler/pypylon) +- [OpenCV](https://opencv.org/) + +### Video Encoding +- [FFmpeg](https://ffmpeg.org/) +- [NVENC (NVIDIA)](https://developer.nvidia.com/nvidia-video-codec-sdk) + +## Getting Help + +### Support Channels +1. Check relevant documentation (use this index!) +2. Search GitHub issues +3. Review example configurations +4. Contact maintainers + +### Reporting Issues +When reporting bugs, include: +- GUI version +- Platform (OS, Python version) +- Camera backend and model +- Configuration file (if applicable) +- Error messages +- Steps to reproduce + +## Contributing + +Interested in contributing? +- See [README - Contributing](../README.md#contributing) +- Review [Development Section](../README.md#development) +- Check open GitHub issues +- Read coding guidelines + +--- + +## Document Version History + +- **v1.0** - Initial comprehensive documentation + - Complete README overhaul + - User guide creation + - Features documentation + - Camera backend guides + - Aravis backend implementation + +## Quick Navigation + +**Popular Pages**: +- [User Guide](user_guide.md) - Most comprehensive walkthrough +- [Features](features.md) - All capabilities detailed +- [Aravis Setup](aravis_backend.md) - Linux industrial cameras +- [Camera Support](camera_support.md) - All camera backends + +**By Experience Level**: +- **Beginner**: [README](../README.md) → [User Guide](user_guide.md) +- **Intermediate**: [Features](features.md) → [Camera Support](camera_support.md) +- **Advanced**: [Processor Plugins](PLUGIN_SYSTEM.md) → Implementation details + +--- + +*Last updated: 2025-10-24* diff --git a/docs/aravis_backend.md b/docs/aravis_backend.md new file mode 100644 index 0000000..67024ba --- /dev/null +++ b/docs/aravis_backend.md @@ -0,0 +1,202 @@ +# Aravis Backend + +The Aravis backend provides support for GenICam-compatible cameras using the [Aravis](https://github.com/AravisProject/aravis) library. + +## Features + +- Support for GenICam/GigE Vision cameras +- Automatic device detection with `get_device_count()` +- Configurable exposure time and gain +- Support for various pixel formats (Mono8, Mono12, Mono16, RGB8, BGR8) +- Efficient streaming with configurable buffer count +- Timeout handling for robust operation + +## Installation + +### Linux (Ubuntu/Debian) +```bash +sudo apt-get install gir1.2-aravis-0.8 python3-gi +``` + +### Linux (Fedora) +```bash +sudo dnf install aravis python3-gobject +``` + +### Windows +Aravis support on Windows requires building from source or using WSL. For native Windows support, consider using the GenTL backend instead. + +### macOS +```bash +brew install aravis +pip install pygobject +``` + +## Configuration + +### Basic Configuration + +Select "aravis" as the backend in the GUI or in your configuration file: + +```json +{ + "camera": { + "backend": "aravis", + "index": 0, + "fps": 30.0, + "exposure": 10000, + "gain": 5.0 + } +} +``` + +### Advanced Properties + +You can configure additional Aravis-specific properties via the `properties` dictionary: + +```json +{ + "camera": { + "backend": "aravis", + "index": 0, + "fps": 30.0, + "exposure": 10000, + "gain": 5.0, + "properties": { + "camera_id": "MyCamera-12345", + "pixel_format": "Mono8", + "timeout": 2000000, + "n_buffers": 10 + } + } +} +``` + +#### Available Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `camera_id` | string | None | Specific camera ID to open (overrides index) | +| `pixel_format` | string | "Mono8" | Pixel format: Mono8, Mono12, Mono16, RGB8, BGR8 | +| `timeout` | int | 2000000 | Frame timeout in microseconds (2 seconds) | +| `n_buffers` | int | 10 | Number of buffers in the acquisition stream | + +### Exposure and Gain + +The Aravis backend supports exposure time (in microseconds) and gain control: + +- **Exposure**: Set via the GUI exposure field or `settings.exposure` (0 = auto, >0 = manual in μs) +- **Gain**: Set via the GUI gain field or `settings.gain` (0.0 = auto, >0.0 = manual value) + +When exposure or gain are set to non-zero values, the backend automatically disables auto-exposure and auto-gain. + +## Camera Selection + +### By Index +The default method is to select cameras by index (0, 1, 2, etc.): +```json +{ + "camera": { + "backend": "aravis", + "index": 0 + } +} +``` + +### By Camera ID +You can also select a specific camera by its ID: +```json +{ + "camera": { + "backend": "aravis", + "properties": { + "camera_id": "TheImagingSource-12345678" + } + } +} +``` + +## Supported Pixel Formats + +The backend automatically converts different pixel formats to BGR format for consistency: + +- **Mono8**: 8-bit grayscale → BGR +- **Mono12**: 12-bit grayscale → scaled to 8-bit → BGR +- **Mono16**: 16-bit grayscale → scaled to 8-bit → BGR +- **RGB8**: 8-bit RGB → BGR (color conversion) +- **BGR8**: 8-bit BGR (no conversion needed) + +## Performance Tuning + +### Buffer Count +Increase `n_buffers` for high-speed cameras or systems with variable latency: +```json +{ + "properties": { + "n_buffers": 20 + } +} +``` + +### Timeout +Adjust timeout for slower cameras or network cameras: +```json +{ + "properties": { + "timeout": 5000000 + } +} +``` +(5 seconds = 5,000,000 microseconds) + +## Troubleshooting + +### No cameras detected +1. Verify Aravis installation: `arv-tool-0.8 -l` +2. Check camera is powered and connected +3. Ensure proper network configuration for GigE cameras +4. Check user permissions for USB cameras + +### Timeout errors +- Increase the `timeout` property +- Check network bandwidth for GigE cameras +- Verify camera is properly configured and streaming + +### Pixel format errors +- Check camera's supported pixel formats: `arv-tool-0.8 -n features` +- Try alternative formats: Mono8, RGB8, etc. + +## Comparison with GenTL Backend + +| Feature | Aravis | GenTL | +|---------|--------|-------| +| Platform | Linux (best), macOS | Windows (best), Linux | +| Camera Support | GenICam/GigE | GenTL producers | +| Installation | System packages | Vendor CTI files | +| Performance | Excellent | Excellent | +| Auto-detection | Yes | Yes | + +## Example: The Imaging Source Camera + +```json +{ + "camera": { + "backend": "aravis", + "index": 0, + "fps": 60.0, + "exposure": 8000, + "gain": 10.0, + "properties": { + "pixel_format": "Mono8", + "n_buffers": 15, + "timeout": 3000000 + } + } +} +``` + +## Resources + +- [Aravis Project](https://github.com/AravisProject/aravis) +- [GenICam Standard](https://www.emva.org/standards-technology/genicam/) +- [Python GObject Documentation](https://pygobject.readthedocs.io/) diff --git a/docs/camera_support.md b/docs/camera_support.md index 6e36e22..4d9ba22 100644 --- a/docs/camera_support.md +++ b/docs/camera_support.md @@ -1,13 +1,85 @@ ## Camera Support -### Windows -- **The Imaging Source USB3 Cameras**: via code based on [Windows code samples](https://github.com/TheImagingSource/IC-Imaging-Control-Samples) provided by The Imaging Source. To use The Imaging Source USB3 cameras on Windows, you must first [install their drivers](https://www.theimagingsource.com/support/downloads-for-windows/device-drivers/icwdmuvccamtis/) and [C library](https://www.theimagingsource.com/support/downloads-for-windows/software-development-kits-sdks/tisgrabberdll/). -- **OpenCV compatible cameras**: OpenCV is installed with DeepLabCut-live-GUI, so webcams or other cameras compatible with OpenCV on Windows require no additional installation. +DeepLabCut-live-GUI supports multiple camera backends for different platforms and camera types: -### Linux and NVIDIA Jetson Development Kits +### Supported Backends -- **OpenCV compatible cameras**: We provide support for many webcams and industrial cameras using OpenCV via Video4Linux drivers. This includes The Imaging Source USB3 cameras (and others, but untested). OpenCV is installed with DeepLabCut-live-GUI. -- **Aravis Project compatible USB3Vision and GigE Cameras**: [The Aravis Project](https://github.com/AravisProject/aravis) supports a number of popular industrial cameras used in neuroscience, including The Imaging Source, Point Grey, and Basler cameras. To use Aravis Project drivers, please follow their [installation instructions](https://github.com/AravisProject/aravis#installing-aravis). The Aravis Project drivers are supported on the NVIDIA Jetson platform, but there are known bugs (e.g. [here](https://github.com/AravisProject/aravis/issues/324)). +1. **OpenCV** - Universal webcam and USB camera support (all platforms) +2. **GenTL** - Industrial cameras via GenTL producers (Windows, Linux) +3. **Aravis** - GenICam/GigE Vision cameras (Linux, macOS) +4. **Basler** - Basler cameras via pypylon (all platforms) + +### Backend Selection + +You can select the backend in the GUI from the "Backend" dropdown, or in your configuration file: + +```json +{ + "camera": { + "backend": "aravis", + "index": 0, + "fps": 30.0 + } +} +``` + +### Platform-Specific Recommendations + +#### Windows +- **OpenCV compatible cameras**: Best for webcams and simple USB cameras. OpenCV is installed with DeepLabCut-live-GUI. +- **GenTL backend**: Recommended for industrial cameras (The Imaging Source, Basler, etc.) via vendor-provided CTI files. +- **Basler cameras**: Can use either GenTL or pypylon backend. + +#### Linux +- **OpenCV compatible cameras**: Good for webcams via Video4Linux drivers. Installed with DeepLabCut-live-GUI. +- **Aravis backend**: **Recommended** for GenICam/GigE Vision industrial cameras (The Imaging Source, Basler, Point Grey, etc.) + - Easy installation via system package manager + - Better Linux support than GenTL + - See [Aravis Backend Documentation](aravis_backend.md) +- **GenTL backend**: Alternative for industrial cameras if vendor provides Linux CTI files. + +#### macOS +- **OpenCV compatible cameras**: For webcams and compatible USB cameras. +- **Aravis backend**: For GenICam/GigE Vision cameras (requires Homebrew installation). + +#### NVIDIA Jetson +- **OpenCV compatible cameras**: Standard V4L2 camera support. +- **Aravis backend**: Supported but may have platform-specific bugs. See [Aravis issues](https://github.com/AravisProject/aravis/issues/324). + +### Quick Installation Guide + +#### Aravis (Linux/Ubuntu) +```bash +sudo apt-get install gir1.2-aravis-0.8 python3-gi +``` + +#### Aravis (macOS) +```bash +brew install aravis +pip install pygobject +``` + +#### GenTL (Windows) +Install vendor-provided camera drivers and SDK. CTI files are typically in: +- `C:\Program Files\The Imaging Source Europe GmbH\IC4 GenTL Driver\bin\` + +### Backend Comparison + +| Feature | OpenCV | GenTL | Aravis | Basler (pypylon) | +|---------|--------|-------|--------|------------------| +| Ease of use | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | +| Auto-detection | Basic | Yes | Yes | Yes | +| Exposure control | Limited | Yes | Yes | Yes | +| Gain control | Limited | Yes | Yes | Yes | +| Windows | ✅ | ✅ | ❌ | ✅ | +| Linux | ✅ | ✅ | ✅ | ✅ | +| macOS | ✅ | ❌ | ✅ | ✅ | + +### Detailed Backend Documentation + +- [Aravis Backend](aravis_backend.md) - GenICam/GigE cameras on Linux/macOS +- GenTL Backend - Industrial cameras via vendor CTI files +- OpenCV Backend - Universal webcam support ### Contributing New Camera Types diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..5fd535d --- /dev/null +++ b/docs/features.md @@ -0,0 +1,653 @@ +# DeepLabCut-live-GUI Features + +## Table of Contents + +- [Camera Control](#camera-control) +- [Real-Time Pose Estimation](#real-time-pose-estimation) +- [Video Recording](#video-recording) +- [Configuration Management](#configuration-management) +- [Processor System](#processor-system) +- [User Interface](#user-interface) +- [Performance Monitoring](#performance-monitoring) +- [Advanced Features](#advanced-features) + +--- + +## Camera Control + +### Multi-Backend Support + +The GUI supports four different camera backends, each optimized for different use cases: + +#### OpenCV Backend +- **Platform**: Windows, Linux, macOS +- **Best For**: Webcams, simple USB cameras +- **Installation**: Built-in with OpenCV +- **Limitations**: Limited exposure/gain control + +#### GenTL Backend (Harvesters) +- **Platform**: Windows, Linux +- **Best For**: Industrial cameras with GenTL producers +- **Installation**: Requires vendor CTI files +- **Features**: Full camera control, smart device detection + +#### Aravis Backend +- **Platform**: Linux (best), macOS +- **Best For**: GenICam/GigE Vision cameras +- **Installation**: System packages (`gir1.2-aravis-0.8`) +- **Features**: Excellent Linux support, native GigE + +#### Basler Backend (pypylon) +- **Platform**: Windows, Linux, macOS +- **Best For**: Basler cameras specifically +- **Installation**: Pylon SDK + pypylon +- **Features**: Vendor-specific optimizations + +### Camera Settings + +#### Frame Rate Control +- Range: 1-240 FPS (hardware dependent) +- Real-time FPS monitoring +- Automatic camera validation + +#### Exposure Control +- Auto mode (value = 0) +- Manual mode (microseconds) +- Range: 0-1,000,000 μs +- Real-time adjustment (backend dependent) + +#### Gain Control +- Auto mode (value = 0.0) +- Manual mode (gain value) +- Range: 0.0-100.0 +- Useful for low-light conditions + +#### Region of Interest (ROI) Cropping +- Define crop region: (x0, y0, x1, y1) +- Applied before recording and inference +- Reduces processing load +- Maintains aspect ratio + +#### Image Rotation +- 0°, 90°, 180°, 270° rotation +- Applied to all outputs +- Useful for mounted cameras + +### Smart Camera Detection + +The GUI intelligently detects available cameras: + +1. **Backend-Specific**: Each backend reports available cameras +2. **No Blind Probing**: GenTL and Aravis query actual device count +3. **Fast Refresh**: Only check connected devices +4. **Detailed Labels**: Shows vendor, model, serial number + +Example detection output: +``` +[CameraDetection] Available cameras for backend 'gentl': + ['0:DMK 37BUX287 (26320523)', '1:Basler acA1920 (40123456)'] +``` + +--- + +## Real-Time Pose Estimation + +### DLCLive Integration + +#### Model Support +- **TensorFlow (Base)**: Original DeepLabCut models +- **PyTorch**: PyTorch-exported models +- Model selection via dropdown +- Automatic model validation + +#### Inference Pipeline +1. **Frame Acquisition**: Camera thread → Queue +2. **Preprocessing**: Crop, resize (optional) +3. **Inference**: DLCLive model processing +4. **Pose Output**: (x, y) coordinates per keypoint +5. **Visualization**: Optional overlay on video + +#### Performance Metrics +- **Inference FPS**: Actual processing rate +- **Latency**: Time from capture to pose output + - Last latency (ms) + - Average latency (ms) +- **Queue Status**: Frame buffer depth +- **Dropped Frames**: Count of skipped frames + +### Pose Visualization + +#### Overlay Options +- **Toggle**: "Display pose predictions" checkbox +- **Keypoint Markers**: Green circles at (x, y) positions +- **Real-Time Update**: Synchronized with video feed +- **No Performance Impact**: Rendering optimized + +#### Bounding Box Visualization +- **Purpose**: Visual ROI definition +- **Configuration**: (x0, y0, x1, y1) coordinates +- **Color**: Red rectangle overlay +- **Use Cases**: + - Crop region preview + - Analysis area marking + - Multi-region tracking + +### Initialization Feedback + +Visual indicators during model loading: +1. **"Initializing DLCLive!"** - Blue button during load +2. **"DLCLive running!"** - Green button when ready +3. Status bar updates with progress + +--- + +## Video Recording + +### Recording Capabilities + +#### Hardware-Accelerated Encoding +- **NVENC (NVIDIA)**: GPU-accelerated H.264/H.265 + - Codecs: `h264_nvenc`, `hevc_nvenc` + - 10x faster than software encoding + - Minimal CPU usage +- **Software Encoding**: CPU-based fallback + - Codecs: `libx264`, `libx265` + - Universal compatibility + +#### Container Formats +- **MP4**: Most compatible, web-ready +- **AVI**: Legacy support +- **MOV**: Apple ecosystem + +#### Quality Control +- **CRF (Constant Rate Factor)**: 0-51 + - 0 = Lossless (huge files) + - 23 = Default (good quality) + - 28 = High compression + - 51 = Lowest quality +- **Presets**: ultrafast, fast, medium, slow + +### Recording Features + +#### Timestamp Synchronization +- Frame-accurate timestamps +- Microsecond precision +- Synchronized with pose data +- Stored in separate files + +#### Performance Monitoring +- **Write FPS**: Actual encoding rate +- **Queue Size**: Buffer depth (~ms) +- **Latency**: Encoding delay +- **Frames Written/Enqueued**: Progress tracking +- **Dropped Frames**: Quality indicator + +#### Buffer Management +- Configurable queue size +- Automatic overflow handling +- Warning on frame drops +- Backpressure indication + +### Auto-Recording Feature + +Processor-triggered recording: + +1. **Enable**: Check "Auto-record video on processor command" +2. **Processor Control**: Custom processor sets recording flag +3. **Automatic Start**: GUI starts recording when flag set +4. **Session Naming**: Uses processor-defined session name +5. **Automatic Stop**: GUI stops when flag cleared + +**Use Cases**: +- Event-triggered recording +- Trial-based experiments +- Conditional data capture +- Remote control via socket + +--- + +## Configuration Management + +### Configuration File Structure + +Single JSON file contains all settings: + +```json +{ + "camera": { ... }, + "dlc": { ... }, + "recording": { ... }, + "bbox": { ... } +} +``` + +### Features + +#### Save/Load Operations +- **Load**: File → Load configuration (Ctrl+O) +- **Save**: File → Save configuration (Ctrl+S) +- **Save As**: File → Save configuration as (Ctrl+Shift+S) +- **Auto-sync**: GUI fields update from file + +#### Multiple Configurations +- Switch between experiments quickly +- Per-animal configurations +- Environment-specific settings +- Backup and version control + +#### Validation +- Type checking on load +- Default values for missing fields +- Error messages for invalid entries +- Safe fallback to defaults + +### Configuration Sections + +#### Camera Settings (`camera`) +```json +{ + "name": "Camera 0", + "index": 0, + "fps": 60.0, + "backend": "gentl", + "exposure": 10000, + "gain": 5.0, + "crop_x0": 0, + "crop_y0": 0, + "crop_x1": 0, + "crop_y1": 0, + "max_devices": 3, + "properties": {} +} +``` + +#### DLC Settings (`dlc`) +```json +{ + "model_path": "/path/to/model", + "model_type": "base", + "additional_options": { + "resize": 0.5, + "processor": "cpu", + "pcutoff": 0.6 + } +} +``` + +#### Recording Settings (`recording`) +```json +{ + "enabled": true, + "directory": "~/Videos/dlc", + "filename": "session.mp4", + "container": "mp4", + "codec": "h264_nvenc", + "crf": 23 +} +``` + +#### Bounding Box Settings (`bbox`) +```json +{ + "enabled": false, + "x0": 0, + "y0": 0, + "x1": 200, + "y1": 100 +} +``` + +--- + +## Processor System + +### Plugin Architecture + +Custom pose processors for real-time analysis and control. + +#### Processor Interface + +```python +class MyProcessor: + """Custom processor example.""" + + def process(self, pose, timestamp): + """Process pose data in real-time. + + Args: + pose: numpy array (n_keypoints, 3) - x, y, likelihood + timestamp: float - frame timestamp + """ + # Extract keypoint positions + nose_x, nose_y = pose[0, :2] + + # Custom logic + if nose_x > 320: + self.trigger_event() + + # Return results (optional) + return {"position": (nose_x, nose_y)} +``` + +#### Loading Processors + +1. Place processor file in `dlclivegui/processors/` +2. Click "Refresh" in processor dropdown +3. Select processor from list +4. Start inference to activate + +#### Built-in Processors + +**Socket Processor** (`dlc_processor_socket.py`): +- TCP socket server for remote control +- Commands: `START_RECORDING`, `STOP_RECORDING` +- Session management +- Multi-client support + +### Auto-Recording Integration + +Processors can control recording: + +```python +class RecordingProcessor: + def __init__(self): + self._vid_recording = False + self.session_name = "default" + + @property + def video_recording(self): + return self._vid_recording + + def start_recording(self, session): + self.session_name = session + self._vid_recording = True + + def stop_recording(self): + self._vid_recording = False +``` + +The GUI monitors `video_recording` property and automatically starts/stops recording. + +--- + +## User Interface + +### Layout + +#### Control Panel (Left) +- **Camera Settings**: Backend, index, FPS, exposure, gain, crop +- **DLC Settings**: Model path, type, processor, options +- **Recording Settings**: Path, filename, codec, quality +- **Bounding Box**: Visualization controls + +#### Video Display (Right) +- Live camera feed +- Pose overlay (optional) +- Bounding box overlay (optional) +- Auto-scaling to window size + +#### Status Bar (Bottom) +- Current operation status +- Error messages +- Success confirmations + +### Control Groups + +#### Camera Controls +- Backend selection dropdown +- Camera index/refresh +- FPS, exposure, gain spinboxes +- Crop coordinates +- Rotation selector +- **Start/Stop Preview** buttons + +#### DLC Controls +- Model path browser +- Model type selector +- Processor folder/selection +- Additional options (JSON) +- **Start/Stop Inference** buttons +- "Display pose predictions" checkbox +- "Auto-record" checkbox +- Processor status display + +#### Recording Controls +- Output directory browser +- Filename input +- Container/codec selectors +- CRF quality slider +- **Start/Stop Recording** buttons + +### Visual Feedback + +#### Button States +- **Disabled**: Gray, not clickable +- **Enabled**: Default color, clickable +- **Active**: + - Preview running: Stop button enabled + - Inference initializing: Blue "Initializing DLCLive!" + - Inference ready: Green "DLCLive running!" + +#### Status Indicators +- Camera FPS (last 5 seconds) +- DLC performance metrics +- Recording statistics +- Processor connection status + +--- + +## Performance Monitoring + +### Real-Time Metrics + +#### Camera Metrics +- **Throughput**: FPS over last 5 seconds +- **Formula**: `(frame_count - 1) / time_elapsed` +- **Display**: "45.2 fps (last 5 s)" + +#### DLC Metrics +- **Inference FPS**: Poses processed per second +- **Latency**: + - Last frame latency (ms) + - Average latency over session (ms) +- **Queue**: Number of frames waiting +- **Dropped**: Frames skipped due to queue full +- **Format**: "150/152 frames | inference 42.1 fps | latency 23.5 ms (avg 24.1 ms) | queue 2 | dropped 2" + +#### Recording Metrics +- **Write FPS**: Encoding rate +- **Frames**: Written/Enqueued ratio +- **Latency**: Encoding delay (ms) +- **Buffer**: Queue size (~milliseconds) +- **Dropped**: Encoding failures +- **Format**: "1500/1502 frames | write 59.8 fps | latency 12.3 ms (avg 12.5 ms) | queue 5 (~83 ms) | dropped 2" + +### Performance Optimization + +#### Automatic Adjustments +- Frame display throttling (25 Hz max) +- Queue backpressure handling +- Automatic resolution detection + +#### User Adjustments +- Reduce camera FPS +- Enable ROI cropping +- Use hardware encoding +- Increase CRF value +- Disable pose visualization +- Adjust buffer counts + +--- + +## Advanced Features + +### Frame Synchronization + +All components share frame timestamps: +- Camera controller generates timestamps +- DLC processor preserves timestamps +- Video recorder stores timestamps +- Enables post-hoc alignment + +### Error Recovery + +#### Camera Connection Loss +- Automatic detection via frame grab failure +- User notification +- Clean resource cleanup +- Restart capability + +#### Recording Errors +- Frame size mismatch detection +- Automatic recovery with new settings +- Warning display +- No data loss + +### Thread Safety + +Multi-threaded architecture: +- **Main Thread**: GUI event loop +- **Camera Thread**: Frame acquisition +- **DLC Thread**: Pose inference +- **Recording Thread**: Video encoding + +Qt signals/slots ensure thread-safe communication. + +### Resource Management + +#### Automatic Cleanup +- Camera release on stop/error +- DLC model unload on stop +- Recording finalization +- Thread termination + +#### Memory Management +- Bounded queues prevent memory leaks +- Frame copy-on-write +- Efficient numpy array handling + +### Extensibility + +#### Custom Backends +Implement `CameraBackend` abstract class: +```python +class MyBackend(CameraBackend): + def open(self): ... + def read(self) -> Tuple[np.ndarray, float]: ... + def close(self): ... + + @classmethod + def get_device_count(cls) -> int: ... +``` + +Register in `factory.py`: +```python +_BACKENDS = { + "mybackend": ("module.path", "MyBackend") +} +``` + +#### Custom Processors +Place in `processors/` directory: +```python +class MyProcessor: + def __init__(self, **kwargs): + # Initialize + pass + + def process(self, pose, timestamp): + # Process pose + pass +``` + +### Debugging Features + +#### Logging +- Console output for errors +- Frame acquisition logging +- Performance warnings +- Connection status + +#### Development Mode +- Syntax validation: `python -m compileall dlclivegui` +- Type checking: `mypy dlclivegui` +- Test files included + +--- + +## Use Case Examples + +### High-Speed Behavior Tracking + +**Setup**: +- Camera: GenTL industrial camera @ 120 FPS +- Codec: h264_nvenc (GPU encoding) +- Crop: Region of interest only +- DLC: PyTorch model on GPU + +**Settings**: +```json +{ + "camera": {"fps": 120, "crop_x0": 200, "crop_y0": 100, "crop_x1": 800, "crop_y1": 600}, + "recording": {"codec": "h264_nvenc", "crf": 28}, + "dlc": {"additional_options": {"processor": "gpu", "resize": 0.5}} +} +``` + +### Event-Triggered Recording + +**Setup**: +- Processor: Socket processor with auto-record +- Trigger: Remote computer sends START/STOP commands +- Session naming: Unique per trial + +**Workflow**: +1. Enable "Auto-record video on processor command" +2. Start preview and inference +3. Remote system connects via socket +4. Sends `START_RECORDING:trial_001` → recording starts +5. Sends `STOP_RECORDING` → recording stops +6. Files saved as `trial_001.mp4` + +### Multi-Camera Synchronization + +**Setup**: +- Multiple GUI instances +- Shared trigger signal +- Synchronized filenames + +**Configuration**: +Each instance with different camera index but same settings template. + +--- + +## Keyboard Shortcuts + +- **Ctrl+O**: Load configuration +- **Ctrl+S**: Save configuration +- **Ctrl+Shift+S**: Save configuration as +- **Ctrl+Q**: Quit application + +--- + +## Platform-Specific Notes + +### Windows +- Best GenTL support (vendor CTI files) +- NVENC highly recommended +- DirectShow backend for webcams + +### Linux +- Best Aravis support (native GigE) +- V4L2 backend for webcams +- NVENC available with proprietary drivers + +### macOS +- Limited industrial camera support +- Aravis via Homebrew +- Software encoding recommended + +### NVIDIA Jetson +- Optimized for edge deployment +- Hardware encoding available +- Some Aravis compatibility issues diff --git a/docs/install.md b/docs/install.md index fa69ab4..24ef4f4 100644 --- a/docs/install.md +++ b/docs/install.md @@ -22,4 +22,4 @@ pip install deeplabcut-live-gui First, please refer to our complete instructions for [installing DeepLabCut-Live! on a NVIDIA Jetson Development Kit](https://github.com/DeepLabCut/DeepLabCut-live/blob/master/docs/install_jetson.md). -Next, install the DeepLabCut-live-GUI: `pip install deeplabcut-live-gui`. \ No newline at end of file +Next, install the DeepLabCut-live-GUI: `pip install deeplabcut-live-gui`. diff --git a/docs/timestamp_format.md b/docs/timestamp_format.md new file mode 100644 index 0000000..ac5e5a7 --- /dev/null +++ b/docs/timestamp_format.md @@ -0,0 +1,79 @@ +# Video Frame Timestamp Format + +When recording video, the application automatically saves frame timestamps to a JSON file alongside the video file. + +## File Naming + +For a video file named `recording_2025-10-23_143052.mp4`, the timestamp file will be: +``` +recording_2025-10-23_143052.mp4_timestamps.json +``` + +## JSON Structure + +```json +{ + "video_file": "recording_2025-10-23_143052.mp4", + "num_frames": 1500, + "timestamps": [ + 1729693852.123456, + 1729693852.156789, + 1729693852.190123, + ... + ], + "start_time": 1729693852.123456, + "end_time": 1729693902.123456, + "duration_seconds": 50.0 +} +``` + +## Fields + +- **video_file**: Name of the associated video file +- **num_frames**: Total number of frames recorded +- **timestamps**: Array of Unix timestamps (seconds since epoch with microsecond precision) for each frame +- **start_time**: Timestamp of the first frame +- **end_time**: Timestamp of the last frame +- **duration_seconds**: Total recording duration in seconds + +## Usage + +The timestamps correspond to the exact time each frame was captured by the camera (from `FrameData.timestamp`). This allows precise synchronization with: + +- DLC pose estimation results +- External sensors or triggers +- Other data streams recorded during the same session + +## Example: Loading Timestamps in Python + +```python +import json +from datetime import datetime + +# Load timestamps +with open('recording_2025-10-23_143052.mp4_timestamps.json', 'r') as f: + data = json.load(f) + +print(f"Video: {data['video_file']}") +print(f"Total frames: {data['num_frames']}") +print(f"Duration: {data['duration_seconds']:.2f} seconds") + +# Convert first timestamp to human-readable format +start_dt = datetime.fromtimestamp(data['start_time']) +print(f"Recording started: {start_dt.isoformat()}") + +# Calculate average frame rate +avg_fps = data['num_frames'] / data['duration_seconds'] +print(f"Average FPS: {avg_fps:.2f}") + +# Access individual frame timestamps +for frame_idx, timestamp in enumerate(data['timestamps']): + print(f"Frame {frame_idx}: {timestamp}") +``` + +## Notes + +- Timestamps use `time.time()` which returns Unix epoch time with high precision +- Frame timestamps are captured when frames arrive from the camera, before any processing +- If frames are dropped due to queue overflow, those frames will not have timestamps in the array +- The timestamp array length should match the number of frames in the video file diff --git a/docs/user_guide.md b/docs/user_guide.md new file mode 100644 index 0000000..289bfb8 --- /dev/null +++ b/docs/user_guide.md @@ -0,0 +1,633 @@ +# DeepLabCut-live-GUI User Guide + +Complete walkthrough for using the DeepLabCut-live-GUI application. + +## Table of Contents + +1. [Getting Started](#getting-started) +2. [Camera Setup](#camera-setup) +3. [DLCLive Configuration](#dlclive-configuration) +4. [Recording Videos](#recording-videos) +5. [Working with Configurations](#working-with-configurations) +6. [Common Workflows](#common-workflows) +7. [Tips and Best Practices](#tips-and-best-practices) + +--- + +## Getting Started + +### First Launch + +1. Open a terminal/command prompt +2. Run the application: + ```bash + dlclivegui + ``` +3. The main window will appear with three control panels and a video display area + +### Interface Overview + +``` +┌─────────────────────────────────────────────────────┐ +│ File Help │ +├─────────────┬───────────────────────────────────────┤ +│ Camera │ │ +│ Settings │ │ +│ │ │ +│ ─────────── │ Video Display │ +│ DLCLive │ │ +│ Settings │ │ +│ │ │ +│ ─────────── │ │ +│ Recording │ │ +│ Settings │ │ +│ │ │ +│ ─────────── │ │ +│ Bounding │ │ +│ Box │ │ +│ │ │ +│ ─────────── │ │ +│ [Preview] │ │ +│ [Stop] │ │ +└─────────────┴───────────────────────────────────────┘ +│ Status: Ready │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## Camera Setup + +### Step 1: Select Camera Backend + +The **Backend** dropdown shows available camera drivers: + +| Backend | When to Use | +|---------|-------------| +| **opencv** | Webcams, USB cameras (universal) | +| **gentl** | Industrial cameras (Windows/Linux) | +| **aravis** | GenICam/GigE cameras (Linux/macOS) | +| **basler** | Basler cameras specifically | + +**Note**: Unavailable backends appear grayed out. Install required drivers to enable them. + +### Step 2: Select Camera + +1. Click **Refresh** next to the camera dropdown +2. Wait for camera detection (1-3 seconds) +3. Select your camera from the dropdown + +The list shows camera details: +``` +0:DMK 37BUX287 (26320523) +│ │ └─ Serial Number +│ └─ Model Name +└─ Index +``` + +### Step 3: Configure Camera Parameters + +#### Frame Rate +- **Range**: 1-240 FPS (hardware dependent) +- **Recommendation**: Start with 30 FPS, increase as needed +- **Note**: Higher FPS = more processing load + +#### Exposure Time +- **Auto**: Set to 0 (default) +- **Manual**: Microseconds (e.g., 10000 = 10ms) +- **Tips**: + - Shorter exposure = less motion blur + - Longer exposure = better low-light performance + - Typical range: 5,000-30,000 μs + +#### Gain +- **Auto**: Set to 0.0 (default) +- **Manual**: 0.0-100.0 +- **Tips**: + - Higher gain = brighter image but more noise + - Start low (5-10) and increase if needed + - Auto mode works well for most cases + +#### Cropping (Optional) +Reduce frame size for faster processing: + +1. Set crop region: (x0, y0, x1, y1) + - x0, y0: Top-left corner + - x1, y1: Bottom-right corner +2. Use Bounding Box visualization to preview +3. Set all to 0 to disable cropping + +**Example**: Crop to center 640x480 region of 1280x720 camera: +``` +x0: 320 +y0: 120 +x1: 960 +y1: 600 +``` + +#### Rotation +Select if camera is mounted at an angle: +- 0° (default) +- 90° (rotated right) +- 180° (upside down) +- 270° (rotated left) + +### Step 4: Start Camera Preview + +1. Click **Start Preview** +2. Video feed should appear in the display area +3. Check the **Throughput** metric below camera settings +4. Verify frame rate matches expected value + +**Troubleshooting**: +- **No preview**: Check camera connection and permissions +- **Low FPS**: Reduce resolution or increase exposure time +- **Black screen**: Check exposure settings +- **Distorted image**: Verify backend compatibility + +--- + +## DLCLive Configuration + +### Prerequisites + +1. Exported DLCLive model (see DLC documentation) +2. DeepLabCut-live installed (`pip install deeplabcut-live`) +3. Camera preview running + +### Step 1: Select Model + +1. Click **Browse** next to "Model directory" +2. Navigate to your exported DLCLive model folder +3. Select the folder containing: + - `pose_cfg.yaml` + - Model weights (`.pb`, `.pth`, etc.) + +### Step 2: Choose Model Type + +Select from dropdown: +- **Base (TensorFlow)**: Standard DLC models +- **PyTorch**: PyTorch-based models (requires PyTorch) + +### Step 3: Configure Options (Optional) + +Click in "Additional options" field and enter JSON: + +```json +{ + "processor": "gpu", + "resize": 0.5, + "pcutoff": 0.6 +} +``` + +**Common options**: +- `processor`: "cpu" or "gpu" +- `resize`: Scale factor (0.5 = half size) +- `pcutoff`: Likelihood threshold +- `cropping`: Crop before inference + +### Step 4: Select Processor (Optional) + +If using custom pose processors: + +1. Click **Browse** next to "Processor folder" (or use default) +2. Click **Refresh** to scan for processors +3. Select processor from dropdown +4. Processor will activate when inference starts + +### Step 5: Start Inference + +1. Ensure camera preview is running +2. Click **Start pose inference** +3. Button changes to "Initializing DLCLive!" (blue) +4. Wait for model loading (5-30 seconds) +5. Button changes to "DLCLive running!" (green) +6. Check **Performance** metrics + +**Performance Metrics**: +``` +150/152 frames | inference 42.1 fps | latency 23.5 ms (avg 24.1 ms) | queue 2 | dropped 2 +``` +- **150/152**: Processed/Total frames +- **inference 42.1 fps**: Processing rate +- **latency 23.5 ms**: Current processing delay +- **queue 2**: Frames waiting +- **dropped 2**: Skipped frames (due to full queue) + +### Step 6: Enable Visualization (Optional) + +Check **"Display pose predictions"** to overlay keypoints on video. + +- Keypoints appear as green circles +- Updates in real-time with video +- Can be toggled during inference + +--- + +## Recording Videos + +### Basic Recording + +1. **Configure output path**: + - Click **Browse** next to "Output directory" + - Select or create destination folder + +2. **Set filename**: + - Enter base filename (e.g., "session_001") + - Extension added automatically based on container + +3. **Select format**: + - **Container**: mp4 (recommended), avi, mov + - **Codec**: + - `h264_nvenc` (NVIDIA GPU - fastest) + - `libx264` (CPU - universal) + - `hevc_nvenc` (NVIDIA H.265) + +4. **Set quality** (CRF slider): + - 0-17: Very high quality, large files + - 18-23: High quality (recommended) + - 24-28: Medium quality, smaller files + - 29-51: Lower quality, smallest files + +5. **Start recording**: + - Ensure camera preview is running + - Click **Start recording** + - **Stop recording** button becomes enabled + +6. **Monitor performance**: + - Check "Performance" metrics + - Watch for dropped frames + - Verify write FPS matches camera FPS + +### Advanced Recording Options + +#### High-Speed Recording (60+ FPS) + +**Settings**: +- Codec: `h264_nvenc` (requires NVIDIA GPU) +- CRF: 28 (higher compression) +- Crop region: Reduce frame size +- Close other applications + +#### High-Quality Recording + +**Settings**: +- Codec: `libx264` or `h264_nvenc` +- CRF: 18-20 +- Full resolution +- Sufficient disk space + +#### Long Duration Recording + +**Tips**: +- Use CRF 23-25 to balance quality/size +- Monitor disk space +- Consider splitting into multiple files +- Use fast SSD storage + +### Auto-Recording + +Enable automatic recording triggered by processor events: + +1. **Select a processor** that supports auto-recording +2. **Enable**: Check "Auto-record video on processor command" +3. **Start inference**: Processor will control recording +4. **Session management**: Files named by processor + +**Use cases**: +- Trial-based experiments +- Event-triggered recording +- Remote control via socket processor +- Conditional data capture + +--- + +## Working with Configurations + +### Saving Current Settings + +**Save** (overwrites existing file): +1. File → Save configuration (or Ctrl+S) +2. If no file loaded, prompts for location + +**Save As** (create new file): +1. File → Save configuration as… (or Ctrl+Shift+S) +2. Choose location and filename +3. Enter name (e.g., `mouse_experiment.json`) +4. Click Save + +### Loading Saved Settings + +1. File → Load configuration… (or Ctrl+O) +2. Navigate to configuration file +3. Select `.json` file +4. Click Open +5. All GUI fields update automatically + +### Managing Multiple Configurations + +**Recommended structure**: +``` +configs/ +├── default.json # Base settings +├── mouse_arena1.json # Arena-specific +├── mouse_arena2.json +├── rat_setup.json +└── high_speed.json # Performance-specific +``` + +**Workflow**: +1. Create base configuration with common settings +2. Save variants for different: + - Animals/subjects + - Experimental setups + - Camera positions + - Recording quality levels + +### Configuration Templates + +#### Webcam + CPU Processing +```json +{ + "camera": { + "backend": "opencv", + "index": 0, + "fps": 30.0 + }, + "dlc": { + "model_type": "base", + "additional_options": {"processor": "cpu"} + }, + "recording": { + "codec": "libx264", + "crf": 23 + } +} +``` + +#### Industrial Camera + GPU +```json +{ + "camera": { + "backend": "gentl", + "index": 0, + "fps": 60.0, + "exposure": 10000, + "gain": 8.0 + }, + "dlc": { + "model_type": "pytorch", + "additional_options": { + "processor": "gpu", + "resize": 0.5 + } + }, + "recording": { + "codec": "h264_nvenc", + "crf": 23 + } +} +``` + +--- + +## Common Workflows + +### Workflow 1: Simple Webcam Tracking + +**Goal**: Track mouse behavior with webcam + +1. **Camera Setup**: + - Backend: opencv + - Camera: Built-in webcam (index 0) + - FPS: 30 + +2. **Start Preview**: Verify mouse is visible + +3. **Load DLC Model**: Browse to mouse tracking model + +4. **Start Inference**: Enable pose estimation + +5. **Verify Tracking**: Enable pose visualization + +6. **Record Trial**: Start/stop recording as needed + +### Workflow 2: High-Speed Industrial Camera + +**Goal**: Track fast movements at 120 FPS + +1. **Camera Setup**: + - Backend: gentl or aravis + - Refresh and select camera + - FPS: 120 + - Exposure: 4000 μs (short exposure) + - Crop: Region of interest only + +2. **Start Preview**: Check FPS is stable + +3. **Configure Recording**: + - Codec: h264_nvenc + - CRF: 28 + - Output: Fast SSD + +4. **Load DLC Model** (if needed): + - PyTorch model + - GPU processor + - Resize: 0.5 (reduce load) + +5. **Start Recording**: Begin data capture + +6. **Monitor Performance**: Watch for dropped frames + +### Workflow 3: Event-Triggered Recording + +**Goal**: Record only during specific events + +1. **Camera Setup**: Configure as normal + +2. **Processor Setup**: + - Select socket processor + - Enable "Auto-record video on processor command" + +3. **Start Preview**: Camera running + +4. **Start Inference**: DLC + processor active + +5. **Remote Control**: + - Connect to socket (default port 5000) + - Send `START_RECORDING:trial_001` + - Recording starts automatically + - Send `STOP_RECORDING` + - Recording stops, file saved + +### Workflow 4: Multi-Subject Tracking + +**Goal**: Track multiple animals simultaneously + +**Option A: Single Camera, Multiple Keypoints** +1. Use DLC model trained for multiple subjects +2. Single GUI instance +3. Processor distinguishes subjects + +**Option B: Multiple Cameras** +1. Launch multiple GUI instances +2. Each with different camera index +3. Synchronized configurations +4. Coordinated filenames + +--- + +## Tips and Best Practices + +### Camera Tips + +1. **Lighting**: + - Consistent, diffuse lighting + - Avoid shadows and reflections + - IR lighting for night vision + +2. **Positioning**: + - Stable mount (minimize vibration) + - Appropriate angle for markers + - Sufficient field of view + +3. **Settings**: + - Start with auto exposure/gain + - Adjust manually if needed + - Test different FPS rates + - Use cropping to reduce load + +### Recording Tips + +1. **File Management**: + - Use descriptive filenames + - Include date/subject/trial info + - Organize by experiment/session + - Regular backups + +2. **Performance**: + - Close unnecessary applications + - Monitor disk space + - Use SSD for high-speed recording + - Enable GPU encoding if available + +3. **Quality**: + - Test CRF values beforehand + - Balance quality vs. file size + - Consider post-processing needs + - Verify recordings occasionally + +### DLCLive Tips + +1. **Model Selection**: + - Use model trained on similar conditions + - Test offline before live use + - Consider resize for speed + - GPU highly recommended + +2. **Performance**: + - Monitor inference FPS + - Check latency values + - Watch queue depth + - Reduce resolution if needed + +3. **Validation**: + - Enable visualization initially + - Verify tracking quality + - Check all keypoints + - Test edge cases + +### General Best Practices + +1. **Configuration Management**: + - Save configurations frequently + - Version control config files + - Document custom settings + - Share team configurations + +2. **Testing**: + - Test setup before experiments + - Run trial recordings + - Verify all components + - Check file outputs + +3. **Troubleshooting**: + - Check status messages + - Monitor performance metrics + - Review error dialogs carefully + - Restart if issues persist + +4. **Data Organization**: + - Consistent naming scheme + - Separate folders per session + - Include metadata files + - Regular data validation + +--- + +## Troubleshooting Guide + +### Camera Issues + +**Problem**: Camera not detected +- **Solution**: Click Refresh, check connections, verify drivers + +**Problem**: Low frame rate +- **Solution**: Reduce resolution, increase exposure, check CPU usage + +**Problem**: Image too dark/bright +- **Solution**: Adjust exposure and gain settings + +### DLCLive Issues + +**Problem**: Model fails to load +- **Solution**: Verify path, check model type, install dependencies + +**Problem**: Slow inference +- **Solution**: Enable GPU, reduce resolution, use resize option + +**Problem**: Poor tracking +- **Solution**: Check lighting, enable visualization, verify model quality + +### Recording Issues + +**Problem**: Dropped frames +- **Solution**: Use GPU encoding, increase CRF, reduce FPS + +**Problem**: Large file sizes +- **Solution**: Increase CRF value, use better codec + +**Problem**: Recording won't start +- **Solution**: Check disk space, verify path permissions + +--- + +## Keyboard Reference + +| Action | Shortcut | +|--------|----------| +| Load configuration | Ctrl+O | +| Save configuration | Ctrl+S | +| Save configuration as | Ctrl+Shift+S | +| Quit application | Ctrl+Q | + +--- + +## Next Steps + +- Explore [Features Documentation](features.md) for detailed capabilities +- Review [Camera Backend Guide](camera_support.md) for advanced setup +- Check [Processor System](PLUGIN_SYSTEM.md) for custom processing +- See [Aravis Backend](aravis_backend.md) for Linux industrial cameras + +--- + +## Getting Help + +If you encounter issues: +1. Check status messages in GUI +2. Review this user guide +3. Consult technical documentation +4. Check GitHub issues +5. Contact support team From ddcbc419350baec4e755b1ca36fb64a768753a04 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Tue, 28 Oct 2025 15:34:36 +0100 Subject: [PATCH 15/69] update dlc_processor, fix filtering --- dlclivegui/processors/dlc_processor_socket.py | 58 ++++++++++++++++--- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index fb6522c..3ab5866 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -1,5 +1,6 @@ import logging import pickle +import socket import time from collections import deque from math import acos, atan2, copysign, degrees, pi, sqrt @@ -7,7 +8,7 @@ from threading import Event, Thread import numpy as np -from dlclive import Processor +from dlclive import Processor # type: ignore LOG = logging.getLogger("dlc_processor_socket") LOG.setLevel(logging.INFO) @@ -42,7 +43,8 @@ def exponential_smoothing(alpha, x, x_prev): def __call__(self, t, x): t_e = t - self.t_prev - + if t_e <= 0: + return x a_d = self.smoothing_factor(t_e, self.d_cutoff) dx = (x - self.x_prev) / t_e dx_hat = self.exponential_smoothing(a_d, dx, self.dx_prev) @@ -223,6 +225,31 @@ def _handle_client_message(self, msg): self._vid_recording.set() LOG.info("Start video recording command received") + elif cmd == "set_filter": + # Handle filter enable/disable (subclasses override if they support filtering) + use_filter = msg.get("use_filter", False) + if hasattr(self, 'use_filter'): + self.use_filter = bool(use_filter) + # Reset filters to reinitialize with new setting + if hasattr(self, 'filters'): + self.filters = None + LOG.info(f"Filtering {'enabled' if use_filter else 'disabled'}") + else: + LOG.warning("set_filter command not supported by this processor") + + elif cmd == "set_filter_params": + # Handle filter parameter updates (subclasses override if they support filtering) + filter_kwargs = msg.get("filter_kwargs", {}) + if hasattr(self, 'filter_kwargs'): + # Update filter parameters + self.filter_kwargs.update(filter_kwargs) + # Reset filters to reinitialize with new parameters + if hasattr(self, 'filters'): + self.filters = None + LOG.info(f"Filter parameters updated: {filter_kwargs}") + else: + LOG.warning("set_filter_params command not supported by this processor") + def _clear_data_queues(self): """Clear all data storage queues. Override in subclasses to clear additional queues.""" self.time_stamp.clear() @@ -286,17 +313,30 @@ def process(self, pose, **kwargs): def stop(self): """Stop the processor and close all connections.""" + LOG.info("Stopping processor...") + + # Signal stop to all threads self._stop.set() - try: - self.listener.close() - except Exception: - pass + + # Close all client connections first for c in list(self.conns): try: c.close() except Exception: pass self.conns.discard(c) + + # Close the listener socket + if hasattr(self, 'listener') and self.listener: + try: + self.listener.close() + except Exception as e: + LOG.debug(f"Error closing listener: {e}") + + # Give the OS time to release the socket on Windows + # This prevents WinError 10048 when restarting + time.sleep(0.1) + LOG.info("Processor stopped, all connections closed") def save(self, file=None): @@ -306,7 +346,7 @@ def save(self, file=None): LOG.info(f"Saving data to {file}") try: save_dict = self.get_data() - pickle.dump(save_dict, open(file, "wb")) + pickle.dump(save_dict, open(file, "wb")) save_code = 1 except Exception as e: LOG.error(f"Save failed: {e}") @@ -383,7 +423,7 @@ def __init__( authkey=b"secret password", use_perf_counter=False, use_filter=False, - filter_kwargs=None, + filter_kwargs={}, save_original=False, ): """ @@ -412,7 +452,7 @@ def __init__( # Filtering self.use_filter = use_filter - self.filter_kwargs = filter_kwargs or {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0} + self.filter_kwargs = filter_kwargs self.filters = None # Will be initialized on first pose def _clear_data_queues(self): From e2ff610eca9e918fb2cd9c32ad93e765bcbb8df2 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Wed, 29 Oct 2025 18:33:31 +0100 Subject: [PATCH 16/69] setting frame resolution --- dlclivegui/cameras/gentl_backend.py | 81 ++++++++++++++++++++++++++-- dlclivegui/cameras/opencv_backend.py | 37 +++++++++++-- 2 files changed, 112 insertions(+), 6 deletions(-) diff --git a/dlclivegui/cameras/gentl_backend.py b/dlclivegui/cameras/gentl_backend.py index 701d4fd..7e81f84 100644 --- a/dlclivegui/cameras/gentl_backend.py +++ b/dlclivegui/cameras/gentl_backend.py @@ -48,6 +48,10 @@ def __init__(self, settings): self._cti_search_paths: Tuple[str, ...] = self._parse_cti_paths( props.get("cti_search_paths") ) + # Parse resolution (width, height) with defaults + self._resolution: Optional[Tuple[int, int]] = self._parse_resolution( + props.get("resolution") + ) self._harvester = None self._acquirer = None @@ -232,6 +236,27 @@ def _parse_crop(self, crop) -> Optional[Tuple[int, int, int, int]]: return tuple(int(v) for v in crop) return None + def _parse_resolution(self, resolution) -> Optional[Tuple[int, int]]: + """Parse resolution setting. + + Args: + resolution: Can be a tuple/list [width, height], or None + + Returns: + Tuple of (width, height) or None if not specified + Default is (720, 540) if parsing fails but value is provided + """ + if resolution is None: + return (720, 540) # Default resolution + + if isinstance(resolution, (list, tuple)) and len(resolution) == 2: + try: + return (int(resolution[0]), int(resolution[1])) + except (ValueError, TypeError): + return (720, 540) + + return (720, 540) + @staticmethod def _search_cti_file(patterns: Tuple[str, ...]) -> Optional[str]: """Search for a CTI file using the given patterns. @@ -318,9 +343,59 @@ def _configure_pixel_format(self, node_map) -> None: pass def _configure_resolution(self, node_map) -> None: - # Don't configure width/height - use camera's native resolution - # Width and height will be determined from actual frames - pass + """Configure camera resolution (width and height).""" + if self._resolution is None: + return + + width, height = self._resolution + + # Try to set width + for width_attr in ("Width", "WidthMax"): + try: + node = getattr(node_map, width_attr) + if width_attr == "Width": + # Get constraints + try: + min_w = node.min + max_w = node.max + inc_w = getattr(node, 'inc', 1) + # Adjust to valid value + width = self._adjust_to_increment(width, min_w, max_w, inc_w) + node.value = int(width) + break + except Exception: + # Try setting without adjustment + try: + node.value = int(width) + break + except Exception: + continue + except AttributeError: + continue + + # Try to set height + for height_attr in ("Height", "HeightMax"): + try: + node = getattr(node_map, height_attr) + if height_attr == "Height": + # Get constraints + try: + min_h = node.min + max_h = node.max + inc_h = getattr(node, 'inc', 1) + # Adjust to valid value + height = self._adjust_to_increment(height, min_h, max_h, inc_h) + node.value = int(height) + break + except Exception: + # Try setting without adjustment + try: + node.value = int(height) + break + except Exception: + continue + except AttributeError: + continue def _configure_exposure(self, node_map) -> None: if self._exposure is None: diff --git a/dlclivegui/cameras/opencv_backend.py b/dlclivegui/cameras/opencv_backend.py index f4ee01a..7face8f 100644 --- a/dlclivegui/cameras/opencv_backend.py +++ b/dlclivegui/cameras/opencv_backend.py @@ -17,6 +17,10 @@ class OpenCVCameraBackend(CameraBackend): def __init__(self, settings): super().__init__(settings) self._capture: cv2.VideoCapture | None = None + # Parse resolution with defaults (720x540) + self._resolution: Tuple[int, int] = self._parse_resolution( + settings.properties.get("resolution") + ) def open(self) -> None: backend_flag = self._resolve_backend(self.settings.properties.get("api")) @@ -77,22 +81,49 @@ def device_name(self) -> str: base_name = backend_name return f"{base_name} camera #{self.settings.index}" + def _parse_resolution(self, resolution) -> Tuple[int, int]: + """Parse resolution setting. + + Args: + resolution: Can be a tuple/list [width, height], or None + + Returns: + Tuple of (width, height), defaults to (720, 540) + """ + if resolution is None: + return (720, 540) # Default resolution + + if isinstance(resolution, (list, tuple)) and len(resolution) == 2: + try: + return (int(resolution[0]), int(resolution[1])) + except (ValueError, TypeError): + return (720, 540) + + return (720, 540) + def _configure_capture(self) -> None: if self._capture is None: return - # Don't set width/height - capture at camera's native resolution - # Only set FPS if specified + + # Set resolution (width x height) + width, height = self._resolution + self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, float(width)) + self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, float(height)) + + # Set FPS if specified if self.settings.fps: self._capture.set(cv2.CAP_PROP_FPS, float(self.settings.fps)) + # Set any additional properties from the properties dict for prop, value in self.settings.properties.items(): - if prop == "api": + if prop in ("api", "resolution"): continue try: prop_id = int(prop) except (TypeError, ValueError): continue self._capture.set(prop_id, float(value)) + # Update actual FPS from camera actual_fps = self._capture.get(cv2.CAP_PROP_FPS) if actual_fps: From 44de72325f5439caf6d4c7a7ee308395b4572fb8 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Tue, 4 Nov 2025 11:13:28 +0100 Subject: [PATCH 17/69] updated to look for model files --- dlclivegui/gui.py | 12 +- dlclivegui/processors/dlc_processor_socket.py | 213 ++++++++++++++++++ docs/features.md | 26 +-- docs/user_guide.md | 2 +- 4 files changed, 233 insertions(+), 20 deletions(-) diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index bd3bee7..efc4b35 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -56,7 +56,7 @@ logging.basicConfig(level=logging.INFO) -PATH2MODELS = "C:\\Users\\User\\Repos\\DeepLabCut-live-GUI\\models" +PATH2MODELS = "C:\\Users\\User\\Repos\\DeepLabCut-live-GUI\\dlc_training\\dlclive" class MainWindow(QMainWindow): @@ -266,7 +266,7 @@ def _build_dlc_group(self) -> QGroupBox: self.model_type_combo = QComboBox() self.model_type_combo.addItem("Base (TensorFlow)", "base") self.model_type_combo.addItem("PyTorch", "pytorch") - self.model_type_combo.setCurrentIndex(0) # Default to base + self.model_type_combo.setCurrentIndex(1) # Default to PyTorch form.addRow("Model type", self.model_type_combo) # Processor selection @@ -729,11 +729,11 @@ def _save_config_to_path(self, path: Path) -> None: self.statusBar().showMessage(f"Saved configuration to {path}", 5000) def _action_browse_model(self) -> None: - directory = QFileDialog.getExistingDirectory( - self, "Select DLCLive model directory", PATH2MODELS + file_path, _ = QFileDialog.getOpenFileName( + self, "Select DLCLive model file", PATH2MODELS, "Model files (*.pt *.pb);;All files (*.*)" ) - if directory: - self.model_path_edit.setText(directory) + if file_path: + self.model_path_edit.setText(file_path) def _action_browse_directory(self) -> None: directory = QFileDialog.getExistingDirectory( diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 3ab5866..9215c5e 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -574,11 +574,224 @@ def get_data(self): save_dict["filter_kwargs"] = self.filter_kwargs return save_dict + + +class MyProcessorTorchmodels_socket(BaseProcessor_socket): + """ + DLC Processor with pose calculations (center, heading, head angle) and optional filtering. + + Calculates: + - center: Weighted average of head keypoints + - heading: Body orientation (degrees) + - head_angle: Head rotation relative to body (radians) + + Broadcasts: [timestamp, center_x, center_y, heading, head_angle] + """ + + # Metadata for GUI discovery + PROCESSOR_NAME = "Mouse Pose with less keypoints" + PROCESSOR_DESCRIPTION = ( + "Calculates mouse center, heading, and head angle with optional One-Euro filtering" + ) + PROCESSOR_PARAMS = { + "bind": { + "type": "tuple", + "default": ("0.0.0.0", 6000), + "description": "Server address (host, port)", + }, + "authkey": { + "type": "bytes", + "default": b"secret password", + "description": "Authentication key for clients", + }, + "use_perf_counter": { + "type": "bool", + "default": False, + "description": "Use time.perf_counter() instead of time.time()", + }, + "use_filter": { + "type": "bool", + "default": False, + "description": "Apply One-Euro filter to calculated values", + }, + "filter_kwargs": { + "type": "dict", + "default": {"min_cutoff": 1.0, "beta": 0.02, "d_cutoff": 1.0}, + "description": "One-Euro filter parameters (min_cutoff, beta, d_cutoff)", + }, + "save_original": { + "type": "bool", + "default": False, + "description": "Save raw pose arrays for analysis", + }, + } + + def __init__( + self, + bind=("0.0.0.0", 6000), + authkey=b"secret password", + use_perf_counter=False, + use_filter=False, + filter_kwargs={}, + save_original=False, + ): + """ + DLC Processor with multi-client broadcasting support. + + Args: + bind: (host, port) tuple for server binding + authkey: Authentication key for client connections + use_perf_counter: If True, use time.perf_counter() instead of time.time() + use_filter: If True, apply One-Euro filter to pose data + filter_kwargs: Dict with OneEuroFilter parameters (min_cutoff, beta, d_cutoff) + save_original: If True, save raw pose arrays + """ + super().__init__( + bind=bind, + authkey=authkey, + use_perf_counter=use_perf_counter, + save_original=save_original, + ) + + # Additional data storage for processed values + self.center_x = deque() + self.center_y = deque() + self.heading_direction = deque() + self.head_angle = deque() + + # Filtering + self.use_filter = use_filter + self.filter_kwargs = filter_kwargs + self.filters = None # Will be initialized on first pose + + def _clear_data_queues(self): + """Clear all data storage queues including pose-specific ones.""" + super()._clear_data_queues() + self.center_x.clear() + self.center_y.clear() + self.heading_direction.clear() + self.head_angle.clear() + + def _initialize_filters(self, vals): + """Initialize One-Euro filters for each output variable.""" + t0 = self.timing_func() + self.filters = { + "center_x": OneEuroFilter(t0, vals[0], **self.filter_kwargs), + "center_y": OneEuroFilter(t0, vals[1], **self.filter_kwargs), + "heading": OneEuroFilter(t0, vals[2], **self.filter_kwargs), + "head_angle": OneEuroFilter(t0, vals[3], **self.filter_kwargs), + } + LOG.debug(f"Initialized One-Euro filters with parameters: {self.filter_kwargs}") + + def process(self, pose, **kwargs): + """ + Process pose: calculate center/heading/head_angle, optionally filter, and broadcast. + + Args: + pose: DLC pose array (N_keypoints x 3) with [x, y, confidence] + **kwargs: Additional metadata (frame_time, pose_time, etc.) + + Returns: + pose: Unmodified pose array + """ + # Save original pose if requested (from base class) + if self.save_original: + self.original_pose.append(pose.copy()) + + # Extract keypoints and confidence + xy = pose[:, :2] + conf = pose[:, 2] + + # Calculate weighted center from head keypoints + head_xy = xy[[0, 1, 2, 3, 5, 6, 7], :] + head_conf = conf[[0, 1, 2, 3, 5, 6, 7]] + center = np.average(head_xy, axis=0, weights=head_conf) + + neck = np.average(xy[[2, 3, 6, 7], :], axis=0, weights=conf[[2, 3, 6, 7]]) + + # Calculate body axis (tail_base -> neck) + body_axis = neck - xy[9] + body_axis /= sqrt(np.sum(body_axis**2)) + + # Calculate head axis (neck -> nose) + head_axis = xy[0] - neck + head_axis /= sqrt(np.sum(head_axis**2)) + + # Calculate head angle relative to body + cross = body_axis[0] * head_axis[1] - head_axis[0] * body_axis[1] + sign = copysign(1, cross) # Positive when looking left + try: + head_angle = acos(body_axis @ head_axis) * sign + except ValueError: + head_angle = 0 + + # Calculate heading (body orientation) + heading = atan2(body_axis[1], body_axis[0]) + heading = degrees(heading) + + # Raw values (heading unwrapped for filtering) + vals = [center[0], center[1], heading, head_angle] + + # Apply filtering if enabled + curr_time = self.timing_func() + if self.use_filter: + if self.filters is None: + self._initialize_filters(vals) + + # Filter each value (heading is filtered in unwrapped space) + filtered_vals = [ + self.filters["center_x"](curr_time, vals[0]), + self.filters["center_y"](curr_time, vals[1]), + self.filters["heading"](curr_time, vals[2]), + self.filters["head_angle"](curr_time, vals[3]), + ] + vals = filtered_vals + + # Wrap heading to [0, 360) after filtering + vals[2] = vals[2] % 360 + + # Update step counter + self.curr_step = self.curr_step + 1 + + # Store processed data (only if recording) + if self.recording: + self.center_x.append(vals[0]) + self.center_y.append(vals[1]) + self.heading_direction.append(vals[2]) + self.head_angle.append(vals[3]) + self.time_stamp.append(curr_time) + self.step.append(self.curr_step) + self.frame_time.append(kwargs.get("frame_time", -1)) + if "pose_time" in kwargs: + self.pose_time.append(kwargs["pose_time"]) + + # Broadcast processed values to all connected clients + payload = [curr_time, vals[0], vals[1], vals[2], vals[3]] + self.broadcast(payload) + + return pose + + def get_data(self): + """Get logged data including base class data and processed values.""" + # Get base class data + save_dict = super().get_data() + + # Add processed values + save_dict["x_pos"] = np.array(self.center_x) + save_dict["y_pos"] = np.array(self.center_y) + save_dict["heading_direction"] = np.array(self.heading_direction) + save_dict["head_angle"] = np.array(self.head_angle) + save_dict["use_filter"] = self.use_filter + save_dict["filter_kwargs"] = self.filter_kwargs + + return save_dict + # Register processors for GUI discovery PROCESSOR_REGISTRY["BaseProcessor_socket"] = BaseProcessor_socket PROCESSOR_REGISTRY["MyProcessor_socket"] = MyProcessor_socket +PROCESSOR_REGISTRY["MyProcessorTorchmodels_socket"] = MyProcessorTorchmodels_socket def get_available_processors(): diff --git a/docs/features.md b/docs/features.md index 5fd535d..1a04068 100644 --- a/docs/features.md +++ b/docs/features.md @@ -84,7 +84,7 @@ The GUI intelligently detects available cameras: Example detection output: ``` -[CameraDetection] Available cameras for backend 'gentl': +[CameraDetection] Available cameras for backend 'gentl': ['0:DMK 37BUX287 (26320523)', '1:Basler acA1920 (40123456)'] ``` @@ -127,7 +127,7 @@ Example detection output: - **Purpose**: Visual ROI definition - **Configuration**: (x0, y0, x1, y1) coordinates - **Color**: Red rectangle overlay -- **Use Cases**: +- **Use Cases**: - Crop region preview - Analysis area marking - Multi-region tracking @@ -310,21 +310,21 @@ Custom pose processors for real-time analysis and control. ```python class MyProcessor: """Custom processor example.""" - + def process(self, pose, timestamp): """Process pose data in real-time. - + Args: pose: numpy array (n_keypoints, 3) - x, y, likelihood timestamp: float - frame timestamp """ # Extract keypoint positions nose_x, nose_y = pose[0, :2] - + # Custom logic if nose_x > 320: self.trigger_event() - + # Return results (optional) return {"position": (nose_x, nose_y)} ``` @@ -353,15 +353,15 @@ class RecordingProcessor: def __init__(self): self._vid_recording = False self.session_name = "default" - + @property def video_recording(self): return self._vid_recording - + def start_recording(self, session): self.session_name = session self._vid_recording = True - + def stop_recording(self): self._vid_recording = False ``` @@ -423,7 +423,7 @@ The GUI monitors `video_recording` property and automatically starts/stops recor #### Button States - **Disabled**: Gray, not clickable - **Enabled**: Default color, clickable -- **Active**: +- **Active**: - Preview running: Stop button enabled - Inference initializing: Blue "Initializing DLCLive!" - Inference ready: Green "DLCLive running!" @@ -447,7 +447,7 @@ The GUI monitors `video_recording` property and automatically starts/stops recor #### DLC Metrics - **Inference FPS**: Poses processed per second -- **Latency**: +- **Latency**: - Last frame latency (ms) - Average latency over session (ms) - **Queue**: Number of frames waiting @@ -535,7 +535,7 @@ class MyBackend(CameraBackend): def open(self): ... def read(self) -> Tuple[np.ndarray, float]: ... def close(self): ... - + @classmethod def get_device_count(cls) -> int: ... ``` @@ -554,7 +554,7 @@ class MyProcessor: def __init__(self, **kwargs): # Initialize pass - + def process(self, pose, timestamp): # Process pose pass diff --git a/docs/user_guide.md b/docs/user_guide.md index 289bfb8..50374c0 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -239,7 +239,7 @@ Check **"Display pose predictions"** to overlay keypoints on video. 3. **Select format**: - **Container**: mp4 (recommended), avi, mov - - **Codec**: + - **Codec**: - `h264_nvenc` (NVIDIA GPU - fastest) - `libx264` (CPU - universal) - `hevc_nvenc` (NVIDIA H.265) From a5e70a654d1444829a2d0bbe79aa44e1a4d80353 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Wed, 5 Nov 2025 14:42:09 +0100 Subject: [PATCH 18/69] dropped tensorflow support via GUI --- dlclivegui/config.py | 2 +- dlclivegui/gui.py | 21 ++------------------- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 126eb13..f78cae8 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -50,7 +50,7 @@ class DLCProcessorSettings: model_path: str = "" additional_options: Dict[str, Any] = field(default_factory=dict) - model_type: Optional[str] = "base" + model_type: str = "pytorch" # Only PyTorch models are supported @dataclass diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index efc4b35..eba2d6c 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -261,13 +261,7 @@ def _build_dlc_group(self) -> QGroupBox: self.browse_model_button = QPushButton("Browse…") self.browse_model_button.clicked.connect(self._action_browse_model) path_layout.addWidget(self.browse_model_button) - form.addRow("Model directory", path_layout) - - self.model_type_combo = QComboBox() - self.model_type_combo.addItem("Base (TensorFlow)", "base") - self.model_type_combo.addItem("PyTorch", "pytorch") - self.model_type_combo.setCurrentIndex(1) # Default to PyTorch - form.addRow("Model type", self.model_type_combo) + form.addRow("Model file", path_layout) # Processor selection processor_path_layout = QHBoxLayout() @@ -481,12 +475,6 @@ def _apply_config(self, config: ApplicationSettings) -> None: dlc = config.dlc self.model_path_edit.setText(dlc.model_path) - # Set model type - model_type = dlc.model_type or "base" - model_type_index = self.model_type_combo.findData(model_type) - if model_type_index >= 0: - self.model_type_combo.setCurrentIndex(model_type_index) - self.additional_options_edit.setPlainText(json.dumps(dlc.additional_options, indent=2)) recording = config.recording @@ -655,13 +643,9 @@ def _parse_json(self, value: str) -> dict: return json.loads(text) def _dlc_settings_from_ui(self) -> DLCProcessorSettings: - model_type = self.model_type_combo.currentData() - if not isinstance(model_type, str): - model_type = "base" - return DLCProcessorSettings( model_path=self.model_path_edit.text().strip(), - model_type=model_type, + model_type="pytorch", additional_options=self._parse_json(self.additional_options_edit.toPlainText()), ) @@ -925,7 +909,6 @@ def _update_dlc_controls_enabled(self) -> None: widgets = [ self.model_path_edit, self.browse_model_button, - self.model_type_combo, self.processor_folder_edit, self.browse_processor_folder_button, self.refresh_processors_button, From 229a8396d27b235a10f477725a880515d35b3d3b Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Thu, 6 Nov 2025 11:51:26 +0100 Subject: [PATCH 19/69] more profiling of the processes --- dlclivegui/dlc_processor.py | 133 +++++++++++++++++++++++++++++++++--- dlclivegui/gui.py | 110 ++++++++++++++++++++++++----- 2 files changed, 219 insertions(+), 24 deletions(-) diff --git a/dlclivegui/dlc_processor.py b/dlclivegui/dlc_processor.py index 80944d8..5ce2061 100644 --- a/dlclivegui/dlc_processor.py +++ b/dlclivegui/dlc_processor.py @@ -17,6 +17,9 @@ LOGGER = logging.getLogger(__name__) +# Enable profiling +ENABLE_PROFILING = True + try: # pragma: no cover - optional dependency from dlclive import DLCLive # type: ignore except Exception: # pragma: no cover - handled gracefully @@ -40,6 +43,14 @@ class ProcessorStats: processing_fps: float = 0.0 average_latency: float = 0.0 last_latency: float = 0.0 + # Profiling metrics + avg_queue_wait: float = 0.0 + avg_inference_time: float = 0.0 + avg_signal_emit_time: float = 0.0 + avg_total_process_time: float = 0.0 + # Separated timing for GPU vs socket processor + avg_gpu_inference_time: float = 0.0 # Pure model inference + avg_processor_overhead: float = 0.0 # Socket processor overhead _SENTINEL = object() @@ -69,6 +80,14 @@ def __init__(self) -> None: self._latencies: deque[float] = deque(maxlen=60) self._processing_times: deque[float] = deque(maxlen=60) self._stats_lock = threading.Lock() + + # Profiling metrics + self._queue_wait_times: deque[float] = deque(maxlen=60) + self._inference_times: deque[float] = deque(maxlen=60) + self._signal_emit_times: deque[float] = deque(maxlen=60) + self._total_process_times: deque[float] = deque(maxlen=60) + self._gpu_inference_times: deque[float] = deque(maxlen=60) + self._processor_overhead_times: deque[float] = deque(maxlen=60) def configure(self, settings: DLCProcessorSettings, processor: Optional[Any] = None) -> None: self._settings = settings @@ -85,6 +104,12 @@ def reset(self) -> None: self._frames_dropped = 0 self._latencies.clear() self._processing_times.clear() + self._queue_wait_times.clear() + self._inference_times.clear() + self._signal_emit_times.clear() + self._total_process_times.clear() + self._gpu_inference_times.clear() + self._processor_overhead_times.clear() def shutdown(self) -> None: self._stop_worker() @@ -128,6 +153,14 @@ def get_stats(self) -> ProcessorStats: ) else: processing_fps = 0.0 + + # Profiling metrics + avg_queue_wait = sum(self._queue_wait_times) / len(self._queue_wait_times) if self._queue_wait_times else 0.0 + avg_inference = sum(self._inference_times) / len(self._inference_times) if self._inference_times else 0.0 + avg_signal_emit = sum(self._signal_emit_times) / len(self._signal_emit_times) if self._signal_emit_times else 0.0 + avg_total = sum(self._total_process_times) / len(self._total_process_times) if self._total_process_times else 0.0 + avg_gpu = sum(self._gpu_inference_times) / len(self._gpu_inference_times) if self._gpu_inference_times else 0.0 + avg_proc_overhead = sum(self._processor_overhead_times) / len(self._processor_overhead_times) if self._processor_overhead_times else 0.0 return ProcessorStats( frames_enqueued=self._frames_enqueued, @@ -137,13 +170,19 @@ def get_stats(self) -> ProcessorStats: processing_fps=processing_fps, average_latency=avg_latency, last_latency=last_latency, + avg_queue_wait=avg_queue_wait, + avg_inference_time=avg_inference, + avg_signal_emit_time=avg_signal_emit, + avg_total_process_time=avg_total, + avg_gpu_inference_time=avg_gpu, + avg_processor_overhead=avg_proc_overhead, ) def _start_worker(self, init_frame: np.ndarray, init_timestamp: float) -> None: if self._worker_thread is not None and self._worker_thread.is_alive(): return - self._queue = queue.Queue(maxsize=2) + self._queue = queue.Queue(maxsize=1) self._stop_event.clear() self._worker_thread = threading.Thread( target=self._worker_loop, @@ -179,31 +218,48 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: if not self._settings.model_path: raise RuntimeError("No DLCLive model path configured.") + init_start = time.perf_counter() options = { "model_path": self._settings.model_path, "model_type": self._settings.model_type, "processor": self._processor, "dynamic": [False, 0.5, 10], "resize": 1.0, + "precision": "FP32", } # todo expose more parameters from settings self._dlc = DLCLive(**options) + + init_inference_start = time.perf_counter() self._dlc.init_inference(init_frame) + init_inference_time = time.perf_counter() - init_inference_start + self._initialized = True self.initialized.emit(True) - LOGGER.info("DLCLive model initialized successfully") + + total_init_time = time.perf_counter() - init_start + LOGGER.info(f"DLCLive model initialized successfully (total: {total_init_time:.3f}s, init_inference: {init_inference_time:.3f}s)") # Process the initialization frame enqueue_time = time.perf_counter() + + inference_start = time.perf_counter() pose = self._dlc.get_pose(init_frame, frame_time=init_timestamp) + inference_time = time.perf_counter() - inference_start + + signal_start = time.perf_counter() + self.pose_ready.emit(PoseResult(pose=pose, timestamp=init_timestamp)) + signal_time = time.perf_counter() - signal_start + process_time = time.perf_counter() with self._stats_lock: self._frames_enqueued += 1 self._frames_processed += 1 self._processing_times.append(process_time) - - self.pose_ready.emit(PoseResult(pose=pose, timestamp=init_timestamp)) + if ENABLE_PROFILING: + self._inference_times.append(inference_time) + self._signal_emit_times.append(signal_time) except Exception as exc: LOGGER.exception("Failed to initialize DLCLive", exc_info=exc) @@ -212,29 +268,90 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: return # Main processing loop + frame_count = 0 while not self._stop_event.is_set(): + loop_start = time.perf_counter() + + # Time spent waiting for queue + queue_wait_start = time.perf_counter() try: item = self._queue.get(timeout=0.1) except queue.Empty: continue + queue_wait_time = time.perf_counter() - queue_wait_start if item is _SENTINEL: break frame, timestamp, enqueue_time = item + try: - start_process = time.perf_counter() + # Time the inference - we need to separate GPU from processor overhead + # If processor exists, wrap its process method to time it separately + processor_overhead_time = 0.0 + gpu_inference_time = 0.0 + + if self._processor is not None: + # Wrap processor.process() to time it + original_process = self._processor.process + processor_time_holder = [0.0] # Use list to allow modification in nested scope + + def timed_process(pose, **kwargs): + proc_start = time.perf_counter() + result = original_process(pose, **kwargs) + processor_time_holder[0] = time.perf_counter() - proc_start + return result + + self._processor.process = timed_process + + inference_start = time.perf_counter() pose = self._dlc.get_pose(frame, frame_time=timestamp) + inference_time = time.perf_counter() - inference_start + + if self._processor is not None: + # Restore original process method + self._processor.process = original_process + processor_overhead_time = processor_time_holder[0] + gpu_inference_time = inference_time - processor_overhead_time + else: + # No processor, all time is GPU inference + gpu_inference_time = inference_time + + # Time the signal emission + signal_start = time.perf_counter() + self.pose_ready.emit(PoseResult(pose=pose, timestamp=timestamp)) + signal_time = time.perf_counter() - signal_start + end_process = time.perf_counter() - + total_process_time = end_process - loop_start latency = end_process - enqueue_time with self._stats_lock: self._frames_processed += 1 self._latencies.append(latency) self._processing_times.append(end_process) - - self.pose_ready.emit(PoseResult(pose=pose, timestamp=timestamp)) + + if ENABLE_PROFILING: + self._queue_wait_times.append(queue_wait_time) + self._inference_times.append(inference_time) + self._signal_emit_times.append(signal_time) + self._total_process_times.append(total_process_time) + self._gpu_inference_times.append(gpu_inference_time) + self._processor_overhead_times.append(processor_overhead_time) + + # Log profiling every 100 frames + frame_count += 1 + if ENABLE_PROFILING and frame_count % 100 == 0: + LOGGER.info( + f"[Profile] Frame {frame_count}: " + f"queue_wait={queue_wait_time*1000:.2f}ms, " + f"inference={inference_time*1000:.2f}ms " + f"(GPU={gpu_inference_time*1000:.2f}ms, processor={processor_overhead_time*1000:.2f}ms), " + f"signal_emit={signal_time*1000:.2f}ms, " + f"total={total_process_time*1000:.2f}ms, " + f"latency={latency*1000:.2f}ms" + ) + except Exception as exc: LOGGER.exception("Pose inference failed", exc_info=exc) self.error.emit(str(exc)) diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index eba2d6c..bb5dd1e 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -65,8 +65,26 @@ class MainWindow(QMainWindow): def __init__(self, config: Optional[ApplicationSettings] = None): super().__init__() self.setWindowTitle("DeepLabCut Live GUI") - self._config = config or DEFAULT_CONFIG - self._config_path: Optional[Path] = None + + # Try to load myconfig.json from the application directory if no config provided + if config is None: + myconfig_path = Path(__file__).parent.parent / "myconfig.json" + if myconfig_path.exists(): + try: + config = ApplicationSettings.load(str(myconfig_path)) + self._config_path = myconfig_path + logging.info(f"Loaded configuration from {myconfig_path}") + except Exception as exc: + logging.warning(f"Failed to load myconfig.json: {exc}. Using default config.") + config = DEFAULT_CONFIG + self._config_path = None + else: + config = DEFAULT_CONFIG + self._config_path = None + else: + self._config_path = None + + self._config = config self._current_frame: Optional[np.ndarray] = None self._raw_frame: Optional[np.ndarray] = None self._last_pose: Optional[PoseResult] = None @@ -105,17 +123,67 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._metrics_timer.timeout.connect(self._update_metrics) self._metrics_timer.start() self._update_metrics() + + # Show status message if myconfig.json was loaded + if self._config_path and self._config_path.name == "myconfig.json": + self.statusBar().showMessage(f"Auto-loaded configuration from {self._config_path}", 5000) # ------------------------------------------------------------------ UI def _setup_ui(self) -> None: central = QWidget() layout = QHBoxLayout(central) + # Video panel with display and performance stats + video_panel = QWidget() + video_layout = QVBoxLayout(video_panel) + video_layout.setContentsMargins(0, 0, 0, 0) + # Video display widget self.video_label = QLabel("Camera preview not started") self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.video_label.setMinimumSize(640, 360) self.video_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + video_layout.addWidget(self.video_label) + + # Stats panel below video with clear labels + stats_widget = QWidget() + stats_widget.setStyleSheet("padding: 5px;") + stats_widget.setMinimumWidth(800) # Prevent excessive line breaks + stats_layout = QVBoxLayout(stats_widget) + stats_layout.setContentsMargins(5, 5, 5, 5) + stats_layout.setSpacing(3) + + # Camera throughput stats + camera_stats_container = QHBoxLayout() + camera_stats_label_title = QLabel("Camera:") + camera_stats_container.addWidget(camera_stats_label_title) + self.camera_stats_label = QLabel("Camera idle") + self.camera_stats_label.setWordWrap(True) + camera_stats_container.addWidget(self.camera_stats_label) + camera_stats_container.addStretch(1) + stats_layout.addLayout(camera_stats_container) + + # DLC processor stats + dlc_stats_container = QHBoxLayout() + dlc_stats_label_title = QLabel("DLC Processor:") + dlc_stats_container.addWidget(dlc_stats_label_title) + self.dlc_stats_label = QLabel("DLC processor idle") + self.dlc_stats_label.setWordWrap(True) + dlc_stats_container.addWidget(self.dlc_stats_label) + dlc_stats_container.addStretch(1) + stats_layout.addLayout(dlc_stats_container) + + # Video recorder stats + recorder_stats_container = QHBoxLayout() + recorder_stats_label_title = QLabel("Recorder:") + recorder_stats_container.addWidget(recorder_stats_label_title) + self.recording_stats_label = QLabel("Recorder idle") + self.recording_stats_label.setWordWrap(True) + recorder_stats_container.addWidget(self.recording_stats_label) + recorder_stats_container.addStretch(1) + stats_layout.addLayout(recorder_stats_container) + + video_layout.addWidget(stats_widget) # Controls panel with fixed width to prevent shifting controls_widget = QWidget() @@ -142,9 +210,9 @@ def _setup_ui(self) -> None: controls_layout.addWidget(button_bar_widget) controls_layout.addStretch(1) - # Add controls and video to main layout + # Add controls and video panel to main layout layout.addWidget(controls_widget, stretch=0) - layout.addWidget(self.video_label, stretch=1) + layout.addWidget(video_panel, stretch=1) self.setCentralWidget(central) self.setStatusBar(QStatusBar()) @@ -245,9 +313,6 @@ def _build_camera_group(self) -> QGroupBox: self.rotation_combo.addItem("270°", 270) form.addRow("Rotation", self.rotation_combo) - self.camera_stats_label = QLabel("Camera idle") - form.addRow("Throughput", self.camera_stats_label) - return group def _build_dlc_group(self) -> QGroupBox: @@ -316,10 +381,6 @@ def _build_dlc_group(self) -> QGroupBox: self.processor_status_label.setWordWrap(True) form.addRow("Processor Status", self.processor_status_label) - self.dlc_stats_label = QLabel("DLC processor idle") - self.dlc_stats_label.setWordWrap(True) - form.addRow("Performance", self.dlc_stats_label) - return group def _build_recording_group(self) -> QGroupBox: @@ -365,10 +426,6 @@ def _build_recording_group(self) -> QGroupBox: buttons.addWidget(self.stop_record_button) form.addRow(recording_button_widget) - self.recording_stats_label = QLabel(self._last_recorder_summary) - self.recording_stats_label.setWordWrap(True) - form.addRow("Performance", self.recording_stats_label) - return group def _build_bbox_group(self) -> QGroupBox: @@ -989,10 +1046,31 @@ def _format_dlc_stats(self, stats: ProcessorStats) -> str: enqueue = stats.frames_enqueued processed = stats.frames_processed dropped = stats.frames_dropped + + # Add profiling info if available + profile_info = "" + if stats.avg_inference_time > 0: + inf_ms = stats.avg_inference_time * 1000.0 + queue_ms = stats.avg_queue_wait * 1000.0 + signal_ms = stats.avg_signal_emit_time * 1000.0 + total_ms = stats.avg_total_process_time * 1000.0 + + # Add GPU vs processor breakdown if available + gpu_breakdown = "" + if stats.avg_gpu_inference_time > 0 or stats.avg_processor_overhead > 0: + gpu_ms = stats.avg_gpu_inference_time * 1000.0 + proc_ms = stats.avg_processor_overhead * 1000.0 + gpu_breakdown = f" (GPU:{gpu_ms:.1f}ms+proc:{proc_ms:.1f}ms)" + + profile_info = ( + f"\n[Profile] inf:{inf_ms:.1f}ms{gpu_breakdown} queue:{queue_ms:.1f}ms " + f"signal:{signal_ms:.1f}ms total:{total_ms:.1f}ms" + ) + return ( f"{processed}/{enqueue} frames | inference {processing_fps:.1f} fps | " f"latency {latency_ms:.1f} ms (avg {avg_ms:.1f} ms) | " - f"queue {stats.queue_size} | dropped {dropped}" + f"queue {stats.queue_size} | dropped {dropped}{profile_info}" ) def _update_metrics(self) -> None: From 448bdc5cd187edb74ee9bc59f9e481a43dbc54f1 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Tue, 18 Nov 2025 13:45:35 +0100 Subject: [PATCH 20/69] Add visualization settings and update camera backend for improved pose display --- dlclivegui/cameras/gentl_backend.py | 2 +- dlclivegui/config.py | 20 ++- dlclivegui/gui.py | 116 +++++++++++++++--- dlclivegui/processors/dlc_processor_socket.py | 16 ++- pyproject.toml | 112 +++++++++++++++++ 5 files changed, 248 insertions(+), 18 deletions(-) create mode 100644 pyproject.toml diff --git a/dlclivegui/cameras/gentl_backend.py b/dlclivegui/cameras/gentl_backend.py index 7e81f84..89b7dd5 100644 --- a/dlclivegui/cameras/gentl_backend.py +++ b/dlclivegui/cameras/gentl_backend.py @@ -13,7 +13,7 @@ from .base import CameraBackend try: # pragma: no cover - optional dependency - from harvesters.core import Harvester + from harvesters.core import Harvester # type: ignore try: from harvesters.core import HarvesterTimeoutError # type: ignore diff --git a/dlclivegui/config.py b/dlclivegui/config.py index f78cae8..c3a49c3 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -64,6 +64,21 @@ class BoundingBoxSettings: y1: int = 100 +@dataclass +class VisualizationSettings: + """Configuration for pose visualization.""" + + p_cutoff: float = 0.6 # Confidence threshold for displaying keypoints + colormap: str = "hot" # Matplotlib colormap for keypoints + bbox_color: tuple[int, int, int] = (0, 0, 255) # BGR color for bounding box (default: red) + + def get_bbox_color_bgr(self) -> tuple[int, int, int]: + """Get bounding box color in BGR format.""" + if isinstance(self.bbox_color, (list, tuple)) and len(self.bbox_color) == 3: + return tuple(int(c) for c in self.bbox_color) + return (0, 0, 255) # Default to red + + @dataclass class RecordingSettings: """Configuration for video recording.""" @@ -108,6 +123,7 @@ class ApplicationSettings: dlc: DLCProcessorSettings = field(default_factory=DLCProcessorSettings) recording: RecordingSettings = field(default_factory=RecordingSettings) bbox: BoundingBoxSettings = field(default_factory=BoundingBoxSettings) + visualization: VisualizationSettings = field(default_factory=VisualizationSettings) @classmethod def from_dict(cls, data: Dict[str, Any]) -> "ApplicationSettings": @@ -123,7 +139,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "ApplicationSettings": recording_data.pop("options", None) recording = RecordingSettings(**recording_data) bbox = BoundingBoxSettings(**data.get("bbox", {})) - return cls(camera=camera, dlc=dlc, recording=recording, bbox=bbox) + visualization = VisualizationSettings(**data.get("visualization", {})) + return cls(camera=camera, dlc=dlc, recording=recording, bbox=bbox, visualization=visualization) def to_dict(self) -> Dict[str, Any]: """Serialise the configuration to a dictionary.""" @@ -133,6 +150,7 @@ def to_dict(self) -> Dict[str, Any]: "dlc": asdict(self.dlc), "recording": asdict(self.recording), "bbox": asdict(self.bbox), + "visualization": asdict(self.visualization), } @classmethod diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index bb5dd1e..d1b9cf0 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -12,6 +12,7 @@ from typing import Optional import cv2 +import matplotlib.pyplot as plt import numpy as np from PyQt6.QtCore import Qt, QTimer from PyQt6.QtGui import QAction, QCloseEvent, QImage, QPixmap @@ -47,12 +48,13 @@ CameraSettings, DLCProcessorSettings, RecordingSettings, + VisualizationSettings, ) from dlclivegui.dlc_processor import DLCLiveProcessor, PoseResult, ProcessorStats from dlclivegui.processors.processor_utils import instantiate_from_scan, scan_processor_folder from dlclivegui.video_recorder import RecorderStats, VideoRecorder -os.environ["CUDA_VISIBLE_DEVICES"] = "0" +os.environ["CUDA_VISIBLE_DEVICES"] = "1" logging.basicConfig(level=logging.INFO) @@ -108,6 +110,11 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._bbox_x1 = 0 self._bbox_y1 = 0 self._bbox_enabled = False + + # Visualization settings (will be updated from config) + self._p_cutoff = 0.6 + self._colormap = "hot" + self._bbox_color = (0, 0, 255) # BGR: red self.camera_controller = CameraController() self.dlc_processor = DLCLiveProcessor() @@ -553,6 +560,12 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.bbox_y0_spin.setValue(bbox.y0) self.bbox_x1_spin.setValue(bbox.x1) self.bbox_y1_spin.setValue(bbox.y1) + + # Set visualization settings from config + viz = config.visualization + self._p_cutoff = viz.p_cutoff + self._colormap = viz.colormap + self._bbox_color = viz.get_bbox_color_bgr() def _current_config(self) -> ApplicationSettings: return ApplicationSettings( @@ -560,6 +573,7 @@ def _current_config(self) -> ApplicationSettings: dlc=self._dlc_settings_from_ui(), recording=self._recording_settings_from_ui(), bbox=self._bbox_settings_from_ui(), + visualization=self._visualization_settings_from_ui(), ) def _camera_settings_from_ui(self) -> CameraSettings: @@ -725,6 +739,13 @@ def _bbox_settings_from_ui(self) -> BoundingBoxSettings: y1=self.bbox_y1_spin.value(), ) + def _visualization_settings_from_ui(self) -> VisualizationSettings: + return VisualizationSettings( + p_cutoff=self._p_cutoff, + colormap=self._colormap, + bbox_color=self._bbox_color, + ) + # ------------------------------------------------------------------ actions def _action_load_config(self) -> None: file_name, _ = QFileDialog.getOpenFileName( @@ -1215,31 +1236,60 @@ def _stop_inference(self, show_message: bool = True) -> None: # ------------------------------------------------------------------ recording def _start_recording(self) -> None: + # If recorder already running, nothing to do if self._video_recorder and self._video_recorder.is_running: return + + # If camera not running, start it automatically so frames will arrive. + # This allows starting recording without the user manually pressing "Start Preview". if not self.camera_controller.is_running(): - self._show_error("Start the camera preview before recording.") - return - if self._current_frame is None: - self._show_error("Wait for the first preview frame before recording.") - return + try: + settings = self._camera_settings_from_ui() + except ValueError as exc: + self._show_error(str(exc)) + return + # Store active settings and start camera preview in background + self._active_camera_settings = settings + self.camera_controller.start(settings) + self.preview_button.setEnabled(False) + self.stop_preview_button.setEnabled(True) + self._current_frame = None + self._raw_frame = None + self._last_pose = None + self._dlc_active = False + self._camera_frame_times.clear() + self._last_display_time = 0.0 + if hasattr(self, "camera_stats_label"): + self.camera_stats_label.setText("Camera starting…") + self.statusBar().showMessage("Starting camera preview…", 3000) + self._update_inference_buttons() + self._update_camera_controls_enabled() recording = self._recording_settings_from_ui() if not recording.enabled: self._show_error("Recording is disabled in the configuration.") return + + # If we already have a current frame, use its shape to set the recorder stream. + # Otherwise start the recorder without a fixed frame_size and configure it + # once the first frame arrives (see _on_frame_ready). frame = self._current_frame - assert frame is not None - height, width = frame.shape[:2] + if frame is not None: + height, width = frame.shape[:2] + frame_size = (height, width) + else: + frame_size = None + frame_rate = ( self._active_camera_settings.fps if self._active_camera_settings is not None else self.camera_fps.value() ) + output_path = recording.output_path() self._video_recorder = VideoRecorder( output_path, - frame_size=(height, width), # Use numpy convention: (height, width) - frame_rate=float(frame_rate), + frame_size=frame_size, # None allowed; will be configured on first frame + frame_rate=float(frame_rate) if frame_rate is not None else None, codec=recording.codec, crf=recording.crf, ) @@ -1295,7 +1345,30 @@ def _on_frame_ready(self, frame_data: FrameData) -> None: frame = np.ascontiguousarray(frame) self._current_frame = frame self._track_camera_frame() + # If recorder is running but was started without a fixed frame_size, configure + # the stream now that we know the actual frame dimensions. if self._video_recorder and self._video_recorder.is_running: + # Configure stream if recorder was started without a frame_size + try: + current_frame_size = getattr(self._video_recorder, "_frame_size", None) + except Exception: + current_frame_size = None + if current_frame_size is None: + try: + fps_value = ( + self._active_camera_settings.fps + if self._active_camera_settings is not None + else self.camera_fps.value() + ) + except Exception: + fps_value = None + h, w = frame.shape[:2] + try: + # configure_stream expects (height, width) + self._video_recorder.configure_stream((h, w), float(fps_value) if fps_value is not None else None) + except Exception: + # Non-fatal: continue and attempt to write anyway + pass try: success = self._video_recorder.write(frame, timestamp=frame_data.timestamp) if not success: @@ -1421,20 +1494,35 @@ def _draw_bbox(self, frame: np.ndarray) -> np.ndarray: x1 = max(x0 + 1, min(x1, width)) y1 = max(y0 + 1, min(y1, height)) - # Draw red rectangle (BGR format: red is (0, 0, 255)) - cv2.rectangle(overlay, (x0, y0), (x1, y1), (0, 0, 255), 2) + # Draw rectangle with configured color + cv2.rectangle(overlay, (x0, y0), (x1, y1), self._bbox_color, 2) return overlay def _draw_pose(self, frame: np.ndarray, pose: np.ndarray) -> np.ndarray: overlay = frame.copy() - for keypoint in np.asarray(pose): + + # Get the colormap from config + cmap = plt.get_cmap(self._colormap) + num_keypoints = len(np.asarray(pose)) + + for idx, keypoint in enumerate(np.asarray(pose)): if len(keypoint) < 2: continue x, y = keypoint[:2] + confidence = keypoint[2] if len(keypoint) > 2 else 1.0 if np.isnan(x) or np.isnan(y): continue - cv2.circle(overlay, (int(x), int(y)), 4, (0, 255, 0), -1) + if confidence < self._p_cutoff: + continue + + # Get color from colormap (cycle through 0 to 1) + color_normalized = idx / max(num_keypoints - 1, 1) + rgba = cmap(color_normalized) + # Convert from RGBA [0, 1] to BGR [0, 255] for OpenCV + bgr_color = (int(rgba[2] * 255), int(rgba[1] * 255), int(rgba[0] * 255)) + + cv2.circle(overlay, (int(x), int(y)), 4, bgr_color, -1) return overlay def _on_dlc_initialised(self, success: bool) -> None: diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 9215c5e..8caf31f 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -6,6 +6,7 @@ from math import acos, atan2, copysign, degrees, pi, sqrt from multiprocessing.connection import Listener from threading import Event, Thread +from pathlib import Path import numpy as np from dlclive import Processor # type: ignore @@ -346,7 +347,9 @@ def save(self, file=None): LOG.info(f"Saving data to {file}") try: save_dict = self.get_data() - pickle.dump(save_dict, open(file, "wb")) + path2save = Path(__file__).parent.parent.parent / "data" / file + LOG.info(f"Path should be {path2save}") + pickle.dump(file, open(path2save, "wb")) save_code = 1 except Exception as e: LOG.error(f"Save failed: {e}") @@ -634,6 +637,7 @@ def __init__( use_filter=False, filter_kwargs={}, save_original=False, + p_cutoff=0.4, ): """ DLC Processor with multi-client broadcasting support. @@ -659,6 +663,8 @@ def __init__( self.heading_direction = deque() self.head_angle = deque() + self.p_cutoff = p_cutoff + # Filtering self.use_filter = use_filter self.filter_kwargs = filter_kwargs @@ -705,7 +711,13 @@ def process(self, pose, **kwargs): # Calculate weighted center from head keypoints head_xy = xy[[0, 1, 2, 3, 5, 6, 7], :] head_conf = conf[[0, 1, 2, 3, 5, 6, 7]] - center = np.average(head_xy, axis=0, weights=head_conf) + # set low confidence keypoints to zero weight + head_conf = np.where(head_conf < self.p_cutoff, 0, head_conf) + try: + center = np.average(head_xy, axis=0, weights=head_conf) + except ZeroDivisionError: + # If all keypoints have zero weight, return without processing + return pose neck = np.average(xy[[2, 3, 6, 7], :], axis=0, weights=conf[[2, 3, 6, 7]]) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..edbb9d0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,112 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "deeplabcut-live-gui" +version = "2.0" +description = "PyQt-based GUI to run real time DeepLabCut experiments" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "GNU Lesser General Public License v3 (LGPLv3)"} +authors = [ + {name = "A. & M. Mathis Labs", email = "adim@deeplabcut.org"} +] +keywords = ["deeplabcut", "pose estimation", "real-time", "gui", "deep learning"] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", + "Operating System :: OS Independent", + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] + +dependencies = [ + "deeplabcut-live", + "PyQt6", + "numpy", + "opencv-python", + "vidgear[core]", + "matplotlib", +] + +[project.optional-dependencies] +basler = ["pypylon"] +gentl = ["harvesters"] +all = ["pypylon", "harvesters"] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "pytest-mock>=3.10", + "pytest-qt>=4.2", + "black>=23.0", + "flake8>=6.0", + "mypy>=1.0", +] +test = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "pytest-mock>=3.10", + "pytest-qt>=4.2", +] + +[project.urls] +Homepage = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" +Repository = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" +Documentation = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" +"Bug Tracker" = "https://github.com/DeepLabCut/DeepLabCut-live-GUI/issues" + +[project.scripts] +dlclivegui = "dlclivegui.gui:main" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["."] +include = ["dlclivegui*"] +exclude = ["tests*", "docs*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--strict-markers", + "--tb=short", + "--cov=dlclivegui", + "--cov-report=term-missing", + "--cov-report=html", +] +markers = [ + "unit: Unit tests for individual components", + "integration: Integration tests for component interaction", + "functional: Functional tests for end-to-end workflows", + "slow: Tests that take a long time to run", + "gui: Tests that require GUI interaction", +] + +[tool.coverage.run] +source = ["dlclivegui"] +omit = [ + "*/tests/*", + "*/__pycache__/*", + "*/site-packages/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "@abstract", +] From 246452f5484f8eb3f6c63874ace1b5a77eac849a Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Fri, 5 Dec 2025 12:01:17 +0100 Subject: [PATCH 21/69] small bug fixes --- dlclivegui/processors/GUI_INTEGRATION.md | 167 ------------------ dlclivegui/processors/dlc_processor_socket.py | 2 +- dlclivegui/video_recorder.py | 16 +- docs/install.md | 25 --- 4 files changed, 9 insertions(+), 201 deletions(-) delete mode 100644 dlclivegui/processors/GUI_INTEGRATION.md delete mode 100644 docs/install.md diff --git a/dlclivegui/processors/GUI_INTEGRATION.md b/dlclivegui/processors/GUI_INTEGRATION.md deleted file mode 100644 index 6e504f1..0000000 --- a/dlclivegui/processors/GUI_INTEGRATION.md +++ /dev/null @@ -1,167 +0,0 @@ -# GUI Integration Guide - -## Quick Answer - -Here's how to use `scan_processor_folder` in your GUI: - -```python -from example_gui_usage import scan_processor_folder, instantiate_from_scan - -# 1. Scan folder -all_processors = scan_processor_folder("./processors") - -# 2. Populate dropdown with keys (for backend) and display names (for user) -for key, info in all_processors.items(): - # key = "file.py::ClassName" (use this for instantiation) - # display_name = "Human Name (file.py)" (show this to user) - display_name = f"{info['name']} ({info['file']})" - dropdown.add_item(key, display_name) - -# 3. When user selects, get the key from dropdown -selected_key = dropdown.get_selected_value() # e.g., "dlc_processor_socket.py::MyProcessor_socket" - -# 4. Get processor info -processor_info = all_processors[selected_key] - -# 5. Build parameter form from processor_info['params'] -for param_name, param_info in processor_info['params'].items(): - add_input_field(param_name, param_info['type'], param_info['default']) - -# 6. When user clicks Create, instantiate using the key -user_params = get_form_values() -processor = instantiate_from_scan(all_processors, selected_key, **user_params) -``` - -## The Key Insight - -**The key returned by `scan_processor_folder` is what you use to instantiate!** - -```python -# OLD problem: "I have a name, how do I load it?" -# NEW solution: Use the key directly - -all_processors = scan_processor_folder(folder) -# Returns: {"file.py::ClassName": {processor_info}, ...} - -# The KEY "file.py::ClassName" uniquely identifies the processor -# Pass this key to instantiate_from_scan() - -processor = instantiate_from_scan(all_processors, "file.py::ClassName", **params) -``` - -## What's in the returned dict? - -```python -all_processors = { - "dlc_processor_socket.py::MyProcessor_socket": { - "class": , # The actual class - "name": "Mouse Pose Processor", # Human-readable name - "description": "Calculates mouse...", # Description - "params": { # All parameters - "bind": { - "type": "tuple", - "default": ("0.0.0.0", 6000), - "description": "Server address" - }, - # ... more parameters - }, - "file": "dlc_processor_socket.py", # Source file - "class_name": "MyProcessor_socket", # Class name - "file_path": "/full/path/to/file.py" # Full path - } -} -``` - -## GUI Workflow - -### Step 1: Scan Folder -```python -all_processors = scan_processor_folder("./processors") -``` - -### Step 2: Populate Dropdown -```python -# Store keys in order (for mapping dropdown index -> key) -self.processor_keys = list(all_processors.keys()) - -# Create display names for dropdown -display_names = [ - f"{info['name']} ({info['file']})" - for info in all_processors.values() -] -dropdown.set_items(display_names) -``` - -### Step 3: User Selects Processor -```python -def on_processor_selected(dropdown_index): - # Get the key - key = self.processor_keys[dropdown_index] - - # Get processor info - info = all_processors[key] - - # Show description - description_label.text = info['description'] - - # Build parameter form - for param_name, param_info in info['params'].items(): - add_parameter_field( - name=param_name, - type=param_info['type'], - default=param_info['default'], - help_text=param_info['description'] - ) -``` - -### Step 4: User Clicks Create -```python -def on_create_clicked(): - # Get selected key - key = self.processor_keys[dropdown.current_index] - - # Get user's parameter values - user_params = parameter_form.get_values() - - # Instantiate using the key! - self.processor = instantiate_from_scan( - all_processors, - key, - **user_params - ) - - print(f"Created: {self.processor.__class__.__name__}") -``` - -## Why This Works - -1. **Unique Keys**: `"file.py::ClassName"` format ensures uniqueness even if multiple files have same class name - -2. **All Info Included**: Each dict entry has everything needed (class, metadata, parameters) - -3. **Simple Lookup**: Just use the key to get processor info or instantiate - -4. **No Manual Imports**: `scan_processor_folder` handles all module loading - -5. **Type Safety**: Parameter metadata includes types for validation - -## Complete Example - -See `processor_gui.py` for a full working tkinter GUI that demonstrates: -- Folder scanning -- Processor selection -- Parameter form generation -- Instantiation - -Run it with: -```bash -python processor_gui.py -``` - -## Files - -- `dlc_processor_socket.py` - Processors with metadata and registry -- `example_gui_usage.py` - Scanning and instantiation functions + examples -- `processor_gui.py` - Full tkinter GUI -- `GUI_USAGE_GUIDE.py` - Pseudocode and examples -- `README.md` - Documentation on the plugin system diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 8caf31f..73a5cb4 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -349,7 +349,7 @@ def save(self, file=None): save_dict = self.get_data() path2save = Path(__file__).parent.parent.parent / "data" / file LOG.info(f"Path should be {path2save}") - pickle.dump(file, open(path2save, "wb")) + pickle.dump(save_dict, open(path2save, "wb")) save_code = 1 except Exception as e: LOG.error(f"Save failed: {e}") diff --git a/dlclivegui/video_recorder.py b/dlclivegui/video_recorder.py index a40ed28..47cfff4 100644 --- a/dlclivegui/video_recorder.py +++ b/dlclivegui/video_recorder.py @@ -27,14 +27,14 @@ class RecorderStats: """Snapshot of recorder throughput metrics.""" - frames_enqueued: int - frames_written: int - dropped_frames: int - queue_size: int - average_latency: float - last_latency: float - write_fps: float - buffer_seconds: float + frames_enqueued: int = 0 + frames_written: int = 0 + dropped_frames: int = 0 + queue_size: int = 0 + average_latency: float = 0.0 + last_latency: float = 0.0 + write_fps: float = 0.0 + buffer_seconds: float = 0.0 _SENTINEL = object() diff --git a/docs/install.md b/docs/install.md deleted file mode 100644 index 24ef4f4..0000000 --- a/docs/install.md +++ /dev/null @@ -1,25 +0,0 @@ -## Installation Instructions - -### Windows or Linux Desktop - -We recommend that you install DeepLabCut-live in a conda environment. First, please install Anaconda: -- [Windows](https://docs.anaconda.com/anaconda/install/windows/) -- [Linux](https://docs.anaconda.com/anaconda/install/linux/) - -Create a conda environment with python 3.7 and tensorflow: -``` -conda create -n dlc-live python=3.7 tensorflow-gpu==1.13.1 # if using GPU -conda create -n dlc-live python=3.7 tensorflow==1.13.1 # if not using GPU -``` - -Activate the conda environment and install the DeepLabCut-live package: -``` -conda activate dlc-live -pip install deeplabcut-live-gui -``` - -### NVIDIA Jetson Development Kit - -First, please refer to our complete instructions for [installing DeepLabCut-Live! on a NVIDIA Jetson Development Kit](https://github.com/DeepLabCut/DeepLabCut-live/blob/master/docs/install_jetson.md). - -Next, install the DeepLabCut-live-GUI: `pip install deeplabcut-live-gui`. From 103399c0a98631d8d96e44948cdd9e45608b96bf Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Fri, 5 Dec 2025 13:36:34 +0100 Subject: [PATCH 22/69] black formatting --- dlclivegui/cameras/gentl_backend.py | 20 ++--- dlclivegui/cameras/opencv_backend.py | 16 ++-- dlclivegui/config.py | 4 +- dlclivegui/dlc_processor.py | 78 ++++++++++++------- dlclivegui/gui.py | 51 ++++++------ dlclivegui/processors/dlc_processor_socket.py | 29 ++++--- 6 files changed, 116 insertions(+), 82 deletions(-) diff --git a/dlclivegui/cameras/gentl_backend.py b/dlclivegui/cameras/gentl_backend.py index 89b7dd5..cdc798e 100644 --- a/dlclivegui/cameras/gentl_backend.py +++ b/dlclivegui/cameras/gentl_backend.py @@ -13,7 +13,7 @@ from .base import CameraBackend try: # pragma: no cover - optional dependency - from harvesters.core import Harvester # type: ignore + from harvesters.core import Harvester # type: ignore try: from harvesters.core import HarvesterTimeoutError # type: ignore @@ -238,23 +238,23 @@ def _parse_crop(self, crop) -> Optional[Tuple[int, int, int, int]]: def _parse_resolution(self, resolution) -> Optional[Tuple[int, int]]: """Parse resolution setting. - + Args: resolution: Can be a tuple/list [width, height], or None - + Returns: Tuple of (width, height) or None if not specified Default is (720, 540) if parsing fails but value is provided """ if resolution is None: return (720, 540) # Default resolution - + if isinstance(resolution, (list, tuple)) and len(resolution) == 2: try: return (int(resolution[0]), int(resolution[1])) except (ValueError, TypeError): return (720, 540) - + return (720, 540) @staticmethod @@ -346,9 +346,9 @@ def _configure_resolution(self, node_map) -> None: """Configure camera resolution (width and height).""" if self._resolution is None: return - + width, height = self._resolution - + # Try to set width for width_attr in ("Width", "WidthMax"): try: @@ -358,7 +358,7 @@ def _configure_resolution(self, node_map) -> None: try: min_w = node.min max_w = node.max - inc_w = getattr(node, 'inc', 1) + inc_w = getattr(node, "inc", 1) # Adjust to valid value width = self._adjust_to_increment(width, min_w, max_w, inc_w) node.value = int(width) @@ -372,7 +372,7 @@ def _configure_resolution(self, node_map) -> None: continue except AttributeError: continue - + # Try to set height for height_attr in ("Height", "HeightMax"): try: @@ -382,7 +382,7 @@ def _configure_resolution(self, node_map) -> None: try: min_h = node.min max_h = node.max - inc_h = getattr(node, 'inc', 1) + inc_h = getattr(node, "inc", 1) # Adjust to valid value height = self._adjust_to_increment(height, min_h, max_h, inc_h) node.value = int(height) diff --git a/dlclivegui/cameras/opencv_backend.py b/dlclivegui/cameras/opencv_backend.py index 7face8f..651eeab 100644 --- a/dlclivegui/cameras/opencv_backend.py +++ b/dlclivegui/cameras/opencv_backend.py @@ -83,37 +83,37 @@ def device_name(self) -> str: def _parse_resolution(self, resolution) -> Tuple[int, int]: """Parse resolution setting. - + Args: resolution: Can be a tuple/list [width, height], or None - + Returns: Tuple of (width, height), defaults to (720, 540) """ if resolution is None: return (720, 540) # Default resolution - + if isinstance(resolution, (list, tuple)) and len(resolution) == 2: try: return (int(resolution[0]), int(resolution[1])) except (ValueError, TypeError): return (720, 540) - + return (720, 540) def _configure_capture(self) -> None: if self._capture is None: return - + # Set resolution (width x height) width, height = self._resolution self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, float(width)) self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, float(height)) - + # Set FPS if specified if self.settings.fps: self._capture.set(cv2.CAP_PROP_FPS, float(self.settings.fps)) - + # Set any additional properties from the properties dict for prop, value in self.settings.properties.items(): if prop in ("api", "resolution"): @@ -123,7 +123,7 @@ def _configure_capture(self) -> None: except (TypeError, ValueError): continue self._capture.set(prop_id, float(value)) - + # Update actual FPS from camera actual_fps = self._capture.get(cv2.CAP_PROP_FPS) if actual_fps: diff --git a/dlclivegui/config.py b/dlclivegui/config.py index c3a49c3..7cc629a 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -140,7 +140,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "ApplicationSettings": recording = RecordingSettings(**recording_data) bbox = BoundingBoxSettings(**data.get("bbox", {})) visualization = VisualizationSettings(**data.get("visualization", {})) - return cls(camera=camera, dlc=dlc, recording=recording, bbox=bbox, visualization=visualization) + return cls( + camera=camera, dlc=dlc, recording=recording, bbox=bbox, visualization=visualization + ) def to_dict(self) -> Dict[str, Any]: """Serialise the configuration to a dictionary.""" diff --git a/dlclivegui/dlc_processor.py b/dlclivegui/dlc_processor.py index 5ce2061..03a9b70 100644 --- a/dlclivegui/dlc_processor.py +++ b/dlclivegui/dlc_processor.py @@ -80,7 +80,7 @@ def __init__(self) -> None: self._latencies: deque[float] = deque(maxlen=60) self._processing_times: deque[float] = deque(maxlen=60) self._stats_lock = threading.Lock() - + # Profiling metrics self._queue_wait_times: deque[float] = deque(maxlen=60) self._inference_times: deque[float] = deque(maxlen=60) @@ -153,14 +153,38 @@ def get_stats(self) -> ProcessorStats: ) else: processing_fps = 0.0 - + # Profiling metrics - avg_queue_wait = sum(self._queue_wait_times) / len(self._queue_wait_times) if self._queue_wait_times else 0.0 - avg_inference = sum(self._inference_times) / len(self._inference_times) if self._inference_times else 0.0 - avg_signal_emit = sum(self._signal_emit_times) / len(self._signal_emit_times) if self._signal_emit_times else 0.0 - avg_total = sum(self._total_process_times) / len(self._total_process_times) if self._total_process_times else 0.0 - avg_gpu = sum(self._gpu_inference_times) / len(self._gpu_inference_times) if self._gpu_inference_times else 0.0 - avg_proc_overhead = sum(self._processor_overhead_times) / len(self._processor_overhead_times) if self._processor_overhead_times else 0.0 + avg_queue_wait = ( + sum(self._queue_wait_times) / len(self._queue_wait_times) + if self._queue_wait_times + else 0.0 + ) + avg_inference = ( + sum(self._inference_times) / len(self._inference_times) + if self._inference_times + else 0.0 + ) + avg_signal_emit = ( + sum(self._signal_emit_times) / len(self._signal_emit_times) + if self._signal_emit_times + else 0.0 + ) + avg_total = ( + sum(self._total_process_times) / len(self._total_process_times) + if self._total_process_times + else 0.0 + ) + avg_gpu = ( + sum(self._gpu_inference_times) / len(self._gpu_inference_times) + if self._gpu_inference_times + else 0.0 + ) + avg_proc_overhead = ( + sum(self._processor_overhead_times) / len(self._processor_overhead_times) + if self._processor_overhead_times + else 0.0 + ) return ProcessorStats( frames_enqueued=self._frames_enqueued, @@ -229,28 +253,30 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: } # todo expose more parameters from settings self._dlc = DLCLive(**options) - + init_inference_start = time.perf_counter() self._dlc.init_inference(init_frame) init_inference_time = time.perf_counter() - init_inference_start - + self._initialized = True self.initialized.emit(True) - + total_init_time = time.perf_counter() - init_start - LOGGER.info(f"DLCLive model initialized successfully (total: {total_init_time:.3f}s, init_inference: {init_inference_time:.3f}s)") + LOGGER.info( + f"DLCLive model initialized successfully (total: {total_init_time:.3f}s, init_inference: {init_inference_time:.3f}s)" + ) # Process the initialization frame enqueue_time = time.perf_counter() - + inference_start = time.perf_counter() pose = self._dlc.get_pose(init_frame, frame_time=init_timestamp) inference_time = time.perf_counter() - inference_start - + signal_start = time.perf_counter() self.pose_ready.emit(PoseResult(pose=pose, timestamp=init_timestamp)) signal_time = time.perf_counter() - signal_start - + process_time = time.perf_counter() with self._stats_lock: @@ -271,7 +297,7 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: frame_count = 0 while not self._stop_event.is_set(): loop_start = time.perf_counter() - + # Time spent waiting for queue queue_wait_start = time.perf_counter() try: @@ -284,30 +310,30 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: break frame, timestamp, enqueue_time = item - + try: # Time the inference - we need to separate GPU from processor overhead # If processor exists, wrap its process method to time it separately processor_overhead_time = 0.0 gpu_inference_time = 0.0 - + if self._processor is not None: # Wrap processor.process() to time it original_process = self._processor.process processor_time_holder = [0.0] # Use list to allow modification in nested scope - + def timed_process(pose, **kwargs): proc_start = time.perf_counter() result = original_process(pose, **kwargs) processor_time_holder[0] = time.perf_counter() - proc_start return result - + self._processor.process = timed_process - + inference_start = time.perf_counter() pose = self._dlc.get_pose(frame, frame_time=timestamp) inference_time = time.perf_counter() - inference_start - + if self._processor is not None: # Restore original process method self._processor.process = original_process @@ -321,7 +347,7 @@ def timed_process(pose, **kwargs): signal_start = time.perf_counter() self.pose_ready.emit(PoseResult(pose=pose, timestamp=timestamp)) signal_time = time.perf_counter() - signal_start - + end_process = time.perf_counter() total_process_time = end_process - loop_start latency = end_process - enqueue_time @@ -330,7 +356,7 @@ def timed_process(pose, **kwargs): self._frames_processed += 1 self._latencies.append(latency) self._processing_times.append(end_process) - + if ENABLE_PROFILING: self._queue_wait_times.append(queue_wait_time) self._inference_times.append(inference_time) @@ -338,7 +364,7 @@ def timed_process(pose, **kwargs): self._total_process_times.append(total_process_time) self._gpu_inference_times.append(gpu_inference_time) self._processor_overhead_times.append(processor_overhead_time) - + # Log profiling every 100 frames frame_count += 1 if ENABLE_PROFILING and frame_count % 100 == 0: @@ -351,7 +377,7 @@ def timed_process(pose, **kwargs): f"total={total_process_time*1000:.2f}ms, " f"latency={latency*1000:.2f}ms" ) - + except Exception as exc: LOGGER.exception("Pose inference failed", exc_info=exc) self.error.emit(str(exc)) diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index d1b9cf0..c4d5781 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -67,7 +67,7 @@ class MainWindow(QMainWindow): def __init__(self, config: Optional[ApplicationSettings] = None): super().__init__() self.setWindowTitle("DeepLabCut Live GUI") - + # Try to load myconfig.json from the application directory if no config provided if config is None: myconfig_path = Path(__file__).parent.parent / "myconfig.json" @@ -85,7 +85,7 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._config_path = None else: self._config_path = None - + self._config = config self._current_frame: Optional[np.ndarray] = None self._raw_frame: Optional[np.ndarray] = None @@ -110,7 +110,7 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._bbox_x1 = 0 self._bbox_y1 = 0 self._bbox_enabled = False - + # Visualization settings (will be updated from config) self._p_cutoff = 0.6 self._colormap = "hot" @@ -130,10 +130,12 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._metrics_timer.timeout.connect(self._update_metrics) self._metrics_timer.start() self._update_metrics() - + # Show status message if myconfig.json was loaded if self._config_path and self._config_path.name == "myconfig.json": - self.statusBar().showMessage(f"Auto-loaded configuration from {self._config_path}", 5000) + self.statusBar().showMessage( + f"Auto-loaded configuration from {self._config_path}", 5000 + ) # ------------------------------------------------------------------ UI def _setup_ui(self) -> None: @@ -144,14 +146,14 @@ def _setup_ui(self) -> None: video_panel = QWidget() video_layout = QVBoxLayout(video_panel) video_layout.setContentsMargins(0, 0, 0, 0) - + # Video display widget self.video_label = QLabel("Camera preview not started") self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.video_label.setMinimumSize(640, 360) self.video_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) video_layout.addWidget(self.video_label) - + # Stats panel below video with clear labels stats_widget = QWidget() stats_widget.setStyleSheet("padding: 5px;") @@ -159,7 +161,7 @@ def _setup_ui(self) -> None: stats_layout = QVBoxLayout(stats_widget) stats_layout.setContentsMargins(5, 5, 5, 5) stats_layout.setSpacing(3) - + # Camera throughput stats camera_stats_container = QHBoxLayout() camera_stats_label_title = QLabel("Camera:") @@ -169,7 +171,7 @@ def _setup_ui(self) -> None: camera_stats_container.addWidget(self.camera_stats_label) camera_stats_container.addStretch(1) stats_layout.addLayout(camera_stats_container) - + # DLC processor stats dlc_stats_container = QHBoxLayout() dlc_stats_label_title = QLabel("DLC Processor:") @@ -179,7 +181,7 @@ def _setup_ui(self) -> None: dlc_stats_container.addWidget(self.dlc_stats_label) dlc_stats_container.addStretch(1) stats_layout.addLayout(dlc_stats_container) - + # Video recorder stats recorder_stats_container = QHBoxLayout() recorder_stats_label_title = QLabel("Recorder:") @@ -189,7 +191,7 @@ def _setup_ui(self) -> None: recorder_stats_container.addWidget(self.recording_stats_label) recorder_stats_container.addStretch(1) stats_layout.addLayout(recorder_stats_container) - + video_layout.addWidget(stats_widget) # Controls panel with fixed width to prevent shifting @@ -560,7 +562,7 @@ def _apply_config(self, config: ApplicationSettings) -> None: self.bbox_y0_spin.setValue(bbox.y0) self.bbox_x1_spin.setValue(bbox.x1) self.bbox_y1_spin.setValue(bbox.y1) - + # Set visualization settings from config viz = config.visualization self._p_cutoff = viz.p_cutoff @@ -792,7 +794,10 @@ def _save_config_to_path(self, path: Path) -> None: def _action_browse_model(self) -> None: file_path, _ = QFileDialog.getOpenFileName( - self, "Select DLCLive model file", PATH2MODELS, "Model files (*.pt *.pb);;All files (*.*)" + self, + "Select DLCLive model file", + PATH2MODELS, + "Model files (*.pt *.pb);;All files (*.*)", ) if file_path: self.model_path_edit.setText(file_path) @@ -1067,7 +1072,7 @@ def _format_dlc_stats(self, stats: ProcessorStats) -> str: enqueue = stats.frames_enqueued processed = stats.frames_processed dropped = stats.frames_dropped - + # Add profiling info if available profile_info = "" if stats.avg_inference_time > 0: @@ -1075,19 +1080,19 @@ def _format_dlc_stats(self, stats: ProcessorStats) -> str: queue_ms = stats.avg_queue_wait * 1000.0 signal_ms = stats.avg_signal_emit_time * 1000.0 total_ms = stats.avg_total_process_time * 1000.0 - + # Add GPU vs processor breakdown if available gpu_breakdown = "" if stats.avg_gpu_inference_time > 0 or stats.avg_processor_overhead > 0: gpu_ms = stats.avg_gpu_inference_time * 1000.0 proc_ms = stats.avg_processor_overhead * 1000.0 gpu_breakdown = f" (GPU:{gpu_ms:.1f}ms+proc:{proc_ms:.1f}ms)" - + profile_info = ( f"\n[Profile] inf:{inf_ms:.1f}ms{gpu_breakdown} queue:{queue_ms:.1f}ms " f"signal:{signal_ms:.1f}ms total:{total_ms:.1f}ms" ) - + return ( f"{processed}/{enqueue} frames | inference {processing_fps:.1f} fps | " f"latency {latency_ms:.1f} ms (avg {avg_ms:.1f} ms) | " @@ -1365,7 +1370,9 @@ def _on_frame_ready(self, frame_data: FrameData) -> None: h, w = frame.shape[:2] try: # configure_stream expects (height, width) - self._video_recorder.configure_stream((h, w), float(fps_value) if fps_value is not None else None) + self._video_recorder.configure_stream( + (h, w), float(fps_value) if fps_value is not None else None + ) except Exception: # Non-fatal: continue and attempt to write anyway pass @@ -1501,11 +1508,11 @@ def _draw_bbox(self, frame: np.ndarray) -> np.ndarray: def _draw_pose(self, frame: np.ndarray, pose: np.ndarray) -> np.ndarray: overlay = frame.copy() - + # Get the colormap from config cmap = plt.get_cmap(self._colormap) num_keypoints = len(np.asarray(pose)) - + for idx, keypoint in enumerate(np.asarray(pose)): if len(keypoint) < 2: continue @@ -1515,13 +1522,13 @@ def _draw_pose(self, frame: np.ndarray, pose: np.ndarray) -> np.ndarray: continue if confidence < self._p_cutoff: continue - + # Get color from colormap (cycle through 0 to 1) color_normalized = idx / max(num_keypoints - 1, 1) rgba = cmap(color_normalized) # Convert from RGBA [0, 1] to BGR [0, 255] for OpenCV bgr_color = (int(rgba[2] * 255), int(rgba[1] * 255), int(rgba[0] * 255)) - + cv2.circle(overlay, (int(x), int(y)), 4, bgr_color, -1) return overlay diff --git a/dlclivegui/processors/dlc_processor_socket.py b/dlclivegui/processors/dlc_processor_socket.py index 73a5cb4..1ec9827 100644 --- a/dlclivegui/processors/dlc_processor_socket.py +++ b/dlclivegui/processors/dlc_processor_socket.py @@ -5,8 +5,8 @@ from collections import deque from math import acos, atan2, copysign, degrees, pi, sqrt from multiprocessing.connection import Listener -from threading import Event, Thread from pathlib import Path +from threading import Event, Thread import numpy as np from dlclive import Processor # type: ignore @@ -229,10 +229,10 @@ def _handle_client_message(self, msg): elif cmd == "set_filter": # Handle filter enable/disable (subclasses override if they support filtering) use_filter = msg.get("use_filter", False) - if hasattr(self, 'use_filter'): + if hasattr(self, "use_filter"): self.use_filter = bool(use_filter) # Reset filters to reinitialize with new setting - if hasattr(self, 'filters'): + if hasattr(self, "filters"): self.filters = None LOG.info(f"Filtering {'enabled' if use_filter else 'disabled'}") else: @@ -241,11 +241,11 @@ def _handle_client_message(self, msg): elif cmd == "set_filter_params": # Handle filter parameter updates (subclasses override if they support filtering) filter_kwargs = msg.get("filter_kwargs", {}) - if hasattr(self, 'filter_kwargs'): + if hasattr(self, "filter_kwargs"): # Update filter parameters self.filter_kwargs.update(filter_kwargs) # Reset filters to reinitialize with new parameters - if hasattr(self, 'filters'): + if hasattr(self, "filters"): self.filters = None LOG.info(f"Filter parameters updated: {filter_kwargs}") else: @@ -315,10 +315,10 @@ def process(self, pose, **kwargs): def stop(self): """Stop the processor and close all connections.""" LOG.info("Stopping processor...") - + # Signal stop to all threads self._stop.set() - + # Close all client connections first for c in list(self.conns): try: @@ -326,18 +326,18 @@ def stop(self): except Exception: pass self.conns.discard(c) - + # Close the listener socket - if hasattr(self, 'listener') and self.listener: + if hasattr(self, "listener") and self.listener: try: self.listener.close() except Exception as e: LOG.debug(f"Error closing listener: {e}") - + # Give the OS time to release the socket on Windows # This prevents WinError 10048 when restarting time.sleep(0.1) - + LOG.info("Processor stopped, all connections closed") def save(self, file=None): @@ -349,7 +349,7 @@ def save(self, file=None): save_dict = self.get_data() path2save = Path(__file__).parent.parent.parent / "data" / file LOG.info(f"Path should be {path2save}") - pickle.dump(save_dict, open(path2save, "wb")) + pickle.dump(save_dict, open(path2save, "wb")) save_code = 1 except Exception as e: LOG.error(f"Save failed: {e}") @@ -577,7 +577,7 @@ def get_data(self): save_dict["filter_kwargs"] = self.filter_kwargs return save_dict - + class MyProcessorTorchmodels_socket(BaseProcessor_socket): """ @@ -716,7 +716,7 @@ def process(self, pose, **kwargs): try: center = np.average(head_xy, axis=0, weights=head_conf) except ZeroDivisionError: - # If all keypoints have zero weight, return without processing + # If all keypoints have zero weight, return without processing return pose neck = np.average(xy[[2, 3, 6, 7], :], axis=0, weights=conf[[2, 3, 6, 7]]) @@ -797,7 +797,6 @@ def get_data(self): save_dict["filter_kwargs"] = self.filter_kwargs return save_dict - # Register processors for GUI discovery From bcd89cc0ec12c4029296dc94fbf71a1d692d59cd Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Fri, 5 Dec 2025 16:15:49 +0100 Subject: [PATCH 23/69] some docs --- docs/camera_support.md | 135 +--------------- docs/features.md | 184 +-------------------- docs/user_guide.md | 353 +---------------------------------------- 3 files changed, 5 insertions(+), 667 deletions(-) diff --git a/docs/camera_support.md b/docs/camera_support.md index 4d9ba22..ed0b1e0 100644 --- a/docs/camera_support.md +++ b/docs/camera_support.md @@ -42,10 +42,6 @@ You can select the backend in the GUI from the "Backend" dropdown, or in your co - **OpenCV compatible cameras**: For webcams and compatible USB cameras. - **Aravis backend**: For GenICam/GigE Vision cameras (requires Homebrew installation). -#### NVIDIA Jetson -- **OpenCV compatible cameras**: Standard V4L2 camera support. -- **Aravis backend**: Supported but may have platform-specific bugs. See [Aravis issues](https://github.com/AravisProject/aravis/issues/324). - ### Quick Installation Guide #### Aravis (Linux/Ubuntu) @@ -67,10 +63,8 @@ Install vendor-provided camera drivers and SDK. CTI files are typically in: | Feature | OpenCV | GenTL | Aravis | Basler (pypylon) | |---------|--------|-------|--------|------------------| -| Ease of use | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | -| Auto-detection | Basic | Yes | Yes | Yes | -| Exposure control | Limited | Yes | Yes | Yes | -| Gain control | Limited | Yes | Yes | Yes | +| Exposure control | No | Yes | Yes | Yes | +| Gain control | No | Yes | Yes | Yes | | Windows | ✅ | ✅ | ❌ | ✅ | | Linux | ✅ | ✅ | ✅ | ✅ | | macOS | ✅ | ❌ | ✅ | ✅ | @@ -80,128 +74,3 @@ Install vendor-provided camera drivers and SDK. CTI files are typically in: - [Aravis Backend](aravis_backend.md) - GenICam/GigE cameras on Linux/macOS - GenTL Backend - Industrial cameras via vendor CTI files - OpenCV Backend - Universal webcam support - -### Contributing New Camera Types - -Any camera that can be accessed through python (e.g. if the company offers a python package) can be integrated into the DeepLabCut-live-GUI. To contribute, please build off of our [base `Camera` class](../dlclivegui/camera/camera.py), and please use our [currently supported cameras](../dlclivegui/camera) as examples. - -New camera classes must inherit our base camera class, and provide at least two arguments: - -- id: an arbitrary name for a camera -- resolution: the image size - -Other common options include: - -- exposure -- gain -- rotate -- crop -- fps - -If the camera does not have it's own display module, you can use our Tkinter video display built into the DeepLabCut-live-GUI by passing `use_tk_display=True` to the base camera class, and control the size of the displayed image using the `display_resize` parameter (`display_resize=1` for full image, `display_resize=0.5` to display images at half the width and height of recorded images). - -Here is an example of a camera that allows users to set the resolution, exposure, and crop, and uses the Tkinter display: - -```python -from dlclivegui import Camera - -class MyNewCamera(Camera) - - def __init__(self, id="", resolution=[640, 480], exposure=0, crop=None, display_resize=1): - super().__init__(id, - resolution=resolution, - exposure=exposure, - crop=crop, - use_tk_display=True, - display_resize=display_resize) - -``` - -All arguments of your camera's `__init__` method will be available to edit in the GUI's `Edit Camera Settings` window. To ensure that you pass arguments of the correct data type, it is helpful to provide default values for each argument of the correct data type (e.g. if `myarg` is a string, please use `myarg=""` instead of `myarg=None`). If a certain argument has only a few possible values, and you want to limit the options user's can input into the `Edit Camera Settings` window, please implement a `@static_method` called `arg_restrictions`. This method should return a dictionary where the keys are the arguments for which you want to provide value restrictions, and the values are the possible values that a specific argument can take on. Below is an example that restrictions the values for `use_tk_display` to `True` or `False`, and restricts the possible values of `resolution` to `[640, 480]` or `[320, 240]`. - -```python - @static_method - def arg_restrictions(): - return {'use_tk_display' : [True, False], - 'resolution' : [[640, 480], [320, 240]]} -``` - -In addition to an `__init__` method that calls the `dlclivegui.Camera.__init__` method, you need to overwrite the `dlclivegui.Camera.set_capture_device`, `dlclive.Camera.close_capture_device`, and one of the following two methods: `dlclivegui.Camera.get_image` or `dlclivegui.Camera.get_image_on_time`. - -Your camera class's `set_capture_device` method should open the camera feed and confirm that the appropriate settings (such as exposure, rotation, gain, etc.) have been properly set. The `close_capture_device` method should simply close the camera stream. For example, see the [OpenCV camera](../dlclivegui/camera/opencv.py) `set_capture_device` and `close_capture_device` method. - -If you're camera has built in methods to ensure the correct frame rate (e.g. when grabbing images, it will block until the next image is ready), then overwrite the `get_image_on_time` method. If the camera does not block until the next image is ready, then please set the `get_image` method, and the base camera class's `get_image_on_time` method will ensure that images are only grabbed at the specified frame rate. - -The `get_image` method has no input arguments, but must return an image as a numpy array. We also recommend converting images to 8-bit integers (data type `uint8`). - -The `get_image_on_time` method has no input arguments, but must return an image as a numpy array (as in `get_image`) and the timestamp at which the image is returned (using python's `time.time()` function). - -### Camera Specific Tips for Installation & Use: - -#### Basler cameras - -Basler USB3 cameras are compatible with Aravis. However, integration with DeepLabCut-live-GUI can also be obtained with `pypylon`, the python module to drive Basler cameras, and supported by the company. Please note using `pypylon` requires you to install Pylon viewer, a free of cost GUI also developed and supported by Basler and available on several platforms. - -* **Pylon viewer**: https://www.baslerweb.com/en/sales-support/downloads/software-downloads/#type=pylonsoftware;language=all;version=all -* `pypylon`: https://github.com/basler/pypylon/releases - -If you want to use DeepLabCut-live-GUI with a Basler USB3 camera via pypylon, see the folllowing instructions. Please note this is tested on Ubuntu 20.04. It may (or may not) work similarly in other platforms (contributed by [@antortjim](https://github.com/antortjim)). This procedure should take around 10 minutes: - -**Install Pylon viewer** - -1. Download .deb file -Download the .deb file in the downloads center of Basler. Last version as of writing this was **pylon 6.2.0 Camera Software Suite Linux x86 (64 Bit) - Debian Installer Package**. - - -2. Install .deb file - -``` -sudo dpkg -i pylon_6.2.0.21487-deb0_amd64.deb -``` - -**Install swig** - -Required for compilation of non python code within pypylon - -1. Install swig dependencies - -You may have to install these in a fresh Ubuntu 20.04 install - -``` -sudo apt install gcc g++ -sudo apt install libpcre3-dev -sudo apt install make -``` - -2. Download swig - -Go to http://prdownloads.sourceforge.net/swig/swig-4.0.2.tar.gz and download the tar gz - -3. Install swig -``` -tar -zxvf swig-4.0.2.tar.gz -cd swig-4.0.2 -./configure -make -sudo make install -``` - -**Install pypylon** - -1. Download pypylon - -``` -wget https://github.com/basler/pypylon/archive/refs/tags/1.7.2.tar.gz -``` - -or go to https://github.com/basler/pypylon/releases and get the version you want! - -2. Install pypylon - -``` -tar -zxvf 1.7.2.tar.gz -cd pypylon-1.7.2 -python setup.py install -``` - -Once you have completed these steps, you should be able to call your Basler camera from DeepLabCut-live-GUI using the BaslerCam camera type that appears after clicking "Add camera") diff --git a/docs/features.md b/docs/features.md index 1a04068..91d87ad 100644 --- a/docs/features.md +++ b/docs/features.md @@ -20,7 +20,7 @@ The GUI supports four different camera backends, each optimized for different use cases: #### OpenCV Backend -- **Platform**: Windows, Linux, macOS +- **Platform**: Windows, Linux - **Best For**: Webcams, simple USB cameras - **Installation**: Built-in with OpenCV - **Limitations**: Limited exposure/gain control @@ -32,10 +32,9 @@ The GUI supports four different camera backends, each optimized for different us - **Features**: Full camera control, smart device detection #### Aravis Backend -- **Platform**: Linux (best), macOS +- **Platform**: Linux (best) - **Best For**: GenICam/GigE Vision cameras - **Installation**: System packages (`gir1.2-aravis-0.8`) -- **Features**: Excellent Linux support, native GigE #### Basler Backend (pypylon) - **Platform**: Windows, Linux, macOS @@ -95,7 +94,6 @@ Example detection output: ### DLCLive Integration #### Model Support -- **TensorFlow (Base)**: Original DeepLabCut models - **PyTorch**: PyTorch-exported models - Model selection via dropdown - Automatic model validation @@ -240,63 +238,6 @@ Single JSON file contains all settings: - Default values for missing fields - Error messages for invalid entries - Safe fallback to defaults - -### Configuration Sections - -#### Camera Settings (`camera`) -```json -{ - "name": "Camera 0", - "index": 0, - "fps": 60.0, - "backend": "gentl", - "exposure": 10000, - "gain": 5.0, - "crop_x0": 0, - "crop_y0": 0, - "crop_x1": 0, - "crop_y1": 0, - "max_devices": 3, - "properties": {} -} -``` - -#### DLC Settings (`dlc`) -```json -{ - "model_path": "/path/to/model", - "model_type": "base", - "additional_options": { - "resize": 0.5, - "processor": "cpu", - "pcutoff": 0.6 - } -} -``` - -#### Recording Settings (`recording`) -```json -{ - "enabled": true, - "directory": "~/Videos/dlc", - "filename": "session.mp4", - "container": "mp4", - "codec": "h264_nvenc", - "crf": 23 -} -``` - -#### Bounding Box Settings (`bbox`) -```json -{ - "enabled": false, - "x0": 0, - "y0": 0, - "x1": 200, - "y1": 100 -} -``` - --- ## Processor System @@ -462,21 +403,6 @@ The GUI monitors `video_recording` property and automatically starts/stops recor - **Dropped**: Encoding failures - **Format**: "1500/1502 frames | write 59.8 fps | latency 12.3 ms (avg 12.5 ms) | queue 5 (~83 ms) | dropped 2" -### Performance Optimization - -#### Automatic Adjustments -- Frame display throttling (25 Hz max) -- Queue backpressure handling -- Automatic resolution detection - -#### User Adjustments -- Reduce camera FPS -- Enable ROI cropping -- Use hardware encoding -- Increase CRF value -- Disable pose visualization -- Adjust buffer counts - --- ## Advanced Features @@ -528,38 +454,6 @@ Qt signals/slots ensure thread-safe communication. ### Extensibility -#### Custom Backends -Implement `CameraBackend` abstract class: -```python -class MyBackend(CameraBackend): - def open(self): ... - def read(self) -> Tuple[np.ndarray, float]: ... - def close(self): ... - - @classmethod - def get_device_count(cls) -> int: ... -``` - -Register in `factory.py`: -```python -_BACKENDS = { - "mybackend": ("module.path", "MyBackend") -} -``` - -#### Custom Processors -Place in `processors/` directory: -```python -class MyProcessor: - def __init__(self, **kwargs): - # Initialize - pass - - def process(self, pose, timestamp): - # Process pose - pass -``` - ### Debugging Features #### Logging @@ -567,58 +461,7 @@ class MyProcessor: - Frame acquisition logging - Performance warnings - Connection status - -#### Development Mode -- Syntax validation: `python -m compileall dlclivegui` -- Type checking: `mypy dlclivegui` -- Test files included - --- - -## Use Case Examples - -### High-Speed Behavior Tracking - -**Setup**: -- Camera: GenTL industrial camera @ 120 FPS -- Codec: h264_nvenc (GPU encoding) -- Crop: Region of interest only -- DLC: PyTorch model on GPU - -**Settings**: -```json -{ - "camera": {"fps": 120, "crop_x0": 200, "crop_y0": 100, "crop_x1": 800, "crop_y1": 600}, - "recording": {"codec": "h264_nvenc", "crf": 28}, - "dlc": {"additional_options": {"processor": "gpu", "resize": 0.5}} -} -``` - -### Event-Triggered Recording - -**Setup**: -- Processor: Socket processor with auto-record -- Trigger: Remote computer sends START/STOP commands -- Session naming: Unique per trial - -**Workflow**: -1. Enable "Auto-record video on processor command" -2. Start preview and inference -3. Remote system connects via socket -4. Sends `START_RECORDING:trial_001` → recording starts -5. Sends `STOP_RECORDING` → recording stops -6. Files saved as `trial_001.mp4` - -### Multi-Camera Synchronization - -**Setup**: -- Multiple GUI instances -- Shared trigger signal -- Synchronized filenames - -**Configuration**: -Each instance with different camera index but same settings template. - --- ## Keyboard Shortcuts @@ -627,27 +470,4 @@ Each instance with different camera index but same settings template. - **Ctrl+S**: Save configuration - **Ctrl+Shift+S**: Save configuration as - **Ctrl+Q**: Quit application - --- - -## Platform-Specific Notes - -### Windows -- Best GenTL support (vendor CTI files) -- NVENC highly recommended -- DirectShow backend for webcams - -### Linux -- Best Aravis support (native GigE) -- V4L2 backend for webcams -- NVENC available with proprietary drivers - -### macOS -- Limited industrial camera support -- Aravis via Homebrew -- Software encoding recommended - -### NVIDIA Jetson -- Optimized for edge deployment -- Hardware encoding available -- Some Aravis compatibility issues diff --git a/docs/user_guide.md b/docs/user_guide.md index 50374c0..0346d53 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -8,10 +8,6 @@ Complete walkthrough for using the DeepLabCut-live-GUI application. 2. [Camera Setup](#camera-setup) 3. [DLCLive Configuration](#dlclive-configuration) 4. [Recording Videos](#recording-videos) -5. [Working with Configurations](#working-with-configurations) -6. [Common Workflows](#common-workflows) -7. [Tips and Best Practices](#tips-and-best-practices) - --- ## Getting Started @@ -164,22 +160,9 @@ Select if camera is mounted at an angle: - Model weights (`.pb`, `.pth`, etc.) ### Step 2: Choose Model Type - -Select from dropdown: -- **Base (TensorFlow)**: Standard DLC models +We only support newer, pytorch based models. - **PyTorch**: PyTorch-based models (requires PyTorch) -### Step 3: Configure Options (Optional) - -Click in "Additional options" field and enter JSON: - -```json -{ - "processor": "gpu", - "resize": 0.5, - "pcutoff": 0.6 -} -``` **Common options**: - `processor`: "cpu" or "gpu" @@ -278,13 +261,6 @@ Check **"Display pose predictions"** to overlay keypoints on video. - Full resolution - Sufficient disk space -#### Long Duration Recording - -**Tips**: -- Use CRF 23-25 to balance quality/size -- Monitor disk space -- Consider splitting into multiple files -- Use fast SSD storage ### Auto-Recording @@ -295,325 +271,7 @@ Enable automatic recording triggered by processor events: 3. **Start inference**: Processor will control recording 4. **Session management**: Files named by processor -**Use cases**: -- Trial-based experiments -- Event-triggered recording -- Remote control via socket processor -- Conditional data capture - ---- - -## Working with Configurations - -### Saving Current Settings - -**Save** (overwrites existing file): -1. File → Save configuration (or Ctrl+S) -2. If no file loaded, prompts for location - -**Save As** (create new file): -1. File → Save configuration as… (or Ctrl+Shift+S) -2. Choose location and filename -3. Enter name (e.g., `mouse_experiment.json`) -4. Click Save - -### Loading Saved Settings - -1. File → Load configuration… (or Ctrl+O) -2. Navigate to configuration file -3. Select `.json` file -4. Click Open -5. All GUI fields update automatically - -### Managing Multiple Configurations - -**Recommended structure**: -``` -configs/ -├── default.json # Base settings -├── mouse_arena1.json # Arena-specific -├── mouse_arena2.json -├── rat_setup.json -└── high_speed.json # Performance-specific -``` - -**Workflow**: -1. Create base configuration with common settings -2. Save variants for different: - - Animals/subjects - - Experimental setups - - Camera positions - - Recording quality levels - -### Configuration Templates - -#### Webcam + CPU Processing -```json -{ - "camera": { - "backend": "opencv", - "index": 0, - "fps": 30.0 - }, - "dlc": { - "model_type": "base", - "additional_options": {"processor": "cpu"} - }, - "recording": { - "codec": "libx264", - "crf": 23 - } -} -``` - -#### Industrial Camera + GPU -```json -{ - "camera": { - "backend": "gentl", - "index": 0, - "fps": 60.0, - "exposure": 10000, - "gain": 8.0 - }, - "dlc": { - "model_type": "pytorch", - "additional_options": { - "processor": "gpu", - "resize": 0.5 - } - }, - "recording": { - "codec": "h264_nvenc", - "crf": 23 - } -} -``` - ---- - -## Common Workflows - -### Workflow 1: Simple Webcam Tracking - -**Goal**: Track mouse behavior with webcam - -1. **Camera Setup**: - - Backend: opencv - - Camera: Built-in webcam (index 0) - - FPS: 30 - -2. **Start Preview**: Verify mouse is visible - -3. **Load DLC Model**: Browse to mouse tracking model - -4. **Start Inference**: Enable pose estimation - -5. **Verify Tracking**: Enable pose visualization - -6. **Record Trial**: Start/stop recording as needed - -### Workflow 2: High-Speed Industrial Camera - -**Goal**: Track fast movements at 120 FPS - -1. **Camera Setup**: - - Backend: gentl or aravis - - Refresh and select camera - - FPS: 120 - - Exposure: 4000 μs (short exposure) - - Crop: Region of interest only - -2. **Start Preview**: Check FPS is stable - -3. **Configure Recording**: - - Codec: h264_nvenc - - CRF: 28 - - Output: Fast SSD - -4. **Load DLC Model** (if needed): - - PyTorch model - - GPU processor - - Resize: 0.5 (reduce load) - -5. **Start Recording**: Begin data capture - -6. **Monitor Performance**: Watch for dropped frames - -### Workflow 3: Event-Triggered Recording - -**Goal**: Record only during specific events - -1. **Camera Setup**: Configure as normal - -2. **Processor Setup**: - - Select socket processor - - Enable "Auto-record video on processor command" - -3. **Start Preview**: Camera running - -4. **Start Inference**: DLC + processor active - -5. **Remote Control**: - - Connect to socket (default port 5000) - - Send `START_RECORDING:trial_001` - - Recording starts automatically - - Send `STOP_RECORDING` - - Recording stops, file saved - -### Workflow 4: Multi-Subject Tracking - -**Goal**: Track multiple animals simultaneously - -**Option A: Single Camera, Multiple Keypoints** -1. Use DLC model trained for multiple subjects -2. Single GUI instance -3. Processor distinguishes subjects - -**Option B: Multiple Cameras** -1. Launch multiple GUI instances -2. Each with different camera index -3. Synchronized configurations -4. Coordinated filenames - ---- - -## Tips and Best Practices - -### Camera Tips - -1. **Lighting**: - - Consistent, diffuse lighting - - Avoid shadows and reflections - - IR lighting for night vision - -2. **Positioning**: - - Stable mount (minimize vibration) - - Appropriate angle for markers - - Sufficient field of view - -3. **Settings**: - - Start with auto exposure/gain - - Adjust manually if needed - - Test different FPS rates - - Use cropping to reduce load - -### Recording Tips - -1. **File Management**: - - Use descriptive filenames - - Include date/subject/trial info - - Organize by experiment/session - - Regular backups - -2. **Performance**: - - Close unnecessary applications - - Monitor disk space - - Use SSD for high-speed recording - - Enable GPU encoding if available - -3. **Quality**: - - Test CRF values beforehand - - Balance quality vs. file size - - Consider post-processing needs - - Verify recordings occasionally - -### DLCLive Tips - -1. **Model Selection**: - - Use model trained on similar conditions - - Test offline before live use - - Consider resize for speed - - GPU highly recommended - -2. **Performance**: - - Monitor inference FPS - - Check latency values - - Watch queue depth - - Reduce resolution if needed - -3. **Validation**: - - Enable visualization initially - - Verify tracking quality - - Check all keypoints - - Test edge cases - -### General Best Practices - -1. **Configuration Management**: - - Save configurations frequently - - Version control config files - - Document custom settings - - Share team configurations - -2. **Testing**: - - Test setup before experiments - - Run trial recordings - - Verify all components - - Check file outputs - -3. **Troubleshooting**: - - Check status messages - - Monitor performance metrics - - Review error dialogs carefully - - Restart if issues persist - -4. **Data Organization**: - - Consistent naming scheme - - Separate folders per session - - Include metadata files - - Regular data validation - --- - -## Troubleshooting Guide - -### Camera Issues - -**Problem**: Camera not detected -- **Solution**: Click Refresh, check connections, verify drivers - -**Problem**: Low frame rate -- **Solution**: Reduce resolution, increase exposure, check CPU usage - -**Problem**: Image too dark/bright -- **Solution**: Adjust exposure and gain settings - -### DLCLive Issues - -**Problem**: Model fails to load -- **Solution**: Verify path, check model type, install dependencies - -**Problem**: Slow inference -- **Solution**: Enable GPU, reduce resolution, use resize option - -**Problem**: Poor tracking -- **Solution**: Check lighting, enable visualization, verify model quality - -### Recording Issues - -**Problem**: Dropped frames -- **Solution**: Use GPU encoding, increase CRF, reduce FPS - -**Problem**: Large file sizes -- **Solution**: Increase CRF value, use better codec - -**Problem**: Recording won't start -- **Solution**: Check disk space, verify path permissions - ---- - -## Keyboard Reference - -| Action | Shortcut | -|--------|----------| -| Load configuration | Ctrl+O | -| Save configuration | Ctrl+S | -| Save configuration as | Ctrl+Shift+S | -| Quit application | Ctrl+Q | - ---- - ## Next Steps - Explore [Features Documentation](features.md) for detailed capabilities @@ -622,12 +280,3 @@ configs/ - See [Aravis Backend](aravis_backend.md) for Linux industrial cameras --- - -## Getting Help - -If you encounter issues: -1. Check status messages in GUI -2. Review this user guide -3. Consult technical documentation -4. Check GitHub issues -5. Contact support team From 7b70c24dac9bdc77ee304de4719917fc50d83330 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Fri, 5 Dec 2025 16:19:04 +0100 Subject: [PATCH 24/69] updated docs --- README.md | 67 +++++-------------------------------------------------- 1 file changed, 5 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 34a2682..a330e0b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ A modern PyQt6 GUI for running [DeepLabCut-live](https://github.com/DeepLabCut/D - **Live Preview**: Real-time camera feed with rotation support (0°, 90°, 180°, 270°) ### DLCLive Features -- **Model Support**: TensorFlow (base) and PyTorch models +- **Model Support**: Only PyTorch models! (in theory also tensorflow models work) - **Processor System**: Plugin architecture for custom pose processing - **Auto-Recording**: Automatic video recording triggered by processor commands - **Performance Metrics**: Real-time FPS, latency, and queue monitoring @@ -141,11 +141,7 @@ The GUI uses a single JSON configuration file containing all experiment settings }, "dlc": { "model_path": "/path/to/exported-model", - "model_type": "base", - "additional_options": { - "resize": 0.5, - "processor": "cpu" - } + "model_type": "pytorch", }, "recording": { "enabled": true, @@ -206,34 +202,11 @@ All GUI fields are automatically synchronized with the configuration file. "index": 0, "fps": 60.0, "exposure": 15000, - "gain": 8.0, - "properties": { - "cti_file": "C:\\Path\\To\\Producer.cti", - "serial_number": "12345678", - "pixel_format": "Mono8" - } + "gain": 8.0, } } ``` -#### Aravis -```json -{ - "camera": { - "backend": "aravis", - "index": 0, - "fps": 60.0, - "exposure": 10000, - "gain": 5.0, - "properties": { - "camera_id": "TheImagingSource-12345678", - "pixel_format": "Mono8", - "n_buffers": 10, - "timeout": 2000000 - } - } -} -``` See [Camera Backend Documentation](docs/camera_support.md) for detailed setup instructions. @@ -241,10 +214,9 @@ See [Camera Backend Documentation](docs/camera_support.md) for detailed setup in ### Model Types -The GUI supports both TensorFlow and PyTorch DLCLive models: +The GUI supports PyTorch DLCLive models: -1. **Base (TensorFlow)**: Original DLC models exported for live inference -2. **PyTorch**: PyTorch-based models (requires PyTorch installation) +1. **PyTorch**: PyTorch-based models (requires PyTorch installation) Select the model type from the dropdown before starting inference. @@ -284,15 +256,6 @@ Enable "Auto-record video on processor command" to automatically start/stop reco 4. **Disable Visualization**: Uncheck "Display pose predictions" during recording 5. **Crop Region**: Use cropping to reduce frame size before inference -### Recommended Settings by FPS - -| FPS Range | Codec | CRF | Buffers | Notes | -|-----------|-------|-----|---------|-------| -| 30-60 | libx264 | 23 | 10 | Standard quality | -| 60-120 | h264_nvenc | 23 | 15 | GPU encoding | -| 120-200 | h264_nvenc | 28 | 20 | Higher compression | -| 200+ | h264_nvenc | 30 | 30 | Max performance | - ### Project Structure ``` @@ -315,26 +278,6 @@ dlclivegui/ └── dlc_processor_socket.py ``` -### Running Tests - -```bash -# Syntax check -python -m compileall dlclivegui - -# Type checking (optional) -mypy dlclivegui - -``` - -### Adding New Camera Backends - -1. Create new backend inheriting from `CameraBackend` -2. Implement required methods: `open()`, `read()`, `close()` -3. Optional: Implement `get_device_count()` for smart detection -4. Register in `cameras/factory.py` - -See [Camera Backend Development](docs/camera_support.md) for detailed instructions. - ## Documentation From 94ccc7b581f5e91fb344429cef1e1c5988cd079a Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Fri, 5 Dec 2025 17:48:34 +0100 Subject: [PATCH 25/69] Update dlclivegui/gui.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dlclivegui/gui.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index c4d5781..9c27a1b 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -686,29 +686,6 @@ def _current_camera_index_value(self) -> Optional[int]: self.camera_index.setEditText("") self.camera_index.blockSignals(False) - def _select_camera_by_index(self, index: int, fallback_text: Optional[str] = None) -> None: - self.camera_index.blockSignals(True) - for row in range(self.camera_index.count()): - if self.camera_index.itemData(row) == index: - self.camera_index.setCurrentIndex(row) - break - else: - text = fallback_text if fallback_text is not None else str(index) - self.camera_index.setEditText(text) - self.camera_index.blockSignals(False) - - def _current_camera_index_value(self) -> Optional[int]: - data = self.camera_index.currentData() - if isinstance(data, int): - return data - text = self.camera_index.currentText().strip() - if not text: - return None - try: - return int(text) - except ValueError: - return None - def _parse_json(self, value: str) -> dict: text = value.strip() if not text: From c5659d7f0c8fa950e49b53ae3ca0213d6e652bf6 Mon Sep 17 00:00:00 2001 From: Mackenzie Mathis Date: Fri, 5 Dec 2025 18:15:45 +0100 Subject: [PATCH 26/69] Rename DLC Processor to DeepLabCut Processor --- dlclivegui/processors/PLUGIN_SYSTEM.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/processors/PLUGIN_SYSTEM.md b/dlclivegui/processors/PLUGIN_SYSTEM.md index fc9cab4..0c0351d 100644 --- a/dlclivegui/processors/PLUGIN_SYSTEM.md +++ b/dlclivegui/processors/PLUGIN_SYSTEM.md @@ -1,4 +1,4 @@ -# DLC Processor Plugin System +# DeepLabCut Processor Plugin System This folder contains a plugin-style architecture for DLC processors that allows GUI tools to discover and instantiate processors dynamically. From 89cc84ae42c9bae9aaac0a5d70d989cebd8394d9 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Tue, 9 Dec 2025 10:36:08 +0100 Subject: [PATCH 27/69] Moved path2models as well as optional device and further DLCProcessor options to config --- README.md | 2 +- dlclivegui/config.py | 16 ++++++++++++++++ dlclivegui/dlc_processor.py | 10 ++++++---- dlclivegui/gui.py | 13 ++++++++----- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a330e0b..1ccf828 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ All GUI fields are automatically synchronized with the configuration file. "index": 0, "fps": 60.0, "exposure": 15000, - "gain": 8.0, + "gain": 8.0, } } ``` diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 7cc629a..6c0b7be 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -49,6 +49,11 @@ class DLCProcessorSettings: """Configuration for DLCLive processing.""" model_path: str = "" + model_directory: str = "." # Default directory for model browser (current dir if not set) + device: Optional[str] = None # Device for inference (e.g., "cuda:0", "cpu"). None = auto + dynamic: tuple = (False, 0.5, 10) # Dynamic cropping: (enabled, margin, max_missing_frames) + resize: float = 1.0 # Resize factor for input frames + precision: str = "FP32" # Inference precision ("FP32", "FP16") additional_options: Dict[str, Any] = field(default_factory=dict) model_type: str = "pytorch" # Only PyTorch models are supported @@ -131,8 +136,19 @@ def from_dict(cls, data: Dict[str, Any]) -> "ApplicationSettings": camera = CameraSettings(**data.get("camera", {})).apply_defaults() dlc_data = dict(data.get("dlc", {})) + # Parse dynamic parameter - can be list or tuple in JSON + dynamic_raw = dlc_data.get("dynamic", [False, 0.5, 10]) + if isinstance(dynamic_raw, (list, tuple)) and len(dynamic_raw) == 3: + dynamic = tuple(dynamic_raw) + else: + dynamic = (False, 0.5, 10) dlc = DLCProcessorSettings( model_path=str(dlc_data.get("model_path", "")), + model_directory=str(dlc_data.get("model_directory", ".")), + device=dlc_data.get("device"), # None if not specified + dynamic=dynamic, + resize=float(dlc_data.get("resize", 1.0)), + precision=str(dlc_data.get("precision", "FP32")), additional_options=dict(dlc_data.get("additional_options", {})), ) recording_data = dict(data.get("recording", {})) diff --git a/dlclivegui/dlc_processor.py b/dlclivegui/dlc_processor.py index 03a9b70..801009a 100644 --- a/dlclivegui/dlc_processor.py +++ b/dlclivegui/dlc_processor.py @@ -247,11 +247,13 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: "model_path": self._settings.model_path, "model_type": self._settings.model_type, "processor": self._processor, - "dynamic": [False, 0.5, 10], - "resize": 1.0, - "precision": "FP32", + "dynamic": list(self._settings.dynamic), + "resize": self._settings.resize, + "precision": self._settings.precision, } - # todo expose more parameters from settings + # Add device if specified in settings + if self._settings.device is not None: + options["device"] = self._settings.device self._dlc = DLCLive(**options) init_inference_start = time.perf_counter() diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 9c27a1b..17f6846 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -54,12 +54,8 @@ from dlclivegui.processors.processor_utils import instantiate_from_scan, scan_processor_folder from dlclivegui.video_recorder import RecorderStats, VideoRecorder -os.environ["CUDA_VISIBLE_DEVICES"] = "1" - logging.basicConfig(level=logging.INFO) -PATH2MODELS = "C:\\Users\\User\\Repos\\DeepLabCut-live-GUI\\dlc_training\\dlclive" - class MainWindow(QMainWindow): """Main application window.""" @@ -695,6 +691,11 @@ def _parse_json(self, value: str) -> dict: def _dlc_settings_from_ui(self) -> DLCProcessorSettings: return DLCProcessorSettings( model_path=self.model_path_edit.text().strip(), + model_directory=self._config.dlc.model_directory, # Preserve from config + device=self._config.dlc.device, # Preserve from config + dynamic=self._config.dlc.dynamic, # Preserve from config + resize=self._config.dlc.resize, # Preserve from config + precision=self._config.dlc.precision, # Preserve from config model_type="pytorch", additional_options=self._parse_json(self.additional_options_edit.toPlainText()), ) @@ -770,10 +771,12 @@ def _save_config_to_path(self, path: Path) -> None: self.statusBar().showMessage(f"Saved configuration to {path}", 5000) def _action_browse_model(self) -> None: + # Use model_directory from config, default to current directory + start_dir = self._config.dlc.model_directory or "." file_path, _ = QFileDialog.getOpenFileName( self, "Select DLCLive model file", - PATH2MODELS, + start_dir, "Model files (*.pt *.pb);;All files (*.*)", ) if file_path: From edf6b8070240b6465d6b373cecd0c43c41c71fb3 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Tue, 9 Dec 2025 11:27:29 +0100 Subject: [PATCH 28/69] remove path from docstring --- dlclivegui/processors/processor_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dlclivegui/processors/processor_utils.py b/dlclivegui/processors/processor_utils.py index b69b3a7..448fa3a 100644 --- a/dlclivegui/processors/processor_utils.py +++ b/dlclivegui/processors/processor_utils.py @@ -10,7 +10,7 @@ def load_processors_from_file(file_path): Args: file_path: Path to Python file containing processors - Returns:/home/as153/work_geneva/mice_ar_tasks/mouse_ar/ctrl/dlc_processors/GUI_INTEGRATION.md + Returns: dict: Dictionary of available processors """ # Load module from file From 37ca392a0faf6ad0b2ccae752db1eaa80933bc3a Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Tue, 9 Dec 2025 18:34:40 +0100 Subject: [PATCH 29/69] fix exposure settings in gentl backend --- dlclivegui/cameras/gentl_backend.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dlclivegui/cameras/gentl_backend.py b/dlclivegui/cameras/gentl_backend.py index cdc798e..87064a5 100644 --- a/dlclivegui/cameras/gentl_backend.py +++ b/dlclivegui/cameras/gentl_backend.py @@ -42,8 +42,11 @@ def __init__(self, settings): self._pixel_format: str = props.get("pixel_format", "Mono8") self._rotate: int = int(props.get("rotate", 0)) % 360 self._crop: Optional[Tuple[int, int, int, int]] = self._parse_crop(props.get("crop")) - self._exposure: Optional[float] = props.get("exposure") - self._gain: Optional[float] = props.get("gain") + # Check settings first (from config), then properties (for backward compatibility) + self._exposure: Optional[float] = ( + settings.exposure if settings.exposure else props.get("exposure") + ) + self._gain: Optional[float] = settings.gain if settings.gain else props.get("gain") self._timeout: float = float(props.get("timeout", 2.0)) self._cti_search_paths: Tuple[str, ...] = self._parse_cti_paths( props.get("cti_search_paths") From 05ff126ac58e4950be9a942e2c69dda04299ad4f Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Thu, 18 Dec 2025 14:18:08 +0100 Subject: [PATCH 30/69] Add logging for camera configuration and error handling in backends - Update configuration settings to include support for single-animal models in DLCProcessorSettings. - Improve user guide with a link to DLC documentation for model export. --- dlclivegui/cameras/aravis_backend.py | 47 ++++++--- dlclivegui/cameras/basler_backend.py | 63 ++++++++++-- dlclivegui/cameras/gentl_backend.py | 142 ++++++++++++++++++++++----- dlclivegui/cameras/opencv_backend.py | 35 +++++-- dlclivegui/config.py | 1 + dlclivegui/dlc_processor.py | 3 +- docs/user_guide.md | 2 +- 7 files changed, 240 insertions(+), 53 deletions(-) diff --git a/dlclivegui/cameras/aravis_backend.py b/dlclivegui/cameras/aravis_backend.py index e033096..e04ad60 100644 --- a/dlclivegui/cameras/aravis_backend.py +++ b/dlclivegui/cameras/aravis_backend.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import time from typing import Optional, Tuple @@ -10,6 +11,8 @@ from .base import CameraBackend +LOG = logging.getLogger(__name__) + try: # pragma: no cover - optional dependency import gi @@ -231,12 +234,13 @@ def _configure_pixel_format(self) -> None: if self._pixel_format in format_map: self._camera.set_pixel_format(format_map[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) - except Exception: - # If pixel format setting fails, continue with default - pass + 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}") def _configure_exposure(self) -> None: """Configure camera exposure time.""" @@ -255,13 +259,19 @@ def _configure_exposure(self) -> None: # Disable auto exposure try: self._camera.set_exposure_time_auto(Aravis.Auto.OFF) - except Exception: - pass + LOG.info("Auto exposure disabled") + except Exception as e: + LOG.warning(f"Failed to disable auto exposure: {e}") # Set exposure time (in microseconds) self._camera.set_exposure_time(exposure) - except Exception: - pass + actual = self._camera.get_exposure_time() + if abs(actual - exposure) > 1.0: # Allow 1μs tolerance + LOG.warning(f"Exposure mismatch: requested {exposure}μs, got {actual}μs") + else: + LOG.info(f"Exposure set to {actual}μs") + except Exception as e: + LOG.warning(f"Failed to set exposure to {exposure}μs: {e}") def _configure_gain(self) -> None: """Configure camera gain.""" @@ -280,13 +290,19 @@ def _configure_gain(self) -> None: # Disable auto gain try: self._camera.set_gain_auto(Aravis.Auto.OFF) - except Exception: - pass + LOG.info("Auto gain disabled") + except Exception as e: + LOG.warning(f"Failed to disable auto gain: {e}") # Set gain value self._camera.set_gain(gain) - except Exception: - pass + actual = self._camera.get_gain() + if abs(actual - gain) > 0.1: # Allow 0.1 tolerance + LOG.warning(f"Gain mismatch: requested {gain}, got {actual}") + else: + LOG.info(f"Gain set to {actual}") + except Exception as e: + LOG.warning(f"Failed to set gain to {gain}: {e}") def _configure_frame_rate(self) -> None: """Configure camera frame rate.""" @@ -296,8 +312,13 @@ def _configure_frame_rate(self) -> None: try: target_fps = float(self.settings.fps) self._camera.set_frame_rate(target_fps) - except Exception: - pass + actual_fps = self._camera.get_frame_rate() + if abs(actual_fps - target_fps) > 0.1: + LOG.warning(f"FPS mismatch: requested {target_fps:.2f}, got {actual_fps:.2f}") + else: + LOG.info(f"Frame rate set to {actual_fps:.2f} FPS") + except Exception as e: + LOG.warning(f"Failed to set frame rate to {self.settings.fps}: {e}") def _resolve_device_label(self) -> Optional[str]: """Get a human-readable device label.""" diff --git a/dlclivegui/cameras/basler_backend.py b/dlclivegui/cameras/basler_backend.py index ec23806..307fa96 100644 --- a/dlclivegui/cameras/basler_backend.py +++ b/dlclivegui/cameras/basler_backend.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import time from typing import Optional, Tuple @@ -9,6 +10,8 @@ from .base import CameraBackend +LOG = logging.getLogger(__name__) + try: # pragma: no cover - optional dependency from pypylon import pylon except Exception: # pragma: no cover - optional dependency @@ -37,29 +40,70 @@ def open(self) -> None: self._camera = pylon.InstantCamera(pylon.TlFactory.GetInstance().CreateDevice(device)) self._camera.Open() + # Configure exposure exposure = self._settings_value("exposure", self.settings.properties) if exposure is not None: - self._camera.ExposureTime.SetValue(float(exposure)) + try: + self._camera.ExposureTime.SetValue(float(exposure)) + actual = self._camera.ExposureTime.GetValue() + if abs(actual - float(exposure)) > 1.0: # Allow 1μs tolerance + LOG.warning(f"Exposure mismatch: requested {exposure}μs, got {actual}μs") + else: + LOG.info(f"Exposure set to {actual}μs") + except Exception as e: + LOG.warning(f"Failed to set exposure to {exposure}μs: {e}") + + # Configure gain gain = self._settings_value("gain", self.settings.properties) if gain is not None: - self._camera.Gain.SetValue(float(gain)) - width = int(self.settings.properties.get("width", self.settings.width)) - height = int(self.settings.properties.get("height", self.settings.height)) - self._camera.Width.SetValue(width) - self._camera.Height.SetValue(height) + try: + self._camera.Gain.SetValue(float(gain)) + actual = self._camera.Gain.GetValue() + if abs(actual - float(gain)) > 0.1: # Allow 0.1 tolerance + LOG.warning(f"Gain mismatch: requested {gain}, got {actual}") + else: + LOG.info(f"Gain set to {actual}") + except Exception as e: + LOG.warning(f"Failed to set gain to {gain}: {e}") + + # Configure resolution + requested_width = int(self.settings.properties.get("width", self.settings.width)) + requested_height = int(self.settings.properties.get("height", self.settings.height)) + try: + self._camera.Width.SetValue(requested_width) + self._camera.Height.SetValue(requested_height) + actual_width = self._camera.Width.GetValue() + actual_height = self._camera.Height.GetValue() + if actual_width != requested_width or actual_height != requested_height: + LOG.warning( + f"Resolution mismatch: requested {requested_width}x{requested_height}, " + f"got {actual_width}x{actual_height}" + ) + else: + LOG.info(f"Resolution set to {actual_width}x{actual_height}") + except Exception as e: + LOG.warning(f"Failed to set resolution to {requested_width}x{requested_height}: {e}") + + # Configure frame rate fps = self._settings_value("fps", self.settings.properties, fallback=self.settings.fps) if fps is not None: try: self._camera.AcquisitionFrameRateEnable.SetValue(True) self._camera.AcquisitionFrameRate.SetValue(float(fps)) - except Exception: - # Some cameras expose different frame-rate features; ignore errors. - pass + actual_fps = self._camera.AcquisitionFrameRate.GetValue() + if abs(actual_fps - float(fps)) > 0.1: + LOG.warning(f"FPS mismatch: requested {fps:.2f}, got {actual_fps:.2f}") + else: + LOG.info(f"Frame rate set to {actual_fps:.2f} FPS") + except Exception as e: + LOG.warning(f"Failed to set frame rate to {fps}: {e}") self._camera.StartGrabbing(pylon.GrabStrategy_LatestImageOnly) self._converter = pylon.ImageFormatConverter() self._converter.OutputPixelFormat = pylon.PixelType_BGR8packed self._converter.OutputBitAlignment = pylon.OutputBitAlignment_MsbAligned + + # Read back final settings try: self.settings.width = int(self._camera.Width.GetValue()) self.settings.height = int(self._camera.Height.GetValue()) @@ -67,6 +111,7 @@ def open(self) -> None: pass try: self.settings.fps = float(self._camera.ResultingFrameRateAbs.GetValue()) + LOG.info(f"Camera configured with resulting FPS: {self.settings.fps:.2f}") except Exception: pass diff --git a/dlclivegui/cameras/gentl_backend.py b/dlclivegui/cameras/gentl_backend.py index 87064a5..274da7a 100644 --- a/dlclivegui/cameras/gentl_backend.py +++ b/dlclivegui/cameras/gentl_backend.py @@ -3,6 +3,7 @@ from __future__ import annotations import glob +import logging import os import time from typing import Iterable, List, Optional, Tuple @@ -12,6 +13,8 @@ from .base import CameraBackend +LOG = logging.getLogger(__name__) + try: # pragma: no cover - optional dependency from harvesters.core import Harvester # type: ignore @@ -342,15 +345,28 @@ def _configure_pixel_format(self, node_map) -> None: try: if self._pixel_format in node_map.PixelFormat.symbolics: node_map.PixelFormat.value = self._pixel_format - except Exception: - pass + actual = node_map.PixelFormat.value + if actual != self._pixel_format: + LOG.warning( + f"Pixel format mismatch: requested '{self._pixel_format}', got '{actual}'" + ) + else: + LOG.info(f"Pixel format set to '{actual}'") + else: + LOG.warning( + f"Pixel format '{self._pixel_format}' not in available formats: " + f"{node_map.PixelFormat.symbolics}" + ) + except Exception as e: + LOG.warning(f"Failed to set pixel format '{self._pixel_format}': {e}") def _configure_resolution(self, node_map) -> None: """Configure camera resolution (width and height).""" if self._resolution is None: return - width, height = self._resolution + requested_width, requested_height = self._resolution + actual_width, actual_height = None, None # Try to set width for width_attr in ("Width", "WidthMax"): @@ -363,15 +379,23 @@ def _configure_resolution(self, node_map) -> None: max_w = node.max inc_w = getattr(node, "inc", 1) # Adjust to valid value - width = self._adjust_to_increment(width, min_w, max_w, inc_w) + width = self._adjust_to_increment(requested_width, min_w, max_w, inc_w) + if width != requested_width: + LOG.info( + f"Width adjusted from {requested_width} to {width} " + f"(min={min_w}, max={max_w}, inc={inc_w})" + ) node.value = int(width) + actual_width = node.value break - except Exception: + except Exception as e: # Try setting without adjustment try: - node.value = int(width) + node.value = int(requested_width) + actual_width = node.value break except Exception: + LOG.warning(f"Failed to set width via {width_attr}: {e}") continue except AttributeError: continue @@ -387,64 +411,130 @@ def _configure_resolution(self, node_map) -> None: max_h = node.max inc_h = getattr(node, "inc", 1) # Adjust to valid value - height = self._adjust_to_increment(height, min_h, max_h, inc_h) + height = self._adjust_to_increment(requested_height, min_h, max_h, inc_h) + if height != requested_height: + LOG.info( + f"Height adjusted from {requested_height} to {height} " + f"(min={min_h}, max={max_h}, inc={inc_h})" + ) node.value = int(height) + actual_height = node.value break - except Exception: + except Exception as e: # Try setting without adjustment try: - node.value = int(height) + node.value = int(requested_height) + actual_height = node.value break except Exception: + LOG.warning(f"Failed to set height via {height_attr}: {e}") continue except AttributeError: continue + # Log final resolution + if actual_width is not None and actual_height is not None: + if actual_width != requested_width or actual_height != requested_height: + LOG.warning( + f"Resolution mismatch: requested {requested_width}x{requested_height}, " + f"got {actual_width}x{actual_height}" + ) + else: + LOG.info(f"Resolution set to {actual_width}x{actual_height}") + else: + LOG.warning( + f"Could not verify resolution setting " + f"(width={actual_width}, height={actual_height})" + ) + def _configure_exposure(self, node_map) -> None: if self._exposure is None: return - for attr in ("ExposureAuto", "ExposureTime", "Exposure"): + + # Try to disable auto exposure first + for attr in ("ExposureAuto",): try: node = getattr(node_map, attr) + node.value = "Off" + LOG.info("Auto exposure disabled") + break except AttributeError: continue + except Exception as e: + LOG.warning(f"Failed to disable auto exposure: {e}") + + # Set exposure value + for attr in ("ExposureTime", "Exposure"): try: - if attr == "ExposureAuto": - node.value = "Off" + node = getattr(node_map, attr) + except AttributeError: + continue + try: + node.value = float(self._exposure) + actual = node.value + if abs(actual - self._exposure) > 1.0: # Allow 1μs tolerance + LOG.warning(f"Exposure mismatch: requested {self._exposure}μs, got {actual}μs") else: - node.value = float(self._exposure) - return - except Exception: + LOG.info(f"Exposure set to {actual}μs") + return + except Exception as e: + LOG.warning(f"Failed to set exposure via {attr}: {e}") continue + LOG.warning(f"Could not set exposure to {self._exposure}μs (no compatible attribute found)") + def _configure_gain(self, node_map) -> None: if self._gain is None: return - for attr in ("GainAuto", "Gain"): + + # Try to disable auto gain first + for attr in ("GainAuto",): + try: + node = getattr(node_map, attr) + node.value = "Off" + LOG.info("Auto gain disabled") + break + except AttributeError: + continue + except Exception as e: + LOG.warning(f"Failed to disable auto gain: {e}") + + # Set gain value + for attr in ("Gain",): try: node = getattr(node_map, attr) except AttributeError: continue try: - if attr == "GainAuto": - node.value = "Off" + node.value = float(self._gain) + actual = node.value + if abs(actual - self._gain) > 0.1: # Allow 0.1 tolerance + LOG.warning(f"Gain mismatch: requested {self._gain}, got {actual}") else: - node.value = float(self._gain) - return - except Exception: + LOG.info(f"Gain set to {actual}") + return + except Exception as e: + LOG.warning(f"Failed to set gain via {attr}: {e}") continue + LOG.warning(f"Could not set gain to {self._gain} (no compatible attribute found)") + def _configure_frame_rate(self, node_map) -> None: if not self.settings.fps: return target = float(self.settings.fps) + + # Try to enable frame rate control for attr in ("AcquisitionFrameRateEnable", "AcquisitionFrameRateControlEnable"): try: getattr(node_map, attr).value = True + LOG.info(f"Frame rate control enabled via {attr}") + break except Exception: continue + # Set frame rate value for attr in ("AcquisitionFrameRate", "ResultingFrameRate", "AcquisitionFrameRateAbs"): try: node = getattr(node_map, attr) @@ -452,10 +542,18 @@ def _configure_frame_rate(self, node_map) -> None: continue try: node.value = target + actual = node.value + if abs(actual - target) > 0.1: + LOG.warning(f"FPS mismatch: requested {target:.2f}, got {actual:.2f}") + else: + LOG.info(f"Frame rate set to {actual:.2f} FPS") return - except Exception: + except Exception as e: + LOG.warning(f"Failed to set frame rate via {attr}: {e}") continue + LOG.warning(f"Could not set frame rate to {target} FPS (no compatible attribute found)") + def _convert_frame(self, frame: np.ndarray) -> np.ndarray: if frame.dtype != np.uint8: max_val = float(frame.max()) if frame.size else 0.0 diff --git a/dlclivegui/cameras/opencv_backend.py b/dlclivegui/cameras/opencv_backend.py index 651eeab..74d50fc 100644 --- a/dlclivegui/cameras/opencv_backend.py +++ b/dlclivegui/cameras/opencv_backend.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import time from typing import Tuple @@ -10,6 +11,8 @@ from .base import CameraBackend +LOG = logging.getLogger(__name__) + class OpenCVCameraBackend(CameraBackend): """Fallback backend using :mod:`cv2.VideoCapture`.""" @@ -107,12 +110,25 @@ def _configure_capture(self) -> None: # Set resolution (width x height) width, height = self._resolution - self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, float(width)) - self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, float(height)) + if not self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, float(width)): + LOG.warning(f"Failed to set frame width to {width}") + if not self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, float(height)): + LOG.warning(f"Failed to set frame height to {height}") + + # Verify resolution was set correctly + actual_width = int(self._capture.get(cv2.CAP_PROP_FRAME_WIDTH)) + actual_height = int(self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT)) + if actual_width != width or actual_height != height: + LOG.warning( + f"Resolution mismatch: requested {width}x{height}, " + f"got {actual_width}x{actual_height}" + ) # Set FPS if specified - if self.settings.fps: - self._capture.set(cv2.CAP_PROP_FPS, float(self.settings.fps)) + requested_fps = self.settings.fps + if requested_fps: + if not self._capture.set(cv2.CAP_PROP_FPS, float(requested_fps)): + LOG.warning(f"Failed to set FPS to {requested_fps}") # Set any additional properties from the properties dict for prop, value in self.settings.properties.items(): @@ -120,14 +136,19 @@ def _configure_capture(self) -> None: continue try: prop_id = int(prop) - except (TypeError, ValueError): + except (TypeError, ValueError) as e: + LOG.warning(f"Could not parse property ID: {prop} ({e})") continue - self._capture.set(prop_id, float(value)) + if not self._capture.set(prop_id, float(value)): + LOG.warning(f"Failed to set property {prop_id} to {value}") - # Update actual FPS from camera + # Update actual FPS from camera and warn if different from requested actual_fps = self._capture.get(cv2.CAP_PROP_FPS) if actual_fps: + if requested_fps and abs(actual_fps - requested_fps) > 0.1: + LOG.warning(f"FPS mismatch: requested {requested_fps:.2f}, got {actual_fps:.2f}") self.settings.fps = float(actual_fps) + LOG.info(f"Camera configured with FPS: {actual_fps:.2f}") def _resolve_backend(self, backend: str | None) -> int: if backend is None: diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 6c0b7be..2440955 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -56,6 +56,7 @@ class DLCProcessorSettings: precision: str = "FP32" # Inference precision ("FP32", "FP16") additional_options: Dict[str, Any] = field(default_factory=dict) model_type: str = "pytorch" # Only PyTorch models are supported + single_animal: bool = True # Only single-animal models are supported @dataclass diff --git a/dlclivegui/dlc_processor.py b/dlclivegui/dlc_processor.py index 801009a..b0fdd45 100644 --- a/dlclivegui/dlc_processor.py +++ b/dlclivegui/dlc_processor.py @@ -22,7 +22,8 @@ try: # pragma: no cover - optional dependency from dlclive import DLCLive # type: ignore -except Exception: # pragma: no cover - handled gracefully +except Exception as e: # pragma: no cover - handled gracefully + LOGGER.error(f"dlclive package could not be imported: {e}") DLCLive = None # type: ignore[assignment] diff --git a/docs/user_guide.md b/docs/user_guide.md index 0346d53..b6f1905 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -147,7 +147,7 @@ Select if camera is mounted at an angle: ### Prerequisites -1. Exported DLCLive model (see DLC documentation) +1. Exported DLCLive model (see [DLC documentation](https://github.com/DeepLabCut/DeepLabCut/blob/main/docs/HelperFunctions.md#model-export-function)) 2. DeepLabCut-live installed (`pip install deeplabcut-live`) 3. Camera preview running From 563405955abcf1e3ba8fdfbe79df80c666844eba Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Thu, 18 Dec 2025 16:33:35 +0100 Subject: [PATCH 31/69] Set default device to 'auto' in DLCProcessorSettings and include single_animal in DLCLiveProcessor settings --- dlclivegui/config.py | 2 +- dlclivegui/dlc_processor.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 2440955..b17b434 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -50,7 +50,7 @@ class DLCProcessorSettings: model_path: str = "" model_directory: str = "." # Default directory for model browser (current dir if not set) - device: Optional[str] = None # Device for inference (e.g., "cuda:0", "cpu"). None = auto + device: Optional[str] = "auto" # Device for inference (e.g., "cuda:0", "cpu"). None should be auto, but might default to cpu dynamic: tuple = (False, 0.5, 10) # Dynamic cropping: (enabled, margin, max_missing_frames) resize: float = 1.0 # Resize factor for input frames precision: str = "FP32" # Inference precision ("FP32", "FP16") diff --git a/dlclivegui/dlc_processor.py b/dlclivegui/dlc_processor.py index b0fdd45..ac8985e 100644 --- a/dlclivegui/dlc_processor.py +++ b/dlclivegui/dlc_processor.py @@ -251,6 +251,7 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: "dynamic": list(self._settings.dynamic), "resize": self._settings.resize, "precision": self._settings.precision, + "single_animal": self._settings.single_animal, } # Add device if specified in settings if self._settings.device is not None: From 1b8622c64bffa1a570d33c08f7b93035caf7fa87 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Mon, 22 Dec 2025 12:38:12 +0100 Subject: [PATCH 32/69] Add multi-camera controller for DLC Live GUI - Implemented MultiCameraController to manage multiple cameras simultaneously. --- dlclivegui/__init__.py | 14 +- dlclivegui/camera_config_dialog.py | 481 +++++++++++++++ dlclivegui/camera_controller.py | 340 ----------- dlclivegui/config.py | 91 ++- dlclivegui/gui.py | 842 ++++++++++---------------- dlclivegui/multi_camera_controller.py | 408 +++++++++++++ 6 files changed, 1302 insertions(+), 874 deletions(-) create mode 100644 dlclivegui/camera_config_dialog.py delete mode 100644 dlclivegui/camera_controller.py create mode 100644 dlclivegui/multi_camera_controller.py diff --git a/dlclivegui/__init__.py b/dlclivegui/__init__.py index d91f23b..c803be5 100644 --- a/dlclivegui/__init__.py +++ b/dlclivegui/__init__.py @@ -1,13 +1,25 @@ """DeepLabCut Live GUI package.""" -from .config import ApplicationSettings, CameraSettings, DLCProcessorSettings, RecordingSettings +from .camera_config_dialog import CameraConfigDialog +from .config import ( + ApplicationSettings, + CameraSettings, + DLCProcessorSettings, + MultiCameraSettings, + RecordingSettings, +) from .gui import MainWindow, main +from .multi_camera_controller import MultiCameraController, MultiFrameData __all__ = [ "ApplicationSettings", "CameraSettings", "DLCProcessorSettings", + "MultiCameraSettings", "RecordingSettings", "MainWindow", + "MultiCameraController", + "MultiFrameData", + "CameraConfigDialog", "main", ] diff --git a/dlclivegui/camera_config_dialog.py b/dlclivegui/camera_config_dialog.py new file mode 100644 index 0000000..b0acb21 --- /dev/null +++ b/dlclivegui/camera_config_dialog.py @@ -0,0 +1,481 @@ +"""Camera configuration dialog for multi-camera setup.""" + +from __future__ import annotations + +import logging +from typing import List, Optional + +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QDoubleSpinBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QMessageBox, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from dlclivegui.cameras import CameraFactory +from dlclivegui.cameras.factory import DetectedCamera +from dlclivegui.config import CameraSettings, MultiCameraSettings + +LOGGER = logging.getLogger(__name__) + + +class CameraConfigDialog(QDialog): + """Dialog for configuring multiple cameras.""" + + MAX_CAMERAS = 4 + settings_changed = pyqtSignal(object) # MultiCameraSettings + + def __init__( + self, + parent: Optional[QWidget] = None, + multi_camera_settings: Optional[MultiCameraSettings] = None, + ): + super().__init__(parent) + self.setWindowTitle("Configure Cameras") + self.setMinimumSize(800, 600) + + self._multi_camera_settings = ( + multi_camera_settings if multi_camera_settings else MultiCameraSettings() + ) + self._detected_cameras: List[DetectedCamera] = [] + self._current_edit_index: Optional[int] = None + + self._setup_ui() + self._populate_from_settings() + self._connect_signals() + + def _setup_ui(self) -> None: + # Main layout for the dialog + main_layout = QVBoxLayout(self) + + # Horizontal layout for left and right panels + panels_layout = QHBoxLayout() + + # Left panel: Camera list and controls + left_panel = QWidget() + left_layout = QVBoxLayout(left_panel) + + # Active cameras list + active_group = QGroupBox("Active Cameras") + active_layout = QVBoxLayout(active_group) + + self.active_cameras_list = QListWidget() + self.active_cameras_list.setMinimumWidth(250) + active_layout.addWidget(self.active_cameras_list) + + # Buttons for managing active cameras + list_buttons = QHBoxLayout() + self.remove_camera_btn = QPushButton("Remove") + self.remove_camera_btn.setEnabled(False) + self.move_up_btn = QPushButton("↑") + self.move_up_btn.setEnabled(False) + self.move_down_btn = QPushButton("↓") + self.move_down_btn.setEnabled(False) + list_buttons.addWidget(self.remove_camera_btn) + list_buttons.addWidget(self.move_up_btn) + list_buttons.addWidget(self.move_down_btn) + active_layout.addLayout(list_buttons) + + left_layout.addWidget(active_group) + + # Available cameras section + available_group = QGroupBox("Available Cameras") + available_layout = QVBoxLayout(available_group) + + # Backend selection + backend_layout = QHBoxLayout() + backend_layout.addWidget(QLabel("Backend:")) + self.backend_combo = QComboBox() + availability = CameraFactory.available_backends() + for backend in CameraFactory.backend_names(): + label = backend + if not availability.get(backend, True): + label = f"{backend} (unavailable)" + self.backend_combo.addItem(label, backend) + backend_layout.addWidget(self.backend_combo) + self.refresh_btn = QPushButton("Refresh") + backend_layout.addWidget(self.refresh_btn) + available_layout.addLayout(backend_layout) + + self.available_cameras_list = QListWidget() + available_layout.addWidget(self.available_cameras_list) + + self.add_camera_btn = QPushButton("Add Selected Camera →") + self.add_camera_btn.setEnabled(False) + available_layout.addWidget(self.add_camera_btn) + + left_layout.addWidget(available_group) + + # Right panel: Camera settings editor + right_panel = QWidget() + right_layout = QVBoxLayout(right_panel) + + settings_group = QGroupBox("Camera Settings") + self.settings_form = QFormLayout(settings_group) + + self.cam_enabled_checkbox = QCheckBox("Enabled") + self.cam_enabled_checkbox.setChecked(True) + self.settings_form.addRow(self.cam_enabled_checkbox) + + self.cam_name_label = QLabel("Camera 0") + self.cam_name_label.setStyleSheet("font-weight: bold; font-size: 14px;") + self.settings_form.addRow("Name:", self.cam_name_label) + + self.cam_index_label = QLabel("0") + self.settings_form.addRow("Index:", self.cam_index_label) + + self.cam_backend_label = QLabel("opencv") + self.settings_form.addRow("Backend:", self.cam_backend_label) + + self.cam_fps = QDoubleSpinBox() + self.cam_fps.setRange(1.0, 240.0) + self.cam_fps.setDecimals(2) + self.cam_fps.setValue(30.0) + self.settings_form.addRow("Frame Rate:", self.cam_fps) + + self.cam_exposure = QSpinBox() + self.cam_exposure.setRange(0, 1000000) + self.cam_exposure.setValue(0) + self.cam_exposure.setSpecialValueText("Auto") + self.cam_exposure.setSuffix(" μs") + self.settings_form.addRow("Exposure:", self.cam_exposure) + + self.cam_gain = QDoubleSpinBox() + self.cam_gain.setRange(0.0, 100.0) + self.cam_gain.setValue(0.0) + self.cam_gain.setSpecialValueText("Auto") + self.cam_gain.setDecimals(2) + self.settings_form.addRow("Gain:", self.cam_gain) + + # Rotation + self.cam_rotation = QComboBox() + self.cam_rotation.addItem("0° (default)", 0) + self.cam_rotation.addItem("90°", 90) + self.cam_rotation.addItem("180°", 180) + self.cam_rotation.addItem("270°", 270) + self.settings_form.addRow("Rotation:", self.cam_rotation) + + # Crop settings + crop_widget = QWidget() + crop_layout = QHBoxLayout(crop_widget) + crop_layout.setContentsMargins(0, 0, 0, 0) + + self.cam_crop_x0 = QSpinBox() + self.cam_crop_x0.setRange(0, 7680) + self.cam_crop_x0.setPrefix("x0:") + self.cam_crop_x0.setSpecialValueText("x0:None") + crop_layout.addWidget(self.cam_crop_x0) + + self.cam_crop_y0 = QSpinBox() + self.cam_crop_y0.setRange(0, 4320) + self.cam_crop_y0.setPrefix("y0:") + self.cam_crop_y0.setSpecialValueText("y0:None") + crop_layout.addWidget(self.cam_crop_y0) + + self.cam_crop_x1 = QSpinBox() + self.cam_crop_x1.setRange(0, 7680) + self.cam_crop_x1.setPrefix("x1:") + self.cam_crop_x1.setSpecialValueText("x1:None") + crop_layout.addWidget(self.cam_crop_x1) + + self.cam_crop_y1 = QSpinBox() + self.cam_crop_y1.setRange(0, 4320) + self.cam_crop_y1.setPrefix("y1:") + self.cam_crop_y1.setSpecialValueText("y1:None") + crop_layout.addWidget(self.cam_crop_y1) + + self.settings_form.addRow("Crop (x0,y0,x1,y1):", crop_widget) + + self.apply_settings_btn = QPushButton("Apply Settings") + self.apply_settings_btn.setEnabled(False) + self.settings_form.addRow(self.apply_settings_btn) + + right_layout.addWidget(settings_group) + right_layout.addStretch(1) + + # Dialog buttons + button_layout = QHBoxLayout() + self.ok_btn = QPushButton("OK") + self.cancel_btn = QPushButton("Cancel") + button_layout.addStretch(1) + button_layout.addWidget(self.ok_btn) + button_layout.addWidget(self.cancel_btn) + + # Add panels to horizontal layout + panels_layout.addWidget(left_panel, stretch=1) + panels_layout.addWidget(right_panel, stretch=1) + + # Add everything to main layout + main_layout.addLayout(panels_layout) + main_layout.addLayout(button_layout) + + def _connect_signals(self) -> None: + self.backend_combo.currentIndexChanged.connect(self._on_backend_changed) + self.refresh_btn.clicked.connect(self._refresh_available_cameras) + self.add_camera_btn.clicked.connect(self._add_selected_camera) + self.remove_camera_btn.clicked.connect(self._remove_selected_camera) + self.move_up_btn.clicked.connect(self._move_camera_up) + self.move_down_btn.clicked.connect(self._move_camera_down) + self.active_cameras_list.currentRowChanged.connect(self._on_active_camera_selected) + self.available_cameras_list.currentRowChanged.connect(self._on_available_camera_selected) + self.apply_settings_btn.clicked.connect(self._apply_camera_settings) + self.ok_btn.clicked.connect(self._on_ok_clicked) + self.cancel_btn.clicked.connect(self.reject) + + def _populate_from_settings(self) -> None: + """Populate the dialog from existing settings.""" + self.active_cameras_list.clear() + for cam in self._multi_camera_settings.cameras: + item = QListWidgetItem(self._format_camera_label(cam)) + item.setData(Qt.ItemDataRole.UserRole, cam) + if not cam.enabled: + item.setForeground(Qt.GlobalColor.gray) + self.active_cameras_list.addItem(item) + + self._refresh_available_cameras() + self._update_button_states() + + def _format_camera_label(self, cam: CameraSettings) -> str: + """Format camera label for display.""" + status = "✓" if cam.enabled else "○" + return f"{status} {cam.name} [{cam.backend}:{cam.index}]" + + def _on_backend_changed(self, _index: int) -> None: + self._refresh_available_cameras() + + def _refresh_available_cameras(self) -> None: + """Refresh the list of available cameras.""" + backend = self.backend_combo.currentData() + if not backend: + backend = self.backend_combo.currentText().split()[0] + + self.available_cameras_list.clear() + self._detected_cameras = CameraFactory.detect_cameras(backend, max_devices=10) + + for cam in self._detected_cameras: + item = QListWidgetItem(f"{cam.label} (index {cam.index})") + item.setData(Qt.ItemDataRole.UserRole, cam) + self.available_cameras_list.addItem(item) + + self._update_button_states() + + def _on_available_camera_selected(self, row: int) -> None: + self.add_camera_btn.setEnabled(row >= 0) + + def _on_active_camera_selected(self, row: int) -> None: + """Handle selection of an active camera.""" + self._current_edit_index = row + self._update_button_states() + + if row < 0 or row >= self.active_cameras_list.count(): + self._clear_settings_form() + return + + item = self.active_cameras_list.item(row) + cam = item.data(Qt.ItemDataRole.UserRole) + if cam: + self._load_camera_to_form(cam) + + def _load_camera_to_form(self, cam: CameraSettings) -> None: + """Load camera settings into the form.""" + self.cam_enabled_checkbox.setChecked(cam.enabled) + self.cam_name_label.setText(cam.name) + self.cam_index_label.setText(str(cam.index)) + self.cam_backend_label.setText(cam.backend) + self.cam_fps.setValue(cam.fps) + self.cam_exposure.setValue(cam.exposure) + self.cam_gain.setValue(cam.gain) + + # Set rotation + rot_index = self.cam_rotation.findData(cam.rotation) + if rot_index >= 0: + self.cam_rotation.setCurrentIndex(rot_index) + + self.cam_crop_x0.setValue(cam.crop_x0) + self.cam_crop_y0.setValue(cam.crop_y0) + self.cam_crop_x1.setValue(cam.crop_x1) + self.cam_crop_y1.setValue(cam.crop_y1) + + self.apply_settings_btn.setEnabled(True) + + def _clear_settings_form(self) -> None: + """Clear the settings form.""" + self.cam_enabled_checkbox.setChecked(True) + self.cam_name_label.setText("") + self.cam_index_label.setText("") + self.cam_backend_label.setText("") + self.cam_fps.setValue(30.0) + self.cam_exposure.setValue(0) + self.cam_gain.setValue(0.0) + self.cam_rotation.setCurrentIndex(0) + self.cam_crop_x0.setValue(0) + self.cam_crop_y0.setValue(0) + self.cam_crop_x1.setValue(0) + self.cam_crop_y1.setValue(0) + self.apply_settings_btn.setEnabled(False) + + def _add_selected_camera(self) -> None: + """Add the selected available camera to active cameras.""" + row = self.available_cameras_list.currentRow() + if row < 0: + return + + # Check limit + active_count = len( + [ + i + for i in range(self.active_cameras_list.count()) + if self.active_cameras_list.item(i).data(Qt.ItemDataRole.UserRole).enabled + ] + ) + if active_count >= self.MAX_CAMERAS: + QMessageBox.warning( + self, + "Maximum Cameras", + f"Maximum of {self.MAX_CAMERAS} active cameras allowed.", + ) + return + + item = self.available_cameras_list.item(row) + detected = item.data(Qt.ItemDataRole.UserRole) + backend = self.backend_combo.currentData() or "opencv" + + # Create new camera settings + new_cam = CameraSettings( + name=detected.label, + index=detected.index, + fps=30.0, + backend=backend, + exposure=0, + gain=0.0, + enabled=True, + ) + + self._multi_camera_settings.cameras.append(new_cam) + + # Add to list + new_item = QListWidgetItem(self._format_camera_label(new_cam)) + new_item.setData(Qt.ItemDataRole.UserRole, new_cam) + self.active_cameras_list.addItem(new_item) + self.active_cameras_list.setCurrentItem(new_item) + + self._update_button_states() + + def _remove_selected_camera(self) -> None: + """Remove the selected camera from active cameras.""" + row = self.active_cameras_list.currentRow() + if row < 0: + return + + self.active_cameras_list.takeItem(row) + if row < len(self._multi_camera_settings.cameras): + del self._multi_camera_settings.cameras[row] + + self._current_edit_index = None + self._clear_settings_form() + self._update_button_states() + + def _move_camera_up(self) -> None: + """Move selected camera up in the list.""" + row = self.active_cameras_list.currentRow() + if row <= 0: + return + + item = self.active_cameras_list.takeItem(row) + self.active_cameras_list.insertItem(row - 1, item) + self.active_cameras_list.setCurrentRow(row - 1) + + # Update settings list + cams = self._multi_camera_settings.cameras + cams[row], cams[row - 1] = cams[row - 1], cams[row] + + def _move_camera_down(self) -> None: + """Move selected camera down in the list.""" + row = self.active_cameras_list.currentRow() + if row < 0 or row >= self.active_cameras_list.count() - 1: + return + + item = self.active_cameras_list.takeItem(row) + self.active_cameras_list.insertItem(row + 1, item) + self.active_cameras_list.setCurrentRow(row + 1) + + # Update settings list + cams = self._multi_camera_settings.cameras + cams[row], cams[row + 1] = cams[row + 1], cams[row] + + def _apply_camera_settings(self) -> None: + """Apply current form settings to the selected camera.""" + if self._current_edit_index is None: + return + + row = self._current_edit_index + if row < 0 or row >= len(self._multi_camera_settings.cameras): + return + + cam = self._multi_camera_settings.cameras[row] + cam.enabled = self.cam_enabled_checkbox.isChecked() + cam.fps = self.cam_fps.value() + cam.exposure = self.cam_exposure.value() + cam.gain = self.cam_gain.value() + cam.rotation = self.cam_rotation.currentData() or 0 + cam.crop_x0 = self.cam_crop_x0.value() + cam.crop_y0 = self.cam_crop_y0.value() + cam.crop_x1 = self.cam_crop_x1.value() + cam.crop_y1 = self.cam_crop_y1.value() + + # Update list item + item = self.active_cameras_list.item(row) + item.setText(self._format_camera_label(cam)) + item.setData(Qt.ItemDataRole.UserRole, cam) + if not cam.enabled: + item.setForeground(Qt.GlobalColor.gray) + else: + item.setForeground(Qt.GlobalColor.black) + + self._update_button_states() + + def _update_button_states(self) -> None: + """Update button enabled states.""" + active_row = self.active_cameras_list.currentRow() + has_active_selection = active_row >= 0 + + self.remove_camera_btn.setEnabled(has_active_selection) + self.move_up_btn.setEnabled(has_active_selection and active_row > 0) + self.move_down_btn.setEnabled( + has_active_selection and active_row < self.active_cameras_list.count() - 1 + ) + + available_row = self.available_cameras_list.currentRow() + self.add_camera_btn.setEnabled(available_row >= 0) + + def _on_ok_clicked(self) -> None: + """Handle OK button click.""" + # Validate that we have at least one enabled camera if any cameras are configured + if self._multi_camera_settings.cameras: + active = self._multi_camera_settings.get_active_cameras() + if not active: + QMessageBox.warning( + self, + "No Active Cameras", + "Please enable at least one camera or remove all cameras.", + ) + return + + self.settings_changed.emit(self._multi_camera_settings) + self.accept() + + def get_settings(self) -> MultiCameraSettings: + """Get the current multi-camera settings.""" + return self._multi_camera_settings diff --git a/dlclivegui/camera_controller.py b/dlclivegui/camera_controller.py deleted file mode 100644 index 7c80df1..0000000 --- a/dlclivegui/camera_controller.py +++ /dev/null @@ -1,340 +0,0 @@ -"""Camera management for the DLC Live GUI.""" - -from __future__ import annotations - -import logging -import time -from dataclasses import dataclass -from threading import Event -from typing import Optional - -import numpy as np -from PyQt6.QtCore import QMetaObject, QObject, Qt, QThread, pyqtSignal, pyqtSlot - -from dlclivegui.cameras import CameraFactory -from dlclivegui.cameras.base import CameraBackend -from dlclivegui.config import CameraSettings - -LOGGER = logging.getLogger(__name__) - - -@dataclass -class FrameData: - """Container for a captured frame.""" - - image: np.ndarray - timestamp: float - - -class CameraWorker(QObject): - """Worker object running inside a :class:`QThread`.""" - - frame_captured = pyqtSignal(object) - started = pyqtSignal(object) - error_occurred = pyqtSignal(str) - warning_occurred = pyqtSignal(str) - finished = pyqtSignal() - - def __init__(self, settings: CameraSettings): - super().__init__() - self._settings = settings - self._stop_event = Event() - self._backend: Optional[CameraBackend] = None - - # Error recovery settings - self._max_consecutive_errors = 5 - self._max_reconnect_attempts = 3 - self._retry_delay = 0.1 # seconds - self._reconnect_delay = 1.0 # seconds - - # Frame validation - self._expected_frame_size: Optional[tuple[int, int]] = None # (height, width) - - @pyqtSlot() - def run(self) -> None: - self._stop_event.clear() - - # Initialize camera - if not self._initialize_camera(): - self.finished.emit() - return - - self.started.emit(self._settings) - - consecutive_errors = 0 - reconnect_attempts = 0 - - while not self._stop_event.is_set(): - try: - frame, timestamp = self._backend.read() - - # Validate frame size - if not self._validate_frame_size(frame): - consecutive_errors += 1 - LOGGER.warning( - f"Frame size validation failed ({consecutive_errors}/{self._max_consecutive_errors})" - ) - if consecutive_errors >= self._max_consecutive_errors: - self.error_occurred.emit("Too many frames with incorrect size") - break - time.sleep(self._retry_delay) - continue - - consecutive_errors = 0 # Reset error count on success - reconnect_attempts = 0 # Reset reconnect attempts on success - - except TimeoutError as exc: - consecutive_errors += 1 - LOGGER.warning( - f"Camera frame timeout ({consecutive_errors}/{self._max_consecutive_errors}): {exc}" - ) - - if self._stop_event.is_set(): - break - - # Handle timeout with retry logic - if consecutive_errors < self._max_consecutive_errors: - self.warning_occurred.emit( - f"Frame timeout (retry {consecutive_errors}/{self._max_consecutive_errors})" - ) - time.sleep(self._retry_delay) - continue - else: - # Too many consecutive errors, try to reconnect - LOGGER.error(f"Too many consecutive timeouts, attempting reconnection...") - if self._attempt_reconnection(): - consecutive_errors = 0 - reconnect_attempts += 1 - self.warning_occurred.emit( - f"Camera reconnected (attempt {reconnect_attempts})" - ) - continue - else: - reconnect_attempts += 1 - if reconnect_attempts >= self._max_reconnect_attempts: - self.error_occurred.emit( - f"Camera reconnection failed after {reconnect_attempts} attempts" - ) - break - else: - consecutive_errors = 0 # Reset to try again - self.warning_occurred.emit( - f"Reconnection attempt {reconnect_attempts} failed, retrying..." - ) - time.sleep(self._reconnect_delay) - continue - - except Exception as exc: - consecutive_errors += 1 - LOGGER.warning( - f"Camera read error ({consecutive_errors}/{self._max_consecutive_errors}): {exc}" - ) - - if self._stop_event.is_set(): - break - - # Handle general errors with retry logic - if consecutive_errors < self._max_consecutive_errors: - self.warning_occurred.emit( - f"Frame read error (retry {consecutive_errors}/{self._max_consecutive_errors})" - ) - time.sleep(self._retry_delay) - continue - else: - # Too many consecutive errors, try to reconnect - LOGGER.error(f"Too many consecutive errors, attempting reconnection...") - if self._attempt_reconnection(): - consecutive_errors = 0 - reconnect_attempts += 1 - self.warning_occurred.emit( - f"Camera reconnected (attempt {reconnect_attempts})" - ) - continue - else: - reconnect_attempts += 1 - if reconnect_attempts >= self._max_reconnect_attempts: - self.error_occurred.emit( - f"Camera failed after {reconnect_attempts} reconnection attempts: {exc}" - ) - break - else: - consecutive_errors = 0 # Reset to try again - self.warning_occurred.emit( - f"Reconnection attempt {reconnect_attempts} failed, retrying..." - ) - time.sleep(self._reconnect_delay) - continue - - if self._stop_event.is_set(): - break - - self.frame_captured.emit(FrameData(frame, timestamp)) - - # Cleanup - self._cleanup_camera() - self.finished.emit() - - def _initialize_camera(self) -> bool: - """Initialize the camera backend. Returns True on success, False on failure.""" - try: - self._backend = CameraFactory.create(self._settings) - self._backend.open() - # Don't set expected frame size - will be established from first frame - self._expected_frame_size = None - LOGGER.info( - "Camera initialized successfully, frame size will be determined from camera" - ) - return True - except Exception as exc: - LOGGER.exception("Failed to initialize camera", exc_info=exc) - self.error_occurred.emit(f"Failed to initialize camera: {exc}") - return False - - def _validate_frame_size(self, frame: np.ndarray) -> bool: - """Validate that the frame has the expected size. Returns True if valid.""" - if frame is None or frame.size == 0: - LOGGER.warning("Received empty frame") - return False - - actual_size = (frame.shape[0], frame.shape[1]) # (height, width) - - if self._expected_frame_size is None: - # First frame - establish expected size - self._expected_frame_size = actual_size - LOGGER.info( - f"Established expected frame size: (h={actual_size[0]}, w={actual_size[1]})" - ) - return True - - if actual_size != self._expected_frame_size: - LOGGER.warning( - f"Frame size mismatch: expected (h={self._expected_frame_size[0]}, w={self._expected_frame_size[1]}), " - f"got (h={actual_size[0]}, w={actual_size[1]}). Camera may have reconnected with different resolution." - ) - # Update expected size for future frames after reconnection - self._expected_frame_size = actual_size - LOGGER.info(f"Updated expected frame size to: (h={actual_size[0]}, w={actual_size[1]})") - # Emit warning so GUI can restart recording if needed - self.warning_occurred.emit( - f"Camera resolution changed to {actual_size[1]}x{actual_size[0]}" - ) - return True # Accept the new size - - return True - - def _attempt_reconnection(self) -> bool: - """Attempt to reconnect to the camera. Returns True on success, False on failure.""" - if self._stop_event.is_set(): - return False - - LOGGER.info("Attempting camera reconnection...") - - # Close existing connection - self._cleanup_camera() - - # Wait longer before reconnecting to let the device fully release - LOGGER.info(f"Waiting {self._reconnect_delay}s before reconnecting...") - time.sleep(self._reconnect_delay) - - if self._stop_event.is_set(): - return False - - # Try to reinitialize (this will also reset expected frame size) - try: - self._backend = CameraFactory.create(self._settings) - self._backend.open() - # Reset expected frame size - will be re-established on first frame - self._expected_frame_size = None - LOGGER.info("Camera reconnection successful, frame size will be determined from camera") - return True - except Exception as exc: - LOGGER.warning(f"Camera reconnection failed: {exc}") - return False - - def _cleanup_camera(self) -> None: - """Clean up camera backend resources.""" - if self._backend is not None: - try: - self._backend.close() - except Exception as exc: - LOGGER.warning(f"Error closing camera: {exc}") - self._backend = None - - @pyqtSlot() - def stop(self) -> None: - self._stop_event.set() - if self._backend is not None: - try: - self._backend.stop() - except Exception: - pass - - -class CameraController(QObject): - """High level controller that manages a camera worker thread.""" - - frame_ready = pyqtSignal(object) - started = pyqtSignal(object) - stopped = pyqtSignal() - error = pyqtSignal(str) - warning = pyqtSignal(str) - - def __init__(self) -> None: - super().__init__() - self._thread: Optional[QThread] = None - self._worker: Optional[CameraWorker] = None - self._pending_settings: Optional[CameraSettings] = None - - def is_running(self) -> bool: - return self._thread is not None and self._thread.isRunning() - - def start(self, settings: CameraSettings) -> None: - if self.is_running(): - self._pending_settings = settings - self.stop(preserve_pending=True) - return - self._pending_settings = None - self._start_worker(settings) - - def stop(self, wait: bool = False, *, preserve_pending: bool = False) -> None: - if not self.is_running(): - if not preserve_pending: - self._pending_settings = None - return - assert self._worker is not None - assert self._thread is not None - if not preserve_pending: - self._pending_settings = None - QMetaObject.invokeMethod( - self._worker, - "stop", - Qt.ConnectionType.QueuedConnection, - ) - self._worker.stop() - self._thread.quit() - if wait: - self._thread.wait() - - def _start_worker(self, settings: CameraSettings) -> None: - self._thread = QThread() - self._worker = CameraWorker(settings) - self._worker.moveToThread(self._thread) - self._thread.started.connect(self._worker.run) - self._worker.frame_captured.connect(self.frame_ready) - self._worker.started.connect(self.started) - self._worker.error_occurred.connect(self.error) - self._worker.warning_occurred.connect(self.warning) - self._worker.finished.connect(self._thread.quit) - self._worker.finished.connect(self._worker.deleteLater) - self._thread.finished.connect(self._cleanup) - self._thread.start() - - @pyqtSlot() - def _cleanup(self) -> None: - self._thread = None - self._worker = None - self.stopped.emit() - if self._pending_settings is not None: - pending = self._pending_settings - self._pending_settings = None - self.start(pending) diff --git a/dlclivegui/config.py b/dlclivegui/config.py index b17b434..9e32a20 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -23,6 +23,8 @@ class CameraSettings: crop_x1: int = 0 # Right edge of crop region (0 = no crop) crop_y1: int = 0 # Bottom edge of crop region (0 = no crop) max_devices: int = 3 # Maximum number of devices to probe during detection + rotation: int = 0 # Rotation degrees (0, 90, 180, 270) + enabled: bool = True # Whether this camera is active in multi-camera mode properties: Dict[str, Any] = field(default_factory=dict) def apply_defaults(self) -> "CameraSettings": @@ -43,6 +45,74 @@ def get_crop_region(self) -> Optional[tuple[int, int, int, int]]: return None return (self.crop_x0, self.crop_y0, self.crop_x1, self.crop_y1) + def copy(self) -> "CameraSettings": + """Create a copy of this settings object.""" + return CameraSettings( + name=self.name, + index=self.index, + fps=self.fps, + backend=self.backend, + exposure=self.exposure, + gain=self.gain, + crop_x0=self.crop_x0, + crop_y0=self.crop_y0, + crop_x1=self.crop_x1, + crop_y1=self.crop_y1, + max_devices=self.max_devices, + rotation=self.rotation, + enabled=self.enabled, + properties=dict(self.properties), + ) + + +@dataclass +class MultiCameraSettings: + """Configuration for multiple cameras.""" + + cameras: list = field(default_factory=list) # List of CameraSettings + max_cameras: int = 4 # Maximum number of cameras that can be active + tile_layout: str = "auto" # "auto", "2x2", "1x4", "4x1" + + def get_active_cameras(self) -> list: + """Get list of enabled cameras.""" + return [cam for cam in self.cameras if cam.enabled] + + def add_camera(self, settings: CameraSettings) -> bool: + """Add a camera to the configuration. Returns True if successful.""" + if len(self.get_active_cameras()) >= self.max_cameras and settings.enabled: + return False + self.cameras.append(settings) + return True + + def remove_camera(self, index: int) -> bool: + """Remove camera at the given list index.""" + if 0 <= index < len(self.cameras): + del self.cameras[index] + return True + return False + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "MultiCameraSettings": + """Create MultiCameraSettings from a dictionary.""" + cameras = [] + for cam_data in data.get("cameras", []): + cam = CameraSettings(**cam_data) + cam.apply_defaults() + cameras.append(cam) + return cls( + cameras=cameras, + max_cameras=data.get("max_cameras", 4), + tile_layout=data.get("tile_layout", "auto"), + ) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for serialization.""" + return { + "cameras": [asdict(cam) for cam in self.cameras], + "max_cameras": self.max_cameras, + "tile_layout": self.tile_layout, + } + @dataclass class DLCProcessorSettings: @@ -50,7 +120,9 @@ class DLCProcessorSettings: model_path: str = "" model_directory: str = "." # Default directory for model browser (current dir if not set) - device: Optional[str] = "auto" # Device for inference (e.g., "cuda:0", "cpu"). None should be auto, but might default to cpu + device: Optional[str] = ( + "auto" # Device for inference (e.g., "cuda:0", "cpu"). None should be auto, but might default to cpu + ) dynamic: tuple = (False, 0.5, 10) # Dynamic cropping: (enabled, margin, max_missing_frames) resize: float = 1.0 # Resize factor for input frames precision: str = "FP32" # Inference precision ("FP32", "FP16") @@ -126,6 +198,7 @@ class ApplicationSettings: """Top level application configuration.""" camera: CameraSettings = field(default_factory=CameraSettings) + multi_camera: MultiCameraSettings = field(default_factory=MultiCameraSettings) dlc: DLCProcessorSettings = field(default_factory=DLCProcessorSettings) recording: RecordingSettings = field(default_factory=RecordingSettings) bbox: BoundingBoxSettings = field(default_factory=BoundingBoxSettings) @@ -136,6 +209,14 @@ def from_dict(cls, data: Dict[str, Any]) -> "ApplicationSettings": """Create an :class:`ApplicationSettings` from a dictionary.""" camera = CameraSettings(**data.get("camera", {})).apply_defaults() + + # Parse multi-camera settings + multi_camera_data = data.get("multi_camera", {}) + if multi_camera_data: + multi_camera = MultiCameraSettings.from_dict(multi_camera_data) + else: + multi_camera = MultiCameraSettings() + dlc_data = dict(data.get("dlc", {})) # Parse dynamic parameter - can be list or tuple in JSON dynamic_raw = dlc_data.get("dynamic", [False, 0.5, 10]) @@ -158,7 +239,12 @@ def from_dict(cls, data: Dict[str, Any]) -> "ApplicationSettings": bbox = BoundingBoxSettings(**data.get("bbox", {})) visualization = VisualizationSettings(**data.get("visualization", {})) return cls( - camera=camera, dlc=dlc, recording=recording, bbox=bbox, visualization=visualization + camera=camera, + multi_camera=multi_camera, + dlc=dlc, + recording=recording, + bbox=bbox, + visualization=visualization, ) def to_dict(self) -> Dict[str, Any]: @@ -166,6 +252,7 @@ def to_dict(self) -> Dict[str, Any]: return { "camera": asdict(self.camera), + "multi_camera": self.multi_camera.to_dict(), "dlc": asdict(self.dlc), "recording": asdict(self.recording), "bbox": asdict(self.bbox), diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 17f6846..a65147d 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -38,19 +38,19 @@ QWidget, ) -from dlclivegui.camera_controller import CameraController, FrameData -from dlclivegui.cameras import CameraFactory -from dlclivegui.cameras.factory import DetectedCamera +from dlclivegui.camera_config_dialog import CameraConfigDialog from dlclivegui.config import ( DEFAULT_CONFIG, ApplicationSettings, BoundingBoxSettings, CameraSettings, DLCProcessorSettings, + MultiCameraSettings, RecordingSettings, VisualizationSettings, ) from dlclivegui.dlc_processor import DLCLiveProcessor, PoseResult, ProcessorStats +from dlclivegui.multi_camera_controller import MultiCameraController, MultiFrameData from dlclivegui.processors.processor_utils import instantiate_from_scan, scan_processor_folder from dlclivegui.video_recorder import RecorderStats, VideoRecorder @@ -87,9 +87,6 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._raw_frame: Optional[np.ndarray] = None self._last_pose: Optional[PoseResult] = None self._dlc_active: bool = False - self._video_recorder: Optional[VideoRecorder] = None - self._rotation_degrees: int = 0 - self._detected_cameras: list[DetectedCamera] = [] self._active_camera_settings: Optional[CameraSettings] = None self._camera_frame_times: deque[float] = deque(maxlen=240) self._last_drop_warning = 0.0 @@ -112,9 +109,14 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._colormap = "hot" self._bbox_color = (0, 0, 255) # BGR: red - self.camera_controller = CameraController() + self.multi_camera_controller = MultiCameraController() self.dlc_processor = DLCLiveProcessor() + # Multi-camera state + self._multi_camera_mode = False + self._multi_camera_recorders: dict[int, VideoRecorder] = {} + self._multi_camera_frames: dict[int, np.ndarray] = {} + self._setup_ui() self._connect_signals() self._apply_config(self._config) @@ -247,76 +249,17 @@ def _build_camera_group(self) -> QGroupBox: group = QGroupBox("Camera settings") form = QFormLayout(group) - self.camera_backend = QComboBox() - availability = CameraFactory.available_backends() - for backend in CameraFactory.backend_names(): - label = backend - if not availability.get(backend, True): - label = f"{backend} (unavailable)" - self.camera_backend.addItem(label, backend) - form.addRow("Backend", self.camera_backend) - - index_layout = QHBoxLayout() - self.camera_index = QComboBox() - self.camera_index.setEditable(True) - index_layout.addWidget(self.camera_index) - self.refresh_cameras_button = QPushButton("Refresh") - index_layout.addWidget(self.refresh_cameras_button) - form.addRow("Camera", index_layout) - - self.camera_fps = QDoubleSpinBox() - self.camera_fps.setRange(1.0, 240.0) - self.camera_fps.setDecimals(2) - form.addRow("Frame rate", self.camera_fps) - - self.camera_exposure = QSpinBox() - self.camera_exposure.setRange(0, 1000000) - self.camera_exposure.setValue(0) - self.camera_exposure.setSpecialValueText("Auto") - self.camera_exposure.setSuffix(" μs") - form.addRow("Exposure", self.camera_exposure) - - self.camera_gain = QDoubleSpinBox() - self.camera_gain.setRange(0.0, 100.0) - self.camera_gain.setValue(0.0) - self.camera_gain.setSpecialValueText("Auto") - self.camera_gain.setDecimals(2) - form.addRow("Gain", self.camera_gain) - - # Crop settings - crop_layout = QHBoxLayout() - self.crop_x0 = QSpinBox() - self.crop_x0.setRange(0, 7680) - self.crop_x0.setPrefix("x0:") - self.crop_x0.setSpecialValueText("x0:None") - crop_layout.addWidget(self.crop_x0) - - self.crop_y0 = QSpinBox() - self.crop_y0.setRange(0, 4320) - self.crop_y0.setPrefix("y0:") - self.crop_y0.setSpecialValueText("y0:None") - crop_layout.addWidget(self.crop_y0) - - self.crop_x1 = QSpinBox() - self.crop_x1.setRange(0, 7680) - self.crop_x1.setPrefix("x1:") - self.crop_x1.setSpecialValueText("x1:None") - crop_layout.addWidget(self.crop_x1) - - self.crop_y1 = QSpinBox() - self.crop_y1.setRange(0, 4320) - self.crop_y1.setPrefix("y1:") - self.crop_y1.setSpecialValueText("y1:None") - crop_layout.addWidget(self.crop_y1) - - form.addRow("Crop (x0,y0,x1,y1)", crop_layout) - - self.rotation_combo = QComboBox() - self.rotation_combo.addItem("0° (default)", 0) - self.rotation_combo.addItem("90°", 90) - self.rotation_combo.addItem("180°", 180) - self.rotation_combo.addItem("270°", 270) - form.addRow("Rotation", self.rotation_combo) + # Camera config button - opens dialog for all camera configuration + config_layout = QHBoxLayout() + self.config_cameras_button = QPushButton("Configure Cameras...") + self.config_cameras_button.setToolTip("Configure camera settings (single or multi-camera)") + config_layout.addWidget(self.config_cameras_button) + form.addRow(config_layout) + + # Active cameras display label + self.active_cameras_label = QLabel("No cameras configured") + self.active_cameras_label.setWordWrap(True) + form.addRow("Active:", self.active_cameras_label) return group @@ -409,8 +352,11 @@ def _build_recording_group(self) -> QGroupBox: form.addRow("Container", self.container_combo) self.codec_combo = QComboBox() - self.codec_combo.addItems(["h264_nvenc", "libx264"]) - self.codec_combo.setCurrentText("h264_nvenc") + if os.sys.platform == "darwin": + self.codec_combo.addItems(["h264_videotoolbox", "libx264", "hevc_videotoolbox"]) + else: + self.codec_combo.addItems(["h264_nvenc", "libx264", "hevc_nvenc"]) + self.codec_combo.setCurrentText("libx264") form.addRow("Codec", self.codec_combo) self.crf_spin = QSpinBox() @@ -478,16 +424,13 @@ def _connect_signals(self) -> None: self.stop_preview_button.clicked.connect(self._stop_preview) self.start_record_button.clicked.connect(self._start_recording) self.stop_record_button.clicked.connect(self._stop_recording) - self.refresh_cameras_button.clicked.connect( - lambda: self._refresh_camera_indices(keep_current=True) - ) - self.camera_backend.currentIndexChanged.connect(self._on_backend_changed) - self.camera_backend.currentIndexChanged.connect(self._update_backend_specific_controls) - self.rotation_combo.currentIndexChanged.connect(self._on_rotation_changed) self.start_inference_button.clicked.connect(self._start_inference) self.stop_inference_button.clicked.connect(lambda: self._stop_inference()) self.show_predictions_checkbox.stateChanged.connect(self._on_show_predictions_changed) + # Camera config dialog + self.config_cameras_button.clicked.connect(self._open_camera_config_dialog) + # Connect bounding box controls self.bbox_enabled_checkbox.stateChanged.connect(self._on_bbox_changed) self.bbox_x0_spin.valueChanged.connect(self._on_bbox_changed) @@ -495,11 +438,11 @@ def _connect_signals(self) -> None: self.bbox_x1_spin.valueChanged.connect(self._on_bbox_changed) self.bbox_y1_spin.valueChanged.connect(self._on_bbox_changed) - self.camera_controller.frame_ready.connect(self._on_frame_ready) - self.camera_controller.started.connect(self._on_camera_started) - self.camera_controller.error.connect(self._show_error) - self.camera_controller.warning.connect(self._show_warning) - self.camera_controller.stopped.connect(self._on_camera_stopped) + # Multi-camera controller signals (used for both single and multi-camera modes) + self.multi_camera_controller.frame_ready.connect(self._on_multi_frame_ready) + self.multi_camera_controller.all_started.connect(self._on_multi_camera_started) + self.multi_camera_controller.all_stopped.connect(self._on_multi_camera_stopped) + self.multi_camera_controller.camera_error.connect(self._on_multi_camera_error) self.dlc_processor.pose_ready.connect(self._on_pose_ready) self.dlc_processor.error.connect(self._on_dlc_error) @@ -507,32 +450,8 @@ def _connect_signals(self) -> None: # ------------------------------------------------------------------ config def _apply_config(self, config: ApplicationSettings) -> None: - camera = config.camera - self.camera_fps.setValue(float(camera.fps)) - - # Set exposure and gain from config - self.camera_exposure.setValue(int(camera.exposure)) - self.camera_gain.setValue(float(camera.gain)) - - # Set crop settings from config - self.crop_x0.setValue(int(camera.crop_x0) if hasattr(camera, "crop_x0") else 0) - self.crop_y0.setValue(int(camera.crop_y0) if hasattr(camera, "crop_y0") else 0) - self.crop_x1.setValue(int(camera.crop_x1) if hasattr(camera, "crop_x1") else 0) - self.crop_y1.setValue(int(camera.crop_y1) if hasattr(camera, "crop_y1") else 0) - - backend_name = camera.backend or "opencv" - self.camera_backend.blockSignals(True) - index = self.camera_backend.findData(backend_name) - if index >= 0: - self.camera_backend.setCurrentIndex(index) - else: - self.camera_backend.setEditText(backend_name) - self.camera_backend.blockSignals(False) - self._refresh_camera_indices(keep_current=False) - self._select_camera_by_index(camera.index, fallback_text=camera.name or str(camera.index)) - - self._active_camera_settings = None - self._update_backend_specific_controls() + # Update active cameras label + self._update_active_cameras_label() dlc = config.dlc self.model_path_edit.setText(dlc.model_path) @@ -566,122 +485,19 @@ def _apply_config(self, config: ApplicationSettings) -> None: self._bbox_color = viz.get_bbox_color_bgr() def _current_config(self) -> ApplicationSettings: + # Get the first camera from multi-camera config for backward compatibility + active_cameras = self._config.multi_camera.get_active_cameras() + camera = active_cameras[0] if active_cameras else CameraSettings() + return ApplicationSettings( - camera=self._camera_settings_from_ui(), + camera=camera, + multi_camera=self._config.multi_camera, dlc=self._dlc_settings_from_ui(), recording=self._recording_settings_from_ui(), bbox=self._bbox_settings_from_ui(), visualization=self._visualization_settings_from_ui(), ) - def _camera_settings_from_ui(self) -> CameraSettings: - index = self._current_camera_index_value() - if index is None: - raise ValueError("Camera selection must provide a numeric index") - backend_text = self._current_backend_name() - - # Get exposure and gain from explicit UI fields - exposure = self.camera_exposure.value() - gain = self.camera_gain.value() - - # Get crop settings from UI - crop_x0 = self.crop_x0.value() - crop_y0 = self.crop_y0.value() - crop_x1 = self.crop_x1.value() - crop_y1 = self.crop_y1.value() - - name_text = self.camera_index.currentText().strip() - settings = CameraSettings( - name=name_text or f"Camera {index}", - index=index, - fps=self.camera_fps.value(), - backend=backend_text or "opencv", - exposure=exposure, - gain=gain, - crop_x0=crop_x0, - crop_y0=crop_y0, - crop_x1=crop_x1, - crop_y1=crop_y1, - properties={}, - ) - return settings.apply_defaults() - - def _current_backend_name(self) -> str: - backend_data = self.camera_backend.currentData() - if isinstance(backend_data, str) and backend_data: - return backend_data - text = self.camera_backend.currentText().strip() - return text or "opencv" - - def _refresh_camera_indices(self, *_args: object, keep_current: bool = True) -> None: - backend = self._current_backend_name() - # Get max_devices from config, default to 3 - max_devices = ( - self._config.camera.max_devices if hasattr(self._config.camera, "max_devices") else 3 - ) - detected = CameraFactory.detect_cameras(backend, max_devices=max_devices) - debug_info = [f"{camera.index}:{camera.label}" for camera in detected] - logging.info(f"[CameraDetection] Available cameras for backend '{backend}': {debug_info}") - self._detected_cameras = detected - previous_index = self._current_camera_index_value() - previous_text = self.camera_index.currentText() - self.camera_index.blockSignals(True) - self.camera_index.clear() - for camera in detected: - self.camera_index.addItem(camera.label, camera.index) - if keep_current and previous_index is not None: - self._select_camera_by_index(previous_index, fallback_text=previous_text) - elif detected: - self.camera_index.setCurrentIndex(0) - else: - if keep_current and previous_text: - self.camera_index.setEditText(previous_text) - else: - self.camera_index.setEditText("") - self.camera_index.blockSignals(False) - - def _select_camera_by_index(self, index: int, fallback_text: Optional[str] = None) -> None: - self.camera_index.blockSignals(True) - for row in range(self.camera_index.count()): - if self.camera_index.itemData(row) == index: - self.camera_index.setCurrentIndex(row) - break - else: - text = fallback_text if fallback_text is not None else str(index) - self.camera_index.setEditText(text) - self.camera_index.blockSignals(False) - - def _current_camera_index_value(self) -> Optional[int]: - data = self.camera_index.currentData() - if isinstance(data, int): - return data - text = self.camera_index.currentText().strip() - if not text: - return None - try: - return int(text) - except ValueError: - return None - debug_info = [f"{camera.index}:{camera.label}" for camera in detected] - logging.info(f"[CameraDetection] Available cameras for backend '{backend}': {debug_info}") - self._detected_cameras = detected - previous_index = self._current_camera_index_value() - previous_text = self.camera_index.currentText() - self.camera_index.blockSignals(True) - self.camera_index.clear() - for camera in detected: - self.camera_index.addItem(camera.label, camera.index) - if keep_current and previous_index is not None: - self._select_camera_by_index(previous_index, fallback_text=previous_text) - elif detected: - self.camera_index.setCurrentIndex(0) - else: - if keep_current and previous_text: - self.camera_index.setEditText(previous_text) - else: - self.camera_index.setEditText("") - self.camera_index.blockSignals(False) - def _parse_json(self, value: str) -> dict: text = value.strip() if not text: @@ -826,110 +642,221 @@ def _refresh_processors(self) -> None: self._scanned_processors = {} self._processor_keys = [] - def _on_backend_changed(self, *_args: object) -> None: - self._refresh_camera_indices(keep_current=False) + # ------------------------------------------------------------------ multi-camera + def _open_camera_config_dialog(self) -> None: + """Open the camera configuration dialog.""" + dialog = CameraConfigDialog(self, self._config.multi_camera) + dialog.settings_changed.connect(self._on_multi_camera_settings_changed) + dialog.exec() + + def _on_multi_camera_settings_changed(self, settings: MultiCameraSettings) -> None: + """Handle changes to multi-camera settings.""" + self._config.multi_camera = settings + self._update_active_cameras_label() + active_count = len(settings.get_active_cameras()) + self.statusBar().showMessage( + f"Camera configuration updated: {active_count} active camera(s)", 3000 + ) - def _update_backend_specific_controls(self) -> None: - """Enable/disable controls based on selected backend.""" - backend = self._current_backend_name() - is_opencv = backend.lower() == "opencv" + def _update_active_cameras_label(self) -> None: + """Update the label showing active cameras.""" + active_cams = self._config.multi_camera.get_active_cameras() + if not active_cams: + self.active_cameras_label.setText("No cameras configured") + elif len(active_cams) == 1: + cam = active_cams[0] + self.active_cameras_label.setText( + f"{cam.name} [{cam.backend}:{cam.index}] @ {cam.fps:.1f} fps" + ) + else: + cam_names = [f"{c.name}" for c in active_cams] + self.active_cameras_label.setText(f"{len(active_cams)} cameras: {', '.join(cam_names)}") + + def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: + """Handle frames from multiple cameras.""" + self._multi_camera_frames = frame_data.frames + self._track_camera_frame() # Track FPS + + # For single camera mode, also set raw_frame for DLC processing + if len(frame_data.frames) == 1: + cam_idx = next(iter(frame_data.frames.keys())) + self._raw_frame = frame_data.frames[cam_idx] + + # Record individual camera feeds if recording is active + if self._multi_camera_recorders: + for cam_idx, frame in frame_data.frames.items(): + if cam_idx in self._multi_camera_recorders: + recorder = self._multi_camera_recorders[cam_idx] + if recorder.is_running: + timestamp = frame_data.timestamps.get(cam_idx, time.time()) + try: + recorder.write(frame, timestamp=timestamp) + except Exception as exc: + logging.warning(f"Failed to write frame for camera {cam_idx}: {exc}") + + # Display tiled frame (or single frame for 1 camera) + if frame_data.tiled_frame is not None: + self._current_frame = frame_data.tiled_frame + self._display_frame(frame_data.tiled_frame) + + # For DLC processing, use single frame if only one camera + if self._dlc_active and len(frame_data.frames) == 1: + cam_idx = next(iter(frame_data.frames.keys())) + frame = frame_data.frames[cam_idx] + timestamp = frame_data.timestamps.get(cam_idx, time.time()) + self.dlc_processor.enqueue_frame(frame, timestamp) + + def _on_multi_camera_started(self) -> None: + """Handle all cameras started event.""" + self.preview_button.setEnabled(False) + self.stop_preview_button.setEnabled(True) + active_count = self.multi_camera_controller.get_active_count() + self.statusBar().showMessage( + f"Multi-camera preview started: {active_count} camera(s)", 5000 + ) + self._update_inference_buttons() + self._update_camera_controls_enabled() - # Disable exposure and gain controls for OpenCV backend - self.camera_exposure.setEnabled(not is_opencv) - self.camera_gain.setEnabled(not is_opencv) + def _on_multi_camera_stopped(self) -> None: + """Handle all cameras stopped event.""" + # Stop all multi-camera recorders + self._stop_multi_camera_recording() - # Set tooltip to explain why controls are disabled - if is_opencv: - tooltip = "Exposure and gain control not supported with OpenCV backend" - self.camera_exposure.setToolTip(tooltip) - self.camera_gain.setToolTip(tooltip) - else: - self.camera_exposure.setToolTip("") - self.camera_gain.setToolTip("") - - def _on_rotation_changed(self, _index: int) -> None: - data = self.rotation_combo.currentData() - self._rotation_degrees = int(data) if isinstance(data, int) else 0 - if self._raw_frame is not None: - rotated = self._apply_rotation(self._raw_frame) - self._current_frame = rotated - self._last_pose = None - self._display_frame(rotated, force=False) + self.preview_button.setEnabled(True) + self.stop_preview_button.setEnabled(False) + self._current_frame = None + self._multi_camera_frames.clear() + self.video_label.setPixmap(QPixmap()) + self.video_label.setText("Camera preview not started") + self.statusBar().showMessage("Multi-camera preview stopped", 3000) + self._update_inference_buttons() + self._update_camera_controls_enabled() + + def _on_multi_camera_error(self, camera_index: int, message: str) -> None: + """Handle error from a camera in multi-camera mode.""" + self._show_warning(f"Camera {camera_index} error: {message}") + + def _start_multi_camera_recording(self) -> None: + """Start recording from all active cameras.""" + if self._multi_camera_recorders: + return # Already recording + + recording = self._recording_settings_from_ui() + if not recording.enabled: + self._show_error("Recording is disabled in the configuration.") + return + + active_cams = self._config.multi_camera.get_active_cameras() + if not active_cams: + self._show_error("No active cameras configured.") + return + + base_path = recording.output_path() + base_stem = base_path.stem + + for cam in active_cams: + cam_idx = cam.index + # Create unique filename for each camera + cam_filename = f"{base_stem}_cam{cam_idx}{base_path.suffix}" + cam_path = base_path.parent / cam_filename + + # Get frame from current frames if available + frame = self._multi_camera_frames.get(cam_idx) + frame_size = (frame.shape[0], frame.shape[1]) if frame is not None else None + + recorder = VideoRecorder( + cam_path, + frame_size=frame_size, + frame_rate=float(cam.fps), + codec=recording.codec, + crf=recording.crf, + ) + + try: + recorder.start() + self._multi_camera_recorders[cam_idx] = recorder + logging.info(f"Started recording camera {cam_idx} to {cam_path}") + except Exception as exc: + self._show_error(f"Failed to start recording for camera {cam_idx}: {exc}") + + if self._multi_camera_recorders: + self.start_record_button.setEnabled(False) + self.stop_record_button.setEnabled(True) + self.statusBar().showMessage( + f"Recording {len(self._multi_camera_recorders)} camera(s) to {recording.directory}", + 5000, + ) + self._update_camera_controls_enabled() + + def _stop_multi_camera_recording(self) -> None: + """Stop recording from all cameras.""" + if not self._multi_camera_recorders: + return + + for cam_idx, recorder in self._multi_camera_recorders.items(): + try: + recorder.stop() + logging.info(f"Stopped recording camera {cam_idx}") + except Exception as exc: + logging.warning(f"Error stopping recorder for camera {cam_idx}: {exc}") + + self._multi_camera_recorders.clear() + self.start_record_button.setEnabled(True) + self.stop_record_button.setEnabled(False) + self.statusBar().showMessage("Multi-camera recording stopped", 3000) + self._update_camera_controls_enabled() # ------------------------------------------------------------------ camera control def _start_preview(self) -> None: - try: - settings = self._camera_settings_from_ui() - except ValueError as exc: - self._show_error(str(exc)) + """Start camera preview - uses multi-camera controller for all configurations.""" + active_cams = self._config.multi_camera.get_active_cameras() + if not active_cams: + self._show_error("No cameras configured. Use 'Configure Cameras...' to add cameras.") return - self._active_camera_settings = settings - self.camera_controller.start(settings) + + # Determine if we're in single or multi-camera mode + self._multi_camera_mode = len(active_cams) > 1 + self.preview_button.setEnabled(False) self.stop_preview_button.setEnabled(True) self._current_frame = None self._raw_frame = None self._last_pose = None - self._dlc_active = False + self._multi_camera_frames.clear() self._camera_frame_times.clear() self._last_display_time = 0.0 + if hasattr(self, "camera_stats_label"): - self.camera_stats_label.setText("Camera starting…") - self.statusBar().showMessage("Starting camera preview…", 3000) + self.camera_stats_label.setText(f"Starting {len(active_cams)} camera(s)…") + self.statusBar().showMessage(f"Starting preview ({len(active_cams)} camera(s))…", 3000) + + # Store active settings for single camera mode (for DLC, recording frame rate, etc.) + self._active_camera_settings = active_cams[0] if active_cams else None + + self.multi_camera_controller.start(active_cams) self._update_inference_buttons() self._update_camera_controls_enabled() def _stop_preview(self) -> None: - if not self.camera_controller.is_running(): + """Stop camera preview.""" + if not self.multi_camera_controller.is_running(): return + self.preview_button.setEnabled(False) self.stop_preview_button.setEnabled(False) self.start_inference_button.setEnabled(False) self.stop_inference_button.setEnabled(False) - self.statusBar().showMessage("Stopping camera preview…", 3000) - self.camera_controller.stop() - self._stop_inference(show_message=False) - self._camera_frame_times.clear() - self._last_display_time = 0.0 - if hasattr(self, "camera_stats_label"): - self.camera_stats_label.setText("Camera idle") + self.statusBar().showMessage("Stopping preview…", 3000) - def _on_camera_started(self, settings: CameraSettings) -> None: - self._active_camera_settings = settings - self.preview_button.setEnabled(False) - self.stop_preview_button.setEnabled(True) - if getattr(settings, "fps", None): - self.camera_fps.blockSignals(True) - self.camera_fps.setValue(float(settings.fps)) - self.camera_fps.blockSignals(False) - # Resolution will be determined from actual camera frames - if getattr(settings, "fps", None): - fps_text = f"{float(settings.fps):.2f} FPS" - else: - fps_text = "unknown FPS" - self.statusBar().showMessage(f"Camera preview started @ {fps_text}", 5000) - self._update_inference_buttons() - self._update_camera_controls_enabled() + # Stop any active recording first + self._stop_multi_camera_recording() - def _on_camera_stopped(self) -> None: - if self._video_recorder and self._video_recorder.is_running: - self._stop_recording() - self.preview_button.setEnabled(True) - self.stop_preview_button.setEnabled(False) + self.multi_camera_controller.stop() self._stop_inference(show_message=False) - self._current_frame = None - self._raw_frame = None - self._last_pose = None - self._active_camera_settings = None - self.video_label.setPixmap(QPixmap()) - self.video_label.setText("Camera preview not started") - self.statusBar().showMessage("Camera preview stopped", 3000) self._camera_frame_times.clear() self._last_display_time = 0.0 if hasattr(self, "camera_stats_label"): self.camera_stats_label.setText("Camera idle") - self._update_inference_buttons() - self._update_camera_controls_enabled() def _configure_dlc(self) -> bool: try: @@ -962,7 +889,7 @@ def _configure_dlc(self) -> bool: return True def _update_inference_buttons(self) -> None: - preview_running = self.camera_controller.is_running() + preview_running = self.multi_camera_controller.is_running() self.start_inference_button.setEnabled(preview_running and not self._dlc_active) self.stop_inference_button.setEnabled(preview_running and self._dlc_active) @@ -982,29 +909,20 @@ def _update_dlc_controls_enabled(self) -> None: widget.setEnabled(allow_changes) def _update_camera_controls_enabled(self) -> None: - recording_active = self._video_recorder is not None and self._video_recorder.is_running - allow_changes = ( - not self.camera_controller.is_running() - and not self._dlc_active - and not recording_active - ) - widgets = [ - self.camera_backend, - self.camera_index, - self.refresh_cameras_button, - self.camera_fps, - self.camera_exposure, - self.camera_gain, - self.crop_x0, - self.crop_y0, - self.crop_x1, - self.crop_y1, - self.rotation_combo, - self.codec_combo, - self.crf_spin, - ] - for widget in widgets: - widget.setEnabled(allow_changes) + multi_cam_recording = bool(self._multi_camera_recorders) + + # Check if preview is running + preview_running = self.multi_camera_controller.is_running() + + allow_changes = not preview_running and not self._dlc_active and not multi_cam_recording + + # Recording settings (codec, crf) should be editable when not recording + recording_editable = not multi_cam_recording + self.codec_combo.setEnabled(recording_editable) + self.crf_spin.setEnabled(recording_editable) + + # Config cameras button should be available when not in preview/recording + self.config_cameras_button.setEnabled(allow_changes) def _track_camera_frame(self) -> None: now = time.perf_counter() @@ -1081,12 +999,23 @@ def _format_dlc_stats(self, stats: ProcessorStats) -> str: def _update_metrics(self) -> None: if hasattr(self, "camera_stats_label"): - if self.camera_controller.is_running(): + running = self.multi_camera_controller.is_running() + + if running: + active_count = self.multi_camera_controller.get_active_count() fps = self._compute_fps(self._camera_frame_times) if fps > 0: - self.camera_stats_label.setText(f"{fps:.1f} fps (last 5 s)") + if active_count > 1: + self.camera_stats_label.setText( + f"{active_count} cameras | {fps:.1f} fps (last 5 s)" + ) + else: + self.camera_stats_label.setText(f"{fps:.1f} fps (last 5 s)") else: - self.camera_stats_label.setText("Measuring…") + if active_count > 1: + self.camera_stats_label.setText(f"{active_count} cameras | Measuring…") + else: + self.camera_stats_label.setText("Measuring…") else: self.camera_stats_label.setText("Camera idle") @@ -1103,15 +1032,40 @@ def _update_metrics(self) -> None: self._update_processor_status() if hasattr(self, "recording_stats_label"): - if self._video_recorder is not None: - stats = self._video_recorder.get_stats() - if stats is not None: - summary = self._format_recorder_stats(stats) - self._last_recorder_summary = summary - self.recording_stats_label.setText(summary) - elif not self._video_recorder.is_running: - self._last_recorder_summary = "Recorder idle" - self.recording_stats_label.setText(self._last_recorder_summary) + # Handle multi-camera recording stats + if self._multi_camera_recorders: + num_recorders = len(self._multi_camera_recorders) + if num_recorders == 1: + # Single camera - show detailed stats + recorder = next(iter(self._multi_camera_recorders.values())) + stats = recorder.get_stats() + if stats: + summary = self._format_recorder_stats(stats) + else: + summary = "Recording..." + else: + # Multiple cameras - show aggregated stats with per-camera details + total_written = 0 + total_dropped = 0 + total_queue = 0 + max_latency = 0.0 + avg_latencies = [] + for recorder in self._multi_camera_recorders.values(): + stats = recorder.get_stats() + if stats: + total_written += stats.frames_written + total_dropped += stats.dropped_frames + total_queue += stats.queue_size + max_latency = max(max_latency, stats.last_latency) + avg_latencies.append(stats.average_latency) + avg_latency = sum(avg_latencies) / len(avg_latencies) if avg_latencies else 0.0 + summary = ( + f"{num_recorders} cams | {total_written} frames | " + f"latency {max_latency*1000:.1f}ms (avg {avg_latency*1000:.1f}ms) | " + f"queue {total_queue} | dropped {total_dropped}" + ) + self._last_recorder_summary = summary + self.recording_stats_label.setText(summary) else: self.recording_stats_label.setText(self._last_recorder_summary) @@ -1150,7 +1104,7 @@ def _update_processor_status(self) -> None: if current_vid_recording != self._last_processor_vid_recording: if current_vid_recording: # Start video recording - if not self._video_recorder or not self._video_recorder.is_running: + if not self._multi_camera_recorders: # Get session name from processor session_name = getattr(processor, "session_name", "auto_session") self._auto_record_session_name = session_name @@ -1166,7 +1120,7 @@ def _update_processor_status(self) -> None: logging.info(f"Auto-recording started for session: {session_name}") else: # Stop video recording - if self._video_recorder and self._video_recorder.is_running: + if self._multi_camera_recorders: self._stop_recording() self.statusBar().showMessage("Auto-stopped recording", 3000) logging.info("Auto-recording stopped") @@ -1177,7 +1131,7 @@ def _start_inference(self) -> None: if self._dlc_active: self.statusBar().showMessage("Pose inference already running", 3000) return - if not self.camera_controller.is_running(): + if not self.multi_camera_controller.is_running(): self._show_error("Start the camera preview before running pose inference.") return if not self._configure_dlc(): @@ -1221,166 +1175,23 @@ def _stop_inference(self, show_message: bool = True) -> None: # ------------------------------------------------------------------ recording def _start_recording(self) -> None: - # If recorder already running, nothing to do - if self._video_recorder and self._video_recorder.is_running: + """Start recording from all active cameras.""" + # Auto-start preview if not running + if not self.multi_camera_controller.is_running(): + self._start_preview() + # Wait a moment for cameras to initialize before recording + # The recording will start after preview is confirmed running + self.statusBar().showMessage("Starting preview before recording...", 3000) + # Use a single-shot timer to start recording after preview starts + QTimer.singleShot(500, self._start_multi_camera_recording) return - # If camera not running, start it automatically so frames will arrive. - # This allows starting recording without the user manually pressing "Start Preview". - if not self.camera_controller.is_running(): - try: - settings = self._camera_settings_from_ui() - except ValueError as exc: - self._show_error(str(exc)) - return - # Store active settings and start camera preview in background - self._active_camera_settings = settings - self.camera_controller.start(settings) - self.preview_button.setEnabled(False) - self.stop_preview_button.setEnabled(True) - self._current_frame = None - self._raw_frame = None - self._last_pose = None - self._dlc_active = False - self._camera_frame_times.clear() - self._last_display_time = 0.0 - if hasattr(self, "camera_stats_label"): - self.camera_stats_label.setText("Camera starting…") - self.statusBar().showMessage("Starting camera preview…", 3000) - self._update_inference_buttons() - self._update_camera_controls_enabled() - recording = self._recording_settings_from_ui() - if not recording.enabled: - self._show_error("Recording is disabled in the configuration.") - return - - # If we already have a current frame, use its shape to set the recorder stream. - # Otherwise start the recorder without a fixed frame_size and configure it - # once the first frame arrives (see _on_frame_ready). - frame = self._current_frame - if frame is not None: - height, width = frame.shape[:2] - frame_size = (height, width) - else: - frame_size = None - - frame_rate = ( - self._active_camera_settings.fps - if self._active_camera_settings is not None - else self.camera_fps.value() - ) - - output_path = recording.output_path() - self._video_recorder = VideoRecorder( - output_path, - frame_size=frame_size, # None allowed; will be configured on first frame - frame_rate=float(frame_rate) if frame_rate is not None else None, - codec=recording.codec, - crf=recording.crf, - ) - self._last_drop_warning = 0.0 - try: - self._video_recorder.start() - except Exception as exc: # pragma: no cover - runtime error - self._show_error(str(exc)) - self._video_recorder = None - return - self.start_record_button.setEnabled(False) - self.stop_record_button.setEnabled(True) - if hasattr(self, "recording_stats_label"): - self._last_recorder_summary = "Recorder running…" - self.recording_stats_label.setText(self._last_recorder_summary) - self.statusBar().showMessage(f"Recording to {output_path}", 5000) - self._update_camera_controls_enabled() + # Preview already running, start recording immediately + self._start_multi_camera_recording() def _stop_recording(self) -> None: - if not self._video_recorder: - return - recorder = self._video_recorder - recorder.stop() - stats = recorder.get_stats() if recorder is not None else None - self._video_recorder = None - self.start_record_button.setEnabled(True) - self.stop_record_button.setEnabled(False) - if hasattr(self, "recording_stats_label"): - if stats is not None: - summary = self._format_recorder_stats(stats) - else: - summary = "Recorder idle" - self._last_recorder_summary = summary - self.recording_stats_label.setText(summary) - else: - self._last_recorder_summary = ( - self._format_recorder_stats(stats) if stats is not None else "Recorder idle" - ) - self._last_drop_warning = 0.0 - self.statusBar().showMessage("Recording stopped", 3000) - self._update_camera_controls_enabled() - - # ------------------------------------------------------------------ frame handling - def _on_frame_ready(self, frame_data: FrameData) -> None: - raw_frame = frame_data.image - self._raw_frame = raw_frame - - # Apply cropping before rotation - frame = self._apply_crop(raw_frame) - - # Apply rotation - frame = self._apply_rotation(frame) - frame = np.ascontiguousarray(frame) - self._current_frame = frame - self._track_camera_frame() - # If recorder is running but was started without a fixed frame_size, configure - # the stream now that we know the actual frame dimensions. - if self._video_recorder and self._video_recorder.is_running: - # Configure stream if recorder was started without a frame_size - try: - current_frame_size = getattr(self._video_recorder, "_frame_size", None) - except Exception: - current_frame_size = None - if current_frame_size is None: - try: - fps_value = ( - self._active_camera_settings.fps - if self._active_camera_settings is not None - else self.camera_fps.value() - ) - except Exception: - fps_value = None - h, w = frame.shape[:2] - try: - # configure_stream expects (height, width) - self._video_recorder.configure_stream( - (h, w), float(fps_value) if fps_value is not None else None - ) - except Exception: - # Non-fatal: continue and attempt to write anyway - pass - try: - success = self._video_recorder.write(frame, timestamp=frame_data.timestamp) - if not success: - now = time.perf_counter() - if now - self._last_drop_warning > 1.0: - self.statusBar().showMessage("Recorder backlog full; dropping frames", 2000) - self._last_drop_warning = now - except RuntimeError as exc: - # Check if it's a frame size error - if "Frame size changed" in str(exc): - self._show_warning(f"Camera resolution changed - restarting recording: {exc}") - was_recording = self._video_recorder and self._video_recorder.is_running - self._stop_recording() - # Restart recording with new resolution if it was already running - if was_recording: - try: - self._start_recording() - except Exception as restart_exc: - self._show_error(f"Failed to restart recording: {restart_exc}") - else: - self._show_error(str(exc)) - self._stop_recording() - if self._dlc_active: - self.dlc_processor.enqueue_frame(frame, frame_data.timestamp) - self._display_frame(frame) + """Stop recording from all cameras.""" + self._stop_multi_camera_recording() def _on_pose_ready(self, result: PoseResult) -> None: if not self._dlc_active: @@ -1413,40 +1224,6 @@ def _update_video_display(self, frame: np.ndarray) -> None: image = QImage(rgb.data, w, h, bytes_per_line, QImage.Format.Format_RGB888) self.video_label.setPixmap(QPixmap.fromImage(image)) - def _apply_crop(self, frame: np.ndarray) -> np.ndarray: - """Apply cropping to the frame based on settings.""" - if self._active_camera_settings is None: - return frame - - crop_region = self._active_camera_settings.get_crop_region() - if crop_region is None: - return frame - - x0, y0, x1, y1 = crop_region - height, width = frame.shape[:2] - - # Validate and constrain crop coordinates - x0 = max(0, min(x0, width)) - y0 = max(0, min(y0, height)) - x1 = max(x0, min(x1, width)) if x1 > 0 else width - y1 = max(y0, min(y1, height)) if y1 > 0 else height - - # Apply crop - if x0 < x1 and y0 < y1: - return frame[y0:y1, x0:x1] - else: - # Invalid crop region, return original frame - return frame - - def _apply_rotation(self, frame: np.ndarray) -> np.ndarray: - if self._rotation_degrees == 90: - return cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) - if self._rotation_degrees == 180: - return cv2.rotate(frame, cv2.ROTATE_180) - if self._rotation_degrees == 270: - return cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE) - return frame - def _on_show_predictions_changed(self, _state: int) -> None: if self._current_frame is not None: self._display_frame(self._current_frame, force=True) @@ -1539,10 +1316,13 @@ def _show_warning(self, message: str) -> None: # ------------------------------------------------------------------ Qt overrides def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI behaviour - if self.camera_controller.is_running(): - self.camera_controller.stop(wait=True) - if self._video_recorder and self._video_recorder.is_running: - self._video_recorder.stop() + if self.multi_camera_controller.is_running(): + self.multi_camera_controller.stop(wait=True) + # Stop all multi-camera recorders + for recorder in self._multi_camera_recorders.values(): + if recorder.is_running: + recorder.stop() + self._multi_camera_recorders.clear() self.dlc_processor.shutdown() if hasattr(self, "_metrics_timer"): self._metrics_timer.stop() diff --git a/dlclivegui/multi_camera_controller.py b/dlclivegui/multi_camera_controller.py new file mode 100644 index 0000000..6d6b9b8 --- /dev/null +++ b/dlclivegui/multi_camera_controller.py @@ -0,0 +1,408 @@ +"""Multi-camera management for the DLC Live GUI.""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass +from threading import Event, Lock +from typing import Dict, List, Optional + +import cv2 +import numpy as np +from PyQt6.QtCore import QObject, QThread, pyqtSignal, pyqtSlot + +from dlclivegui.cameras import CameraFactory +from dlclivegui.cameras.base import CameraBackend +from dlclivegui.config import CameraSettings, MultiCameraSettings + +LOGGER = logging.getLogger(__name__) + + +@dataclass +class MultiFrameData: + """Container for frames from multiple cameras.""" + + frames: Dict[int, np.ndarray] # camera_index -> frame + timestamps: Dict[int, float] # camera_index -> timestamp + tiled_frame: Optional[np.ndarray] = None # Combined tiled frame + + +class SingleCameraWorker(QObject): + """Worker for a single camera in multi-camera mode.""" + + frame_captured = pyqtSignal(int, object, float) # camera_index, frame, timestamp + error_occurred = pyqtSignal(int, str) # camera_index, error_message + started = pyqtSignal(int) # camera_index + stopped = pyqtSignal(int) # camera_index + + def __init__(self, camera_index: int, settings: CameraSettings): + super().__init__() + self._camera_index = camera_index + self._settings = settings + self._stop_event = Event() + self._backend: Optional[CameraBackend] = None + self._max_consecutive_errors = 5 + self._retry_delay = 0.1 + + @pyqtSlot() + def run(self) -> None: + self._stop_event.clear() + + try: + self._backend = CameraFactory.create(self._settings) + self._backend.open() + except Exception as exc: + LOGGER.exception(f"Failed to initialize camera {self._camera_index}", exc_info=exc) + self.error_occurred.emit(self._camera_index, f"Failed to initialize camera: {exc}") + self.stopped.emit(self._camera_index) + return + + self.started.emit(self._camera_index) + consecutive_errors = 0 + + while not self._stop_event.is_set(): + try: + frame, timestamp = self._backend.read() + if frame is None or frame.size == 0: + consecutive_errors += 1 + if consecutive_errors >= self._max_consecutive_errors: + self.error_occurred.emit(self._camera_index, "Too many empty frames") + break + time.sleep(self._retry_delay) + continue + + consecutive_errors = 0 + self.frame_captured.emit(self._camera_index, frame, timestamp) + + except Exception as exc: + consecutive_errors += 1 + if self._stop_event.is_set(): + break + if consecutive_errors >= self._max_consecutive_errors: + self.error_occurred.emit(self._camera_index, f"Camera read error: {exc}") + break + time.sleep(self._retry_delay) + continue + + # Cleanup + if self._backend is not None: + try: + self._backend.close() + except Exception: + pass + self.stopped.emit(self._camera_index) + + def stop(self) -> None: + self._stop_event.set() + + +class MultiCameraController(QObject): + """Controller for managing multiple cameras simultaneously.""" + + # Signals + frame_ready = pyqtSignal(object) # MultiFrameData + camera_started = pyqtSignal(int, object) # camera_index, settings + camera_stopped = pyqtSignal(int) # camera_index + camera_error = pyqtSignal(int, str) # camera_index, error_message + all_started = pyqtSignal() + all_stopped = pyqtSignal() + + MAX_CAMERAS = 4 + + def __init__(self): + super().__init__() + self._workers: Dict[int, SingleCameraWorker] = {} + self._threads: Dict[int, QThread] = {} + self._settings: Dict[int, CameraSettings] = {} + self._frames: Dict[int, np.ndarray] = {} + self._timestamps: Dict[int, float] = {} + self._frame_lock = Lock() + self._running = False + self._started_cameras: set = set() + + def is_running(self) -> bool: + """Check if any camera is currently running.""" + return self._running and len(self._started_cameras) > 0 + + def get_active_count(self) -> int: + """Get the number of active cameras.""" + return len(self._started_cameras) + + def start(self, camera_settings: List[CameraSettings]) -> None: + """Start multiple cameras. + + Parameters + ---------- + camera_settings : List[CameraSettings] + List of camera settings for each camera to start. + Maximum of MAX_CAMERAS cameras allowed. + """ + if self._running: + LOGGER.warning("Multi-camera controller already running") + return + + # Limit to MAX_CAMERAS + active_settings = [s for s in camera_settings if s.enabled][: self.MAX_CAMERAS] + if not active_settings: + LOGGER.warning("No active cameras to start") + return + + self._running = True + self._frames.clear() + self._timestamps.clear() + self._started_cameras.clear() + + for settings in active_settings: + self._start_camera(settings) + + def _start_camera(self, settings: CameraSettings) -> None: + """Start a single camera.""" + cam_idx = settings.index + if cam_idx in self._workers: + LOGGER.warning(f"Camera {cam_idx} already has a worker") + return + + self._settings[cam_idx] = settings + worker = SingleCameraWorker(cam_idx, settings) + thread = QThread() + worker.moveToThread(thread) + + # Connect signals + thread.started.connect(worker.run) + worker.frame_captured.connect(self._on_frame_captured) + worker.started.connect(self._on_camera_started) + worker.stopped.connect(self._on_camera_stopped) + worker.error_occurred.connect(self._on_camera_error) + + self._workers[cam_idx] = worker + self._threads[cam_idx] = thread + thread.start() + + def stop(self, wait: bool = True) -> None: + """Stop all cameras.""" + if not self._running: + return + + self._running = False + + # Signal all workers to stop + for worker in self._workers.values(): + worker.stop() + + # Wait for threads to finish + if wait: + for thread in self._threads.values(): + if thread.isRunning(): + thread.quit() + thread.wait(5000) + + self._workers.clear() + self._threads.clear() + self._settings.clear() + self._started_cameras.clear() + self.all_stopped.emit() + + def _on_frame_captured(self, camera_index: int, frame: np.ndarray, timestamp: float) -> None: + """Handle a frame from one camera.""" + # Apply rotation if configured + settings = self._settings.get(camera_index) + if settings and settings.rotation: + frame = self._apply_rotation(frame, settings.rotation) + + # Apply cropping if configured + if settings: + crop_region = settings.get_crop_region() + if crop_region: + frame = self._apply_crop(frame, crop_region) + + with self._frame_lock: + self._frames[camera_index] = frame + self._timestamps[camera_index] = timestamp + + # Create tiled frame whenever we have at least one frame + # This ensures smoother updates even if cameras have different frame rates + if self._frames: + tiled = self._create_tiled_frame() + frame_data = MultiFrameData( + frames=dict(self._frames), + timestamps=dict(self._timestamps), + tiled_frame=tiled, + ) + self.frame_ready.emit(frame_data) + + def _apply_rotation(self, frame: np.ndarray, degrees: int) -> np.ndarray: + """Apply rotation to frame.""" + if degrees == 90: + return cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) + elif degrees == 180: + return cv2.rotate(frame, cv2.ROTATE_180) + elif degrees == 270: + return cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE) + return frame + + def _apply_crop(self, frame: np.ndarray, crop_region: tuple[int, int, int, int]) -> np.ndarray: + """Apply crop to frame.""" + x0, y0, x1, y1 = crop_region + height, width = frame.shape[:2] + + x0 = max(0, min(x0, width)) + y0 = max(0, min(y0, height)) + x1 = max(x0, min(x1, width)) if x1 > 0 else width + y1 = max(y0, min(y1, height)) if y1 > 0 else height + + if x0 < x1 and y0 < y1: + return frame[y0:y1, x0:x1] + return frame + + def _create_tiled_frame(self) -> np.ndarray: + """Create a tiled frame from all camera frames. + + The tiled frame is scaled to fit within a maximum canvas size + while maintaining aspect ratio of individual camera frames. + """ + if not self._frames: + return np.zeros((480, 640, 3), dtype=np.uint8) + + frames_list = [self._frames[idx] for idx in sorted(self._frames.keys())] + num_frames = len(frames_list) + + if num_frames == 0: + return np.zeros((480, 640, 3), dtype=np.uint8) + + # Determine grid layout + if num_frames == 1: + rows, cols = 1, 1 + elif num_frames == 2: + rows, cols = 1, 2 + elif num_frames <= 4: + rows, cols = 2, 2 + else: + rows, cols = 2, 2 # Limit to 4 + + # Maximum canvas size to fit on screen (leaving room for UI elements) + max_canvas_width = 1200 + max_canvas_height = 800 + + # Calculate tile size based on frame aspect ratio and available space + first_frame = frames_list[0] + frame_h, frame_w = first_frame.shape[:2] + frame_aspect = frame_w / frame_h if frame_h > 0 else 1.0 + + # Calculate tile dimensions that fit within the canvas + tile_w = max_canvas_width // cols + tile_h = max_canvas_height // rows + + # Maintain aspect ratio of original frames + tile_aspect = tile_w / tile_h if tile_h > 0 else 1.0 + + if frame_aspect > tile_aspect: + # Frame is wider than tile slot - constrain by width + tile_h = int(tile_w / frame_aspect) + else: + # Frame is taller than tile slot - constrain by height + tile_w = int(tile_h * frame_aspect) + + # Ensure minimum size + tile_w = max(160, tile_w) + tile_h = max(120, tile_h) + + # Create canvas + canvas = np.zeros((rows * tile_h, cols * tile_w, 3), dtype=np.uint8) + + # Place each frame in the grid + for idx, frame in enumerate(frames_list[: rows * cols]): + row = idx // cols + col = idx % cols + + # Ensure frame is 3-channel + if frame.ndim == 2: + frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) + elif frame.shape[2] == 4: + frame = cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR) + + # Resize to tile size + resized = cv2.resize(frame, (tile_w, tile_h)) + + # Add camera index label + cam_indices = sorted(self._frames.keys()) + if idx < len(cam_indices): + label = f"Cam {cam_indices[idx]}" + cv2.putText( + resized, + label, + (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, + 1.0, + (0, 255, 0), + 2, + ) + + # Place in canvas + y_start = row * tile_h + y_end = y_start + tile_h + x_start = col * tile_w + x_end = x_start + tile_w + canvas[y_start:y_end, x_start:x_end] = resized + + return canvas + + def _on_camera_started(self, camera_index: int) -> None: + """Handle camera start event.""" + self._started_cameras.add(camera_index) + settings = self._settings.get(camera_index) + self.camera_started.emit(camera_index, settings) + LOGGER.info(f"Camera {camera_index} started") + + # Check if all cameras have started + if len(self._started_cameras) == len(self._settings): + self.all_started.emit() + + def _on_camera_stopped(self, camera_index: int) -> None: + """Handle camera stop event.""" + self._started_cameras.discard(camera_index) + self.camera_stopped.emit(camera_index) + LOGGER.info(f"Camera {camera_index} stopped") + + # Cleanup thread + if camera_index in self._threads: + thread = self._threads[camera_index] + if thread.isRunning(): + thread.quit() + thread.wait(1000) + del self._threads[camera_index] + + if camera_index in self._workers: + del self._workers[camera_index] + + # Remove frame data + with self._frame_lock: + self._frames.pop(camera_index, None) + self._timestamps.pop(camera_index, None) + + # Check if all cameras have stopped + if not self._started_cameras and self._running: + self._running = False + self.all_stopped.emit() + + def _on_camera_error(self, camera_index: int, message: str) -> None: + """Handle camera error event.""" + LOGGER.error(f"Camera {camera_index} error: {message}") + self.camera_error.emit(camera_index, message) + + def get_frame(self, camera_index: int) -> Optional[np.ndarray]: + """Get the latest frame from a specific camera.""" + with self._frame_lock: + return self._frames.get(camera_index) + + def get_all_frames(self) -> Dict[int, np.ndarray]: + """Get the latest frames from all cameras.""" + with self._frame_lock: + return dict(self._frames) + + def get_tiled_frame(self) -> Optional[np.ndarray]: + """Get a tiled view of all camera frames.""" + with self._frame_lock: + if self._frames: + return self._create_tiled_frame() + return None From 5c7ee50b76d1e555165725f9b6a244dcce1b3a31 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Mon, 22 Dec 2025 13:17:42 +0100 Subject: [PATCH 33/69] Refactor multi-camera handling to use camera IDs instead of indices; fix for basler backend --- dlclivegui/camera_config_dialog.py | 26 +++++ dlclivegui/cameras/basler_backend.py | 27 +++++- dlclivegui/gui.py | 78 ++++++++++----- dlclivegui/multi_camera_controller.py | 132 ++++++++++++++------------ 4 files changed, 175 insertions(+), 88 deletions(-) diff --git a/dlclivegui/camera_config_dialog.py b/dlclivegui/camera_config_dialog.py index b0acb21..110c141 100644 --- a/dlclivegui/camera_config_dialog.py +++ b/dlclivegui/camera_config_dialog.py @@ -20,6 +20,7 @@ QMessageBox, QPushButton, QSpinBox, + QStyle, QVBoxLayout, QWidget, ) @@ -78,10 +79,15 @@ def _setup_ui(self) -> None: # Buttons for managing active cameras list_buttons = QHBoxLayout() self.remove_camera_btn = QPushButton("Remove") + self.remove_camera_btn.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon) + ) self.remove_camera_btn.setEnabled(False) self.move_up_btn = QPushButton("↑") + self.move_up_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowUp)) self.move_up_btn.setEnabled(False) self.move_down_btn = QPushButton("↓") + self.move_down_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown)) self.move_down_btn.setEnabled(False) list_buttons.addWidget(self.remove_camera_btn) list_buttons.addWidget(self.move_up_btn) @@ -106,6 +112,7 @@ def _setup_ui(self) -> None: self.backend_combo.addItem(label, backend) backend_layout.addWidget(self.backend_combo) self.refresh_btn = QPushButton("Refresh") + self.refresh_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload)) backend_layout.addWidget(self.refresh_btn) available_layout.addLayout(backend_layout) @@ -113,6 +120,7 @@ def _setup_ui(self) -> None: available_layout.addWidget(self.available_cameras_list) self.add_camera_btn = QPushButton("Add Selected Camera →") + self.add_camera_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowRight)) self.add_camera_btn.setEnabled(False) available_layout.addWidget(self.add_camera_btn) @@ -199,6 +207,9 @@ def _setup_ui(self) -> None: self.settings_form.addRow("Crop (x0,y0,x1,y1):", crop_widget) self.apply_settings_btn = QPushButton("Apply Settings") + self.apply_settings_btn.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton) + ) self.apply_settings_btn.setEnabled(False) self.settings_form.addRow(self.apply_settings_btn) @@ -208,7 +219,11 @@ def _setup_ui(self) -> None: # Dialog buttons button_layout = QHBoxLayout() self.ok_btn = QPushButton("OK") + self.ok_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogOkButton)) self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_DialogCancelButton) + ) button_layout.addStretch(1) button_layout.addWidget(self.ok_btn) button_layout.addWidget(self.cancel_btn) @@ -352,6 +367,17 @@ def _add_selected_camera(self) -> None: detected = item.data(Qt.ItemDataRole.UserRole) backend = self.backend_combo.currentData() or "opencv" + # Check if this camera (same backend + index) is already added + for i in range(self.active_cameras_list.count()): + existing_cam = self.active_cameras_list.item(i).data(Qt.ItemDataRole.UserRole) + if existing_cam.backend == backend and existing_cam.index == detected.index: + QMessageBox.warning( + self, + "Duplicate Camera", + f"Camera '{backend}:{detected.index}' is already in the active list.", + ) + return + # Create new camera settings new_cam = CameraSettings( name=detected.label, diff --git a/dlclivegui/cameras/basler_backend.py b/dlclivegui/cameras/basler_backend.py index 307fa96..7517f6c 100644 --- a/dlclivegui/cameras/basler_backend.py +++ b/dlclivegui/cameras/basler_backend.py @@ -25,6 +25,10 @@ def __init__(self, settings): super().__init__(settings) self._camera: Optional["pylon.InstantCamera"] = None self._converter: Optional["pylon.ImageFormatConverter"] = None + # Parse resolution with defaults (720x540) + self._resolution: Tuple[int, int] = self._parse_resolution( + settings.properties.get("resolution") + ) @classmethod def is_available(cls) -> bool: @@ -67,8 +71,7 @@ def open(self) -> None: LOG.warning(f"Failed to set gain to {gain}: {e}") # Configure resolution - requested_width = int(self.settings.properties.get("width", self.settings.width)) - requested_height = int(self.settings.properties.get("height", self.settings.height)) + requested_width, requested_height = self._resolution try: self._camera.Width.SetValue(requested_width) self._camera.Height.SetValue(requested_height) @@ -181,6 +184,26 @@ def _rotate(self, frame: np.ndarray, angle: float) -> np.ndarray: ) from exc return rotate_bound(frame, angle) + def _parse_resolution(self, resolution) -> Tuple[int, int]: + """Parse resolution setting. + + Args: + resolution: Can be a tuple/list [width, height], or None + + Returns: + Tuple of (width, height), defaults to (720, 540) + """ + if resolution is None: + return (720, 540) # Default resolution + + if isinstance(resolution, (list, tuple)) and len(resolution) == 2: + try: + return (int(resolution[0]), int(resolution[1])) + except (ValueError, TypeError): + return (720, 540) + + return (720, 540) + @staticmethod def _settings_value(key: str, source: dict, fallback: Optional[float] = None): value = source.get(key, fallback) diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index a65147d..90aeb15 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -34,6 +34,7 @@ QSizePolicy, QSpinBox, QStatusBar, + QStyle, QVBoxLayout, QWidget, ) @@ -50,7 +51,7 @@ VisualizationSettings, ) from dlclivegui.dlc_processor import DLCLiveProcessor, PoseResult, ProcessorStats -from dlclivegui.multi_camera_controller import MultiCameraController, MultiFrameData +from dlclivegui.multi_camera_controller import MultiCameraController, MultiFrameData, get_camera_id from dlclivegui.processors.processor_utils import instantiate_from_scan, scan_processor_folder from dlclivegui.video_recorder import RecorderStats, VideoRecorder @@ -114,8 +115,8 @@ def __init__(self, config: Optional[ApplicationSettings] = None): # Multi-camera state self._multi_camera_mode = False - self._multi_camera_recorders: dict[int, VideoRecorder] = {} - self._multi_camera_frames: dict[int, np.ndarray] = {} + self._multi_camera_recorders: dict[str, VideoRecorder] = {} + self._multi_camera_frames: dict[str, np.ndarray] = {} self._setup_ui() self._connect_signals() @@ -208,8 +209,12 @@ def _setup_ui(self) -> None: button_bar = QHBoxLayout(button_bar_widget) button_bar.setContentsMargins(0, 5, 0, 5) self.preview_button = QPushButton("Start Preview") + self.preview_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) self.preview_button.setMinimumWidth(150) self.stop_preview_button = QPushButton("Stop Preview") + self.stop_preview_button.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop) + ) self.stop_preview_button.setEnabled(False) self.stop_preview_button.setMinimumWidth(150) button_bar.addWidget(self.preview_button) @@ -252,6 +257,9 @@ def _build_camera_group(self) -> QGroupBox: # Camera config button - opens dialog for all camera configuration config_layout = QHBoxLayout() self.config_cameras_button = QPushButton("Configure Cameras...") + self.config_cameras_button.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon) + ) self.config_cameras_button.setToolTip("Configure camera settings (single or multi-camera)") config_layout.addWidget(self.config_cameras_button) form.addRow(config_layout) @@ -272,6 +280,9 @@ def _build_dlc_group(self) -> QGroupBox: self.model_path_edit.setPlaceholderText("/path/to/exported/model") path_layout.addWidget(self.model_path_edit) self.browse_model_button = QPushButton("Browse…") + self.browse_model_button.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon) + ) self.browse_model_button.clicked.connect(self._action_browse_model) path_layout.addWidget(self.browse_model_button) form.addRow("Model file", path_layout) @@ -283,10 +294,16 @@ def _build_dlc_group(self) -> QGroupBox: processor_path_layout.addWidget(self.processor_folder_edit) self.browse_processor_folder_button = QPushButton("Browse...") + self.browse_processor_folder_button.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon) + ) self.browse_processor_folder_button.clicked.connect(self._action_browse_processor_folder) processor_path_layout.addWidget(self.browse_processor_folder_button) self.refresh_processors_button = QPushButton("Refresh") + self.refresh_processors_button.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload) + ) self.refresh_processors_button.clicked.connect(self._refresh_processors) processor_path_layout.addWidget(self.refresh_processors_button) form.addRow("Processor folder", processor_path_layout) @@ -305,10 +322,16 @@ def _build_dlc_group(self) -> QGroupBox: inference_buttons = QHBoxLayout(inference_button_widget) inference_buttons.setContentsMargins(0, 0, 0, 0) self.start_inference_button = QPushButton("Start pose inference") + self.start_inference_button.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowRight) + ) self.start_inference_button.setEnabled(False) self.start_inference_button.setMinimumWidth(150) inference_buttons.addWidget(self.start_inference_button) self.stop_inference_button = QPushButton("Stop pose inference") + self.stop_inference_button.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserStop) + ) self.stop_inference_button.setEnabled(False) self.stop_inference_button.setMinimumWidth(150) inference_buttons.addWidget(self.stop_inference_button) @@ -339,6 +362,7 @@ def _build_recording_group(self) -> QGroupBox: self.output_directory_edit = QLineEdit() dir_layout.addWidget(self.output_directory_edit) browse_dir = QPushButton("Browse…") + browse_dir.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon)) browse_dir.clicked.connect(self._action_browse_directory) dir_layout.addWidget(browse_dir) form.addRow("Output directory", dir_layout) @@ -369,9 +393,15 @@ def _build_recording_group(self) -> QGroupBox: buttons = QHBoxLayout(recording_button_widget) buttons.setContentsMargins(0, 0, 0, 0) self.start_record_button = QPushButton("Start recording") + self.start_record_button.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_DialogYesButton) + ) self.start_record_button.setMinimumWidth(150) buttons.addWidget(self.start_record_button) self.stop_record_button = QPushButton("Stop recording") + self.stop_record_button.setIcon( + self.style().standardIcon(QStyle.StandardPixmap.SP_DialogNoButton) + ) self.stop_record_button.setEnabled(False) self.stop_record_button.setMinimumWidth(150) buttons.addWidget(self.stop_record_button) @@ -679,20 +709,20 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: # For single camera mode, also set raw_frame for DLC processing if len(frame_data.frames) == 1: - cam_idx = next(iter(frame_data.frames.keys())) - self._raw_frame = frame_data.frames[cam_idx] + cam_id = next(iter(frame_data.frames.keys())) + self._raw_frame = frame_data.frames[cam_id] # Record individual camera feeds if recording is active if self._multi_camera_recorders: - for cam_idx, frame in frame_data.frames.items(): - if cam_idx in self._multi_camera_recorders: - recorder = self._multi_camera_recorders[cam_idx] + for cam_id, frame in frame_data.frames.items(): + if cam_id in self._multi_camera_recorders: + recorder = self._multi_camera_recorders[cam_id] if recorder.is_running: - timestamp = frame_data.timestamps.get(cam_idx, time.time()) + timestamp = frame_data.timestamps.get(cam_id, time.time()) try: recorder.write(frame, timestamp=timestamp) except Exception as exc: - logging.warning(f"Failed to write frame for camera {cam_idx}: {exc}") + logging.warning(f"Failed to write frame for camera {cam_id}: {exc}") # Display tiled frame (or single frame for 1 camera) if frame_data.tiled_frame is not None: @@ -701,9 +731,9 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: # For DLC processing, use single frame if only one camera if self._dlc_active and len(frame_data.frames) == 1: - cam_idx = next(iter(frame_data.frames.keys())) - frame = frame_data.frames[cam_idx] - timestamp = frame_data.timestamps.get(cam_idx, time.time()) + cam_id = next(iter(frame_data.frames.keys())) + frame = frame_data.frames[cam_id] + timestamp = frame_data.timestamps.get(cam_id, time.time()) self.dlc_processor.enqueue_frame(frame, timestamp) def _on_multi_camera_started(self) -> None: @@ -732,9 +762,9 @@ def _on_multi_camera_stopped(self) -> None: self._update_inference_buttons() self._update_camera_controls_enabled() - def _on_multi_camera_error(self, camera_index: int, message: str) -> None: + def _on_multi_camera_error(self, camera_id: str, message: str) -> None: """Handle error from a camera in multi-camera mode.""" - self._show_warning(f"Camera {camera_index} error: {message}") + self._show_warning(f"Camera {camera_id} error: {message}") def _start_multi_camera_recording(self) -> None: """Start recording from all active cameras.""" @@ -755,13 +785,13 @@ def _start_multi_camera_recording(self) -> None: base_stem = base_path.stem for cam in active_cams: - cam_idx = cam.index + cam_id = get_camera_id(cam) # Create unique filename for each camera - cam_filename = f"{base_stem}_cam{cam_idx}{base_path.suffix}" + cam_filename = f"{base_stem}_{cam.backend}_cam{cam.index}{base_path.suffix}" cam_path = base_path.parent / cam_filename # Get frame from current frames if available - frame = self._multi_camera_frames.get(cam_idx) + frame = self._multi_camera_frames.get(cam_id) frame_size = (frame.shape[0], frame.shape[1]) if frame is not None else None recorder = VideoRecorder( @@ -774,10 +804,10 @@ def _start_multi_camera_recording(self) -> None: try: recorder.start() - self._multi_camera_recorders[cam_idx] = recorder - logging.info(f"Started recording camera {cam_idx} to {cam_path}") + self._multi_camera_recorders[cam_id] = recorder + logging.info(f"Started recording camera {cam_id} to {cam_path}") except Exception as exc: - self._show_error(f"Failed to start recording for camera {cam_idx}: {exc}") + self._show_error(f"Failed to start recording for camera {cam_id}: {exc}") if self._multi_camera_recorders: self.start_record_button.setEnabled(False) @@ -793,12 +823,12 @@ def _stop_multi_camera_recording(self) -> None: if not self._multi_camera_recorders: return - for cam_idx, recorder in self._multi_camera_recorders.items(): + for cam_id, recorder in self._multi_camera_recorders.items(): try: recorder.stop() - logging.info(f"Stopped recording camera {cam_idx}") + logging.info(f"Stopped recording camera {cam_id}") except Exception as exc: - logging.warning(f"Error stopping recorder for camera {cam_idx}: {exc}") + logging.warning(f"Error stopping recorder for camera {cam_id}: {exc}") self._multi_camera_recorders.clear() self.start_record_button.setEnabled(True) diff --git a/dlclivegui/multi_camera_controller.py b/dlclivegui/multi_camera_controller.py index 6d6b9b8..e2352e3 100644 --- a/dlclivegui/multi_camera_controller.py +++ b/dlclivegui/multi_camera_controller.py @@ -23,22 +23,22 @@ class MultiFrameData: """Container for frames from multiple cameras.""" - frames: Dict[int, np.ndarray] # camera_index -> frame - timestamps: Dict[int, float] # camera_index -> timestamp + frames: Dict[str, np.ndarray] # camera_id -> frame + timestamps: Dict[str, float] # camera_id -> timestamp tiled_frame: Optional[np.ndarray] = None # Combined tiled frame class SingleCameraWorker(QObject): """Worker for a single camera in multi-camera mode.""" - frame_captured = pyqtSignal(int, object, float) # camera_index, frame, timestamp - error_occurred = pyqtSignal(int, str) # camera_index, error_message - started = pyqtSignal(int) # camera_index - stopped = pyqtSignal(int) # camera_index + frame_captured = pyqtSignal(str, object, float) # camera_id, frame, timestamp + error_occurred = pyqtSignal(str, str) # camera_id, error_message + started = pyqtSignal(str) # camera_id + stopped = pyqtSignal(str) # camera_id - def __init__(self, camera_index: int, settings: CameraSettings): + def __init__(self, camera_id: str, settings: CameraSettings): super().__init__() - self._camera_index = camera_index + self._camera_id = camera_id self._settings = settings self._stop_event = Event() self._backend: Optional[CameraBackend] = None @@ -53,12 +53,12 @@ def run(self) -> None: self._backend = CameraFactory.create(self._settings) self._backend.open() except Exception as exc: - LOGGER.exception(f"Failed to initialize camera {self._camera_index}", exc_info=exc) - self.error_occurred.emit(self._camera_index, f"Failed to initialize camera: {exc}") - self.stopped.emit(self._camera_index) + LOGGER.exception(f"Failed to initialize camera {self._camera_id}", exc_info=exc) + self.error_occurred.emit(self._camera_id, f"Failed to initialize camera: {exc}") + self.stopped.emit(self._camera_id) return - self.started.emit(self._camera_index) + self.started.emit(self._camera_id) consecutive_errors = 0 while not self._stop_event.is_set(): @@ -67,20 +67,20 @@ def run(self) -> None: if frame is None or frame.size == 0: consecutive_errors += 1 if consecutive_errors >= self._max_consecutive_errors: - self.error_occurred.emit(self._camera_index, "Too many empty frames") + self.error_occurred.emit(self._camera_id, "Too many empty frames") break time.sleep(self._retry_delay) continue consecutive_errors = 0 - self.frame_captured.emit(self._camera_index, frame, timestamp) + self.frame_captured.emit(self._camera_id, frame, timestamp) except Exception as exc: consecutive_errors += 1 if self._stop_event.is_set(): break if consecutive_errors >= self._max_consecutive_errors: - self.error_occurred.emit(self._camera_index, f"Camera read error: {exc}") + self.error_occurred.emit(self._camera_id, f"Camera read error: {exc}") break time.sleep(self._retry_delay) continue @@ -91,20 +91,25 @@ def run(self) -> None: self._backend.close() except Exception: pass - self.stopped.emit(self._camera_index) + self.stopped.emit(self._camera_id) def stop(self) -> None: self._stop_event.set() +def get_camera_id(settings: CameraSettings) -> str: + """Generate a unique camera ID from settings.""" + return f"{settings.backend}:{settings.index}" + + class MultiCameraController(QObject): """Controller for managing multiple cameras simultaneously.""" # Signals frame_ready = pyqtSignal(object) # MultiFrameData - camera_started = pyqtSignal(int, object) # camera_index, settings - camera_stopped = pyqtSignal(int) # camera_index - camera_error = pyqtSignal(int, str) # camera_index, error_message + camera_started = pyqtSignal(str, object) # camera_id, settings + camera_stopped = pyqtSignal(str) # camera_id + camera_error = pyqtSignal(str, str) # camera_id, error_message all_started = pyqtSignal() all_stopped = pyqtSignal() @@ -112,11 +117,11 @@ class MultiCameraController(QObject): def __init__(self): super().__init__() - self._workers: Dict[int, SingleCameraWorker] = {} - self._threads: Dict[int, QThread] = {} - self._settings: Dict[int, CameraSettings] = {} - self._frames: Dict[int, np.ndarray] = {} - self._timestamps: Dict[int, float] = {} + self._workers: Dict[str, SingleCameraWorker] = {} + self._threads: Dict[str, QThread] = {} + self._settings: Dict[str, CameraSettings] = {} + self._frames: Dict[str, np.ndarray] = {} + self._timestamps: Dict[str, float] = {} self._frame_lock = Lock() self._running = False self._started_cameras: set = set() @@ -158,13 +163,13 @@ def start(self, camera_settings: List[CameraSettings]) -> None: def _start_camera(self, settings: CameraSettings) -> None: """Start a single camera.""" - cam_idx = settings.index - if cam_idx in self._workers: - LOGGER.warning(f"Camera {cam_idx} already has a worker") + cam_id = get_camera_id(settings) + if cam_id in self._workers: + LOGGER.warning(f"Camera {cam_id} already has a worker") return - self._settings[cam_idx] = settings - worker = SingleCameraWorker(cam_idx, settings) + self._settings[cam_id] = settings + worker = SingleCameraWorker(cam_id, settings) thread = QThread() worker.moveToThread(thread) @@ -175,8 +180,8 @@ def _start_camera(self, settings: CameraSettings) -> None: worker.stopped.connect(self._on_camera_stopped) worker.error_occurred.connect(self._on_camera_error) - self._workers[cam_idx] = worker - self._threads[cam_idx] = thread + self._workers[cam_id] = worker + self._threads[cam_id] = thread thread.start() def stop(self, wait: bool = True) -> None: @@ -203,10 +208,10 @@ def stop(self, wait: bool = True) -> None: self._started_cameras.clear() self.all_stopped.emit() - def _on_frame_captured(self, camera_index: int, frame: np.ndarray, timestamp: float) -> None: + def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: """Handle a frame from one camera.""" # Apply rotation if configured - settings = self._settings.get(camera_index) + settings = self._settings.get(camera_id) if settings and settings.rotation: frame = self._apply_rotation(frame, settings.rotation) @@ -217,8 +222,8 @@ def _on_frame_captured(self, camera_index: int, frame: np.ndarray, timestamp: fl frame = self._apply_crop(frame, crop_region) with self._frame_lock: - self._frames[camera_index] = frame - self._timestamps[camera_index] = timestamp + self._frames[camera_id] = frame + self._timestamps[camera_id] = timestamp # Create tiled frame whenever we have at least one frame # This ensures smoother updates even if cameras have different frame rates @@ -310,6 +315,10 @@ def _create_tiled_frame(self) -> np.ndarray: # Create canvas canvas = np.zeros((rows * tile_h, cols * tile_w, 3), dtype=np.uint8) + # Get sorted camera IDs for consistent ordering + cam_ids = sorted(self._frames.keys()) + frames_list = [self._frames[cam_id] for cam_id in cam_ids] + # Place each frame in the grid for idx, frame in enumerate(frames_list[: rows * cols]): row = idx // cols @@ -324,16 +333,15 @@ def _create_tiled_frame(self) -> np.ndarray: # Resize to tile size resized = cv2.resize(frame, (tile_w, tile_h)) - # Add camera index label - cam_indices = sorted(self._frames.keys()) - if idx < len(cam_indices): - label = f"Cam {cam_indices[idx]}" + # Add camera ID label + if idx < len(cam_ids): + label = cam_ids[idx] cv2.putText( resized, label, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, - 1.0, + 0.7, (0, 255, 0), 2, ) @@ -347,55 +355,55 @@ def _create_tiled_frame(self) -> np.ndarray: return canvas - def _on_camera_started(self, camera_index: int) -> None: + def _on_camera_started(self, camera_id: str) -> None: """Handle camera start event.""" - self._started_cameras.add(camera_index) - settings = self._settings.get(camera_index) - self.camera_started.emit(camera_index, settings) - LOGGER.info(f"Camera {camera_index} started") + self._started_cameras.add(camera_id) + settings = self._settings.get(camera_id) + self.camera_started.emit(camera_id, settings) + LOGGER.info(f"Camera {camera_id} started") # Check if all cameras have started if len(self._started_cameras) == len(self._settings): self.all_started.emit() - def _on_camera_stopped(self, camera_index: int) -> None: + def _on_camera_stopped(self, camera_id: str) -> None: """Handle camera stop event.""" - self._started_cameras.discard(camera_index) - self.camera_stopped.emit(camera_index) - LOGGER.info(f"Camera {camera_index} stopped") + self._started_cameras.discard(camera_id) + self.camera_stopped.emit(camera_id) + LOGGER.info(f"Camera {camera_id} stopped") # Cleanup thread - if camera_index in self._threads: - thread = self._threads[camera_index] + if camera_id in self._threads: + thread = self._threads[camera_id] if thread.isRunning(): thread.quit() thread.wait(1000) - del self._threads[camera_index] + del self._threads[camera_id] - if camera_index in self._workers: - del self._workers[camera_index] + if camera_id in self._workers: + del self._workers[camera_id] # Remove frame data with self._frame_lock: - self._frames.pop(camera_index, None) - self._timestamps.pop(camera_index, None) + self._frames.pop(camera_id, None) + self._timestamps.pop(camera_id, None) # Check if all cameras have stopped if not self._started_cameras and self._running: self._running = False self.all_stopped.emit() - def _on_camera_error(self, camera_index: int, message: str) -> None: + def _on_camera_error(self, camera_id: str, message: str) -> None: """Handle camera error event.""" - LOGGER.error(f"Camera {camera_index} error: {message}") - self.camera_error.emit(camera_index, message) + LOGGER.error(f"Camera {camera_id} error: {message}") + self.camera_error.emit(camera_id, message) - def get_frame(self, camera_index: int) -> Optional[np.ndarray]: + def get_frame(self, camera_id: str) -> Optional[np.ndarray]: """Get the latest frame from a specific camera.""" with self._frame_lock: - return self._frames.get(camera_index) + return self._frames.get(camera_id) - def get_all_frames(self) -> Dict[int, np.ndarray]: + def get_all_frames(self) -> Dict[str, np.ndarray]: """Get the latest frames from all cameras.""" with self._frame_lock: return dict(self._frames) From f33229155349c6f3a9fb8707bfe9fd3db6fad8ba Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Mon, 22 Dec 2025 15:39:57 +0100 Subject: [PATCH 34/69] Enhance multi-camera support with tiled view rendering and display optimization --- dlclivegui/gui.py | 145 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 124 insertions(+), 21 deletions(-) diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 90aeb15..d709f76 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -117,6 +117,12 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._multi_camera_mode = False self._multi_camera_recorders: dict[str, VideoRecorder] = {} self._multi_camera_frames: dict[str, np.ndarray] = {} + # DLC pose rendering info for tiled view + self._dlc_tile_offset: tuple[int, int] = (0, 0) # (x, y) offset in tiled frame + self._dlc_tile_scale: tuple[float, float] = (1.0, 1.0) # (scale_x, scale_y) + # Pending frame for display (decoupled from frame capture for performance) + self._pending_display_frame: Optional[np.ndarray] = None + self._display_dirty: bool = False self._setup_ui() self._connect_signals() @@ -130,6 +136,12 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._metrics_timer.start() self._update_metrics() + # Display timer - decoupled from frame capture for performance + self._display_timer = QTimer(self) + self._display_timer.setInterval(33) # ~30 fps display rate + self._display_timer.timeout.connect(self._update_display_from_pending) + self._display_timer.start() + # Show status message if myconfig.json was loaded if self._config_path and self._config_path.name == "myconfig.json": self.statusBar().showMessage( @@ -703,16 +715,33 @@ def _update_active_cameras_label(self) -> None: self.active_cameras_label.setText(f"{len(active_cams)} cameras: {', '.join(cam_names)}") def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: - """Handle frames from multiple cameras.""" + """Handle frames from multiple cameras. + + Priority order for performance: + 1. DLC processing (highest priority - enqueue immediately) + 2. Recording (queued writes, non-blocking) + 3. Display (lowest priority - updated on separate timer) + """ self._multi_camera_frames = frame_data.frames self._track_camera_frame() # Track FPS - # For single camera mode, also set raw_frame for DLC processing - if len(frame_data.frames) == 1: - cam_id = next(iter(frame_data.frames.keys())) - self._raw_frame = frame_data.frames[cam_id] + # Always update tile info for pose/bbox rendering (needed even without DLC) + active_cams = self._config.multi_camera.get_active_cameras() + dlc_cam_id = None + if active_cams and frame_data.frames: + dlc_cam_id = get_camera_id(active_cams[0]) + if dlc_cam_id in frame_data.frames: + frame = frame_data.frames[dlc_cam_id] + self._raw_frame = frame + self._update_dlc_tile_info(dlc_cam_id, frame, frame_data) + + # PRIORITY 1: DLC processing - do this first! + if self._dlc_active and dlc_cam_id and dlc_cam_id in frame_data.frames: + frame = frame_data.frames[dlc_cam_id] + timestamp = frame_data.timestamps.get(dlc_cam_id, time.time()) + self.dlc_processor.enqueue_frame(frame, timestamp) - # Record individual camera feeds if recording is active + # PRIORITY 2: Recording (queued, non-blocking) if self._multi_camera_recorders: for cam_id, frame in frame_data.frames.items(): if cam_id in self._multi_camera_recorders: @@ -724,17 +753,60 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: except Exception as exc: logging.warning(f"Failed to write frame for camera {cam_id}: {exc}") - # Display tiled frame (or single frame for 1 camera) + # PRIORITY 3: Store frame for display (updated on separate timer) if frame_data.tiled_frame is not None: self._current_frame = frame_data.tiled_frame - self._display_frame(frame_data.tiled_frame) + self._pending_display_frame = frame_data.tiled_frame + self._display_dirty = True + + def _update_dlc_tile_info( + self, dlc_cam_id: str, original_frame: np.ndarray, frame_data: MultiFrameData + ) -> None: + """Calculate tile offset and scale for drawing DLC poses on tiled frame.""" + if frame_data.tiled_frame is None: + self._dlc_tile_offset = (0, 0) + self._dlc_tile_scale = (1.0, 1.0) + return - # For DLC processing, use single frame if only one camera - if self._dlc_active and len(frame_data.frames) == 1: - cam_id = next(iter(frame_data.frames.keys())) - frame = frame_data.frames[cam_id] - timestamp = frame_data.timestamps.get(cam_id, time.time()) - self.dlc_processor.enqueue_frame(frame, timestamp) + num_cameras = len(frame_data.frames) + + # Get original frame dimensions + orig_h, orig_w = original_frame.shape[:2] + + # Calculate grid layout (must match _create_tiled_frame logic) + if num_cameras == 1: + rows, cols = 1, 1 + elif num_cameras == 2: + rows, cols = 1, 2 + else: + rows, cols = 2, 2 + + # Calculate tile dimensions from tiled frame + tiled_h, tiled_w = frame_data.tiled_frame.shape[:2] + tile_w = tiled_w // cols + tile_h = tiled_h // rows + + # Find the position of the DLC camera in the sorted camera list + sorted_cam_ids = sorted(frame_data.frames.keys()) + try: + dlc_cam_idx = sorted_cam_ids.index(dlc_cam_id) + except ValueError: + dlc_cam_idx = 0 + + # Calculate grid position + row = dlc_cam_idx // cols + col = dlc_cam_idx % cols + + # Calculate offset (top-left corner of the tile) + offset_x = col * tile_w + offset_y = row * tile_h + + # Calculate scale factors (always calculate, even for single camera) + scale_x = tile_w / orig_w if orig_w > 0 else 1.0 + scale_y = tile_h / orig_h if orig_h > 0 else 1.0 + + self._dlc_tile_offset = (offset_x, offset_y) + self._dlc_tile_scale = (scale_x, scale_y) def _on_multi_camera_started(self) -> None: """Handle all cameras started event.""" @@ -970,6 +1042,12 @@ def _display_frame(self, frame: np.ndarray, *, force: bool = False) -> None: self._last_display_time = now self._update_video_display(frame) + def _update_display_from_pending(self) -> None: + """Update display from pending frame (called by display timer).""" + if self._display_dirty and self._pending_display_frame is not None: + self._display_dirty = False + self._update_video_display(self._pending_display_frame) + def _compute_fps(self, times: deque[float]) -> float: if len(times) < 2: return 0.0 @@ -1271,8 +1349,14 @@ def _on_bbox_changed(self, _value: int = 0) -> None: self._display_frame(self._current_frame, force=True) def _draw_bbox(self, frame: np.ndarray) -> np.ndarray: - """Draw bounding box on frame with red lines.""" + """Draw bounding box on frame (on first camera tile, scaled like pose).""" overlay = frame.copy() + + # Get tile offset and scale (same as pose rendering) + offset_x, offset_y = self._dlc_tile_offset + scale_x, scale_y = self._dlc_tile_scale + + # Get bbox coordinates in camera pixel space x0 = self._bbox_x0 y0 = self._bbox_y0 x1 = self._bbox_x1 @@ -1282,24 +1366,39 @@ def _draw_bbox(self, frame: np.ndarray) -> np.ndarray: if x0 >= x1 or y0 >= y1: return overlay + # Scale and offset to display coordinates + x0_scaled = int(x0 * scale_x + offset_x) + y0_scaled = int(y0 * scale_y + offset_y) + x1_scaled = int(x1 * scale_x + offset_x) + y1_scaled = int(y1 * scale_y + offset_y) + + # Clamp to frame boundaries height, width = frame.shape[:2] - x0 = max(0, min(x0, width - 1)) - y0 = max(0, min(y0, height - 1)) - x1 = max(x0 + 1, min(x1, width)) - y1 = max(y0 + 1, min(y1, height)) + x0_scaled = max(0, min(x0_scaled, width - 1)) + y0_scaled = max(0, min(y0_scaled, height - 1)) + x1_scaled = max(x0_scaled + 1, min(x1_scaled, width)) + y1_scaled = max(y0_scaled + 1, min(y1_scaled, height)) # Draw rectangle with configured color - cv2.rectangle(overlay, (x0, y0), (x1, y1), self._bbox_color, 2) + cv2.rectangle(overlay, (x0_scaled, y0_scaled), (x1_scaled, y1_scaled), self._bbox_color, 2) return overlay def _draw_pose(self, frame: np.ndarray, pose: np.ndarray) -> np.ndarray: overlay = frame.copy() + # Get tile offset and scale for multi-camera mode + offset_x, offset_y = self._dlc_tile_offset + scale_x, scale_y = self._dlc_tile_scale + # Get the colormap from config cmap = plt.get_cmap(self._colormap) num_keypoints = len(np.asarray(pose)) + # Calculate scaled radius for the keypoint circles + base_radius = 4 + scaled_radius = max(2, int(base_radius * min(scale_x, scale_y))) + for idx, keypoint in enumerate(np.asarray(pose)): if len(keypoint) < 2: continue @@ -1310,13 +1409,17 @@ def _draw_pose(self, frame: np.ndarray, pose: np.ndarray) -> np.ndarray: if confidence < self._p_cutoff: continue + # Apply scale and offset for tiled view + x_scaled = int(x * scale_x + offset_x) + y_scaled = int(y * scale_y + offset_y) + # Get color from colormap (cycle through 0 to 1) color_normalized = idx / max(num_keypoints - 1, 1) rgba = cmap(color_normalized) # Convert from RGBA [0, 1] to BGR [0, 255] for OpenCV bgr_color = (int(rgba[2] * 255), int(rgba[1] * 255), int(rgba[0] * 255)) - cv2.circle(overlay, (int(x), int(y)), 4, bgr_color, -1) + cv2.circle(overlay, (x_scaled, y_scaled), scaled_radius, bgr_color, -1) return overlay def _on_dlc_initialised(self, success: bool) -> None: From 009d0d5ef658f1828e9b1348e1a203ff3342b7b8 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Mon, 22 Dec 2025 16:01:24 +0100 Subject: [PATCH 35/69] Refactor multi-camera frame handling to improve performance; emit frame data without tiling and track source camera ID --- dlclivegui/gui.py | 162 ++++++++++++++++++++------ dlclivegui/multi_camera_controller.py | 10 +- 2 files changed, 134 insertions(+), 38 deletions(-) diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index d709f76..feb0b80 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -120,8 +120,7 @@ def __init__(self, config: Optional[ApplicationSettings] = None): # DLC pose rendering info for tiled view self._dlc_tile_offset: tuple[int, int] = (0, 0) # (x, y) offset in tiled frame self._dlc_tile_scale: tuple[float, float] = (1.0, 1.0) # (scale_x, scale_y) - # Pending frame for display (decoupled from frame capture for performance) - self._pending_display_frame: Optional[np.ndarray] = None + # Display flag (decoupled from frame capture for performance) self._display_dirty: bool = False self._setup_ui() @@ -718,25 +717,28 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: """Handle frames from multiple cameras. Priority order for performance: - 1. DLC processing (highest priority - enqueue immediately) + 1. DLC processing (highest priority - enqueue immediately, only for DLC camera) 2. Recording (queued writes, non-blocking) - 3. Display (lowest priority - updated on separate timer) + 3. Display (lowest priority - tiled and updated on separate timer) """ self._multi_camera_frames = frame_data.frames self._track_camera_frame() # Track FPS - # Always update tile info for pose/bbox rendering (needed even without DLC) + # Determine DLC camera (first active camera) active_cams = self._config.multi_camera.get_active_cameras() - dlc_cam_id = None - if active_cams and frame_data.frames: - dlc_cam_id = get_camera_id(active_cams[0]) - if dlc_cam_id in frame_data.frames: - frame = frame_data.frames[dlc_cam_id] - self._raw_frame = frame - self._update_dlc_tile_info(dlc_cam_id, frame, frame_data) - - # PRIORITY 1: DLC processing - do this first! - if self._dlc_active and dlc_cam_id and dlc_cam_id in frame_data.frames: + dlc_cam_id = get_camera_id(active_cams[0]) if active_cams else None + + # Check if this frame is from the DLC camera + is_dlc_camera_frame = frame_data.source_camera_id == dlc_cam_id + + # Update tile info and raw frame only when DLC camera frame arrives + if is_dlc_camera_frame and dlc_cam_id in frame_data.frames: + frame = frame_data.frames[dlc_cam_id] + self._raw_frame = frame + self._update_dlc_tile_info(dlc_cam_id, frame, frame_data.frames) + + # PRIORITY 1: DLC processing - only enqueue when DLC camera frame arrives! + if self._dlc_active and is_dlc_camera_frame and dlc_cam_id in frame_data.frames: frame = frame_data.frames[dlc_cam_id] timestamp = frame_data.timestamps.get(dlc_cam_id, time.time()) self.dlc_processor.enqueue_frame(frame, timestamp) @@ -753,23 +755,19 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: except Exception as exc: logging.warning(f"Failed to write frame for camera {cam_id}: {exc}") - # PRIORITY 3: Store frame for display (updated on separate timer) - if frame_data.tiled_frame is not None: - self._current_frame = frame_data.tiled_frame - self._pending_display_frame = frame_data.tiled_frame - self._display_dirty = True + # PRIORITY 3: Mark display dirty (tiling done in display timer) + self._display_dirty = True def _update_dlc_tile_info( - self, dlc_cam_id: str, original_frame: np.ndarray, frame_data: MultiFrameData + self, dlc_cam_id: str, original_frame: np.ndarray, frames: dict[str, np.ndarray] ) -> None: """Calculate tile offset and scale for drawing DLC poses on tiled frame.""" - if frame_data.tiled_frame is None: + num_cameras = len(frames) + if num_cameras == 0: self._dlc_tile_offset = (0, 0) self._dlc_tile_scale = (1.0, 1.0) return - num_cameras = len(frame_data.frames) - # Get original frame dimensions orig_h, orig_w = original_frame.shape[:2] @@ -781,13 +779,25 @@ def _update_dlc_tile_info( else: rows, cols = 2, 2 - # Calculate tile dimensions from tiled frame - tiled_h, tiled_w = frame_data.tiled_frame.shape[:2] - tile_w = tiled_w // cols - tile_h = tiled_h // rows + # Calculate tile dimensions using same logic as _create_tiled_frame + max_canvas_width = 1200 + max_canvas_height = 800 + frame_aspect = orig_w / orig_h if orig_h > 0 else 1.0 + + tile_w = max_canvas_width // cols + tile_h = max_canvas_height // rows + tile_aspect = tile_w / tile_h if tile_h > 0 else 1.0 + + if frame_aspect > tile_aspect: + tile_h = int(tile_w / frame_aspect) + else: + tile_w = int(tile_h * frame_aspect) + + tile_w = max(160, tile_w) + tile_h = max(120, tile_h) # Find the position of the DLC camera in the sorted camera list - sorted_cam_ids = sorted(frame_data.frames.keys()) + sorted_cam_ids = sorted(frames.keys()) try: dlc_cam_idx = sorted_cam_ids.index(dlc_cam_id) except ValueError: @@ -1043,10 +1053,96 @@ def _display_frame(self, frame: np.ndarray, *, force: bool = False) -> None: self._update_video_display(frame) def _update_display_from_pending(self) -> None: - """Update display from pending frame (called by display timer).""" - if self._display_dirty and self._pending_display_frame is not None: - self._display_dirty = False - self._update_video_display(self._pending_display_frame) + """Update display from pending frames (called by display timer).""" + if not self._display_dirty: + return + if not self._multi_camera_frames: + return + + self._display_dirty = False + + # Create tiled frame on demand (moved from camera thread for performance) + tiled = self._create_tiled_frame(self._multi_camera_frames) + if tiled is not None: + self._current_frame = tiled + self._update_video_display(tiled) + + def _create_tiled_frame(self, frames: dict[str, np.ndarray]) -> np.ndarray: + """Create a tiled frame from camera frames for display.""" + if not frames: + return np.zeros((480, 640, 3), dtype=np.uint8) + + cam_ids = sorted(frames.keys()) + frames_list = [frames[cam_id] for cam_id in cam_ids] + num_frames = len(frames_list) + + if num_frames == 0: + return np.zeros((480, 640, 3), dtype=np.uint8) + + # Determine grid layout + if num_frames == 1: + rows, cols = 1, 1 + elif num_frames == 2: + rows, cols = 1, 2 + else: + rows, cols = 2, 2 + + # Maximum canvas size + max_canvas_width = 1200 + max_canvas_height = 800 + + # Calculate tile size based on first frame aspect ratio + first_frame = frames_list[0] + frame_h, frame_w = first_frame.shape[:2] + frame_aspect = frame_w / frame_h if frame_h > 0 else 1.0 + + tile_w = max_canvas_width // cols + tile_h = max_canvas_height // rows + tile_aspect = tile_w / tile_h if tile_h > 0 else 1.0 + + if frame_aspect > tile_aspect: + tile_h = int(tile_w / frame_aspect) + else: + tile_w = int(tile_h * frame_aspect) + + tile_w = max(160, tile_w) + tile_h = max(120, tile_h) + + # Create canvas + canvas = np.zeros((rows * tile_h, cols * tile_w, 3), dtype=np.uint8) + + # Place each frame in the grid + for idx, frame in enumerate(frames_list[: rows * cols]): + row = idx // cols + col = idx % cols + + # Ensure frame is 3-channel + if frame.ndim == 2: + frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) + elif frame.shape[2] == 4: + frame = cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR) + + # Resize to tile size + resized = cv2.resize(frame, (tile_w, tile_h)) + + # Add camera ID label + if idx < len(cam_ids): + cv2.putText( + resized, + cam_ids[idx], + (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, + 0.7, + (0, 255, 0), + 2, + ) + + # Place in canvas + y_start = row * tile_h + x_start = col * tile_w + canvas[y_start : y_start + tile_h, x_start : x_start + tile_w] = resized + + return canvas def _compute_fps(self, times: deque[float]) -> float: if len(times) < 2: diff --git a/dlclivegui/multi_camera_controller.py b/dlclivegui/multi_camera_controller.py index e2352e3..4cfca0f 100644 --- a/dlclivegui/multi_camera_controller.py +++ b/dlclivegui/multi_camera_controller.py @@ -25,7 +25,8 @@ class MultiFrameData: frames: Dict[str, np.ndarray] # camera_id -> frame timestamps: Dict[str, float] # camera_id -> timestamp - tiled_frame: Optional[np.ndarray] = None # Combined tiled frame + source_camera_id: str = "" # ID of camera that triggered this emission + tiled_frame: Optional[np.ndarray] = None # Combined tiled frame (deprecated, done in GUI) class SingleCameraWorker(QObject): @@ -225,14 +226,13 @@ def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float self._frames[camera_id] = frame self._timestamps[camera_id] = timestamp - # Create tiled frame whenever we have at least one frame - # This ensures smoother updates even if cameras have different frame rates + # Emit frame data without tiling (tiling done in GUI for performance) if self._frames: - tiled = self._create_tiled_frame() frame_data = MultiFrameData( frames=dict(self._frames), timestamps=dict(self._timestamps), - tiled_frame=tiled, + source_camera_id=camera_id, # Track which camera triggered this + tiled_frame=None, ) self.frame_ready.emit(frame_data) From 557403f70d1d66cd574b3d496d3bc7d91a1f8792 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Tue, 23 Dec 2025 11:01:12 +0100 Subject: [PATCH 36/69] switched to pyside as gui backend --- README.md | 6 +++--- dlclivegui/camera_config_dialog.py | 6 +++--- dlclivegui/dlc_processor.py | 8 ++++---- dlclivegui/gui.py | 10 ++++++---- dlclivegui/multi_camera_controller.py | 24 ++++++++++++------------ pyproject.toml | 6 +++--- setup.py | 4 ++-- 7 files changed, 33 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 1ccf828..b9a2785 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # DeepLabCut Live GUI -A modern PyQt6 GUI for running [DeepLabCut-live](https://github.com/DeepLabCut/DeepLabCut-live) experiments with real-time pose estimation. The application streams frames from industrial or consumer cameras, performs DLCLive inference, and records high-quality video with synchronized pose data. +A modern PySide6 GUI for running [DeepLabCut-live](https://github.com/DeepLabCut/DeepLabCut-live) experiments with real-time pose estimation. The application streams frames from industrial or consumer cameras, performs DLCLive inference, and records high-quality video with synchronized pose data. ## Features ### Core Functionality -- **Modern Python Stack**: Python 3.10+ compatible codebase with PyQt6 interface +- **Modern Python Stack**: Python 3.10+ compatible codebase with PySide6 interface - **Multi-Backend Camera Support**: OpenCV, GenTL (Harvesters), Aravis, and Basler (pypylon) - **Real-Time Pose Estimation**: Live DLCLive inference with configurable models (TensorFlow, PyTorch) - **High-Performance Recording**: Hardware-accelerated video encoding via FFmpeg @@ -261,7 +261,7 @@ Enable "Auto-record video on processor command" to automatically start/stop reco ``` dlclivegui/ ├── __init__.py -├── gui.py # Main PyQt6 application +├── gui.py # Main PySide6 application ├── config.py # Configuration dataclasses ├── camera_controller.py # Camera capture thread ├── dlc_processor.py # DLCLive inference thread diff --git a/dlclivegui/camera_config_dialog.py b/dlclivegui/camera_config_dialog.py index 110c141..0624989 100644 --- a/dlclivegui/camera_config_dialog.py +++ b/dlclivegui/camera_config_dialog.py @@ -5,8 +5,8 @@ import logging from typing import List, Optional -from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtWidgets import ( +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDialog, @@ -36,7 +36,7 @@ class CameraConfigDialog(QDialog): """Dialog for configuring multiple cameras.""" MAX_CAMERAS = 4 - settings_changed = pyqtSignal(object) # MultiCameraSettings + settings_changed = Signal(object) # MultiCameraSettings def __init__( self, diff --git a/dlclivegui/dlc_processor.py b/dlclivegui/dlc_processor.py index ac8985e..e5eb7d2 100644 --- a/dlclivegui/dlc_processor.py +++ b/dlclivegui/dlc_processor.py @@ -11,7 +11,7 @@ from typing import Any, Optional import numpy as np -from PyQt6.QtCore import QObject, pyqtSignal +from PySide6.QtCore import QObject, Signal from dlclivegui.config import DLCProcessorSettings @@ -60,9 +60,9 @@ class ProcessorStats: class DLCLiveProcessor(QObject): """Background pose estimation using DLCLive with queue-based threading.""" - pose_ready = pyqtSignal(object) - error = pyqtSignal(str) - initialized = pyqtSignal(bool) + pose_ready = Signal(object) + error = Signal(str) + initialized = Signal(bool) def __init__(self) -> None: super().__init__() diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index feb0b80..05c51ec 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -1,4 +1,4 @@ -"""PyQt6 based GUI for DeepLabCut Live.""" +"""PySide6 based GUI for DeepLabCut Live.""" from __future__ import annotations @@ -11,12 +11,14 @@ from pathlib import Path from typing import Optional +os.environ["PYLON_CAMEMU"] = "2" + import cv2 import matplotlib.pyplot as plt import numpy as np -from PyQt6.QtCore import Qt, QTimer -from PyQt6.QtGui import QAction, QCloseEvent, QImage, QPixmap -from PyQt6.QtWidgets import ( +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QAction, QCloseEvent, QImage, QPixmap +from PySide6.QtWidgets import ( QApplication, QCheckBox, QComboBox, diff --git a/dlclivegui/multi_camera_controller.py b/dlclivegui/multi_camera_controller.py index 4cfca0f..d152854 100644 --- a/dlclivegui/multi_camera_controller.py +++ b/dlclivegui/multi_camera_controller.py @@ -10,7 +10,7 @@ import cv2 import numpy as np -from PyQt6.QtCore import QObject, QThread, pyqtSignal, pyqtSlot +from PySide6.QtCore import QObject, QThread, Signal, Slot from dlclivegui.cameras import CameraFactory from dlclivegui.cameras.base import CameraBackend @@ -32,10 +32,10 @@ class MultiFrameData: class SingleCameraWorker(QObject): """Worker for a single camera in multi-camera mode.""" - frame_captured = pyqtSignal(str, object, float) # camera_id, frame, timestamp - error_occurred = pyqtSignal(str, str) # camera_id, error_message - started = pyqtSignal(str) # camera_id - stopped = pyqtSignal(str) # camera_id + frame_captured = Signal(str, object, float) # camera_id, frame, timestamp + error_occurred = Signal(str, str) # camera_id, error_message + started = Signal(str) # camera_id + stopped = Signal(str) # camera_id def __init__(self, camera_id: str, settings: CameraSettings): super().__init__() @@ -46,7 +46,7 @@ def __init__(self, camera_id: str, settings: CameraSettings): self._max_consecutive_errors = 5 self._retry_delay = 0.1 - @pyqtSlot() + @Slot() def run(self) -> None: self._stop_event.clear() @@ -107,12 +107,12 @@ class MultiCameraController(QObject): """Controller for managing multiple cameras simultaneously.""" # Signals - frame_ready = pyqtSignal(object) # MultiFrameData - camera_started = pyqtSignal(str, object) # camera_id, settings - camera_stopped = pyqtSignal(str) # camera_id - camera_error = pyqtSignal(str, str) # camera_id, error_message - all_started = pyqtSignal() - all_stopped = pyqtSignal() + frame_ready = Signal(object) # MultiFrameData + camera_started = Signal(str, object) # camera_id, settings + camera_stopped = Signal(str) # camera_id + camera_error = Signal(str, str) # camera_id, error_message + all_started = Signal() + all_stopped = Signal() MAX_CAMERAS = 4 diff --git a/pyproject.toml b/pyproject.toml index edbb9d0..b989b57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "deeplabcut-live-gui" version = "2.0" -description = "PyQt-based GUI to run real time DeepLabCut experiments" +description = "PySide6-based GUI to run real time DeepLabCut experiments" readme = "README.md" requires-python = ">=3.10" license = {text = "GNU Lesser General Public License v3 (LGPLv3)"} @@ -26,8 +26,8 @@ classifiers = [ ] dependencies = [ - "deeplabcut-live", - "PyQt6", + "deeplabcut-live", # might be missing timm and scipy + "PySide6", "numpy", "opencv-python", "vidgear[core]", diff --git a/setup.py b/setup.py index 02954f2..1c6d5fa 100644 --- a/setup.py +++ b/setup.py @@ -12,14 +12,14 @@ version="2.0", author="A. & M. Mathis Labs", author_email="adim@deeplabcut.org", - description="PyQt-based GUI to run real time DeepLabCut experiments", + description="PySide6-based GUI to run real time DeepLabCut experiments", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/DeepLabCut/DeepLabCut-live-GUI", python_requires=">=3.10", install_requires=[ "deeplabcut-live", - "PyQt6", + "PySide6", "numpy", "opencv-python", "vidgear[core]", From be9108616f602d2e5c3d0f186bae528ed384f00b Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Tue, 23 Dec 2025 11:30:44 +0100 Subject: [PATCH 37/69] Add camera availability check and handle initialization failures in multi-camera controller --- dlclivegui/camera_config_dialog.py | 7 +++ dlclivegui/cameras/factory.py | 36 +++++++++++++++ dlclivegui/gui.py | 65 ++++++++++++++++++++++++++- dlclivegui/multi_camera_controller.py | 39 +++++++++++++--- 4 files changed, 139 insertions(+), 8 deletions(-) diff --git a/dlclivegui/camera_config_dialog.py b/dlclivegui/camera_config_dialog.py index 0624989..581a7a3 100644 --- a/dlclivegui/camera_config_dialog.py +++ b/dlclivegui/camera_config_dialog.py @@ -245,6 +245,9 @@ def _connect_signals(self) -> None: self.move_down_btn.clicked.connect(self._move_camera_down) self.active_cameras_list.currentRowChanged.connect(self._on_active_camera_selected) self.available_cameras_list.currentRowChanged.connect(self._on_available_camera_selected) + self.available_cameras_list.itemDoubleClicked.connect( + self._on_available_camera_double_clicked + ) self.apply_settings_btn.clicked.connect(self._apply_camera_settings) self.ok_btn.clicked.connect(self._on_ok_clicked) self.cancel_btn.clicked.connect(self.reject) @@ -289,6 +292,10 @@ def _refresh_available_cameras(self) -> None: def _on_available_camera_selected(self, row: int) -> None: self.add_camera_btn.setEnabled(row >= 0) + def _on_available_camera_double_clicked(self, item: QListWidgetItem) -> None: + """Handle double-click on an available camera to add it.""" + self._add_selected_camera() + def _on_active_camera_selected(self, row: int) -> None: """Handle selection of an active camera.""" self._current_edit_index = row diff --git a/dlclivegui/cameras/factory.py b/dlclivegui/cameras/factory.py index 3261ba5..fcdf679 100644 --- a/dlclivegui/cameras/factory.py +++ b/dlclivegui/cameras/factory.py @@ -127,6 +127,42 @@ def create(settings: CameraSettings) -> CameraBackend: ) return backend_cls(settings) + @staticmethod + def check_camera_available(settings: CameraSettings) -> tuple[bool, str]: + """Check if a camera is available without keeping it open. + + Parameters + ---------- + settings : CameraSettings + The camera settings to check. + + Returns + ------- + tuple[bool, str] + A tuple of (is_available, error_message). + If available, error_message is empty. + """ + backend_name = (settings.backend or "opencv").lower() + + # Check if backend module is available + try: + backend_cls = CameraFactory._resolve_backend(backend_name) + except RuntimeError as exc: + return False, f"Backend '{backend_name}' not installed: {exc}" + + # Check if backend reports as available (drivers installed) + if not backend_cls.is_available(): + return False, f"Backend '{backend_name}' is not available (missing drivers/packages)" + + # Try to actually open the camera briefly + try: + backend_instance = backend_cls(settings) + backend_instance.open() + backend_instance.close() + return True, "" + except Exception as exc: + return False, f"Camera not accessible: {exc}" + @staticmethod def _resolve_backend(name: str) -> Type[CameraBackend]: try: diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 05c51ec..23410d2 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -42,6 +42,7 @@ ) from dlclivegui.camera_config_dialog import CameraConfigDialog +from dlclivegui.cameras import CameraFactory from dlclivegui.config import ( DEFAULT_CONFIG, ApplicationSettings, @@ -149,6 +150,9 @@ def __init__(self, config: Optional[ApplicationSettings] = None): f"Auto-loaded configuration from {self._config_path}", 5000 ) + # Validate cameras from loaded config (deferred to allow window to show first) + QTimer.singleShot(100, self._validate_configured_cameras) + # ------------------------------------------------------------------ UI def _setup_ui(self) -> None: central = QWidget() @@ -486,6 +490,9 @@ def _connect_signals(self) -> None: self.multi_camera_controller.all_started.connect(self._on_multi_camera_started) self.multi_camera_controller.all_stopped.connect(self._on_multi_camera_stopped) self.multi_camera_controller.camera_error.connect(self._on_multi_camera_error) + self.multi_camera_controller.initialization_failed.connect( + self._on_multi_camera_initialization_failed + ) self.dlc_processor.pose_ready.connect(self._on_pose_ready) self.dlc_processor.error.connect(self._on_dlc_error) @@ -601,6 +608,8 @@ def _action_load_config(self) -> None: self._config_path = Path(file_name) self._apply_config(config) self.statusBar().showMessage(f"Loaded configuration: {file_name}", 5000) + # Validate cameras after loading + self._validate_configured_cameras() def _action_save_config(self) -> None: if self._config_path is None: @@ -715,6 +724,39 @@ def _update_active_cameras_label(self) -> None: cam_names = [f"{c.name}" for c in active_cams] self.active_cameras_label.setText(f"{len(active_cams)} cameras: {', '.join(cam_names)}") + def _validate_configured_cameras(self) -> None: + """Validate that configured cameras are available. + + Disables unavailable cameras and shows a warning dialog. + """ + active_cams = self._config.multi_camera.get_active_cameras() + if not active_cams: + return + + unavailable: list[tuple[str, str, CameraSettings]] = [] + for cam in active_cams: + cam_id = f"{cam.backend}:{cam.index}" + available, error = CameraFactory.check_camera_available(cam) + if not available: + unavailable.append((cam.name or cam_id, error, cam)) + + if unavailable: + # Disable unavailable cameras + for _, _, cam in unavailable: + cam.enabled = False + + # Update the active cameras label + self._update_active_cameras_label() + + # Build warning message + error_lines = ["The following camera(s) are not available and have been disabled:"] + for cam_name, error_msg, _ in unavailable: + error_lines.append(f" • {cam_name}: {error_msg}") + error_lines.append("") + error_lines.append("Please check camera connections or re-enable in camera settings.") + self._show_warning("\n".join(error_lines)) + logging.warning("\n".join(error_lines)) + def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: """Handle frames from multiple cameras. @@ -850,6 +892,19 @@ def _on_multi_camera_error(self, camera_id: str, message: str) -> None: """Handle error from a camera in multi-camera mode.""" self._show_warning(f"Camera {camera_id} error: {message}") + def _on_multi_camera_initialization_failed(self, failures: list) -> None: + """Handle complete failure to initialize cameras.""" + # Build error message with details for each failed camera + error_lines = ["Failed to initialize camera(s):"] + for camera_id, error_msg in failures: + error_lines.append(f" • {camera_id}: {error_msg}") + error_lines.append("") + error_lines.append("Please check that the required camera backend is installed.") + + error_message = "\n".join(error_lines) + self._show_error(error_message) + logging.error(error_message) + def _start_multi_camera_recording(self) -> None: """Start recording from all active cameras.""" if self._multi_camera_recorders: @@ -1542,8 +1597,14 @@ def _show_error(self, message: str) -> None: QMessageBox.critical(self, "Error", message) def _show_warning(self, message: str) -> None: - """Display a warning message in the status bar without blocking.""" - self.statusBar().showMessage(f"⚠ {message}", 3000) + """Display a warning message dialog.""" + self.statusBar().showMessage(f"⚠ {message}", 5000) + QMessageBox.warning(self, "Warning", message) + + def _show_info(self, message: str) -> None: + """Display an informational message dialog.""" + self.statusBar().showMessage(message, 5000) + QMessageBox.information(self, "Information", message) # ------------------------------------------------------------------ Qt overrides def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI behaviour diff --git a/dlclivegui/multi_camera_controller.py b/dlclivegui/multi_camera_controller.py index d152854..f9b4226 100644 --- a/dlclivegui/multi_camera_controller.py +++ b/dlclivegui/multi_camera_controller.py @@ -113,6 +113,7 @@ class MultiCameraController(QObject): camera_error = Signal(str, str) # camera_id, error_message all_started = Signal() all_stopped = Signal() + initialization_failed = Signal(list) # List of (camera_id, error_message) tuples MAX_CAMERAS = 4 @@ -126,6 +127,8 @@ def __init__(self): self._frame_lock = Lock() self._running = False self._started_cameras: set = set() + self._failed_cameras: Dict[str, str] = {} # camera_id -> error message + self._expected_cameras: int = 0 # Number of cameras we're trying to start def is_running(self) -> bool: """Check if any camera is currently running.""" @@ -158,6 +161,8 @@ def start(self, camera_settings: List[CameraSettings]) -> None: self._frames.clear() self._timestamps.clear() self._started_cameras.clear() + self._failed_cameras.clear() + self._expected_cameras = len(active_settings) for settings in active_settings: self._start_camera(settings) @@ -207,6 +212,8 @@ def stop(self, wait: bool = True) -> None: self._threads.clear() self._settings.clear() self._started_cameras.clear() + self._failed_cameras.clear() + self._expected_cameras = 0 self.all_stopped.emit() def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None: @@ -362,15 +369,21 @@ def _on_camera_started(self, camera_id: str) -> None: self.camera_started.emit(camera_id, settings) LOGGER.info(f"Camera {camera_id} started") - # Check if all cameras have started - if len(self._started_cameras) == len(self._settings): - self.all_started.emit() + # Check if all cameras have reported (started or failed) + total_reported = len(self._started_cameras) + len(self._failed_cameras) + if total_reported == self._expected_cameras: + if self._started_cameras: + # At least some cameras started successfully + self.all_started.emit() + # If no cameras started but all failed, that's handled in _on_camera_stopped def _on_camera_stopped(self, camera_id: str) -> None: """Handle camera stop event.""" + # Check if this camera never started (initialization failure) + was_started = camera_id in self._started_cameras self._started_cameras.discard(camera_id) self.camera_stopped.emit(camera_id) - LOGGER.info(f"Camera {camera_id} stopped") + LOGGER.info(f"Camera {camera_id} stopped (was_started={was_started})") # Cleanup thread if camera_id in self._threads: @@ -388,14 +401,28 @@ def _on_camera_stopped(self, camera_id: str) -> None: self._frames.pop(camera_id, None) self._timestamps.pop(camera_id, None) - # Check if all cameras have stopped - if not self._started_cameras and self._running: + # Check if all cameras have reported and none started + total_reported = len(self._started_cameras) + len(self._failed_cameras) + if total_reported == self._expected_cameras and not self._started_cameras: + # All cameras failed to start + if self._running and self._failed_cameras: + self._running = False + failure_list = list(self._failed_cameras.items()) + self.initialization_failed.emit(failure_list) + self.all_stopped.emit() + return + + # Check if all running cameras have stopped (normal shutdown) + if not self._started_cameras and self._running and not self._workers: self._running = False self.all_stopped.emit() def _on_camera_error(self, camera_id: str, message: str) -> None: """Handle camera error event.""" LOGGER.error(f"Camera {camera_id} error: {message}") + # Track failed cameras (only if not already started - i.e., initialization failure) + if camera_id not in self._started_cameras: + self._failed_cameras[camera_id] = message self.camera_error.emit(camera_id, message) def get_frame(self, camera_id: str) -> Optional[np.ndarray]: From 6957468925a5d9ea3fb00a3735a8a11bbb087333 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Tue, 23 Dec 2025 12:23:11 +0100 Subject: [PATCH 38/69] Add camera preview functionality with start/stop toggle and real-time updates --- dlclivegui/camera_config_dialog.py | 209 +++++++++++++++++++++++++++-- dlclivegui/cameras/factory.py | 63 ++++++--- 2 files changed, 241 insertions(+), 31 deletions(-) diff --git a/dlclivegui/camera_config_dialog.py b/dlclivegui/camera_config_dialog.py index 581a7a3..978419e 100644 --- a/dlclivegui/camera_config_dialog.py +++ b/dlclivegui/camera_config_dialog.py @@ -5,7 +5,10 @@ import logging from typing import List, Optional -from PySide6.QtCore import Qt, Signal +import cv2 +import numpy as np +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtGui import QImage, QPixmap from PySide6.QtWidgets import ( QCheckBox, QComboBox, @@ -26,6 +29,7 @@ ) from dlclivegui.cameras import CameraFactory +from dlclivegui.cameras.base import CameraBackend from dlclivegui.cameras.factory import DetectedCamera from dlclivegui.config import CameraSettings, MultiCameraSettings @@ -45,13 +49,16 @@ def __init__( ): super().__init__(parent) self.setWindowTitle("Configure Cameras") - self.setMinimumSize(800, 600) + self.setMinimumSize(960, 720) self._multi_camera_settings = ( multi_camera_settings if multi_camera_settings else MultiCameraSettings() ) self._detected_cameras: List[DetectedCamera] = [] self._current_edit_index: Optional[int] = None + self._preview_backend: Optional[CameraBackend] = None + self._preview_timer: Optional[QTimer] = None + self._preview_active: bool = False self._setup_ui() self._populate_from_settings() @@ -213,7 +220,26 @@ def _setup_ui(self) -> None: self.apply_settings_btn.setEnabled(False) self.settings_form.addRow(self.apply_settings_btn) + # Preview button + self.preview_btn = QPushButton("Start Preview") + self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) + self.preview_btn.setEnabled(False) + self.settings_form.addRow(self.preview_btn) + right_layout.addWidget(settings_group) + + # Preview widget + self.preview_group = QGroupBox("Camera Preview") + preview_layout = QVBoxLayout(self.preview_group) + self.preview_label = QLabel("No preview") + self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.preview_label.setMinimumSize(320, 240) + self.preview_label.setMaximumSize(400, 300) + self.preview_label.setStyleSheet("background-color: #1a1a1a; color: #888;") + preview_layout.addWidget(self.preview_label) + self.preview_group.setVisible(False) + right_layout.addWidget(self.preview_group) + right_layout.addStretch(1) # Dialog buttons @@ -249,14 +275,15 @@ def _connect_signals(self) -> None: self._on_available_camera_double_clicked ) self.apply_settings_btn.clicked.connect(self._apply_camera_settings) + self.preview_btn.clicked.connect(self._toggle_preview) self.ok_btn.clicked.connect(self._on_ok_clicked) self.cancel_btn.clicked.connect(self.reject) def _populate_from_settings(self) -> None: """Populate the dialog from existing settings.""" self.active_cameras_list.clear() - for cam in self._multi_camera_settings.cameras: - item = QListWidgetItem(self._format_camera_label(cam)) + for i, cam in enumerate(self._multi_camera_settings.cameras): + item = QListWidgetItem(self._format_camera_label(cam, i)) item.setData(Qt.ItemDataRole.UserRole, cam) if not cam.enabled: item.setForeground(Qt.GlobalColor.gray) @@ -265,10 +292,27 @@ def _populate_from_settings(self) -> None: self._refresh_available_cameras() self._update_button_states() - def _format_camera_label(self, cam: CameraSettings) -> str: - """Format camera label for display.""" + def _format_camera_label(self, cam: CameraSettings, index: int = -1) -> str: + """Format camera label for display. + + Parameters + ---------- + cam : CameraSettings + The camera settings. + index : int + The index of the camera in the list. If 0 and enabled, shows DLC indicator. + """ status = "✓" if cam.enabled else "○" - return f"{status} {cam.name} [{cam.backend}:{cam.index}]" + dlc_indicator = " [DLC]" if index == 0 and cam.enabled else "" + return f"{status} {cam.name} [{cam.backend}:{cam.index}]{dlc_indicator}" + + def _refresh_camera_labels(self) -> None: + """Refresh all camera labels in the active list (e.g., after reorder).""" + for i in range(self.active_cameras_list.count()): + item = self.active_cameras_list.item(i) + cam = item.data(Qt.ItemDataRole.UserRole) + if cam: + item.setText(self._format_camera_label(cam, i)) def _on_backend_changed(self, _index: int) -> None: self._refresh_available_cameras() @@ -298,6 +342,10 @@ def _on_available_camera_double_clicked(self, item: QListWidgetItem) -> None: def _on_active_camera_selected(self, row: int) -> None: """Handle selection of an active camera.""" + # Stop any running preview when selection changes + if self._preview_active: + self._stop_preview() + self._current_edit_index = row self._update_button_states() @@ -399,11 +447,14 @@ def _add_selected_camera(self) -> None: self._multi_camera_settings.cameras.append(new_cam) # Add to list - new_item = QListWidgetItem(self._format_camera_label(new_cam)) + new_index = len(self._multi_camera_settings.cameras) - 1 + new_item = QListWidgetItem(self._format_camera_label(new_cam, new_index)) new_item.setData(Qt.ItemDataRole.UserRole, new_cam) self.active_cameras_list.addItem(new_item) self.active_cameras_list.setCurrentItem(new_item) + # Refresh labels in case this is the first camera (gets DLC indicator) + self._refresh_camera_labels() self._update_button_states() def _remove_selected_camera(self) -> None: @@ -418,6 +469,8 @@ def _remove_selected_camera(self) -> None: self._current_edit_index = None self._clear_settings_form() + # Refresh labels since DLC camera may have changed + self._refresh_camera_labels() self._update_button_states() def _move_camera_up(self) -> None: @@ -434,6 +487,9 @@ def _move_camera_up(self) -> None: cams = self._multi_camera_settings.cameras cams[row], cams[row - 1] = cams[row - 1], cams[row] + # Refresh labels since DLC camera may have changed + self._refresh_camera_labels() + def _move_camera_down(self) -> None: """Move selected camera down in the list.""" row = self.active_cameras_list.currentRow() @@ -448,6 +504,9 @@ def _move_camera_down(self) -> None: cams = self._multi_camera_settings.cameras cams[row], cams[row + 1] = cams[row + 1], cams[row] + # Refresh labels since DLC camera may have changed + self._refresh_camera_labels() + def _apply_camera_settings(self) -> None: """Apply current form settings to the selected camera.""" if self._current_edit_index is None: @@ -470,15 +529,22 @@ def _apply_camera_settings(self) -> None: # Update list item item = self.active_cameras_list.item(row) - item.setText(self._format_camera_label(cam)) + item.setText(self._format_camera_label(cam, row)) item.setData(Qt.ItemDataRole.UserRole, cam) if not cam.enabled: item.setForeground(Qt.GlobalColor.gray) else: item.setForeground(Qt.GlobalColor.black) + # Refresh all labels in case enabled state changed (affects DLC indicator) + self._refresh_camera_labels() self._update_button_states() + # Restart preview to apply new settings (exposure, gain, fps, etc.) + if self._preview_active: + self._stop_preview() + self._start_preview() + def _update_button_states(self) -> None: """Update button enabled states.""" active_row = self.active_cameras_list.currentRow() @@ -489,12 +555,16 @@ def _update_button_states(self) -> None: self.move_down_btn.setEnabled( has_active_selection and active_row < self.active_cameras_list.count() - 1 ) + self.preview_btn.setEnabled(has_active_selection) available_row = self.available_cameras_list.currentRow() self.add_camera_btn.setEnabled(available_row >= 0) def _on_ok_clicked(self) -> None: """Handle OK button click.""" + # Stop preview if running + self._stop_preview() + # Validate that we have at least one enabled camera if any cameras are configured if self._multi_camera_settings.cameras: active = self._multi_camera_settings.get_active_cameras() @@ -509,6 +579,127 @@ def _on_ok_clicked(self) -> None: self.settings_changed.emit(self._multi_camera_settings) self.accept() + def reject(self) -> None: + """Handle dialog rejection (Cancel or close).""" + self._stop_preview() + super().reject() + + def _toggle_preview(self) -> None: + """Toggle camera preview on/off.""" + if self._preview_active: + self._stop_preview() + else: + self._start_preview() + + def _start_preview(self) -> None: + """Start camera preview for the currently selected camera.""" + if self._current_edit_index is None or self._current_edit_index < 0: + return + + item = self.active_cameras_list.item(self._current_edit_index) + if not item: + return + + cam = item.data(Qt.ItemDataRole.UserRole) + if not cam: + return + + try: + self._preview_backend = CameraFactory.create(cam) + self._preview_backend.open() + except Exception as exc: + LOGGER.error(f"Failed to start preview: {exc}") + QMessageBox.warning(self, "Preview Error", f"Failed to start camera preview:\n{exc}") + self._preview_backend = None + return + + self._preview_active = True + self.preview_btn.setText("Stop Preview") + self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop)) + self.preview_group.setVisible(True) + self.preview_label.setText("Starting...") + + # Start timer to update preview + self._preview_timer = QTimer(self) + self._preview_timer.timeout.connect(self._update_preview) + self._preview_timer.start(33) # ~30 fps + + def _stop_preview(self) -> None: + """Stop camera preview.""" + if self._preview_timer: + self._preview_timer.stop() + self._preview_timer = None + + if self._preview_backend: + try: + self._preview_backend.close() + except Exception: + pass + self._preview_backend = None + + self._preview_active = False + self.preview_btn.setText("Start Preview") + self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) + self.preview_group.setVisible(False) + self.preview_label.setText("No preview") + self.preview_label.setPixmap(QPixmap()) + + def _update_preview(self) -> None: + """Update preview frame.""" + if not self._preview_backend or not self._preview_active: + return + + try: + frame, _ = self._preview_backend.read() + if frame is None or frame.size == 0: + return + + # Apply rotation if set in the form (real-time from UI) + rotation = self.cam_rotation.currentData() + if rotation == 90: + frame = cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) + elif rotation == 180: + frame = cv2.rotate(frame, cv2.ROTATE_180) + elif rotation == 270: + frame = cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE) + + # Apply crop if set in the form (real-time from UI) + h, w = frame.shape[:2] + x0 = self.cam_crop_x0.value() + y0 = self.cam_crop_y0.value() + x1 = self.cam_crop_x1.value() or w + y1 = self.cam_crop_y1.value() or h + # Clamp to frame bounds + x0 = max(0, min(x0, w)) + y0 = max(0, min(y0, h)) + x1 = max(x0, min(x1, w)) + y1 = max(y0, min(y1, h)) + if x1 > x0 and y1 > y0: + frame = frame[y0:y1, x0:x1] + + # Resize to fit preview label + h, w = frame.shape[:2] + max_w, max_h = 400, 300 + scale = min(max_w / w, max_h / h) + new_w, new_h = int(w * scale), int(h * scale) + frame = cv2.resize(frame, (new_w, new_h)) + + # Convert to QImage and display + if frame.ndim == 2: + frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2RGB) + elif frame.shape[2] == 4: + frame = cv2.cvtColor(frame, cv2.COLOR_BGRA2RGB) + else: + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + + h, w, ch = frame.shape + bytes_per_line = ch * w + q_img = QImage(frame.data, w, h, bytes_per_line, QImage.Format.Format_RGB888) + self.preview_label.setPixmap(QPixmap.fromImage(q_img)) + + except Exception as exc: + LOGGER.warning(f"Preview frame error: {exc}") + def get_settings(self) -> MultiCameraSettings: """Get the current multi-camera settings.""" return self._multi_camera_settings diff --git a/dlclivegui/cameras/factory.py b/dlclivegui/cameras/factory.py index fcdf679..3a98d7c 100644 --- a/dlclivegui/cameras/factory.py +++ b/dlclivegui/cameras/factory.py @@ -3,13 +3,30 @@ from __future__ import annotations import importlib +from contextlib import contextmanager from dataclasses import dataclass -from typing import Dict, Iterable, List, Tuple, Type +from typing import Dict, Generator, Iterable, List, Tuple, Type from ..config import CameraSettings from .base import CameraBackend +@contextmanager +def _suppress_opencv_logging() -> Generator[None, None, None]: + """Temporarily suppress OpenCV logging during camera probing.""" + try: + import cv2 + + old_level = cv2.getLogLevel() + cv2.setLogLevel(0) # LOG_LEVEL_SILENT + try: + yield + finally: + cv2.setLogLevel(old_level) + except ImportError: + yield + + @dataclass class DetectedCamera: """Information about a camera discovered during probing.""" @@ -85,29 +102,31 @@ def detect_cameras(backend: str, max_devices: int = 10) -> List[DetectedCamera]: pass detected: List[DetectedCamera] = [] - for index in range(num_devices): - settings = CameraSettings( - name=f"Probe {index}", - index=index, - fps=30.0, - backend=backend, - properties={}, - ) - backend_instance = backend_cls(settings) - try: - backend_instance.open() - except Exception: - continue - else: - label = backend_instance.device_name() - if not label: - label = f"{backend.title()} #{index}" - detected.append(DetectedCamera(index=index, label=label)) - finally: + # Suppress OpenCV warnings/errors during probing (e.g., "can't open camera by index") + with _suppress_opencv_logging(): + for index in range(num_devices): + settings = CameraSettings( + name=f"Probe {index}", + index=index, + fps=30.0, + backend=backend, + properties={}, + ) + backend_instance = backend_cls(settings) try: - backend_instance.close() + backend_instance.open() except Exception: - pass + continue + else: + label = backend_instance.device_name() + if not label: + label = f"{backend.title()} #{index}" + detected.append(DetectedCamera(index=index, label=label)) + finally: + try: + backend_instance.close() + except Exception: + pass detected.sort(key=lambda camera: camera.index) return detected From 51fed8abb77a7a22efe7ff10acb8c5640057503e Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Tue, 23 Dec 2025 12:58:29 +0100 Subject: [PATCH 39/69] support multi-animal poses with distinct markers --- dlclivegui/gui.py | 88 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 23410d2..c1684fe 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -1538,21 +1538,93 @@ def _draw_bbox(self, frame: np.ndarray) -> np.ndarray: return overlay def _draw_pose(self, frame: np.ndarray, pose: np.ndarray) -> np.ndarray: + """Draw pose predictions on frame using colormap. + + Supports both single-animal poses (shape: num_keypoints x 3) and + multi-animal poses (shape: num_animals x num_keypoints x 3). + """ overlay = frame.copy() + pose_arr = np.asarray(pose) # Get tile offset and scale for multi-camera mode offset_x, offset_y = self._dlc_tile_offset scale_x, scale_y = self._dlc_tile_scale - # Get the colormap from config - cmap = plt.get_cmap(self._colormap) - num_keypoints = len(np.asarray(pose)) - # Calculate scaled radius for the keypoint circles base_radius = 4 scaled_radius = max(2, int(base_radius * min(scale_x, scale_y))) - for idx, keypoint in enumerate(np.asarray(pose)): + # Get colormap from config + cmap = plt.get_cmap(self._colormap) + + # Detect multi-animal pose: shape (num_animals, num_keypoints, 3) + # vs single-animal pose: shape (num_keypoints, 3) + if pose_arr.ndim == 3: + # Multi-animal pose - use different markers per animal + num_animals = pose_arr.shape[0] + num_keypoints = pose_arr.shape[1] + # Cycle through different marker types for each animal + marker_types = [ + cv2.MARKER_CROSS, + cv2.MARKER_TILTED_CROSS, + cv2.MARKER_STAR, + cv2.MARKER_DIAMOND, + cv2.MARKER_SQUARE, + cv2.MARKER_TRIANGLE_UP, + cv2.MARKER_TRIANGLE_DOWN, + ] + for animal_idx in range(num_animals): + marker = marker_types[animal_idx % len(marker_types)] + animal_pose = pose_arr[animal_idx] + self._draw_keypoints( + overlay, + animal_pose, + num_keypoints, + cmap, + offset_x, + offset_y, + scale_x, + scale_y, + scaled_radius, + marker=marker, + ) + else: + # Single-animal pose - use circles (marker=None) + num_keypoints = len(pose_arr) + self._draw_keypoints( + overlay, + pose_arr, + num_keypoints, + cmap, + offset_x, + offset_y, + scale_x, + scale_y, + scaled_radius, + marker=None, + ) + + return overlay + + def _draw_keypoints( + self, + overlay: np.ndarray, + keypoints: np.ndarray, + num_keypoints: int, + cmap, + offset_x: int, + offset_y: int, + scale_x: float, + scale_y: float, + radius: int, + marker: int | None = None, + ) -> None: + """Draw keypoints for a single animal on the overlay. + + Args: + marker: OpenCV marker type (e.g., cv2.MARKER_CROSS). If None, draws circles. + """ + for idx, keypoint in enumerate(keypoints): if len(keypoint) < 2: continue x, y = keypoint[:2] @@ -1572,8 +1644,10 @@ def _draw_pose(self, frame: np.ndarray, pose: np.ndarray) -> np.ndarray: # Convert from RGBA [0, 1] to BGR [0, 255] for OpenCV bgr_color = (int(rgba[2] * 255), int(rgba[1] * 255), int(rgba[0] * 255)) - cv2.circle(overlay, (x_scaled, y_scaled), scaled_radius, bgr_color, -1) - return overlay + if marker is None: + cv2.circle(overlay, (x_scaled, y_scaled), radius, bgr_color, -1) + else: + cv2.drawMarker(overlay, (x_scaled, y_scaled), bgr_color, marker, radius * 2, 2) def _on_dlc_initialised(self, success: bool) -> None: if success: From b083a35214cf0468826a7c5aad36840f9a1e97d9 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 23 Jan 2026 10:18:03 +0100 Subject: [PATCH 40/69] Update .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index d2ee717..e5d2f1f 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,5 @@ venv.bak/ .vscode !dlclivegui/config.py +# uv package files +uv.lock From 88c883e979c4cb2498128ae82dff9987a14e4981 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 23 Jan 2026 10:18:23 +0100 Subject: [PATCH 41/69] Update opencv_backend.py --- dlclivegui/cameras/opencv_backend.py | 83 ++++++++++++---------------- 1 file changed, 36 insertions(+), 47 deletions(-) diff --git a/dlclivegui/cameras/opencv_backend.py b/dlclivegui/cameras/opencv_backend.py index 74d50fc..7110579 100644 --- a/dlclivegui/cameras/opencv_backend.py +++ b/dlclivegui/cameras/opencv_backend.py @@ -1,26 +1,22 @@ -"""OpenCV based camera backend.""" -from __future__ import annotations +"""OpenCV based camera backend with MJPG enforcement for WSL2.""" +from __future__ import annotations import logging import time from typing import Tuple - import cv2 import numpy as np - from .base import CameraBackend LOG = logging.getLogger(__name__) - class OpenCVCameraBackend(CameraBackend): - """Fallback backend using :mod:`cv2.VideoCapture`.""" + """Fallback backend using :mod:`cv2.VideoCapture` with MJPG optimization.""" def __init__(self, settings): super().__init__(settings) self._capture: cv2.VideoCapture | None = None - # Parse resolution with defaults (720x540) self._resolution: Tuple[int, int] = self._parse_resolution( settings.properties.get("resolution") ) @@ -36,16 +32,11 @@ def read(self) -> Tuple[np.ndarray, float]: if self._capture is None: raise RuntimeError("Camera has not been opened") - # Try grab first - this is non-blocking and helps detect connection issues faster - grabbed = self._capture.grab() - if not grabbed: - # Check if camera is still opened - if not, it's a serious error + if not self._capture.grab(): if not self._capture.isOpened(): raise RuntimeError("OpenCV camera connection lost") - # Otherwise treat as temporary frame read failure (timeout-like) raise TimeoutError("Failed to grab frame from OpenCV camera (temporary)") - # Now retrieve the frame success, frame = self._capture.retrieve() if not success or frame is None: raise TimeoutError("Failed to retrieve frame from OpenCV camera (temporary)") @@ -53,19 +44,17 @@ def read(self) -> Tuple[np.ndarray, float]: return frame, time.time() def close(self) -> None: - if self._capture is not None: + if self._capture: try: - # Try to release properly self._capture.release() except Exception: pass finally: self._capture = None - # Give the system a moment to fully release the device time.sleep(0.1) def stop(self) -> None: - if self._capture is not None: + if self._capture: try: self._capture.release() except Exception: @@ -75,62 +64,70 @@ def stop(self) -> None: def device_name(self) -> str: base_name = "OpenCV" - if self._capture is not None and hasattr(self._capture, "getBackendName"): + if self._capture and hasattr(self._capture, "getBackendName"): try: backend_name = self._capture.getBackendName() - except Exception: # pragma: no cover - backend specific + except Exception: backend_name = "" if backend_name: base_name = backend_name return f"{base_name} camera #{self.settings.index}" def _parse_resolution(self, resolution) -> Tuple[int, int]: - """Parse resolution setting. - - Args: - resolution: Can be a tuple/list [width, height], or None - - Returns: - Tuple of (width, height), defaults to (720, 540) - """ if resolution is None: - return (720, 540) # Default resolution - + return (720, 540) if isinstance(resolution, (list, tuple)) and len(resolution) == 2: try: return (int(resolution[0]), int(resolution[1])) except (ValueError, TypeError): + LOG.debug(f"Invalid resolution values: {resolution}, defaulting to 720x540") return (720, 540) - return (720, 540) def _configure_capture(self) -> None: - if self._capture is None: + if not self._capture: return - # Set resolution (width x height) + # Use MJPG if available + if not self._capture.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG')): + LOG.warning("Failed to set MJPG format, falling back to default") + + # Log actual codec + fourcc = int(self._capture.get(cv2.CAP_PROP_FOURCC)) + codec = "".join([chr((fourcc >> 8 * i) & 0xFF) for i in range(4)]) + LOG.info(f"Camera using codec: {codec}") + + # Set resolution width, height = self._resolution if not self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, float(width)): LOG.warning(f"Failed to set frame width to {width}") if not self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, float(height)): LOG.warning(f"Failed to set frame height to {height}") - # Verify resolution was set correctly actual_width = int(self._capture.get(cv2.CAP_PROP_FRAME_WIDTH)) actual_height = int(self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT)) if actual_width != width or actual_height != height: - LOG.warning( - f"Resolution mismatch: requested {width}x{height}, " - f"got {actual_width}x{actual_height}" - ) + LOG.warning(f"Resolution mismatch: requested {width}x{height}, got {actual_width}x{actual_height}") + try: + self._resolution = (actual_width, actual_height) + except Exception: + LOG.warning("Failed to update internal resolution state") + LOG.info(f"Camera configured with resolution: {self._resolution[0]}x{self._resolution[1]}") - # Set FPS if specified + # Set FPS requested_fps = self.settings.fps if requested_fps: if not self._capture.set(cv2.CAP_PROP_FPS, float(requested_fps)): LOG.warning(f"Failed to set FPS to {requested_fps}") - # Set any additional properties from the properties dict + actual_fps = self._capture.get(cv2.CAP_PROP_FPS) + if actual_fps: + if requested_fps and abs(actual_fps - requested_fps) > 0.1: + LOG.warning(f"FPS mismatch: requested {requested_fps:.2f}, got {actual_fps:.2f}") + self.settings.fps = float(actual_fps) + LOG.info(f"Camera configured with FPS: {actual_fps:.2f}") + + # Apply extra properties for prop, value in self.settings.properties.items(): if prop in ("api", "resolution"): continue @@ -142,14 +139,6 @@ def _configure_capture(self) -> None: if not self._capture.set(prop_id, float(value)): LOG.warning(f"Failed to set property {prop_id} to {value}") - # Update actual FPS from camera and warn if different from requested - actual_fps = self._capture.get(cv2.CAP_PROP_FPS) - if actual_fps: - if requested_fps and abs(actual_fps - requested_fps) > 0.1: - LOG.warning(f"FPS mismatch: requested {requested_fps:.2f}, got {actual_fps:.2f}") - self.settings.fps = float(actual_fps) - LOG.info(f"Camera configured with FPS: {actual_fps:.2f}") - def _resolve_backend(self, backend: str | None) -> int: if backend is None: return cv2.CAP_ANY From 351e576f8a0084528f8e6052d7d1ace90d814bed Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 28 Jan 2026 11:40:43 +0100 Subject: [PATCH 42/69] Switch to Ruff for linting and formatting Replaces isort and black with Ruff in pre-commit configuration and adds Ruff settings to pyproject.toml. Also comments out deeplabcut-live dependency. This unifies linting and formatting under Ruff for improved workflow. --- .pre-commit-config.yaml | 18 +++++++----------- pyproject.toml | 12 +++++++++++- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0178c8e..ceaed2d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,15 +9,11 @@ repos: args: [--pytest-test-first] - id: trailing-whitespace - id: check-merge-conflict - - - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.10 hooks: - - id: isort - args: ["--profile", "black", "--line-length", "100", "--atomic"] - - - repo: https://github.com/psf/black - rev: 24.4.2 - hooks: - - id: black - args: ["--line-length=100"] + # Run the formatter. + - id: ruff-format + # Run the linter. + - id: ruff-check + args: [--fix,--unsafe-fixes] diff --git a/pyproject.toml b/pyproject.toml index b989b57..bb931d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ ] dependencies = [ - "deeplabcut-live", # might be missing timm and scipy + # "deeplabcut-live", # might be missing timm and scipy "PySide6", "numpy", "opencv-python", @@ -110,3 +110,13 @@ exclude_lines = [ "if TYPE_CHECKING:", "@abstract", ] + +[tool.ruff] +lint.select = ["E", "F", "B", "I", "UP"] +lint.ignore = ["E741"] +target-version = "py310" +fix = true +line-length = 120 + +[tool.ruff.lint.pydocstyle] +convention = "google" From 1f139ab35b4879eef98ae8e0fb0b5f8938724e8a Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 28 Jan 2026 11:41:01 +0100 Subject: [PATCH 43/69] Upgrade camera detection speed and UI Refactor camera configuration dialog to use background threads for camera detection and preview loading, improving UI responsiveness. Update OpenCV backend for robust, fast startup and Windows-optimized camera handling. Enhance camera factory to support cancellation and progress reporting during device discovery. --- dlclivegui/camera_config_dialog.py | 589 ++++++++++++++++++++------- dlclivegui/cameras/factory.py | 116 +++--- dlclivegui/cameras/opencv_backend.py | 320 +++++++++++---- dlclivegui/gui.py | 143 +++---- 4 files changed, 819 insertions(+), 349 deletions(-) diff --git a/dlclivegui/camera_config_dialog.py b/dlclivegui/camera_config_dialog.py index 978419e..0c85abf 100644 --- a/dlclivegui/camera_config_dialog.py +++ b/dlclivegui/camera_config_dialog.py @@ -1,14 +1,13 @@ -"""Camera configuration dialog for multi-camera setup.""" +"""Camera configuration dialog for multi-camera setup (with async preview loading).""" from __future__ import annotations +import copy # NEW import logging -from typing import List, Optional import cv2 -import numpy as np -from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtGui import QImage, QPixmap +from PySide6.QtCore import QElapsedTimer, Qt, QThread, QTimer, Signal +from PySide6.QtGui import QFont, QImage, QPixmap, QTextCursor from PySide6.QtWidgets import ( QCheckBox, QComboBox, @@ -21,9 +20,11 @@ QListWidget, QListWidgetItem, QMessageBox, + QProgressBar, QPushButton, QSpinBox, QStyle, + QTextEdit, # NEW: lightweight status console in the preview panel QVBoxLayout, QWidget, ) @@ -36,34 +37,168 @@ LOGGER = logging.getLogger(__name__) +# ------------------------------- +# Background worker to detect cameras +# ------------------------------- +class DetectCamerasWorker(QThread): + """Background worker to detect cameras for the selected backend.""" + + progress = Signal(str) # human-readable text + result = Signal(list) # list[DetectedCamera] + error = Signal(str) + finished = Signal() + + def __init__(self, backend: str, max_devices: int = 10, parent: QWidget | None = None): + super().__init__(parent) + self.backend = backend + self.max_devices = max_devices + + def run(self): + try: + # Initial message + self.progress.emit(f"Scanning {self.backend} cameras…") + + cams = CameraFactory.detect_cameras( + self.backend, + max_devices=self.max_devices, + should_cancel=self.isInterruptionRequested, + progress_cb=self.progress.emit, + ) + self.result.emit(cams) + except Exception as exc: + self.error.emit(f"{type(exc).__name__}: {exc}") + finally: + self.finished.emit() + + +# ------------------------------- +# Singleton camera preview loader worker +# ------------------------------- +class CameraLoadWorker(QThread): + """Open/configure a camera backend off the UI thread with progress and cancel support.""" + + progress = Signal(str) # Human-readable status updates + success = Signal(object) # Emits the ready backend (CameraBackend) + error = Signal(str) # Emits error message + canceled = Signal() # Emits when canceled before success + + def __init__(self, cam: CameraSettings, parent: QWidget | None = None): + super().__init__(parent) + # Work on a defensive copy so we never mutate the original settings + self._cam = copy.deepcopy(cam) + # Make first-time opening snappier by allowing backend fast-path if supported + if isinstance(self._cam.properties, dict): + self._cam.properties.setdefault("fast_start", True) + self._cancel = False + self._backend: CameraBackend | None = None + + def request_cancel(self): + self._cancel = True + + def _check_cancel(self) -> bool: + if self._cancel: + self.progress.emit("Canceled by user.") + return True + return False + + def run(self): + try: + self.progress.emit("Creating backend…") + if self._check_cancel(): + self.canceled.emit() + return + + LOGGER.debug("Creating camera backend for %s:%d", self._cam.backend, self._cam.index) + self._backend = CameraFactory.create(self._cam) + + self.progress.emit("Opening device…") + if self._check_cancel(): + self.canceled.emit() + return + + self._backend.open() # heavy: backend chooses/negotiates API/format/res/FPS + + self.progress.emit("Warming up stream…") + if self._check_cancel(): + self._backend.close() + self.canceled.emit() + return + + # Warmup: allow driver pipeline to stabilize (skip None frames silently) + warm_ok = False + timer = QElapsedTimer() + timer.start() + budget = 50000 # ms + while timer.elapsed() < budget and not self._cancel: + frame, _ = self._backend.read() + if frame is not None and frame.size > 0: + warm_ok = True + break + + if self._cancel: + self._backend.close() + self.canceled.emit() + return + + if not warm_ok: + # Not fatal—some cameras deliver the first frame only after UI starts polling. + self.progress.emit("Warmup yielded no frame, proceeding…") + + self.progress.emit("Camera ready.") + self.success.emit(self._backend) + # Ownership of _backend transfers to the receiver; do not close here. + + except Exception as exc: + msg = f"{type(exc).__name__}: {exc}" + try: + if self._backend: + self._backend.close() + except Exception: + pass + self.error.emit(msg) + + class CameraConfigDialog(QDialog): - """Dialog for configuring multiple cameras.""" + """Dialog for configuring multiple cameras with async preview loading.""" MAX_CAMERAS = 4 settings_changed = Signal(object) # MultiCameraSettings + # Camera discovery signals + scan_started = Signal(str) + scan_finished = Signal() def __init__( self, - parent: Optional[QWidget] = None, - multi_camera_settings: Optional[MultiCameraSettings] = None, + parent: QWidget | None = None, + multi_camera_settings: MultiCameraSettings | None = None, ): super().__init__(parent) self.setWindowTitle("Configure Cameras") self.setMinimumSize(960, 720) - self._multi_camera_settings = ( - multi_camera_settings if multi_camera_settings else MultiCameraSettings() - ) - self._detected_cameras: List[DetectedCamera] = [] - self._current_edit_index: Optional[int] = None - self._preview_backend: Optional[CameraBackend] = None - self._preview_timer: Optional[QTimer] = None + self._multi_camera_settings = multi_camera_settings if multi_camera_settings else MultiCameraSettings() + self._detected_cameras: list[DetectedCamera] = [] + self._current_edit_index: int | None = None + + # Preview state + self._preview_backend: CameraBackend | None = None + self._preview_timer: QTimer | None = None self._preview_active: bool = False + # Camera detection worker + self._scan_worker: DetectCamerasWorker | None = None + + # Singleton loader per dialog + self._loader: CameraLoadWorker | None = None + self._loading_active: bool = False + self._setup_ui() self._populate_from_settings() self._connect_signals() + # ------------------------------- + # UI setup + # ------------------------------- def _setup_ui(self) -> None: # Main layout for the dialog main_layout = QVBoxLayout(self) @@ -86,9 +221,7 @@ def _setup_ui(self) -> None: # Buttons for managing active cameras list_buttons = QHBoxLayout() self.remove_camera_btn = QPushButton("Remove") - self.remove_camera_btn.setIcon( - self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon) - ) + self.remove_camera_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon)) self.remove_camera_btn.setEnabled(False) self.move_up_btn = QPushButton("↑") self.move_up_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowUp)) @@ -126,6 +259,30 @@ def _setup_ui(self) -> None: self.available_cameras_list = QListWidget() available_layout.addWidget(self.available_cameras_list) + # Show status overlay during scan + self._scan_overlay = QLabel(available_group) + self._scan_overlay.setVisible(False) + self._scan_overlay.setAlignment(Qt.AlignCenter) + self._scan_overlay.setWordWrap(True) + self._scan_overlay.setStyleSheet( + "background-color: rgba(0, 0, 0, 140);color: white;padding: 12px;border: 1px solid #333;font-size: 12px;" + ) + self._scan_overlay.setText("Discovering cameras…") + self.available_cameras_list.installEventFilter(self) + + # Indeterminate progress bar + status text for async scan + self.scan_progress = QProgressBar() + self.scan_progress.setRange(0, 0) + self.scan_progress.setVisible(False) + + available_layout.addWidget(self.scan_progress) + + self.scan_cancel_btn = QPushButton("Cancel Scan") + self.scan_cancel_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserStop)) + self.scan_cancel_btn.setVisible(False) + self.scan_cancel_btn.clicked.connect(self._on_scan_cancel) + available_layout.addWidget(self.scan_cancel_btn) + self.add_camera_btn = QPushButton("Add Selected Camera →") self.add_camera_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowRight)) self.add_camera_btn.setEnabled(False) @@ -214,9 +371,7 @@ def _setup_ui(self) -> None: self.settings_form.addRow("Crop (x0,y0,x1,y1):", crop_widget) self.apply_settings_btn = QPushButton("Apply Settings") - self.apply_settings_btn.setIcon( - self.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton) - ) + self.apply_settings_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton)) self.apply_settings_btn.setEnabled(False) self.settings_form.addRow(self.apply_settings_btn) @@ -231,12 +386,35 @@ def _setup_ui(self) -> None: # Preview widget self.preview_group = QGroupBox("Camera Preview") preview_layout = QVBoxLayout(self.preview_group) + self.preview_label = QLabel("No preview") self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.preview_label.setMinimumSize(320, 240) self.preview_label.setMaximumSize(400, 300) self.preview_label.setStyleSheet("background-color: #1a1a1a; color: #888;") preview_layout.addWidget(self.preview_label) + self.preview_label.installEventFilter(self) # For resize events + + # NEW: small, read-only status console for loader messages + self.preview_status = QTextEdit() + self.preview_status.setReadOnly(True) + self.preview_status.setFixedHeight(45) + self.preview_status.setStyleSheet( + "QTextEdit { background: #141414; color: #bdbdbd; border: 1px solid #2a2a2a; }" + ) + font = QFont("Consolas") + font.setPointSize(9) + self.preview_status.setFont(font) + preview_layout.addWidget(self.preview_status) + + # NEW: overlay label for loading glass pane + self._loading_overlay = QLabel(self.preview_group) + self._loading_overlay.setVisible(False) + self._loading_overlay.setAlignment(Qt.AlignCenter) + self._loading_overlay.setStyleSheet("background-color: rgba(0,0,0,140); color: white; border: 1px solid #333;") + self._loading_overlay.setText("Loading camera…") + # We size/position it on show & on resize + self.preview_group.setVisible(False) right_layout.addWidget(self.preview_group) @@ -247,9 +425,7 @@ def _setup_ui(self) -> None: self.ok_btn = QPushButton("OK") self.ok_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogOkButton)) self.cancel_btn = QPushButton("Cancel") - self.cancel_btn.setIcon( - self.style().standardIcon(QStyle.StandardPixmap.SP_DialogCancelButton) - ) + self.cancel_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogCancelButton)) button_layout.addStretch(1) button_layout.addWidget(self.ok_btn) button_layout.addWidget(self.cancel_btn) @@ -262,6 +438,48 @@ def _setup_ui(self) -> None: main_layout.addLayout(panels_layout) main_layout.addLayout(button_layout) + # Maintain overlay geometry when resizing + def resizeEvent(self, event): # NEW + super().resizeEvent(event) + if self._loading_overlay and self._loading_overlay.isVisible(): + self._position_loading_overlay() + if hasattr(self, "_loading_overlay") and self._loading_overlay.isVisible(): + self._position_loading_overlay() + + def eventFilter(self, obj, event): + if obj is self.available_cameras_list and event.type() == event.Type.Resize: + if self._scan_overlay and self._scan_overlay.isVisible(): + self._position_scan_overlay() + return super().eventFilter(obj, event) + + def _position_scan_overlay(self) -> None: + """Position scan overlay to cover the available_cameras_list area.""" + if not self._scan_overlay or not self.available_cameras_list: + return + parent = self._scan_overlay.parent() # available_group + top_left = self.available_cameras_list.mapTo(parent, self.available_cameras_list.rect().topLeft()) + rect = self.available_cameras_list.rect() + self._scan_overlay.setGeometry(top_left.x(), top_left.y(), rect.width(), rect.height()) + + def _show_scan_overlay(self, message: str = "Discovering cameras…") -> None: + self._scan_overlay.setText(message) + self._scan_overlay.setVisible(True) + self._position_scan_overlay() + + def _hide_scan_overlay(self) -> None: + self._scan_overlay.setVisible(False) + + def _position_loading_overlay(self): # NEW + # Cover just the preview image area (label), not the whole group + if not self.preview_label: + return + gp = self.preview_label.mapTo(self.preview_group, self.preview_label.rect().topLeft()) + rect = self.preview_label.rect() + self._loading_overlay.setGeometry(gp.x(), gp.y(), rect.width(), rect.height()) + + # ------------------------------- + # Signals / population + # ------------------------------- def _connect_signals(self) -> None: self.backend_combo.currentIndexChanged.connect(self._on_backend_changed) self.refresh_btn.clicked.connect(self._refresh_available_cameras) @@ -271,9 +489,7 @@ def _connect_signals(self) -> None: self.move_down_btn.clicked.connect(self._move_camera_down) self.active_cameras_list.currentRowChanged.connect(self._on_active_camera_selected) self.available_cameras_list.currentRowChanged.connect(self._on_available_camera_selected) - self.available_cameras_list.itemDoubleClicked.connect( - self._on_available_camera_double_clicked - ) + self.available_cameras_list.itemDoubleClicked.connect(self._on_available_camera_double_clicked) self.apply_settings_btn.clicked.connect(self._apply_camera_settings) self.preview_btn.clicked.connect(self._toggle_preview) self.ok_btn.clicked.connect(self._on_ok_clicked) @@ -293,21 +509,11 @@ def _populate_from_settings(self) -> None: self._update_button_states() def _format_camera_label(self, cam: CameraSettings, index: int = -1) -> str: - """Format camera label for display. - - Parameters - ---------- - cam : CameraSettings - The camera settings. - index : int - The index of the camera in the list. If 0 and enabled, shows DLC indicator. - """ status = "✓" if cam.enabled else "○" dlc_indicator = " [DLC]" if index == 0 and cam.enabled else "" return f"{status} {cam.name} [{cam.backend}:{cam.index}]{dlc_indicator}" def _refresh_camera_labels(self) -> None: - """Refresh all camera labels in the active list (e.g., after reorder).""" for i in range(self.active_cameras_list.count()): item = self.active_cameras_list.item(i) cam = item.data(Qt.ItemDataRole.UserRole) @@ -318,48 +524,96 @@ def _on_backend_changed(self, _index: int) -> None: self._refresh_available_cameras() def _refresh_available_cameras(self) -> None: - """Refresh the list of available cameras.""" + """Refresh the list of available cameras asynchronously.""" backend = self.backend_combo.currentData() if not backend: backend = self.backend_combo.currentText().split()[0] - self.available_cameras_list.clear() - self._detected_cameras = CameraFactory.detect_cameras(backend, max_devices=10) + # If already scanning, ignore new requests to avoid races + if getattr(self, "_scan_worker", None) and self._scan_worker.isRunning(): + self._show_scan_overlay("Already discovering cameras…") + return + # Reset list UI and show progress + self.available_cameras_list.clear() + self._detected_cameras = [] + msg = f"Discovering {backend} cameras…" + self._show_scan_overlay(msg) + self.scan_progress.setRange(0, 0) + self.scan_progress.setVisible(True) + self.scan_cancel_btn.setVisible(True) + self.add_camera_btn.setEnabled(False) + self.refresh_btn.setEnabled(False) + self.backend_combo.setEnabled(False) + + # Start worker + self._scan_worker = DetectCamerasWorker(backend, max_devices=10, parent=self) + self._scan_worker.progress.connect(self._on_scan_progress) + self._scan_worker.result.connect(self._on_scan_result) + self._scan_worker.error.connect(self._on_scan_error) + self._scan_worker.finished.connect(self._on_scan_finished) + self.scan_started.emit(f"Scanning {backend} cameras…") + self._scan_worker.start() + + def _on_scan_progress(self, msg: str) -> None: + self._show_scan_overlay(msg or "Discovering cameras…") + + def _on_scan_result(self, cams: list) -> None: + self._detected_cameras = cams or [] for cam in self._detected_cameras: item = QListWidgetItem(f"{cam.label} (index {cam.index})") item.setData(Qt.ItemDataRole.UserRole, cam) self.available_cameras_list.addItem(item) + def _on_scan_error(self, msg: str) -> None: + QMessageBox.warning(self, "Camera Scan", f"Failed to detect cameras:\n{msg}") + + def _on_scan_finished(self) -> None: + self._hide_scan_overlay() + self.scan_progress.setVisible(False) + self._scan_worker = None + + self.scan_cancel_btn.setVisible(False) + self.scan_cancel_btn.setEnabled(True) + self.refresh_btn.setEnabled(True) + self.backend_combo.setEnabled(True) + self._update_button_states() + self.scan_finished.emit() + + def _on_scan_cancel(self) -> None: + """User requested to cancel discovery.""" + if self._scan_worker and self._scan_worker.isRunning(): + try: + self._scan_worker.requestInterruption() + except Exception: + pass + # Keep the busy bar, update texts + self._show_scan_overlay("Canceling discovery…") + self.scan_progress.setVisible(True) # stay visible as indeterminate + self.scan_cancel_btn.setEnabled(False) def _on_available_camera_selected(self, row: int) -> None: self.add_camera_btn.setEnabled(row >= 0) def _on_available_camera_double_clicked(self, item: QListWidgetItem) -> None: - """Handle double-click on an available camera to add it.""" self._add_selected_camera() def _on_active_camera_selected(self, row: int) -> None: - """Handle selection of an active camera.""" # Stop any running preview when selection changes if self._preview_active: self._stop_preview() - self._current_edit_index = row self._update_button_states() - if row < 0 or row >= self.active_cameras_list.count(): self._clear_settings_form() return - item = self.active_cameras_list.item(row) cam = item.data(Qt.ItemDataRole.UserRole) if cam: self._load_camera_to_form(cam) def _load_camera_to_form(self, cam: CameraSettings) -> None: - """Load camera settings into the form.""" self.cam_enabled_checkbox.setChecked(cam.enabled) self.cam_name_label.setText(cam.name) self.cam_index_label.setText(str(cam.index)) @@ -367,21 +621,16 @@ def _load_camera_to_form(self, cam: CameraSettings) -> None: self.cam_fps.setValue(cam.fps) self.cam_exposure.setValue(cam.exposure) self.cam_gain.setValue(cam.gain) - - # Set rotation rot_index = self.cam_rotation.findData(cam.rotation) if rot_index >= 0: self.cam_rotation.setCurrentIndex(rot_index) - self.cam_crop_x0.setValue(cam.crop_x0) self.cam_crop_y0.setValue(cam.crop_y0) self.cam_crop_x1.setValue(cam.crop_x1) self.cam_crop_y1.setValue(cam.crop_y1) - self.apply_settings_btn.setEnabled(True) def _clear_settings_form(self) -> None: - """Clear the settings form.""" self.cam_enabled_checkbox.setChecked(True) self.cam_name_label.setText("") self.cam_index_label.setText("") @@ -397,12 +646,10 @@ def _clear_settings_form(self) -> None: self.apply_settings_btn.setEnabled(False) def _add_selected_camera(self) -> None: - """Add the selected available camera to active cameras.""" row = self.available_cameras_list.currentRow() if row < 0: return - - # Check limit + # limit check active_count = len( [ i @@ -411,29 +658,20 @@ def _add_selected_camera(self) -> None: ] ) if active_count >= self.MAX_CAMERAS: - QMessageBox.warning( - self, - "Maximum Cameras", - f"Maximum of {self.MAX_CAMERAS} active cameras allowed.", - ) + QMessageBox.warning(self, "Maximum Cameras", f"Maximum of {self.MAX_CAMERAS} active cameras allowed.") return - item = self.available_cameras_list.item(row) detected = item.data(Qt.ItemDataRole.UserRole) backend = self.backend_combo.currentData() or "opencv" - # Check if this camera (same backend + index) is already added for i in range(self.active_cameras_list.count()): existing_cam = self.active_cameras_list.item(i).data(Qt.ItemDataRole.UserRole) if existing_cam.backend == backend and existing_cam.index == detected.index: QMessageBox.warning( - self, - "Duplicate Camera", - f"Camera '{backend}:{detected.index}' is already in the active list.", + self, "Duplicate Camera", f"Camera '{backend}:{detected.index}' is already in the active list." ) return - # Create new camera settings new_cam = CameraSettings( name=detected.label, index=detected.index, @@ -443,79 +681,55 @@ def _add_selected_camera(self) -> None: gain=0.0, enabled=True, ) - self._multi_camera_settings.cameras.append(new_cam) - - # Add to list new_index = len(self._multi_camera_settings.cameras) - 1 new_item = QListWidgetItem(self._format_camera_label(new_cam, new_index)) new_item.setData(Qt.ItemDataRole.UserRole, new_cam) self.active_cameras_list.addItem(new_item) self.active_cameras_list.setCurrentItem(new_item) - - # Refresh labels in case this is the first camera (gets DLC indicator) self._refresh_camera_labels() self._update_button_states() def _remove_selected_camera(self) -> None: - """Remove the selected camera from active cameras.""" row = self.active_cameras_list.currentRow() if row < 0: return - self.active_cameras_list.takeItem(row) if row < len(self._multi_camera_settings.cameras): del self._multi_camera_settings.cameras[row] - self._current_edit_index = None self._clear_settings_form() - # Refresh labels since DLC camera may have changed self._refresh_camera_labels() self._update_button_states() def _move_camera_up(self) -> None: - """Move selected camera up in the list.""" row = self.active_cameras_list.currentRow() if row <= 0: return - item = self.active_cameras_list.takeItem(row) self.active_cameras_list.insertItem(row - 1, item) self.active_cameras_list.setCurrentRow(row - 1) - - # Update settings list cams = self._multi_camera_settings.cameras cams[row], cams[row - 1] = cams[row - 1], cams[row] - - # Refresh labels since DLC camera may have changed self._refresh_camera_labels() def _move_camera_down(self) -> None: - """Move selected camera down in the list.""" row = self.active_cameras_list.currentRow() if row < 0 or row >= self.active_cameras_list.count() - 1: return - item = self.active_cameras_list.takeItem(row) self.active_cameras_list.insertItem(row + 1, item) self.active_cameras_list.setCurrentRow(row + 1) - - # Update settings list cams = self._multi_camera_settings.cameras cams[row], cams[row + 1] = cams[row + 1], cams[row] - - # Refresh labels since DLC camera may have changed self._refresh_camera_labels() def _apply_camera_settings(self) -> None: - """Apply current form settings to the selected camera.""" if self._current_edit_index is None: return - row = self._current_edit_index if row < 0 or row >= len(self._multi_camera_settings.cameras): return - cam = self._multi_camera_settings.cameras[row] cam.enabled = self.cam_enabled_checkbox.isChecked() cam.fps = self.cam_fps.value() @@ -526,124 +740,215 @@ def _apply_camera_settings(self) -> None: cam.crop_y0 = self.cam_crop_y0.value() cam.crop_x1 = self.cam_crop_x1.value() cam.crop_y1 = self.cam_crop_y1.value() - - # Update list item item = self.active_cameras_list.item(row) item.setText(self._format_camera_label(cam, row)) item.setData(Qt.ItemDataRole.UserRole, cam) - if not cam.enabled: - item.setForeground(Qt.GlobalColor.gray) - else: - item.setForeground(Qt.GlobalColor.black) - - # Refresh all labels in case enabled state changed (affects DLC indicator) + item.setForeground(Qt.GlobalColor.gray if not cam.enabled else Qt.GlobalColor.black) self._refresh_camera_labels() self._update_button_states() - - # Restart preview to apply new settings (exposure, gain, fps, etc.) if self._preview_active: self._stop_preview() self._start_preview() def _update_button_states(self) -> None: - """Update button enabled states.""" active_row = self.active_cameras_list.currentRow() has_active_selection = active_row >= 0 - self.remove_camera_btn.setEnabled(has_active_selection) self.move_up_btn.setEnabled(has_active_selection and active_row > 0) - self.move_down_btn.setEnabled( - has_active_selection and active_row < self.active_cameras_list.count() - 1 - ) - self.preview_btn.setEnabled(has_active_selection) - + self.move_down_btn.setEnabled(has_active_selection and active_row < self.active_cameras_list.count() - 1) + # During loading, preview button becomes "Cancel Loading" + self.preview_btn.setEnabled(has_active_selection or self._loading_active) available_row = self.available_cameras_list.currentRow() self.add_camera_btn.setEnabled(available_row >= 0) def _on_ok_clicked(self) -> None: - """Handle OK button click.""" - # Stop preview if running self._stop_preview() - - # Validate that we have at least one enabled camera if any cameras are configured if self._multi_camera_settings.cameras: active = self._multi_camera_settings.get_active_cameras() if not active: QMessageBox.warning( - self, - "No Active Cameras", - "Please enable at least one camera or remove all cameras.", + self, "No Active Cameras", "Please enable at least one camera or remove all cameras." ) return - self.settings_changed.emit(self._multi_camera_settings) self.accept() def reject(self) -> None: """Handle dialog rejection (Cancel or close).""" self._stop_preview() + + if getattr(self, "_scan_worker", None) and self._scan_worker.isRunning(): + try: + self._scan_worker.requestInterruption() + except Exception: + pass + self._scan_worker.wait(1500) + self._scan_worker = None + + self._hide_scan_overlay() + self.scan_progress.setVisible(False) + self.scan_cancel_btn.setVisible(False) + self.scan_cancel_btn.setEnabled(True) + self.refresh_btn.setEnabled(True) + self.backend_combo.setEnabled(True) + super().reject() + # ------------------------------- + # Preview start/stop (ASYNC) + # ------------------------------- def _toggle_preview(self) -> None: - """Toggle camera preview on/off.""" + if self._loading_active: + self._cancel_loading() + return if self._preview_active: self._stop_preview() else: self._start_preview() def _start_preview(self) -> None: - """Start camera preview for the currently selected camera.""" + """Start camera preview asynchronously (no UI freeze).""" if self._current_edit_index is None or self._current_edit_index < 0: return - item = self.active_cameras_list.item(self._current_edit_index) if not item: return - cam = item.data(Qt.ItemDataRole.UserRole) if not cam: return - try: - self._preview_backend = CameraFactory.create(cam) - self._preview_backend.open() - except Exception as exc: - LOGGER.error(f"Failed to start preview: {exc}") - QMessageBox.warning(self, "Preview Error", f"Failed to start camera preview:\n{exc}") - self._preview_backend = None - return + # Ensure any existing preview or loader is stopped/canceled + self._stop_preview() + if self._loader and self._loader.isRunning(): + self._loader.request_cancel() - self._preview_active = True - self.preview_btn.setText("Stop Preview") - self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop)) + # Prepare UI self.preview_group.setVisible(True) - self.preview_label.setText("Starting...") - - # Start timer to update preview - self._preview_timer = QTimer(self) - self._preview_timer.timeout.connect(self._update_preview) - self._preview_timer.start(33) # ~30 fps + self.preview_label.setText("No preview") + self.preview_status.clear() + self._show_loading_overlay("Loading camera…") + self._set_preview_button_loading(True) + + # Create singleton worker + self._loader = CameraLoadWorker(cam, self) + self._loader.progress.connect(self._on_loader_progress) + self._loader.success.connect(self._on_loader_success) + self._loader.error.connect(self._on_loader_error) + self._loader.canceled.connect(self._on_loader_canceled) + self._loader.finished.connect(self._on_loader_finished) + self._loading_active = True + self._update_button_states() + self._loader.start() def _stop_preview(self) -> None: - """Stop camera preview.""" + """Stop camera preview and cancel any ongoing loading.""" + # Cancel loader if running + if self._loader and self._loader.isRunning(): + self._loader.request_cancel() + self._loader.wait(1500) + self._loader = None + # Stop timer if self._preview_timer: self._preview_timer.stop() self._preview_timer = None - + # Close backend if self._preview_backend: try: self._preview_backend.close() except Exception: pass self._preview_backend = None - - self._preview_active = False + # Reset UI + self._loading_active = False + self._set_preview_button_loading(False) self.preview_btn.setText("Start Preview") self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) self.preview_group.setVisible(False) self.preview_label.setText("No preview") self.preview_label.setPixmap(QPixmap()) + self._hide_loading_overlay() + self._update_button_states() + # ------------------------------- + # Loader UI helpers / slots + # ------------------------------- + def _set_preview_button_loading(self, loading: bool) -> None: + if loading: + self.preview_btn.setText("Cancel Loading") + self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserStop)) + else: + self.preview_btn.setText("Start Preview") + self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) + + def _show_loading_overlay(self, message: str) -> None: + self._loading_overlay.setText(message) + self._loading_overlay.setVisible(True) + self._position_loading_overlay() + + def _hide_loading_overlay(self) -> None: + self._loading_overlay.setVisible(False) + + def _append_status(self, text: str) -> None: + self.preview_status.append(text) + self.preview_status.moveCursor(QTextCursor.End) + self.preview_status.ensureCursorVisible() + + def _cancel_loading(self) -> None: + if self._loader and self._loader.isRunning(): + self._append_status("Cancel requested…") + self._loader.request_cancel() + # UI will flip back on finished -> _on_loader_finished + else: + self._loading_active = False + self._set_preview_button_loading(False) + self._hide_loading_overlay() + self._update_button_states() + + # Loader signal handlers + def _on_loader_progress(self, message: str) -> None: + self._show_loading_overlay(message) + self._append_status(message) + + def _on_loader_success(self, backend: CameraBackend) -> None: + # Transfer ownership to dialog + self._preview_backend = backend + self._append_status("Starting preview…") + + # Mark preview as active + self._preview_active = True + self.preview_btn.setText("Stop Preview") + self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop)) + self.preview_group.setVisible(True) + self.preview_label.setText("Starting…") + self._hide_loading_overlay() + + # Start timer to update preview (~25 fps more stable on Windows) + self._preview_timer = QTimer(self) + self._preview_timer.timeout.connect(self._update_preview) + self._preview_timer.start(40) + + def _on_loader_error(self, error: str) -> None: + self._append_status(f"Error: {error}") + LOGGER.error(f"Failed to start preview: {error}") + QMessageBox.warning(self, "Preview Error", f"Failed to start camera preview:\n{error}") + self._hide_loading_overlay() + self.preview_group.setVisible(False) + + def _on_loader_canceled(self) -> None: + self._append_status("Loading canceled.") + self._hide_loading_overlay() + + def _on_loader_finished(self) -> None: + # Reset loading state and preview button iff not already running preview + self._loading_active = False + if not self._preview_active: + self._set_preview_button_loading(False) + self._loader = None + self._update_button_states() + + # ------------------------------- + # Preview frame update (unchanged logic, robust to None frames) + # ------------------------------- def _update_preview(self) -> None: """Update preview frame.""" if not self._preview_backend or not self._preview_active: @@ -698,8 +1003,4 @@ def _update_preview(self) -> None: self.preview_label.setPixmap(QPixmap.fromImage(q_img)) except Exception as exc: - LOGGER.warning(f"Preview frame error: {exc}") - - def get_settings(self) -> MultiCameraSettings: - """Get the current multi-camera settings.""" - return self._multi_camera_settings + LOGGER.debug(f"Preview frame skipped: {exc}") diff --git a/dlclivegui/cameras/factory.py b/dlclivegui/cameras/factory.py index 3a98d7c..54b8206 100644 --- a/dlclivegui/cameras/factory.py +++ b/dlclivegui/cameras/factory.py @@ -3,9 +3,9 @@ from __future__ import annotations import importlib +from collections.abc import Callable, Generator, Iterable # CHANGED from contextlib import contextmanager from dataclasses import dataclass -from typing import Dict, Generator, Iterable, List, Tuple, Type from ..config import CameraSettings from .base import CameraBackend @@ -35,7 +35,7 @@ class DetectedCamera: label: str -_BACKENDS: Dict[str, Tuple[str, str]] = { +_BACKENDS: dict[str, tuple[str, str]] = { "opencv": ("dlclivegui.cameras.opencv_backend", "OpenCVCameraBackend"), "basler": ("dlclivegui.cameras.basler_backend", "BaslerCameraBackend"), "gentl": ("dlclivegui.cameras.gentl_backend", "GenTLCameraBackend"), @@ -49,14 +49,12 @@ class CameraFactory: @staticmethod def backend_names() -> Iterable[str]: """Return the identifiers of all known backends.""" - return tuple(_BACKENDS.keys()) @staticmethod - def available_backends() -> Dict[str, bool]: + def available_backends() -> dict[str, bool]: """Return a mapping of backend names to availability flags.""" - - availability: Dict[str, bool] = {} + availability: dict[str, bool] = {} for name in _BACKENDS: try: backend_cls = CameraFactory._resolve_backend(name) @@ -67,7 +65,13 @@ def available_backends() -> Dict[str, bool]: return availability @staticmethod - def detect_cameras(backend: str, max_devices: int = 10) -> List[DetectedCamera]: + def detect_cameras( + backend: str, + max_devices: int = 10, + *, + should_cancel: Callable[[], bool] | None = None, # NEW + progress_cb: Callable[[str], None] | None = None, # NEW + ) -> list[DetectedCamera]: """Probe ``backend`` for available cameras. Parameters @@ -77,13 +81,21 @@ def detect_cameras(backend: str, max_devices: int = 10) -> List[DetectedCamera]: max_devices: Upper bound for the indices that should be probed. For backends with get_device_count (GenTL, Aravis), the actual device count is queried. + should_cancel: + Optional callable that returns True if discovery should be canceled. + When cancellation is requested, the function returns the cameras found so far. + progress_cb: + Optional callable to receive human-readable progress messages. Returns ------- list of :class:`DetectedCamera` - Sorted list of detected cameras with human readable labels. + Sorted list of detected cameras with human readable labels (partial if canceled). """ + def _canceled() -> bool: + return bool(should_cancel and should_cancel()) + try: backend_cls = CameraFactory._resolve_backend(backend) except RuntimeError: @@ -91,49 +103,72 @@ def detect_cameras(backend: str, max_devices: int = 10) -> List[DetectedCamera]: if not backend_cls.is_available(): return [] - # For GenTL backend, try to get actual device count + # Resolve device count if possible num_devices = max_devices if hasattr(backend_cls, "get_device_count"): try: + if _canceled(): + return [] actual_count = backend_cls.get_device_count() if actual_count >= 0: num_devices = actual_count except Exception: pass - detected: List[DetectedCamera] = [] + detected: list[DetectedCamera] = [] # Suppress OpenCV warnings/errors during probing (e.g., "can't open camera by index") with _suppress_opencv_logging(): - for index in range(num_devices): - settings = CameraSettings( - name=f"Probe {index}", - index=index, - fps=30.0, - backend=backend, - properties={}, - ) - backend_instance = backend_cls(settings) - try: - backend_instance.open() - except Exception: - continue - else: - label = backend_instance.device_name() - if not label: - label = f"{backend.title()} #{index}" - detected.append(DetectedCamera(index=index, label=label)) - finally: + try: + for index in range(num_devices): + if _canceled(): + # return partial results immediately + break + + if progress_cb: + progress_cb(f"Probing {backend}:{index}…") + + settings = CameraSettings( + name=f"Probe {index}", + index=index, + fps=30.0, + backend=backend, + properties={}, + ) + backend_instance = backend_cls(settings) + try: - backend_instance.close() + # This open() may block for a short time depending on driver/backend. + backend_instance.open() except Exception: + # Not available → continue probing next index pass + else: + label = backend_instance.device_name() or f"{backend.title()} #{index}" + detected.append(DetectedCamera(index=index, label=label)) + if progress_cb: + progress_cb(f"Found {label}") + finally: + try: + backend_instance.close() + except Exception: + pass + + # Check cancel again between indices + if _canceled(): + break + + except KeyboardInterrupt: + # Graceful early exit with partial results + if progress_cb: + progress_cb("Discovery interrupted.") + # any other exception bubbles up to caller + detected.sort(key=lambda camera: camera.index) return detected @staticmethod def create(settings: CameraSettings) -> CameraBackend: """Instantiate a backend for ``settings``.""" - backend_name = (settings.backend or "opencv").lower() try: backend_cls = CameraFactory._resolve_backend(backend_name) @@ -148,32 +183,17 @@ def create(settings: CameraSettings) -> CameraBackend: @staticmethod def check_camera_available(settings: CameraSettings) -> tuple[bool, str]: - """Check if a camera is available without keeping it open. - - Parameters - ---------- - settings : CameraSettings - The camera settings to check. - - Returns - ------- - tuple[bool, str] - A tuple of (is_available, error_message). - If available, error_message is empty. - """ + """Check if a camera is available without keeping it open.""" backend_name = (settings.backend or "opencv").lower() - # Check if backend module is available try: backend_cls = CameraFactory._resolve_backend(backend_name) except RuntimeError as exc: return False, f"Backend '{backend_name}' not installed: {exc}" - # Check if backend reports as available (drivers installed) if not backend_cls.is_available(): return False, f"Backend '{backend_name}' is not available (missing drivers/packages)" - # Try to actually open the camera briefly try: backend_instance = backend_cls(settings) backend_instance.open() @@ -183,7 +203,7 @@ def check_camera_available(settings: CameraSettings) -> tuple[bool, str]: return False, f"Camera not accessible: {exc}" @staticmethod - def _resolve_backend(name: str) -> Type[CameraBackend]: + def _resolve_backend(name: str) -> type[CameraBackend]: try: module_name, class_name = _BACKENDS[name] except KeyError as exc: diff --git a/dlclivegui/cameras/opencv_backend.py b/dlclivegui/cameras/opencv_backend.py index 7110579..96784a6 100644 --- a/dlclivegui/cameras/opencv_backend.py +++ b/dlclivegui/cameras/opencv_backend.py @@ -1,66 +1,103 @@ - -"""OpenCV based camera backend with MJPG enforcement for WSL2.""" +"""OpenCV-based camera backend (Windows-optimized, fast startup, robust read).""" from __future__ import annotations + import logging +import platform import time -from typing import Tuple + import cv2 import numpy as np + from .base import CameraBackend LOG = logging.getLogger(__name__) + class OpenCVCameraBackend(CameraBackend): - """Fallback backend using :mod:`cv2.VideoCapture` with MJPG optimization.""" + """Backend using :mod:`cv2.VideoCapture` with Windows/MSMF preference and safe MJPG attempt. + + Key features: + - Prefers MediaFoundation (MSMF) on Windows; falls back to DirectShow (DSHOW) or ANY. + - Attempts to enable MJPG **only** on Windows and **only** if the device accepts it. + - Minimizes expensive property negotiations (width/height/FPS) to what’s really needed. + - Robust `read()` that returns (None, ts) on transient failures instead of raising. + - Optional fast-start mode: set `properties["fast_start"]=True` to skip noncritical sets. + """ + + # Whitelisted camera properties we allow from settings.properties (numeric IDs only). + SAFE_PROP_IDS = { + # Exposure: note Windows backends differ in support (some expect relative values) + int(getattr(cv2, "CAP_PROP_EXPOSURE", 15)), + int(getattr(cv2, "CAP_PROP_AUTO_EXPOSURE", 21)), + # Gain (not always supported) + int(getattr(cv2, "CAP_PROP_GAIN", 14)), + # FPS (read-only on many webcams; we still attempt) + int(getattr(cv2, "CAP_PROP_FPS", 5)), + # Brightness / Contrast (optional, many cams support) + int(getattr(cv2, "CAP_PROP_BRIGHTNESS", 10)), + int(getattr(cv2, "CAP_PROP_CONTRAST", 11)), + int(getattr(cv2, "CAP_PROP_SATURATION", 12)), + int(getattr(cv2, "CAP_PROP_HUE", 13)), + # Disable RGB conversion (can reduce overhead if needed) + int(getattr(cv2, "CAP_PROP_CONVERT_RGB", 17)), + } def __init__(self, settings): super().__init__(settings) self._capture: cv2.VideoCapture | None = None - self._resolution: Tuple[int, int] = self._parse_resolution( - settings.properties.get("resolution") - ) + self._resolution: tuple[int, int] = self._parse_resolution(settings.properties.get("resolution")) + # Optional fast-start: skip some property sets to reduce startup latency. + self._fast_start: bool = bool(self.settings.properties.get("fast_start", False)) + # Cache last-known device state to avoid repeated queries + self._actual_width: int | None = None + self._actual_height: int | None = None + self._actual_fps: float | None = None + self._codec_str: str = "" + + # ---------------------------- + # Public API + # ---------------------------- def open(self) -> None: - backend_flag = self._resolve_backend(self.settings.properties.get("api")) - self._capture = cv2.VideoCapture(int(self.settings.index), backend_flag) - if not self._capture.isOpened(): - raise RuntimeError(f"Unable to open camera index {self.settings.index} with OpenCV") - self._configure_capture() + backend_flag = self._preferred_backend_flag(self.settings.properties.get("api")) + index = int(self.settings.index) - def read(self) -> Tuple[np.ndarray, float]: - if self._capture is None: - raise RuntimeError("Camera has not been opened") + # Try preferred backend, then fallback chain + self._capture = self._try_open(index, backend_flag) + if not self._capture or not self._capture.isOpened(): + raise RuntimeError( + f"Unable to open camera index {self.settings.index} with OpenCV (backend {backend_flag})" + ) - if not self._capture.grab(): - if not self._capture.isOpened(): - raise RuntimeError("OpenCV camera connection lost") - raise TimeoutError("Failed to grab frame from OpenCV camera (temporary)") + self._configure_capture() - success, frame = self._capture.retrieve() - if not success or frame is None: - raise TimeoutError("Failed to retrieve frame from OpenCV camera (temporary)") + def read(self) -> tuple[np.ndarray | None, float]: + """Robust frame read: return (None, ts) on transient failures; never raises.""" + if self._capture is None: + # This should never happen in normal operation. + LOG.warning("OpenCVCameraBackend.read() called before open()") + return None, time.time() - return frame, time.time() + # Some Windows webcams intermittently fail grab/retrieve. + # We *do not* raise, to avoid GUI restarts / loops. + try: + if not self._capture.grab(): + return None, time.time() + success, frame = self._capture.retrieve() + if not success or frame is None or frame.size == 0: + return None, time.time() + return frame, time.time() + except Exception as exc: + # Log at debug to avoid warning spam + LOG.debug(f"OpenCV read transient error: {exc}") + return None, time.time() def close(self) -> None: - if self._capture: - try: - self._capture.release() - except Exception: - pass - finally: - self._capture = None - time.sleep(0.1) + self._release_capture() def stop(self) -> None: - if self._capture: - try: - self._capture.release() - except Exception: - pass - finally: - self._capture = None + self._release_capture() def device_name(self) -> str: base_name = "OpenCV" @@ -73,7 +110,22 @@ def device_name(self) -> str: base_name = backend_name return f"{base_name} camera #{self.settings.index}" - def _parse_resolution(self, resolution) -> Tuple[int, int]: + # ---------------------------- + # Internal helpers + # ---------------------------- + + def _release_capture(self) -> None: + if self._capture: + try: + self._capture.release() + except Exception: + pass + finally: + self._capture = None + # Small pause helps certain Windows drivers settle after release. + time.sleep(0.02 if platform.system() == "Windows" else 0.0) + + def _parse_resolution(self, resolution) -> tuple[int, int]: if resolution is None: return (720, 540) if isinstance(resolution, (list, tuple)) and len(resolution) == 2: @@ -84,63 +136,177 @@ def _parse_resolution(self, resolution) -> Tuple[int, int]: return (720, 540) return (720, 540) + def _preferred_backend_flag(self, backend: str | None) -> int: + """Resolve preferred backend, with Windows-aware defaults.""" + if backend: # explicit request from settings + return self._resolve_backend(backend) + + # Default preference by platform: + if platform.system() == "Windows": + # Prefer MSMF on modern Windows; fallback to DSHOW if needed. + return getattr(cv2, "CAP_MSMF", cv2.CAP_ANY) + else: + # Non-Windows: let OpenCV pick + return cv2.CAP_ANY + + def _try_open(self, index: int, preferred_flag: int) -> cv2.VideoCapture | None: + """Try opening with preferred backend, then fall back.""" + # 1) preferred + cap = cv2.VideoCapture(index, preferred_flag) + if cap.isOpened(): + return cap + + # 2) Windows fallback chain + if platform.system() == "Windows": + # If preferred was MSMF, try DSHOW, then ANY + dshow = getattr(cv2, "CAP_DSHOW", cv2.CAP_ANY) + if preferred_flag != dshow: + cap = cv2.VideoCapture(index, dshow) + if cap.isOpened(): + return cap + + # 3) Any + cap = cv2.VideoCapture(index, cv2.CAP_ANY) + if cap.isOpened(): + return cap + + return None + def _configure_capture(self) -> None: if not self._capture: return - # Use MJPG if available - if not self._capture.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG')): - LOG.warning("Failed to set MJPG format, falling back to default") + # --- Codec (FourCC) --- + self._codec_str = self._read_codec_string() + LOG.info(f"Camera using codec: {self._codec_str}") - # Log actual codec - fourcc = int(self._capture.get(cv2.CAP_PROP_FOURCC)) - codec = "".join([chr((fourcc >> 8 * i) & 0xFF) for i in range(4)]) - LOG.info(f"Camera using codec: {codec}") + # Attempt MJPG on Windows only, then re-read codec + if platform.system() == "Windows": + self._maybe_enable_mjpg() + self._codec_str = self._read_codec_string() + LOG.info(f"Camera codec after MJPG attempt: {self._codec_str}") - # Set resolution + # --- Resolution --- width, height = self._resolution - if not self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, float(width)): - LOG.warning(f"Failed to set frame width to {width}") - if not self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, float(height)): - LOG.warning(f"Failed to set frame height to {height}") - - actual_width = int(self._capture.get(cv2.CAP_PROP_FRAME_WIDTH)) - actual_height = int(self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT)) - if actual_width != width or actual_height != height: - LOG.warning(f"Resolution mismatch: requested {width}x{height}, got {actual_width}x{actual_height}") - try: - self._resolution = (actual_width, actual_height) - except Exception: - LOG.warning("Failed to update internal resolution state") + if not self._fast_start: + self._set_resolution_if_needed(width, height) + else: + # Fast-start: Avoid early set; just read actual once for logging. + self._actual_width = int(self._capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) + self._actual_height = int(self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0) + + # If mismatch, update internal state for downstream consumers (avoid retries). + if self._actual_width and self._actual_height: + if (self._actual_width != width) or (self._actual_height != height): + LOG.warning( + f"Resolution mismatch: requested {width}x{height}, got {self._actual_width}x{self._actual_height}" + ) + self._resolution = (self._actual_width, self._actual_height) + LOG.info(f"Camera configured with resolution: {self._resolution[0]}x{self._resolution[1]}") - # Set FPS - requested_fps = self.settings.fps - if requested_fps: - if not self._capture.set(cv2.CAP_PROP_FPS, float(requested_fps)): - LOG.warning(f"Failed to set FPS to {requested_fps}") + # --- FPS --- + requested_fps = float(self.settings.fps or 0.0) + if not self._fast_start and requested_fps > 0.0: + # Only set if different and meaningful + current_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0) + if current_fps <= 0.0 or abs(current_fps - requested_fps) > 0.1: + if not self._capture.set(cv2.CAP_PROP_FPS, requested_fps): + LOG.debug(f"Device ignored FPS set to {requested_fps:.2f}") + # Re-read + self._actual_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0) + else: + # Fast-start: just read for logging + self._actual_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0) - actual_fps = self._capture.get(cv2.CAP_PROP_FPS) - if actual_fps: - if requested_fps and abs(actual_fps - requested_fps) > 0.1: - LOG.warning(f"FPS mismatch: requested {requested_fps:.2f}, got {actual_fps:.2f}") - self.settings.fps = float(actual_fps) - LOG.info(f"Camera configured with FPS: {actual_fps:.2f}") + if self._actual_fps and requested_fps: + if abs(self._actual_fps - requested_fps) > 0.1: + LOG.warning(f"FPS mismatch: requested {requested_fps:.2f}, got {self._actual_fps:.2f}") + if self._actual_fps: + self.settings.fps = float(self._actual_fps) + LOG.info(f"Camera configured with FPS: {self._actual_fps:.2f}") - # Apply extra properties + # --- Extra properties (whitelisted only, numeric IDs only) --- for prop, value in self.settings.properties.items(): - if prop in ("api", "resolution"): + if prop in ("api", "resolution", "fast_start"): continue try: prop_id = int(prop) - except (TypeError, ValueError) as e: - LOG.warning(f"Could not parse property ID: {prop} ({e})") + except (TypeError, ValueError): + # Named properties are not supported here; keep numeric only + LOG.debug(f"Ignoring non-numeric property ID: {prop}") continue - if not self._capture.set(prop_id, float(value)): - LOG.warning(f"Failed to set property {prop_id} to {value}") + + if prop_id not in self.SAFE_PROP_IDS: + LOG.debug(f"Skipping unsupported/unsafe property {prop_id}") + continue + + try: + if not self._capture.set(prop_id, float(value)): + LOG.debug(f"Device ignored property {prop_id} -> {value}") + except Exception as exc: + LOG.debug(f"Failed to set property {prop_id} -> {value}: {exc}") + + # ---------------------------- + # Lower-level helpers + # ---------------------------- + + def _read_codec_string(self) -> str: + """Get FourCC as text; returns empty if not available.""" + try: + fourcc = int(self._capture.get(cv2.CAP_PROP_FOURCC) or 0) + except Exception: + fourcc = 0 + if fourcc <= 0: + return "" + # FourCC in little-endian order + return "".join([chr((fourcc >> (8 * i)) & 0xFF) for i in range(4)]) + + def _maybe_enable_mjpg(self) -> None: + """Attempt to enable MJPG on Windows devices; verify and log.""" + try: + fourcc_mjpg = cv2.VideoWriter_fourcc(*"MJPG") + if self._capture.set(cv2.CAP_PROP_FOURCC, fourcc_mjpg): + # Verify + verify = self._read_codec_string() + if verify and verify.upper().startswith("MJPG"): + LOG.info("MJPG enabled successfully.") + else: + LOG.debug(f"MJPG set reported success, but codec is '{verify}'") + else: + LOG.debug("Device rejected MJPG FourCC set.") + except Exception as exc: + LOG.debug(f"MJPG enable attempt raised: {exc}") + + def _set_resolution_if_needed(self, width: int, height: int) -> None: + """Set width/height only if different to minimize renegotiation cost.""" + # Read current + try: + cur_w = int(self._capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) + cur_h = int(self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0) + except Exception: + cur_w, cur_h = 0, 0 + + # Only set if different + if (cur_w != width) or (cur_h != height): + # Set desired + set_w_ok = self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, float(width)) + set_h_ok = self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, float(height)) + if not set_w_ok: + LOG.debug(f"Failed to set frame width to {width}") + if not set_h_ok: + LOG.debug(f"Failed to set frame height to {height}") + + # Re-read actual and cache + try: + self._actual_width = int(self._capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) + self._actual_height = int(self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0) + except Exception: + self._actual_width, self._actual_height = 0, 0 def _resolve_backend(self, backend: str | None) -> int: if backend is None: return cv2.CAP_ANY key = backend.upper() + # Common aliases: MSMF, DSHOW, ANY, V4L2 (non-Windows) return getattr(cv2, f"CAP_{key}", cv2.CAP_ANY) diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index c1684fe..b30ee20 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -5,11 +5,11 @@ import json import logging import os +import signal import sys import time from collections import deque from pathlib import Path -from typing import Optional os.environ["PYLON_CAMEMU"] = "2" @@ -22,7 +22,6 @@ QApplication, QCheckBox, QComboBox, - QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, @@ -58,13 +57,14 @@ from dlclivegui.processors.processor_utils import instantiate_from_scan, scan_processor_folder from dlclivegui.video_recorder import RecorderStats, VideoRecorder -logging.basicConfig(level=logging.INFO) +# logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.DEBUG) class MainWindow(QMainWindow): """Main application window.""" - def __init__(self, config: Optional[ApplicationSettings] = None): + def __init__(self, config: ApplicationSettings | None = None): super().__init__() self.setWindowTitle("DeepLabCut Live GUI") @@ -87,11 +87,11 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._config_path = None self._config = config - self._current_frame: Optional[np.ndarray] = None - self._raw_frame: Optional[np.ndarray] = None - self._last_pose: Optional[PoseResult] = None + self._current_frame: np.ndarray | None = None + self._raw_frame: np.ndarray | None = None + self._last_pose: PoseResult | None = None self._dlc_active: bool = False - self._active_camera_settings: Optional[CameraSettings] = None + self._active_camera_settings: CameraSettings | None = None self._camera_frame_times: deque[float] = deque(maxlen=240) self._last_drop_warning = 0.0 self._last_recorder_summary = "Recorder idle" @@ -101,12 +101,14 @@ def __init__(self, config: Optional[ApplicationSettings] = None): self._scanned_processors: dict = {} self._processor_keys: list = [] self._last_processor_vid_recording = False - self._auto_record_session_name: Optional[str] = None + self._auto_record_session_name: str | None = None self._bbox_x0 = 0 self._bbox_y0 = 0 self._bbox_x1 = 0 self._bbox_y1 = 0 self._bbox_enabled = False + # UI elements + self._cam_dialog: CameraConfigDialog | None = None # Visualization settings (will be updated from config) self._p_cutoff = 0.6 @@ -146,9 +148,7 @@ def __init__(self, config: Optional[ApplicationSettings] = None): # Show status message if myconfig.json was loaded if self._config_path and self._config_path.name == "myconfig.json": - self.statusBar().showMessage( - f"Auto-loaded configuration from {self._config_path}", 5000 - ) + self.statusBar().showMessage(f"Auto-loaded configuration from {self._config_path}", 5000) # Validate cameras from loaded config (deferred to allow window to show first) QTimer.singleShot(100, self._validate_configured_cameras) @@ -229,9 +229,7 @@ def _setup_ui(self) -> None: self.preview_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) self.preview_button.setMinimumWidth(150) self.stop_preview_button = QPushButton("Stop Preview") - self.stop_preview_button.setIcon( - self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop) - ) + self.stop_preview_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop)) self.stop_preview_button.setEnabled(False) self.stop_preview_button.setMinimumWidth(150) button_bar.addWidget(self.preview_button) @@ -274,9 +272,7 @@ def _build_camera_group(self) -> QGroupBox: # Camera config button - opens dialog for all camera configuration config_layout = QHBoxLayout() self.config_cameras_button = QPushButton("Configure Cameras...") - self.config_cameras_button.setIcon( - self.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon) - ) + self.config_cameras_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ComputerIcon)) self.config_cameras_button.setToolTip("Configure camera settings (single or multi-camera)") config_layout.addWidget(self.config_cameras_button) form.addRow(config_layout) @@ -297,9 +293,7 @@ def _build_dlc_group(self) -> QGroupBox: self.model_path_edit.setPlaceholderText("/path/to/exported/model") path_layout.addWidget(self.model_path_edit) self.browse_model_button = QPushButton("Browse…") - self.browse_model_button.setIcon( - self.style().standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon) - ) + self.browse_model_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon)) self.browse_model_button.clicked.connect(self._action_browse_model) path_layout.addWidget(self.browse_model_button) form.addRow("Model file", path_layout) @@ -311,16 +305,12 @@ def _build_dlc_group(self) -> QGroupBox: processor_path_layout.addWidget(self.processor_folder_edit) self.browse_processor_folder_button = QPushButton("Browse...") - self.browse_processor_folder_button.setIcon( - self.style().standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon) - ) + self.browse_processor_folder_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon)) self.browse_processor_folder_button.clicked.connect(self._action_browse_processor_folder) processor_path_layout.addWidget(self.browse_processor_folder_button) self.refresh_processors_button = QPushButton("Refresh") - self.refresh_processors_button.setIcon( - self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload) - ) + self.refresh_processors_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload)) self.refresh_processors_button.clicked.connect(self._refresh_processors) processor_path_layout.addWidget(self.refresh_processors_button) form.addRow("Processor folder", processor_path_layout) @@ -339,16 +329,12 @@ def _build_dlc_group(self) -> QGroupBox: inference_buttons = QHBoxLayout(inference_button_widget) inference_buttons.setContentsMargins(0, 0, 0, 0) self.start_inference_button = QPushButton("Start pose inference") - self.start_inference_button.setIcon( - self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowRight) - ) + self.start_inference_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowRight)) self.start_inference_button.setEnabled(False) self.start_inference_button.setMinimumWidth(150) inference_buttons.addWidget(self.start_inference_button) self.stop_inference_button = QPushButton("Stop pose inference") - self.stop_inference_button.setIcon( - self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserStop) - ) + self.stop_inference_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserStop)) self.stop_inference_button.setEnabled(False) self.stop_inference_button.setMinimumWidth(150) inference_buttons.addWidget(self.stop_inference_button) @@ -410,15 +396,11 @@ def _build_recording_group(self) -> QGroupBox: buttons = QHBoxLayout(recording_button_widget) buttons.setContentsMargins(0, 0, 0, 0) self.start_record_button = QPushButton("Start recording") - self.start_record_button.setIcon( - self.style().standardIcon(QStyle.StandardPixmap.SP_DialogYesButton) - ) + self.start_record_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogYesButton)) self.start_record_button.setMinimumWidth(150) buttons.addWidget(self.start_record_button) self.stop_record_button = QPushButton("Stop recording") - self.stop_record_button.setIcon( - self.style().standardIcon(QStyle.StandardPixmap.SP_DialogNoButton) - ) + self.stop_record_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogNoButton)) self.stop_record_button.setEnabled(False) self.stop_record_button.setMinimumWidth(150) buttons.addWidget(self.stop_record_button) @@ -490,9 +472,7 @@ def _connect_signals(self) -> None: self.multi_camera_controller.all_started.connect(self._on_multi_camera_started) self.multi_camera_controller.all_stopped.connect(self._on_multi_camera_stopped) self.multi_camera_controller.camera_error.connect(self._on_multi_camera_error) - self.multi_camera_controller.initialization_failed.connect( - self._on_multi_camera_initialization_failed - ) + self.multi_camera_controller.initialization_failed.connect(self._on_multi_camera_initialization_failed) self.dlc_processor.pose_ready.connect(self._on_pose_ready) self.dlc_processor.error.connect(self._on_dlc_error) @@ -594,9 +574,7 @@ def _visualization_settings_from_ui(self) -> VisualizationSettings: # ------------------------------------------------------------------ actions def _action_load_config(self) -> None: - file_name, _ = QFileDialog.getOpenFileName( - self, "Load configuration", str(Path.home()), "JSON files (*.json)" - ) + file_name, _ = QFileDialog.getOpenFileName(self, "Load configuration", str(Path.home()), "JSON files (*.json)") if not file_name: return try: @@ -618,9 +596,7 @@ def _action_save_config(self) -> None: self._save_config_to_path(self._config_path) def _action_save_config_as(self) -> None: - file_name, _ = QFileDialog.getSaveFileName( - self, "Save configuration", str(Path.home()), "JSON files (*.json)" - ) + file_name, _ = QFileDialog.getSaveFileName(self, "Save configuration", str(Path.home()), "JSON files (*.json)") if not file_name: return path = Path(file_name) @@ -651,9 +627,7 @@ def _action_browse_model(self) -> None: self.model_path_edit.setText(file_path) def _action_browse_directory(self) -> None: - directory = QFileDialog.getExistingDirectory( - self, "Select output directory", str(Path.home()) - ) + directory = QFileDialog.getExistingDirectory(self, "Select output directory", str(Path.home())) if directory: self.output_directory_edit.setText(directory) @@ -696,19 +670,28 @@ def _refresh_processors(self) -> None: # ------------------------------------------------------------------ multi-camera def _open_camera_config_dialog(self) -> None: - """Open the camera configuration dialog.""" - dialog = CameraConfigDialog(self, self._config.multi_camera) - dialog.settings_changed.connect(self._on_multi_camera_settings_changed) - dialog.exec() + """Open the camera configuration dialog (non-modal, async inside).""" + if self.multi_camera_controller.is_running(): + self._show_warning("Stop the main preview before configuring cameras.") + return + + if self._cam_dialog is None: + self._cam_dialog = CameraConfigDialog(self, self._config.multi_camera) + self._cam_dialog.settings_changed.connect(self._on_multi_camera_settings_changed) + else: + # Refresh its UI from current settings when reopened + self._cam_dialog._populate_from_settings() + + self._cam_dialog.show() + self._cam_dialog.raise_() + self._cam_dialog.activateWindow() def _on_multi_camera_settings_changed(self, settings: MultiCameraSettings) -> None: """Handle changes to multi-camera settings.""" self._config.multi_camera = settings self._update_active_cameras_label() active_count = len(settings.get_active_cameras()) - self.statusBar().showMessage( - f"Camera configuration updated: {active_count} active camera(s)", 3000 - ) + self.statusBar().showMessage(f"Camera configuration updated: {active_count} active camera(s)", 3000) def _update_active_cameras_label(self) -> None: """Update the label showing active cameras.""" @@ -717,9 +700,7 @@ def _update_active_cameras_label(self) -> None: self.active_cameras_label.setText("No cameras configured") elif len(active_cams) == 1: cam = active_cams[0] - self.active_cameras_label.setText( - f"{cam.name} [{cam.backend}:{cam.index}] @ {cam.fps:.1f} fps" - ) + self.active_cameras_label.setText(f"{cam.name} [{cam.backend}:{cam.index}] @ {cam.fps:.1f} fps") else: cam_names = [f"{c.name}" for c in active_cams] self.active_cameras_label.setText(f"{len(active_cams)} cameras: {', '.join(cam_names)}") @@ -802,9 +783,7 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: # PRIORITY 3: Mark display dirty (tiling done in display timer) self._display_dirty = True - def _update_dlc_tile_info( - self, dlc_cam_id: str, original_frame: np.ndarray, frames: dict[str, np.ndarray] - ) -> None: + def _update_dlc_tile_info(self, dlc_cam_id: str, original_frame: np.ndarray, frames: dict[str, np.ndarray]) -> None: """Calculate tile offset and scale for drawing DLC poses on tiled frame.""" num_cameras = len(frames) if num_cameras == 0: @@ -867,9 +846,7 @@ def _on_multi_camera_started(self) -> None: self.preview_button.setEnabled(False) self.stop_preview_button.setEnabled(True) active_count = self.multi_camera_controller.get_active_count() - self.statusBar().showMessage( - f"Multi-camera preview started: {active_count} camera(s)", 5000 - ) + self.statusBar().showMessage(f"Multi-camera preview started: {active_count} camera(s)", 5000) self._update_inference_buttons() self._update_camera_controls_enabled() @@ -1001,6 +978,8 @@ def _start_preview(self) -> None: # Store active settings for single camera mode (for DLC, recording frame rate, etc.) self._active_camera_settings = active_cams[0] if active_cams else None + for cam in active_cams: + cam.properties.setdefault("fast_start", True) self.multi_camera_controller.start(active_cams) self._update_inference_buttons() @@ -1267,9 +1246,7 @@ def _update_metrics(self) -> None: fps = self._compute_fps(self._camera_frame_times) if fps > 0: if active_count > 1: - self.camera_stats_label.setText( - f"{active_count} cameras | {fps:.1f} fps (last 5 s)" - ) + self.camera_stats_label.setText(f"{active_count} cameras | {fps:.1f} fps (last 5 s)") else: self.camera_stats_label.setText(f"{fps:.1f} fps (last 5 s)") else: @@ -1322,7 +1299,7 @@ def _update_metrics(self) -> None: avg_latency = sum(avg_latencies) / len(avg_latencies) if avg_latencies else 0.0 summary = ( f"{num_recorders} cams | {total_written} frames | " - f"latency {max_latency*1000:.1f}ms (avg {avg_latency*1000:.1f}ms) | " + f"latency {max_latency * 1000:.1f}ms (avg {avg_latency * 1000:.1f}ms) | " f"queue {total_queue} | dropped {total_dropped}" ) self._last_recorder_summary = summary @@ -1371,13 +1348,11 @@ def _update_processor_status(self) -> None: self._auto_record_session_name = session_name # Update filename with session name - original_filename = self.filename_edit.text() + self.filename_edit.text() self.filename_edit.setText(f"{session_name}.mp4") self._start_recording() - self.statusBar().showMessage( - f"Auto-started recording: {session_name}", 3000 - ) + self.statusBar().showMessage(f"Auto-started recording: {session_name}", 3000) logging.info(f"Auto-recording started for session: {session_name}") else: # Stop video recording @@ -1468,11 +1443,7 @@ def _on_dlc_error(self, message: str) -> None: def _update_video_display(self, frame: np.ndarray) -> None: display_frame = frame - if ( - self.show_predictions_checkbox.isChecked() - and self._last_pose - and self._last_pose.pose is not None - ): + if self.show_predictions_checkbox.isChecked() and self._last_pose and self._last_pose.pose is not None: display_frame = self._draw_pose(frame, self._last_pose.pose) # Draw bounding box if enabled @@ -1684,11 +1655,21 @@ def _show_info(self, message: str) -> None: def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI behaviour if self.multi_camera_controller.is_running(): self.multi_camera_controller.stop(wait=True) + # Stop all multi-camera recorders for recorder in self._multi_camera_recorders.values(): if recorder.is_running: recorder.stop() self._multi_camera_recorders.clear() + + # Close the camera dialog if open (ensures its worker thread is canceled) + if getattr(self, "_cam_dialog", None) is not None and self._cam_dialog.isVisible(): + try: + self._cam_dialog.close() + except Exception: + pass + self._cam_dialog = None + self.dlc_processor.shutdown() if hasattr(self, "_metrics_timer"): self._metrics_timer.stop() @@ -1696,6 +1677,8 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha def main() -> None: + signal.signal(signal.SIGINT, signal.SIG_DFL) # Allow Ctrl+C to terminate the app + app = QApplication(sys.argv) window = MainWindow() window.show() From 988eb6d3578e634a40bd5d2898dedc7e6e4f9860 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 28 Jan 2026 11:57:52 +0100 Subject: [PATCH 44/69] Refactor camera loader initialization and cleanup comments Cleaned up comments and removed 'NEW' markers in camera_config_dialog.py. Adjusted the order of UI preparation and loader creation in the camera preview logic, and commented out redundant loader cancellation code. --- dlclivegui/camera_config_dialog.py | 33 +++++++++++++++--------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/dlclivegui/camera_config_dialog.py b/dlclivegui/camera_config_dialog.py index 0c85abf..9771a43 100644 --- a/dlclivegui/camera_config_dialog.py +++ b/dlclivegui/camera_config_dialog.py @@ -24,7 +24,7 @@ QPushButton, QSpinBox, QStyle, - QTextEdit, # NEW: lightweight status console in the preview panel + QTextEdit, QVBoxLayout, QWidget, ) @@ -395,7 +395,7 @@ def _setup_ui(self) -> None: preview_layout.addWidget(self.preview_label) self.preview_label.installEventFilter(self) # For resize events - # NEW: small, read-only status console for loader messages + # Small, read-only status console for loader messages self.preview_status = QTextEdit() self.preview_status.setReadOnly(True) self.preview_status.setFixedHeight(45) @@ -407,13 +407,12 @@ def _setup_ui(self) -> None: self.preview_status.setFont(font) preview_layout.addWidget(self.preview_status) - # NEW: overlay label for loading glass pane + # Overlay label for loading glass pane self._loading_overlay = QLabel(self.preview_group) self._loading_overlay.setVisible(False) self._loading_overlay.setAlignment(Qt.AlignCenter) self._loading_overlay.setStyleSheet("background-color: rgba(0,0,0,140); color: white; border: 1px solid #333;") self._loading_overlay.setText("Loading camera…") - # We size/position it on show & on resize self.preview_group.setVisible(False) right_layout.addWidget(self.preview_group) @@ -439,7 +438,7 @@ def _setup_ui(self) -> None: main_layout.addLayout(button_layout) # Maintain overlay geometry when resizing - def resizeEvent(self, event): # NEW + def resizeEvent(self, event): super().resizeEvent(event) if self._loading_overlay and self._loading_overlay.isVisible(): self._position_loading_overlay() @@ -469,7 +468,7 @@ def _show_scan_overlay(self, message: str = "Discovering cameras…") -> None: def _hide_scan_overlay(self) -> None: self._scan_overlay.setVisible(False) - def _position_loading_overlay(self): # NEW + def _position_loading_overlay(self): # Cover just the preview image area (label), not the whole group if not self.preview_label: return @@ -819,17 +818,9 @@ def _start_preview(self) -> None: # Ensure any existing preview or loader is stopped/canceled self._stop_preview() - if self._loader and self._loader.isRunning(): - self._loader.request_cancel() - - # Prepare UI - self.preview_group.setVisible(True) - self.preview_label.setText("No preview") - self.preview_status.clear() - self._show_loading_overlay("Loading camera…") - self._set_preview_button_loading(True) - - # Create singleton worker + # if self._loader and self._loader.isRunning(): + # self._loader.request_cancel() + # Create worker self._loader = CameraLoadWorker(cam, self) self._loader.progress.connect(self._on_loader_progress) self._loader.success.connect(self._on_loader_success) @@ -838,6 +829,14 @@ def _start_preview(self) -> None: self._loader.finished.connect(self._on_loader_finished) self._loading_active = True self._update_button_states() + + # Prepare UI + self.preview_group.setVisible(True) + self.preview_label.setText("No preview") + self.preview_status.clear() + self._show_loading_overlay("Loading camera…") + self._set_preview_button_loading(True) + self._loader.start() def _stop_preview(self) -> None: From 4fe8abc12a1d3cf85b7ad65954a171e6a2dcb95a Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 28 Jan 2026 11:58:13 +0100 Subject: [PATCH 45/69] Enhance OpenCV camera backend for cross-platform support Refactored the OpenCVCameraBackend to improve platform-specific handling for Windows, macOS, and Linux. Added resolution normalization, alternate index probing, and robust fallback logic for device opening and configuration. Improved MJPG enabling, property whitelisting, and added a quick_ping discovery helper. Enhanced logging and made the backend more robust to device quirks and transient errors. --- dlclivegui/cameras/opencv_backend.py | 213 +++++++++++++++++---------- 1 file changed, 136 insertions(+), 77 deletions(-) diff --git a/dlclivegui/cameras/opencv_backend.py b/dlclivegui/cameras/opencv_backend.py index 96784a6..ffbc0d0 100644 --- a/dlclivegui/cameras/opencv_backend.py +++ b/dlclivegui/cameras/opencv_backend.py @@ -1,8 +1,9 @@ -"""OpenCV-based camera backend (Windows-optimized, fast startup, robust read).""" +"""OpenCV-based camera backend (platform-optimized, fast startup, robust read).""" from __future__ import annotations import logging +import os import platform import time @@ -15,45 +16,48 @@ class OpenCVCameraBackend(CameraBackend): - """Backend using :mod:`cv2.VideoCapture` with Windows/MSMF preference and safe MJPG attempt. - - Key features: - - Prefers MediaFoundation (MSMF) on Windows; falls back to DirectShow (DSHOW) or ANY. - - Attempts to enable MJPG **only** on Windows and **only** if the device accepts it. - - Minimizes expensive property negotiations (width/height/FPS) to what’s really needed. - - Robust `read()` that returns (None, ts) on transient failures instead of raising. - - Optional fast-start mode: set `properties["fast_start"]=True` to skip noncritical sets. + """ + Platform-aware OpenCV backend: + + - Windows: prefer DSHOW, fall back to MSMF/ANY. + Order: FOURCC -> resolution -> FPS. Try standard UVC modes if request fails. + Optional alt-index probe (index+1) for Logitech-like endpoints: properties["alt_index_probe"]=True + Optional fast-start: properties["fast_start"]=True + + - macOS: prefer AVFOUNDATION, fall back to ANY. + + - Linux: prefer V4L2, fall back to GStreamer (if explicitly requested) or ANY. + Discovery can use /dev/video* to avoid blind opens (via quick_ping()). + + Robust read(): returns (None, ts) on transient failures (never raises). """ - # Whitelisted camera properties we allow from settings.properties (numeric IDs only). SAFE_PROP_IDS = { - # Exposure: note Windows backends differ in support (some expect relative values) int(getattr(cv2, "CAP_PROP_EXPOSURE", 15)), int(getattr(cv2, "CAP_PROP_AUTO_EXPOSURE", 21)), - # Gain (not always supported) int(getattr(cv2, "CAP_PROP_GAIN", 14)), - # FPS (read-only on many webcams; we still attempt) int(getattr(cv2, "CAP_PROP_FPS", 5)), - # Brightness / Contrast (optional, many cams support) int(getattr(cv2, "CAP_PROP_BRIGHTNESS", 10)), int(getattr(cv2, "CAP_PROP_CONTRAST", 11)), int(getattr(cv2, "CAP_PROP_SATURATION", 12)), int(getattr(cv2, "CAP_PROP_HUE", 13)), - # Disable RGB conversion (can reduce overhead if needed) int(getattr(cv2, "CAP_PROP_CONVERT_RGB", 17)), } + # Standard UVC modes that commonly succeed fast on Windows/Logitech + UVC_FALLBACK_MODES = [(1280, 720), (1920, 1080), (640, 480)] + def __init__(self, settings): super().__init__(settings) self._capture: cv2.VideoCapture | None = None self._resolution: tuple[int, int] = self._parse_resolution(settings.properties.get("resolution")) - # Optional fast-start: skip some property sets to reduce startup latency. self._fast_start: bool = bool(self.settings.properties.get("fast_start", False)) - # Cache last-known device state to avoid repeated queries + self._alt_index_probe: bool = bool(self.settings.properties.get("alt_index_probe", False)) self._actual_width: int | None = None self._actual_height: int | None = None self._actual_fps: float | None = None self._codec_str: str = "" + self._mjpg_attempted: bool = False # ---------------------------- # Public API @@ -63,24 +67,38 @@ def open(self) -> None: backend_flag = self._preferred_backend_flag(self.settings.properties.get("api")) index = int(self.settings.index) - # Try preferred backend, then fallback chain + # 1) Preferred backend self._capture = self._try_open(index, backend_flag) + + # 2) Optional Logitech endpoint trick (Windows only) + if ( + (not self._capture or not self._capture.isOpened()) + and platform.system() == "Windows" + and self._alt_index_probe + ): + LOG.debug("Primary index failed; trying alternate endpoint (index+1) with same backend.") + self._capture = self._try_open(index + 1, backend_flag) + if not self._capture or not self._capture.isOpened(): raise RuntimeError( f"Unable to open camera index {self.settings.index} with OpenCV (backend {backend_flag})" ) + # MSMF hint for slow systems + if platform.system() == "Windows" and backend_flag == getattr(cv2, "CAP_MSMF", cv2.CAP_ANY): + if os.environ.get("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS") is None: + LOG.debug( + "MSMF selected. If open is slow, consider setting " + "OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS=0 before importing cv2." + ) + self._configure_capture() def read(self) -> tuple[np.ndarray | None, float]: """Robust frame read: return (None, ts) on transient failures; never raises.""" if self._capture is None: - # This should never happen in normal operation. LOG.warning("OpenCVCameraBackend.read() called before open()") return None, time.time() - - # Some Windows webcams intermittently fail grab/retrieve. - # We *do not* raise, to avoid GUI restarts / loops. try: if not self._capture.grab(): return None, time.time() @@ -89,7 +107,6 @@ def read(self) -> tuple[np.ndarray | None, float]: return None, time.time() return frame, time.time() except Exception as exc: - # Log at debug to avoid warning spam LOG.debug(f"OpenCV read transient error: {exc}") return None, time.time() @@ -122,12 +139,11 @@ def _release_capture(self) -> None: pass finally: self._capture = None - # Small pause helps certain Windows drivers settle after release. time.sleep(0.02 if platform.system() == "Windows" else 0.0) def _parse_resolution(self, resolution) -> tuple[int, int]: if resolution is None: - return (720, 540) + return (720, 540) # normalized later where needed if isinstance(resolution, (list, tuple)) and len(resolution) == 2: try: return (int(resolution[0]), int(resolution[1])) @@ -136,111 +152,131 @@ def _parse_resolution(self, resolution) -> tuple[int, int]: return (720, 540) return (720, 540) + def _normalize_resolution(self, width: int, height: int) -> tuple[int, int]: + """On Windows, map non-standard requests to UVC-friendly modes for fast acceptance.""" + if platform.system() == "Windows": + if (width, height) in self.UVC_FALLBACK_MODES: + return (width, height) + LOG.debug(f"Normalizing unsupported resolution {width}x{height} to 1280x720 on Windows.") + return self.UVC_FALLBACK_MODES[0] + return (width, height) + def _preferred_backend_flag(self, backend: str | None) -> int: - """Resolve preferred backend, with Windows-aware defaults.""" - if backend: # explicit request from settings + """Resolve preferred backend by platform.""" + if backend: # user override return self._resolve_backend(backend) - # Default preference by platform: - if platform.system() == "Windows": - # Prefer MSMF on modern Windows; fallback to DSHOW if needed. - return getattr(cv2, "CAP_MSMF", cv2.CAP_ANY) - else: - # Non-Windows: let OpenCV pick - return cv2.CAP_ANY + sys = platform.system() + if sys == "Windows": + # Prefer DSHOW (faster on many Logitech cams), then MSMF, then ANY. + return getattr(cv2, "CAP_DSHOW", cv2.CAP_ANY) + if sys == "Darwin": + return getattr(cv2, "CAP_AVFOUNDATION", cv2.CAP_ANY) + # Linux and others + return getattr(cv2, "CAP_V4L2", cv2.CAP_ANY) def _try_open(self, index: int, preferred_flag: int) -> cv2.VideoCapture | None: - """Try opening with preferred backend, then fall back.""" + """Try opening with preferred backend, then platform-appropriate fallbacks.""" # 1) preferred cap = cv2.VideoCapture(index, preferred_flag) if cap.isOpened(): return cap - # 2) Windows fallback chain - if platform.system() == "Windows": - # If preferred was MSMF, try DSHOW, then ANY - dshow = getattr(cv2, "CAP_DSHOW", cv2.CAP_ANY) - if preferred_flag != dshow: - cap = cv2.VideoCapture(index, dshow) + sys = platform.system() + + # Windows: try MSMF then ANY + if sys == "Windows": + ms = getattr(cv2, "CAP_MSMF", cv2.CAP_ANY) + if preferred_flag != ms: + cap = cv2.VideoCapture(index, ms) if cap.isOpened(): return cap - # 3) Any + # macOS: ANY fallback + if sys == "Darwin": + cap = cv2.VideoCapture(index, cv2.CAP_ANY) + if cap.isOpened(): + return cap + + # Linux: try ANY as final fallback cap = cv2.VideoCapture(index, cv2.CAP_ANY) if cap.isOpened(): return cap - return None def _configure_capture(self) -> None: if not self._capture: return - # --- Codec (FourCC) --- + # --- FOURCC (Windows benefits from setting this first) --- self._codec_str = self._read_codec_string() LOG.info(f"Camera using codec: {self._codec_str}") - # Attempt MJPG on Windows only, then re-read codec - if platform.system() == "Windows": + if platform.system() == "Windows" and not self._mjpg_attempted: self._maybe_enable_mjpg() + self._mjpg_attempted = True self._codec_str = self._read_codec_string() LOG.info(f"Camera codec after MJPG attempt: {self._codec_str}") - # --- Resolution --- - width, height = self._resolution + # --- Resolution (normalize non-standard on Windows) --- + req_w, req_h = self._resolution + req_w, req_h = self._normalize_resolution(req_w, req_h) + if not self._fast_start: - self._set_resolution_if_needed(width, height) + self._set_resolution_if_needed(req_w, req_h) else: - # Fast-start: Avoid early set; just read actual once for logging. self._actual_width = int(self._capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) self._actual_height = int(self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0) - # If mismatch, update internal state for downstream consumers (avoid retries). - if self._actual_width and self._actual_height: - if (self._actual_width != width) or (self._actual_height != height): + # Handle mismatch quickly with a few known-good UVC fallbacks (Windows only) + if platform.system() == "Windows" and self._actual_width and self._actual_height: + if (self._actual_width, self._actual_height) != (req_w, req_h) and not self._fast_start: LOG.warning( - f"Resolution mismatch: requested {width}x{height}, got {self._actual_width}x{self._actual_height}" + f"Resolution mismatch: requested {req_w}x{req_h}, got {self._actual_width}x{self._actual_height}" ) - self._resolution = (self._actual_width, self._actual_height) + for fw, fh in self.UVC_FALLBACK_MODES: + if (fw, fh) == (self._actual_width, self._actual_height): + break # already at a fallback + if self._set_resolution_if_needed(fw, fh, reconfigure_only=True): + LOG.info(f"Switched to supported resolution {fw}x{fh}") + self._actual_width, self._actual_height = fw, fh + break + self._resolution = (self._actual_width or req_w, self._actual_height or req_h) + else: + # Non-Windows: accept actual as-is + self._resolution = (self._actual_width or req_w, self._actual_height or req_h) LOG.info(f"Camera configured with resolution: {self._resolution[0]}x{self._resolution[1]}") # --- FPS --- requested_fps = float(self.settings.fps or 0.0) if not self._fast_start and requested_fps > 0.0: - # Only set if different and meaningful current_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0) if current_fps <= 0.0 or abs(current_fps - requested_fps) > 0.1: if not self._capture.set(cv2.CAP_PROP_FPS, requested_fps): LOG.debug(f"Device ignored FPS set to {requested_fps:.2f}") - # Re-read self._actual_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0) else: - # Fast-start: just read for logging self._actual_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0) - if self._actual_fps and requested_fps: - if abs(self._actual_fps - requested_fps) > 0.1: - LOG.warning(f"FPS mismatch: requested {requested_fps:.2f}, got {self._actual_fps:.2f}") + if self._actual_fps and requested_fps and abs(self._actual_fps - requested_fps) > 0.1: + LOG.warning(f"FPS mismatch: requested {requested_fps:.2f}, got {self._actual_fps:.2f}") if self._actual_fps: self.settings.fps = float(self._actual_fps) LOG.info(f"Camera configured with FPS: {self._actual_fps:.2f}") - # --- Extra properties (whitelisted only, numeric IDs only) --- + # --- Extra properties (safe whitelist) --- for prop, value in self.settings.properties.items(): - if prop in ("api", "resolution", "fast_start"): + if prop in ("api", "resolution", "fast_start", "alt_index_probe"): continue try: prop_id = int(prop) except (TypeError, ValueError): - # Named properties are not supported here; keep numeric only LOG.debug(f"Ignoring non-numeric property ID: {prop}") continue - if prop_id not in self.SAFE_PROP_IDS: LOG.debug(f"Skipping unsupported/unsafe property {prop_id}") continue - try: if not self._capture.set(prop_id, float(value)): LOG.debug(f"Device ignored property {prop_id} -> {value}") @@ -252,22 +288,21 @@ def _configure_capture(self) -> None: # ---------------------------- def _read_codec_string(self) -> str: - """Get FourCC as text; returns empty if not available.""" try: fourcc = int(self._capture.get(cv2.CAP_PROP_FOURCC) or 0) except Exception: fourcc = 0 if fourcc <= 0: return "" - # FourCC in little-endian order return "".join([chr((fourcc >> (8 * i)) & 0xFF) for i in range(4)]) def _maybe_enable_mjpg(self) -> None: - """Attempt to enable MJPG on Windows devices; verify and log.""" + """Attempt to enable MJPG on Windows devices; verify once.""" + if platform.system() != "Windows": + return try: fourcc_mjpg = cv2.VideoWriter_fourcc(*"MJPG") if self._capture.set(cv2.CAP_PROP_FOURCC, fourcc_mjpg): - # Verify verify = self._read_codec_string() if verify and verify.upper().startswith("MJPG"): LOG.info("MJPG enabled successfully.") @@ -278,18 +313,17 @@ def _maybe_enable_mjpg(self) -> None: except Exception as exc: LOG.debug(f"MJPG enable attempt raised: {exc}") - def _set_resolution_if_needed(self, width: int, height: int) -> None: - """Set width/height only if different to minimize renegotiation cost.""" - # Read current + def _set_resolution_if_needed(self, width: int, height: int, reconfigure_only: bool = False) -> bool: + """Set width/height only if different. + Returns True if the device ends up at the requested size. + """ try: cur_w = int(self._capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) cur_h = int(self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0) except Exception: cur_w, cur_h = 0, 0 - # Only set if different if (cur_w != width) or (cur_h != height): - # Set desired set_w_ok = self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, float(width)) set_h_ok = self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, float(height)) if not set_w_ok: @@ -297,16 +331,41 @@ def _set_resolution_if_needed(self, width: int, height: int) -> None: if not set_h_ok: LOG.debug(f"Failed to set frame height to {height}") - # Re-read actual and cache try: self._actual_width = int(self._capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) self._actual_height = int(self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0) except Exception: self._actual_width, self._actual_height = 0, 0 + return (self._actual_width, self._actual_height) == (width, height) + def _resolve_backend(self, backend: str | None) -> int: if backend is None: return cv2.CAP_ANY key = backend.upper() - # Common aliases: MSMF, DSHOW, ANY, V4L2 (non-Windows) return getattr(cv2, f"CAP_{key}", cv2.CAP_ANY) + + # ---------------------------- + # Discovery helper (optional use by factory) + # ---------------------------- + @staticmethod + def quick_ping(index: int, backend_flag: int | None = None) -> bool: + """Cheap 'is-present' check to avoid expensive blind opens during discovery.""" + sys = platform.system() + if sys == "Linux": + # /dev/videoN present? That's a cheap, reliable hint. + return os.path.exists(f"/dev/video{index}") + if backend_flag is None: + if sys == "Windows": + backend_flag = getattr(cv2, "CAP_DSHOW", cv2.CAP_ANY) + elif sys == "Darwin": + backend_flag = getattr(cv2, "CAP_AVFOUNDATION", cv2.CAP_ANY) + else: + backend_flag = getattr(cv2, "CAP_V4L2", cv2.CAP_ANY) + cap = cv2.VideoCapture(index, backend_flag) + ok = cap.isOpened() + try: + cap.release() + except Exception: + pass + return ok From 8d02ac68765fd66e5d5e27d25b8e226bc5f604a4 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 28 Jan 2026 12:05:11 +0100 Subject: [PATCH 46/69] Refactor camera loading and fix preview initialization logic Updated CameraLoadWorker to emit the camera object directly and moved backend creation to the main thread for Windows compatibility. Improved _on_loader_success to handle both CameraBackend and CameraSettings payloads, ensuring proper preview initialization and error handling. Also fixed overlay geometry handling and reset preview state on stop. --- dlclivegui/camera_config_dialog.py | 59 ++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/dlclivegui/camera_config_dialog.py b/dlclivegui/camera_config_dialog.py index 9771a43..43e4efc 100644 --- a/dlclivegui/camera_config_dialog.py +++ b/dlclivegui/camera_config_dialog.py @@ -109,9 +109,11 @@ def run(self): return LOGGER.debug("Creating camera backend for %s:%d", self._cam.backend, self._cam.index) - self._backend = CameraFactory.create(self._cam) - + # self._backend = CameraFactory.create(self._cam) self.progress.emit("Opening device…") + self.success.emit(self._cam) + return + if self._check_cancel(): self.canceled.emit() return @@ -440,8 +442,6 @@ def _setup_ui(self) -> None: # Maintain overlay geometry when resizing def resizeEvent(self, event): super().resizeEvent(event) - if self._loading_overlay and self._loading_overlay.isVisible(): - self._position_loading_overlay() if hasattr(self, "_loading_overlay") and self._loading_overlay.isVisible(): self._position_loading_overlay() @@ -859,6 +859,7 @@ def _stop_preview(self) -> None: self._preview_backend = None # Reset UI self._loading_active = False + self._preview_active = False self._set_preview_button_loading(False) self.preview_btn.setText("Start Preview") self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) @@ -908,23 +909,43 @@ def _on_loader_progress(self, message: str) -> None: self._show_loading_overlay(message) self._append_status(message) - def _on_loader_success(self, backend: CameraBackend) -> None: - # Transfer ownership to dialog - self._preview_backend = backend - self._append_status("Starting preview…") + def _on_loader_success(self, payload) -> None: + """ + Payload is either: + - CameraBackend (non-Windows path if you kept worker-open), or + - CameraSettings (Windows probe-only, open on GUI thread) + """ + try: + if isinstance(payload, CameraBackend): + # Legacy path: backend already opened in worker + self._preview_backend = payload - # Mark preview as active - self._preview_active = True - self.preview_btn.setText("Stop Preview") - self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop)) - self.preview_group.setVisible(True) - self.preview_label.setText("Starting…") - self._hide_loading_overlay() + elif isinstance(payload, CameraSettings): + # Windows probe path: open now on GUI thread + cam_settings = payload + self._append_status("Opening camera on main thread…") + self._preview_backend = CameraFactory.create(cam_settings) + self._preview_backend.open() # fast now; overlay keeps UI pleasant + + else: + raise TypeError(f"Unexpected success payload type: {type(payload)}") + + self._append_status("Starting preview…") + self._preview_active = True + self.preview_btn.setText("Stop Preview") + self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop)) + self.preview_group.setVisible(True) + self.preview_label.setText("Starting…") + self._hide_loading_overlay() + + # Start timer to update preview (~25 fps more stable on Windows) + self._preview_timer = QTimer(self) + self._preview_timer.timeout.connect(self._update_preview) + self._preview_timer.start(40) - # Start timer to update preview (~25 fps more stable on Windows) - self._preview_timer = QTimer(self) - self._preview_timer.timeout.connect(self._update_preview) - self._preview_timer.start(40) + except Exception as exc: + # If open failed here, fall back to error handling + self._on_loader_error(str(exc)) def _on_loader_error(self, error: str) -> None: self._append_status(f"Error: {error}") From 6defc2e0a1096294c3609fbf0e76076df62e5216 Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Mon, 26 Jan 2026 18:10:48 +0100 Subject: [PATCH 47/69] fixed resizing of camera view, also prevent user from changing config while acquiring. --- dlclivegui/gui.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index b30ee20..19c3894 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -248,9 +248,9 @@ def _setup_ui(self) -> None: def _build_menus(self) -> None: file_menu = self.menuBar().addMenu("&File") - load_action = QAction("Load configuration…", self) - load_action.triggered.connect(self._action_load_config) - file_menu.addAction(load_action) + self.load_config_action = QAction("Load configuration…", self) + self.load_config_action.triggered.connect(self._action_load_config) + file_menu.addAction(self.load_config_action) save_action = QAction("Save configuration", self) save_action.triggered.connect(self._action_save_config) @@ -1072,6 +1072,10 @@ def _update_camera_controls_enabled(self) -> None: # Config cameras button should be available when not in preview/recording self.config_cameras_button.setEnabled(allow_changes) + # Disable loading configurations when preview/recording is active + if hasattr(self, "load_config_action"): + self.load_config_action.setEnabled(allow_changes) + def _track_camera_frame(self) -> None: now = time.perf_counter() self._camera_frame_times.append(now) @@ -1454,7 +1458,15 @@ def _update_video_display(self, frame: np.ndarray) -> None: h, w, ch = rgb.shape bytes_per_line = ch * w image = QImage(rgb.data, w, h, bytes_per_line, QImage.Format.Format_RGB888) - self.video_label.setPixmap(QPixmap.fromImage(image)) + pixmap = QPixmap.fromImage(image) + + # Scale pixmap to fit label while preserving aspect ratio + scaled_pixmap = pixmap.scaled( + self.video_label.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + self.video_label.setPixmap(scaled_pixmap) def _on_show_predictions_changed(self, _state: int) -> None: if self._current_frame is not None: From ba1e3d8c1c3715927dcab99efdc5b01d43f5d61c Mon Sep 17 00:00:00 2001 From: Artur Schneider Date: Wed, 28 Jan 2026 12:05:29 +0100 Subject: [PATCH 48/69] fix timestamping with multiple cameras --- dlclivegui/gui.py | 23 +++++++++++++---------- dlclivegui/video_recorder.py | 5 ++--- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 19c3894..2aec780 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -769,16 +769,19 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: self.dlc_processor.enqueue_frame(frame, timestamp) # PRIORITY 2: Recording (queued, non-blocking) - if self._multi_camera_recorders: - for cam_id, frame in frame_data.frames.items(): - if cam_id in self._multi_camera_recorders: - recorder = self._multi_camera_recorders[cam_id] - if recorder.is_running: - timestamp = frame_data.timestamps.get(cam_id, time.time()) - try: - recorder.write(frame, timestamp=timestamp) - except Exception as exc: - logging.warning(f"Failed to write frame for camera {cam_id}: {exc}") + # Only record the frame from the camera that triggered this signal to avoid + # writing duplicate timestamps when multiple cameras are running + if self._multi_camera_recorders and frame_data.source_camera_id: + cam_id = frame_data.source_camera_id + if cam_id in self._multi_camera_recorders and cam_id in frame_data.frames: + recorder = self._multi_camera_recorders[cam_id] + if recorder.is_running: + frame = frame_data.frames[cam_id] + timestamp = frame_data.timestamps.get(cam_id, time.time()) + try: + recorder.write(frame, timestamp=timestamp) + except Exception as exc: + logging.warning(f"Failed to write frame for camera {cam_id}: {exc}") # PRIORITY 3: Mark display dirty (tiling done in display timer) self._display_dirty = True diff --git a/dlclivegui/video_recorder.py b/dlclivegui/video_recorder.py index 47cfff4..3afa9e7 100644 --- a/dlclivegui/video_recorder.py +++ b/dlclivegui/video_recorder.py @@ -125,11 +125,9 @@ def write(self, frame: np.ndarray, timestamp: Optional[float] = None) -> bool: if error is not None: raise RuntimeError(f"Video encoding failed: {error}") from error - # Record timestamp for this frame + # Capture timestamp now, but only record it if frame is successfully enqueued if timestamp is None: timestamp = time.time() - with self._stats_lock: - self._frame_timestamps.append(timestamp) # Convert frame to uint8 if needed if frame.dtype != np.uint8: @@ -179,6 +177,7 @@ def write(self, frame: np.ndarray, timestamp: Optional[float] = None) -> bool: return False with self._stats_lock: self._frames_enqueued += 1 + self._frame_timestamps.append(timestamp) return True def stop(self) -> None: From 38fbf4b6c777920bee2011f47d7697dd4257086a Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 28 Jan 2026 16:08:58 +0100 Subject: [PATCH 49/69] Add dark and system theme switching to GUI Introduces a theme selection menu with dark and system (default) styles using qdarkstyle and QActionGroup. The current theme is tracked and can be switched via the new Appearance submenu under View. --- dlclivegui/gui.py | 62 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 2aec780..68a36d7 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -2,6 +2,7 @@ from __future__ import annotations +import enum import json import logging import os @@ -16,8 +17,9 @@ import cv2 import matplotlib.pyplot as plt import numpy as np +import qdarkstyle from PySide6.QtCore import Qt, QTimer -from PySide6.QtGui import QAction, QCloseEvent, QImage, QPixmap +from PySide6.QtGui import QAction, QActionGroup, QCloseEvent, QImage, QPixmap from PySide6.QtWidgets import ( QApplication, QCheckBox, @@ -61,6 +63,12 @@ logging.basicConfig(level=logging.DEBUG) +# auto enum for styles +class AppStyle(enum.Enum): + SYS_DEFAULT = "system" + DARK = "dark" + + class MainWindow(QMainWindow): """Main application window.""" @@ -108,6 +116,7 @@ def __init__(self, config: ApplicationSettings | None = None): self._bbox_y1 = 0 self._bbox_enabled = False # UI elements + self._current_style: AppStyle = AppStyle.DARK self._cam_dialog: CameraConfigDialog | None = None # Visualization settings (will be updated from config) @@ -154,6 +163,25 @@ def __init__(self, config: ApplicationSettings | None = None): QTimer.singleShot(100, self._validate_configured_cameras) # ------------------------------------------------------------------ UI + def _init_theme_actions(self) -> None: + """Set initial checked state for theme actions based on current app stylesheet.""" + self.action_dark_mode.setChecked(self._current_style == AppStyle.DARK) + self.action_light_mode.setChecked(self._current_style == AppStyle.SYS_DEFAULT) + + def _apply_theme(self, mode: AppStyle) -> None: + """Apply the selected theme and update menu action states.""" + app = QApplication.instance() + if mode == AppStyle.DARK: + css = qdarkstyle.load_stylesheet_pyside6() + app.setStyleSheet(css) + self.action_dark_mode.setChecked(True) + self.action_light_mode.setChecked(False) + else: + app.setStyleSheet("") # empty -> default Qt + self.action_dark_mode.setChecked(False) + self.action_light_mode.setChecked(True) + self._current_style = mode + def _setup_ui(self) -> None: central = QWidget() layout = QHBoxLayout(central) @@ -246,25 +274,43 @@ def _setup_ui(self) -> None: self._build_menus() def _build_menus(self) -> None: + # File menu file_menu = self.menuBar().addMenu("&File") + ## Save/Load config self.load_config_action = QAction("Load configuration…", self) self.load_config_action.triggered.connect(self._action_load_config) file_menu.addAction(self.load_config_action) - save_action = QAction("Save configuration", self) save_action.triggered.connect(self._action_save_config) file_menu.addAction(save_action) - save_as_action = QAction("Save configuration as…", self) save_as_action.triggered.connect(self._action_save_config_as) file_menu.addAction(save_as_action) - + ## Close file_menu.addSeparator() - exit_action = QAction("Exit", self) + exit_action = QAction("Close window", self) exit_action.triggered.connect(self.close) file_menu.addAction(exit_action) + # View menu + view_menu = self.menuBar().addMenu("&View") + appearance_menu = view_menu.addMenu("Appearance") + ## Style actions + self.action_dark_mode = QAction("Dark theme", self, checkable=True) + self.action_light_mode = QAction("System theme", self, checkable=True) + theme_group = QActionGroup(self) + theme_group.setExclusive(True) + theme_group.addAction(self.action_dark_mode) + theme_group.addAction(self.action_light_mode) + self.action_dark_mode.triggered.connect(lambda: self._apply_theme(AppStyle.DARK)) + self.action_light_mode.triggered.connect(lambda: self._apply_theme(AppStyle.SYS_DEFAULT)) + + appearance_menu.addAction(self.action_light_mode) + appearance_menu.addAction(self.action_dark_mode) + self._apply_theme(self._current_style) + self._init_theme_actions() + def _build_camera_group(self) -> QGroupBox: group = QGroupBox("Camera settings") form = QFormLayout(group) @@ -1462,12 +1508,10 @@ def _update_video_display(self, frame: np.ndarray) -> None: bytes_per_line = ch * w image = QImage(rgb.data, w, h, bytes_per_line, QImage.Format.Format_RGB888) pixmap = QPixmap.fromImage(image) - + # Scale pixmap to fit label while preserving aspect ratio scaled_pixmap = pixmap.scaled( - self.video_label.size(), - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation + self.video_label.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation ) self.video_label.setPixmap(scaled_pixmap) From cc9a4400de7a0f3e116f7ec39f22f28d8b54ffee Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 28 Jan 2026 16:09:13 +0100 Subject: [PATCH 50/69] Add qdarkstyle and new optional dependencies Added 'qdarkstyle' to main dependencies for UI theming. Introduced 'pytorch' and 'tf' optional dependency groups for deeplabcut-live with respective extras. --- pyproject.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index bb931d2..ee960cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ classifiers = [ dependencies = [ # "deeplabcut-live", # might be missing timm and scipy "PySide6", + "qdarkstyle", "numpy", "opencv-python", "vidgear[core]", @@ -38,6 +39,12 @@ dependencies = [ basler = ["pypylon"] gentl = ["harvesters"] all = ["pypylon", "harvesters"] +pytorch = [ + "deeplabcut-live[pytorch]", +] +tf = [ + "deeplabcut-live[tf]", +] dev = [ "pytest>=7.0", "pytest-cov>=4.0", From c3624c3ec3299b59166bf1e1e5cfbd2b8fb8a2e1 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 28 Jan 2026 16:33:50 +0100 Subject: [PATCH 51/69] Add inference camera selection to GUI Introduces a dropdown to select which camera is used for pose inference, replacing the previous hardcoded behavior. Updates camera label formatting and synchronizes selection between dialogs and main window. Removes unused additional options UI and ensures overlays update when the inference camera changes. --- dlclivegui/camera_config_dialog.py | 67 ++++++++++-------------------- dlclivegui/gui.py | 66 ++++++++++++++++++++++++----- 2 files changed, 78 insertions(+), 55 deletions(-) diff --git a/dlclivegui/camera_config_dialog.py b/dlclivegui/camera_config_dialog.py index 43e4efc..09245ec 100644 --- a/dlclivegui/camera_config_dialog.py +++ b/dlclivegui/camera_config_dialog.py @@ -6,7 +6,7 @@ import logging import cv2 -from PySide6.QtCore import QElapsedTimer, Qt, QThread, QTimer, Signal +from PySide6.QtCore import Qt, QThread, QTimer, Signal from PySide6.QtGui import QFont, QImage, QPixmap, QTextCursor from PySide6.QtWidgets import ( QCheckBox, @@ -109,46 +109,8 @@ def run(self): return LOGGER.debug("Creating camera backend for %s:%d", self._cam.backend, self._cam.index) - # self._backend = CameraFactory.create(self._cam) self.progress.emit("Opening device…") self.success.emit(self._cam) - return - - if self._check_cancel(): - self.canceled.emit() - return - - self._backend.open() # heavy: backend chooses/negotiates API/format/res/FPS - - self.progress.emit("Warming up stream…") - if self._check_cancel(): - self._backend.close() - self.canceled.emit() - return - - # Warmup: allow driver pipeline to stabilize (skip None frames silently) - warm_ok = False - timer = QElapsedTimer() - timer.start() - budget = 50000 # ms - while timer.elapsed() < budget and not self._cancel: - frame, _ = self._backend.read() - if frame is not None and frame.size > 0: - warm_ok = True - break - - if self._cancel: - self._backend.close() - self.canceled.emit() - return - - if not warm_ok: - # Not fatal—some cameras deliver the first frame only after UI starts polling. - self.progress.emit("Warmup yielded no frame, proceeding…") - - self.progress.emit("Camera ready.") - self.success.emit(self._backend) - # Ownership of _backend transfers to the receiver; do not close here. except Exception as exc: msg = f"{type(exc).__name__}: {exc}" @@ -178,6 +140,7 @@ def __init__( self.setWindowTitle("Configure Cameras") self.setMinimumSize(960, 720) + self.dlc_camera_id: str | None = None self._multi_camera_settings = multi_camera_settings if multi_camera_settings else MultiCameraSettings() self._detected_cameras: list[DetectedCamera] = [] self._current_edit_index: int | None = None @@ -198,6 +161,17 @@ def __init__( self._populate_from_settings() self._connect_signals() + @property + def dlc_camera_id(self) -> str | None: + """Get the currently selected DLC camera ID.""" + return self._dlc_camera_id + + @dlc_camera_id.setter + def dlc_camera_id(self, value: str | None) -> None: + """Set the currently selected DLC camera ID.""" + self._dlc_camera_id = value + self._refresh_camera_labels() + # ------------------------------- # UI setup # ------------------------------- @@ -509,15 +483,18 @@ def _populate_from_settings(self) -> None: def _format_camera_label(self, cam: CameraSettings, index: int = -1) -> str: status = "✓" if cam.enabled else "○" - dlc_indicator = " [DLC]" if index == 0 and cam.enabled else "" + this_id = f"{cam.backend}:{cam.index}" + dlc_indicator = " [DLC]" if this_id == self._dlc_camera_id and cam.enabled else "" return f"{status} {cam.name} [{cam.backend}:{cam.index}]{dlc_indicator}" def _refresh_camera_labels(self) -> None: - for i in range(self.active_cameras_list.count()): - item = self.active_cameras_list.item(i) - cam = item.data(Qt.ItemDataRole.UserRole) - if cam: - item.setText(self._format_camera_label(cam, i)) + cam_list = getattr(self, "active_cameras_list", None) + if cam_list: + for i in range(cam_list.count()): + item = cam_list.item(i) + cam = item.data(Qt.ItemDataRole.UserRole) + if cam: + item.setText(self._format_camera_label(cam, i)) def _on_backend_changed(self, _index: int) -> None: self._refresh_available_cameras() diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 68a36d7..6b16eb5 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -32,7 +32,6 @@ QLineEdit, QMainWindow, QMessageBox, - QPlainTextEdit, QPushButton, QSizePolicy, QSpinBox, @@ -77,6 +76,7 @@ def __init__(self, config: ApplicationSettings | None = None): self.setWindowTitle("DeepLabCut Live GUI") # Try to load myconfig.json from the application directory if no config provided + # FIXME @C-Achard change this behavior for release if config is None: myconfig_path = Path(__file__).parent.parent / "myconfig.json" if myconfig_path.exists(): @@ -95,6 +95,7 @@ def __init__(self, config: ApplicationSettings | None = None): self._config_path = None self._config = config + self._inference_camera_id: str | None = None # Camera ID used for inference self._current_frame: np.ndarray | None = None self._raw_frame: np.ndarray | None = None self._last_pose: PoseResult | None = None @@ -365,10 +366,13 @@ def _build_dlc_group(self) -> QGroupBox: self.processor_combo.addItem("No Processor", None) form.addRow("Processor", self.processor_combo) - self.additional_options_edit = QPlainTextEdit() - self.additional_options_edit.setPlaceholderText("") - self.additional_options_edit.setFixedHeight(40) - form.addRow("Additional options", self.additional_options_edit) + # self.additional_options_edit = QPlainTextEdit() + # self.additional_options_edit.setPlaceholderText("") + # self.additional_options_edit.setFixedHeight(40) + # form.addRow("Additional options", self.additional_options_edit) + self.dlc_camera_combo = QComboBox() + self.dlc_camera_combo.setToolTip("Select which camera to use for pose inference") + form.addRow("Inference Camera", self.dlc_camera_combo) # Wrap inference buttons in a widget to prevent shifting inference_button_widget = QWidget() @@ -523,6 +527,7 @@ def _connect_signals(self) -> None: self.dlc_processor.pose_ready.connect(self._on_pose_ready) self.dlc_processor.error.connect(self._on_dlc_error) self.dlc_processor.initialized.connect(self._on_dlc_initialised) + self.dlc_camera_combo.currentIndexChanged.connect(self._on_dlc_camera_changed) # ------------------------------------------------------------------ config def _apply_config(self, config: ApplicationSettings) -> None: @@ -532,7 +537,7 @@ def _apply_config(self, config: ApplicationSettings) -> None: dlc = config.dlc self.model_path_edit.setText(dlc.model_path) - self.additional_options_edit.setPlainText(json.dumps(dlc.additional_options, indent=2)) + # self.additional_options_edit.setPlainText(json.dumps(dlc.additional_options, indent=2)) recording = config.recording self.output_directory_edit.setText(recording.directory) @@ -559,6 +564,8 @@ def _apply_config(self, config: ApplicationSettings) -> None: self._p_cutoff = viz.p_cutoff self._colormap = viz.colormap self._bbox_color = viz.get_bbox_color_bgr() + # Update DLC camera list + self._refresh_dlc_camera_list() def _current_config(self) -> ApplicationSettings: # Get the first camera from multi-camera config for backward compatibility @@ -588,8 +595,8 @@ def _dlc_settings_from_ui(self) -> DLCProcessorSettings: dynamic=self._config.dlc.dynamic, # Preserve from config resize=self._config.dlc.resize, # Preserve from config precision=self._config.dlc.precision, # Preserve from config - model_type="pytorch", - additional_options=self._parse_json(self.additional_options_edit.toPlainText()), + model_type="pytorch", # FIXME @C-Achard hardcoded for now, we should allow tf models too + # additional_options=self._parse_json(self.additional_options_edit.toPlainText()), ) def _recording_settings_from_ui(self) -> RecordingSettings: @@ -727,6 +734,7 @@ def _open_camera_config_dialog(self) -> None: else: # Refresh its UI from current settings when reopened self._cam_dialog._populate_from_settings() + self._cam_dialog.dlc_camera_id = self._inference_camera_id self._cam_dialog.show() self._cam_dialog.raise_() @@ -736,6 +744,7 @@ def _on_multi_camera_settings_changed(self, settings: MultiCameraSettings) -> No """Handle changes to multi-camera settings.""" self._config.multi_camera = settings self._update_active_cameras_label() + self._refresh_dlc_camera_list() active_count = len(settings.get_active_cameras()) self.statusBar().showMessage(f"Camera configuration updated: {active_count} active camera(s)", 3000) @@ -784,6 +793,39 @@ def _validate_configured_cameras(self) -> None: self._show_warning("\n".join(error_lines)) logging.warning("\n".join(error_lines)) + def _refresh_dlc_camera_list(self) -> None: + """Populate the inference camera dropdown from active cameras.""" + self.dlc_camera_combo.blockSignals(True) + self.dlc_camera_combo.clear() + + active_cams = self._config.multi_camera.get_active_cameras() + for cam in active_cams: + cam_id = get_camera_id(cam) # e.g., "opencv:0" or "pylon:1" + label = f"{cam.name} [{cam.backend}:{cam.index}]" + self.dlc_camera_combo.addItem(label, cam_id) + + # Keep previous selection if still present, else default to first + if self._inference_camera_id is not None: + idx = self.dlc_camera_combo.findData(self._inference_camera_id) + if idx >= 0: + self.dlc_camera_combo.setCurrentIndex(idx) + elif self.dlc_camera_combo.count() > 0: + self.dlc_camera_combo.setCurrentIndex(0) + self._inference_camera_id = self.dlc_camera_combo.currentData() + else: + if self.dlc_camera_combo.count() > 0: + self.dlc_camera_combo.setCurrentIndex(0) + self._inference_camera_id = self.dlc_camera_combo.currentData() + + self.dlc_camera_combo.blockSignals(False) + + def _on_dlc_camera_changed(self, _index: int) -> None: + """Track user selection of the inference camera.""" + self._inference_camera_id = self.dlc_camera_combo.currentData() + # Force redraw so bbox/pose overlays switch to the new tile immediately + if self._current_frame is not None: + self._display_frame(self._current_frame, force=True) + def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: """Handle frames from multiple cameras. @@ -797,7 +839,10 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: # Determine DLC camera (first active camera) active_cams = self._config.multi_camera.get_active_cameras() - dlc_cam_id = get_camera_id(active_cams[0]) if active_cams else None + selected_id = self._inference_camera_id + fallback_id = get_camera_id(active_cams[0]) if active_cams else None + + dlc_cam_id = selected_id if selected_id in frame_data.frames else fallback_id # Check if this frame is from the DLC camera is_dlc_camera_frame = frame_data.source_camera_id == dlc_cam_id @@ -1100,7 +1145,8 @@ def _update_dlc_controls_enabled(self) -> None: self.browse_processor_folder_button, self.refresh_processors_button, self.processor_combo, - self.additional_options_edit, + # self.additional_options_edit, + self.dlc_camera_combo, ] for widget in widgets: widget.setEnabled(allow_changes) From 4eaec2b2322abae5b3c9eaf0fd11a3781a04d7a8 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 28 Jan 2026 17:05:52 +0100 Subject: [PATCH 52/69] Improve camera stats display and FPS tracking Refactored camera FPS tracking to support multiple cameras using per-camera deques and a configurable time window. Enhanced the camera stats panel to display per-camera FPS with clearer formatting, improved layout and sizing of stats widgets, and enabled text selection for stats labels. Minor UI adjustments were made for better usability and appearance. --- dlclivegui/gui.py | 103 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 73 insertions(+), 30 deletions(-) diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 6b16eb5..8fa98ca 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -59,7 +59,7 @@ from dlclivegui.video_recorder import RecorderStats, VideoRecorder # logging.basicConfig(level=logging.INFO) -logging.basicConfig(level=logging.DEBUG) +logging.basicConfig(level=logging.DEBUG) # FIXME @C-Achard set back to INFO for release # auto enum for styles @@ -101,7 +101,9 @@ def __init__(self, config: ApplicationSettings | None = None): self._last_pose: PoseResult | None = None self._dlc_active: bool = False self._active_camera_settings: CameraSettings | None = None - self._camera_frame_times: deque[float] = deque(maxlen=240) + # self._camera_frame_times: deque[float] = deque(maxlen=240) + self._camera_frame_times: dict[str, deque[float]] = {} + self._fps_window_seconds = 5.0 # seconds for fps calculation self._last_drop_warning = 0.0 self._last_recorder_summary = "Recorder idle" self._display_interval = 1.0 / 25.0 @@ -197,47 +199,62 @@ def _setup_ui(self) -> None: self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.video_label.setMinimumSize(640, 360) self.video_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - video_layout.addWidget(self.video_label) + video_layout.addWidget(self.video_label, stretch=1) # Stats panel below video with clear labels stats_widget = QWidget() stats_widget.setStyleSheet("padding: 5px;") - stats_widget.setMinimumWidth(800) # Prevent excessive line breaks + # stats_widget.setMinimumWidth(800) # Prevent excessive line breaks + stats_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + stats_widget.setMinimumHeight(80) stats_layout = QVBoxLayout(stats_widget) stats_layout.setContentsMargins(5, 5, 5, 5) stats_layout.setSpacing(3) # Camera throughput stats camera_stats_container = QHBoxLayout() + camera_stats_container.setContentsMargins(0, 0, 0, 0) + camera_stats_container.setSpacing(8) camera_stats_label_title = QLabel("Camera:") - camera_stats_container.addWidget(camera_stats_label_title) + camera_stats_label_title.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) + camera_stats_container.addWidget(camera_stats_label_title, stretch=0) self.camera_stats_label = QLabel("Camera idle") self.camera_stats_label.setWordWrap(True) - camera_stats_container.addWidget(self.camera_stats_label) - camera_stats_container.addStretch(1) + self.camera_stats_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + camera_stats_container.addWidget(self.camera_stats_label, stretch=1) + # camera_stats_container.addStretch(1) stats_layout.addLayout(camera_stats_container) # DLC processor stats dlc_stats_container = QHBoxLayout() dlc_stats_label_title = QLabel("DLC Processor:") - dlc_stats_container.addWidget(dlc_stats_label_title) + dlc_stats_container.setContentsMargins(0, 0, 0, 0) + dlc_stats_container.setSpacing(8) + dlc_stats_label_title.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) + dlc_stats_container.addWidget(dlc_stats_label_title, stretch=0) self.dlc_stats_label = QLabel("DLC processor idle") self.dlc_stats_label.setWordWrap(True) - dlc_stats_container.addWidget(self.dlc_stats_label) - dlc_stats_container.addStretch(1) + self.dlc_stats_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + dlc_stats_container.addWidget(self.dlc_stats_label, stretch=1) stats_layout.addLayout(dlc_stats_container) # Video recorder stats recorder_stats_container = QHBoxLayout() recorder_stats_label_title = QLabel("Recorder:") - recorder_stats_container.addWidget(recorder_stats_label_title) + recorder_stats_container.setContentsMargins(0, 0, 0, 0) + recorder_stats_container.setSpacing(8) + recorder_stats_label_title.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) + recorder_stats_container.addWidget(recorder_stats_label_title, stretch=0) self.recording_stats_label = QLabel("Recorder idle") self.recording_stats_label.setWordWrap(True) - recorder_stats_container.addWidget(self.recording_stats_label) - recorder_stats_container.addStretch(1) + self.recording_stats_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + recorder_stats_container.addWidget(self.recording_stats_label, stretch=1) stats_layout.addLayout(recorder_stats_container) + video_layout.addWidget(stats_widget, stretch=0) - video_layout.addWidget(stats_widget) + # Allow user to select stats text + for lbl in (self.camera_stats_label, self.dlc_stats_label, self.recording_stats_label): + lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) # Controls panel with fixed width to prevent shifting controls_widget = QWidget() @@ -269,6 +286,8 @@ def _setup_ui(self) -> None: # Add controls and video panel to main layout layout.addWidget(controls_widget, stretch=0) layout.addWidget(video_panel, stretch=1) + layout.setStretch(0, 0) + layout.setStretch(1, 1) self.setCentralWidget(central) self.setStatusBar(QStatusBar()) @@ -835,7 +854,9 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: 3. Display (lowest priority - tiled and updated on separate timer) """ self._multi_camera_frames = frame_data.frames - self._track_camera_frame() # Track FPS + src_id = frame_data.source_camera_id + if src_id: + self._track_camera_frame(src_id) # Track FPS # Determine DLC camera (first active camera) active_cams = self._config.multi_camera.get_active_cameras() @@ -1171,12 +1192,20 @@ def _update_camera_controls_enabled(self) -> None: if hasattr(self, "load_config_action"): self.load_config_action.setEnabled(allow_changes) - def _track_camera_frame(self) -> None: + def _track_camera_frame(self, camera_id: str) -> None: now = time.perf_counter() - self._camera_frame_times.append(now) - window_seconds = 5.0 - while self._camera_frame_times and now - self._camera_frame_times[0] > window_seconds: - self._camera_frame_times.popleft() + dq = self._camera_frame_times.get(camera_id) + if dq is None: + # Maxlen sized to about the highest plausible FPS * window + # e.g., 240 entries ~ 48 FPS over 5s + dq = deque(maxlen=240) + self._camera_frame_times[camera_id] = dq + dq.append(now) + + # Drop old timestamps outside window + window_seconds = self._fps_window_seconds + while dq and (now - dq[0]) > window_seconds: + dq.popleft() def _display_frame(self, frame: np.ndarray, *, force: bool = False) -> None: if frame is None: @@ -1337,25 +1366,38 @@ def _format_dlc_stats(self, stats: ProcessorStats) -> str: ) def _update_metrics(self) -> None: + # --- Camera stats --- if hasattr(self, "camera_stats_label"): running = self.multi_camera_controller.is_running() - if running: active_count = self.multi_camera_controller.get_active_count() - fps = self._compute_fps(self._camera_frame_times) - if fps > 0: - if active_count > 1: - self.camera_stats_label.setText(f"{active_count} cameras | {fps:.1f} fps (last 5 s)") + + # Build per-camera FPS list for active cameras only + active_cams = self._config.multi_camera.get_active_cameras() + lines = [] + for cam in active_cams: + cam_id = get_camera_id(cam) # e.g., "opencv:0" or "pylon:1" + dq = self._camera_frame_times.get(cam_id, deque()) + fps = self._compute_fps(dq) + # Make a compact label: name [backend:index] @ fps + label = f"{cam.name or cam_id} [{cam.backend}:{cam.index}]" + if fps > 0: + lines.append(f"{label} @ {fps:.1f} fps") else: - self.camera_stats_label.setText(f"{fps:.1f} fps (last 5 s)") + lines.append(f"{label} @ Measuring…") + + if active_count == 1: + # Single camera: show just the line + summary = lines[0] if lines else "Measuring…" else: - if active_count > 1: - self.camera_stats_label.setText(f"{active_count} cameras | Measuring…") - else: - self.camera_stats_label.setText("Measuring…") + # Multi camera: join lines with separator + summary = " | ".join(lines) + + self.camera_stats_label.setText(summary) else: self.camera_stats_label.setText("Camera idle") + # --- DLC processor stats --- if hasattr(self, "dlc_stats_label"): if self._dlc_active and self._dlc_initialized: stats = self.dlc_processor.get_stats() @@ -1368,6 +1410,7 @@ def _update_metrics(self) -> None: if hasattr(self, "processor_status_label"): self._update_processor_status() + # --- Recorder stats --- if hasattr(self, "recording_stats_label"): # Handle multi-camera recording stats if self._multi_camera_recorders: From 7e1fe886418c58f5a53c62deb4dfe957a1e41469 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 28 Jan 2026 17:11:42 +0100 Subject: [PATCH 53/69] Refactor stats layout to use QGridLayout Replaces the nested QHBoxLayout-based stats section with a QGridLayout for improved alignment and spacing of the Camera, DLC Processor, and Recorder status labels. This change simplifies the layout code and ensures better control over column stretching and widget alignment. --- dlclivegui/gui.py | 73 ++++++++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 8fa98ca..95b5a31 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -26,6 +26,7 @@ QComboBox, QFileDialog, QFormLayout, + QGridLayout, QGroupBox, QHBoxLayout, QLabel, @@ -207,49 +208,49 @@ def _setup_ui(self) -> None: # stats_widget.setMinimumWidth(800) # Prevent excessive line breaks stats_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) stats_widget.setMinimumHeight(80) - stats_layout = QVBoxLayout(stats_widget) + + stats_layout = QGridLayout(stats_widget) stats_layout.setContentsMargins(5, 5, 5, 5) - stats_layout.setSpacing(3) - - # Camera throughput stats - camera_stats_container = QHBoxLayout() - camera_stats_container.setContentsMargins(0, 0, 0, 0) - camera_stats_container.setSpacing(8) - camera_stats_label_title = QLabel("Camera:") - camera_stats_label_title.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) - camera_stats_container.addWidget(camera_stats_label_title, stretch=0) + stats_layout.setHorizontalSpacing(8) # tighten horizontal gap between title and value + stats_layout.setVerticalSpacing(3) + + row = 0 + + # Camera + title_camera = QLabel("Camera:") + title_camera.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) + stats_layout.addWidget(title_camera, row, 0, alignment=Qt.AlignTop) + self.camera_stats_label = QLabel("Camera idle") self.camera_stats_label.setWordWrap(True) - self.camera_stats_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - camera_stats_container.addWidget(self.camera_stats_label, stretch=1) - # camera_stats_container.addStretch(1) - stats_layout.addLayout(camera_stats_container) - - # DLC processor stats - dlc_stats_container = QHBoxLayout() - dlc_stats_label_title = QLabel("DLC Processor:") - dlc_stats_container.setContentsMargins(0, 0, 0, 0) - dlc_stats_container.setSpacing(8) - dlc_stats_label_title.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) - dlc_stats_container.addWidget(dlc_stats_label_title, stretch=0) + self.camera_stats_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + stats_layout.addWidget(self.camera_stats_label, row, 1, alignment=Qt.AlignTop) + row += 1 + + # DLC + title_dlc = QLabel("DLC Processor:") + title_dlc.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) + stats_layout.addWidget(title_dlc, row, 0, alignment=Qt.AlignTop) + self.dlc_stats_label = QLabel("DLC processor idle") self.dlc_stats_label.setWordWrap(True) - self.dlc_stats_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - dlc_stats_container.addWidget(self.dlc_stats_label, stretch=1) - stats_layout.addLayout(dlc_stats_container) - - # Video recorder stats - recorder_stats_container = QHBoxLayout() - recorder_stats_label_title = QLabel("Recorder:") - recorder_stats_container.setContentsMargins(0, 0, 0, 0) - recorder_stats_container.setSpacing(8) - recorder_stats_label_title.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) - recorder_stats_container.addWidget(recorder_stats_label_title, stretch=0) + self.dlc_stats_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + stats_layout.addWidget(self.dlc_stats_label, row, 1, alignment=Qt.AlignTop) + row += 1 + + # Recorder + title_rec = QLabel("Recorder:") + title_rec.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred) + stats_layout.addWidget(title_rec, row, 0, alignment=Qt.AlignTop) + self.recording_stats_label = QLabel("Recorder idle") self.recording_stats_label.setWordWrap(True) - self.recording_stats_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - recorder_stats_container.addWidget(self.recording_stats_label, stretch=1) - stats_layout.addLayout(recorder_stats_container) + self.recording_stats_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + stats_layout.addWidget(self.recording_stats_label, row, 1, alignment=Qt.AlignTop) + + # Critical: make column 1 (values) eat the width, keep column 0 tight + stats_layout.setColumnStretch(0, 0) + stats_layout.setColumnStretch(1, 1) video_layout.addWidget(stats_widget, stretch=0) # Allow user to select stats text From 5fb96f211e66013238bd4ce8e27717be8a2f6f88 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Wed, 28 Jan 2026 19:08:23 +0100 Subject: [PATCH 54/69] Add splash screen and logo assets to GUI Introduces splash screen functionality and displays a logo with version text in the preview area when cameras are not running. Adds new logo and welcome image assets, updates the main window class name to DLCLiveMainWindow, and refactors icon loading and display logic for improved branding and user experience. --- dlclivegui/__init__.py | 4 +- dlclivegui/assets/logo.png | Bin 0 -> 162713 bytes dlclivegui/assets/logo_transparent.png | Bin 0 -> 130352 bytes dlclivegui/assets/welcome.png | Bin 0 -> 160086 bytes dlclivegui/gui.py | 119 +++++++++++++++++++++++-- 5 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 dlclivegui/assets/logo.png create mode 100644 dlclivegui/assets/logo_transparent.png create mode 100644 dlclivegui/assets/welcome.png diff --git a/dlclivegui/__init__.py b/dlclivegui/__init__.py index c803be5..e4fa54c 100644 --- a/dlclivegui/__init__.py +++ b/dlclivegui/__init__.py @@ -8,7 +8,7 @@ MultiCameraSettings, RecordingSettings, ) -from .gui import MainWindow, main +from .gui import DLCLiveMainWindow, main from .multi_camera_controller import MultiCameraController, MultiFrameData __all__ = [ @@ -17,7 +17,7 @@ "DLCProcessorSettings", "MultiCameraSettings", "RecordingSettings", - "MainWindow", + "DLCLiveMainWindow", "MultiCameraController", "MultiFrameData", "CameraConfigDialog", diff --git a/dlclivegui/assets/logo.png b/dlclivegui/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ec77b4a720bdb40e82ebc9a33de18daf6c2e6240 GIT binary patch literal 162713 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 z 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ℑ1pA*;ijS_NYYZme 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�c|b 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ⅈf_ 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 E0YN3zA^-pY literal 0 HcmV?d00001 diff --git a/dlclivegui/assets/welcome.png b/dlclivegui/assets/welcome.png new file mode 100644 index 0000000000000000000000000000000000000000..9afebe0f41ac7e89ec78486196eef25990926a12 GIT binary patch literal 160086 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
7BK5*ZwaYbCv1d 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

olJ0zT!0aNEZ#jvrxXD_$mhd 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 YWp>w%q$#5xRVBc`J$5dnaw3)SFK%jQWB>pF literal 0 HcmV?d00001 diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 95b5a31..971f800 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -3,6 +3,7 @@ from __future__ import annotations import enum +import importlib.metadata import json import logging import os @@ -19,7 +20,7 @@ import numpy as np import qdarkstyle from PySide6.QtCore import Qt, QTimer -from PySide6.QtGui import QAction, QActionGroup, QCloseEvent, QImage, QPixmap +from PySide6.QtGui import QAction, QActionGroup, QCloseEvent, QFont, QIcon, QImage, QPainter, QPixmap from PySide6.QtWidgets import ( QApplication, QCheckBox, @@ -36,6 +37,7 @@ QPushButton, QSizePolicy, QSpinBox, + QSplashScreen, QStatusBar, QStyle, QVBoxLayout, @@ -62,6 +64,11 @@ # logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.DEBUG) # FIXME @C-Achard set back to INFO for release +ASSETS = Path(__file__).parent / "assets" +LOGO = str(ASSETS / "logo.png") +LOGO_ALPHA = str(ASSETS / "logo_transparent.png") +SPLASH_SCREEN = str(ASSETS / "welcome.png") + # auto enum for styles class AppStyle(enum.Enum): @@ -69,7 +76,7 @@ class AppStyle(enum.Enum): DARK = "dark" -class MainWindow(QMainWindow): +class DLCLiveMainWindow(QMainWindow): """Main application window.""" def __init__(self, config: ApplicationSettings | None = None): @@ -141,6 +148,8 @@ def __init__(self, config: ApplicationSettings | None = None): # Display flag (decoupled from frame capture for performance) self._display_dirty: bool = False + self._load_icons() + self._preview_pixmap = QPixmap(LOGO_ALPHA) self._setup_ui() self._connect_signals() self._apply_config(self._config) @@ -166,6 +175,11 @@ def __init__(self, config: ApplicationSettings | None = None): # Validate cameras from loaded config (deferred to allow window to show first) QTimer.singleShot(100, self._validate_configured_cameras) + def resizeEvent(self, event): + super().resizeEvent(event) + if not self.multi_camera_controller.is_running(): + self._show_logo_and_text() + # ------------------------------------------------------------------ UI def _init_theme_actions(self) -> None: """Set initial checked state for theme actions based on current app stylesheet.""" @@ -186,6 +200,9 @@ def _apply_theme(self, mode: AppStyle) -> None: self.action_light_mode.setChecked(True) self._current_style = mode + def _load_icons(self): + self.setWindowIcon(QIcon(LOGO)) + def _setup_ui(self) -> None: central = QWidget() layout = QHBoxLayout(central) @@ -196,7 +213,7 @@ def _setup_ui(self) -> None: video_layout.setContentsMargins(0, 0, 0, 0) # Video display widget - self.video_label = QLabel("Camera preview not started") + self.video_label = QLabel() self.video_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.video_label.setMinimumSize(640, 360) self.video_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) @@ -293,6 +310,7 @@ def _setup_ui(self) -> None: self.setCentralWidget(central) self.setStatusBar(QStatusBar()) self._build_menus() + QTimer.singleShot(0, self._show_logo_and_text) def _build_menus(self) -> None: # File menu @@ -1069,6 +1087,59 @@ def _stop_multi_camera_recording(self) -> None: self._update_camera_controls_enabled() # ------------------------------------------------------------------ camera control + def _show_logo_and_text(self): + """Show the transparent logo with text below it in the preview area when not running.""" + from PySide6.QtCore import QRect + from PySide6.QtGui import QColor + + size = self.video_label.size() + + if size.width() <= 0 or size.height() <= 0: + return + + # Prepare blank canvas (transparent) + composed = QPixmap(size) + composed.fill(Qt.transparent) + + painter = QPainter(composed) + painter.setRenderHint(QPainter.SmoothPixmapTransform) + painter.setRenderHint(QPainter.Antialiasing) + + # --- Scale logo to at most 50% height (nice proportion) --- + max_logo_height = int(size.height() * 0.45) + logo = self._preview_pixmap.scaledToHeight(max_logo_height, Qt.SmoothTransformation) + + # Center the logo horizontally + logo_x = (size.width() - logo.width()) // 2 + logo_y = int(size.height() * 0.15) # small top margin + + painter.drawPixmap(logo_x, logo_y, logo) + + # --- Draw text BELOW the logo --- + painter.setPen(QColor(255, 255, 255)) + painter.setFont(QFont("Arial", 22, QFont.Bold)) + + text = "DeepLabCut-Live! " + try: + version = importlib.metadata.version("dlclivegui") + except Exception: + version = "" + if version: + text += f"\n(v{version})" + + # Position text under the logo with a small gap + text_rect = QRect( + 0, + logo_y + logo.height() + 15, # 15px gap under logo + size.width(), + size.height() - (logo_y + logo.height() + 15), + ) + + painter.drawText(text_rect, Qt.AlignHCenter | Qt.AlignTop, text) + + painter.end() + self.video_label.setPixmap(composed) + def _start_preview(self) -> None: """Start camera preview - uses multi-camera controller for all configurations.""" active_cams = self._config.multi_camera.get_active_cameras() @@ -1121,6 +1192,7 @@ def _stop_preview(self) -> None: self._last_display_time = 0.0 if hasattr(self, "camera_stats_label"): self.camera_stats_label.setText("Camera idle") + # self._show_logo_and_text() def _configure_dlc(self) -> bool: try: @@ -1826,11 +1898,46 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha def main() -> None: - signal.signal(signal.SIGINT, signal.SIG_DFL) # Allow Ctrl+C to terminate the app + signal.signal(signal.SIGINT, signal.SIG_DFL) + + # Enable HiDPI pixmaps (optional but recommended) + QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) app = QApplication(sys.argv) - window = MainWindow() - window.show() + app.setWindowIcon(QIcon(LOGO)) + + # Load and scale splash pixmap + raw_pixmap = QPixmap(SPLASH_SCREEN) + splash_width = 600 + + if not raw_pixmap.isNull(): + aspect_ratio = raw_pixmap.width() / raw_pixmap.height() + splash_height = int(splash_width / aspect_ratio) + scaled_pixmap = raw_pixmap.scaled( + splash_width, + splash_height, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + else: + # Fallback: empty pixmap; you can also use a color fill if desired + splash_height = 400 + scaled_pixmap = QPixmap(splash_width, splash_height) + scaled_pixmap.fill(Qt.black) + + # Create splash with the *scaled* pixmap + splash = QSplashScreen(scaled_pixmap) + splash.show() + + # Let the splash breathe without blocking the event loop + def show_main(): + splash.close() + window = DLCLiveMainWindow() + window.show() + + # Show main window after 1500 ms + QTimer.singleShot(1500, show_main) + sys.exit(app.exec()) From df8c9d8292511eb9180bd30ea80400cbd278ec0f Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 29 Jan 2026 09:10:01 +0100 Subject: [PATCH 55/69] Improve camera disconnect/error handling in multi-camera recording Enhances robustness by stopping and removing recorders after write errors, updating user notifications, and refining error messages for device disconnection. Also improves thread finalization and error handling in the VideoRecorder's writer loop. --- dlclivegui/gui.py | 11 ++- dlclivegui/multi_camera_controller.py | 35 ++++---- dlclivegui/video_recorder.py | 114 ++++++++++++-------------- 3 files changed, 80 insertions(+), 80 deletions(-) diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 971f800..f5e8987 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -913,6 +913,12 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: recorder.write(frame, timestamp=timestamp) except Exception as exc: logging.warning(f"Failed to write frame for camera {cam_id}: {exc}") + try: + recorder.stop() + except Exception: + logging.exception(f"Failed to stop recorder for camera {cam_id} after write error.") + self._multi_camera_recorders.pop(cam_id, None) + self.statusBar().showMessage(f"Recording stopped for camera {cam_id} due to write error.", 5000) # PRIORITY 3: Mark display dirty (tiling done in display timer) self._display_dirty = True @@ -1001,7 +1007,8 @@ def _on_multi_camera_stopped(self) -> None: def _on_multi_camera_error(self, camera_id: str, message: str) -> None: """Handle error from a camera in multi-camera mode.""" - self._show_warning(f"Camera {camera_id} error: {message}") + self._show_warning(f"Camera {camera_id} error: {message}\nRecording stopped.") + self._stop_recording() def _on_multi_camera_initialization_failed(self, failures: list) -> None: """Handle complete failure to initialize cameras.""" @@ -1936,7 +1943,7 @@ def show_main(): window.show() # Show main window after 1500 ms - QTimer.singleShot(1500, show_main) + QTimer.singleShot(1000, show_main) sys.exit(app.exec()) diff --git a/dlclivegui/multi_camera_controller.py b/dlclivegui/multi_camera_controller.py index f9b4226..6de7f2a 100644 --- a/dlclivegui/multi_camera_controller.py +++ b/dlclivegui/multi_camera_controller.py @@ -6,7 +6,6 @@ import time from dataclasses import dataclass from threading import Event, Lock -from typing import Dict, List, Optional import cv2 import numpy as np @@ -14,7 +13,7 @@ from dlclivegui.cameras import CameraFactory from dlclivegui.cameras.base import CameraBackend -from dlclivegui.config import CameraSettings, MultiCameraSettings +from dlclivegui.config import CameraSettings LOGGER = logging.getLogger(__name__) @@ -23,10 +22,10 @@ class MultiFrameData: """Container for frames from multiple cameras.""" - frames: Dict[str, np.ndarray] # camera_id -> frame - timestamps: Dict[str, float] # camera_id -> timestamp + frames: dict[str, np.ndarray] # camera_id -> frame + timestamps: dict[str, float] # camera_id -> timestamp source_camera_id: str = "" # ID of camera that triggered this emission - tiled_frame: Optional[np.ndarray] = None # Combined tiled frame (deprecated, done in GUI) + tiled_frame: np.ndarray | None = None # Combined tiled frame (deprecated, done in GUI) class SingleCameraWorker(QObject): @@ -42,7 +41,7 @@ def __init__(self, camera_id: str, settings: CameraSettings): self._camera_id = camera_id self._settings = settings self._stop_event = Event() - self._backend: Optional[CameraBackend] = None + self._backend: CameraBackend | None = None self._max_consecutive_errors = 5 self._retry_delay = 0.1 @@ -68,7 +67,9 @@ def run(self) -> None: if frame is None or frame.size == 0: consecutive_errors += 1 if consecutive_errors >= self._max_consecutive_errors: - self.error_occurred.emit(self._camera_id, "Too many empty frames") + self.error_occurred.emit( + self._camera_id, "Too many empty frames.\nWas the device disconnected " + ) break time.sleep(self._retry_delay) continue @@ -119,15 +120,15 @@ class MultiCameraController(QObject): def __init__(self): super().__init__() - self._workers: Dict[str, SingleCameraWorker] = {} - self._threads: Dict[str, QThread] = {} - self._settings: Dict[str, CameraSettings] = {} - self._frames: Dict[str, np.ndarray] = {} - self._timestamps: Dict[str, float] = {} + self._workers: dict[str, SingleCameraWorker] = {} + self._threads: dict[str, QThread] = {} + self._settings: dict[str, CameraSettings] = {} + self._frames: dict[str, np.ndarray] = {} + self._timestamps: dict[str, float] = {} self._frame_lock = Lock() self._running = False self._started_cameras: set = set() - self._failed_cameras: Dict[str, str] = {} # camera_id -> error message + self._failed_cameras: dict[str, str] = {} # camera_id -> error message self._expected_cameras: int = 0 # Number of cameras we're trying to start def is_running(self) -> bool: @@ -138,7 +139,7 @@ def get_active_count(self) -> int: """Get the number of active cameras.""" return len(self._started_cameras) - def start(self, camera_settings: List[CameraSettings]) -> None: + def start(self, camera_settings: list[CameraSettings]) -> None: """Start multiple cameras. Parameters @@ -425,17 +426,17 @@ def _on_camera_error(self, camera_id: str, message: str) -> None: self._failed_cameras[camera_id] = message self.camera_error.emit(camera_id, message) - def get_frame(self, camera_id: str) -> Optional[np.ndarray]: + def get_frame(self, camera_id: str) -> np.ndarray | None: """Get the latest frame from a specific camera.""" with self._frame_lock: return self._frames.get(camera_id) - def get_all_frames(self) -> Dict[str, np.ndarray]: + def get_all_frames(self) -> dict[str, np.ndarray]: """Get the latest frames from all cameras.""" with self._frame_lock: return dict(self._frames) - def get_tiled_frame(self) -> Optional[np.ndarray]: + def get_tiled_frame(self) -> np.ndarray | None: """Get a tiled view of all camera frames.""" with self._frame_lock: if self._frames: diff --git a/dlclivegui/video_recorder.py b/dlclivegui/video_recorder.py index 3afa9e7..92e8b05 100644 --- a/dlclivegui/video_recorder.py +++ b/dlclivegui/video_recorder.py @@ -10,7 +10,7 @@ from collections import deque from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any import numpy as np @@ -46,21 +46,21 @@ class VideoRecorder: def __init__( self, output: Path | str, - frame_size: Optional[Tuple[int, int]] = None, - frame_rate: Optional[float] = None, + frame_size: tuple[int, int] | None = None, + frame_rate: float | None = None, codec: str = "libx264", crf: int = 23, buffer_size: int = 240, ): self._output = Path(output) - self._writer: Optional[Any] = None + self._writer: Any | None = None self._frame_size = frame_size self._frame_rate = frame_rate self._codec = codec self._crf = int(crf) self._buffer_size = max(1, int(buffer_size)) - self._queue: Optional[queue.Queue[Any]] = None - self._writer_thread: Optional[threading.Thread] = None + self._queue: queue.Queue[Any] | None = None + self._writer_thread: threading.Thread | None = None self._stop_event = threading.Event() self._stats_lock = threading.Lock() self._frames_enqueued = 0 @@ -69,9 +69,9 @@ def __init__( self._total_latency = 0.0 self._last_latency = 0.0 self._written_times: deque[float] = deque(maxlen=600) - self._encode_error: Optional[Exception] = None + self._encode_error: Exception | None = None self._last_log_time = 0.0 - self._frame_timestamps: List[float] = [] + self._frame_timestamps: list[float] = [] @property def is_running(self) -> bool: @@ -79,14 +79,12 @@ def is_running(self) -> bool: def start(self) -> None: if WriteGear is None: - raise RuntimeError( - "vidgear is required for video recording. Install it with 'pip install vidgear'." - ) + raise RuntimeError("vidgear is required for video recording. Install it with 'pip install vidgear'.") if self._writer is not None: return fps_value = float(self._frame_rate) if self._frame_rate else 30.0 - writer_kwargs: Dict[str, Any] = { + writer_kwargs: dict[str, Any] = { "compression_mode": True, "logging": False, "-input_framerate": fps_value, @@ -114,11 +112,11 @@ def start(self) -> None: ) self._writer_thread.start() - def configure_stream(self, frame_size: Tuple[int, int], frame_rate: Optional[float]) -> None: + def configure_stream(self, frame_size: tuple[int, int], frame_rate: float | None) -> None: self._frame_size = frame_size self._frame_rate = frame_rate - def write(self, frame: np.ndarray, timestamp: Optional[float] = None) -> bool: + def write(self, frame: np.ndarray, timestamp: float | None = None) -> bool: if not self.is_running or self._queue is None: return False error = self._current_error() @@ -188,7 +186,8 @@ def stop(self) -> None: try: self._queue.put_nowait(_SENTINEL) except queue.Full: - self._queue.put(_SENTINEL) + pass + # self._queue.put(_SENTINEL) if self._writer_thread is not None: self._writer_thread.join(timeout=5.0) if self._writer_thread.is_alive(): @@ -206,7 +205,7 @@ def stop(self) -> None: self._writer_thread = None self._queue = None - def get_stats(self) -> Optional[RecorderStats]: + def get_stats(self) -> RecorderStats | None: if ( self._writer is None and not self.is_running @@ -221,9 +220,7 @@ def get_stats(self) -> Optional[RecorderStats]: frames_enqueued = self._frames_enqueued frames_written = self._frames_written dropped = self._dropped_frames - avg_latency = ( - self._total_latency / self._frames_written if self._frames_written else 0.0 - ) + avg_latency = self._total_latency / self._frames_written if self._frames_written else 0.0 last_latency = self._last_latency write_fps = self._compute_write_fps_locked() buffer_seconds = queue_size * avg_latency if avg_latency > 0 else 0.0 @@ -240,47 +237,43 @@ def get_stats(self) -> Optional[RecorderStats]: def _writer_loop(self) -> None: assert self._queue is not None - while True: - try: - item = self._queue.get(timeout=0.1) - except queue.Empty: - if self._stop_event.is_set(): + try: + while True: + try: + item = self._queue.get(timeout=0.1) + except queue.Empty: + if self._stop_event.is_set(): + break + continue + if item is _SENTINEL: + self._queue.task_done() break - continue - if item is _SENTINEL: - self._queue.task_done() - break - frame = item - start = time.perf_counter() - try: - assert self._writer is not None - self._writer.write(frame) - except OSError as exc: + frame = item + start = time.perf_counter() + try: + assert self._writer is not None + self._writer.write(frame) + except OSError as exc: + with self._stats_lock: + self._encode_error = exc + logger.exception("Video encoding failed while writing frame") + self._queue.task_done() + self._stop_event.set() + break + elapsed = time.perf_counter() - start + now = time.perf_counter() with self._stats_lock: - self._encode_error = exc - logger.exception("Video encoding failed while writing frame") + self._frames_written += 1 + self._total_latency += elapsed + self._last_latency = elapsed + self._written_times.append(now) + if now - self._last_log_time >= 1.0: + self._compute_write_fps_locked() + self._queue.qsize() + self._last_log_time = now self._queue.task_done() - self._stop_event.set() - break - elapsed = time.perf_counter() - start - now = time.perf_counter() - with self._stats_lock: - self._frames_written += 1 - self._total_latency += elapsed - self._last_latency = elapsed - self._written_times.append(now) - if now - self._last_log_time >= 1.0: - fps = self._compute_write_fps_locked() - queue_size = self._queue.qsize() - # logger.info( - # "Recorder throughput: %.2f fps, latency %.2f ms, queue=%d", - # fps, - # elapsed * 1000.0, - # queue_size, - # ) - self._last_log_time = now - self._queue.task_done() - self._finalize_writer() + finally: + self._finalize_writer() def _finalize_writer(self) -> None: writer = self._writer @@ -288,6 +281,7 @@ def _finalize_writer(self) -> None: if writer is not None: try: writer.close() + time.sleep(0.2) # give some time to finalize except Exception: logger.exception("Failed to close WriteGear during finalisation") @@ -299,7 +293,7 @@ def _compute_write_fps_locked(self) -> float: return 0.0 return (len(self._written_times) - 1) / duration - def _current_error(self) -> Optional[Exception]: + def _current_error(self) -> Exception | None: with self._stats_lock: return self._encode_error @@ -310,9 +304,7 @@ def _save_timestamps(self) -> None: return # Create timestamps file path - timestamp_file = self._output.with_suffix("").with_suffix( - self._output.suffix + "_timestamps.json" - ) + timestamp_file = self._output.with_suffix("").with_suffix(self._output.suffix + "_timestamps.json") try: with self._stats_lock: From 67a0b180e59fa5b657336c737f8c3b19ceba4e47 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 29 Jan 2026 10:03:18 +0100 Subject: [PATCH 56/69] Add persistent model path and improve camera selection logic Introduces persistent storage of the last used model path using QSettings and validates model files with a new utility function. Enhances camera selection logic to handle dynamic camera availability, updates the inference camera dropdown accordingly, and improves error handling and logging. Adds dlclivegui/utils.py with is_model_file for model file validation. --- dlclivegui/gui.py | 136 ++++++++++++++++++++++---- dlclivegui/multi_camera_controller.py | 2 +- dlclivegui/utils.py | 11 +++ 3 files changed, 127 insertions(+), 22 deletions(-) create mode 100644 dlclivegui/utils.py diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index f5e8987..5316496 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -19,7 +19,7 @@ import matplotlib.pyplot as plt import numpy as np import qdarkstyle -from PySide6.QtCore import Qt, QTimer +from PySide6.QtCore import QSettings, Qt, QTimer from PySide6.QtGui import QAction, QActionGroup, QCloseEvent, QFont, QIcon, QImage, QPainter, QPixmap from PySide6.QtWidgets import ( QApplication, @@ -59,10 +59,12 @@ from dlclivegui.dlc_processor import DLCLiveProcessor, PoseResult, ProcessorStats from dlclivegui.multi_camera_controller import MultiCameraController, MultiFrameData, get_camera_id from dlclivegui.processors.processor_utils import instantiate_from_scan, scan_processor_folder +from dlclivegui.utils import is_model_file from dlclivegui.video_recorder import RecorderStats, VideoRecorder # logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.DEBUG) # FIXME @C-Achard set back to INFO for release +logger = logging.getLogger("DLCLiveGUI") ASSETS = Path(__file__).parent / "assets" LOGO = str(ASSETS / "logo.png") @@ -91,9 +93,9 @@ def __init__(self, config: ApplicationSettings | None = None): try: config = ApplicationSettings.load(str(myconfig_path)) self._config_path = myconfig_path - logging.info(f"Loaded configuration from {myconfig_path}") + logger.info(f"Loaded configuration from {myconfig_path}") except Exception as exc: - logging.warning(f"Failed to load myconfig.json: {exc}. Using default config.") + logger.warning(f"Failed to load myconfig.json: {exc}. Using default config.") config = DEFAULT_CONFIG self._config_path = None else: @@ -102,8 +104,11 @@ def __init__(self, config: ApplicationSettings | None = None): else: self._config_path = None + self.settings = QSettings("DeepLabCut", "DLCLiveGUI") + self._config = config self._inference_camera_id: str | None = None # Camera ID used for inference + self._running_cams_ids: set[str] = set() self._current_frame: np.ndarray | None = None self._raw_frame: np.ndarray | None = None self._last_pose: PoseResult | None = None @@ -573,7 +578,8 @@ def _apply_config(self, config: ApplicationSettings) -> None: self._update_active_cameras_label() dlc = config.dlc - self.model_path_edit.setText(dlc.model_path) + resolved_model_path = self._resolve_model_path(dlc.model_path) + self.model_path_edit.setText(resolved_model_path) # self.additional_options_edit.setPlainText(json.dumps(dlc.additional_options, indent=2)) @@ -625,6 +631,37 @@ def _parse_json(self, value: str) -> dict: return {} return json.loads(text) + def _load_last_model_path(self) -> str | None: + """Load and validate the last model path from OS settings.""" + last_path = self.settings.value("dlc/last_model_path") + last_path = str(last_path) if last_path else None + logger.debug(f"Loaded last model path from settings: {last_path}") + if not last_path: + return None + try: + return last_path if is_model_file(last_path) else None + except Exception: + logger.debug("Invalid last model path in settings", exc_info=True) + pass + return None # invalid or missing + + def _resolve_model_path(self, config_path: str | None) -> str: + if config_path and is_model_file(config_path): + return config_path + persisted = self._load_last_model_path() + if persisted and is_model_file(persisted): + return persisted + return "" + + def _save_last_model_path(self, path: str) -> None: + """Persist the last model path only if it looks valid.""" + try: + if path and is_model_file(path): + self.settings.setValue("dlc/last_model_path", str(Path(path))) + logger.debug(f"Persisted last model path to settings: {path}") + except Exception: + logger.debug("Ignoring invalid model path persistence", exc_info=True) + def _dlc_settings_from_ui(self) -> DLCProcessorSettings: return DLCProcessorSettings( model_path=self.model_path_edit.text().strip(), @@ -712,10 +749,11 @@ def _action_browse_model(self) -> None: self, "Select DLCLive model file", start_dir, - "Model files (*.pt *.pb);;All files (*.*)", + "Model files (*.pt *.pth *.pb);;All files (*.*)", ) if file_path: self.model_path_edit.setText(file_path) + self._save_last_model_path(file_path) def _action_browse_directory(self) -> None: directory = QFileDialog.getExistingDirectory(self, "Select output directory", str(Path.home())) @@ -755,7 +793,7 @@ def _refresh_processors(self) -> None: except Exception as e: error_msg = f"Error scanning processors: {e}" self.statusBar().showMessage(error_msg, 5000) - logging.error(error_msg) + logger.error(error_msg) self._scanned_processors = {} self._processor_keys = [] @@ -829,7 +867,38 @@ def _validate_configured_cameras(self) -> None: error_lines.append("") error_lines.append("Please check camera connections or re-enable in camera settings.") self._show_warning("\n".join(error_lines)) - logging.warning("\n".join(error_lines)) + logger.warning("\n".join(error_lines)) + + def _label_for_cam_id(self, cam_id: str) -> str: + for cam in self._config.multi_camera.get_active_cameras(): + if get_camera_id(cam) == cam_id: + return f"{cam.name} [{cam.backend}:{cam.index}]" + return cam_id + + def _refresh_dlc_camera_list_running(self) -> None: + """Populate the inference camera dropdown from currently running cameras.""" + self.dlc_camera_combo.blockSignals(True) + self.dlc_camera_combo.clear() + for cam_id in sorted(self._running_cams_ids): + self.dlc_camera_combo.addItem(self._label_for_cam_id(cam_id), cam_id) + + # Keep current selection if still present, else select first running + if self._inference_camera_id in self._running_cams_ids: + idx = self.dlc_camera_combo.findData(self._inference_camera_id) + if idx >= 0: + self.dlc_camera_combo.setCurrentIndex(idx) + elif self.dlc_camera_combo.count() > 0: + self.dlc_camera_combo.setCurrentIndex(0) + self._inference_camera_id = self.dlc_camera_combo.currentData() + self.dlc_camera_combo.blockSignals(False) + + def _set_dlc_combo_to_id(self, cam_id: str) -> None: + """Update combo selection to a given ID without firing signals.""" + self.dlc_camera_combo.blockSignals(True) + idx = self.dlc_camera_combo.findData(cam_id) + if idx >= 0: + self.dlc_camera_combo.setCurrentIndex(idx) + self.dlc_camera_combo.blockSignals(False) def _refresh_dlc_camera_list(self) -> None: """Populate the inference camera dropdown from active cameras.""" @@ -877,12 +946,29 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: if src_id: self._track_camera_frame(src_id) # Track FPS + new_running = set(frame_data.frames.keys()) + if new_running != self._running_cams_ids: + self._running_cams_ids = new_running + self._refresh_dlc_camera_list_running() + # Determine DLC camera (first active camera) - active_cams = self._config.multi_camera.get_active_cameras() selected_id = self._inference_camera_id - fallback_id = get_camera_id(active_cams[0]) if active_cams else None - - dlc_cam_id = selected_id if selected_id in frame_data.frames else fallback_id + available_ids = sorted(frame_data.frames.keys()) + if selected_id in frame_data.frames: + dlc_cam_id = selected_id + else: + dlc_cam_id = available_ids[0] if available_ids else "" + if dlc_cam_id is not None: + self._inference_camera_id = dlc_cam_id + self._set_dlc_combo_to_id(dlc_cam_id) + self.statusBar().showMessage( + f"DLC inference camera changed to {self._label_for_cam_id(dlc_cam_id)}", 3000 + ) + else: # No more cameras available + if self._dlc_active: + self._stop_inference(show_message=True) + self._display_dirty = True + return # Check if this frame is from the DLC camera is_dlc_camera_frame = frame_data.source_camera_id == dlc_cam_id @@ -912,11 +998,11 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: try: recorder.write(frame, timestamp=timestamp) except Exception as exc: - logging.warning(f"Failed to write frame for camera {cam_id}: {exc}") + logger.warning(f"Failed to write frame for camera {cam_id}: {exc}") try: recorder.stop() except Exception: - logging.exception(f"Failed to stop recorder for camera {cam_id} after write error.") + logger.exception(f"Failed to stop recorder for camera {cam_id} after write error.") self._multi_camera_recorders.pop(cam_id, None) self.statusBar().showMessage(f"Recording stopped for camera {cam_id} due to write error.", 5000) @@ -1008,6 +1094,8 @@ def _on_multi_camera_stopped(self) -> None: def _on_multi_camera_error(self, camera_id: str, message: str) -> None: """Handle error from a camera in multi-camera mode.""" self._show_warning(f"Camera {camera_id} error: {message}\nRecording stopped.") + self._refresh_dlc_camera_list_running() + # self._stop_inference() # We now gracefully switch DLC camera if needed self._stop_recording() def _on_multi_camera_initialization_failed(self, failures: list) -> None: @@ -1021,7 +1109,7 @@ def _on_multi_camera_initialization_failed(self, failures: list) -> None: error_message = "\n".join(error_lines) self._show_error(error_message) - logging.error(error_message) + logger.error(error_message) def _start_multi_camera_recording(self) -> None: """Start recording from all active cameras.""" @@ -1062,7 +1150,7 @@ def _start_multi_camera_recording(self) -> None: try: recorder.start() self._multi_camera_recorders[cam_id] = recorder - logging.info(f"Started recording camera {cam_id} to {cam_path}") + logger.info(f"Started recording camera {cam_id} to {cam_path}") except Exception as exc: self._show_error(f"Failed to start recording for camera {cam_id}: {exc}") @@ -1083,9 +1171,9 @@ def _stop_multi_camera_recording(self) -> None: for cam_id, recorder in self._multi_camera_recorders.items(): try: recorder.stop() - logging.info(f"Stopped recording camera {cam_id}") + logger.info(f"Stopped recording camera {cam_id}") except Exception as exc: - logging.warning(f"Error stopping recorder for camera {cam_id}: {exc}") + logger.warning(f"Error stopping recorder for camera {cam_id}: {exc}") self._multi_camera_recorders.clear() self.start_record_button.setEnabled(True) @@ -1225,10 +1313,11 @@ def _configure_dlc(self) -> bool: except Exception as e: error_msg = f"Failed to instantiate processor: {e}" self._show_error(error_msg) - logging.error(error_msg) + logger.error(error_msg) return False self.dlc_processor.configure(settings, processor=processor) + self._save_last_model_path(settings.model_path) return True def _update_inference_buttons(self) -> None: @@ -1575,13 +1664,13 @@ def _update_processor_status(self) -> None: self._start_recording() self.statusBar().showMessage(f"Auto-started recording: {session_name}", 3000) - logging.info(f"Auto-recording started for session: {session_name}") + logger.info(f"Auto-recording started for session: {session_name}") else: # Stop video recording if self._multi_camera_recorders: self._stop_recording() self.statusBar().showMessage("Auto-stopped recording", 3000) - logging.info("Auto-recording stopped") + logger.info("Auto-recording stopped") self._last_processor_vid_recording = current_vid_recording @@ -1655,7 +1744,7 @@ def _on_pose_ready(self, result: PoseResult) -> None: if not self._dlc_active: return self._last_pose = result - # logging.info(f"Pose result: {result.pose}, Timestamp: {result.timestamp}") + # logger.debug(f"Pose result: {result.pose}, Timestamp: {result.timestamp}") if self._current_frame is not None: self._display_frame(self._current_frame, force=True) @@ -1901,6 +1990,11 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha self.dlc_processor.shutdown() if hasattr(self, "_metrics_timer"): self._metrics_timer.stop() + + # Remember model path on exit + self._save_last_model_path(self.model_path_edit.text().strip()) + + # Close the window super().closeEvent(event) diff --git a/dlclivegui/multi_camera_controller.py b/dlclivegui/multi_camera_controller.py index 6de7f2a..da1e2d9 100644 --- a/dlclivegui/multi_camera_controller.py +++ b/dlclivegui/multi_camera_controller.py @@ -68,7 +68,7 @@ def run(self) -> None: consecutive_errors += 1 if consecutive_errors >= self._max_consecutive_errors: self.error_occurred.emit( - self._camera_id, "Too many empty frames.\nWas the device disconnected " + self._camera_id, "Too many empty frames.\nWas the device disconnected ?" ) break time.sleep(self._retry_delay) diff --git a/dlclivegui/utils.py b/dlclivegui/utils.py new file mode 100644 index 0000000..5cf54a9 --- /dev/null +++ b/dlclivegui/utils.py @@ -0,0 +1,11 @@ +from pathlib import Path + +SUPPORTED_MODELS = [".pt", ".pth", ".pb"] + + +def is_model_file(file_path: Path | str) -> bool: + if not isinstance(file_path, Path): + file_path = Path(file_path) + if not file_path.is_file(): + return False + return file_path.suffix.lower() in SUPPORTED_MODELS From fcaba09f024a0346dffb4ca7575777fafa747660 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 29 Jan 2026 11:14:01 +0100 Subject: [PATCH 57/69] Improve camera config dialog and backend handling Refactors CameraConfigDialog to use a working copy of settings, adds UI helpers for backend-specific controls, and improves preview FPS reconciliation. Enhances camera backend probing in factory.py with quick_ping and sanitized settings, and exposes actual_fps and actual_resolution in OpenCVCameraBackend. Updates main window to skip camera validation while the config dialog is active. --- dlclivegui/camera_config_dialog.py | 196 ++++++++++++++++++++------- dlclivegui/cameras/factory.py | 61 ++++++++- dlclivegui/cameras/opencv_backend.py | 17 +++ dlclivegui/gui.py | 4 + 4 files changed, 227 insertions(+), 51 deletions(-) diff --git a/dlclivegui/camera_config_dialog.py b/dlclivegui/camera_config_dialog.py index 09245ec..b055e06 100644 --- a/dlclivegui/camera_config_dialog.py +++ b/dlclivegui/camera_config_dialog.py @@ -2,7 +2,7 @@ from __future__ import annotations -import copy # NEW +import copy import logging import cv2 @@ -141,7 +141,9 @@ def __init__( self.setMinimumSize(960, 720) self.dlc_camera_id: str | None = None + # Actual/working camera settings self._multi_camera_settings = multi_camera_settings if multi_camera_settings else MultiCameraSettings() + self._working_settings = copy.deepcopy(self._multi_camera_settings) self._detected_cameras: list[DetectedCamera] = [] self._current_edit_index: int | None = None @@ -467,11 +469,13 @@ def _connect_signals(self) -> None: self.preview_btn.clicked.connect(self._toggle_preview) self.ok_btn.clicked.connect(self._on_ok_clicked) self.cancel_btn.clicked.connect(self.reject) + self.scan_started.connect(lambda _: setattr(self, "_dialog_active", True)) + self.scan_finished.connect(lambda: setattr(self, "_dialog_active", False)) def _populate_from_settings(self) -> None: """Populate the dialog from existing settings.""" self.active_cameras_list.clear() - for i, cam in enumerate(self._multi_camera_settings.cameras): + for i, cam in enumerate(self._working_settings.cameras): item = QListWidgetItem(self._format_camera_label(cam, i)) item.setData(Qt.ItemDataRole.UserRole, cam) if not cam.enabled: @@ -499,6 +503,24 @@ def _refresh_camera_labels(self) -> None: def _on_backend_changed(self, _index: int) -> None: self._refresh_available_cameras() + def _is_backend_opencv(self, backend_name: str) -> bool: + return backend_name.lower() == "opencv" + + def _update_controls_for_backend(self, backend_name: str) -> None: + # FIXME in camera backend ABC, we should have a method to query supported features + is_opencv = self._is_backend_opencv(backend_name) + self.cam_exposure.setEnabled(not is_opencv) + self.cam_gain.setEnabled(not is_opencv) + + tip = "" + if is_opencv: + tip = ( + "Exposure/Gain are not configurable via the generic OpenCV backend and " + "will be ignored by most UVC devices." + ) + self.cam_exposure.setToolTip(tip) + self.cam_gain.setToolTip(tip) + def _refresh_available_cameras(self) -> None: """Refresh the list of available cameras asynchronously.""" backend = self.backend_combo.currentData() @@ -589,11 +611,93 @@ def _on_active_camera_selected(self, row: int) -> None: if cam: self._load_camera_to_form(cam) + # ------------------------------- + # UI helpers/actions + # ------------------------------- + def _write_form_to_cam(self, cam: CameraSettings) -> None: + """Copy form values into the CameraSettings object.""" + cam.enabled = self.cam_enabled_checkbox.isChecked() + cam.fps = float(self.cam_fps.value()) + cam.exposure = int(self.cam_exposure.value()) + cam.gain = float(self.cam_gain.value()) + cam.rotation = int(self.cam_rotation.currentData() or 0) + cam.crop_x0 = int(self.cam_crop_x0.value()) + cam.crop_y0 = int(self.cam_crop_y0.value()) + cam.crop_x1 = int(self.cam_crop_x1.value()) + cam.crop_y1 = int(self.cam_crop_y1.value()) + + def _needs_preview_reopen(self, cam: CameraSettings) -> bool: + """Return True if changes require reopening the backend (non-FPS fields).""" + if not (self._preview_active and self._preview_backend): + return False + # Compare fields that cannot be applied without reopening + return any( + [ + cam.exposure != getattr(self._preview_backend.settings, "exposure", cam.exposure), + cam.gain != getattr(self._preview_backend.settings, "gain", cam.gain), + cam.rotation != getattr(self._preview_backend.settings, "rotation", cam.rotation), + (cam.crop_x0, cam.crop_y0, cam.crop_x1, cam.crop_y1) + != ( + getattr(self._preview_backend.settings, "crop_x0", cam.crop_x0), + getattr(self._preview_backend.settings, "crop_y0", cam.crop_y0), + getattr(self._preview_backend.settings, "crop_x1", cam.crop_x1), + getattr(self._preview_backend.settings, "crop_y1", cam.crop_y1), + ), + ] + ) + + def _backend_actual_fps(self) -> float | None: + """Read backend's reconciled FPS safely (prefers property, falls back to settings).""" + if not self._preview_backend: + return None + try: + # property-style attribute + actual = getattr(self._preview_backend, "actual_fps", None) + if not actual: + # reconciled settings + actual = getattr(self._preview_backend.settings, "fps", None) + return float(actual) if isinstance(actual, (int, float)) else None + except Exception: + return None + + def _adjust_preview_timer_for_fps(self, fps: float | None) -> None: + """Adjust preview cadence to match actual FPS (bounded for CPU).""" + if not self._preview_timer or not fps or fps <= 0: + return + interval_ms = max(15, int(1000.0 / min(max(fps, 1.0), 60.0))) + self._preview_timer.start(interval_ms) + + def _reconcile_fps_from_backend(self, cam: CameraSettings) -> None: + """ + Clamp UI & settings to device-supported FPS when using OpenCV. + This implements your snippet but contained in one method. + """ + if not self._is_backend_opencv(cam.backend): + return + actual = self._backend_actual_fps() + if isinstance(actual, (int, float)) and actual > 0 and abs(cam.fps - actual) > 0.5: + cam.fps = actual + self.cam_fps.setValue(actual) + self._append_status(f"[Info] FPS adjusted to device-supported ~{actual:.2f}.") + self._adjust_preview_timer_for_fps(actual) + + def _update_active_list_item(self, row: int, cam: CameraSettings) -> None: + """Refresh the active camera list row text and color.""" + item = self.active_cameras_list.item(row) + if not item: + return + item.setText(self._format_camera_label(cam, row)) + item.setData(Qt.ItemDataRole.UserRole, cam) + item.setForeground(Qt.GlobalColor.gray if not cam.enabled else Qt.GlobalColor.black) + self._refresh_camera_labels() + self._update_button_states() + def _load_camera_to_form(self, cam: CameraSettings) -> None: self.cam_enabled_checkbox.setChecked(cam.enabled) self.cam_name_label.setText(cam.name) self.cam_index_label.setText(str(cam.index)) self.cam_backend_label.setText(cam.backend) + self._update_controls_for_backend(cam.backend) self.cam_fps.setValue(cam.fps) self.cam_exposure.setValue(cam.exposure) self.cam_gain.setValue(cam.gain) @@ -657,8 +761,8 @@ def _add_selected_camera(self) -> None: gain=0.0, enabled=True, ) - self._multi_camera_settings.cameras.append(new_cam) - new_index = len(self._multi_camera_settings.cameras) - 1 + self._working_settings.cameras.append(new_cam) + new_index = len(self._working_settings.cameras) - 1 new_item = QListWidgetItem(self._format_camera_label(new_cam, new_index)) new_item.setData(Qt.ItemDataRole.UserRole, new_cam) self.active_cameras_list.addItem(new_item) @@ -671,8 +775,8 @@ def _remove_selected_camera(self) -> None: if row < 0: return self.active_cameras_list.takeItem(row) - if row < len(self._multi_camera_settings.cameras): - del self._multi_camera_settings.cameras[row] + if row < len(self._working_settings.cameras): + del self._working_settings.cameras[row] self._current_edit_index = None self._clear_settings_form() self._refresh_camera_labels() @@ -685,7 +789,7 @@ def _move_camera_up(self) -> None: item = self.active_cameras_list.takeItem(row) self.active_cameras_list.insertItem(row - 1, item) self.active_cameras_list.setCurrentRow(row - 1) - cams = self._multi_camera_settings.cameras + cams = self._working_settings.cameras cams[row], cams[row - 1] = cams[row - 1], cams[row] self._refresh_camera_labels() @@ -696,7 +800,7 @@ def _move_camera_down(self) -> None: item = self.active_cameras_list.takeItem(row) self.active_cameras_list.insertItem(row + 1, item) self.active_cameras_list.setCurrentRow(row + 1) - cams = self._multi_camera_settings.cameras + cams = self._working_settings.cameras cams[row], cams[row + 1] = cams[row + 1], cams[row] self._refresh_camera_labels() @@ -704,27 +808,30 @@ def _apply_camera_settings(self) -> None: if self._current_edit_index is None: return row = self._current_edit_index - if row < 0 or row >= len(self._multi_camera_settings.cameras): + if row < 0 or row >= len(self._working_settings.cameras): return - cam = self._multi_camera_settings.cameras[row] - cam.enabled = self.cam_enabled_checkbox.isChecked() - cam.fps = self.cam_fps.value() - cam.exposure = self.cam_exposure.value() - cam.gain = self.cam_gain.value() - cam.rotation = self.cam_rotation.currentData() or 0 - cam.crop_x0 = self.cam_crop_x0.value() - cam.crop_y0 = self.cam_crop_y0.value() - cam.crop_x1 = self.cam_crop_x1.value() - cam.crop_y1 = self.cam_crop_y1.value() - item = self.active_cameras_list.item(row) - item.setText(self._format_camera_label(cam, row)) - item.setData(Qt.ItemDataRole.UserRole, cam) - item.setForeground(Qt.GlobalColor.gray if not cam.enabled else Qt.GlobalColor.black) - self._refresh_camera_labels() - self._update_button_states() + + cam = self._working_settings.cameras[row] + + # 1) Write form to camera settings + self._write_form_to_cam(cam) + # 2) Decide if we must reopen (non-FPS changes) + must_reopen = self._needs_preview_reopen(cam) + + # 3) If preview is active: if self._preview_active: - self._stop_preview() - self._start_preview() + if must_reopen: + # Reopen only when necessary (rotation/crop/exposure/gain) + self._stop_preview() + self._start_preview() + else: + # FPS-only change: let backend reconcile & adjust cadence + self._reconcile_fps_from_backend(cam) + if not self._backend_actual_fps(): + self._append_status("[Info] FPS will reconcile automatically during preview.") + + # 4) Refresh list row + self._update_active_list_item(row, cam) def _update_button_states(self) -> None: active_row = self.active_cameras_list.currentRow() @@ -739,14 +846,11 @@ def _update_button_states(self) -> None: def _on_ok_clicked(self) -> None: self._stop_preview() - if self._multi_camera_settings.cameras: - active = self._multi_camera_settings.get_active_cameras() - if not active: - QMessageBox.warning( - self, "No Active Cameras", "Please enable at least one camera or remove all cameras." - ) - return - self.settings_changed.emit(self._multi_camera_settings) + active = self._multi_camera_settings.get_active_cameras() + if self._working_settings.cameras and not active: + QMessageBox.warning(self, "No Active Cameras", "Please enable at least one camera or remove all cameras.") + return + self.settings_changed.emit(copy.deepcopy(self._working_settings)) self.accept() def reject(self) -> None: @@ -887,26 +991,25 @@ def _on_loader_progress(self, message: str) -> None: self._append_status(message) def _on_loader_success(self, payload) -> None: - """ - Payload is either: - - CameraBackend (non-Windows path if you kept worker-open), or - - CameraSettings (Windows probe-only, open on GUI thread) - """ try: if isinstance(payload, CameraBackend): - # Legacy path: backend already opened in worker self._preview_backend = payload - elif isinstance(payload, CameraSettings): - # Windows probe path: open now on GUI thread cam_settings = payload self._append_status("Opening camera on main thread…") self._preview_backend = CameraFactory.create(cam_settings) - self._preview_backend.open() # fast now; overlay keeps UI pleasant - + self._preview_backend.open() else: raise TypeError(f"Unexpected success payload type: {type(payload)}") + # FPS reconciliation + cadence (single source of truth) + actual_fps = self._backend_actual_fps() + if isinstance(actual_fps, (int, float)) and actual_fps > 0: + self.cam_fps.setValue(actual_fps) + self._append_status(f"Camera opened at ~{actual_fps:.2f} FPS.") + self._adjust_preview_timer_for_fps(actual_fps) + + # Start preview UX self._append_status("Starting preview…") self._preview_active = True self.preview_btn.setText("Stop Preview") @@ -915,13 +1018,12 @@ def _on_loader_success(self, payload) -> None: self.preview_label.setText("Starting…") self._hide_loading_overlay() - # Start timer to update preview (~25 fps more stable on Windows) + # Timer @ ~25 fps default; cadence may be overridden above self._preview_timer = QTimer(self) self._preview_timer.timeout.connect(self._update_preview) self._preview_timer.start(40) except Exception as exc: - # If open failed here, fall back to error handling self._on_loader_error(str(exc)) def _on_loader_error(self, error: str) -> None: diff --git a/dlclivegui/cameras/factory.py b/dlclivegui/cameras/factory.py index 54b8206..4364a20 100644 --- a/dlclivegui/cameras/factory.py +++ b/dlclivegui/cameras/factory.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import importlib from collections.abc import Callable, Generator, Iterable # CHANGED from contextlib import contextmanager @@ -43,6 +44,24 @@ class DetectedCamera: } +def _sanitize_for_probe(settings: CameraSettings) -> CameraSettings: + """ + Return a light, side-effect-minimized copy of CameraSettings for availability probes. + - Zero FPS (let driver pick default) + - Keep only 'api' hint in properties, force fast_start=True + - Do not change 'enabled' + """ + probe = copy.deepcopy(settings) + probe.fps = 0.0 # don't force FPS during probe + props = probe.properties if isinstance(probe.properties, dict) else {} + api = props.get("api") + probe.properties = {} + if api is not None: + probe.properties["api"] = api + probe.properties["fast_start"] = True + return probe + + class CameraFactory: """Create camera backend instances based on configuration.""" @@ -127,6 +146,19 @@ def _canceled() -> bool: if progress_cb: progress_cb(f"Probing {backend}:{index}…") + # Prefer quick presence check first + quick_ok = None + if hasattr(backend_cls, "quick_ping"): + try: + quick_ok = bool(backend_cls.quick_ping(index)) # type: ignore[attr-defined] + except TypeError: + quick_ok = bool(backend_cls.quick_ping(index, None)) # type: ignore[attr-defined] + except Exception: + quick_ok = None + if quick_ok is False: + # Definitely not present, skip heavy open + continue + settings = CameraSettings( name=f"Probe {index}", index=index, @@ -183,7 +215,7 @@ def create(settings: CameraSettings) -> CameraBackend: @staticmethod def check_camera_available(settings: CameraSettings) -> tuple[bool, str]: - """Check if a camera is available without keeping it open.""" + """Check if a camera is present/accessible without pushing heavy settings like FPS.""" backend_name = (settings.backend or "opencv").lower() try: @@ -194,10 +226,31 @@ def check_camera_available(settings: CameraSettings) -> tuple[bool, str]: if not backend_cls.is_available(): return False, f"Backend '{backend_name}' is not available (missing drivers/packages)" + # Prefer quick presence test if the backend provides it (e.g., OpenCV.quick_ping) + if hasattr(backend_cls, "quick_ping"): + try: + with _suppress_opencv_logging(): + idx = int(settings.index) + # Most backends expose quick_ping(index [, backend_flag]) + ok = False + try: + ok = backend_cls.quick_ping(idx) # type: ignore[attr-defined] + except TypeError: + # Fallback signature with backend flag if required by the specific backend + ok = backend_cls.quick_ping(idx, None) # type: ignore[attr-defined] + if ok: + return True, "" + return False, "Device not present" + except Exception as exc: + return False, f"Quick probe failed: {exc}" + + # 2) Fallback: try a very lightweight open/close with sanitized settings try: - backend_instance = backend_cls(settings) - backend_instance.open() - backend_instance.close() + probe_settings = _sanitize_for_probe(settings) + backend_instance = backend_cls(probe_settings) + with _suppress_opencv_logging(): + backend_instance.open() + backend_instance.close() return True, "" except Exception as exc: return False, f"Camera not accessible: {exc}" diff --git a/dlclivegui/cameras/opencv_backend.py b/dlclivegui/cameras/opencv_backend.py index ffbc0d0..baefa37 100644 --- a/dlclivegui/cameras/opencv_backend.py +++ b/dlclivegui/cameras/opencv_backend.py @@ -127,6 +127,18 @@ def device_name(self) -> str: base_name = backend_name return f"{base_name} camera #{self.settings.index}" + @property + def actual_fps(self) -> float | None: + """Return the actual configured FPS, if known.""" + return self._actual_fps + + @property + def actual_resolution(self) -> tuple[int, int] | None: + """Return the actual configured resolution, if known.""" + if self._actual_width and self._actual_height: + return (self._actual_width, self._actual_height) + return None + # ---------------------------- # Internal helpers # ---------------------------- @@ -227,6 +239,8 @@ def _configure_capture(self) -> None: else: self._actual_width = int(self._capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) self._actual_height = int(self._capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0) + if self._actual_width and self._actual_height: + self.settings.properties["resolution"] = (self._actual_width, self._actual_height) # Handle mismatch quickly with a few known-good UVC fallbacks (Windows only) if platform.system() == "Windows" and self._actual_width and self._actual_height: @@ -259,8 +273,11 @@ def _configure_capture(self) -> None: else: self._actual_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0) + # Log any mismatch if self._actual_fps and requested_fps and abs(self._actual_fps - requested_fps) > 0.1: LOG.warning(f"FPS mismatch: requested {requested_fps:.2f}, got {self._actual_fps:.2f}") + + # Always reconcile the settings with what we measured/obtained if self._actual_fps: self.settings.fps = float(self._actual_fps) LOG.info(f"Camera configured with FPS: {self._actual_fps:.2f}") diff --git a/dlclivegui/gui.py b/dlclivegui/gui.py index 5316496..44140de 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui.py @@ -841,6 +841,10 @@ def _validate_configured_cameras(self) -> None: Disables unavailable cameras and shows a warning dialog. """ + if getattr(self._cam_dialog, "_dialog_active", False): + # Skip validation if camera config dialog is open + return + active_cams = self._config.multi_camera.get_active_cameras() if not active_cams: return From 8bb68e05c4cf7ece8b82277fd76a84ea9f2b5395 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 29 Jan 2026 14:06:16 +0100 Subject: [PATCH 58/69] Improve camera config dialog and OpenCV backend handling Enhances the CameraConfigDialog to better handle Enter key events, apply settings more robustly, and improve preview state management. Refines FPS reconciliation logic for OpenCV cameras, ensuring user-requested FPS is not overwritten unless actual FPS is measurable. In the OpenCV backend, replaces LOG with logger, adds more detailed debug logging, and clarifies FPS and resolution handling, including improved logging for property setting and codec negotiation. --- dlclivegui/camera_config_dialog.py | 171 +++++++++++++++++++-------- dlclivegui/cameras/opencv_backend.py | 57 +++++---- 2 files changed, 151 insertions(+), 77 deletions(-) diff --git a/dlclivegui/camera_config_dialog.py b/dlclivegui/camera_config_dialog.py index b055e06..1e9654e 100644 --- a/dlclivegui/camera_config_dialog.py +++ b/dlclivegui/camera_config_dialog.py @@ -6,8 +6,8 @@ import logging import cv2 -from PySide6.QtCore import Qt, QThread, QTimer, Signal -from PySide6.QtGui import QFont, QImage, QPixmap, QTextCursor +from PySide6.QtCore import QEvent, Qt, QThread, QTimer, Signal +from PySide6.QtGui import QFont, QImage, QKeyEvent, QPixmap, QTextCursor from PySide6.QtWidgets import ( QCheckBox, QComboBox, @@ -140,6 +140,7 @@ def __init__( self.setWindowTitle("Configure Cameras") self.setMinimumSize(960, 720) + self._dlc_camera_id = None self.dlc_camera_id: str | None = None # Actual/working camera settings self._multi_camera_settings = multi_camera_settings if multi_camera_settings else MultiCameraSettings() @@ -400,8 +401,12 @@ def _setup_ui(self) -> None: # Dialog buttons button_layout = QHBoxLayout() self.ok_btn = QPushButton("OK") + self.ok_btn.setAutoDefault(False) + self.ok_btn.setDefault(False) self.ok_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogOkButton)) self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.setAutoDefault(False) + self.cancel_btn.setDefault(False) self.cancel_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogCancelButton)) button_layout.addStretch(1) button_layout.addWidget(self.ok_btn) @@ -415,6 +420,25 @@ def _setup_ui(self) -> None: main_layout.addLayout(panels_layout) main_layout.addLayout(button_layout) + # Pressing enter on any settings field applies settings + self.cam_fps.setKeyboardTracking(False) + fields = [ + self.cam_enabled_checkbox, + self.cam_fps, + self.cam_exposure, + self.cam_gain, + self.cam_crop_x0, + self.cam_crop_y0, + self.cam_crop_x1, + self.cam_crop_y1, + ] + for field in fields: + if hasattr(field, "lineEdit"): + if hasattr(field.lineEdit(), "returnPressed"): + field.lineEdit().returnPressed.connect(self._apply_camera_settings) + if hasattr(field, "installEventFilter"): + field.installEventFilter(self) + # Maintain overlay geometry when resizing def resizeEvent(self, event): super().resizeEvent(event) @@ -422,9 +446,26 @@ def resizeEvent(self, event): self._position_loading_overlay() def eventFilter(self, obj, event): + # Keep your existing overlay resize handling if obj is self.available_cameras_list and event.type() == event.Type.Resize: if self._scan_overlay and self._scan_overlay.isVisible(): self._position_scan_overlay() + return super().eventFilter(obj, event) + + # Intercept Enter in FPS and crop spinboxes + if event.type() == QEvent.KeyPress and isinstance(event, QKeyEvent): + if event.key() in (Qt.Key_Return, Qt.Key_Enter): + if obj in (self.cam_fps, self.cam_crop_x0, self.cam_crop_y0, self.cam_crop_x1, self.cam_crop_y1): + # Commit any pending text → value + try: + obj.interpretText() + except Exception: + pass + # Apply settings to persist crop/FPS to CameraSettings + self._apply_camera_settings() + # Consume so OK isn't triggered + return True + return super().eventFilter(obj, event) def _position_scan_overlay(self) -> None: @@ -471,6 +512,10 @@ def _connect_signals(self) -> None: self.cancel_btn.clicked.connect(self.reject) self.scan_started.connect(lambda _: setattr(self, "_dialog_active", True)) self.scan_finished.connect(lambda: setattr(self, "_dialog_active", False)) + for sb in (self.cam_fps, self.cam_crop_x0, self.cam_crop_y0, self.cam_crop_x1, self.cam_crop_y1): + if hasattr(sb, "valueChanged"): + sb.valueChanged.connect(lambda _=None: self.apply_settings_btn.setEnabled(True)) + self.cam_rotation.currentIndexChanged.connect(lambda _: self.apply_settings_btn.setEnabled(True)) def _populate_from_settings(self) -> None: """Populate the dialog from existing settings.""" @@ -609,6 +654,7 @@ def _on_active_camera_selected(self, row: int) -> None: item = self.active_cameras_list.item(row) cam = item.data(Qt.ItemDataRole.UserRole) if cam: + self.apply_settings_btn.setEnabled(True) self._load_camera_to_form(cam) # ------------------------------- @@ -627,10 +673,15 @@ def _write_form_to_cam(self, cam: CameraSettings) -> None: cam.crop_y1 = int(self.cam_crop_y1.value()) def _needs_preview_reopen(self, cam: CameraSettings) -> bool: - """Return True if changes require reopening the backend (non-FPS fields).""" if not (self._preview_active and self._preview_backend): return False - # Compare fields that cannot be applied without reopening + + # FPS: for OpenCV, treat FPS changes as requiring reopen. + if self._is_backend_opencv(cam.backend): + prev_fps = getattr(self._preview_backend.settings, "fps", None) + if isinstance(prev_fps, (int, float)) and abs(cam.fps - float(prev_fps)) > 0.1: + return True + return any( [ cam.exposure != getattr(self._preview_backend.settings, "exposure", cam.exposure), @@ -647,16 +698,14 @@ def _needs_preview_reopen(self, cam: CameraSettings) -> bool: ) def _backend_actual_fps(self) -> float | None: - """Read backend's reconciled FPS safely (prefers property, falls back to settings).""" + """Return backend's actual FPS if known; for OpenCV do NOT fall back to settings.fps.""" if not self._preview_backend: return None try: - # property-style attribute actual = getattr(self._preview_backend, "actual_fps", None) - if not actual: - # reconciled settings - actual = getattr(self._preview_backend.settings, "fps", None) - return float(actual) if isinstance(actual, (int, float)) else None + if isinstance(actual, (int, float)) and actual > 0: + return float(actual) + return None except Exception: return None @@ -668,14 +717,17 @@ def _adjust_preview_timer_for_fps(self, fps: float | None) -> None: self._preview_timer.start(interval_ms) def _reconcile_fps_from_backend(self, cam: CameraSettings) -> None: - """ - Clamp UI & settings to device-supported FPS when using OpenCV. - This implements your snippet but contained in one method. - """ + """Clamp UI/settings to measured device FPS when we can actually measure it.""" if not self._is_backend_opencv(cam.backend): return + actual = self._backend_actual_fps() - if isinstance(actual, (int, float)) and actual > 0 and abs(cam.fps - actual) > 0.5: + if actual is None: + # OpenCV can't reliably report FPS; do not overwrite user's requested value. + self._append_status("[Info] OpenCV can't reliably report actual FPS; keeping requested value.") + return + + if abs(cam.fps - actual) > 0.5: cam.fps = actual self.cam_fps.setValue(actual) self._append_status(f"[Info] FPS adjusted to device-supported ~{actual:.2f}.") @@ -805,33 +857,37 @@ def _move_camera_down(self) -> None: self._refresh_camera_labels() def _apply_camera_settings(self) -> None: - if self._current_edit_index is None: - return - row = self._current_edit_index - if row < 0 or row >= len(self._working_settings.cameras): - return + try: + for sb in (self.cam_fps, self.cam_crop_x0, self.cam_crop_y0, self.cam_crop_x1, self.cam_crop_y1): + try: + sb.interpretText() + except Exception: + pass + if self._current_edit_index is None: + return + row = self._current_edit_index + if row < 0 or row >= len(self._working_settings.cameras): + return - cam = self._working_settings.cameras[row] + cam = self._working_settings.cameras[row] + self._write_form_to_cam(cam) - # 1) Write form to camera settings - self._write_form_to_cam(cam) - # 2) Decide if we must reopen (non-FPS changes) - must_reopen = self._needs_preview_reopen(cam) + must_reopen = self._needs_preview_reopen(cam) - # 3) If preview is active: - if self._preview_active: - if must_reopen: - # Reopen only when necessary (rotation/crop/exposure/gain) - self._stop_preview() - self._start_preview() - else: - # FPS-only change: let backend reconcile & adjust cadence - self._reconcile_fps_from_backend(cam) - if not self._backend_actual_fps(): - self._append_status("[Info] FPS will reconcile automatically during preview.") + if self._preview_active: + if must_reopen: + self._stop_preview() + self._start_preview() + else: + self._reconcile_fps_from_backend(cam) + if not self._backend_actual_fps(): + self._append_status("[Info] FPS will reconcile automatically during preview.") - # 4) Refresh list row - self._update_active_list_item(row, cam) + self._update_active_list_item(row, cam) + + except Exception as exc: + LOGGER.exception("Apply camera settings failed") + QMessageBox.warning(self, "Apply Settings Error", str(exc)) def _update_button_states(self) -> None: active_row = self.active_cameras_list.currentRow() @@ -846,7 +902,7 @@ def _update_button_states(self) -> None: def _on_ok_clicked(self) -> None: self._stop_preview() - active = self._multi_camera_settings.get_active_cameras() + active = self._working_settings.get_active_cameras() if self._working_settings.cameras and not active: QMessageBox.warning(self, "No Active Cameras", "Please enable at least one camera or remove all cameras.") return @@ -1002,13 +1058,6 @@ def _on_loader_success(self, payload) -> None: else: raise TypeError(f"Unexpected success payload type: {type(payload)}") - # FPS reconciliation + cadence (single source of truth) - actual_fps = self._backend_actual_fps() - if isinstance(actual_fps, (int, float)) and actual_fps > 0: - self.cam_fps.setValue(actual_fps) - self._append_status(f"Camera opened at ~{actual_fps:.2f} FPS.") - self._adjust_preview_timer_for_fps(actual_fps) - # Start preview UX self._append_status("Starting preview…") self._preview_active = True @@ -1023,26 +1072,44 @@ def _on_loader_success(self, payload) -> None: self._preview_timer.timeout.connect(self._update_preview) self._preview_timer.start(40) + # FPS reconciliation + cadence (single source of truth) + actual_fps = self._backend_actual_fps() + if actual_fps: + self._adjust_preview_timer_for_fps(actual_fps) + + self.apply_settings_btn.setEnabled(True) except Exception as exc: self._on_loader_error(str(exc)) def _on_loader_error(self, error: str) -> None: self._append_status(f"Error: {error}") - LOGGER.error(f"Failed to start preview: {error}") - QMessageBox.warning(self, "Preview Error", f"Failed to start camera preview:\n{error}") + LOGGER.exception("Failed to start preview") + self._preview_active = False + self._loading_active = False self._hide_loading_overlay() self.preview_group.setVisible(False) + self._set_preview_button_loading(False) + self._update_button_states() + QMessageBox.warning(self, "Preview Error", f"Failed to start camera preview:\n{error}") def _on_loader_canceled(self) -> None: self._append_status("Loading canceled.") self._hide_loading_overlay() - def _on_loader_finished(self) -> None: - # Reset loading state and preview button iff not already running preview + def _on_loader_finished(self): self._loading_active = False - if not self._preview_active: - self._set_preview_button_loading(False) self._loader = None + + # If preview ended successfully, ensure Stop Preview is shown + if self._preview_active: + self.preview_btn.setText("Stop Preview") + self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop)) + else: + # Otherwise show Start Preview + self.preview_btn.setText("Start Preview") + self.preview_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay)) + + # ALWAYS refresh button states self._update_button_states() # ------------------------------- diff --git a/dlclivegui/cameras/opencv_backend.py b/dlclivegui/cameras/opencv_backend.py index baefa37..3df4c91 100644 --- a/dlclivegui/cameras/opencv_backend.py +++ b/dlclivegui/cameras/opencv_backend.py @@ -12,7 +12,8 @@ from .base import CameraBackend -LOG = logging.getLogger(__name__) +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) # FIXME @C-Achard remove before release class OpenCVCameraBackend(CameraBackend): @@ -76,7 +77,7 @@ def open(self) -> None: and platform.system() == "Windows" and self._alt_index_probe ): - LOG.debug("Primary index failed; trying alternate endpoint (index+1) with same backend.") + logger.debug("Primary index failed; trying alternate endpoint (index+1) with same backend.") self._capture = self._try_open(index + 1, backend_flag) if not self._capture or not self._capture.isOpened(): @@ -87,7 +88,7 @@ def open(self) -> None: # MSMF hint for slow systems if platform.system() == "Windows" and backend_flag == getattr(cv2, "CAP_MSMF", cv2.CAP_ANY): if os.environ.get("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS") is None: - LOG.debug( + logger.debug( "MSMF selected. If open is slow, consider setting " "OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS=0 before importing cv2." ) @@ -97,7 +98,7 @@ def open(self) -> None: def read(self) -> tuple[np.ndarray | None, float]: """Robust frame read: return (None, ts) on transient failures; never raises.""" if self._capture is None: - LOG.warning("OpenCVCameraBackend.read() called before open()") + logger.warning("OpenCVCameraBackend.read() called before open()") return None, time.time() try: if not self._capture.grab(): @@ -107,7 +108,7 @@ def read(self) -> tuple[np.ndarray | None, float]: return None, time.time() return frame, time.time() except Exception as exc: - LOG.debug(f"OpenCV read transient error: {exc}") + logger.debug(f"OpenCV read transient error: {exc}") return None, time.time() def close(self) -> None: @@ -160,7 +161,7 @@ def _parse_resolution(self, resolution) -> tuple[int, int]: try: return (int(resolution[0]), int(resolution[1])) except (ValueError, TypeError): - LOG.debug(f"Invalid resolution values: {resolution}, defaulting to 720x540") + logger.debug(f"Invalid resolution values: {resolution}, defaulting to 720x540") return (720, 540) return (720, 540) @@ -169,7 +170,7 @@ def _normalize_resolution(self, width: int, height: int) -> tuple[int, int]: if platform.system() == "Windows": if (width, height) in self.UVC_FALLBACK_MODES: return (width, height) - LOG.debug(f"Normalizing unsupported resolution {width}x{height} to 1280x720 on Windows.") + logger.debug(f"Normalizing unsupported resolution {width}x{height} to 1280x720 on Windows.") return self.UVC_FALLBACK_MODES[0] return (width, height) @@ -222,13 +223,13 @@ def _configure_capture(self) -> None: # --- FOURCC (Windows benefits from setting this first) --- self._codec_str = self._read_codec_string() - LOG.info(f"Camera using codec: {self._codec_str}") + logger.info(f"Camera using codec: {self._codec_str}") if platform.system() == "Windows" and not self._mjpg_attempted: self._maybe_enable_mjpg() self._mjpg_attempted = True self._codec_str = self._read_codec_string() - LOG.info(f"Camera codec after MJPG attempt: {self._codec_str}") + logger.info(f"Camera codec after MJPG attempt: {self._codec_str}") # --- Resolution (normalize non-standard on Windows) --- req_w, req_h = self._resolution @@ -245,14 +246,14 @@ def _configure_capture(self) -> None: # Handle mismatch quickly with a few known-good UVC fallbacks (Windows only) if platform.system() == "Windows" and self._actual_width and self._actual_height: if (self._actual_width, self._actual_height) != (req_w, req_h) and not self._fast_start: - LOG.warning( + logger.warning( f"Resolution mismatch: requested {req_w}x{req_h}, got {self._actual_width}x{self._actual_height}" ) for fw, fh in self.UVC_FALLBACK_MODES: if (fw, fh) == (self._actual_width, self._actual_height): break # already at a fallback if self._set_resolution_if_needed(fw, fh, reconfigure_only=True): - LOG.info(f"Switched to supported resolution {fw}x{fh}") + logger.info(f"Switched to supported resolution {fw}x{fh}") self._actual_width, self._actual_height = fw, fh break self._resolution = (self._actual_width or req_w, self._actual_height or req_h) @@ -260,7 +261,7 @@ def _configure_capture(self) -> None: # Non-Windows: accept actual as-is self._resolution = (self._actual_width or req_w, self._actual_height or req_h) - LOG.info(f"Camera configured with resolution: {self._resolution[0]}x{self._resolution[1]}") + logger.info(f"Camera configured with resolution: {self._resolution[0]}x{self._resolution[1]}") # --- FPS --- requested_fps = float(self.settings.fps or 0.0) @@ -268,19 +269,25 @@ def _configure_capture(self) -> None: current_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0) if current_fps <= 0.0 or abs(current_fps - requested_fps) > 0.1: if not self._capture.set(cv2.CAP_PROP_FPS, requested_fps): - LOG.debug(f"Device ignored FPS set to {requested_fps:.2f}") + logger.debug(f"Device ignored FPS set to {requested_fps:.2f}") self._actual_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0) else: self._actual_fps = float(self._capture.get(cv2.CAP_PROP_FPS) or 0.0) # Log any mismatch if self._actual_fps and requested_fps and abs(self._actual_fps - requested_fps) > 0.1: - LOG.warning(f"FPS mismatch: requested {requested_fps:.2f}, got {self._actual_fps:.2f}") + logger.warning(f"FPS mismatch: requested {requested_fps:.2f}, got {self._actual_fps:.2f}") # Always reconcile the settings with what we measured/obtained if self._actual_fps: self.settings.fps = float(self._actual_fps) - LOG.info(f"Camera configured with FPS: {self._actual_fps:.2f}") + logger.info(f"Camera configured with FPS: {self._actual_fps:.2f}") + logger.debug( + "CAP_PROP_FPS requested=%s set_ok=%s get=%s", + self.settings.fps, + self._capture.set(cv2.CAP_PROP_FPS, float(self.settings.fps)), + self._capture.get(cv2.CAP_PROP_FPS), + ) # --- Extra properties (safe whitelist) --- for prop, value in self.settings.properties.items(): @@ -289,16 +296,16 @@ def _configure_capture(self) -> None: try: prop_id = int(prop) except (TypeError, ValueError): - LOG.debug(f"Ignoring non-numeric property ID: {prop}") + logger.debug(f"Ignoring non-numeric property ID: {prop}") continue if prop_id not in self.SAFE_PROP_IDS: - LOG.debug(f"Skipping unsupported/unsafe property {prop_id}") + logger.debug(f"Skipping unsupported/unsafe property {prop_id}") continue try: if not self._capture.set(prop_id, float(value)): - LOG.debug(f"Device ignored property {prop_id} -> {value}") + logger.debug(f"Device ignored property {prop_id} -> {value}") except Exception as exc: - LOG.debug(f"Failed to set property {prop_id} -> {value}: {exc}") + logger.debug(f"Failed to set property {prop_id} -> {value}: {exc}") # ---------------------------- # Lower-level helpers @@ -322,13 +329,13 @@ def _maybe_enable_mjpg(self) -> None: if self._capture.set(cv2.CAP_PROP_FOURCC, fourcc_mjpg): verify = self._read_codec_string() if verify and verify.upper().startswith("MJPG"): - LOG.info("MJPG enabled successfully.") + logger.info("MJPG enabled successfully.") else: - LOG.debug(f"MJPG set reported success, but codec is '{verify}'") + logger.debug(f"MJPG set reported success, but codec is '{verify}'") else: - LOG.debug("Device rejected MJPG FourCC set.") + logger.debug("Device rejected MJPG FourCC set.") except Exception as exc: - LOG.debug(f"MJPG enable attempt raised: {exc}") + logger.debug(f"MJPG enable attempt raised: {exc}") def _set_resolution_if_needed(self, width: int, height: int, reconfigure_only: bool = False) -> bool: """Set width/height only if different. @@ -344,9 +351,9 @@ def _set_resolution_if_needed(self, width: int, height: int, reconfigure_only: b set_w_ok = self._capture.set(cv2.CAP_PROP_FRAME_WIDTH, float(width)) set_h_ok = self._capture.set(cv2.CAP_PROP_FRAME_HEIGHT, float(height)) if not set_w_ok: - LOG.debug(f"Failed to set frame width to {width}") + logger.debug(f"Failed to set frame width to {width}") if not set_h_ok: - LOG.debug(f"Failed to set frame height to {height}") + logger.debug(f"Failed to set frame height to {height}") try: self._actual_width = int(self._capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) From 56fd47d932426ac85a90cfec18d99267c933d3ab Mon Sep 17 00:00:00 2001 From: C-Achard Date: Thu, 29 Jan 2026 18:21:00 +0100 Subject: [PATCH 59/69] Refactor GUI structure and modularize utilities Major refactor of the GUI codebase: moves main window, camera config dialog, and theme logic into a new gui/ subpackage; introduces a RecordingManager for multi-camera recording; modularizes display, stats, and utility functions into utils/; moves service logic (DLC processor, video recorder, multi-camera controller) into services/; updates imports and usage throughout. This improves maintainability, separation of concerns, and code clarity. --- dlclivegui/__init__.py | 7 +- dlclivegui/cameras/factory.py | 46 +- dlclivegui/config.py | 62 +- dlclivegui/{ => gui}/camera_config_dialog.py | 0 dlclivegui/{gui.py => gui/main_window.py} | 631 ++---------------- dlclivegui/gui/recording_manager.py | 117 ++++ dlclivegui/gui/theme.py | 31 + dlclivegui/main.py | 57 ++ dlclivegui/{ => services}/dlc_processor.py | 162 +++-- .../{ => services}/multi_camera_controller.py | 0 dlclivegui/{ => services}/video_recorder.py | 0 dlclivegui/utils.py | 11 - dlclivegui/utils/display.py | 217 ++++++ dlclivegui/utils/stats.py | 45 ++ dlclivegui/utils/utils.py | 46 ++ 15 files changed, 782 insertions(+), 650 deletions(-) rename dlclivegui/{ => gui}/camera_config_dialog.py (100%) rename dlclivegui/{gui.py => gui/main_window.py} (75%) create mode 100644 dlclivegui/gui/recording_manager.py create mode 100644 dlclivegui/gui/theme.py create mode 100644 dlclivegui/main.py rename dlclivegui/{ => services}/dlc_processor.py (73%) rename dlclivegui/{ => services}/multi_camera_controller.py (100%) rename dlclivegui/{ => services}/video_recorder.py (100%) delete mode 100644 dlclivegui/utils.py create mode 100644 dlclivegui/utils/display.py create mode 100644 dlclivegui/utils/stats.py create mode 100644 dlclivegui/utils/utils.py diff --git a/dlclivegui/__init__.py b/dlclivegui/__init__.py index e4fa54c..98c82aa 100644 --- a/dlclivegui/__init__.py +++ b/dlclivegui/__init__.py @@ -1,6 +1,5 @@ """DeepLabCut Live GUI package.""" -from .camera_config_dialog import CameraConfigDialog from .config import ( ApplicationSettings, CameraSettings, @@ -8,8 +7,10 @@ MultiCameraSettings, RecordingSettings, ) -from .gui import DLCLiveMainWindow, main -from .multi_camera_controller import MultiCameraController, MultiFrameData +from .gui.camera_config_dialog import CameraConfigDialog +from .gui.main_window import DLCLiveMainWindow +from .main import main +from .services.multi_camera_controller import MultiCameraController, MultiFrameData __all__ = [ "ApplicationSettings", diff --git a/dlclivegui/cameras/factory.py b/dlclivegui/cameras/factory.py index 4364a20..8c58b49 100644 --- a/dlclivegui/cameras/factory.py +++ b/dlclivegui/cameras/factory.py @@ -4,7 +4,7 @@ import copy import importlib -from collections.abc import Callable, Generator, Iterable # CHANGED +from collections.abc import Callable, Iterable # CHANGED from contextlib import contextmanager from dataclasses import dataclass @@ -12,19 +12,53 @@ from .base import CameraBackend +def _opencv_get_log_level(cv2): + """Return OpenCV log level using new utils.logging API when available, else legacy.""" + # Preferred (OpenCV ≥ 4.x): cv2.utils.logging.getLogLevel() + try: + return cv2.utils.logging.getLogLevel() + except Exception: + # Legacy (older OpenCV): cv2.getLogLevel() + try: + return cv2.getLogLevel() + except Exception: + return None # unknown / not supported + + +def _opencv_set_log_level(cv2, level: int): + """Set OpenCV log level using new utils.logging API when available, else legacy.""" + # Preferred (OpenCV ≥ 4.x): cv2.utils.logging.setLogLevel(level) + try: + cv2.utils.logging.setLogLevel(level) + return + except Exception: + # Legacy (older OpenCV): cv2.setLogLevel(level) + try: + cv2.setLogLevel(level) + except Exception: + pass # not supported on this build + + @contextmanager -def _suppress_opencv_logging() -> Generator[None, None, None]: - """Temporarily suppress OpenCV logging during camera probing.""" +def _suppress_opencv_logging(): + """Temporarily suppress OpenCV logging during camera probing (backwards compatible).""" try: import cv2 - old_level = cv2.getLogLevel() - cv2.setLogLevel(0) # LOG_LEVEL_SILENT + # Resolve a 'silent' level cross-version. + # In newer OpenCV it's 0 (LOG_LEVEL_SILENT). + SILENT = 0 + old_level = _opencv_get_log_level(cv2) + + _opencv_set_log_level(cv2, SILENT) try: yield finally: - cv2.setLogLevel(old_level) + # Restore if we were able to read it + if old_level is not None: + _opencv_set_log_level(cv2, int(old_level)) except ImportError: + # OpenCV not installed; nothing to suppress yield diff --git a/dlclivegui/config.py b/dlclivegui/config.py index 9e32a20..c7f862c 100644 --- a/dlclivegui/config.py +++ b/dlclivegui/config.py @@ -5,7 +5,11 @@ import json from dataclasses import asdict, dataclass, field from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any + +from PySide6.QtCore import QSettings + +from dlclivegui.utils.utils import is_model_file @dataclass @@ -25,9 +29,9 @@ class CameraSettings: max_devices: int = 3 # Maximum number of devices to probe during detection rotation: int = 0 # Rotation degrees (0, 90, 180, 270) enabled: bool = True # Whether this camera is active in multi-camera mode - properties: Dict[str, Any] = field(default_factory=dict) + properties: dict[str, Any] = field(default_factory=dict) - def apply_defaults(self) -> "CameraSettings": + def apply_defaults(self) -> CameraSettings: """Ensure fps is a positive number and validate crop settings.""" self.fps = float(self.fps) if self.fps else 30.0 @@ -39,13 +43,13 @@ def apply_defaults(self) -> "CameraSettings": self.crop_y1 = max(0, int(self.crop_y1)) if hasattr(self, "crop_y1") else 0 return self - def get_crop_region(self) -> Optional[tuple[int, int, int, int]]: + def get_crop_region(self) -> tuple[int, int, int, int] | None: """Get crop region as (x0, y0, x1, y1) or None if no cropping.""" if self.crop_x0 == 0 and self.crop_y0 == 0 and self.crop_x1 == 0 and self.crop_y1 == 0: return None return (self.crop_x0, self.crop_y0, self.crop_x1, self.crop_y1) - def copy(self) -> "CameraSettings": + def copy(self) -> CameraSettings: """Create a copy of this settings object.""" return CameraSettings( name=self.name, @@ -92,7 +96,7 @@ def remove_camera(self, index: int) -> bool: return False @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "MultiCameraSettings": + def from_dict(cls, data: dict[str, Any]) -> MultiCameraSettings: """Create MultiCameraSettings from a dictionary.""" cameras = [] for cam_data in data.get("cameras", []): @@ -105,7 +109,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "MultiCameraSettings": tile_layout=data.get("tile_layout", "auto"), ) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Convert to dictionary for serialization.""" return { "cameras": [asdict(cam) for cam in self.cameras], @@ -120,13 +124,13 @@ class DLCProcessorSettings: model_path: str = "" model_directory: str = "." # Default directory for model browser (current dir if not set) - device: Optional[str] = ( + device: str | None = ( "auto" # Device for inference (e.g., "cuda:0", "cpu"). None should be auto, but might default to cpu ) dynamic: tuple = (False, 0.5, 10) # Dynamic cropping: (enabled, margin, max_missing_frames) resize: float = 1.0 # Resize factor for input frames precision: str = "FP32" # Inference precision ("FP32", "FP16") - additional_options: Dict[str, Any] = field(default_factory=dict) + additional_options: dict[str, Any] = field(default_factory=dict) model_type: str = "pytorch" # Only PyTorch models are supported single_animal: bool = True # Only single-animal models are supported @@ -180,7 +184,7 @@ def output_path(self) -> Path: filename = name.with_suffix(f".{self.container}") return directory / filename - def writegear_options(self, fps: float) -> Dict[str, Any]: + def writegear_options(self, fps: float) -> dict[str, Any]: """Return compression parameters for WriteGear.""" fps_value = float(fps) if fps else 30.0 @@ -205,7 +209,7 @@ class ApplicationSettings: visualization: VisualizationSettings = field(default_factory=VisualizationSettings) @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "ApplicationSettings": + def from_dict(cls, data: dict[str, Any]) -> ApplicationSettings: """Create an :class:`ApplicationSettings` from a dictionary.""" camera = CameraSettings(**data.get("camera", {})).apply_defaults() @@ -247,7 +251,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "ApplicationSettings": visualization=visualization, ) - def to_dict(self) -> Dict[str, Any]: + def to_dict(self) -> dict[str, Any]: """Serialise the configuration to a dictionary.""" return { @@ -260,7 +264,7 @@ def to_dict(self) -> Dict[str, Any]: } @classmethod - def load(cls, path: Path | str) -> "ApplicationSettings": + def load(cls, path: Path | str) -> ApplicationSettings: """Load configuration from ``path``.""" file_path = Path(path).expanduser() @@ -280,3 +284,35 @@ def save(self, path: Path | str) -> None: DEFAULT_CONFIG = ApplicationSettings() + + +class ModelPathStore: + """Persist and resolve the last model path via QSettings.""" + + def __init__(self, settings: QSettings | None = None): + self._settings = settings or QSettings("DeepLabCut", "DLCLiveGUI") + + def load_last(self) -> str | None: + val = self._settings.value("dlc/last_model_path") + if not val: + return None + path = str(val) + try: + return path if is_model_file(path) else None + except Exception: + return None + + def save_if_valid(self, path: str) -> None: + try: + if path and is_model_file(path): + self._settings.setValue("dlc/last_model_path", str(Path(path))) + except Exception: + pass + + def resolve(self, config_path: str | None) -> str: + if config_path and is_model_file(config_path): + return config_path + persisted = self.load_last() + if persisted and is_model_file(persisted): + return persisted + return "" diff --git a/dlclivegui/camera_config_dialog.py b/dlclivegui/gui/camera_config_dialog.py similarity index 100% rename from dlclivegui/camera_config_dialog.py rename to dlclivegui/gui/camera_config_dialog.py diff --git a/dlclivegui/gui.py b/dlclivegui/gui/main_window.py similarity index 75% rename from dlclivegui/gui.py rename to dlclivegui/gui/main_window.py index 44140de..c6f0a7a 100644 --- a/dlclivegui/gui.py +++ b/dlclivegui/gui/main_window.py @@ -2,27 +2,20 @@ from __future__ import annotations -import enum import importlib.metadata import json import logging import os -import signal -import sys import time -from collections import deque from pathlib import Path os.environ["PYLON_CAMEMU"] = "2" import cv2 -import matplotlib.pyplot as plt import numpy as np -import qdarkstyle from PySide6.QtCore import QSettings, Qt, QTimer from PySide6.QtGui import QAction, QActionGroup, QCloseEvent, QFont, QIcon, QImage, QPainter, QPixmap from PySide6.QtWidgets import ( - QApplication, QCheckBox, QComboBox, QFileDialog, @@ -37,14 +30,12 @@ QPushButton, QSizePolicy, QSpinBox, - QSplashScreen, QStatusBar, QStyle, QVBoxLayout, QWidget, ) -from dlclivegui.camera_config_dialog import CameraConfigDialog from dlclivegui.cameras import CameraFactory from dlclivegui.config import ( DEFAULT_CONFIG, @@ -52,31 +43,25 @@ BoundingBoxSettings, CameraSettings, DLCProcessorSettings, + ModelPathStore, MultiCameraSettings, RecordingSettings, VisualizationSettings, ) -from dlclivegui.dlc_processor import DLCLiveProcessor, PoseResult, ProcessorStats -from dlclivegui.multi_camera_controller import MultiCameraController, MultiFrameData, get_camera_id +from dlclivegui.gui.camera_config_dialog import CameraConfigDialog +from dlclivegui.gui.recording_manager import RecordingManager +from dlclivegui.gui.theme import LOGO, LOGO_ALPHA, AppStyle, apply_theme from dlclivegui.processors.processor_utils import instantiate_from_scan, scan_processor_folder -from dlclivegui.utils import is_model_file -from dlclivegui.video_recorder import RecorderStats, VideoRecorder +from dlclivegui.services.dlc_processor import DLCLiveProcessor, PoseResult, ProcessorStats +from dlclivegui.services.multi_camera_controller import MultiCameraController, MultiFrameData, get_camera_id +from dlclivegui.services.video_recorder import RecorderStats +from dlclivegui.utils.display import compute_tile_info, create_tiled_frame, draw_bbox, draw_pose +from dlclivegui.utils.utils import FPSTracker # logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.DEBUG) # FIXME @C-Achard set back to INFO for release logger = logging.getLogger("DLCLiveGUI") -ASSETS = Path(__file__).parent / "assets" -LOGO = str(ASSETS / "logo.png") -LOGO_ALPHA = str(ASSETS / "logo_transparent.png") -SPLASH_SCREEN = str(ASSETS / "welcome.png") - - -# auto enum for styles -class AppStyle(enum.Enum): - SYS_DEFAULT = "system" - DARK = "dark" - class DLCLiveMainWindow(QMainWindow): """Main application window.""" @@ -105,6 +90,11 @@ def __init__(self, config: ApplicationSettings | None = None): self._config_path = None self.settings = QSettings("DeepLabCut", "DLCLiveGUI") + self._model_path_store = ModelPathStore(self.settings) + self._fps_tracker = FPSTracker() + self._rec_manager = RecordingManager() + self._dlc = DLCLiveProcessor() + self.multi_camera_controller = MultiCameraController() self._config = config self._inference_camera_id: str | None = None # Camera ID used for inference @@ -114,9 +104,6 @@ def __init__(self, config: ApplicationSettings | None = None): self._last_pose: PoseResult | None = None self._dlc_active: bool = False self._active_camera_settings: CameraSettings | None = None - # self._camera_frame_times: deque[float] = deque(maxlen=240) - self._camera_frame_times: dict[str, deque[float]] = {} - self._fps_window_seconds = 5.0 # seconds for fps calculation self._last_drop_warning = 0.0 self._last_recorder_summary = "Recorder idle" self._display_interval = 1.0 / 25.0 @@ -140,12 +127,8 @@ def __init__(self, config: ApplicationSettings | None = None): self._colormap = "hot" self._bbox_color = (0, 0, 255) # BGR: red - self.multi_camera_controller = MultiCameraController() - self.dlc_processor = DLCLiveProcessor() - # Multi-camera state self._multi_camera_mode = False - self._multi_camera_recorders: dict[str, VideoRecorder] = {} self._multi_camera_frames: dict[str, np.ndarray] = {} # DLC pose rendering info for tiled view self._dlc_tile_offset: tuple[int, int] = (0, 0) # (x, y) offset in tiled frame @@ -193,16 +176,7 @@ def _init_theme_actions(self) -> None: def _apply_theme(self, mode: AppStyle) -> None: """Apply the selected theme and update menu action states.""" - app = QApplication.instance() - if mode == AppStyle.DARK: - css = qdarkstyle.load_stylesheet_pyside6() - app.setStyleSheet(css) - self.action_dark_mode.setChecked(True) - self.action_light_mode.setChecked(False) - else: - app.setStyleSheet("") # empty -> default Qt - self.action_dark_mode.setChecked(False) - self.action_light_mode.setChecked(True) + apply_theme(mode, self.action_dark_mode, self.action_light_mode) self._current_style = mode def _load_icons(self): @@ -567,9 +541,9 @@ def _connect_signals(self) -> None: self.multi_camera_controller.camera_error.connect(self._on_multi_camera_error) self.multi_camera_controller.initialization_failed.connect(self._on_multi_camera_initialization_failed) - self.dlc_processor.pose_ready.connect(self._on_pose_ready) - self.dlc_processor.error.connect(self._on_dlc_error) - self.dlc_processor.initialized.connect(self._on_dlc_initialised) + self._dlc.pose_ready.connect(self._on_pose_ready) + self._dlc.error.connect(self._on_dlc_error) + self._dlc.initialized.connect(self._on_dlc_initialised) self.dlc_camera_combo.currentIndexChanged.connect(self._on_dlc_camera_changed) # ------------------------------------------------------------------ config @@ -578,7 +552,7 @@ def _apply_config(self, config: ApplicationSettings) -> None: self._update_active_cameras_label() dlc = config.dlc - resolved_model_path = self._resolve_model_path(dlc.model_path) + resolved_model_path = self._model_path_store.resolve(dlc.model_path) self.model_path_edit.setText(resolved_model_path) # self.additional_options_edit.setPlainText(json.dumps(dlc.additional_options, indent=2)) @@ -631,37 +605,6 @@ def _parse_json(self, value: str) -> dict: return {} return json.loads(text) - def _load_last_model_path(self) -> str | None: - """Load and validate the last model path from OS settings.""" - last_path = self.settings.value("dlc/last_model_path") - last_path = str(last_path) if last_path else None - logger.debug(f"Loaded last model path from settings: {last_path}") - if not last_path: - return None - try: - return last_path if is_model_file(last_path) else None - except Exception: - logger.debug("Invalid last model path in settings", exc_info=True) - pass - return None # invalid or missing - - def _resolve_model_path(self, config_path: str | None) -> str: - if config_path and is_model_file(config_path): - return config_path - persisted = self._load_last_model_path() - if persisted and is_model_file(persisted): - return persisted - return "" - - def _save_last_model_path(self, path: str) -> None: - """Persist the last model path only if it looks valid.""" - try: - if path and is_model_file(path): - self.settings.setValue("dlc/last_model_path", str(Path(path))) - logger.debug(f"Persisted last model path to settings: {path}") - except Exception: - logger.debug("Ignoring invalid model path persistence", exc_info=True) - def _dlc_settings_from_ui(self) -> DLCProcessorSettings: return DLCProcessorSettings( model_path=self.model_path_edit.text().strip(), @@ -753,7 +696,7 @@ def _action_browse_model(self) -> None: ) if file_path: self.model_path_edit.setText(file_path) - self._save_last_model_path(file_path) + self._model_path_store.save_if_valid(file_path) def _action_browse_directory(self) -> None: directory = QFileDialog.getExistingDirectory(self, "Select output directory", str(Path.home())) @@ -948,7 +891,7 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: self._multi_camera_frames = frame_data.frames src_id = frame_data.source_camera_id if src_id: - self._track_camera_frame(src_id) # Track FPS + self._fps_tracker.note_frame(src_id) # Track FPS new_running = set(frame_data.frames.keys()) if new_running != self._running_cams_ids: @@ -981,96 +924,23 @@ def _on_multi_frame_ready(self, frame_data: MultiFrameData) -> None: if is_dlc_camera_frame and dlc_cam_id in frame_data.frames: frame = frame_data.frames[dlc_cam_id] self._raw_frame = frame - self._update_dlc_tile_info(dlc_cam_id, frame, frame_data.frames) + self._dlc_tile_offset, self._dlc_tile_size = compute_tile_info(dlc_cam_id, frame, frame_data.frames) # PRIORITY 1: DLC processing - only enqueue when DLC camera frame arrives! if self._dlc_active and is_dlc_camera_frame and dlc_cam_id in frame_data.frames: frame = frame_data.frames[dlc_cam_id] timestamp = frame_data.timestamps.get(dlc_cam_id, time.time()) - self.dlc_processor.enqueue_frame(frame, timestamp) + self._dlc.enqueue_frame(frame, timestamp) # PRIORITY 2: Recording (queued, non-blocking) - # Only record the frame from the camera that triggered this signal to avoid - # writing duplicate timestamps when multiple cameras are running - if self._multi_camera_recorders and frame_data.source_camera_id: - cam_id = frame_data.source_camera_id - if cam_id in self._multi_camera_recorders and cam_id in frame_data.frames: - recorder = self._multi_camera_recorders[cam_id] - if recorder.is_running: - frame = frame_data.frames[cam_id] - timestamp = frame_data.timestamps.get(cam_id, time.time()) - try: - recorder.write(frame, timestamp=timestamp) - except Exception as exc: - logger.warning(f"Failed to write frame for camera {cam_id}: {exc}") - try: - recorder.stop() - except Exception: - logger.exception(f"Failed to stop recorder for camera {cam_id} after write error.") - self._multi_camera_recorders.pop(cam_id, None) - self.statusBar().showMessage(f"Recording stopped for camera {cam_id} due to write error.", 5000) + if self._rec_manager.is_active and src_id in frame_data.frames: + frame = frame_data.frames[src_id] + ts = frame_data.timestamps.get(src_id, time.time()) + self._rec_manager.write_frame(src_id, frame, ts) # PRIORITY 3: Mark display dirty (tiling done in display timer) self._display_dirty = True - def _update_dlc_tile_info(self, dlc_cam_id: str, original_frame: np.ndarray, frames: dict[str, np.ndarray]) -> None: - """Calculate tile offset and scale for drawing DLC poses on tiled frame.""" - num_cameras = len(frames) - if num_cameras == 0: - self._dlc_tile_offset = (0, 0) - self._dlc_tile_scale = (1.0, 1.0) - return - - # Get original frame dimensions - orig_h, orig_w = original_frame.shape[:2] - - # Calculate grid layout (must match _create_tiled_frame logic) - if num_cameras == 1: - rows, cols = 1, 1 - elif num_cameras == 2: - rows, cols = 1, 2 - else: - rows, cols = 2, 2 - - # Calculate tile dimensions using same logic as _create_tiled_frame - max_canvas_width = 1200 - max_canvas_height = 800 - frame_aspect = orig_w / orig_h if orig_h > 0 else 1.0 - - tile_w = max_canvas_width // cols - tile_h = max_canvas_height // rows - tile_aspect = tile_w / tile_h if tile_h > 0 else 1.0 - - if frame_aspect > tile_aspect: - tile_h = int(tile_w / frame_aspect) - else: - tile_w = int(tile_h * frame_aspect) - - tile_w = max(160, tile_w) - tile_h = max(120, tile_h) - - # Find the position of the DLC camera in the sorted camera list - sorted_cam_ids = sorted(frames.keys()) - try: - dlc_cam_idx = sorted_cam_ids.index(dlc_cam_id) - except ValueError: - dlc_cam_idx = 0 - - # Calculate grid position - row = dlc_cam_idx // cols - col = dlc_cam_idx % cols - - # Calculate offset (top-left corner of the tile) - offset_x = col * tile_w - offset_y = row * tile_h - - # Calculate scale factors (always calculate, even for single camera) - scale_x = tile_w / orig_w if orig_w > 0 else 1.0 - scale_y = tile_h / orig_h if orig_h > 0 else 1.0 - - self._dlc_tile_offset = (offset_x, offset_y) - self._dlc_tile_scale = (scale_x, scale_y) - def _on_multi_camera_started(self) -> None: """Handle all cameras started event.""" self.preview_button.setEnabled(False) @@ -1117,69 +987,20 @@ def _on_multi_camera_initialization_failed(self, failures: list) -> None: def _start_multi_camera_recording(self) -> None: """Start recording from all active cameras.""" - if self._multi_camera_recorders: - return # Already recording - recording = self._recording_settings_from_ui() - if not recording.enabled: - self._show_error("Recording is disabled in the configuration.") - return - active_cams = self._config.multi_camera.get_active_cameras() - if not active_cams: - self._show_error("No active cameras configured.") - return - - base_path = recording.output_path() - base_stem = base_path.stem - - for cam in active_cams: - cam_id = get_camera_id(cam) - # Create unique filename for each camera - cam_filename = f"{base_stem}_{cam.backend}_cam{cam.index}{base_path.suffix}" - cam_path = base_path.parent / cam_filename - - # Get frame from current frames if available - frame = self._multi_camera_frames.get(cam_id) - frame_size = (frame.shape[0], frame.shape[1]) if frame is not None else None - - recorder = VideoRecorder( - cam_path, - frame_size=frame_size, - frame_rate=float(cam.fps), - codec=recording.codec, - crf=recording.crf, - ) - - try: - recorder.start() - self._multi_camera_recorders[cam_id] = recorder - logger.info(f"Started recording camera {cam_id} to {cam_path}") - except Exception as exc: - self._show_error(f"Failed to start recording for camera {cam_id}: {exc}") + self._rec_manager.start_all(recording, active_cams, self._multi_camera_frames) - if self._multi_camera_recorders: + if self._rec_manager.is_active: self.start_record_button.setEnabled(False) self.stop_record_button.setEnabled(True) - self.statusBar().showMessage( - f"Recording {len(self._multi_camera_recorders)} camera(s) to {recording.directory}", - 5000, - ) + self.statusBar().showMessage(f"Recording {len(active_cams)} camera(s) to {recording.directory}", 5000) self._update_camera_controls_enabled() def _stop_multi_camera_recording(self) -> None: - """Stop recording from all cameras.""" - if not self._multi_camera_recorders: + if not self._rec_manager.is_active: return - - for cam_id, recorder in self._multi_camera_recorders.items(): - try: - recorder.stop() - logger.info(f"Stopped recording camera {cam_id}") - except Exception as exc: - logger.warning(f"Error stopping recorder for camera {cam_id}: {exc}") - - self._multi_camera_recorders.clear() + self._rec_manager.stop_all() self.start_record_button.setEnabled(True) self.stop_record_button.setEnabled(False) self.statusBar().showMessage("Multi-camera recording stopped", 3000) @@ -1255,7 +1076,7 @@ def _start_preview(self) -> None: self._raw_frame = None self._last_pose = None self._multi_camera_frames.clear() - self._camera_frame_times.clear() + self._fps_tracker.clear() self._last_display_time = 0.0 if hasattr(self, "camera_stats_label"): @@ -1287,7 +1108,7 @@ def _stop_preview(self) -> None: self.multi_camera_controller.stop() self._stop_inference(show_message=False) - self._camera_frame_times.clear() + self._fps_tracker.clear() self._last_display_time = 0.0 if hasattr(self, "camera_stats_label"): self.camera_stats_label.setText("Camera idle") @@ -1320,8 +1141,8 @@ def _configure_dlc(self) -> bool: logger.error(error_msg) return False - self.dlc_processor.configure(settings, processor=processor) - self._save_last_model_path(settings.model_path) + self._dlc.configure(settings, processor=processor) + self._model_path_store.save_if_valid(settings.model_path) return True def _update_inference_buttons(self) -> None: @@ -1346,7 +1167,7 @@ def _update_dlc_controls_enabled(self) -> None: widget.setEnabled(allow_changes) def _update_camera_controls_enabled(self) -> None: - multi_cam_recording = bool(self._multi_camera_recorders) + multi_cam_recording = self._rec_manager.is_active # Check if preview is running preview_running = self.multi_camera_controller.is_running() @@ -1365,21 +1186,6 @@ def _update_camera_controls_enabled(self) -> None: if hasattr(self, "load_config_action"): self.load_config_action.setEnabled(allow_changes) - def _track_camera_frame(self, camera_id: str) -> None: - now = time.perf_counter() - dq = self._camera_frame_times.get(camera_id) - if dq is None: - # Maxlen sized to about the highest plausible FPS * window - # e.g., 240 entries ~ 48 FPS over 5s - dq = deque(maxlen=240) - self._camera_frame_times[camera_id] = dq - dq.append(now) - - # Drop old timestamps outside window - window_seconds = self._fps_window_seconds - while dq and (now - dq[0]) > window_seconds: - dq.popleft() - def _display_frame(self, frame: np.ndarray, *, force: bool = False) -> None: if frame is None: return @@ -1399,96 +1205,11 @@ def _update_display_from_pending(self) -> None: self._display_dirty = False # Create tiled frame on demand (moved from camera thread for performance) - tiled = self._create_tiled_frame(self._multi_camera_frames) + tiled = create_tiled_frame(self._multi_camera_frames) if tiled is not None: self._current_frame = tiled self._update_video_display(tiled) - def _create_tiled_frame(self, frames: dict[str, np.ndarray]) -> np.ndarray: - """Create a tiled frame from camera frames for display.""" - if not frames: - return np.zeros((480, 640, 3), dtype=np.uint8) - - cam_ids = sorted(frames.keys()) - frames_list = [frames[cam_id] for cam_id in cam_ids] - num_frames = len(frames_list) - - if num_frames == 0: - return np.zeros((480, 640, 3), dtype=np.uint8) - - # Determine grid layout - if num_frames == 1: - rows, cols = 1, 1 - elif num_frames == 2: - rows, cols = 1, 2 - else: - rows, cols = 2, 2 - - # Maximum canvas size - max_canvas_width = 1200 - max_canvas_height = 800 - - # Calculate tile size based on first frame aspect ratio - first_frame = frames_list[0] - frame_h, frame_w = first_frame.shape[:2] - frame_aspect = frame_w / frame_h if frame_h > 0 else 1.0 - - tile_w = max_canvas_width // cols - tile_h = max_canvas_height // rows - tile_aspect = tile_w / tile_h if tile_h > 0 else 1.0 - - if frame_aspect > tile_aspect: - tile_h = int(tile_w / frame_aspect) - else: - tile_w = int(tile_h * frame_aspect) - - tile_w = max(160, tile_w) - tile_h = max(120, tile_h) - - # Create canvas - canvas = np.zeros((rows * tile_h, cols * tile_w, 3), dtype=np.uint8) - - # Place each frame in the grid - for idx, frame in enumerate(frames_list[: rows * cols]): - row = idx // cols - col = idx % cols - - # Ensure frame is 3-channel - if frame.ndim == 2: - frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) - elif frame.shape[2] == 4: - frame = cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR) - - # Resize to tile size - resized = cv2.resize(frame, (tile_w, tile_h)) - - # Add camera ID label - if idx < len(cam_ids): - cv2.putText( - resized, - cam_ids[idx], - (10, 30), - cv2.FONT_HERSHEY_SIMPLEX, - 0.7, - (0, 255, 0), - 2, - ) - - # Place in canvas - y_start = row * tile_h - x_start = col * tile_w - canvas[y_start : y_start + tile_h, x_start : x_start + tile_w] = resized - - return canvas - - def _compute_fps(self, times: deque[float]) -> float: - if len(times) < 2: - return 0.0 - duration = times[-1] - times[0] - if duration <= 0: - return 0.0 - return (len(times) - 1) / duration - def _format_recorder_stats(self, stats: RecorderStats) -> str: latency_ms = stats.last_latency * 1000.0 avg_ms = stats.average_latency * 1000.0 @@ -1550,8 +1271,7 @@ def _update_metrics(self) -> None: lines = [] for cam in active_cams: cam_id = get_camera_id(cam) # e.g., "opencv:0" or "pylon:1" - dq = self._camera_frame_times.get(cam_id, deque()) - fps = self._compute_fps(dq) + fps = self._fps_tracker.fps(cam_id) # Make a compact label: name [backend:index] @ fps label = f"{cam.name or cam_id} [{cam.backend}:{cam.index}]" if fps > 0: @@ -1573,7 +1293,7 @@ def _update_metrics(self) -> None: # --- DLC processor stats --- if hasattr(self, "dlc_stats_label"): if self._dlc_active and self._dlc_initialized: - stats = self.dlc_processor.get_stats() + stats = self._dlc.get_stats() summary = self._format_dlc_stats(stats) self.dlc_stats_label.setText(summary) else: @@ -1585,38 +1305,8 @@ def _update_metrics(self) -> None: # --- Recorder stats --- if hasattr(self, "recording_stats_label"): - # Handle multi-camera recording stats - if self._multi_camera_recorders: - num_recorders = len(self._multi_camera_recorders) - if num_recorders == 1: - # Single camera - show detailed stats - recorder = next(iter(self._multi_camera_recorders.values())) - stats = recorder.get_stats() - if stats: - summary = self._format_recorder_stats(stats) - else: - summary = "Recording..." - else: - # Multiple cameras - show aggregated stats with per-camera details - total_written = 0 - total_dropped = 0 - total_queue = 0 - max_latency = 0.0 - avg_latencies = [] - for recorder in self._multi_camera_recorders.values(): - stats = recorder.get_stats() - if stats: - total_written += stats.frames_written - total_dropped += stats.dropped_frames - total_queue += stats.queue_size - max_latency = max(max_latency, stats.last_latency) - avg_latencies.append(stats.average_latency) - avg_latency = sum(avg_latencies) / len(avg_latencies) if avg_latencies else 0.0 - summary = ( - f"{num_recorders} cams | {total_written} frames | " - f"latency {max_latency * 1000:.1f}ms (avg {avg_latency * 1000:.1f}ms) | " - f"queue {total_queue} | dropped {total_dropped}" - ) + if self._rec_manager.is_active: + summary = self._rec_manager.get_stats_summary() self._last_recorder_summary = summary self.recording_stats_label.setText(summary) else: @@ -1628,8 +1318,8 @@ def _update_processor_status(self) -> None: self.processor_status_label.setText("Processor: Not active") return - # Get processor instance from dlc_processor - processor = self.dlc_processor._processor + # Get processor instance from _dlc + processor = self._dlc._processor if processor is None: self.processor_status_label.setText("Processor: None loaded") @@ -1657,7 +1347,7 @@ def _update_processor_status(self) -> None: if current_vid_recording != self._last_processor_vid_recording: if current_vid_recording: # Start video recording - if not self._multi_camera_recorders: + if not self._rec_manager.is_active: # Get session name from processor session_name = getattr(processor, "session_name", "auto_session") self._auto_record_session_name = session_name @@ -1671,7 +1361,7 @@ def _update_processor_status(self) -> None: logger.info(f"Auto-recording started for session: {session_name}") else: # Stop video recording - if self._multi_camera_recorders: + if self._rec_manager.is_active: self._stop_recording() self.statusBar().showMessage("Auto-stopped recording", 3000) logger.info("Auto-recording stopped") @@ -1688,7 +1378,7 @@ def _start_inference(self) -> None: if not self._configure_dlc(): self._update_inference_buttons() return - self.dlc_processor.reset() + self._dlc.reset() self._last_pose = None self._dlc_active = True self._dlc_initialized = False @@ -1707,7 +1397,7 @@ def _stop_inference(self, show_message: bool = True) -> None: was_active = self._dlc_active self._dlc_active = False self._dlc_initialized = False - self.dlc_processor.reset() + self._dlc.reset() self._last_pose = None self._last_processor_vid_recording = False self._auto_record_session_name = None @@ -1758,14 +1448,28 @@ def _on_dlc_error(self, message: str) -> None: def _update_video_display(self, frame: np.ndarray) -> None: display_frame = frame + if self.show_predictions_checkbox.isChecked() and self._last_pose and self._last_pose.pose is not None: - display_frame = self._draw_pose(frame, self._last_pose.pose) + display_frame = draw_pose( + frame, + self._last_pose.pose, + p_cutoff=self._p_cutoff, + colormap=self._colormap, + offset=self._dlc_tile_offset, + scale=self._dlc_tile_scale, + ) - # Draw bounding box if enabled if self._bbox_enabled: - display_frame = self._draw_bbox(display_frame) + display_frame = draw_bbox( + display_frame, + (self._bbox_x0, self._bbox_y0, self._bbox_x1, self._bbox_y1), + color_bgr=self._bbox_color, + offset=self._dlc_tile_offset, + scale=self._dlc_tile_scale, + ) rgb = cv2.cvtColor(display_frame, cv2.COLOR_BGR2RGB) + h, w, ch = rgb.shape bytes_per_line = ch * w image = QImage(rgb.data, w, h, bytes_per_line, QImage.Format.Format_RGB888) @@ -1793,154 +1497,6 @@ def _on_bbox_changed(self, _value: int = 0) -> None: if self._current_frame is not None: self._display_frame(self._current_frame, force=True) - def _draw_bbox(self, frame: np.ndarray) -> np.ndarray: - """Draw bounding box on frame (on first camera tile, scaled like pose).""" - overlay = frame.copy() - - # Get tile offset and scale (same as pose rendering) - offset_x, offset_y = self._dlc_tile_offset - scale_x, scale_y = self._dlc_tile_scale - - # Get bbox coordinates in camera pixel space - x0 = self._bbox_x0 - y0 = self._bbox_y0 - x1 = self._bbox_x1 - y1 = self._bbox_y1 - - # Validate coordinates - if x0 >= x1 or y0 >= y1: - return overlay - - # Scale and offset to display coordinates - x0_scaled = int(x0 * scale_x + offset_x) - y0_scaled = int(y0 * scale_y + offset_y) - x1_scaled = int(x1 * scale_x + offset_x) - y1_scaled = int(y1 * scale_y + offset_y) - - # Clamp to frame boundaries - height, width = frame.shape[:2] - x0_scaled = max(0, min(x0_scaled, width - 1)) - y0_scaled = max(0, min(y0_scaled, height - 1)) - x1_scaled = max(x0_scaled + 1, min(x1_scaled, width)) - y1_scaled = max(y0_scaled + 1, min(y1_scaled, height)) - - # Draw rectangle with configured color - cv2.rectangle(overlay, (x0_scaled, y0_scaled), (x1_scaled, y1_scaled), self._bbox_color, 2) - - return overlay - - def _draw_pose(self, frame: np.ndarray, pose: np.ndarray) -> np.ndarray: - """Draw pose predictions on frame using colormap. - - Supports both single-animal poses (shape: num_keypoints x 3) and - multi-animal poses (shape: num_animals x num_keypoints x 3). - """ - overlay = frame.copy() - pose_arr = np.asarray(pose) - - # Get tile offset and scale for multi-camera mode - offset_x, offset_y = self._dlc_tile_offset - scale_x, scale_y = self._dlc_tile_scale - - # Calculate scaled radius for the keypoint circles - base_radius = 4 - scaled_radius = max(2, int(base_radius * min(scale_x, scale_y))) - - # Get colormap from config - cmap = plt.get_cmap(self._colormap) - - # Detect multi-animal pose: shape (num_animals, num_keypoints, 3) - # vs single-animal pose: shape (num_keypoints, 3) - if pose_arr.ndim == 3: - # Multi-animal pose - use different markers per animal - num_animals = pose_arr.shape[0] - num_keypoints = pose_arr.shape[1] - # Cycle through different marker types for each animal - marker_types = [ - cv2.MARKER_CROSS, - cv2.MARKER_TILTED_CROSS, - cv2.MARKER_STAR, - cv2.MARKER_DIAMOND, - cv2.MARKER_SQUARE, - cv2.MARKER_TRIANGLE_UP, - cv2.MARKER_TRIANGLE_DOWN, - ] - for animal_idx in range(num_animals): - marker = marker_types[animal_idx % len(marker_types)] - animal_pose = pose_arr[animal_idx] - self._draw_keypoints( - overlay, - animal_pose, - num_keypoints, - cmap, - offset_x, - offset_y, - scale_x, - scale_y, - scaled_radius, - marker=marker, - ) - else: - # Single-animal pose - use circles (marker=None) - num_keypoints = len(pose_arr) - self._draw_keypoints( - overlay, - pose_arr, - num_keypoints, - cmap, - offset_x, - offset_y, - scale_x, - scale_y, - scaled_radius, - marker=None, - ) - - return overlay - - def _draw_keypoints( - self, - overlay: np.ndarray, - keypoints: np.ndarray, - num_keypoints: int, - cmap, - offset_x: int, - offset_y: int, - scale_x: float, - scale_y: float, - radius: int, - marker: int | None = None, - ) -> None: - """Draw keypoints for a single animal on the overlay. - - Args: - marker: OpenCV marker type (e.g., cv2.MARKER_CROSS). If None, draws circles. - """ - for idx, keypoint in enumerate(keypoints): - if len(keypoint) < 2: - continue - x, y = keypoint[:2] - confidence = keypoint[2] if len(keypoint) > 2 else 1.0 - if np.isnan(x) or np.isnan(y): - continue - if confidence < self._p_cutoff: - continue - - # Apply scale and offset for tiled view - x_scaled = int(x * scale_x + offset_x) - y_scaled = int(y * scale_y + offset_y) - - # Get color from colormap (cycle through 0 to 1) - color_normalized = idx / max(num_keypoints - 1, 1) - rgba = cmap(color_normalized) - # Convert from RGBA [0, 1] to BGR [0, 255] for OpenCV - bgr_color = (int(rgba[2] * 255), int(rgba[1] * 255), int(rgba[0] * 255)) - - if marker is None: - cv2.circle(overlay, (x_scaled, y_scaled), radius, bgr_color, -1) - else: - cv2.drawMarker(overlay, (x_scaled, y_scaled), bgr_color, marker, radius * 2, 2) - def _on_dlc_initialised(self, success: bool) -> None: if success: self._dlc_initialized = True @@ -1978,10 +1534,7 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha self.multi_camera_controller.stop(wait=True) # Stop all multi-camera recorders - for recorder in self._multi_camera_recorders.values(): - if recorder.is_running: - recorder.stop() - self._multi_camera_recorders.clear() + self._rec_manager.stop_all() # Close the camera dialog if open (ensures its worker thread is canceled) if getattr(self, "_cam_dialog", None) is not None and self._cam_dialog.isVisible(): @@ -1991,60 +1544,12 @@ def closeEvent(self, event: QCloseEvent) -> None: # pragma: no cover - GUI beha pass self._cam_dialog = None - self.dlc_processor.shutdown() + self._dlc.shutdown() if hasattr(self, "_metrics_timer"): self._metrics_timer.stop() # Remember model path on exit - self._save_last_model_path(self.model_path_edit.text().strip()) + self._model_path_store.save_if_valid(self.model_path_edit.text().strip()) # Close the window super().closeEvent(event) - - -def main() -> None: - signal.signal(signal.SIGINT, signal.SIG_DFL) - - # Enable HiDPI pixmaps (optional but recommended) - QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) - - app = QApplication(sys.argv) - app.setWindowIcon(QIcon(LOGO)) - - # Load and scale splash pixmap - raw_pixmap = QPixmap(SPLASH_SCREEN) - splash_width = 600 - - if not raw_pixmap.isNull(): - aspect_ratio = raw_pixmap.width() / raw_pixmap.height() - splash_height = int(splash_width / aspect_ratio) - scaled_pixmap = raw_pixmap.scaled( - splash_width, - splash_height, - Qt.KeepAspectRatio, - Qt.SmoothTransformation, - ) - else: - # Fallback: empty pixmap; you can also use a color fill if desired - splash_height = 400 - scaled_pixmap = QPixmap(splash_width, splash_height) - scaled_pixmap.fill(Qt.black) - - # Create splash with the *scaled* pixmap - splash = QSplashScreen(scaled_pixmap) - splash.show() - - # Let the splash breathe without blocking the event loop - def show_main(): - splash.close() - window = DLCLiveMainWindow() - window.show() - - # Show main window after 1500 ms - QTimer.singleShot(1000, show_main) - - sys.exit(app.exec()) - - -if __name__ == "__main__": # pragma: no cover - manual start - main() diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py new file mode 100644 index 0000000..39a78fd --- /dev/null +++ b/dlclivegui/gui/recording_manager.py @@ -0,0 +1,117 @@ +# dlclivegui/services/recording_manager.py +from __future__ import annotations + +import logging +import time + +import numpy as np + +from dlclivegui.config import CameraSettings, RecordingSettings +from dlclivegui.services.multi_camera_controller import get_camera_id +from dlclivegui.services.video_recorder import RecorderStats, VideoRecorder + +log = logging.getLogger(__name__) + + +class RecordingManager: + """Handle multi-camera recording lifecycle and filenames.""" + + def __init__(self): + self._recorders: dict[str, VideoRecorder] = {} + + @property + def is_active(self) -> bool: + return bool(self._recorders) + + @property + def recorders(self) -> dict[str, VideoRecorder]: + return self._recorders + + def pop(self, cam_id: str, default=None) -> VideoRecorder | None: + return self._recorders.pop(cam_id, default) + + def start_all( + self, recording: RecordingSettings, active_cams: list[CameraSettings], current_frames: dict[str, np.ndarray] + ) -> None: + if self._recorders: + return + base_path = recording.output_path() + base_stem = base_path.stem + + for cam in active_cams: + cam_id = get_camera_id(cam) + cam_filename = f"{base_stem}_{cam.backend}_cam{cam.index}{base_path.suffix}" + cam_path = base_path.parent / cam_filename + frame = current_frames.get(cam_id) + frame_size = (frame.shape[0], frame.shape[1]) if frame is not None else None + recorder = VideoRecorder( + cam_path, + frame_size=frame_size, + frame_rate=float(cam.fps), + codec=recording.codec, + crf=recording.crf, + ) + try: + recorder.start() + self._recorders[cam_id] = recorder + log.info("Started recording %s -> %s", cam_id, cam_path) + except Exception as exc: + log.error("Failed to start recording for %s: %s", cam_id, exc) + + def stop_all(self) -> None: + for cam_id, rec in self._recorders.items(): + try: + rec.stop() + log.info("Stopped recording %s", cam_id) + except Exception as exc: + log.warning("Error stopping recorder for %s: %s", cam_id, exc) + self._recorders.clear() + + def write_frame(self, cam_id: str, frame: np.ndarray, timestamp: float | None = None) -> None: + rec = self._recorders.get(cam_id) + if not rec or not rec.is_running: + return + try: + rec.write(frame, timestamp=timestamp or time.time()) + except Exception as exc: + log.warning("Failed to write frame for %s: %s", cam_id, exc) + try: + rec.stop() + except Exception: + log.exception("Failed to stop recorder for %s after write error.") + self._recorders.pop(cam_id, None) + + def get_stats_summary(self) -> str: + # Aggregate stats across recorders + totals = { + "written": 0, + "dropped": 0, + "queue": 0, + "max_latency": 0.0, + "avg_latencies": [], + } + for rec in self._recorders.values(): + stats: RecorderStats | None = rec.get_stats() + if not stats: + continue + totals["written"] += stats.frames_written + totals["dropped"] += stats.dropped_frames + totals["queue"] += stats.queue_size + totals["max_latency"] = max(totals["max_latency"], stats.last_latency) + totals["avg_latencies"].append(stats.average_latency) + + if len(self._recorders) == 1: + rec = next(iter(self._recorders.values())) + stats = rec.get_stats() + if stats: + from dlclivegui.utils.stats import format_recorder_stats + + return format_recorder_stats(stats) + return "Recording..." + else: + avg = sum(totals["avg_latencies"]) / len(totals["avg_latencies"]) if totals["avg_latencies"] else 0.0 + return ( + f"{len(self._recorders)} cams | {totals['written']} frames | " + f"latency {totals['max_latency'] * 1000:.1f}ms (avg {avg * 1000:.1f}ms) | " + f"queue {totals['queue']} | dropped {totals['dropped']}" + ) diff --git a/dlclivegui/gui/theme.py b/dlclivegui/gui/theme.py new file mode 100644 index 0000000..949a105 --- /dev/null +++ b/dlclivegui/gui/theme.py @@ -0,0 +1,31 @@ +# dlclivegui/utils/theme.py +from __future__ import annotations + +import enum +from pathlib import Path + +import qdarkstyle +from PySide6.QtGui import QAction +from PySide6.QtWidgets import QApplication + +ASSETS = Path(__file__).parent.parent / "assets" +LOGO = str(ASSETS / "logo.png") +LOGO_ALPHA = str(ASSETS / "logo_transparent.png") +SPLASH_SCREEN = str(ASSETS / "welcome.png") + + +class AppStyle(enum.Enum): + SYS_DEFAULT = "system" + DARK = "dark" + + +def apply_theme(mode: AppStyle, action_dark: QAction, action_light: QAction) -> None: + app = QApplication.instance() + if mode == AppStyle.DARK: + app.setStyleSheet(qdarkstyle.load_stylesheet_pyside6()) + action_dark.setChecked(True) + action_light.setChecked(False) + else: + app.setStyleSheet("") + action_dark.setChecked(False) + action_light.setChecked(True) diff --git a/dlclivegui/main.py b/dlclivegui/main.py new file mode 100644 index 0000000..b3a16c4 --- /dev/null +++ b/dlclivegui/main.py @@ -0,0 +1,57 @@ +import signal +import sys + +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QIcon, QPixmap +from PySide6.QtWidgets import QApplication, QSplashScreen + +from dlclivegui.gui.main_window import DLCLiveMainWindow +from dlclivegui.gui.theme import LOGO, SPLASH_SCREEN + + +def main() -> None: + signal.signal(signal.SIGINT, signal.SIG_DFL) + + # Enable HiDPI pixmaps (optional but recommended) + QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) + + app = QApplication(sys.argv) + app.setWindowIcon(QIcon(LOGO)) + + # Load and scale splash pixmap + raw_pixmap = QPixmap(SPLASH_SCREEN) + splash_width = 600 + + if not raw_pixmap.isNull(): + aspect_ratio = raw_pixmap.width() / raw_pixmap.height() + splash_height = int(splash_width / aspect_ratio) + scaled_pixmap = raw_pixmap.scaled( + splash_width, + splash_height, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + else: + # Fallback: empty pixmap; you can also use a color fill if desired + splash_height = 400 + scaled_pixmap = QPixmap(splash_width, splash_height) + scaled_pixmap.fill(Qt.black) + + # Create splash with the *scaled* pixmap + splash = QSplashScreen(scaled_pixmap) + splash.show() + + # Let the splash breathe without blocking the event loop + def show_main(): + splash.close() + window = DLCLiveMainWindow() + window.show() + + # Show main window after 1500 ms + QTimer.singleShot(1000, show_main) + + sys.exit(app.exec()) + + +if __name__ == "__main__": # pragma: no cover - manual start + main() diff --git a/dlclivegui/dlc_processor.py b/dlclivegui/services/dlc_processor.py similarity index 73% rename from dlclivegui/dlc_processor.py rename to dlclivegui/services/dlc_processor.py index e5eb7d2..9f85b22 100644 --- a/dlclivegui/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -7,15 +7,16 @@ import threading import time from collections import deque -from dataclasses import dataclass, field -from typing import Any, Optional +from dataclasses import dataclass +from typing import Any import numpy as np from PySide6.QtCore import QObject, Signal from dlclivegui.config import DLCProcessorSettings +from dlclivegui.processors.processor_utils import instantiate_from_scan -LOGGER = logging.getLogger(__name__) +logger = logging.getLogger(__name__) # Enable profiling ENABLE_PROFILING = True @@ -23,13 +24,13 @@ try: # pragma: no cover - optional dependency from dlclive import DLCLive # type: ignore except Exception as e: # pragma: no cover - handled gracefully - LOGGER.error(f"dlclive package could not be imported: {e}") + logger.error(f"dlclive package could not be imported: {e}") DLCLive = None # type: ignore[assignment] @dataclass class PoseResult: - pose: Optional[np.ndarray] + pose: np.ndarray | None timestamp: float @@ -67,10 +68,10 @@ class DLCLiveProcessor(QObject): def __init__(self) -> None: super().__init__() self._settings = DLCProcessorSettings() - self._dlc: Optional[Any] = None - self._processor: Optional[Any] = None - self._queue: Optional[queue.Queue[Any]] = None - self._worker_thread: Optional[threading.Thread] = None + self._dlc: Any | None = None + self._processor: Any | None = None + self._queue: queue.Queue[Any] | None = None + self._worker_thread: threading.Thread | None = None self._stop_event = threading.Event() self._initialized = False @@ -90,7 +91,7 @@ def __init__(self) -> None: self._gpu_inference_times: deque[float] = deque(maxlen=60) self._processor_overhead_times: deque[float] = deque(maxlen=60) - def configure(self, settings: DLCProcessorSettings, processor: Optional[Any] = None) -> None: + def configure(self, settings: DLCProcessorSettings, processor: Any | None = None) -> None: self._settings = settings self._processor = processor @@ -134,7 +135,7 @@ def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: with self._stats_lock: self._frames_enqueued += 1 except queue.Full: - LOGGER.debug("DLC queue full, dropping frame") + logger.debug("DLC queue full, dropping frame") with self._stats_lock: self._frames_dropped += 1 @@ -149,37 +150,23 @@ def get_stats(self) -> ProcessorStats: # Compute processing FPS from processing times if len(self._processing_times) >= 2: duration = self._processing_times[-1] - self._processing_times[0] - processing_fps = ( - (len(self._processing_times) - 1) / duration if duration > 0 else 0.0 - ) + processing_fps = (len(self._processing_times) - 1) / duration if duration > 0 else 0.0 else: processing_fps = 0.0 # Profiling metrics avg_queue_wait = ( - sum(self._queue_wait_times) / len(self._queue_wait_times) - if self._queue_wait_times - else 0.0 - ) - avg_inference = ( - sum(self._inference_times) / len(self._inference_times) - if self._inference_times - else 0.0 + sum(self._queue_wait_times) / len(self._queue_wait_times) if self._queue_wait_times else 0.0 ) + avg_inference = sum(self._inference_times) / len(self._inference_times) if self._inference_times else 0.0 avg_signal_emit = ( - sum(self._signal_emit_times) / len(self._signal_emit_times) - if self._signal_emit_times - else 0.0 + sum(self._signal_emit_times) / len(self._signal_emit_times) if self._signal_emit_times else 0.0 ) avg_total = ( - sum(self._total_process_times) / len(self._total_process_times) - if self._total_process_times - else 0.0 + sum(self._total_process_times) / len(self._total_process_times) if self._total_process_times else 0.0 ) avg_gpu = ( - sum(self._gpu_inference_times) / len(self._gpu_inference_times) - if self._gpu_inference_times - else 0.0 + sum(self._gpu_inference_times) / len(self._gpu_inference_times) if self._gpu_inference_times else 0.0 ) avg_proc_overhead = ( sum(self._processor_overhead_times) / len(self._processor_overhead_times) @@ -230,7 +217,7 @@ def _stop_worker(self) -> None: self._worker_thread.join(timeout=2.0) if self._worker_thread.is_alive(): - LOGGER.warning("DLC worker thread did not terminate cleanly") + logger.warning("DLC worker thread did not terminate cleanly") self._worker_thread = None self._queue = None @@ -266,8 +253,9 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: self.initialized.emit(True) total_init_time = time.perf_counter() - init_start - LOGGER.info( - f"DLCLive model initialized successfully (total: {total_init_time:.3f}s, init_inference: {init_inference_time:.3f}s)" + logger.info( + f"DLCLive model initialized successfully " + f"(total: {total_init_time:.3f}s, init_inference: {init_inference_time:.3f}s)" ) # Process the initialization frame @@ -292,7 +280,7 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: self._signal_emit_times.append(signal_time) except Exception as exc: - LOGGER.exception("Failed to initialize DLCLive", exc_info=exc) + logger.exception("Failed to initialize DLCLive", exc_info=exc) self.error.emit(str(exc)) self.initialized.emit(False) return @@ -321,26 +309,33 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: processor_overhead_time = 0.0 gpu_inference_time = 0.0 + original_process = None # bind for finally safety + if self._processor is not None: # Wrap processor.process() to time it original_process = self._processor.process processor_time_holder = [0.0] # Use list to allow modification in nested scope - def timed_process(pose, **kwargs): + # Bind original_process and holder into defaults to satisfy flake8-bugbear B023 + def timed_process(pose, _op=original_process, _holder=processor_time_holder, **kwargs): proc_start = time.perf_counter() - result = original_process(pose, **kwargs) - processor_time_holder[0] = time.perf_counter() - proc_start - return result + try: + return _op(pose, **kwargs) + finally: + _holder[0] = time.perf_counter() - proc_start self._processor.process = timed_process - inference_start = time.perf_counter() - pose = self._dlc.get_pose(frame, frame_time=timestamp) - inference_time = time.perf_counter() - inference_start + try: + inference_start = time.perf_counter() + pose = self._dlc.get_pose(frame, frame_time=timestamp) + inference_time = time.perf_counter() - inference_start + finally: + # Always restore the original process method if we wrapped it + if original_process is not None and self._processor is not None: + self._processor.process = original_process - if self._processor is not None: - # Restore original process method - self._processor.process = original_process + if original_process is not None: processor_overhead_time = processor_time_holder[0] gpu_inference_time = inference_time - processor_overhead_time else: @@ -372,20 +367,79 @@ def timed_process(pose, **kwargs): # Log profiling every 100 frames frame_count += 1 if ENABLE_PROFILING and frame_count % 100 == 0: - LOGGER.info( + logger.info( f"[Profile] Frame {frame_count}: " - f"queue_wait={queue_wait_time*1000:.2f}ms, " - f"inference={inference_time*1000:.2f}ms " - f"(GPU={gpu_inference_time*1000:.2f}ms, processor={processor_overhead_time*1000:.2f}ms), " - f"signal_emit={signal_time*1000:.2f}ms, " - f"total={total_process_time*1000:.2f}ms, " - f"latency={latency*1000:.2f}ms" + f"queue_wait={queue_wait_time * 1000:.2f}ms, " + f"inference={inference_time * 1000:.2f}ms " + f"(GPU={gpu_inference_time * 1000:.2f}ms, processor={processor_overhead_time * 1000:.2f}ms), " + f"signal_emit={signal_time * 1000:.2f}ms, " + f"total={total_process_time * 1000:.2f}ms, " + f"latency={latency * 1000:.2f}ms" ) except Exception as exc: - LOGGER.exception("Pose inference failed", exc_info=exc) + logger.exception("Pose inference failed", exc_info=exc) self.error.emit(str(exc)) finally: self._queue.task_done() - LOGGER.info("DLC worker thread exiting") + logger.info("DLC worker thread exiting") + + +class DLCService: + """Wrap DLCLiveProcessor lifecycle & configuration.""" + + def __init__(self): + self._proc = DLCLiveProcessor() + self.active = False + self.initialized = False + self._last_pose: PoseResult | None = None + self._processor_info = None + + @property + def processor(self): + return self._proc._processor + + def configure(self, settings: DLCProcessorSettings, scanned_processors: dict, selected_key) -> bool: + processor = None + if selected_key is not None and scanned_processors: + try: + processor = instantiate_from_scan(scanned_processors, selected_key) + except Exception as exc: + logger.error("Failed to instantiate processor: %s", exc) + return False + self._proc.configure(settings, processor=processor) + return True + + def start(self): + self._proc.reset() + self.active = True + self.initialized = False + + def stop(self): + self.active = False + self.initialized = False + self._proc.reset() + self._last_pose = None + + def stats(self) -> ProcessorStats: + return self._proc.get_stats() + + def last_pose(self) -> PoseResult | None: + return self._last_pose + + # Expose key signals (to let MainWindow connect easily) + @property + def pose_ready(self): + return self._proc.pose_ready + + @property + def error(self): + return self._proc.error + + @property + def initialized(self): + return self._proc.initialized + + def enqueue(self, frame, ts): + self._proc.enqueue_frame(frame, ts) diff --git a/dlclivegui/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py similarity index 100% rename from dlclivegui/multi_camera_controller.py rename to dlclivegui/services/multi_camera_controller.py diff --git a/dlclivegui/video_recorder.py b/dlclivegui/services/video_recorder.py similarity index 100% rename from dlclivegui/video_recorder.py rename to dlclivegui/services/video_recorder.py diff --git a/dlclivegui/utils.py b/dlclivegui/utils.py deleted file mode 100644 index 5cf54a9..0000000 --- a/dlclivegui/utils.py +++ /dev/null @@ -1,11 +0,0 @@ -from pathlib import Path - -SUPPORTED_MODELS = [".pt", ".pth", ".pb"] - - -def is_model_file(file_path: Path | str) -> bool: - if not isinstance(file_path, Path): - file_path = Path(file_path) - if not file_path.is_file(): - return False - return file_path.suffix.lower() in SUPPORTED_MODELS diff --git a/dlclivegui/utils/display.py b/dlclivegui/utils/display.py new file mode 100644 index 0000000..8b93403 --- /dev/null +++ b/dlclivegui/utils/display.py @@ -0,0 +1,217 @@ +# dlclivegui/utils/display.py +from __future__ import annotations + +import cv2 +import matplotlib.pyplot as plt +import numpy as np + + +def create_tiled_frame(frames: dict[str, np.ndarray], max_canvas: tuple[int, int] = (1200, 800)) -> np.ndarray: + """Create a tiled canvas (1x1, 1x2, or 2x2) with camera-id labels.""" + if not frames: + return np.zeros((480, 640, 3), dtype=np.uint8) + + cam_ids = sorted(frames.keys()) + frames_list = [frames[cid] for cid in cam_ids] + num_frames = len(frames_list) + + if num_frames == 1: + rows, cols = 1, 1 + elif num_frames == 2: + rows, cols = 1, 2 + else: + rows, cols = 2, 2 + + max_w, max_h = max_canvas + h0, w0 = frames_list[0].shape[:2] + frame_aspect = w0 / h0 if h0 > 0 else 1.0 + + tile_w = max_w // cols + tile_h = max_h // rows + tile_aspect = tile_w / tile_h if tile_h > 0 else 1.0 + + if frame_aspect > tile_aspect: + tile_h = int(tile_w / frame_aspect) + else: + tile_w = int(tile_h * frame_aspect) + + tile_w = max(160, tile_w) + tile_h = max(120, tile_h) + + canvas = np.zeros((rows * tile_h, cols * tile_w, 3), dtype=np.uint8) + + for idx, frame in enumerate(frames_list[: rows * cols]): + row = idx // cols + col = idx % cols + + if frame.ndim == 2: + frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR) + elif frame.shape[2] == 4: + frame = cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR) + + resized = cv2.resize(frame, (tile_w, tile_h)) + if idx < len(cam_ids): + cv2.putText( + resized, + cam_ids[idx], + (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, + 0.7, + (0, 255, 0), + 2, + ) + + y0 = row * tile_h + x0 = col * tile_w + canvas[y0 : y0 + tile_h, x0 : x0 + tile_w] = resized + + return canvas + + +def compute_tile_info( + dlc_cam_id: str, + original_frame: np.ndarray, + frames: dict[str, np.ndarray], + max_canvas: tuple[int, int] = (1200, 800), +) -> tuple[tuple[int, int], tuple[float, float]]: + """Return ((offset_x, offset_y), (scale_x, scale_y)) for overlaying on the tiled view.""" + num_cameras = len(frames) + if num_cameras == 0: + return (0, 0), (1.0, 1.0) + + orig_h, orig_w = original_frame.shape[:2] + if num_cameras == 1: + rows, cols = 1, 1 + elif num_cameras == 2: + rows, cols = 1, 2 + else: + rows, cols = 2, 2 + + max_w, max_h = max_canvas + frame_aspect = orig_w / orig_h if orig_h > 0 else 1.0 + + tile_w = max_w // cols + tile_h = max_h // rows + tile_aspect = tile_w / tile_h if tile_h > 0 else 1.0 + + if frame_aspect > tile_aspect: + tile_h = int(tile_w / frame_aspect) + else: + tile_w = int(tile_h * frame_aspect) + + tile_w = max(160, tile_w) + tile_h = max(120, tile_h) + + sorted_cam_ids = sorted(frames.keys()) + try: + dlc_cam_idx = sorted_cam_ids.index(dlc_cam_id) + except ValueError: + dlc_cam_idx = 0 + + row = dlc_cam_idx // cols + col = dlc_cam_idx % cols + offset_x = col * tile_w + offset_y = row * tile_h + + scale_x = tile_w / orig_w if orig_w > 0 else 1.0 + scale_y = tile_h / orig_h if orig_h > 0 else 1.0 + + return (offset_x, offset_y), (scale_x, scale_y) + + +def draw_bbox( + frame: np.ndarray, + bbox_xyxy: tuple[int, int, int, int], + color_bgr: tuple[int, int, int], + offset: tuple[int, int] = (0, 0), + scale: tuple[float, float] = (1.0, 1.0), +) -> np.ndarray: + """Draw a bbox on the frame, transformed by offset/scale for tiled views.""" + x0, y0, x1, y1 = bbox_xyxy + if x0 >= x1 or y0 >= y1: + return frame + + ox, oy = offset + sx, sy = scale + x0s = int(x0 * sx + ox) + y0s = int(y0 * sy + oy) + x1s = int(x1 * sx + ox) + y1s = int(y1 * sy + oy) + + h, w = frame.shape[:2] + x0s = max(0, min(x0s, w - 1)) + y0s = max(0, min(y0s, h - 1)) + x1s = max(x0s + 1, min(x1s, w)) + y1s = max(y0s + 1, min(y1s, h)) + + out = frame.copy() + cv2.rectangle(out, (x0s, y0s), (x1s, y1s), color_bgr, 2) + return out + + +def draw_keypoints(overlay, p_cutoff, sx, ox, sy, oy, radius, cmap, keypoints: np.ndarray, marker: int | None) -> None: + num_kpts = len(keypoints) + for idx, kpt in enumerate(keypoints): + if len(kpt) < 2: + continue + x, y = kpt[:2] + conf = kpt[2] if len(kpt) > 2 else 1.0 + if np.isnan(x) or np.isnan(y) or conf < p_cutoff: + continue + + xs = int(x * sx + ox) + ys = int(y * sy + oy) + + t = idx / max(num_kpts - 1, 1) + rgba = cmap(t) + bgr = (int(rgba[2] * 255), int(rgba[1] * 255), int(rgba[0] * 255)) + if marker is None: + cv2.circle(overlay, (xs, ys), radius, bgr, -1) + else: + cv2.drawMarker(overlay, (xs, ys), bgr, marker, radius * 2, 2) + + +def draw_pose( + frame: np.ndarray, + pose: np.ndarray, + p_cutoff: float, + colormap: str, + offset: tuple[int, int], + scale: tuple[float, float], + base_radius: int = 4, +) -> np.ndarray: + """Draw single- or multi-animal pose (N x 3 or A x N x 3) on the frame.""" + overlay = frame.copy() + pose_arr = np.asarray(pose) + ox, oy = offset + sx, sy = scale + radius = max(2, int(base_radius * min(sx, sy))) + cmap = plt.get_cmap(colormap) + + if pose_arr.ndim == 3: + markers = [ + cv2.MARKER_CROSS, + cv2.MARKER_TILTED_CROSS, + cv2.MARKER_STAR, + cv2.MARKER_DIAMOND, + cv2.MARKER_SQUARE, + cv2.MARKER_TRIANGLE_UP, + cv2.MARKER_TRIANGLE_DOWN, + ] + for i, animal_pose in enumerate(pose_arr): + draw_keypoints( + overlay, + p_cutoff, + sx, + ox, + sy, + oy, + radius, + cmap, + animal_pose, + markers[i % len(markers)], + ) + else: + draw_keypoints(overlay, p_cutoff, sx, ox, sy, oy, radius, cmap, pose_arr, marker=None) + + return overlay diff --git a/dlclivegui/utils/stats.py b/dlclivegui/utils/stats.py new file mode 100644 index 0000000..23e9d57 --- /dev/null +++ b/dlclivegui/utils/stats.py @@ -0,0 +1,45 @@ +# dlclivegui/utils/stats.py +from __future__ import annotations + +from dlclivegui.services.dlc_processor import ProcessorStats +from dlclivegui.services.video_recorder import RecorderStats + + +def format_recorder_stats(stats: RecorderStats) -> str: + latency_ms = stats.last_latency * 1000.0 + avg_ms = stats.average_latency * 1000.0 + buffer_ms = stats.buffer_seconds * 1000.0 + return ( + f"{stats.frames_written}/{stats.frames_enqueued} frames | " + f"write {stats.write_fps:.1f} fps | " + f"latency {latency_ms:.1f} ms (avg {avg_ms:.1f} ms) | " + f"queue {stats.queue_size} (~{buffer_ms:.0f} ms) | " + f"dropped {stats.dropped_frames}" + ) + + +def format_dlc_stats(stats: ProcessorStats) -> str: + latency_ms = stats.last_latency * 1000.0 + avg_ms = stats.average_latency * 1000.0 + profile = "" + if stats.avg_inference_time > 0: + inf_ms = stats.avg_inference_time * 1000.0 + queue_ms = stats.avg_queue_wait * 1000.0 + signal_ms = stats.avg_signal_emit_time * 1000.0 + total_ms = stats.avg_total_process_time * 1000.0 + gpu_breakdown = "" + if stats.avg_gpu_inference_time > 0 or stats.avg_processor_overhead > 0: + gpu_ms = stats.avg_gpu_inference_time * 1000.0 + proc_ms = stats.avg_processor_overhead * 1000.0 + gpu_breakdown = f" (GPU:{gpu_ms:.1f}ms+proc:{proc_ms:.1f}ms)" + profile = ( + f"\n[Profile] inf:{inf_ms:.1f}ms{gpu_breakdown} " + f"queue:{queue_ms:.1f}ms signal:{signal_ms:.1f}ms total:{total_ms:.1f}ms" + ) + + return ( + f"{stats.frames_processed}/{stats.frames_enqueued} frames | " + f"inference {stats.processing_fps:.1f} fps | " + f"latency {latency_ms:.1f} ms (avg {avg_ms:.1f} ms) | " + f"queue {stats.queue_size} | dropped {stats.frames_dropped}{profile}" + ) diff --git a/dlclivegui/utils/utils.py b/dlclivegui/utils/utils.py new file mode 100644 index 0000000..38a8504 --- /dev/null +++ b/dlclivegui/utils/utils.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import time +from collections import deque +from pathlib import Path + +SUPPORTED_MODELS = [".pt", ".pth", ".pb"] + + +def is_model_file(file_path: Path | str) -> bool: + if not isinstance(file_path, Path): + file_path = Path(file_path) + if not file_path.is_file(): + return False + return file_path.suffix.lower() in SUPPORTED_MODELS + + +class FPSTracker: + """Track per-camera FPS within a sliding time window.""" + + def __init__(self, window_seconds: float = 5.0, maxlen: int = 240): + self.window_seconds = window_seconds + self._times: dict[str, deque[float]] = {} + self._maxlen = maxlen + + def clear(self) -> None: + self._times.clear() + + def note_frame(self, camera_id: str) -> None: + now = time.perf_counter() + dq = self._times.get(camera_id) + if dq is None: + dq = deque(maxlen=self._maxlen) + self._times[camera_id] = dq + dq.append(now) + while dq and (now - dq[0]) > self.window_seconds: + dq.popleft() + + def fps(self, camera_id: str) -> float: + dq = self._times.get(camera_id) + if not dq or len(dq) < 2: + return 0.0 + duration = dq[-1] - dq[0] + if duration <= 0: + return 0.0 + return (len(dq) - 1) / duration From 60cf5f59c922c21bc99ba65513eeaf0a42ebc5a0 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 30 Jan 2026 10:52:40 +0100 Subject: [PATCH 60/69] Fix script entry point and clean up splash logic Corrects the dlclivegui script entry point in pyproject.toml to point to dlclivegui:main. Cleans up comments and redundant code in splash screen logic in main.py. Updates a status message in camera_config_dialog.py for clarity. --- dlclivegui/gui/camera_config_dialog.py | 2 +- dlclivegui/main.py | 5 +---- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/dlclivegui/gui/camera_config_dialog.py b/dlclivegui/gui/camera_config_dialog.py index 1e9654e..d2ba9c3 100644 --- a/dlclivegui/gui/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config_dialog.py @@ -1052,7 +1052,7 @@ def _on_loader_success(self, payload) -> None: self._preview_backend = payload elif isinstance(payload, CameraSettings): cam_settings = payload - self._append_status("Opening camera on main thread…") + self._append_status("Opening camera…") self._preview_backend = CameraFactory.create(cam_settings) self._preview_backend.open() else: diff --git a/dlclivegui/main.py b/dlclivegui/main.py index b3a16c4..23802d8 100644 --- a/dlclivegui/main.py +++ b/dlclivegui/main.py @@ -32,22 +32,19 @@ def main() -> None: Qt.SmoothTransformation, ) else: - # Fallback: empty pixmap; you can also use a color fill if desired + # Fallback: empty pixmap splash_height = 400 scaled_pixmap = QPixmap(splash_width, splash_height) scaled_pixmap.fill(Qt.black) - # Create splash with the *scaled* pixmap splash = QSplashScreen(scaled_pixmap) splash.show() - # Let the splash breathe without blocking the event loop def show_main(): splash.close() window = DLCLiveMainWindow() window.show() - # Show main window after 1500 ms QTimer.singleShot(1000, show_main) sys.exit(app.exec()) diff --git a/pyproject.toml b/pyproject.toml index ee960cd..8998fba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ Documentation = "https://github.com/DeepLabCut/DeepLabCut-live-GUI" "Bug Tracker" = "https://github.com/DeepLabCut/DeepLabCut-live-GUI/issues" [project.scripts] -dlclivegui = "dlclivegui.gui:main" +dlclivegui = "dlclivegui:main" [tool.setuptools] include-package-data = true From df3b52bfaaf523f89c2ed1bb1c6f5462d91138b0 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 30 Jan 2026 13:41:42 +0100 Subject: [PATCH 61/69] Refactor camera backend system and add Pydantic config models Camera backends have been moved to a dedicated 'backends' subpackage and now use a registry with decorators for dynamic registration. Introduced config_adapters for flexible CameraSettings handling, and added Pydantic-based config models in utils/config_models.py for validation and conversion. The camera factory and processor logic now accept both dataclass and Pydantic models, improving flexibility and type safety. Also added a QtSettingsStore utility for persistent settings, and updated dependencies to include pydantic. --- .gitignore | 6 - dlclivegui/cameras/__init__.py | 16 +- .../cameras/{ => backends}/aravis_backend.py | 16 +- .../cameras/{ => backends}/basler_backend.py | 32 ++- .../cameras/{ => backends}/gentl_backend.py | 72 +++---- .../cameras/{ => backends}/opencv_backend.py | 3 +- dlclivegui/cameras/base.py | 58 ++++-- dlclivegui/cameras/config_adapters.py | 42 ++++ dlclivegui/cameras/factory.py | 78 +++----- dlclivegui/services/dlc_processor.py | 64 ++++-- dlclivegui/utils/config_models.py | 182 ++++++++++++++++++ dlclivegui/utils/settings_store.py | 38 ++++ pyproject.toml | 17 +- 13 files changed, 455 insertions(+), 169 deletions(-) rename dlclivegui/cameras/{ => backends}/aravis_backend.py (96%) rename dlclivegui/cameras/{ => backends}/basler_backend.py (88%) rename dlclivegui/cameras/{ => backends}/gentl_backend.py (91%) rename dlclivegui/cameras/{ => backends}/opencv_backend.py (99%) create mode 100644 dlclivegui/cameras/config_adapters.py create mode 100644 dlclivegui/utils/config_models.py create mode 100644 dlclivegui/utils/settings_store.py diff --git a/.gitignore b/.gitignore index e5d2f1f..7c5b18d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,3 @@ -##################### -### DLC Live Specific -##################### - -**test* - ################### ### python standard ################### diff --git a/dlclivegui/cameras/__init__.py b/dlclivegui/cameras/__init__.py index 7aa4621..b5dad4f 100644 --- a/dlclivegui/cameras/__init__.py +++ b/dlclivegui/cameras/__init__.py @@ -2,6 +2,18 @@ from __future__ import annotations -from .factory import CameraFactory +from ..config import CameraSettings +from .base import _BACKEND_REGISTRY as BACKENDS +from .base import CameraBackend +from .config_adapters import CameraSettingsLike, ensure_dc_camera +from .factory import CameraFactory, DetectedCamera -__all__ = ["CameraFactory"] +__all__ = [ + "CameraSettings", + "CameraBackend", + "CameraFactory", + "DetectedCamera", + "CameraSettingsLike", + "ensure_dc_camera", + "BACKENDS", +] diff --git a/dlclivegui/cameras/aravis_backend.py b/dlclivegui/cameras/backends/aravis_backend.py similarity index 96% rename from dlclivegui/cameras/aravis_backend.py rename to dlclivegui/cameras/backends/aravis_backend.py index e04ad60..d3512ea 100644 --- a/dlclivegui/cameras/aravis_backend.py +++ b/dlclivegui/cameras/backends/aravis_backend.py @@ -4,12 +4,11 @@ import logging import time -from typing import Optional, Tuple import cv2 import numpy as np -from .base import CameraBackend +from ..base import CameraBackend, register_backend LOG = logging.getLogger(__name__) @@ -25,20 +24,21 @@ ARAVIS_AVAILABLE = False +@register_backend("aravis") class AravisCameraBackend(CameraBackend): """Capture frames from GenICam-compatible devices via Aravis.""" def __init__(self, settings): super().__init__(settings) props = settings.properties - self._camera_id: Optional[str] = props.get("camera_id") + self._camera_id: str | None = props.get("camera_id") self._pixel_format: str = props.get("pixel_format", "Mono8") self._timeout: int = int(props.get("timeout", 2000000)) # microseconds self._n_buffers: int = int(props.get("n_buffers", 10)) self._camera = None self._stream = None - self._device_label: Optional[str] = None + self._device_label: str | None = None @classmethod def is_available(cls) -> bool: @@ -83,9 +83,7 @@ def open(self) -> None: else: index = int(self.settings.index or 0) if index < 0 or index >= n_devices: - raise RuntimeError( - f"Camera index {index} out of range for {n_devices} Aravis device(s)" - ) + raise RuntimeError(f"Camera index {index} out of range for {n_devices} Aravis device(s)") camera_id = Aravis.get_device_id(index) self._camera = Aravis.Camera.new(camera_id) if self._camera is None: @@ -113,7 +111,7 @@ def open(self) -> None: # Start acquisition self._camera.start_acquisition() - def read(self) -> Tuple[np.ndarray, float]: + def read(self) -> tuple[np.ndarray, float]: """Read a frame from the camera.""" if self._camera is None or self._stream is None: raise RuntimeError("Aravis camera not initialized") @@ -320,7 +318,7 @@ def _configure_frame_rate(self) -> None: except Exception as e: LOG.warning(f"Failed to set frame rate to {self.settings.fps}: {e}") - def _resolve_device_label(self) -> Optional[str]: + def _resolve_device_label(self) -> str | None: """Get a human-readable device label.""" if self._camera is None: return None diff --git a/dlclivegui/cameras/basler_backend.py b/dlclivegui/cameras/backends/basler_backend.py similarity index 88% rename from dlclivegui/cameras/basler_backend.py rename to dlclivegui/cameras/backends/basler_backend.py index 7517f6c..31ab2c7 100644 --- a/dlclivegui/cameras/basler_backend.py +++ b/dlclivegui/cameras/backends/basler_backend.py @@ -4,11 +4,10 @@ import logging import time -from typing import Optional, Tuple import numpy as np -from .base import CameraBackend +from ..base import CameraBackend, register_backend LOG = logging.getLogger(__name__) @@ -18,17 +17,16 @@ pylon = None # type: ignore +@register_backend("basler") class BaslerCameraBackend(CameraBackend): """Capture frames from Basler cameras using the Pylon SDK.""" def __init__(self, settings): super().__init__(settings) - self._camera: Optional["pylon.InstantCamera"] = None - self._converter: Optional["pylon.ImageFormatConverter"] = None + self._camera: pylon.InstantCamera | None = None + self._converter: pylon.ImageFormatConverter | None = None # Parse resolution with defaults (720x540) - self._resolution: Tuple[int, int] = self._parse_resolution( - settings.properties.get("resolution") - ) + self._resolution: tuple[int, int] = self._parse_resolution(settings.properties.get("resolution")) @classmethod def is_available(cls) -> bool: @@ -118,13 +116,13 @@ def open(self) -> None: except Exception: pass - def read(self) -> Tuple[np.ndarray, float]: + def read(self) -> tuple[np.ndarray, float]: if self._camera is None or self._converter is None: raise RuntimeError("Basler camera not opened") try: grab_result = self._camera.RetrieveResult(100, pylon.TimeoutHandling_ThrowException) except Exception as exc: - raise RuntimeError(f"Failed to retrieve image from Basler camera: {exc}") + raise RuntimeError("Failed to retrieve image from Basler camera.") from exc if not grab_result.GrabSucceeded(): grab_result.Release() raise RuntimeError("Basler camera did not return an image") @@ -161,30 +159,24 @@ def _enumerate_devices(self): return factory.EnumerateDevices() def _select_device(self, devices): - serial = self.settings.properties.get("serial") or self.settings.properties.get( - "serial_number" - ) + serial = self.settings.properties.get("serial") or self.settings.properties.get("serial_number") if serial: for device in devices: if getattr(device, "GetSerialNumber", None) and device.GetSerialNumber() == serial: return device index = int(self.settings.index) if index < 0 or index >= len(devices): - raise RuntimeError( - f"Camera index {index} out of range for {len(devices)} Basler device(s)" - ) + raise RuntimeError(f"Camera index {index} out of range for {len(devices)} Basler device(s)") return devices[index] def _rotate(self, frame: np.ndarray, angle: float) -> np.ndarray: try: from imutils import rotate_bound # pragma: no cover - optional except Exception as exc: # pragma: no cover - optional dependency - raise RuntimeError( - "Rotation requested for Basler camera but imutils is not installed" - ) from exc + raise RuntimeError("Rotation requested for Basler camera but imutils is not installed") from exc return rotate_bound(frame, angle) - def _parse_resolution(self, resolution) -> Tuple[int, int]: + def _parse_resolution(self, resolution) -> tuple[int, int]: """Parse resolution setting. Args: @@ -205,6 +197,6 @@ def _parse_resolution(self, resolution) -> Tuple[int, int]: return (720, 540) @staticmethod - def _settings_value(key: str, source: dict, fallback: Optional[float] = None): + def _settings_value(key: str, source: dict, fallback: float | None = None): value = source.get(key, fallback) return None if value is None else value diff --git a/dlclivegui/cameras/gentl_backend.py b/dlclivegui/cameras/backends/gentl_backend.py similarity index 91% rename from dlclivegui/cameras/gentl_backend.py rename to dlclivegui/cameras/backends/gentl_backend.py index 274da7a..e9b7c0d 100644 --- a/dlclivegui/cameras/gentl_backend.py +++ b/dlclivegui/cameras/backends/gentl_backend.py @@ -6,12 +6,12 @@ import logging import os import time -from typing import Iterable, List, Optional, Tuple +from collections.abc import Iterable import cv2 import numpy as np -from .base import CameraBackend +from ..base import CameraBackend, register_backend LOG = logging.getLogger(__name__) @@ -27,10 +27,11 @@ HarvesterTimeoutError = TimeoutError # type: ignore +@register_backend("gentl") class GenTLCameraBackend(CameraBackend): """Capture frames from GenTL-compatible devices via Harvesters.""" - _DEFAULT_CTI_PATTERNS: Tuple[str, ...] = ( + _DEFAULT_CTI_PATTERNS: tuple[str, ...] = ( r"C:\\Program Files\\The Imaging Source Europe GmbH\\IC4 GenTL Driver for USB3Vision Devices *\\bin\\*.cti", r"C:\\Program Files\\The Imaging Source Europe GmbH\\TIS Grabber\\bin\\win64_x64\\*.cti", r"C:\\Program Files\\The Imaging Source Europe GmbH\\TIS Camera SDK\\bin\\win64_x64\\*.cti", @@ -40,28 +41,22 @@ class GenTLCameraBackend(CameraBackend): def __init__(self, settings): super().__init__(settings) props = settings.properties - self._cti_file: Optional[str] = props.get("cti_file") - self._serial_number: Optional[str] = props.get("serial_number") or props.get("serial") + self._cti_file: str | None = props.get("cti_file") + self._serial_number: str | None = props.get("serial_number") or props.get("serial") self._pixel_format: str = props.get("pixel_format", "Mono8") self._rotate: int = int(props.get("rotate", 0)) % 360 - self._crop: Optional[Tuple[int, int, int, int]] = self._parse_crop(props.get("crop")) + self._crop: tuple[int, int, int, int] | None = self._parse_crop(props.get("crop")) # Check settings first (from config), then properties (for backward compatibility) - self._exposure: Optional[float] = ( - settings.exposure if settings.exposure else props.get("exposure") - ) - self._gain: Optional[float] = settings.gain if settings.gain else props.get("gain") + self._exposure: float | None = settings.exposure if settings.exposure else props.get("exposure") + self._gain: float | None = settings.gain if settings.gain else props.get("gain") self._timeout: float = float(props.get("timeout", 2.0)) - self._cti_search_paths: Tuple[str, ...] = self._parse_cti_paths( - props.get("cti_search_paths") - ) + self._cti_search_paths: tuple[str, ...] = self._parse_cti_paths(props.get("cti_search_paths")) # Parse resolution (width, height) with defaults - self._resolution: Optional[Tuple[int, int]] = self._parse_resolution( - props.get("resolution") - ) + self._resolution: tuple[int, int] | None = self._parse_resolution(props.get("resolution")) self._harvester = None self._acquirer = None - self._device_label: Optional[str] = None + self._device_label: str | None = None @classmethod def is_available(cls) -> bool: @@ -100,8 +95,7 @@ def get_device_count(cls) -> int: def open(self) -> None: if Harvester is None: # pragma: no cover - optional dependency raise RuntimeError( - "The 'harvesters' package is required for the GenTL backend. " - "Install it via 'pip install harvesters'." + "The 'harvesters' package is required for the GenTL backend. Install it via 'pip install harvesters'." ) self._harvester = Harvester() @@ -118,16 +112,12 @@ def open(self) -> None: available = self._available_serials() matches = [s for s in available if serial in s] if not matches: - raise RuntimeError( - f"Camera with serial '{serial}' not found. Available cameras: {available}" - ) + raise RuntimeError(f"Camera with serial '{serial}' not found. Available cameras: {available}") serial = matches[0] else: device_count = len(self._harvester.device_info_list) if index < 0 or index >= device_count: - raise RuntimeError( - f"Camera index {index} out of range for {device_count} GenTL device(s)" - ) + raise RuntimeError(f"Camera index {index} out of range for {device_count} GenTL device(s)") self._acquirer = self._create_acquirer(serial, index) @@ -170,7 +160,7 @@ def open(self) -> None: self._acquirer.start() - def read(self) -> Tuple[np.ndarray, float]: + def read(self) -> tuple[np.ndarray, float]: if self._acquirer is None: raise RuntimeError("GenTL image acquirer not initialised") @@ -228,7 +218,7 @@ def close(self) -> None: # Helpers # ------------------------------------------------------------------ - def _parse_cti_paths(self, value) -> Tuple[str, ...]: + def _parse_cti_paths(self, value) -> tuple[str, ...]: if value is None: return self._DEFAULT_CTI_PATTERNS if isinstance(value, str): @@ -237,12 +227,12 @@ def _parse_cti_paths(self, value) -> Tuple[str, ...]: return tuple(str(item) for item in value) return self._DEFAULT_CTI_PATTERNS - def _parse_crop(self, crop) -> Optional[Tuple[int, int, int, int]]: + def _parse_crop(self, crop) -> tuple[int, int, int, int] | None: if isinstance(crop, (list, tuple)) and len(crop) == 4: return tuple(int(v) for v in crop) return None - def _parse_resolution(self, resolution) -> Optional[Tuple[int, int]]: + def _parse_resolution(self, resolution) -> tuple[int, int] | None: """Parse resolution setting. Args: @@ -264,7 +254,7 @@ def _parse_resolution(self, resolution) -> Optional[Tuple[int, int]]: return (720, 540) @staticmethod - def _search_cti_file(patterns: Tuple[str, ...]) -> Optional[str]: + def _search_cti_file(patterns: tuple[str, ...]) -> str | None: """Search for a CTI file using the given patterns. Returns the first CTI file found, or None if none found. @@ -288,23 +278,23 @@ def _find_cti_file(self) -> str: ) return cti_file - def _available_serials(self) -> List[str]: + def _available_serials(self) -> list[str]: assert self._harvester is not None - serials: List[str] = [] + serials: list[str] = [] for info in self._harvester.device_info_list: serial = getattr(info, "serial_number", "") if serial: serials.append(serial) return serials - def _create_acquirer(self, serial: Optional[str], index: int): + def _create_acquirer(self, serial: str | None, index: int): assert self._harvester is not None methods = [ getattr(self._harvester, "create", None), getattr(self._harvester, "create_image_acquirer", None), ] methods = [m for m in methods if m is not None] - errors: List[str] = [] + errors: list[str] = [] device_info = None if not serial: device_list = self._harvester.device_info_list @@ -347,15 +337,12 @@ def _configure_pixel_format(self, node_map) -> None: node_map.PixelFormat.value = self._pixel_format actual = node_map.PixelFormat.value if actual != self._pixel_format: - LOG.warning( - f"Pixel format mismatch: requested '{self._pixel_format}', got '{actual}'" - ) + LOG.warning(f"Pixel format mismatch: requested '{self._pixel_format}', got '{actual}'") else: LOG.info(f"Pixel format set to '{actual}'") else: LOG.warning( - f"Pixel format '{self._pixel_format}' not in available formats: " - f"{node_map.PixelFormat.symbolics}" + f"Pixel format '{self._pixel_format}' not in available formats: {node_map.PixelFormat.symbolics}" ) except Exception as e: LOG.warning(f"Failed to set pixel format '{self._pixel_format}': {e}") @@ -442,10 +429,7 @@ def _configure_resolution(self, node_map) -> None: else: LOG.info(f"Resolution set to {actual_width}x{actual_height}") else: - LOG.warning( - f"Could not verify resolution setting " - f"(width={actual_width}, height={actual_height})" - ) + LOG.warning(f"Could not verify resolution setting (width={actual_width}, height={actual_height})") def _configure_exposure(self, node_map) -> None: if self._exposure is None: @@ -585,7 +569,7 @@ def _convert_frame(self, frame: np.ndarray) -> np.ndarray: return frame.copy() - def _resolve_device_label(self, node_map) -> Optional[str]: + def _resolve_device_label(self, node_map) -> str | None: candidates = [ ("DeviceModelName", "DeviceSerialNumber"), ("DeviceDisplayName", "DeviceSerialNumber"), diff --git a/dlclivegui/cameras/opencv_backend.py b/dlclivegui/cameras/backends/opencv_backend.py similarity index 99% rename from dlclivegui/cameras/opencv_backend.py rename to dlclivegui/cameras/backends/opencv_backend.py index 3df4c91..2de4f25 100644 --- a/dlclivegui/cameras/opencv_backend.py +++ b/dlclivegui/cameras/backends/opencv_backend.py @@ -10,12 +10,13 @@ import cv2 import numpy as np -from .base import CameraBackend +from ..base import CameraBackend, register_backend logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) # FIXME @C-Achard remove before release +@register_backend("opencv") class OpenCVCameraBackend(CameraBackend): """ Platform-aware OpenCV backend: diff --git a/dlclivegui/cameras/base.py b/dlclivegui/cameras/base.py index f060d8b..6c3340d 100644 --- a/dlclivegui/cameras/base.py +++ b/dlclivegui/cameras/base.py @@ -1,42 +1,76 @@ -"""Abstract camera backend definitions.""" - +# dlclivegui/cameras/base.py from __future__ import annotations from abc import ABC, abstractmethod -from typing import Tuple import numpy as np from ..config import CameraSettings +from .config_adapters import CameraSettingsLike, ensure_dc_camera # NEW + +_BACKEND_REGISTRY: dict[str, type[CameraBackend]] = {} + + +def register_backend(name: str): + """ + Decorator to register a camera backend class. + + Usage: + @register_backend("opencv") + class OpenCVCameraBackend(CameraBackend): + ... + """ + + def decorator(cls: type[CameraBackend]): + if not issubclass(cls, CameraBackend): + raise TypeError(f"Backend '{name}' must subclass CameraBackend") + _BACKEND_REGISTRY[name.lower()] = cls + return cls + + return decorator + + +def register_backend_direct(name: str, cls: type[CameraBackend]): + """Allow tests or dynamic plugins to register backends programmatically.""" + if not issubclass(cls, CameraBackend): + raise TypeError(f"Backend '{name}' must subclass CameraBackend") + _BACKEND_REGISTRY[name.lower()] = cls + + +def unregister_backend(name: str): + """Remove a backend from the registry. Useful for tests.""" + _BACKEND_REGISTRY.pop(name.lower(), None) + + +def reset_backends(): + """Clear registry (useful for isolated unit tests).""" + _BACKEND_REGISTRY.clear() class CameraBackend(ABC): """Abstract base class for camera backends.""" - def __init__(self, settings: CameraSettings): - self.settings = settings + def __init__(self, settings: CameraSettingsLike): # CHANGED + # Normalize to dataclass so all backends stay unchanged + self.settings: CameraSettings = ensure_dc_camera(settings) # NEW @classmethod def name(cls) -> str: """Return the backend identifier.""" - return cls.__name__.lower() @classmethod def is_available(cls) -> bool: """Return whether the backend can be used on this system.""" - return True + @abstractmethod def stop(self) -> None: """Request a graceful stop.""" - - # Most backends do not require additional handling, but subclasses may - # override when they need to interrupt blocking reads. + # Subclasses may override when they need to interrupt blocking reads. def device_name(self) -> str: """Return a human readable name for the device currently in use.""" - return self.settings.name @abstractmethod @@ -44,7 +78,7 @@ def open(self) -> None: """Open the capture device.""" @abstractmethod - def read(self) -> Tuple[np.ndarray, float]: + def read(self) -> tuple[np.ndarray, float]: """Read a frame and return the image with a timestamp.""" @abstractmethod diff --git a/dlclivegui/cameras/config_adapters.py b/dlclivegui/cameras/config_adapters.py new file mode 100644 index 0000000..2e0bff7 --- /dev/null +++ b/dlclivegui/cameras/config_adapters.py @@ -0,0 +1,42 @@ +# dlclivegui/cameras/adapters.py +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING, Any, Union + +if TYPE_CHECKING: + from dlclivegui.config import CameraSettingsModel + +from dlclivegui.config import CameraSettings +from dlclivegui.utils.config_models import CameraSettingsModel + +CameraSettingsLike = Union[CameraSettings, "CameraSettingsModel", dict[str, Any]] + + +def ensure_dc_camera(settings: CameraSettingsLike) -> CameraSettings: + """ + Normalize any supported camera settings payload to the legacy dataclass CameraSettings. + - If already a dataclass: deep-copy and return. + - If it's a Pydantic CameraSettingsModel: convert via model_dump(). + - If it's a dict: unpack into CameraSettings. + Ensures default application and type coercions via dataclass.apply_defaults(). + """ + # Case 1: Already the dataclass + if isinstance(settings, CameraSettings): + dc = copy.deepcopy(settings) + return dc.apply_defaults() + + # Case 2: Pydantic model (if available in this environment) + if CameraSettingsModel is not None and isinstance(settings, CameraSettingsModel): + data = settings.model_dump() + dc = CameraSettings(**data) + return dc.apply_defaults() + + # Case 3: Plain dict (best-effort flexibility) + if isinstance(settings, dict): + dc = CameraSettings(**settings) + return dc.apply_defaults() + + raise TypeError( + "Unsupported camera settings type. Expected CameraSettings dataclass, CameraSettingsModel (Pydantic), or dict." + ) diff --git a/dlclivegui/cameras/factory.py b/dlclivegui/cameras/factory.py index 8c58b49..3a82b96 100644 --- a/dlclivegui/cameras/factory.py +++ b/dlclivegui/cameras/factory.py @@ -3,13 +3,22 @@ from __future__ import annotations import copy -import importlib -from collections.abc import Callable, Iterable # CHANGED +from collections.abc import Callable, Iterable from contextlib import contextmanager from dataclasses import dataclass from ..config import CameraSettings +from .base import _BACKEND_REGISTRY as BACKENDS from .base import CameraBackend +from .config_adapters import CameraSettingsLike, ensure_dc_camera + + +@dataclass +class DetectedCamera: + """Information about a camera discovered during probing.""" + + index: int + label: str def _opencv_get_log_level(cv2): @@ -62,30 +71,15 @@ def _suppress_opencv_logging(): yield -@dataclass -class DetectedCamera: - """Information about a camera discovered during probing.""" - - index: int - label: str - - -_BACKENDS: dict[str, tuple[str, str]] = { - "opencv": ("dlclivegui.cameras.opencv_backend", "OpenCVCameraBackend"), - "basler": ("dlclivegui.cameras.basler_backend", "BaslerCameraBackend"), - "gentl": ("dlclivegui.cameras.gentl_backend", "GenTLCameraBackend"), - "aravis": ("dlclivegui.cameras.aravis_backend", "AravisCameraBackend"), -} - - -def _sanitize_for_probe(settings: CameraSettings) -> CameraSettings: +def _sanitize_for_probe(settings: CameraSettingsLike) -> CameraSettings: """ - Return a light, side-effect-minimized copy of CameraSettings for availability probes. + Return a light, side-effect-minimized dataclass copy for availability probes. - Zero FPS (let driver pick default) - Keep only 'api' hint in properties, force fast_start=True - Do not change 'enabled' """ - probe = copy.deepcopy(settings) + dc = ensure_dc_camera(settings) # normalize first + probe = copy.deepcopy(dc) probe.fps = 0.0 # don't force FPS during probe props = probe.properties if isinstance(probe.properties, dict) else {} api = props.get("api") @@ -102,13 +96,13 @@ class CameraFactory: @staticmethod def backend_names() -> Iterable[str]: """Return the identifiers of all known backends.""" - return tuple(_BACKENDS.keys()) + return tuple(BACKENDS.keys()) @staticmethod def available_backends() -> dict[str, bool]: """Return a mapping of backend names to availability flags.""" availability: dict[str, bool] = {} - for name in _BACKENDS: + for name in BACKENDS: try: backend_cls = CameraFactory._resolve_backend(name) except RuntimeError: @@ -122,8 +116,8 @@ def detect_cameras( backend: str, max_devices: int = 10, *, - should_cancel: Callable[[], bool] | None = None, # NEW - progress_cb: Callable[[str], None] | None = None, # NEW + should_cancel: Callable[[], bool] | None = None, + progress_cb: Callable[[str], None] | None = None, ) -> list[DetectedCamera]: """Probe ``backend`` for available cameras. @@ -233,9 +227,10 @@ def _canceled() -> bool: return detected @staticmethod - def create(settings: CameraSettings) -> CameraBackend: + def create(settings: CameraSettingsLike) -> CameraBackend: """Instantiate a backend for ``settings``.""" - backend_name = (settings.backend or "opencv").lower() + dc = ensure_dc_camera(settings) + backend_name = (dc.backend or "opencv").lower() try: backend_cls = CameraFactory._resolve_backend(backend_name) except RuntimeError as exc: # pragma: no cover - runtime configuration @@ -245,12 +240,13 @@ def create(settings: CameraSettings) -> CameraBackend: f"Camera backend '{backend_name}' is not available. " "Ensure the required drivers and Python packages are installed." ) - return backend_cls(settings) + return backend_cls(dc) @staticmethod - def check_camera_available(settings: CameraSettings) -> tuple[bool, str]: + def check_camera_available(settings: CameraSettingsLike) -> tuple[bool, str]: """Check if a camera is present/accessible without pushing heavy settings like FPS.""" - backend_name = (settings.backend or "opencv").lower() + dc = ensure_dc_camera(settings) + backend_name = (dc.backend or "opencv").lower() try: backend_cls = CameraFactory._resolve_backend(backend_name) @@ -260,17 +256,15 @@ def check_camera_available(settings: CameraSettings) -> tuple[bool, str]: if not backend_cls.is_available(): return False, f"Backend '{backend_name}' is not available (missing drivers/packages)" - # Prefer quick presence test if the backend provides it (e.g., OpenCV.quick_ping) + # Prefer quick presence test if hasattr(backend_cls, "quick_ping"): try: with _suppress_opencv_logging(): - idx = int(settings.index) - # Most backends expose quick_ping(index [, backend_flag]) + idx = int(dc.index) ok = False try: ok = backend_cls.quick_ping(idx) # type: ignore[attr-defined] except TypeError: - # Fallback signature with backend flag if required by the specific backend ok = backend_cls.quick_ping(idx, None) # type: ignore[attr-defined] if ok: return True, "" @@ -278,9 +272,9 @@ def check_camera_available(settings: CameraSettings) -> tuple[bool, str]: except Exception as exc: return False, f"Quick probe failed: {exc}" - # 2) Fallback: try a very lightweight open/close with sanitized settings + # Fallback: lightweight open/close with sanitized settings try: - probe_settings = _sanitize_for_probe(settings) + probe_settings = _sanitize_for_probe(dc) backend_instance = backend_cls(probe_settings) with _suppress_opencv_logging(): backend_instance.open() @@ -292,14 +286,6 @@ def check_camera_available(settings: CameraSettings) -> tuple[bool, str]: @staticmethod def _resolve_backend(name: str) -> type[CameraBackend]: try: - module_name, class_name = _BACKENDS[name] + return BACKENDS[name.lower()] except KeyError as exc: - raise RuntimeError("backend not registered") from exc - try: - module = importlib.import_module(module_name) - except ImportError as exc: - raise RuntimeError(str(exc)) from exc - backend_cls = getattr(module, class_name) - if not issubclass(backend_cls, CameraBackend): # pragma: no cover - safety - raise RuntimeError(f"Backend '{name}' does not implement CameraBackend") - return backend_cls + raise RuntimeError("Backend %s not registered", name) from exc diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index 9f85b22..0ee7e56 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -1,7 +1,9 @@ """DLCLive integration helpers.""" +# dlclivegui/services/dlc_processor.py from __future__ import annotations +import copy import logging import queue import threading @@ -15,6 +17,7 @@ from dlclivegui.config import DLCProcessorSettings from dlclivegui.processors.processor_utils import instantiate_from_scan +from dlclivegui.utils.config_models import DLCProcessorSettingsModel logger = logging.getLogger(__name__) @@ -28,6 +31,22 @@ DLCLive = None # type: ignore[assignment] +def ensure_dc_dlc(settings: DLCProcessorSettings | DLCProcessorSettingsModel) -> DLCProcessorSettings: + if isinstance(settings, DLCProcessorSettings): + return copy.deepcopy(settings) + if isinstance(settings, DLCProcessorSettingsModel): + settings = DLCProcessorSettingsModel.model_validate(settings) + data = settings.model_dump() + dyn = data.get("dynamic") + # Convert DynamicCropModel -> tuple expected by dataclass + if hasattr(dyn, "enabled"): + data["dynamic"] = (dyn.enabled, dyn.margin, dyn.max_missing_frames) + elif isinstance(dyn, dict) and {"enabled", "margin", "max_missing_frames"} <= set(dyn): + data["dynamic"] = (dyn["enabled"], dyn["margin"], dyn["max_missing_frames"]) + return DLCProcessorSettings(**data) + raise TypeError("Unsupported DLC settings type") + + @dataclass class PoseResult: pose: np.ndarray | None @@ -91,8 +110,10 @@ def __init__(self) -> None: self._gpu_inference_times: deque[float] = deque(maxlen=60) self._processor_overhead_times: deque[float] = deque(maxlen=60) - def configure(self, settings: DLCProcessorSettings, processor: Any | None = None) -> None: - self._settings = settings + def configure( + self, settings: DLCProcessorSettings | DLCProcessorSettingsModel, processor: Any | None = None + ) -> None: + self._settings = ensure_dc_dlc(settings) self._processor = processor def reset(self) -> None: @@ -231,17 +252,21 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: raise RuntimeError("No DLCLive model path configured.") init_start = time.perf_counter() + + enabled, margin, max_missing = self._settings.dynamic options = { "model_path": self._settings.model_path, "model_type": self._settings.model_type, "processor": self._processor, - "dynamic": list(self._settings.dynamic), + "dynamic": [enabled, margin, max_missing], "resize": self._settings.resize, "precision": self._settings.precision, "single_animal": self._settings.single_animal, } # Add device if specified in settings if self._settings.device is not None: + # FIXME @C-Achard make sure this is ok for tf + # maybe add smth in utils or config to validate device strings options["device"] = self._settings.device self._dlc = DLCLive(**options) @@ -392,7 +417,6 @@ class DLCService: def __init__(self): self._proc = DLCLiveProcessor() self.active = False - self.initialized = False self._last_pose: PoseResult | None = None self._processor_info = None @@ -400,6 +424,22 @@ def __init__(self): def processor(self): return self._proc._processor + # Expose key signals (to let MainWindow connect easily) + @property + def pose_ready(self): + return self._proc.pose_ready + + @property + def error(self): + return self._proc.error + + @property + def initialized(self): + return self._proc.initialized + + def enqueue(self, frame, ts): + self._proc.enqueue_frame(frame, ts) + def configure(self, settings: DLCProcessorSettings, scanned_processors: dict, selected_key) -> bool: processor = None if selected_key is not None and scanned_processors: @@ -427,19 +467,3 @@ def stats(self) -> ProcessorStats: def last_pose(self) -> PoseResult | None: return self._last_pose - - # Expose key signals (to let MainWindow connect easily) - @property - def pose_ready(self): - return self._proc.pose_ready - - @property - def error(self): - return self._proc.error - - @property - def initialized(self): - return self._proc.initialized - - def enqueue(self, frame, ts): - self._proc.enqueue_frame(frame, ts) diff --git a/dlclivegui/utils/config_models.py b/dlclivegui/utils/config_models.py new file mode 100644 index 0000000..96e874b --- /dev/null +++ b/dlclivegui/utils/config_models.py @@ -0,0 +1,182 @@ +# config_models.py +from __future__ import annotations + +from pathlib import Path +from typing import Any, Literal + +from pydantic import BaseModel, Field, field_validator, model_validator + +from dlclivegui.config import ( + ApplicationSettings, + BoundingBoxSettings, + CameraSettings, + DLCProcessorSettings, + MultiCameraSettings, + RecordingSettings, + VisualizationSettings, +) + +Backend = Literal["gentl", "opencv", "basler", "aravis"] # extend as needed +Rotation = Literal[0, 90, 180, 270] +TileLayout = Literal["auto", "2x2", "1x4", "4x1"] +Precision = Literal["FP32", "FP16"] + + +class CameraSettingsModel(BaseModel): + name: str = "Camera 0" + index: int = 0 + fps: float = 25.0 + backend: Backend = "gentl" + exposure: int = 500 # 0=auto else µs + gain: float = 10.0 # 0.0=auto else value + crop_x0: int = 0 + crop_y0: int = 0 + crop_x1: int = 0 + crop_y1: int = 0 + max_devices: int = 3 + rotation: Rotation = 0 + enabled: bool = True + properties: dict[str, Any] = Field(default_factory=dict) + + @field_validator("fps") + @classmethod + def _fps_positive(cls, v): + return float(v) if v and v > 0 else 30.0 + + @field_validator("exposure") + @classmethod + def _coerce_exposure(cls, v): # allow None->0 and int + return int(v) if v is not None else 0 + + @field_validator("gain") + @classmethod + def _coerce_gain(cls, v): + return float(v) if v is not None else 0.0 + + @model_validator(mode="after") + def _validate_crop(self): + for f in ("crop_x0", "crop_y0", "crop_x1", "crop_y1"): + setattr(self, f, max(0, int(getattr(self, f)))) + # Optional: if any crop is set, enforce x1>x0 and y1>y0 + if any([self.crop_x0, self.crop_y0, self.crop_x1, self.crop_y1]): + if not (self.crop_x1 > self.crop_x0 and self.crop_y1 > self.crop_y0): + raise ValueError("Invalid crop rectangle: require x1>x0 and y1>y0 when cropping is enabled.") + return self + + def get_crop_region(self) -> tuple[int, int, int, int] | None: + if self.crop_x0 == self.crop_y0 == self.crop_x1 == self.crop_y1 == 0: + return None + return (self.crop_x0, self.crop_y0, self.crop_x1, self.crop_y1) + + +class MultiCameraSettingsModel(BaseModel): + cameras: list[CameraSettingsModel] = Field(default_factory=list) + max_cameras: int = 4 + tile_layout: TileLayout = "auto" + + def get_active_cameras(self) -> list[CameraSettingsModel]: + return [c for c in self.cameras if c.enabled] + + @model_validator(mode="after") + def _enforce_max_active(self): + if len(self.get_active_cameras()) > self.max_cameras: + raise ValueError("Number of enabled cameras exceeds max_cameras.") + return self + + +class DynamicCropModel(BaseModel): + enabled: bool = False + margin: float = Field(default=0.5, ge=0.0, le=1.0) + max_missing_frames: int = Field(default=10, ge=0) + + @classmethod + def from_tupleish(cls, v): + # Accept (enabled, margin, max_missing_frames) + if isinstance(v, (list, tuple)) and len(v) == 3: + return cls(enabled=bool(v[0]), margin=float(v[1]), max_missing_frames=int(v[2])) + if isinstance(v, dict): + return cls(**v) + if isinstance(v, cls): + return v + return cls() + + +class DLCProcessorSettingsModel(BaseModel): + model_path: str = "" + model_directory: str = "." + device: str | None = "auto" # "cuda:0", "cpu", or None + dynamic: DynamicCropModel = Field(default_factory=DynamicCropModel) + resize: float = Field(default=1.0, gt=0) + precision: Precision = "FP32" + additional_options: dict[str, Any] = Field(default_factory=dict) + model_type: Literal["pytorch"] = "pytorch" + single_animal: bool = True + + @field_validator("dynamic", mode="before") + @classmethod + def _coerce_dynamic(cls, v): + return DynamicCropModel.from_tupleish(v) + + +class BoundingBoxSettingsModel(BaseModel): + enabled: bool = False + x0: int = 0 + y0: int = 0 + x1: int = 200 + y1: int = 100 + + @model_validator(mode="after") + def _bbox_logic(self): + if self.enabled and not (self.x1 > self.x0 and self.y1 > self.y0): + raise ValueError("Bounding box enabled but coordinates are invalid (x1>x0 and y1>y0 required).") + return self + + +class VisualizationSettingsModel(BaseModel): + p_cutoff: float = Field(default=0.6, ge=0.0, le=1.0) + colormap: str = "hot" + bbox_color: tuple[int, int, int] = (0, 0, 255) + + +class RecordingSettingsModel(BaseModel): + enabled: bool = False + directory: str = Field(default_factory=lambda: str(Path.home() / "Videos" / "deeplabcut-live")) + filename: str = "session.mp4" + container: Literal["mp4", "avi", "mov"] = "mp4" + codec: str = "libx264" + crf: int = Field(default=23, ge=0, le=51) + + +class ApplicationSettingsModel(BaseModel): + # optional: add a semantic version for migrations + version: int = 1 + camera: CameraSettingsModel = Field(default_factory=CameraSettingsModel) # kept for backward compat + multi_camera: MultiCameraSettingsModel = Field(default_factory=MultiCameraSettingsModel) + dlc: DLCProcessorSettingsModel = Field(default_factory=DLCProcessorSettingsModel) + recording: RecordingSettingsModel = Field(default_factory=RecordingSettingsModel) + bbox: BoundingBoxSettingsModel = Field(default_factory=BoundingBoxSettingsModel) + visualization: VisualizationSettingsModel = Field(default_factory=VisualizationSettingsModel) + + +def dc_to_model(dc_cfg: ApplicationSettings) -> ApplicationSettingsModel: + # Use your current dc.to_dict() then validate; preserves defaults + coercion + return ApplicationSettingsModel.model_validate(dc_cfg.to_dict()) + + +def model_to_dc(model: ApplicationSettingsModel) -> ApplicationSettings: + # Build dataclasses from validated data + cam_dc = CameraSettings(**model.camera.model_dump()) + mc_dc = MultiCameraSettings.from_dict(model.multi_camera.model_dump()) + dlc_dc = DLCProcessorSettings(**model.dlc.model_dump()) + rec_dc = RecordingSettings(**model.recording.model_dump()) + bbox_dc = BoundingBoxSettings(**model.bbox.model_dump()) + viz_dc = VisualizationSettings(**model.visualization.model_dump()) + + return ApplicationSettings( + camera=cam_dc, + multi_camera=mc_dc, + dlc=dlc_dc, + recording=rec_dc, + bbox=bbox_dc, + visualization=viz_dc, + ) diff --git a/dlclivegui/utils/settings_store.py b/dlclivegui/utils/settings_store.py new file mode 100644 index 0000000..6696376 --- /dev/null +++ b/dlclivegui/utils/settings_store.py @@ -0,0 +1,38 @@ +# settings_store.py + +from PySide6.QtCore import QSettings + +from .config_models import ApplicationSettingsModel + + +class QtSettingsStore: + def __init__(self, qsettings: QSettings | None = None): + self._s = qsettings or QSettings("DeepLabCut", "DLCLiveGUI") + + # --- lightweight prefs --- + def get_last_model_path(self) -> str | None: + v = self._s.value("dlc/last_model_path", "") + return str(v) if v else None + + def set_last_model_path(self, path: str) -> None: + self._s.setValue("dlc/last_model_path", path or "") + + def get_last_config_path(self) -> str | None: + v = self._s.value("app/last_config_path", "") + return str(v) if v else None + + def set_last_config_path(self, path: str) -> None: + self._s.setValue("app/last_config_path", path or "") + + # --- optional: snapshot full config as JSON in QSettings --- + def save_full_config_snapshot(self, cfg: ApplicationSettingsModel) -> None: + self._s.setValue("app/config_json", cfg.model_dump_json()) + + def load_full_config_snapshot(self) -> ApplicationSettingsModel | None: + raw = self._s.value("app/config_json", "") + if not raw: + return None + try: + return ApplicationSettingsModel.model_validate_json(str(raw)) + except Exception: + return None diff --git a/pyproject.toml b/pyproject.toml index 8998fba..46eb777 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,11 +26,12 @@ classifiers = [ ] dependencies = [ - # "deeplabcut-live", # might be missing timm and scipy + "deeplabcut-live", # might be missing timm and scipy "PySide6", "qdarkstyle", "numpy", "opencv-python", + "pydantic>=2.0", "vidgear[core]", "matplotlib", ] @@ -50,9 +51,7 @@ dev = [ "pytest-cov>=4.0", "pytest-mock>=3.10", "pytest-qt>=4.2", - "black>=23.0", - "flake8>=6.0", - "mypy>=1.0", + "pre-commit", ] test = [ "pytest>=7.0", @@ -84,12 +83,12 @@ python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] addopts = [ - "-v", "--strict-markers", - "--tb=short", - "--cov=dlclivegui", - "--cov-report=term-missing", - "--cov-report=html", + "--strict-config", + "--disable-warnings", + # "--maxfail=1", + "-ra", + "-q", ] markers = [ "unit: Unit tests for individual components", From d3eec5be4d4a8ad1ccea9a0cb492938e268f29dd Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 30 Jan 2026 13:41:57 +0100 Subject: [PATCH 62/69] Add unit and functional tests for cameras and DLC processor Introduce comprehensive test coverage for camera adapters, factory, and fake backends, as well as the DLC processor service. Includes fixtures and test doubles for isolated testing, and covers configuration, initialization, frame processing, error handling, and statistics computation. --- tests/cameras/test_adapters.py | 41 +++++++ tests/cameras/test_factory.py | 109 +++++++++++++++++ tests/cameras/test_fake_backend.py | 44 +++++++ tests/conftest.py | 60 ++++++++++ tests/services/test_dlc_processor.py | 168 +++++++++++++++++++++++++++ 5 files changed, 422 insertions(+) create mode 100644 tests/cameras/test_adapters.py create mode 100644 tests/cameras/test_factory.py create mode 100644 tests/cameras/test_fake_backend.py create mode 100644 tests/conftest.py create mode 100644 tests/services/test_dlc_processor.py diff --git a/tests/cameras/test_adapters.py b/tests/cameras/test_adapters.py new file mode 100644 index 0000000..587e5a4 --- /dev/null +++ b/tests/cameras/test_adapters.py @@ -0,0 +1,41 @@ +# tests/cameras/test_adapters.py +import pytest + +from dlclivegui.cameras.config_adapters import ensure_dc_camera +from dlclivegui.config import CameraSettings + +# If available: +try: + from dlclivegui.utils.config_models import CameraSettingsModel + + HAS_PYD = True +except Exception: + HAS_PYD = False + + +@pytest.mark.unit +def test_ensure_dc_from_dataclass(): + dc = CameraSettings(name="TestCam", index=2, fps=0) + out = ensure_dc_camera(dc) + assert isinstance(out, CameraSettings) + assert out is not dc # must be deep-copied + assert out.fps > 0 # apply_defaults triggers replacement of 0fps + + +@pytest.mark.unit +@pytest.mark.skipif(not HAS_PYD, reason="Pydantic models not installed yet") +def test_ensure_dc_from_pydantic(): + pm = CameraSettingsModel(name="PM", index=1, fps=15) + out = ensure_dc_camera(pm) + assert isinstance(out, CameraSettings) + assert out.index == 1 + assert out.fps == 15.0 + + +@pytest.mark.unit +def test_ensure_dc_from_dict(): + d = {"name": "DictCam", "index": 5, "fps": 60, "backend": "opencv"} + out = ensure_dc_camera(d) + assert isinstance(out, CameraSettings) + assert out.index == 5 + assert out.backend == "opencv" diff --git a/tests/cameras/test_factory.py b/tests/cameras/test_factory.py new file mode 100644 index 0000000..78d560b --- /dev/null +++ b/tests/cameras/test_factory.py @@ -0,0 +1,109 @@ +# tests/cameras/test_factory_basic.py +import sys +import types + +import pytest + +from dlclivegui.cameras import CameraFactory, DetectedCamera, base +from dlclivegui.config import CameraSettings + + +@pytest.mark.unit +def test_create_uses_backend_class(): + """Ensure CameraFactory.create instantiates correct backend class.""" + + # Create fake module + backend class + fake_mod = types.ModuleType("fake_backend_mod") + + class FakeBackend(base.CameraBackend): + opened = False + closed = False + + def open(self): + FakeBackend.opened = True + + def read(self): + return None, 0.0 + + def close(self): + FakeBackend.closed = True + + fake_mod.FakeBackend = FakeBackend + sys.modules["fake_backend_mod"] = fake_mod + base.register_backend_direct("fake", FakeBackend) + + settings = CameraSettings(backend="fake", index=0) + backend = CameraFactory.create(settings) + + assert isinstance(backend, FakeBackend) + backend.open() + backend.close() + + assert FakeBackend.opened is True + assert FakeBackend.closed is True + + +@pytest.mark.unit +def test_check_camera_available_quick_ping(): + mod = types.ModuleType("mock_mod") + + class MockBackend(base.CameraBackend): + @classmethod + def is_available(cls): + return True + + @staticmethod + def quick_ping(i): + return i == 0 + + def open(self): + pass + + def read(self): + return None, 0.0 + + def close(self): + pass + + mod.MockBackend = MockBackend + sys.modules["mock_mod"] = mod + base.register_backend_direct("mock", MockBackend) + + ok, msg = CameraFactory.check_camera_available(CameraSettings(backend="mock", index=0)) + assert ok is True + + ok, msg = CameraFactory.check_camera_available(CameraSettings(backend="mock", index=3)) + assert ok is False + + +@pytest.mark.unit +def test_detect_cameras(): + mod = types.ModuleType("detect_mod") + + class DetectBackend(base.CameraBackend): + @classmethod + def is_available(cls): + return True + + @staticmethod + def quick_ping(i): + return i in (0, 2) # pretend devices 0 and 2 exist + + def open(self): + if self.settings.index not in (0, 2): + raise RuntimeError("no device") + + def read(self): + return None, 0 + + def close(self): + pass + + mod.DetectBackend = DetectBackend + sys.modules["detect_mod"] = mod + base.register_backend_direct("detect", DetectBackend) + + detected = CameraFactory.detect_cameras("detect", max_devices=4) + assert isinstance(detected, list) + assert [c.index for c in detected] == [0, 2] + assert all(isinstance(c, DetectedCamera) for c in detected) diff --git a/tests/cameras/test_fake_backend.py b/tests/cameras/test_fake_backend.py new file mode 100644 index 0000000..bab40a7 --- /dev/null +++ b/tests/cameras/test_fake_backend.py @@ -0,0 +1,44 @@ +# tests/cameras/test_fake_backend.py +import sys +import types + +import numpy as np +import pytest + +from dlclivegui.cameras import CameraFactory, base +from dlclivegui.config import CameraSettings + + +@pytest.mark.functional +def test_fake_backend_e2e(): + mod = types.ModuleType("fake_mod") + + class FakeBackend(base.CameraBackend): + @classmethod + def is_available(cls): + return True + + def open(self): + self._opened = True + + def read(self): + assert self._opened + img = np.zeros((10, 20, 3), dtype=np.uint8) + return img, 123.456 + + def close(self): + self._opened = False + + mod.FakeBackend = FakeBackend + sys.modules["fake_mod"] = mod + base.register_backend_direct("fake2", FakeBackend) + + s = CameraSettings(backend="fake2", name="X") + cam = CameraFactory.create(s) + cam.open() + frame, ts = cam.read() + + assert frame.shape == (10, 20, 3) + assert ts == 123.456 + + cam.close() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a274e23 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,60 @@ +# tests/dlc/conftest.py + +from __future__ import annotations + +import numpy as np +import pytest + +from dlclivegui.config import DLCProcessorSettings +from dlclivegui.utils.config_models import DLCProcessorSettingsModel + +# --------------------------------------------------------------------- +# Test doubles +# --------------------------------------------------------------------- + + +class FakeDLCLive: + """A minimal fake DLCLive object for testing.""" + + def __init__(self, **opts): + self.opts = opts + self.init_called = False + self.pose_calls = 0 + + def init_inference(self, frame): + self.init_called = True + + def get_pose(self, frame, frame_time=None): + self.pose_calls += 1 + # Deterministic small pose array + return np.ones((2, 2), dtype=float) + + +# --------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------- + + +@pytest.fixture +def monkeypatch_dlclive(monkeypatch): + """ + Replace the dlclive.DLCLive import with FakeDLCLive *within* the dlc_processor module. + + Scope is function-level by default, which keeps tests isolated. + """ + from dlclivegui.services import dlc_processor + + monkeypatch.setattr(dlc_processor, "DLCLive", FakeDLCLive) + return FakeDLCLive + + +@pytest.fixture +def settings_dc(): + """A standard DLCProcessorSettings dataclass for tests.""" + return DLCProcessorSettings(model_path="dummy.pt") + + +@pytest.fixture +def settings_model(): + """A standard Pydantic DLCProcessorSettingsModel for tests.""" + return DLCProcessorSettingsModel(model_path="dummy.pt") diff --git a/tests/services/test_dlc_processor.py b/tests/services/test_dlc_processor.py new file mode 100644 index 0000000..9fed8b3 --- /dev/null +++ b/tests/services/test_dlc_processor.py @@ -0,0 +1,168 @@ +import numpy as np +import pytest + +from dlclivegui.config import DLCProcessorSettings +from dlclivegui.services.dlc_processor import ( + DLCLiveProcessor, + ProcessorStats, +) + +# --------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------- + + +@pytest.mark.unit +def test_configure_accepts_dataclass(settings_dc, monkeypatch_dlclive): + proc = DLCLiveProcessor() + proc.configure(settings_dc) + + assert proc._settings.model_path == "dummy.pt" + assert proc._processor is None + + +@pytest.mark.unit +def test_configure_accepts_pydantic(settings_model, monkeypatch_dlclive): + proc = DLCLiveProcessor() + proc.configure(settings_model) + + # Should have normalized to dataclass internally + assert isinstance(proc._settings, DLCProcessorSettings) + assert proc._settings.model_path == "dummy.pt" + + +@pytest.mark.unit +def test_worker_initializes_on_first_frame(qtbot, monkeypatch_dlclive, settings_dc): + proc = DLCLiveProcessor() + proc.configure(settings_dc) + + try: + # First enqueued frame triggers worker start + initialization. + with qtbot.waitSignal(proc.initialized, timeout=1500) as init_blocker: + proc.enqueue_frame(np.zeros((100, 100, 3), dtype=np.uint8), timestamp=1.0) + + assert init_blocker.args == [True] + assert proc._initialized + assert getattr(proc._dlc, "init_called", False) + + # Optional: also ensure the init pose was delivered + qtbot.waitSignal(proc.pose_ready, timeout=1500) + + finally: + proc.reset() # Ensure thread cleanup + + +@pytest.mark.unit +def test_worker_processes_frames(qtbot, monkeypatch_dlclive, settings_dc): + proc = DLCLiveProcessor() + proc.configure(settings_dc) + + try: + frame = np.zeros((64, 64, 3), dtype=np.uint8) + + # The first frame should initialize DLCLive (initialized -> True) and produce the first pose. + with qtbot.waitSignal(proc.initialized, timeout=1500): + proc.enqueue_frame(frame, timestamp=1.0) + + # Wait for init pose + qtbot.waitSignal(proc.pose_ready, timeout=1500) + + # Enqueue more frames; wait for at least one more pose + for i in range(3): + proc.enqueue_frame(frame, timestamp=2.0 + i) + + qtbot.waitSignal(proc.pose_ready, timeout=1500) + + assert proc._frames_processed >= 2 # at least init + one more + + finally: + proc.reset() + + +@pytest.mark.unit +def test_queue_full_drops_frames(qtbot, monkeypatch_dlclive, settings_dc): + proc = DLCLiveProcessor() + proc.configure(settings_dc) + + try: + frame = np.zeros((32, 32, 3), dtype=np.uint8) + + # Start the worker with the first frame + with qtbot.waitSignal(proc.initialized, timeout=1500): + proc.enqueue_frame(frame, 1.0) + + # Flood the 1-slot queue to force drops + for _ in range(50): + proc.enqueue_frame(frame, 2.0) + + # Wait until we observe dropped frames + qtbot.waitUntil(lambda: proc._frames_dropped > 0, timeout=1500) + assert proc._frames_dropped > 0 + + finally: + proc.reset() + + +@pytest.mark.unit +def test_error_signal_on_initialization_failure(qtbot, monkeypatch): + """Simulate DLCLive raising on init.""" + + class FailingDLCLive: + def __init__(self, **opts): + raise RuntimeError("bad model") + + from dlclivegui.services import dlc_processor + + monkeypatch.setattr(dlc_processor, "DLCLive", FailingDLCLive) + + proc = DLCLiveProcessor() + proc.configure(DLCProcessorSettings(model_path="fail.pt")) + + try: + frame = np.zeros((10, 10, 3), dtype=np.uint8) + + error_args = [] + init_args = [] + + proc.error.connect(lambda msg: error_args.append(msg)) + proc.initialized.connect(lambda ok: init_args.append(ok)) + + with qtbot.waitSignals([proc.error, proc.initialized], timeout=1500): + proc.enqueue_frame(frame, 1.0) + + assert len(error_args) == 1 + assert "bad model" in error_args[0] + + assert len(init_args) == 1 + assert init_args[0] is False + + finally: + proc.reset() + + +@pytest.mark.unit +def test_stats_computation(qtbot, monkeypatch_dlclive, settings_dc): + proc = DLCLiveProcessor() + proc.configure(settings_dc) + + try: + frame = np.zeros((64, 64, 3), dtype=np.uint8) + + # Start and wait for init + with qtbot.waitSignal(proc.initialized, timeout=1500): + proc.enqueue_frame(frame, 1.0) + + # Wait for init pose + qtbot.waitSignal(proc.pose_ready, timeout=1500) + + # Enqueue a second frame and wait for its pose + proc.enqueue_frame(frame, 2.0) + qtbot.waitSignal(proc.pose_ready, timeout=1500) + + stats = proc.get_stats() + assert isinstance(stats, ProcessorStats) + assert stats.frames_processed >= 1 + assert stats.processing_fps >= 0 + + finally: + proc.reset() From f6968fb9b7748197fbda71a03a82a9654a4c8132 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 30 Jan 2026 13:50:20 +0100 Subject: [PATCH 63/69] Add support for flexible camera settings and tests Enhanced MultiCameraController to accept various camera settings formats (dataclasses, dicts, or pydantic models) and normalize them. Added a utility for converting settings, updated worker and controller logic, and introduced a new test for mixed input types. Also added a FakeBackend and patch_factory fixture for testing. --- .../services/multi_camera_controller.py | 64 +++++++++++++------ tests/conftest.py | 40 ++++++++++++ tests/services/test_multicam_controller.py | 36 +++++++++++ 3 files changed, 121 insertions(+), 19 deletions(-) create mode 100644 tests/services/test_multicam_controller.py diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index da1e2d9..2ec9df1 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -4,6 +4,7 @@ import logging import time +from collections.abc import Sequence from dataclasses import dataclass from threading import Event, Lock @@ -13,11 +14,33 @@ from dlclivegui.cameras import CameraFactory from dlclivegui.cameras.base import CameraBackend +from dlclivegui.cameras.config_adapters import CameraSettingsLike, ensure_dc_camera from dlclivegui.config import CameraSettings +from dlclivegui.utils.config_models import MultiCameraSettingsModel LOGGER = logging.getLogger(__name__) +def list_of_dc_cameras( + payload: Sequence[CameraSettingsLike] | MultiCameraSettingsModel, +) -> list[CameraSettings]: + """ + Convert either: + - a list/tuple of CameraSettingsLike, or + - a MultiCameraSettingsModel + into a list[CameraSettings] (dataclass), applying defaults. + """ + if MultiCameraSettingsModel is not None and isinstance(payload, MultiCameraSettingsModel): + # Use only enabled cameras (honor the model’s method) + cams = payload.get_active_cameras() + return [ensure_dc_camera(c) for c in cams] + + if isinstance(payload, (list, tuple)): + return [ensure_dc_camera(c) for c in payload] + + raise TypeError("Expected a list of CameraSettings-like objects or MultiCameraSettingsModel.") + + @dataclass class MultiFrameData: """Container for frames from multiple cameras.""" @@ -36,10 +59,10 @@ class SingleCameraWorker(QObject): started = Signal(str) # camera_id stopped = Signal(str) # camera_id - def __init__(self, camera_id: str, settings: CameraSettings): + def __init__(self, camera_id: str, settings: CameraSettingsLike): super().__init__() self._camera_id = camera_id - self._settings = settings + self._settings = ensure_dc_camera(settings) self._stop_event = Event() self._backend: CameraBackend | None = None self._max_consecutive_errors = 5 @@ -99,9 +122,10 @@ def stop(self) -> None: self._stop_event.set() -def get_camera_id(settings: CameraSettings) -> str: +def get_camera_id(settings: CameraSettingsLike) -> str: """Generate a unique camera ID from settings.""" - return f"{settings.backend}:{settings.index}" + dc = ensure_dc_camera(settings) + return f"{dc.backend}:{dc.index}" class MultiCameraController(QObject): @@ -139,21 +163,20 @@ def get_active_count(self) -> int: """Get the number of active cameras.""" return len(self._started_cameras) - def start(self, camera_settings: list[CameraSettings]) -> None: - """Start multiple cameras. - - Parameters - ---------- - camera_settings : List[CameraSettings] - List of camera settings for each camera to start. - Maximum of MAX_CAMERAS cameras allowed. - """ + def start(self, camera_settings: list[CameraSettingsLike]) -> None: + """Start multiple cameras; accepts dataclasses, pydantic models, or dicts.""" if self._running: LOGGER.warning("Multi-camera controller already running") return - # Limit to MAX_CAMERAS - active_settings = [s for s in camera_settings if s.enabled][: self.MAX_CAMERAS] + # Normalize and limit + try: + dc_list = list_of_dc_cameras(camera_settings) + except TypeError: + # fallback if plain list contained dataclasses or dicts only + dc_list = [ensure_dc_camera(cs) for cs in camera_settings] + + active_settings = [s for s in dc_list if s.enabled][: self.MAX_CAMERAS] if not active_settings: LOGGER.warning("No active cameras to start") return @@ -168,19 +191,22 @@ def start(self, camera_settings: list[CameraSettings]) -> None: for settings in active_settings: self._start_camera(settings) - def _start_camera(self, settings: CameraSettings) -> None: + def _start_camera(self, settings: CameraSettingsLike) -> None: """Start a single camera.""" cam_id = get_camera_id(settings) if cam_id in self._workers: LOGGER.warning(f"Camera {cam_id} already has a worker") return - self._settings[cam_id] = settings - worker = SingleCameraWorker(cam_id, settings) + # Normalize and store the dataclass once + dc = ensure_dc_camera(settings) + self._settings[cam_id] = dc + + worker = SingleCameraWorker(cam_id, dc) thread = QThread() worker.moveToThread(thread) - # Connect signals + # Connections unchanged thread.started.connect(worker.run) worker.frame_captured.connect(self._on_frame_captured) worker.started.connect(self._on_camera_started) diff --git a/tests/conftest.py b/tests/conftest.py index a274e23..151f4f8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,9 +2,13 @@ from __future__ import annotations +import time + import numpy as np import pytest +from dlclivegui.cameras import CameraFactory +from dlclivegui.cameras.base import CameraBackend from dlclivegui.config import DLCProcessorSettings from dlclivegui.utils.config_models import DLCProcessorSettingsModel @@ -30,9 +34,45 @@ def get_pose(self, frame, frame_time=None): return np.ones((2, 2), dtype=float) +class FakeBackend(CameraBackend): + def __init__(self, settings): + super().__init__(settings) + self._opened = False + self._counter = 0 + + @classmethod + def is_available(cls) -> bool: + return True + + def open(self) -> None: + self._opened = True + + def read(self): + # Produce a deterministic small frame + if not self._opened: + raise RuntimeError("not opened") + self._counter += 1 + frame = np.zeros((48, 64, 3), dtype=np.uint8) + ts = time.time() + return frame, ts + + def close(self) -> None: + self._opened = False + + def stop(self) -> None: + pass + + # --------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------- +@pytest.fixture +def patch_factory(monkeypatch): + def _create(settings): + return FakeBackend(settings) + + monkeypatch.setattr(CameraFactory, "create", staticmethod(_create)) + return _create @pytest.fixture diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py new file mode 100644 index 0000000..63110f7 --- /dev/null +++ b/tests/services/test_multicam_controller.py @@ -0,0 +1,36 @@ +# tests/services/test_multicam_controller.py +import pytest + +from dlclivegui.config import CameraSettings +from dlclivegui.services.multi_camera_controller import MultiCameraController + + +@pytest.mark.unit +def test_start_and_frames(qtbot, patch_factory): + mc = MultiCameraController() + + # One dataclass + one dict (simulate mixed inputs) + cam1 = CameraSettings(name="C1", backend="opencv", index=0, fps=25.0).apply_defaults() + cam2 = {"name": "C2", "backend": "opencv", "index": 1, "fps": 30.0, "enabled": True} + + frames_seen = [] + + def on_ready(mfd): + frames_seen.append((mfd.source_camera_id, {k: v.shape for k, v in mfd.frames.items()})) + + mc.frame_ready.connect(on_ready) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam1, cam2]) + + # Wait for at least one composite emission + qtbot.waitUntil(lambda: len(frames_seen) >= 1, timeout=2000) + + assert mc.is_running() + # We should have at least one entry with 1 or 2 frames (depending on timing) + assert any(len(shape_map) >= 1 for _, shape_map in frames_seen) + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) From b0f82ea6d33983f1a1a1f227318f4f8e07df49b7 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 30 Jan 2026 13:53:41 +0100 Subject: [PATCH 64/69] Add tests for rotation, crop, and init failure in MultiCameraController Added unit tests to verify frame rotation and cropping behavior, as well as handling of camera initialization failures in MultiCameraController. These tests improve coverage for edge cases and error handling. --- tests/services/test_multicam_controller.py | 60 +++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 63110f7..1ae32e8 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -1,8 +1,9 @@ # tests/services/test_multicam_controller.py import pytest +from dlclivegui.cameras.factory import CameraFactory from dlclivegui.config import CameraSettings -from dlclivegui.services.multi_camera_controller import MultiCameraController +from dlclivegui.services.multi_camera_controller import MultiCameraController, get_camera_id @pytest.mark.unit @@ -34,3 +35,60 @@ def on_ready(mfd): finally: with qtbot.waitSignal(mc.all_stopped, timeout=2000): mc.stop(wait=True) + + +@pytest.mark.unit +def test_rotation_and_crop(qtbot, patch_factory): + mc = MultiCameraController() + + # 64x48 frame; rotate 90 => 48x64 then crop to 32x32 box + cam = CameraSettings( + name="C", + backend="opencv", + index=0, + enabled=True, + rotation=90, + crop_x0=0, + crop_y0=0, + crop_x1=32, + crop_y1=32, + ).apply_defaults() + + last_shape = {"shape": None} + + def on_ready(mfd): + f = mfd.frames.get(get_camera_id(cam)) + if f is not None: + last_shape["shape"] = f.shape + + mc.frame_ready.connect(on_ready) + + try: + with qtbot.waitSignal(mc.all_started, timeout=1500): + mc.start([cam]) + + # Wait until a rotated+cropped frame arrives + qtbot.waitUntil(lambda: last_shape["shape"] is not None, timeout=2000) + + # Expect height=32, width=32, 3 channels + assert last_shape["shape"] == (32, 32, 3) + + finally: + with qtbot.waitSignal(mc.all_stopped, timeout=2000): + mc.stop(wait=True) + + +@pytest.mark.unit +def test_initialization_failure(qtbot, monkeypatch): + # Make factory.create raise + def _create(_settings): + raise RuntimeError("no device") + + monkeypatch.setattr(CameraFactory, "create", staticmethod(_create)) + + mc = MultiCameraController() + cam = CameraSettings(name="C", backend="opencv", index=0, enabled=True) + + # Expect initialization_failed with the camera id + with qtbot.waitSignals([mc.initialization_failed, mc.all_stopped], timeout=2000) as _: + mc.start([cam]) From 7edc152bb33591992ddcfe7889e406a936bf79bf Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 30 Jan 2026 14:14:31 +0100 Subject: [PATCH 65/69] Skip task_done for sentinel; add GUI e2e tests Avoid calling queue.task_done() for the shutdown sentinel in DLCLiveProcessor worker loop and guard against ValueError if task_done is called unexpectedly. This prevents erroneous task accounting when the sentinel is used to stop the thread. Add GUI end-to-end tests and test fixtures: introduce tests/services/gui/conftest.py to provide a headless DLCLiveMainWindow fixture, patch CameraFactory and DLCLive to use test doubles, and add tests/services/gui/test_e2e.py which exercises preview rendering and inference flow. Minor test updates: supply a FakeBackend import for camera factory tests and add stop() stubs to fake backend implementations used in tests. --- dlclivegui/services/dlc_processor.py | 6 +- tests/cameras/test_factory.py | 38 +-------- tests/cameras/test_fake_backend.py | 3 + tests/services/gui/conftest.py | 123 +++++++++++++++++++++++++++ tests/services/gui/test_e2e.py | 99 +++++++++++++++++++++ 5 files changed, 233 insertions(+), 36 deletions(-) create mode 100644 tests/services/gui/conftest.py create mode 100644 tests/services/gui/test_e2e.py diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index 0ee7e56..2e8bf70 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -406,7 +406,11 @@ def timed_process(pose, _op=original_process, _holder=processor_time_holder, **k logger.exception("Pose inference failed", exc_info=exc) self.error.emit(str(exc)) finally: - self._queue.task_done() + if item is not _SENTINEL: + try: + self._queue.task_done() + except ValueError: + pass logger.info("DLC worker thread exiting") diff --git a/tests/cameras/test_factory.py b/tests/cameras/test_factory.py index 78d560b..d67fa0e 100644 --- a/tests/cameras/test_factory.py +++ b/tests/cameras/test_factory.py @@ -8,41 +8,6 @@ from dlclivegui.config import CameraSettings -@pytest.mark.unit -def test_create_uses_backend_class(): - """Ensure CameraFactory.create instantiates correct backend class.""" - - # Create fake module + backend class - fake_mod = types.ModuleType("fake_backend_mod") - - class FakeBackend(base.CameraBackend): - opened = False - closed = False - - def open(self): - FakeBackend.opened = True - - def read(self): - return None, 0.0 - - def close(self): - FakeBackend.closed = True - - fake_mod.FakeBackend = FakeBackend - sys.modules["fake_backend_mod"] = fake_mod - base.register_backend_direct("fake", FakeBackend) - - settings = CameraSettings(backend="fake", index=0) - backend = CameraFactory.create(settings) - - assert isinstance(backend, FakeBackend) - backend.open() - backend.close() - - assert FakeBackend.opened is True - assert FakeBackend.closed is True - - @pytest.mark.unit def test_check_camera_available_quick_ping(): mod = types.ModuleType("mock_mod") @@ -99,6 +64,9 @@ def read(self): def close(self): pass + def stop(self): + pass + mod.DetectBackend = DetectBackend sys.modules["detect_mod"] = mod base.register_backend_direct("detect", DetectBackend) diff --git a/tests/cameras/test_fake_backend.py b/tests/cameras/test_fake_backend.py index bab40a7..750e2da 100644 --- a/tests/cameras/test_fake_backend.py +++ b/tests/cameras/test_fake_backend.py @@ -29,6 +29,9 @@ def read(self): def close(self): self._opened = False + def stop(self): + pass + mod.FakeBackend = FakeBackend sys.modules["fake_mod"] = mod base.register_backend_direct("fake2", FakeBackend) diff --git a/tests/services/gui/conftest.py b/tests/services/gui/conftest.py new file mode 100644 index 0000000..6b8e1c2 --- /dev/null +++ b/tests/services/gui/conftest.py @@ -0,0 +1,123 @@ +# tests/services/gui/conftest.py +from __future__ import annotations + +import pytest +from PySide6.QtCore import Qt + +from dlclivegui.cameras import CameraFactory +from dlclivegui.config import ( + DEFAULT_CONFIG, + ApplicationSettings, + CameraSettings, + MultiCameraSettings, +) +from dlclivegui.gui.main_window import DLCLiveMainWindow +from tests.conftest import FakeBackend, FakeDLCLive # noqa: F401 + +# ---------- Test helpers: application configuration with two fake cameras ---------- + + +@pytest.fixture +def app_config_two_cams(tmp_path) -> ApplicationSettings: + """An app config with two enabled cameras (fake backend) and writable recording dir.""" + cfg = ApplicationSettings.from_dict(DEFAULT_CONFIG.to_dict()) + + cam_a = CameraSettings(name="CamA", backend="fake", index=0, enabled=True, fps=30.0) + cam_b = CameraSettings(name="CamB", backend="fake", index=1, enabled=True, fps=30.0) + + cfg.multi_camera = MultiCameraSettings(cameras=[cam_a, cam_b], max_cameras=4, tile_layout="auto") + cfg.camera = cam_a # kept for backward-compat single-camera access in UI + + cfg.recording.directory = str(tmp_path / "videos") + cfg.recording.enabled = True + return cfg + + +# ---------- Autouse patches to keep GUI tests fast and side-effect-free ---------- + + +@pytest.fixture(autouse=True) +def _patch_camera_factory(monkeypatch): + """ + Replace hardware backends with FakeBackend globally for GUI tests. + We patch at the central creation point used by the controller. + """ + + def _create_stub(settings: CameraSettings): + # FakeBackend ignores 'backend' and produces deterministic frames + return FakeBackend(settings) + + monkeypatch.setattr(CameraFactory, "create", staticmethod(_create_stub)) + + +@pytest.fixture(autouse=True) +def _patch_camera_validation(monkeypatch): + """ + Accept all cameras regardless of backend and silence warning/error dialogs in the window. + """ + # 1) Pretend all cameras are available + monkeypatch.setattr( + CameraFactory, + "check_camera_available", + staticmethod(lambda cam: (True, "")), + ) + + # 2) Silence GUI dialogs during tests + monkeypatch.setattr(DLCLiveMainWindow, "_show_warning", lambda self, msg: None) + monkeypatch.setattr(DLCLiveMainWindow, "_show_error", lambda self, msg: None) + + +@pytest.fixture(autouse=True) +def _patch_dlclive_to_fake(monkeypatch): + """ + Ensure dlclive is replaced by the test double in the DLCLiveProcessor module. + (The window will instantiate DLCLiveProcessor internally, which imports DLCLive.) + """ + from dlclivegui.services import dlc_processor as dlcp_mod + + monkeypatch.setattr(dlcp_mod, "DLCLive", FakeDLCLive) + + +# ---------- The main window fixture (focus) ---------- + + +@pytest.fixture +def window(qtbot, app_config_two_cams) -> DLCLiveMainWindow: + """ + Construct the real DLCLiveMainWindow with a valid two-camera config, + make it headless, show it, and yield it. Threads and timers are managed by close(). + """ + w = DLCLiveMainWindow(config=app_config_two_cams) + qtbot.addWidget(w) + # Don't pop windows in CI: + w.setAttribute(Qt.WA_DontShowOnScreen, True) + w.show() + + try: + yield w + finally: + # The window's closeEvent stops controllers, recorders, timers, etc. + # Use .close() to trigger the standard shutdown path. + try: + w.close() + except Exception: + pass + + +# ---------- Convenience fixtures that expose controller/processor from the window ---------- + + +@pytest.fixture +def multi_camera_controller(window): + """ + Return the *controller used by the window* so tests can wait on all_started/all_stopped. + """ + return window.multi_camera_controller + + +@pytest.fixture +def dlc_processor(window): + """ + Return the *processor used by the window* so tests can connect to pose/initialized. + """ + return window._dlc diff --git a/tests/services/gui/test_e2e.py b/tests/services/gui/test_e2e.py new file mode 100644 index 0000000..83d4bcc --- /dev/null +++ b/tests/services/gui/test_e2e.py @@ -0,0 +1,99 @@ +import pytest +from PySide6.QtCore import Qt +from PySide6.QtGui import QImage + + +def pixmap_bytes(label) -> bytes: + pm = label.pixmap() + assert pm is not None and not pm.isNull() + img = pm.toImage().convertToFormat(QImage.Format.Format_RGB888) + ptr = img.bits() + ptr.setsize(img.sizeInBytes()) + return bytes(ptr) + + +@pytest.mark.gui +@pytest.mark.functional +def test_preview_renders_frames(qtbot, window, multi_camera_controller): + """ + Validate that: + - Preview starts (`preview_button` clicked) + - Camera controller emits all_started + - GUI receives and renders frames to video_label.pixmap() + - Preview stops cleanly + """ + + w = window + ctrl = multi_camera_controller + + with qtbot.waitSignal(ctrl.all_started, timeout=4000): + qtbot.mouseClick(w.preview_button, Qt.LeftButton) + + qtbot.waitUntil( + lambda: w.video_label.pixmap() is not None and not w.video_label.pixmap().isNull(), + timeout=6000, + ) + + with qtbot.waitSignal(ctrl.all_stopped, timeout=4000): + qtbot.mouseClick(w.stop_preview_button, Qt.LeftButton) + + assert not ctrl.is_running() + + +@pytest.mark.gui +@pytest.mark.functional +def test_start_inference_emits_pose(qtbot, window, multi_camera_controller, dlc_processor): + """ + Validate that: + - Preview is running + - GUI sets a valid model path + - Start Inference triggers DLCLiveProcessor initialization + - initialized(True) fires + - pose_ready fires at least once + - Preview can be stopped cleanly + """ + + w = window + ctrl = multi_camera_controller + dlc = dlc_processor + + # Start preview first + with qtbot.waitSignal(ctrl.all_started, timeout=4000): + qtbot.mouseClick(w.preview_button, Qt.LeftButton) + + # Ensure preview is producing actual GUI frames + qtbot.waitUntil( + lambda: w.video_label.pixmap() is not None and not w.video_label.pixmap().isNull(), + timeout=6000, + ) + + w.model_path_edit.setText("dummy_model.pt") + pose_count = [0] + + def _on_pose(result): + pose_count[0] += 1 + + dlc.pose_ready.connect(_on_pose) + + try: + # Click "Start Inference" and wait for DLCLiveProcessor.initialized(True) + with qtbot.waitSignal(dlc.initialized, timeout=7000) as init_blocker: + qtbot.mouseClick(w.start_inference_button, Qt.LeftButton) + + # Validate initialized==True + assert init_blocker.args[0] is True + + # Wait until at least one pose is emitted + qtbot.waitUntil(lambda: pose_count[0] >= 1, timeout=7000) + + finally: + # Avoid leaking connections across tests + try: + dlc.pose_ready.disconnect(_on_pose) + except Exception: + pass + + with qtbot.waitSignal(ctrl.all_stopped, timeout=4000): + qtbot.mouseClick(w.stop_preview_button, Qt.LeftButton) + + assert not ctrl.is_running() From f649d359c64644b4dd103b74eccd466b66bc0b1d Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 30 Jan 2026 15:43:17 +0100 Subject: [PATCH 66/69] Use Pydantic models and lazy-load camera backends Migrate config dataclasses to Pydantic models and add lazy backend discovery. Key changes: - Replace dataclass-based CameraSettings/ApplicationSettings/etc. with Pydantic models in utils/config_models.py (CameraSettingsModel, ApplicationSettingsModel, MultiCameraSettingsModel, etc.). Added model helpers (from_dict/to_dict, load/save, output_path, writegear_options, convenience methods). - Update camera backend API to accept CameraSettingsModel and tighten abstract methods (raise NotImplementedError by default). - Rename and expose internal backend registry as _BACKEND_REGISTRY and update factory code to reference it. - Implement lazy backend loading in cameras.factory: import backend packages/modules via importlib/pkgutil on first use so third-party or on-disk backend packages can register themselves via @register_backend. Added guard to raise if no backends are registered in GUI dialog. - Adapt GUI (camera_config_dialog.py, main_window.py) to use the new models and validate/coerce form data via Pydantic models. Added form->model builder and updated preview/reconcile logic to operate on models. - Add dlclivegui/cameras/backends/__init__.py to import built-in backend modules. - Add tests/cameras/test_backend_discovery.py to verify lazy discovery, detection and creation of a temporarily installed test backend package. Notes/compatibility: - Public APIs that previously accepted dataclasses now expect the new *Model types (e.g. CameraSettingsModel, ApplicationSettingsModel). This is a breaking change; conversion helpers/compat shims were added where needed in the GUI. - Factory now ensures backends are imported before listing/using them, enabling plugin/backends discovered on disk. - Small behavioral change: CameraBackend methods now explicitly raise NotImplementedError unless overridden. --- dlclivegui/cameras/__init__.py | 4 +- dlclivegui/cameras/backends/__init__.py | 11 ++ dlclivegui/cameras/base.py | 11 +- dlclivegui/cameras/factory.py | 66 +++++++--- dlclivegui/gui/camera_config_dialog.py | 84 ++++++++----- dlclivegui/gui/main_window.py | 62 ++++++---- dlclivegui/utils/config_models.py | 152 +++++++++++++++++++----- tests/cameras/test_backend_discovery.py | 130 ++++++++++++++++++++ 8 files changed, 411 insertions(+), 109 deletions(-) create mode 100644 dlclivegui/cameras/backends/__init__.py create mode 100644 tests/cameras/test_backend_discovery.py diff --git a/dlclivegui/cameras/__init__.py b/dlclivegui/cameras/__init__.py index b5dad4f..4bca7a7 100644 --- a/dlclivegui/cameras/__init__.py +++ b/dlclivegui/cameras/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from ..config import CameraSettings -from .base import _BACKEND_REGISTRY as BACKENDS +from .base import _BACKEND_REGISTRY as _BACKEND_REGISTRY from .base import CameraBackend from .config_adapters import CameraSettingsLike, ensure_dc_camera from .factory import CameraFactory, DetectedCamera @@ -15,5 +15,5 @@ "DetectedCamera", "CameraSettingsLike", "ensure_dc_camera", - "BACKENDS", + "_BACKEND_REGISTRY", ] diff --git a/dlclivegui/cameras/backends/__init__.py b/dlclivegui/cameras/backends/__init__.py new file mode 100644 index 0000000..d14e764 --- /dev/null +++ b/dlclivegui/cameras/backends/__init__.py @@ -0,0 +1,11 @@ +from .aravis_backend import AravisCameraBackend +from .basler_backend import BaslerCameraBackend +from .gentl_backend import GenTLCameraBackend +from .opencv_backend import OpenCVCameraBackend + +__all__ = [ + "AravisCameraBackend", + "BaslerCameraBackend", + "GenTLCameraBackend", + "OpenCVCameraBackend", +] diff --git a/dlclivegui/cameras/base.py b/dlclivegui/cameras/base.py index 6c3340d..6ca9c4c 100644 --- a/dlclivegui/cameras/base.py +++ b/dlclivegui/cameras/base.py @@ -5,8 +5,7 @@ import numpy as np -from ..config import CameraSettings -from .config_adapters import CameraSettingsLike, ensure_dc_camera # NEW +from ..utils.config_models import CameraSettingsModel _BACKEND_REGISTRY: dict[str, type[CameraBackend]] = {} @@ -50,9 +49,9 @@ def reset_backends(): class CameraBackend(ABC): """Abstract base class for camera backends.""" - def __init__(self, settings: CameraSettingsLike): # CHANGED + def __init__(self, settings: CameraSettingsModel): # Normalize to dataclass so all backends stay unchanged - self.settings: CameraSettings = ensure_dc_camera(settings) # NEW + self.settings: CameraSettingsModel = settings @classmethod def name(cls) -> str: @@ -68,6 +67,7 @@ def is_available(cls) -> bool: def stop(self) -> None: """Request a graceful stop.""" # Subclasses may override when they need to interrupt blocking reads. + raise NotImplementedError def device_name(self) -> str: """Return a human readable name for the device currently in use.""" @@ -76,11 +76,14 @@ def device_name(self) -> str: @abstractmethod def open(self) -> None: """Open the capture device.""" + raise NotImplementedError @abstractmethod def read(self) -> tuple[np.ndarray, float]: """Read a frame and return the image with a timestamp.""" + raise NotImplementedError @abstractmethod def close(self) -> None: """Release the capture device.""" + raise NotImplementedError diff --git a/dlclivegui/cameras/factory.py b/dlclivegui/cameras/factory.py index 3a82b96..695159a 100644 --- a/dlclivegui/cameras/factory.py +++ b/dlclivegui/cameras/factory.py @@ -3,14 +3,14 @@ from __future__ import annotations import copy +import importlib +import pkgutil from collections.abc import Callable, Iterable from contextlib import contextmanager from dataclasses import dataclass -from ..config import CameraSettings -from .base import _BACKEND_REGISTRY as BACKENDS -from .base import CameraBackend -from .config_adapters import CameraSettingsLike, ensure_dc_camera +from ..utils.config_models import CameraSettingsModel +from .base import _BACKEND_REGISTRY, CameraBackend @dataclass @@ -71,14 +71,49 @@ def _suppress_opencv_logging(): yield -def _sanitize_for_probe(settings: CameraSettingsLike) -> CameraSettings: +# Lazy loader for backends (ensures @register_backend runs) +_BUILTIN_BACKEND_PACKAGES = ( + "dlclivegui.cameras.backends", # import every submodule once +) +_BACKENDS_IMPORTED = False + + +def _ensure_backends_loaded() -> None: + """Import all built-in backend modules once so their decorators run.""" + global _BACKENDS_IMPORTED + if _BACKENDS_IMPORTED: + return + + for pkg_name in _BUILTIN_BACKEND_PACKAGES: + try: + pkg = importlib.import_module(pkg_name) + except Exception: + # Package might not exist (fine if all backends are third-party via tests/plugins) + continue + + # Import every submodule of the package (triggers decorator side-effects) + pkg_path = getattr(pkg, "__path__", None) + if not pkg_path: + continue + + for _finder, mod_name, _is_pkg in pkgutil.iter_modules(pkg_path, prefix=pkg_name + "."): + try: + importlib.import_module(mod_name) + except Exception: + # Ignore misconfigured/optional backends; they just won't register + continue + + _BACKENDS_IMPORTED = True + + +def _sanitize_for_probe(settings: CameraSettingsModel) -> CameraSettingsModel: """ Return a light, side-effect-minimized dataclass copy for availability probes. - Zero FPS (let driver pick default) - Keep only 'api' hint in properties, force fast_start=True - Do not change 'enabled' """ - dc = ensure_dc_camera(settings) # normalize first + dc = settings probe = copy.deepcopy(dc) probe.fps = 0.0 # don't force FPS during probe props = probe.properties if isinstance(probe.properties, dict) else {} @@ -96,13 +131,15 @@ class CameraFactory: @staticmethod def backend_names() -> Iterable[str]: """Return the identifiers of all known backends.""" - return tuple(BACKENDS.keys()) + _ensure_backends_loaded() + return tuple(_BACKEND_REGISTRY.keys()) @staticmethod def available_backends() -> dict[str, bool]: """Return a mapping of backend names to availability flags.""" + _ensure_backends_loaded() availability: dict[str, bool] = {} - for name in BACKENDS: + for name in _BACKEND_REGISTRY: try: backend_cls = CameraFactory._resolve_backend(name) except RuntimeError: @@ -139,6 +176,7 @@ def detect_cameras( list of :class:`DetectedCamera` Sorted list of detected cameras with human readable labels (partial if canceled). """ + _ensure_backends_loaded() def _canceled() -> bool: return bool(should_cancel and should_cancel()) @@ -187,7 +225,7 @@ def _canceled() -> bool: # Definitely not present, skip heavy open continue - settings = CameraSettings( + settings = CameraSettingsModel( name=f"Probe {index}", index=index, fps=30.0, @@ -227,9 +265,9 @@ def _canceled() -> bool: return detected @staticmethod - def create(settings: CameraSettingsLike) -> CameraBackend: + def create(settings: CameraSettingsModel) -> CameraBackend: """Instantiate a backend for ``settings``.""" - dc = ensure_dc_camera(settings) + dc = settings backend_name = (dc.backend or "opencv").lower() try: backend_cls = CameraFactory._resolve_backend(backend_name) @@ -243,9 +281,9 @@ def create(settings: CameraSettingsLike) -> CameraBackend: return backend_cls(dc) @staticmethod - def check_camera_available(settings: CameraSettingsLike) -> tuple[bool, str]: + def check_camera_available(settings: CameraSettingsModel) -> tuple[bool, str]: """Check if a camera is present/accessible without pushing heavy settings like FPS.""" - dc = ensure_dc_camera(settings) + dc = settings backend_name = (dc.backend or "opencv").lower() try: @@ -286,6 +324,6 @@ def check_camera_available(settings: CameraSettingsLike) -> tuple[bool, str]: @staticmethod def _resolve_backend(name: str) -> type[CameraBackend]: try: - return BACKENDS[name.lower()] + return _BACKEND_REGISTRY[name.lower()] except KeyError as exc: raise RuntimeError("Backend %s not registered", name) from exc diff --git a/dlclivegui/gui/camera_config_dialog.py b/dlclivegui/gui/camera_config_dialog.py index d2ba9c3..5737280 100644 --- a/dlclivegui/gui/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config_dialog.py @@ -32,7 +32,7 @@ from dlclivegui.cameras import CameraFactory from dlclivegui.cameras.base import CameraBackend from dlclivegui.cameras.factory import DetectedCamera -from dlclivegui.config import CameraSettings, MultiCameraSettings +from dlclivegui.utils.config_models import CameraSettingsModel, MultiCameraSettingsModel LOGGER = logging.getLogger(__name__) @@ -82,7 +82,7 @@ class CameraLoadWorker(QThread): error = Signal(str) # Emits error message canceled = Signal() # Emits when canceled before success - def __init__(self, cam: CameraSettings, parent: QWidget | None = None): + def __init__(self, cam: CameraSettingsModel, parent: QWidget | None = None): super().__init__(parent) # Work on a defensive copy so we never mutate the original settings self._cam = copy.deepcopy(cam) @@ -110,6 +110,7 @@ def run(self): LOGGER.debug("Creating camera backend for %s:%d", self._cam.backend, self._cam.index) self.progress.emit("Opening device…") + # Open only in GUI thread to avoid simultaneous opens self.success.emit(self._cam) except Exception as exc: @@ -126,7 +127,7 @@ class CameraConfigDialog(QDialog): """Dialog for configuring multiple cameras with async preview loading.""" MAX_CAMERAS = 4 - settings_changed = Signal(object) # MultiCameraSettings + settings_changed = Signal(object) # MultiCameraSettingsModel # Camera discovery signals scan_started = Signal(str) scan_finished = Signal() @@ -134,7 +135,7 @@ class CameraConfigDialog(QDialog): def __init__( self, parent: QWidget | None = None, - multi_camera_settings: MultiCameraSettings | None = None, + multi_camera_settings: MultiCameraSettingsModel | None = None, ): super().__init__(parent) self.setWindowTitle("Configure Cameras") @@ -143,8 +144,8 @@ def __init__( self._dlc_camera_id = None self.dlc_camera_id: str | None = None # Actual/working camera settings - self._multi_camera_settings = multi_camera_settings if multi_camera_settings else MultiCameraSettings() - self._working_settings = copy.deepcopy(self._multi_camera_settings) + self._multi_camera_settings = multi_camera_settings + self._working_settings = self._multi_camera_settings.model_copy(deep=True) self._detected_cameras: list[DetectedCamera] = [] self._current_edit_index: int | None = None @@ -175,6 +176,29 @@ def dlc_camera_id(self, value: str | None) -> None: self._dlc_camera_id = value self._refresh_camera_labels() + # ------------------------------- + # Config helpers + # ------------------------------ + + def _build_model_from_form(self, base: CameraSettingsModel) -> CameraSettingsModel: + # construct a dict from form widgets; Pydantic will coerce/validate + payload = base.model_dump() + payload.update( + { + "enabled": bool(self.cam_enabled_checkbox.isChecked()), + "fps": float(self.cam_fps.value()), + "exposure": int(self.cam_exposure.value()), + "gain": float(self.cam_gain.value()), + "rotation": int(self.cam_rotation.currentData() or 0), + "crop_x0": int(self.cam_crop_x0.value()), + "crop_y0": int(self.cam_crop_y0.value()), + "crop_x1": int(self.cam_crop_x1.value()), + "crop_y1": int(self.cam_crop_y1.value()), + } + ) + # Validate and coerce; if invalid, Pydantic will raise + return CameraSettingsModel.model_validate(payload) + # ------------------------------- # UI setup # ------------------------------- @@ -229,6 +253,8 @@ def _setup_ui(self) -> None: if not availability.get(backend, True): label = f"{backend} (unavailable)" self.backend_combo.addItem(label, backend) + if self.backend_combo.count() == 0: + raise RuntimeError("No camera backends are registered!") backend_layout.addWidget(self.backend_combo) self.refresh_btn = QPushButton("Refresh") self.refresh_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload)) @@ -530,7 +556,7 @@ def _populate_from_settings(self) -> None: self._refresh_available_cameras() self._update_button_states() - def _format_camera_label(self, cam: CameraSettings, index: int = -1) -> str: + def _format_camera_label(self, cam: CameraSettingsModel, index: int = -1) -> str: status = "✓" if cam.enabled else "○" this_id = f"{cam.backend}:{cam.index}" dlc_indicator = " [DLC]" if this_id == self._dlc_camera_id and cam.enabled else "" @@ -660,19 +686,8 @@ def _on_active_camera_selected(self, row: int) -> None: # ------------------------------- # UI helpers/actions # ------------------------------- - def _write_form_to_cam(self, cam: CameraSettings) -> None: - """Copy form values into the CameraSettings object.""" - cam.enabled = self.cam_enabled_checkbox.isChecked() - cam.fps = float(self.cam_fps.value()) - cam.exposure = int(self.cam_exposure.value()) - cam.gain = float(self.cam_gain.value()) - cam.rotation = int(self.cam_rotation.currentData() or 0) - cam.crop_x0 = int(self.cam_crop_x0.value()) - cam.crop_y0 = int(self.cam_crop_y0.value()) - cam.crop_x1 = int(self.cam_crop_x1.value()) - cam.crop_y1 = int(self.cam_crop_y1.value()) - - def _needs_preview_reopen(self, cam: CameraSettings) -> bool: + + def _needs_preview_reopen(self, cam: CameraSettingsModel) -> bool: if not (self._preview_active and self._preview_backend): return False @@ -716,7 +731,7 @@ def _adjust_preview_timer_for_fps(self, fps: float | None) -> None: interval_ms = max(15, int(1000.0 / min(max(fps, 1.0), 60.0))) self._preview_timer.start(interval_ms) - def _reconcile_fps_from_backend(self, cam: CameraSettings) -> None: + def _reconcile_fps_from_backend(self, cam: CameraSettingsModel) -> None: """Clamp UI/settings to measured device FPS when we can actually measure it.""" if not self._is_backend_opencv(cam.backend): return @@ -733,7 +748,7 @@ def _reconcile_fps_from_backend(self, cam: CameraSettings) -> None: self._append_status(f"[Info] FPS adjusted to device-supported ~{actual:.2f}.") self._adjust_preview_timer_for_fps(actual) - def _update_active_list_item(self, row: int, cam: CameraSettings) -> None: + def _update_active_list_item(self, row: int, cam: CameraSettingsModel) -> None: """Refresh the active camera list row text and color.""" item = self.active_cameras_list.item(row) if not item: @@ -744,7 +759,7 @@ def _update_active_list_item(self, row: int, cam: CameraSettings) -> None: self._refresh_camera_labels() self._update_button_states() - def _load_camera_to_form(self, cam: CameraSettings) -> None: + def _load_camera_to_form(self, cam: CameraSettingsModel) -> None: self.cam_enabled_checkbox.setChecked(cam.enabled) self.cam_name_label.setText(cam.name) self.cam_index_label.setText(str(cam.index)) @@ -804,7 +819,7 @@ def _add_selected_camera(self) -> None: ) return - new_cam = CameraSettings( + new_cam = CameraSettingsModel( name=detected.label, index=detected.index, fps=30.0, @@ -869,21 +884,30 @@ def _apply_camera_settings(self) -> None: if row < 0 or row >= len(self._working_settings.cameras): return + current_model = self._working_settings.cameras[row] + new_model = self._build_model_from_form(current_model) + cam = self._working_settings.cameras[row] self._write_form_to_cam(cam) - must_reopen = self._needs_preview_reopen(cam) + must_reopen = False + if self._preview_active and self._preview_backend: + prev_model = getattr(self._preview_backend, "settings", None) + if prev_model: + must_reopen = self._needs_preview_reopen(new_model, prev_model) if self._preview_active: if must_reopen: self._stop_preview() self._start_preview() else: - self._reconcile_fps_from_backend(cam) + self._reconcile_fps_from_backend(new_model) if not self._backend_actual_fps(): self._append_status("[Info] FPS will reconcile automatically during preview.") - self._update_active_list_item(row, cam) + # Persist validated model back + self._working_settings.cameras[row] = new_model + self._update_active_list_item(row, new_model) except Exception as exc: LOGGER.exception("Apply camera settings failed") @@ -1048,9 +1072,7 @@ def _on_loader_progress(self, message: str) -> None: def _on_loader_success(self, payload) -> None: try: - if isinstance(payload, CameraBackend): - self._preview_backend = payload - elif isinstance(payload, CameraSettings): + if isinstance(payload, CameraSettingsModel): cam_settings = payload self._append_status("Opening camera…") self._preview_backend = CameraFactory.create(cam_settings) @@ -1113,7 +1135,7 @@ def _on_loader_finished(self): self._update_button_states() # ------------------------------- - # Preview frame update (unchanged logic, robust to None frames) + # Preview frame update # ------------------------------- def _update_preview(self) -> None: """Update preview frame.""" diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index c6f0a7a..e834476 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -38,15 +38,15 @@ from dlclivegui.cameras import CameraFactory from dlclivegui.config import ( - DEFAULT_CONFIG, - ApplicationSettings, - BoundingBoxSettings, - CameraSettings, - DLCProcessorSettings, + # DEFAULT_CONFIG, + # ApplicationSettings, + # BoundingBoxSettings, + # CameraSettings, + # DLCProcessorSettings, ModelPathStore, - MultiCameraSettings, - RecordingSettings, - VisualizationSettings, + # MultiCameraSettings, + # RecordingSettings, + # VisualizationSettings, ) from dlclivegui.gui.camera_config_dialog import CameraConfigDialog from dlclivegui.gui.recording_manager import RecordingManager @@ -55,6 +55,16 @@ from dlclivegui.services.dlc_processor import DLCLiveProcessor, PoseResult, ProcessorStats from dlclivegui.services.multi_camera_controller import MultiCameraController, MultiFrameData, get_camera_id from dlclivegui.services.video_recorder import RecorderStats +from dlclivegui.utils.config_models import ( + DEFAULT_CONFIG, + ApplicationSettingsModel, + BoundingBoxSettingsModel, + CameraSettingsModel, + DLCProcessorSettingsModel, + MultiCameraSettingsModel, + RecordingSettingsModel, + VisualizationSettingsModel, +) from dlclivegui.utils.display import compute_tile_info, create_tiled_frame, draw_bbox, draw_pose from dlclivegui.utils.utils import FPSTracker @@ -66,7 +76,7 @@ class DLCLiveMainWindow(QMainWindow): """Main application window.""" - def __init__(self, config: ApplicationSettings | None = None): + def __init__(self, config: ApplicationSettingsModel | None = None): super().__init__() self.setWindowTitle("DeepLabCut Live GUI") @@ -76,7 +86,7 @@ def __init__(self, config: ApplicationSettings | None = None): myconfig_path = Path(__file__).parent.parent / "myconfig.json" if myconfig_path.exists(): try: - config = ApplicationSettings.load(str(myconfig_path)) + config = ApplicationSettingsModel.load(str(myconfig_path)) self._config_path = myconfig_path logger.info(f"Loaded configuration from {myconfig_path}") except Exception as exc: @@ -103,7 +113,7 @@ def __init__(self, config: ApplicationSettings | None = None): self._raw_frame: np.ndarray | None = None self._last_pose: PoseResult | None = None self._dlc_active: bool = False - self._active_camera_settings: CameraSettings | None = None + self._active_camera_settings: CameraSettingsModel | None = None self._last_drop_warning = 0.0 self._last_recorder_summary = "Recorder idle" self._display_interval = 1.0 / 25.0 @@ -547,7 +557,7 @@ def _connect_signals(self) -> None: self.dlc_camera_combo.currentIndexChanged.connect(self._on_dlc_camera_changed) # ------------------------------------------------------------------ config - def _apply_config(self, config: ApplicationSettings) -> None: + def _apply_config(self, config: ApplicationSettingsModel) -> None: # Update active cameras label self._update_active_cameras_label() @@ -585,12 +595,12 @@ def _apply_config(self, config: ApplicationSettings) -> None: # Update DLC camera list self._refresh_dlc_camera_list() - def _current_config(self) -> ApplicationSettings: + def _current_config(self) -> ApplicationSettingsModel: # Get the first camera from multi-camera config for backward compatibility active_cameras = self._config.multi_camera.get_active_cameras() - camera = active_cameras[0] if active_cameras else CameraSettings() + camera = active_cameras[0] if active_cameras else CameraSettingsModel() - return ApplicationSettings( + return ApplicationSettingsModel( camera=camera, multi_camera=self._config.multi_camera, dlc=self._dlc_settings_from_ui(), @@ -605,8 +615,8 @@ def _parse_json(self, value: str) -> dict: return {} return json.loads(text) - def _dlc_settings_from_ui(self) -> DLCProcessorSettings: - return DLCProcessorSettings( + def _dlc_settings_from_ui(self) -> DLCProcessorSettingsModel: + return DLCProcessorSettingsModel( model_path=self.model_path_edit.text().strip(), model_directory=self._config.dlc.model_directory, # Preserve from config device=self._config.dlc.device, # Preserve from config @@ -617,8 +627,8 @@ def _dlc_settings_from_ui(self) -> DLCProcessorSettings: # additional_options=self._parse_json(self.additional_options_edit.toPlainText()), ) - def _recording_settings_from_ui(self) -> RecordingSettings: - return RecordingSettings( + def _recording_settings_from_ui(self) -> RecordingSettingsModel: + return RecordingSettingsModel( enabled=True, # Always enabled - recording controlled by button directory=self.output_directory_edit.text().strip(), filename=self.filename_edit.text().strip() or "session.mp4", @@ -627,8 +637,8 @@ def _recording_settings_from_ui(self) -> RecordingSettings: crf=int(self.crf_spin.value()), ) - def _bbox_settings_from_ui(self) -> BoundingBoxSettings: - return BoundingBoxSettings( + def _bbox_settings_from_ui(self) -> BoundingBoxSettingsModel: + return BoundingBoxSettingsModel( enabled=self.bbox_enabled_checkbox.isChecked(), x0=self.bbox_x0_spin.value(), y0=self.bbox_y0_spin.value(), @@ -636,8 +646,8 @@ def _bbox_settings_from_ui(self) -> BoundingBoxSettings: y1=self.bbox_y1_spin.value(), ) - def _visualization_settings_from_ui(self) -> VisualizationSettings: - return VisualizationSettings( + def _visualization_settings_from_ui(self) -> VisualizationSettingsModel: + return VisualizationSettingsModel( p_cutoff=self._p_cutoff, colormap=self._colormap, bbox_color=self._bbox_color, @@ -649,7 +659,7 @@ def _action_load_config(self) -> None: if not file_name: return try: - config = ApplicationSettings.load(file_name) + config = ApplicationSettingsModel.load(file_name) except Exception as exc: # pragma: no cover - GUI interaction self._show_error(str(exc)) return @@ -759,7 +769,7 @@ def _open_camera_config_dialog(self) -> None: self._cam_dialog.raise_() self._cam_dialog.activateWindow() - def _on_multi_camera_settings_changed(self, settings: MultiCameraSettings) -> None: + def _on_multi_camera_settings_changed(self, settings: MultiCameraSettingsModel) -> None: """Handle changes to multi-camera settings.""" self._config.multi_camera = settings self._update_active_cameras_label() @@ -792,7 +802,7 @@ def _validate_configured_cameras(self) -> None: if not active_cams: return - unavailable: list[tuple[str, str, CameraSettings]] = [] + unavailable: list[tuple[str, str, CameraSettingsModel]] = [] for cam in active_cams: cam_id = f"{cam.backend}:{cam.index}" available, error = CameraFactory.check_camera_available(cam) diff --git a/dlclivegui/utils/config_models.py b/dlclivegui/utils/config_models.py index 96e874b..ef27ac2 100644 --- a/dlclivegui/utils/config_models.py +++ b/dlclivegui/utils/config_models.py @@ -1,21 +1,12 @@ # config_models.py from __future__ import annotations +import json from pathlib import Path from typing import Any, Literal from pydantic import BaseModel, Field, field_validator, model_validator -from dlclivegui.config import ( - ApplicationSettings, - BoundingBoxSettings, - CameraSettings, - DLCProcessorSettings, - MultiCameraSettings, - RecordingSettings, - VisualizationSettings, -) - Backend = Literal["gentl", "opencv", "basler", "aravis"] # extend as needed Rotation = Literal[0, 90, 180, 270] TileLayout = Literal["auto", "2x2", "1x4", "4x1"] @@ -68,6 +59,10 @@ def get_crop_region(self) -> tuple[int, int, int, int] | None: return None return (self.crop_x0, self.crop_y0, self.crop_x1, self.crop_y1) + @classmethod + def from_defaults(cls) -> CameraSettingsModel: + return cls() + class MultiCameraSettingsModel(BaseModel): cameras: list[CameraSettingsModel] = Field(default_factory=list) @@ -83,6 +78,35 @@ def _enforce_max_active(self): raise ValueError("Number of enabled cameras exceeds max_cameras.") return self + def add_camera(self, camera: CameraSettingsModel) -> bool: + """Add a new camera if under max_cameras limit.""" + if len(self.cameras) >= self.max_cameras: + return False + self.cameras.append(camera) + return True + + def remove_camera(self, index: int) -> bool: + """Remove camera at given index.""" + if 0 <= index < len(self.cameras): + del self.cameras[index] + return True + return False + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> MultiCameraSettingsModel: + cameras_data = data.get("cameras", []) + cameras = [CameraSettingsModel(**cam) for cam in cameras_data] + max_cameras = data.get("max_cameras", 4) + tile_layout = data.get("tile_layout", "auto") + return cls(cameras=cameras, max_cameras=max_cameras, tile_layout=tile_layout) + + def to_dict(self) -> dict[str, Any]: + return { + "cameras": [cam.model_dump() for cam in self.cameras], + "max_cameras": self.max_cameras, + "tile_layout": self.tile_layout, + } + class DynamicCropModel(BaseModel): enabled: bool = False @@ -137,6 +161,12 @@ class VisualizationSettingsModel(BaseModel): colormap: str = "hot" bbox_color: tuple[int, int, int] = (0, 0, 255) + def get_bbox_color_bgr(self) -> tuple[int, int, int]: + """Get bounding box color in BGR format""" + if isinstance(self.bbox_color, (list, tuple)) and len(self.bbox_color) == 3: + return tuple(int(c) for c in self.bbox_color) + return (0, 0, 255) # default red + class RecordingSettingsModel(BaseModel): enabled: bool = False @@ -146,6 +176,30 @@ class RecordingSettingsModel(BaseModel): codec: str = "libx264" crf: int = Field(default=23, ge=0, le=51) + def output_path(self) -> Path: + """Return the absolute output path for recordings.""" + + directory = Path(self.directory).expanduser().resolve() + directory.mkdir(parents=True, exist_ok=True) + name = Path(self.filename) + if name.suffix: + filename = name + else: + filename = name.with_suffix(f".{self.container}") + return directory / filename + + def writegear_options(self, fps: float) -> dict[str, Any]: + """Return compression parameters for WriteGear.""" + + fps_value = float(fps) if fps else 30.0 + codec_value = (self.codec or "libx264").strip() or "libx264" + crf_value = int(self.crf) if self.crf is not None else 23 + return { + "-input_framerate": f"{fps_value:.6f}", + "-vcodec": codec_value, + "-crf": str(crf_value), + } + class ApplicationSettingsModel(BaseModel): # optional: add a semantic version for migrations @@ -157,26 +211,60 @@ class ApplicationSettingsModel(BaseModel): bbox: BoundingBoxSettingsModel = Field(default_factory=BoundingBoxSettingsModel) visualization: VisualizationSettingsModel = Field(default_factory=VisualizationSettingsModel) + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ApplicationSettingsModel: + camera_data = data.get("camera", {}) + multi_camera_data = data.get("multi_camera", {}) + dlc_data = data.get("dlc", {}) + recording_data = data.get("recording", {}) + bbox_data = data.get("bbox", {}) + visualization_data = data.get("visualization", {}) + + camera = CameraSettingsModel(**camera_data) + multi_camera = MultiCameraSettingsModel.from_dict(multi_camera_data) + dlc = DLCProcessorSettingsModel(**dlc_data) + recording = RecordingSettingsModel(**recording_data) + bbox = BoundingBoxSettingsModel(**bbox_data) + visualization = VisualizationSettingsModel(**visualization_data) + + return cls( + camera=camera, + multi_camera=multi_camera, + dlc=dlc, + recording=recording, + bbox=bbox, + visualization=visualization, + ) + + def to_dict(self) -> dict[str, Any]: + return { + "version": self.version, + "camera": self.camera.model_dump(), + "multi_camera": self.multi_camera.to_dict(), + "dlc": self.dlc.model_dump(), + "recording": self.recording.model_dump(), + "bbox": self.bbox.model_dump(), + "visualization": self.visualization.model_dump(), + } + + @classmethod + def load(cls, path: Path | str) -> ApplicationSettingsModel: + """Load configuration from ``path``.""" + + file_path = Path(path).expanduser() + if not file_path.exists(): + raise FileNotFoundError(f"Configuration file not found: {file_path}") + with file_path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + return cls.from_dict(data) + + def save(self, path: Path | str) -> None: + """Persist configuration to ``path``.""" + + file_path = Path(path).expanduser() + file_path.parent.mkdir(parents=True, exist_ok=True) + with file_path.open("w", encoding="utf-8") as handle: + json.dump(self.to_dict(), handle, indent=2) + -def dc_to_model(dc_cfg: ApplicationSettings) -> ApplicationSettingsModel: - # Use your current dc.to_dict() then validate; preserves defaults + coercion - return ApplicationSettingsModel.model_validate(dc_cfg.to_dict()) - - -def model_to_dc(model: ApplicationSettingsModel) -> ApplicationSettings: - # Build dataclasses from validated data - cam_dc = CameraSettings(**model.camera.model_dump()) - mc_dc = MultiCameraSettings.from_dict(model.multi_camera.model_dump()) - dlc_dc = DLCProcessorSettings(**model.dlc.model_dump()) - rec_dc = RecordingSettings(**model.recording.model_dump()) - bbox_dc = BoundingBoxSettings(**model.bbox.model_dump()) - viz_dc = VisualizationSettings(**model.visualization.model_dump()) - - return ApplicationSettings( - camera=cam_dc, - multi_camera=mc_dc, - dlc=dlc_dc, - recording=rec_dc, - bbox=bbox_dc, - visualization=viz_dc, - ) +DEFAULT_CONFIG = ApplicationSettingsModel() diff --git a/tests/cameras/test_backend_discovery.py b/tests/cameras/test_backend_discovery.py new file mode 100644 index 0000000..ec22edd --- /dev/null +++ b/tests/cameras/test_backend_discovery.py @@ -0,0 +1,130 @@ +# tests/cameras/test_backend_discovery.py +from __future__ import annotations + +import sys +import textwrap +from pathlib import Path + +import pytest + +from dlclivegui.cameras import factory as cam_factory +from dlclivegui.cameras.base import _BACKEND_REGISTRY, reset_backends +from dlclivegui.utils.config_models import CameraSettingsModel + + +def _write_temp_backend_package(tmp_path: Path, pkg_name: str = "test_backends_pkg") -> str: + """ + Create a temporary backend package with a single backend module that registers + itself using the @register_backend decorator. + + Returns the *package name* to be used in CameraFactory's discovery list. + """ + pkg_root = tmp_path / pkg_name + pkg_root.mkdir(parents=True, exist_ok=True) + (pkg_root / "__init__.py").write_text("# test backends package\n", encoding="utf-8") + + # A backend module which registers itself as "lazyfake" + backend_code = textwrap.dedent( + """ + from dlclivegui.cameras.base import register_backend, CameraBackend + from dlclivegui.utils.config_models import CameraSettingsModel + import numpy as np + import time + + @register_backend("lazyfake") + class LazyFakeBackend(CameraBackend): + @classmethod + def is_available(cls) -> bool: + return True + + def open(self) -> None: + # No-op open for testing + self._opened = True + + def read(self): + # Small deterministic frame + timestamp + frame = np.zeros((2, 3, 3), dtype=np.uint8) + return frame, time.time() + + def close(self) -> None: + self._opened = False + + # Optional: friendly name for detect_cameras label + def device_name(self) -> str: + return self.settings.name or f"LazyFake #{self.settings.index}" + """ + ) + (pkg_root / "fake_backend.py").write_text(backend_code, encoding="utf-8") + return pkg_name + + +@pytest.fixture +def temp_backends_pkg(tmp_path, monkeypatch): + """ + Fixture that creates a temporary backend package and configures CameraFactory + to import from it during lazy discovery. Resets the global registry/import flags. + """ + # 1) Create on-disk package with a single backend + pkg_name = _write_temp_backend_package(tmp_path) + + # 2) Ensure Python can import it + sys.path.insert(0, str(tmp_path)) + try: + # 3) Reset registry & lazy-import flags + reset_backends() + monkeypatch.setattr(cam_factory, "_BACKENDS_IMPORTED", False, raising=False) + monkeypatch.setattr(cam_factory, "_BUILTIN_BACKEND_PACKAGES", (pkg_name,), raising=False) + + yield pkg_name + finally: + # Cleanup sys.path + try: + sys.path.remove(str(tmp_path)) + except ValueError: + pass + reset_backends() + + +def test_backend_lazy_discovery_from_package(temp_backends_pkg): + """ + Verify that calling CameraFactory.backend_names() triggers lazy import and + registers the backend found in the temporary package. + """ + # Initially empty + assert len(_BACKEND_REGISTRY) == 0 + + names = set(cam_factory.CameraFactory.backend_names()) + assert "lazyfake" in names, f"Expected 'lazyfake' in discovered backends, got {names}" + # Registry should now contain our backend + assert "lazyfake" in _BACKEND_REGISTRY + + +def test_detect_and_create_with_discovered_backend(temp_backends_pkg): + """ + Verify CameraFactory.detect_cameras() and CameraFactory.create() work + with the lazily-discovered backend. + """ + # Trigger discovery + names = set(cam_factory.CameraFactory.backend_names()) + assert "lazyfake" in names + + # detect_cameras should instantiate/open/close without error and yield a label + detected = cam_factory.CameraFactory.detect_cameras("lazyfake", max_devices=1) + assert isinstance(detected, list) + assert len(detected) >= 1 + # Our backend returns device_name() -> "Probe 0" (from factory) or our override in device_name + assert detected[0].index == 0 + assert isinstance(detected[0].label, str) + assert len(detected[0].label) > 0 + + # create() should return an instance of our registered backend using a model-only settings + s = CameraSettingsModel(name="UnitCam", backend="lazyfake", index=0, fps=30.0) + backend = cam_factory.CameraFactory.create(s) + # A minimal behavior check: open/read/close work + backend.open() + frame, ts = backend.read() + backend.close() + + assert frame is not None and getattr(frame, "shape", None) is not None + assert frame.shape == (2, 3, 3) + assert isinstance(ts, float) From f6b529d0c8ebc32a7b8d67abac32fa547c7952e9 Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 30 Jan 2026 16:11:58 +0100 Subject: [PATCH 67/69] Switch to Pydantic config models Replace legacy dataclass config usage with Pydantic models across cameras and DLC services. CameraSettingsModel and DLCProcessorSettingsModel are now used in controllers, processors, recorders and tests; adapters and ensure_dc_camera/list_of_dc_cameras utilities were removed/simplified. CameraBackend.stop is now an optional no-op by default. Add CameraSettingsModel helpers (from_dict, from_defaults, apply_defaults) and relax backend typing/default. Update tests and imports accordingly, and remove the obsolete test_adapters.py file. --- dlclivegui/cameras/__init__.py | 3 - dlclivegui/cameras/base.py | 7 +-- dlclivegui/gui/recording_manager.py | 9 ++- dlclivegui/services/dlc_processor.py | 17 ++---- .../services/multi_camera_controller.py | 56 +++++-------------- dlclivegui/utils/config_models.py | 14 ++++- tests/cameras/test_adapters.py | 41 -------------- tests/cameras/test_backend_discovery.py | 3 + tests/cameras/test_factory.py | 8 ++- tests/cameras/test_fake_backend.py | 6 +- tests/conftest.py | 9 +-- tests/services/gui/conftest.py | 29 ++++++---- tests/services/test_dlc_processor.py | 33 +++++------ tests/services/test_multicam_controller.py | 11 ++-- 14 files changed, 93 insertions(+), 153 deletions(-) diff --git a/dlclivegui/cameras/__init__.py b/dlclivegui/cameras/__init__.py index 4bca7a7..d7290a9 100644 --- a/dlclivegui/cameras/__init__.py +++ b/dlclivegui/cameras/__init__.py @@ -5,7 +5,6 @@ from ..config import CameraSettings from .base import _BACKEND_REGISTRY as _BACKEND_REGISTRY from .base import CameraBackend -from .config_adapters import CameraSettingsLike, ensure_dc_camera from .factory import CameraFactory, DetectedCamera __all__ = [ @@ -13,7 +12,5 @@ "CameraBackend", "CameraFactory", "DetectedCamera", - "CameraSettingsLike", - "ensure_dc_camera", "_BACKEND_REGISTRY", ] diff --git a/dlclivegui/cameras/base.py b/dlclivegui/cameras/base.py index 6ca9c4c..6cb2fbe 100644 --- a/dlclivegui/cameras/base.py +++ b/dlclivegui/cameras/base.py @@ -63,11 +63,10 @@ def is_available(cls) -> bool: """Return whether the backend can be used on this system.""" return True - @abstractmethod - def stop(self) -> None: - """Request a graceful stop.""" + def stop(self) -> None: # noqa B027 + """Optional: Request a graceful stop. No-op by default.""" # Subclasses may override when they need to interrupt blocking reads. - raise NotImplementedError + pass def device_name(self) -> str: """Return a human readable name for the device currently in use.""" diff --git a/dlclivegui/gui/recording_manager.py b/dlclivegui/gui/recording_manager.py index 39a78fd..0ee7b39 100644 --- a/dlclivegui/gui/recording_manager.py +++ b/dlclivegui/gui/recording_manager.py @@ -6,10 +6,12 @@ import numpy as np -from dlclivegui.config import CameraSettings, RecordingSettings from dlclivegui.services.multi_camera_controller import get_camera_id from dlclivegui.services.video_recorder import RecorderStats, VideoRecorder +# from dlclivegui.config import CameraSettings, RecordingSettings +from dlclivegui.utils.config_models import CameraSettingsModel, RecordingSettingsModel + log = logging.getLogger(__name__) @@ -31,7 +33,10 @@ def pop(self, cam_id: str, default=None) -> VideoRecorder | None: return self._recorders.pop(cam_id, default) def start_all( - self, recording: RecordingSettings, active_cams: list[CameraSettings], current_frames: dict[str, np.ndarray] + self, + recording: RecordingSettingsModel, + active_cams: list[CameraSettingsModel], + current_frames: dict[str, np.ndarray], ) -> None: if self._recorders: return diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index 2e8bf70..00b30b4 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -3,7 +3,6 @@ # dlclivegui/services/dlc_processor.py from __future__ import annotations -import copy import logging import queue import threading @@ -15,7 +14,7 @@ import numpy as np from PySide6.QtCore import QObject, Signal -from dlclivegui.config import DLCProcessorSettings +# from dlclivegui.config import DLCProcessorSettings from dlclivegui.processors.processor_utils import instantiate_from_scan from dlclivegui.utils.config_models import DLCProcessorSettingsModel @@ -31,9 +30,7 @@ DLCLive = None # type: ignore[assignment] -def ensure_dc_dlc(settings: DLCProcessorSettings | DLCProcessorSettingsModel) -> DLCProcessorSettings: - if isinstance(settings, DLCProcessorSettings): - return copy.deepcopy(settings) +def ensure_dc_dlc(settings: DLCProcessorSettingsModel) -> DLCProcessorSettingsModel: if isinstance(settings, DLCProcessorSettingsModel): settings = DLCProcessorSettingsModel.model_validate(settings) data = settings.model_dump() @@ -43,7 +40,7 @@ def ensure_dc_dlc(settings: DLCProcessorSettings | DLCProcessorSettingsModel) -> data["dynamic"] = (dyn.enabled, dyn.margin, dyn.max_missing_frames) elif isinstance(dyn, dict) and {"enabled", "margin", "max_missing_frames"} <= set(dyn): data["dynamic"] = (dyn["enabled"], dyn["margin"], dyn["max_missing_frames"]) - return DLCProcessorSettings(**data) + return DLCProcessorSettingsModel(**data) raise TypeError("Unsupported DLC settings type") @@ -86,7 +83,7 @@ class DLCLiveProcessor(QObject): def __init__(self) -> None: super().__init__() - self._settings = DLCProcessorSettings() + self._settings = DLCProcessorSettingsModel() self._dlc: Any | None = None self._processor: Any | None = None self._queue: queue.Queue[Any] | None = None @@ -110,9 +107,7 @@ def __init__(self) -> None: self._gpu_inference_times: deque[float] = deque(maxlen=60) self._processor_overhead_times: deque[float] = deque(maxlen=60) - def configure( - self, settings: DLCProcessorSettings | DLCProcessorSettingsModel, processor: Any | None = None - ) -> None: + def configure(self, settings: DLCProcessorSettingsModel, processor: Any | None = None) -> None: self._settings = ensure_dc_dlc(settings) self._processor = processor @@ -444,7 +439,7 @@ def initialized(self): def enqueue(self, frame, ts): self._proc.enqueue_frame(frame, ts) - def configure(self, settings: DLCProcessorSettings, scanned_processors: dict, selected_key) -> bool: + def configure(self, settings: DLCProcessorSettingsModel, scanned_processors: dict, selected_key) -> bool: processor = None if selected_key is not None and scanned_processors: try: diff --git a/dlclivegui/services/multi_camera_controller.py b/dlclivegui/services/multi_camera_controller.py index 2ec9df1..e1a1d28 100644 --- a/dlclivegui/services/multi_camera_controller.py +++ b/dlclivegui/services/multi_camera_controller.py @@ -4,7 +4,6 @@ import logging import time -from collections.abc import Sequence from dataclasses import dataclass from threading import Event, Lock @@ -14,31 +13,11 @@ from dlclivegui.cameras import CameraFactory from dlclivegui.cameras.base import CameraBackend -from dlclivegui.cameras.config_adapters import CameraSettingsLike, ensure_dc_camera -from dlclivegui.config import CameraSettings -from dlclivegui.utils.config_models import MultiCameraSettingsModel -LOGGER = logging.getLogger(__name__) - - -def list_of_dc_cameras( - payload: Sequence[CameraSettingsLike] | MultiCameraSettingsModel, -) -> list[CameraSettings]: - """ - Convert either: - - a list/tuple of CameraSettingsLike, or - - a MultiCameraSettingsModel - into a list[CameraSettings] (dataclass), applying defaults. - """ - if MultiCameraSettingsModel is not None and isinstance(payload, MultiCameraSettingsModel): - # Use only enabled cameras (honor the model’s method) - cams = payload.get_active_cameras() - return [ensure_dc_camera(c) for c in cams] - - if isinstance(payload, (list, tuple)): - return [ensure_dc_camera(c) for c in payload] +# from dlclivegui.config import CameraSettings +from dlclivegui.utils.config_models import CameraSettingsModel - raise TypeError("Expected a list of CameraSettings-like objects or MultiCameraSettingsModel.") +LOGGER = logging.getLogger(__name__) @dataclass @@ -59,10 +38,10 @@ class SingleCameraWorker(QObject): started = Signal(str) # camera_id stopped = Signal(str) # camera_id - def __init__(self, camera_id: str, settings: CameraSettingsLike): + def __init__(self, camera_id: str, settings: CameraSettingsModel): super().__init__() self._camera_id = camera_id - self._settings = ensure_dc_camera(settings) + self._settings = settings self._stop_event = Event() self._backend: CameraBackend | None = None self._max_consecutive_errors = 5 @@ -122,10 +101,9 @@ def stop(self) -> None: self._stop_event.set() -def get_camera_id(settings: CameraSettingsLike) -> str: +def get_camera_id(settings: CameraSettingsModel) -> str: """Generate a unique camera ID from settings.""" - dc = ensure_dc_camera(settings) - return f"{dc.backend}:{dc.index}" + return f"{settings.backend}:{settings.index}" class MultiCameraController(QObject): @@ -146,7 +124,7 @@ def __init__(self): super().__init__() self._workers: dict[str, SingleCameraWorker] = {} self._threads: dict[str, QThread] = {} - self._settings: dict[str, CameraSettings] = {} + self._settings: dict[str, CameraSettingsModel] = {} self._frames: dict[str, np.ndarray] = {} self._timestamps: dict[str, float] = {} self._frame_lock = Lock() @@ -163,20 +141,13 @@ def get_active_count(self) -> int: """Get the number of active cameras.""" return len(self._started_cameras) - def start(self, camera_settings: list[CameraSettingsLike]) -> None: + def start(self, camera_settings: list[CameraSettingsModel]) -> None: """Start multiple cameras; accepts dataclasses, pydantic models, or dicts.""" if self._running: LOGGER.warning("Multi-camera controller already running") return - # Normalize and limit - try: - dc_list = list_of_dc_cameras(camera_settings) - except TypeError: - # fallback if plain list contained dataclasses or dicts only - dc_list = [ensure_dc_camera(cs) for cs in camera_settings] - - active_settings = [s for s in dc_list if s.enabled][: self.MAX_CAMERAS] + active_settings = [s for s in camera_settings if s.enabled][: self.MAX_CAMERAS] if not active_settings: LOGGER.warning("No active cameras to start") return @@ -191,7 +162,7 @@ def start(self, camera_settings: list[CameraSettingsLike]) -> None: for settings in active_settings: self._start_camera(settings) - def _start_camera(self, settings: CameraSettingsLike) -> None: + def _start_camera(self, settings: CameraSettingsModel) -> None: """Start a single camera.""" cam_id = get_camera_id(settings) if cam_id in self._workers: @@ -199,9 +170,8 @@ def _start_camera(self, settings: CameraSettingsLike) -> None: return # Normalize and store the dataclass once - dc = ensure_dc_camera(settings) - self._settings[cam_id] = dc - + self._settings[cam_id] = settings + dc = self._settings[cam_id] worker = SingleCameraWorker(cam_id, dc) thread = QThread() worker.moveToThread(thread) diff --git a/dlclivegui/utils/config_models.py b/dlclivegui/utils/config_models.py index ef27ac2..52cf80a 100644 --- a/dlclivegui/utils/config_models.py +++ b/dlclivegui/utils/config_models.py @@ -7,7 +7,6 @@ from pydantic import BaseModel, Field, field_validator, model_validator -Backend = Literal["gentl", "opencv", "basler", "aravis"] # extend as needed Rotation = Literal[0, 90, 180, 270] TileLayout = Literal["auto", "2x2", "1x4", "4x1"] Precision = Literal["FP32", "FP16"] @@ -17,7 +16,7 @@ class CameraSettingsModel(BaseModel): name: str = "Camera 0" index: int = 0 fps: float = 25.0 - backend: Backend = "gentl" + backend: str = "opencv" exposure: int = 500 # 0=auto else µs gain: float = 10.0 # 0.0=auto else value crop_x0: int = 0 @@ -59,10 +58,21 @@ def get_crop_region(self) -> tuple[int, int, int, int] | None: return None return (self.crop_x0, self.crop_y0, self.crop_x1, self.crop_y1) + @classmethod + def from_dict(cls, data: dict[str, Any]) -> CameraSettingsModel: + return cls(**data) + @classmethod def from_defaults(cls) -> CameraSettingsModel: return cls() + def apply_defaults(self) -> CameraSettingsModel: + default = self.from_defaults() + for field in CameraSettingsModel.model_fields: + if getattr(self, field) in (None, 0, 0.0): + setattr(self, field, getattr(default, field)) + return self + class MultiCameraSettingsModel(BaseModel): cameras: list[CameraSettingsModel] = Field(default_factory=list) diff --git a/tests/cameras/test_adapters.py b/tests/cameras/test_adapters.py index 587e5a4..e69de29 100644 --- a/tests/cameras/test_adapters.py +++ b/tests/cameras/test_adapters.py @@ -1,41 +0,0 @@ -# tests/cameras/test_adapters.py -import pytest - -from dlclivegui.cameras.config_adapters import ensure_dc_camera -from dlclivegui.config import CameraSettings - -# If available: -try: - from dlclivegui.utils.config_models import CameraSettingsModel - - HAS_PYD = True -except Exception: - HAS_PYD = False - - -@pytest.mark.unit -def test_ensure_dc_from_dataclass(): - dc = CameraSettings(name="TestCam", index=2, fps=0) - out = ensure_dc_camera(dc) - assert isinstance(out, CameraSettings) - assert out is not dc # must be deep-copied - assert out.fps > 0 # apply_defaults triggers replacement of 0fps - - -@pytest.mark.unit -@pytest.mark.skipif(not HAS_PYD, reason="Pydantic models not installed yet") -def test_ensure_dc_from_pydantic(): - pm = CameraSettingsModel(name="PM", index=1, fps=15) - out = ensure_dc_camera(pm) - assert isinstance(out, CameraSettings) - assert out.index == 1 - assert out.fps == 15.0 - - -@pytest.mark.unit -def test_ensure_dc_from_dict(): - d = {"name": "DictCam", "index": 5, "fps": 60, "backend": "opencv"} - out = ensure_dc_camera(d) - assert isinstance(out, CameraSettings) - assert out.index == 5 - assert out.backend == "opencv" diff --git a/tests/cameras/test_backend_discovery.py b/tests/cameras/test_backend_discovery.py index ec22edd..cb17a0a 100644 --- a/tests/cameras/test_backend_discovery.py +++ b/tests/cameras/test_backend_discovery.py @@ -75,6 +75,9 @@ def temp_backends_pkg(tmp_path, monkeypatch): monkeypatch.setattr(cam_factory, "_BACKENDS_IMPORTED", False, raising=False) monkeypatch.setattr(cam_factory, "_BUILTIN_BACKEND_PACKAGES", (pkg_name,), raising=False) + sys.modules.pop(pkg_name, None) + sys.modules.pop(f"{pkg_name}.fake_backend", None) + yield pkg_name finally: # Cleanup sys.path diff --git a/tests/cameras/test_factory.py b/tests/cameras/test_factory.py index d67fa0e..663e9a5 100644 --- a/tests/cameras/test_factory.py +++ b/tests/cameras/test_factory.py @@ -5,7 +5,9 @@ import pytest from dlclivegui.cameras import CameraFactory, DetectedCamera, base -from dlclivegui.config import CameraSettings + +# from dlclivegui.config import CameraSettings +from dlclivegui.utils.config_models import CameraSettingsModel @pytest.mark.unit @@ -34,10 +36,10 @@ def close(self): sys.modules["mock_mod"] = mod base.register_backend_direct("mock", MockBackend) - ok, msg = CameraFactory.check_camera_available(CameraSettings(backend="mock", index=0)) + ok, msg = CameraFactory.check_camera_available(CameraSettingsModel(backend="mock", index=0)) assert ok is True - ok, msg = CameraFactory.check_camera_available(CameraSettings(backend="mock", index=3)) + ok, msg = CameraFactory.check_camera_available(CameraSettingsModel(backend="mock", index=3)) assert ok is False diff --git a/tests/cameras/test_fake_backend.py b/tests/cameras/test_fake_backend.py index 750e2da..ade97e0 100644 --- a/tests/cameras/test_fake_backend.py +++ b/tests/cameras/test_fake_backend.py @@ -6,7 +6,9 @@ import pytest from dlclivegui.cameras import CameraFactory, base -from dlclivegui.config import CameraSettings + +# from dlclivegui.config import CameraSettings +from dlclivegui.utils.config_models import CameraSettingsModel @pytest.mark.functional @@ -36,7 +38,7 @@ def stop(self): sys.modules["fake_mod"] = mod base.register_backend_direct("fake2", FakeBackend) - s = CameraSettings(backend="fake2", name="X") + s = CameraSettingsModel(backend="fake2", name="X") cam = CameraFactory.create(s) cam.open() frame, ts = cam.read() diff --git a/tests/conftest.py b/tests/conftest.py index 151f4f8..e698dfe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,8 @@ from dlclivegui.cameras import CameraFactory from dlclivegui.cameras.base import CameraBackend -from dlclivegui.config import DLCProcessorSettings + +# from dlclivegui.config import DLCProcessorSettings from dlclivegui.utils.config_models import DLCProcessorSettingsModel # --------------------------------------------------------------------- @@ -88,12 +89,6 @@ def monkeypatch_dlclive(monkeypatch): return FakeDLCLive -@pytest.fixture -def settings_dc(): - """A standard DLCProcessorSettings dataclass for tests.""" - return DLCProcessorSettings(model_path="dummy.pt") - - @pytest.fixture def settings_model(): """A standard Pydantic DLCProcessorSettingsModel for tests.""" diff --git a/tests/services/gui/conftest.py b/tests/services/gui/conftest.py index 6b8e1c2..4807c3a 100644 --- a/tests/services/gui/conftest.py +++ b/tests/services/gui/conftest.py @@ -5,27 +5,34 @@ from PySide6.QtCore import Qt from dlclivegui.cameras import CameraFactory -from dlclivegui.config import ( +from dlclivegui.gui.main_window import DLCLiveMainWindow + +# from dlclivegui.config import ( +# DEFAULT_CONFIG, +# ApplicationSettings, +# CameraSettings, +# MultiCameraSettings, +# ) +from dlclivegui.utils.config_models import ( DEFAULT_CONFIG, - ApplicationSettings, - CameraSettings, - MultiCameraSettings, + ApplicationSettingsModel, + CameraSettingsModel, + MultiCameraSettingsModel, ) -from dlclivegui.gui.main_window import DLCLiveMainWindow from tests.conftest import FakeBackend, FakeDLCLive # noqa: F401 # ---------- Test helpers: application configuration with two fake cameras ---------- @pytest.fixture -def app_config_two_cams(tmp_path) -> ApplicationSettings: +def app_config_two_cams(tmp_path) -> ApplicationSettingsModel: """An app config with two enabled cameras (fake backend) and writable recording dir.""" - cfg = ApplicationSettings.from_dict(DEFAULT_CONFIG.to_dict()) + cfg = ApplicationSettingsModel.from_dict(DEFAULT_CONFIG.to_dict()) - cam_a = CameraSettings(name="CamA", backend="fake", index=0, enabled=True, fps=30.0) - cam_b = CameraSettings(name="CamB", backend="fake", index=1, enabled=True, fps=30.0) + cam_a = CameraSettingsModel(name="CamA", backend="fake", index=0, enabled=True, fps=30.0) + cam_b = CameraSettingsModel(name="CamB", backend="fake", index=1, enabled=True, fps=30.0) - cfg.multi_camera = MultiCameraSettings(cameras=[cam_a, cam_b], max_cameras=4, tile_layout="auto") + cfg.multi_camera = MultiCameraSettingsModel(cameras=[cam_a, cam_b], max_cameras=4, tile_layout="auto") cfg.camera = cam_a # kept for backward-compat single-camera access in UI cfg.recording.directory = str(tmp_path / "videos") @@ -43,7 +50,7 @@ def _patch_camera_factory(monkeypatch): We patch at the central creation point used by the controller. """ - def _create_stub(settings: CameraSettings): + def _create_stub(settings: CameraSettingsModel): # FakeBackend ignores 'backend' and produces deterministic frames return FakeBackend(settings) diff --git a/tests/services/test_dlc_processor.py b/tests/services/test_dlc_processor.py index 9fed8b3..37e6eb3 100644 --- a/tests/services/test_dlc_processor.py +++ b/tests/services/test_dlc_processor.py @@ -1,40 +1,33 @@ import numpy as np import pytest -from dlclivegui.config import DLCProcessorSettings from dlclivegui.services.dlc_processor import ( DLCLiveProcessor, ProcessorStats, ) +# from dlclivegui.config import DLCProcessorSettings +from dlclivegui.utils.config_models import DLCProcessorSettingsModel + # --------------------------------------------------------------------- # Tests # --------------------------------------------------------------------- -@pytest.mark.unit -def test_configure_accepts_dataclass(settings_dc, monkeypatch_dlclive): - proc = DLCLiveProcessor() - proc.configure(settings_dc) - - assert proc._settings.model_path == "dummy.pt" - assert proc._processor is None - - @pytest.mark.unit def test_configure_accepts_pydantic(settings_model, monkeypatch_dlclive): proc = DLCLiveProcessor() proc.configure(settings_model) # Should have normalized to dataclass internally - assert isinstance(proc._settings, DLCProcessorSettings) + assert isinstance(proc._settings, DLCProcessorSettingsModel) assert proc._settings.model_path == "dummy.pt" @pytest.mark.unit -def test_worker_initializes_on_first_frame(qtbot, monkeypatch_dlclive, settings_dc): +def test_worker_initializes_on_first_frame(qtbot, monkeypatch_dlclive, settings_model): proc = DLCLiveProcessor() - proc.configure(settings_dc) + proc.configure(settings_model) try: # First enqueued frame triggers worker start + initialization. @@ -53,9 +46,9 @@ def test_worker_initializes_on_first_frame(qtbot, monkeypatch_dlclive, settings_ @pytest.mark.unit -def test_worker_processes_frames(qtbot, monkeypatch_dlclive, settings_dc): +def test_worker_processes_frames(qtbot, monkeypatch_dlclive, settings_model): proc = DLCLiveProcessor() - proc.configure(settings_dc) + proc.configure(settings_model) try: frame = np.zeros((64, 64, 3), dtype=np.uint8) @@ -80,9 +73,9 @@ def test_worker_processes_frames(qtbot, monkeypatch_dlclive, settings_dc): @pytest.mark.unit -def test_queue_full_drops_frames(qtbot, monkeypatch_dlclive, settings_dc): +def test_queue_full_drops_frames(qtbot, monkeypatch_dlclive, settings_model): proc = DLCLiveProcessor() - proc.configure(settings_dc) + proc.configure(settings_model) try: frame = np.zeros((32, 32, 3), dtype=np.uint8) @@ -116,7 +109,7 @@ def __init__(self, **opts): monkeypatch.setattr(dlc_processor, "DLCLive", FailingDLCLive) proc = DLCLiveProcessor() - proc.configure(DLCProcessorSettings(model_path="fail.pt")) + proc.configure(DLCProcessorSettingsModel(model_path="fail.pt")) try: frame = np.zeros((10, 10, 3), dtype=np.uint8) @@ -141,9 +134,9 @@ def __init__(self, **opts): @pytest.mark.unit -def test_stats_computation(qtbot, monkeypatch_dlclive, settings_dc): +def test_stats_computation(qtbot, monkeypatch_dlclive, settings_model): proc = DLCLiveProcessor() - proc.configure(settings_dc) + proc.configure(settings_model) try: frame = np.zeros((64, 64, 3), dtype=np.uint8) diff --git a/tests/services/test_multicam_controller.py b/tests/services/test_multicam_controller.py index 1ae32e8..5c26a86 100644 --- a/tests/services/test_multicam_controller.py +++ b/tests/services/test_multicam_controller.py @@ -2,17 +2,20 @@ import pytest from dlclivegui.cameras.factory import CameraFactory -from dlclivegui.config import CameraSettings from dlclivegui.services.multi_camera_controller import MultiCameraController, get_camera_id +# from dlclivegui.config import CameraSettings +from dlclivegui.utils.config_models import CameraSettingsModel + @pytest.mark.unit def test_start_and_frames(qtbot, patch_factory): mc = MultiCameraController() # One dataclass + one dict (simulate mixed inputs) - cam1 = CameraSettings(name="C1", backend="opencv", index=0, fps=25.0).apply_defaults() + cam1 = CameraSettingsModel(name="C1", backend="opencv", index=0, fps=25.0).apply_defaults() cam2 = {"name": "C2", "backend": "opencv", "index": 1, "fps": 30.0, "enabled": True} + cam2 = CameraSettingsModel.from_dict(cam2).apply_defaults() frames_seen = [] @@ -42,7 +45,7 @@ def test_rotation_and_crop(qtbot, patch_factory): mc = MultiCameraController() # 64x48 frame; rotate 90 => 48x64 then crop to 32x32 box - cam = CameraSettings( + cam = CameraSettingsModel( name="C", backend="opencv", index=0, @@ -87,7 +90,7 @@ def _create(_settings): monkeypatch.setattr(CameraFactory, "create", staticmethod(_create)) mc = MultiCameraController() - cam = CameraSettings(name="C", backend="opencv", index=0, enabled=True) + cam = CameraSettingsModel(name="C", backend="opencv", index=0, enabled=True).apply_defaults() # Expect initialization_failed with the camera id with qtbot.waitSignals([mc.initialization_failed, mc.all_stopped], timeout=2000) as _: From 25712f29a180f34b0b7793aa4a956fc64b44d79f Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 30 Jan 2026 16:21:55 +0100 Subject: [PATCH 68/69] Handle dynamic crop conversion and UI form sync Add conversion helper and validation for dynamic crop settings, and sync camera UI to models. - utils/config_models.py: Add DynamicCropModel.to_tuple() to expose (enabled, margin, max_missing_frames). - services/dlc_processor.py: Accept DynamicCropModel-like objects for dynamic settings by attempting .to_tuple(); validate format and raise a clear error on invalid data before unpacking. - gui/camera_config_dialog.py: Automatically select the first available camera backend after populating the backend list, add _write_form_to_cam() to write UI control values back into a CameraSettingsModel, and update the preview reopen call to match the changed _needs_preview_reopen signature. These changes improve robustness when dynamic crop settings are provided as model objects and ensure the camera configuration UI persists and selects a usable backend by default. --- dlclivegui/gui/camera_config_dialog.py | 19 ++++++++++++++++++- dlclivegui/services/dlc_processor.py | 8 +++++++- dlclivegui/utils/config_models.py | 3 +++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/dlclivegui/gui/camera_config_dialog.py b/dlclivegui/gui/camera_config_dialog.py index 5737280..d693d03 100644 --- a/dlclivegui/gui/camera_config_dialog.py +++ b/dlclivegui/gui/camera_config_dialog.py @@ -255,6 +255,12 @@ def _setup_ui(self) -> None: self.backend_combo.addItem(label, backend) if self.backend_combo.count() == 0: raise RuntimeError("No camera backends are registered!") + # Switch to first available backend + for i in range(self.backend_combo.count()): + backend = self.backend_combo.itemData(i) + if availability.get(backend, False): + self.backend_combo.setCurrentIndex(i) + break backend_layout.addWidget(self.backend_combo) self.refresh_btn = QPushButton("Refresh") self.refresh_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload)) @@ -777,6 +783,17 @@ def _load_camera_to_form(self, cam: CameraSettingsModel) -> None: self.cam_crop_y1.setValue(cam.crop_y1) self.apply_settings_btn.setEnabled(True) + def _write_form_to_cam(self, cam: CameraSettingsModel) -> None: + cam.enabled = bool(self.cam_enabled_checkbox.isChecked()) + cam.fps = float(self.cam_fps.value()) + cam.exposure = int(self.cam_exposure.value()) + cam.gain = float(self.cam_gain.value()) + cam.rotation = int(self.cam_rotation.currentData() or 0) + cam.crop_x0 = int(self.cam_crop_x0.value()) + cam.crop_y0 = int(self.cam_crop_y0.value()) + cam.crop_x1 = int(self.cam_crop_x1.value()) + cam.crop_y1 = int(self.cam_crop_y1.value()) + def _clear_settings_form(self) -> None: self.cam_enabled_checkbox.setChecked(True) self.cam_name_label.setText("") @@ -894,7 +911,7 @@ def _apply_camera_settings(self) -> None: if self._preview_active and self._preview_backend: prev_model = getattr(self._preview_backend, "settings", None) if prev_model: - must_reopen = self._needs_preview_reopen(new_model, prev_model) + must_reopen = self._needs_preview_reopen(new_model) if self._preview_active: if must_reopen: diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index 00b30b4..fb2a544 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -248,7 +248,13 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: init_start = time.perf_counter() - enabled, margin, max_missing = self._settings.dynamic + dyn = self._settings.dynamic + if not isinstance(dyn, (list, tuple)) or len(dyn) != 3: + try: + dyn = dyn.to_tuple() + except Exception as e: + raise RuntimeError("Invalid dynamic crop settings format.") from e + enabled, margin, max_missing = dyn options = { "model_path": self._settings.model_path, "model_type": self._settings.model_type, diff --git a/dlclivegui/utils/config_models.py b/dlclivegui/utils/config_models.py index 52cf80a..98c6306 100644 --- a/dlclivegui/utils/config_models.py +++ b/dlclivegui/utils/config_models.py @@ -134,6 +134,9 @@ def from_tupleish(cls, v): return v return cls() + def to_tuple(self) -> tuple[bool, float, int]: + return (self.enabled, self.margin, self.max_missing_frames) + class DLCProcessorSettingsModel(BaseModel): model_path: str = "" From 40bfd660e97b59ab032ca074d91ecf14b97d2e2f Mon Sep 17 00:00:00 2001 From: C-Achard Date: Fri, 30 Jan 2026 17:49:48 +0100 Subject: [PATCH 69/69] Refactor DLCLiveProcessor, add camera tests Major refactor of DLCLiveProcessor: remove legacy settings normalization, simplify configure(), start worker on first enqueued frame, always enqueue if queue exists, and eliminate sentinel-based shutdown. Introduce _timed_processor contextmanager and a dedicated _process_frame() to separate GPU inference, optional processor overhead timing, signal emission, and stats updates; add frame_processed signal and improve queue/task_done handling and stop/drain logic. Small change in GUI main window: only stop inference on camera error when no DLC camera remains. Add unit and end-to-end tests for camera config dialog and extend/rename several test modules to cover processor behavior and queue accounting. --- dlclivegui/gui/main_window.py | 3 +- dlclivegui/services/dlc_processor.py | 286 +++++++++--------- .../gui/camera_config/test_cam_dialog_e2e.py | 103 +++++++ .../gui/camera_config/test_cam_dialog_unit.py | 74 +++++ tests/{services => }/gui/conftest.py | 0 .../gui/test_e2e.py => gui/test_main.py} | 0 tests/services/test_dlc_processor.py | 118 +++++++- 7 files changed, 423 insertions(+), 161 deletions(-) create mode 100644 tests/gui/camera_config/test_cam_dialog_e2e.py create mode 100644 tests/gui/camera_config/test_cam_dialog_unit.py rename tests/{services => }/gui/conftest.py (100%) rename tests/{services/gui/test_e2e.py => gui/test_main.py} (100%) diff --git a/dlclivegui/gui/main_window.py b/dlclivegui/gui/main_window.py index e834476..d6c5f96 100644 --- a/dlclivegui/gui/main_window.py +++ b/dlclivegui/gui/main_window.py @@ -979,7 +979,8 @@ def _on_multi_camera_error(self, camera_id: str, message: str) -> None: """Handle error from a camera in multi-camera mode.""" self._show_warning(f"Camera {camera_id} error: {message}\nRecording stopped.") self._refresh_dlc_camera_list_running() - # self._stop_inference() # We now gracefully switch DLC camera if needed + if self.dlc_camera_combo.count() <= 1: + self._stop_inference() # We now gracefully switch DLC camera if needed, but if none left, stop inference self._stop_recording() def _on_multi_camera_initialization_failed(self, failures: list) -> None: diff --git a/dlclivegui/services/dlc_processor.py b/dlclivegui/services/dlc_processor.py index fb2a544..7d5776a 100644 --- a/dlclivegui/services/dlc_processor.py +++ b/dlclivegui/services/dlc_processor.py @@ -8,6 +8,7 @@ import threading import time from collections import deque +from contextlib import contextmanager from dataclasses import dataclass from typing import Any @@ -30,20 +31,6 @@ DLCLive = None # type: ignore[assignment] -def ensure_dc_dlc(settings: DLCProcessorSettingsModel) -> DLCProcessorSettingsModel: - if isinstance(settings, DLCProcessorSettingsModel): - settings = DLCProcessorSettingsModel.model_validate(settings) - data = settings.model_dump() - dyn = data.get("dynamic") - # Convert DynamicCropModel -> tuple expected by dataclass - if hasattr(dyn, "enabled"): - data["dynamic"] = (dyn.enabled, dyn.margin, dyn.max_missing_frames) - elif isinstance(dyn, dict) and {"enabled", "margin", "max_missing_frames"} <= set(dyn): - data["dynamic"] = (dyn["enabled"], dyn["margin"], dyn["max_missing_frames"]) - return DLCProcessorSettingsModel(**data) - raise TypeError("Unsupported DLC settings type") - - @dataclass class PoseResult: pose: np.ndarray | None @@ -71,7 +58,7 @@ class ProcessorStats: avg_processor_overhead: float = 0.0 # Socket processor overhead -_SENTINEL = object() +# _SENTINEL = object() class DLCLiveProcessor(QObject): @@ -80,6 +67,7 @@ class DLCLiveProcessor(QObject): pose_ready = Signal(object) error = Signal(str) initialized = Signal(bool) + frame_processed = Signal() def __init__(self) -> None: super().__init__() @@ -108,7 +96,7 @@ def __init__(self) -> None: self._processor_overhead_times: deque[float] = deque(maxlen=60) def configure(self, settings: DLCProcessorSettingsModel, processor: Any | None = None) -> None: - self._settings = ensure_dc_dlc(settings) + self._settings = settings self._processor = processor def reset(self) -> None: @@ -135,25 +123,22 @@ def shutdown(self) -> None: self._initialized = False def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None: - if not self._initialized and self._worker_thread is None: - # Start worker thread with initialization + # Start worker on first frame + if self._worker_thread is None: self._start_worker(frame.copy(), timestamp) return - # Don't count dropped frames until processor is initialized - if not self._initialized: + # As long as worker and queue are ready, ALWAYS enqueue + if self._queue is None: return - if self._queue is not None: - try: - # Non-blocking put - drop frame if queue is full - self._queue.put_nowait((frame.copy(), timestamp, time.perf_counter())) - with self._stats_lock: - self._frames_enqueued += 1 - except queue.Full: - logger.debug("DLC queue full, dropping frame") - with self._stats_lock: - self._frames_dropped += 1 + try: + self._queue.put_nowait((frame.copy(), timestamp, time.perf_counter())) + with self._stats_lock: + self._frames_enqueued += 1 + except queue.Full: + with self._stats_lock: + self._frames_dropped += 1 def get_stats(self) -> ProcessorStats: """Get current processing statistics.""" @@ -225,12 +210,8 @@ def _stop_worker(self) -> None: return self._stop_event.set() - if self._queue is not None: - try: - self._queue.put_nowait(_SENTINEL) - except queue.Full: - pass + # Just wait for the timed get() loop to observe the flag and drain self._worker_thread.join(timeout=2.0) if self._worker_thread.is_alive(): logger.warning("DLC worker thread did not terminate cleanly") @@ -238,16 +219,91 @@ def _stop_worker(self) -> None: self._worker_thread = None self._queue = None + @contextmanager + def _timed_processor(self): + """ + If a socket processor is attached, temporarily wrap its .process() + to measure processor overhead time independently of GPU inference. + Yields a one-element list [processor_overhead_seconds] or None when no processor. + Always restores the original .process reference. + """ + if self._processor is None: + yield None + return + + original = self._processor.process + holder = [0.0] + + def timed_process(pose, _op=original, _holder=holder, **kwargs): + start = time.perf_counter() + try: + return _op(pose, **kwargs) + finally: + _holder[0] = time.perf_counter() - start + + self._processor.process = timed_process + try: + yield holder + finally: + # Restore even if inference/errors occur + self._processor.process = original + + def _process_frame( + self, + frame: np.ndarray, + timestamp: float, + enqueue_time: float, + *, + queue_wait_time: float = 0.0, + ) -> None: + """ + Single source of truth for: inference -> (optional) processor timing -> signal emit -> stats. + Updates: frames_processed, latency, processing timeline, profiling metrics. + """ + # Time GPU inference (and processor overhead when present) + with self._timed_processor() as proc_holder: + inference_start = time.perf_counter() + pose = self._dlc.get_pose(frame, frame_time=timestamp) + inference_time = time.perf_counter() - inference_start + + processor_overhead = 0.0 + gpu_inference_time = inference_time + if proc_holder is not None: + processor_overhead = proc_holder[0] + gpu_inference_time = max(0.0, inference_time - processor_overhead) + + # Emit pose (measure signal overhead) + signal_start = time.perf_counter() + self.pose_ready.emit(PoseResult(pose=pose, timestamp=timestamp)) + signal_time = time.perf_counter() - signal_start + + end_ts = time.perf_counter() + latency = end_ts - enqueue_time + total_process_time = end_ts - (end_ts - (inference_time + signal_time)) # keep for completeness + + with self._stats_lock: + self._frames_processed += 1 + self._latencies.append(latency) + self._processing_times.append(end_ts) + if ENABLE_PROFILING: + self._queue_wait_times.append(queue_wait_time) + self._inference_times.append(inference_time) + self._signal_emit_times.append(signal_time) + self._total_process_times.append(total_process_time) + self._gpu_inference_times.append(gpu_inference_time) + self._processor_overhead_times.append(processor_overhead) + + self.frame_processed.emit() + def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: try: - # Initialize model + # -------- Initialization (unchanged) -------- if DLCLive is None: raise RuntimeError("The 'dlclive' package is required for pose estimation.") if not self._settings.model_path: raise RuntimeError("No DLCLive model path configured.") init_start = time.perf_counter() - dyn = self._settings.dynamic if not isinstance(dyn, (list, tuple)) or len(dyn) != 3: try: @@ -255,6 +311,7 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: except Exception as e: raise RuntimeError("Invalid dynamic crop settings format.") from e enabled, margin, max_missing = dyn + options = { "model_path": self._settings.model_path, "model_type": self._settings.model_type, @@ -264,13 +321,12 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: "precision": self._settings.precision, "single_animal": self._settings.single_animal, } - # Add device if specified in settings if self._settings.device is not None: - # FIXME @C-Achard make sure this is ok for tf - # maybe add smth in utils or config to validate device strings options["device"] = self._settings.device + self._dlc = DLCLive(**options) + # First inference to initialize init_inference_start = time.perf_counter() self._dlc.init_inference(init_frame) init_inference_time = time.perf_counter() - init_inference_start @@ -280,30 +336,15 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: total_init_time = time.perf_counter() - init_start logger.info( - f"DLCLive model initialized successfully " - f"(total: {total_init_time:.3f}s, init_inference: {init_inference_time:.3f}s)" + "DLCLive model initialized successfully (total: %.3fs, init_inference: %.3fs)", + total_init_time, + init_inference_time, ) - # Process the initialization frame - enqueue_time = time.perf_counter() - - inference_start = time.perf_counter() - pose = self._dlc.get_pose(init_frame, frame_time=init_timestamp) - inference_time = time.perf_counter() - inference_start - - signal_start = time.perf_counter() - self.pose_ready.emit(PoseResult(pose=pose, timestamp=init_timestamp)) - signal_time = time.perf_counter() - signal_start - - process_time = time.perf_counter() - + # Emit pose for init frame & update stats (not dequeued) + self._process_frame(init_frame, init_timestamp, time.perf_counter(), queue_wait_time=0.0) with self._stats_lock: self._frames_enqueued += 1 - self._frames_processed += 1 - self._processing_times.append(process_time) - if ENABLE_PROFILING: - self._inference_times.append(inference_time) - self._signal_emit_times.append(signal_time) except Exception as exc: logger.exception("Failed to initialize DLCLive", exc_info=exc) @@ -311,107 +352,50 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None: self.initialized.emit(False) return - # Main processing loop - frame_count = 0 - while not self._stop_event.is_set(): - loop_start = time.perf_counter() + # -------- Main processing loop: stop-flag + timed get + drain -------- + # NOTE: We never exit early unless _stop_event is set. + while True: + # If stop requested, only exit when queue is empty + if self._stop_event.is_set(): + if self._queue is not None: + try: + frame, ts, enq = self._queue.get_nowait() + except queue.Empty: + # NOW it is safe to exit + break + else: + # Still work to do, process one + try: + self._process_frame(frame, ts, enq, queue_wait_time=0.0) + except Exception as exc: + logger.exception("Pose inference failed", exc_info=exc) + self.error.emit(str(exc)) + finally: + try: + self._queue.task_done() + except ValueError: + pass + continue # check stop_event again WITHOUT breaking - # Time spent waiting for queue - queue_wait_start = time.perf_counter() + # Normal operation: timed get try: - item = self._queue.get(timeout=0.1) + wait_start = time.perf_counter() + item = self._queue.get(timeout=0.05) + queue_wait_time = time.perf_counter() - wait_start except queue.Empty: continue - queue_wait_time = time.perf_counter() - queue_wait_start - - if item is _SENTINEL: - break - - frame, timestamp, enqueue_time = item try: - # Time the inference - we need to separate GPU from processor overhead - # If processor exists, wrap its process method to time it separately - processor_overhead_time = 0.0 - gpu_inference_time = 0.0 - - original_process = None # bind for finally safety - - if self._processor is not None: - # Wrap processor.process() to time it - original_process = self._processor.process - processor_time_holder = [0.0] # Use list to allow modification in nested scope - - # Bind original_process and holder into defaults to satisfy flake8-bugbear B023 - def timed_process(pose, _op=original_process, _holder=processor_time_holder, **kwargs): - proc_start = time.perf_counter() - try: - return _op(pose, **kwargs) - finally: - _holder[0] = time.perf_counter() - proc_start - - self._processor.process = timed_process - - try: - inference_start = time.perf_counter() - pose = self._dlc.get_pose(frame, frame_time=timestamp) - inference_time = time.perf_counter() - inference_start - finally: - # Always restore the original process method if we wrapped it - if original_process is not None and self._processor is not None: - self._processor.process = original_process - - if original_process is not None: - processor_overhead_time = processor_time_holder[0] - gpu_inference_time = inference_time - processor_overhead_time - else: - # No processor, all time is GPU inference - gpu_inference_time = inference_time - - # Time the signal emission - signal_start = time.perf_counter() - self.pose_ready.emit(PoseResult(pose=pose, timestamp=timestamp)) - signal_time = time.perf_counter() - signal_start - - end_process = time.perf_counter() - total_process_time = end_process - loop_start - latency = end_process - enqueue_time - - with self._stats_lock: - self._frames_processed += 1 - self._latencies.append(latency) - self._processing_times.append(end_process) - - if ENABLE_PROFILING: - self._queue_wait_times.append(queue_wait_time) - self._inference_times.append(inference_time) - self._signal_emit_times.append(signal_time) - self._total_process_times.append(total_process_time) - self._gpu_inference_times.append(gpu_inference_time) - self._processor_overhead_times.append(processor_overhead_time) - - # Log profiling every 100 frames - frame_count += 1 - if ENABLE_PROFILING and frame_count % 100 == 0: - logger.info( - f"[Profile] Frame {frame_count}: " - f"queue_wait={queue_wait_time * 1000:.2f}ms, " - f"inference={inference_time * 1000:.2f}ms " - f"(GPU={gpu_inference_time * 1000:.2f}ms, processor={processor_overhead_time * 1000:.2f}ms), " - f"signal_emit={signal_time * 1000:.2f}ms, " - f"total={total_process_time * 1000:.2f}ms, " - f"latency={latency * 1000:.2f}ms" - ) - + frame, ts, enq = item + self._process_frame(frame, ts, enq, queue_wait_time=queue_wait_time) except Exception as exc: logger.exception("Pose inference failed", exc_info=exc) self.error.emit(str(exc)) finally: - if item is not _SENTINEL: - try: - self._queue.task_done() - except ValueError: - pass + try: + self._queue.task_done() + except ValueError: + pass logger.info("DLC worker thread exiting") diff --git a/tests/gui/camera_config/test_cam_dialog_e2e.py b/tests/gui/camera_config/test_cam_dialog_e2e.py new file mode 100644 index 0000000..efe1797 --- /dev/null +++ b/tests/gui/camera_config/test_cam_dialog_e2e.py @@ -0,0 +1,103 @@ +# tests/gui/camera_config/test_cam_dialog_e2e.py +from __future__ import annotations + +import numpy as np +import pytest +from PySide6.QtCore import Qt + +from dlclivegui.cameras import CameraFactory +from dlclivegui.cameras.base import CameraBackend +from dlclivegui.cameras.factory import DetectedCamera +from dlclivegui.gui.camera_config_dialog import CameraConfigDialog +from dlclivegui.utils.config_models import CameraSettingsModel, MultiCameraSettingsModel + +# ---------------- Fake backend ---------------- + + +class FakeBackend(CameraBackend): + def __init__(self, settings): + super().__init__(settings) + self._opened = False + + def open(self): + self._opened = True + + def close(self): + self._opened = False + + def read(self): + return np.zeros((30, 40, 3), dtype=np.uint8), 0.1 + + +# ---------------- Fixtures ---------------- + + +@pytest.fixture +def patch_factory(monkeypatch): + monkeypatch.setattr(CameraFactory, "create", lambda s: FakeBackend(s)) + monkeypatch.setattr( + CameraFactory, + "detect_cameras", + lambda backend, max_devices=10, **kw: [ + DetectedCamera(index=0, label=f"{backend}-X"), + DetectedCamera(index=1, label=f"{backend}-Y"), + ], + ) + + +@pytest.fixture +def dialog(qtbot, patch_factory): + s = MultiCameraSettingsModel( + cameras=[ + CameraSettingsModel(name="A", backend="opencv", index=0, enabled=True), + ] + ) + d = CameraConfigDialog(None, s) + qtbot.addWidget(d) + return d + + +# ---------------- End‑to‑End tests ---------------- + + +def test_e2e_async_camera_scan(dialog, qtbot): + qtbot.mouseClick(dialog.refresh_btn, Qt.LeftButton) + + with qtbot.waitSignal(dialog.scan_finished, timeout=2000): + pass + + assert dialog.available_cameras_list.count() == 2 + + +def test_e2e_preview_start_stop(dialog, qtbot): + dialog.active_cameras_list.setCurrentRow(0) + + qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) + + # loader thread finishes → preview becomes active + qtbot.waitUntil(lambda: dialog._loader is None and dialog._preview_active, timeout=2000) + + assert dialog._preview_active + + # preview running → pixmap must update + qtbot.waitUntil(lambda: dialog.preview_label.pixmap() is not None, timeout=2000) + + # stop preview + qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) + + assert dialog._preview_active is False + assert dialog._preview_backend is None + + +def test_e2e_apply_settings_reopens_preview(dialog, qtbot): + dialog.active_cameras_list.setCurrentRow(0) + qtbot.mouseClick(dialog.preview_btn, Qt.LeftButton) + + # Wait for preview start + qtbot.waitUntil(lambda: dialog._loader is None and dialog._preview_active, timeout=2000) + + dialog.cam_fps.setValue(99.0) + qtbot.mouseClick(dialog.apply_settings_btn, Qt.LeftButton) + + # Should still be active → restarted backend + qtbot.waitUntil(lambda: dialog._preview_active and dialog._preview_backend is not None, timeout=2000) diff --git a/tests/gui/camera_config/test_cam_dialog_unit.py b/tests/gui/camera_config/test_cam_dialog_unit.py new file mode 100644 index 0000000..83163e6 --- /dev/null +++ b/tests/gui/camera_config/test_cam_dialog_unit.py @@ -0,0 +1,74 @@ +# tests/gui/camera_config/test_cam_dialog_unit.py +from __future__ import annotations + +import pytest +from PySide6.QtCore import Qt + +from dlclivegui.cameras.factory import DetectedCamera +from dlclivegui.gui.camera_config_dialog import CameraConfigDialog +from dlclivegui.utils.config_models import CameraSettingsModel, MultiCameraSettingsModel + + +@pytest.fixture +def dialog(qtbot, monkeypatch): + # Patch detect_cameras to avoid hardware access + monkeypatch.setattr( + "dlclivegui.cameras.CameraFactory.detect_cameras", + lambda backend, max_devices=10, **kw: [ + DetectedCamera(index=0, label=f"{backend}-X"), + DetectedCamera(index=1, label=f"{backend}-Y"), + ], + ) + + s = MultiCameraSettingsModel( + cameras=[ + CameraSettingsModel(name="CamA", backend="opencv", index=0, enabled=True), + CameraSettingsModel(name="CamB", backend="opencv", index=1, enabled=False), + ] + ) + d = CameraConfigDialog(None, s) + qtbot.addWidget(d) + return d + + +# ---------------------- UNIT TESTS ---------------------- +def test_add_camera_populates_working_settings(dialog, qtbot): + dialog._on_scan_result([DetectedCamera(index=2, label="ExtraCam2")]) + dialog.available_cameras_list.setCurrentRow(0) + + qtbot.mouseClick(dialog.add_camera_btn, Qt.LeftButton) + + added = dialog._working_settings.cameras[-1] + assert added.index == 2 + assert added.name == "ExtraCam2" + + +def test_remove_camera(dialog, qtbot): + dialog.active_cameras_list.setCurrentRow(0) + qtbot.mouseClick(dialog.remove_camera_btn, Qt.LeftButton) + + assert len(dialog._working_settings.cameras) == 1 + assert dialog._working_settings.cameras[0].name == "CamB" + + +def test_apply_settings_updates_model(dialog, qtbot): + dialog.active_cameras_list.setCurrentRow(0) + + dialog.cam_fps.setValue(55.0) + dialog.cam_gain.setValue(12.0) + + qtbot.mouseClick(dialog.apply_settings_btn, Qt.LeftButton) + + updated = dialog._working_settings.cameras[0] + assert updated.fps == 55.0 + assert updated.gain == 12.0 + + +def test_backend_control_disables_exposure_gain_for_opencv(dialog): + dialog._update_controls_for_backend("opencv") + assert not dialog.cam_exposure.isEnabled() + assert not dialog.cam_gain.isEnabled() + + dialog._update_controls_for_backend("basler") + assert dialog.cam_exposure.isEnabled() + assert dialog.cam_gain.isEnabled() diff --git a/tests/services/gui/conftest.py b/tests/gui/conftest.py similarity index 100% rename from tests/services/gui/conftest.py rename to tests/gui/conftest.py diff --git a/tests/services/gui/test_e2e.py b/tests/gui/test_main.py similarity index 100% rename from tests/services/gui/test_e2e.py rename to tests/gui/test_main.py diff --git a/tests/services/test_dlc_processor.py b/tests/services/test_dlc_processor.py index 37e6eb3..210e820 100644 --- a/tests/services/test_dlc_processor.py +++ b/tests/services/test_dlc_processor.py @@ -19,7 +19,6 @@ def test_configure_accepts_pydantic(settings_model, monkeypatch_dlclive): proc = DLCLiveProcessor() proc.configure(settings_model) - # Should have normalized to dataclass internally assert isinstance(proc._settings, DLCProcessorSettingsModel) assert proc._settings.model_path == "dummy.pt" @@ -39,7 +38,7 @@ def test_worker_initializes_on_first_frame(qtbot, monkeypatch_dlclive, settings_ assert getattr(proc._dlc, "init_called", False) # Optional: also ensure the init pose was delivered - qtbot.waitSignal(proc.pose_ready, timeout=1500) + qtbot.waitUntil(lambda: proc.get_stats().frames_processed >= 1, timeout=1500) finally: proc.reset() # Ensure thread cleanup @@ -58,15 +57,13 @@ def test_worker_processes_frames(qtbot, monkeypatch_dlclive, settings_model): proc.enqueue_frame(frame, timestamp=1.0) # Wait for init pose - qtbot.waitSignal(proc.pose_ready, timeout=1500) + qtbot.waitUntil(lambda: proc.get_stats().frames_processed >= 1, timeout=1500) # Enqueue more frames; wait for at least one more pose - for i in range(3): + for i in range(10): proc.enqueue_frame(frame, timestamp=2.0 + i) - qtbot.waitSignal(proc.pose_ready, timeout=1500) - - assert proc._frames_processed >= 2 # at least init + one more + qtbot.waitUntil(lambda: proc.get_stats().frames_processed >= 3, timeout=1500) finally: proc.reset() @@ -146,11 +143,11 @@ def test_stats_computation(qtbot, monkeypatch_dlclive, settings_model): proc.enqueue_frame(frame, 1.0) # Wait for init pose - qtbot.waitSignal(proc.pose_ready, timeout=1500) + qtbot.waitUntil(lambda: proc.get_stats().frames_processed >= 1, timeout=1500) # Enqueue a second frame and wait for its pose proc.enqueue_frame(frame, 2.0) - qtbot.waitSignal(proc.pose_ready, timeout=1500) + qtbot.waitUntil(lambda: proc.get_stats().frames_processed >= 2, timeout=1500) stats = proc.get_stats() assert isinstance(stats, ProcessorStats) @@ -159,3 +156,106 @@ def test_stats_computation(qtbot, monkeypatch_dlclive, settings_model): finally: proc.reset() + + +@pytest.mark.unit +def test_worker_processes_second_frame_and_updates_stats(qtbot, monkeypatch_dlclive, settings_model): + """ + Explicitly verify that after initialization, a queued frame is processed: + - frame_processed is emitted for the second frame + - frames_processed >= 2 (init + 1 queued) + """ + proc = DLCLiveProcessor() + proc.configure(settings_model) + + try: + frame = np.zeros((64, 64, 3), dtype=np.uint8) + + # First frame triggers initialization + init pose + with qtbot.waitSignal(proc.initialized, timeout=1500): + proc.enqueue_frame(frame, 1.0) + qtbot.waitUntil(lambda: proc.get_stats().frames_processed >= 1, timeout=1500) # init pose + + # Enqueue one more frame and wait for its pose + proc.enqueue_frame(frame, 2.0) + qtbot.waitUntil(lambda: proc.get_stats().frames_processed >= 2, timeout=1500) + stats = proc.get_stats() + # >= 2: init + the second frame + assert stats.frames_processed >= 2 + # queue drained + assert stats.queue_size == 0 + + finally: + proc.reset() + + +@pytest.mark.unit +def test_worker_survives_empty_timeouts_then_processes_next(qtbot, monkeypatch_dlclive, settings_model): + """ + Verify the worker doesn't exit after queue.Empty timeouts and still processes + a subsequent enqueued frame (this asserts the loop continues running). + """ + proc = DLCLiveProcessor() + proc.configure(settings_model) + + try: + frame = np.zeros((64, 64, 3), dtype=np.uint8) + + # Initialize with first frame + with qtbot.waitSignal(proc.initialized, timeout=1500): + proc.enqueue_frame(frame, 1.0) + qtbot.waitUntil(lambda: proc.get_stats().frames_processed >= 1, timeout=1500) # init pose + + # Let the worker spin with an empty queue (several 0.1s timeouts) + qtbot.wait(350) # ~3-4 timeouts + + # The worker thread should still be alive + assert proc._worker_thread is not None and proc._worker_thread.is_alive() + + # Enqueue another frame and ensure it is processed + proc.enqueue_frame(frame, 2.0) + qtbot.waitUntil(lambda: proc.get_stats().frames_processed >= 2, timeout=1500) + + stats = proc.get_stats() + assert stats.frames_processed >= 2 + + finally: + proc.reset() + + +@pytest.mark.unit +def test_queue_accounting_clears_after_processed_frame(qtbot, monkeypatch_dlclive, settings_model): + """ + After a queued frame is processed: + - queue size returns to zero + - unfinished task count (if accessible) is zero + + This implicitly validates correct task_done() usage for processed items. + Note: the init frame is not queued, so we only check queued work accounting. + """ + proc = DLCLiveProcessor() + proc.configure(settings_model) + + try: + frame = np.zeros((32, 32, 3), dtype=np.uint8) + + # Initialize (no queue involvement for the init frame) + with qtbot.waitSignal(proc.initialized, timeout=1500): + proc.enqueue_frame(frame, 1.0) + qtbot.waitUntil(lambda: proc.get_stats().frames_processed >= 1, timeout=1500) # init pose + + # Enqueue one queued frame + proc.enqueue_frame(frame, 2.0) + qtbot.waitUntil(lambda: proc.get_stats().frames_processed >= 2, timeout=1500) + + # Queue should be drained + q = proc._queue + # It's allowed to be None if the worker shut down, but in normal run it should exist + if q is not None: + assert q.qsize() == 0 + # CPython exposes 'unfinished_tasks'; if present, it should be zero + unfinished = getattr(q, "unfinished_tasks", 0) + assert unfinished == 0 + + finally: + proc.reset()