-
-
+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("<9zfU7FuNQNcv1b*#wJCq}K~s_|bV=(x;O|^V*9kRsGZdwy4>yU~~47 zo9mC5#R{_K1FoBsndz0_jlR4|JNZxZ76!VjTHFUnH2>d&iI>O+I0T!e*8<ptuH`Fv#_ypD#@>sui;r>skgz7%GQ})geve0DQ z-mKUWT=yE3U9^$sAO^n2-1}#4Zf)kZQk$%bnls2aZ8m>KwSy9y-RqfiQ6cyeF>PGc z#gfnU3qn07wYho66D!=Jmhyi-D)HA%CZ^rY2s4bxT|b=bkqo{uhqSb=KEW63!WE^$ z`KPK%anci+X6#)4w$Le!v4LLhOVVRM1_WpdZg(1|7P=ljRyWt z1pg-jvHugn|A_$l|APpakRDmS#o!Ic=}q@6m#^&Zr}YbV3Obc0Q&ElKZ?}ahG2UZL zY>tw3P`eb##>HfoSSYK;X|%>B=MN|j5rpv!rA=uhS&t~GDG@K4^o?Oazk0C6K%cHk zURoUklU@wkb;(B3Q-|YIWrE4!8)4zI4+ci&?xkMnD)EW`v@ft=vM^L0`Q!O1#Tqv^ zjS4rHCA9WnZ#UA|0)9&hg7}Ehk)9^}#RwJS#`sJl^HX?HgtfKKydnuKn<=8@4Yq*Z zt9~(sMyfTNPFvfzS_%52s>7zg+lLdCc)9vgoxNXkTx2Hln5&ZnbK^V%^O8k&2>UOg zKqy%t+0bimO>+$K6kk*sw{R!JEJN^)W3IK&p_xuhbzd@%xpNASiTG>oy+KWGC#9Y= zp~p-W7ZCJU;<}I6CC80zt6<`@gtp*Zld?P`m;F2YZLSI5lK0KzrlK1ZepF7bmVJ3+ z|C(oclCOFPk;reZ4nbn~ArN;m(B;RC Ul9$o@=(t@#lP% zw2gVWNq~emg(9Ya9utCc3OAN0Y#EWBRf@H$%H4e~TC>sCd}Di>H*-&@O* 1DzI8g!ABvspyaZ|)pSl} z;wr+#UXKKZSpRlKWTv1Ss}Tf2iqn H7_=#zeOEIu*PdoM|@no56s?25<(UbT2EJ~BuXmJ%> 5u6PYo7enxCJi*3TPwdP&dO+VfrpZaS#b4Oo+#Rmr> z*I #0Zvp+dAniL8;&wo|2XxF$BHx zG=+hgz2d+M--swJK6_ClQltErvxh`pm#E2o)wY#CY}q0N6XIyOB?j8je^fo#iAfa= zuIKd>t~;&wivm!~U0dz*c-OO&?a<`lWH1NAL1Y|ffX+8PM53>E(W>cYP+Jgj_o*V; z$Fu5LiuL5NHjff_pPnQkUI b!o4TMUp~(gZC85&EJO!JfXV z#zLT+y|Mm8r_6CqMO!b75U5vs>FeEf` pm@P3N*v?C2%*GKeRI#03 t5z-!mpBmOwX0jkYfkn__rLl z)d?=NP~ra>OVYgK#M^FNC_+=L8#sS}Ys*1N1sPCYH+_DJb-J*=zD3~u%1+lSb*DIN z?&G!%LeL${9k~{9)LL#T|JoUG(hm>(KqEzmNh3P#?L+<6>5vNKx@~EwHN@sLFVuf( zB#ziPwHLX1KhME2P7`5zm8-9FMofpCJuUK_lbPUK)r&SZ9q7f;NWJAa X))S0!f6y2FhGMABgdwj;QCIjnlwYOi{)ze|J@0<8?qf4{R}5+*4Elob@M2IHJv( zS@iKa1DgMa<$-UU{n%-#MIg|{=hrO^jJgAA9C|kg^a;FUqaS&sa}(Zx0PHLV &mbGCiK zcT= 8wqv7dxK)N7fE8SeCU60>tmf*v56Bh#wYVJVUg4-3_RtQ07>e;)04V5X1Y>Un; zfVkeFX7K$j5YWy_2~E;jOSik-8NQ+0&>*sNlr#cllh-2yULPf&waA{%cj*`@oPo^P z;P`svmgZk#=dR0=Wmg@Iu$U3xv8q|lo;e;k0YL8iwX)xaSK0)pZw#LXtBl3^o6b$T zYTPEd2^8q}1LVH*NlKpz%==o4cIX(itO9i>ENgVL29xtdP0lO>$Pno@mVOn$hDIjF z?zz|=yH`5X;6P9S%5MF-&Fw-HO0((7af)?@-uI*AFx7r;X?t lVQA?&wf#-5m`ZFhB+O9rq+d|ub%d@b~nE_$m?&M}QMF*b=& zxKK7D3J!vNI#OePaRW(x9~ck@RC$aNAf~l&;QXqW!p+bCBERXX zOH1uHXi&<{>u)v7eB8KQc(6j_C1+UOurrBCpyqs(N~B~z0PX>t6wFX~-jPT?@(tsE zFmM1;vZ<`6cJZEnQrCo_JbBdW{{zBZDAN@UYWZf+tSM9cwGqA*etjPFP{;h&!|c{& zH7xZTp*bdJY2nf?7XbfobwjN#<}0_3>(;%MTVyZuiARM$HBzz8^a9;{Nr)T{5lZA) zG+BE3e&=&m>4V1Ft@Q!kaexgW_)tse<{KXQZ)ysDGn7 z{`l*~UpU#%%V2w}&a}t#J3vklB#BZMy>Gek zVQ32z!02yjAB~I;z*!;am=QJG5C$R~qyjg)j)B9VuX_}CEu^$Z$h~sCE0-!aFzv)) zaDV*WTNTeYt!9~XLIe;j1*ITTRu&Tk98oOR?gCp=yA2FP;Fx3#Uyu1?bhH 2M`L}lG7u=z+vptBuB>j~fndsXC?)mH zl}E#FHEF#xIe8TcqAC#iV4~z~ e661RB5YR+##MP)Gr#k)e{htZ1!PtBrZ{MhX`%xpB`x{6>!&tnK$K9A;R({ p22RY@`>;mEIF2Dgp%PfXs>PO0#n zrcg8No6#xI;k&1m?!SoO-}Ngfoo$(2+1jSwwLIK+F}aFxH{w%`*J#V`lD%tUB7HQX zI{as9Z}93Wc*EXg!iZi&E_k;*n4!CI@F=(Yym8-BO?5G%l&KU4`sI2pv1};=1|I*7 z^~>U;A0RTG=Vd!f)#O;*AhTrrB7-J1C#HYfn66FCyI$1&%p K6UsgOV8U)o6hf{4=$+OK1MNd z*Hsu~Rg^kN1d^HEzYrBTf6X^FI^@nYm+XJPN{cKS^TCM9-p}lkayEL6F|%rl%(7g% z+!48u{0T3-sxU1v<+wmrHDWcl8s$U37sOEF+iNviq4|j(GB}!sc~@Wi;ADKboNm_X zH~VtPVqPXYF0b;)a~%G`gYUwO<9tNDx>$P!VTT*eI6{La1${D2`kxiCpFh_lj h}St#N)LRXMH`Lz5U*+|lXFV>QD N_u$J`|=-Yj>fT@O+)q81BZU_;8h+F!lh6DWPY_f2~on z3&sRLg0?0i38z1Ce4AzLhiQK~^OKD&dMifmGt!zDeWI>-+#947W4cHu>*Va?B;_QM zkZpLQAQ1YcigMjB(l~8Hul*pO;Og|NKGzvP-aihH^ce66^+9Om`mW!|?U%Uy%eaSF z0`+@e*ERFWAF73Lk8&?1fSkySjWUU9sKPFr9`|_xv&D@EP-t@bCtHM67$Z0jxaa+W zSJK`jf*iw$(~9{S(u=$re@xdsU#fX`IKr84F&KH~f#q(5jDmh1 z(zL-xSwUF%!Omi>JUt%~r{24-$>Ai9%y3C+dcy;f0}u`Izt^FZ+t<4*a{5QKy)FI# z4N>WMcqui+9!^2h^oT9d`*$Q cmlyXnU#I@Oo#?%b2YyA>EUEyYjY(eo`@vta$b@j9htI;LvawO1^GGu3y zGV_&TX{4W5`bI_v>ZpW+%oM*pLBod=_B(2c3$a#|9;DIZrPfoD?l7H_D)E_s%4-uO zf
0*v}yPr`NMn-_R$y+UaS+ z;_w{2zc;)VP-D@@vr}+V8sv``B41N8&GnOTO1t_8VLeS450-`$u!_xZ-9-qJ)OZDP zCD}r8bM-0B6odtVi4$c*U>JC!LMF-wsa}^?!lzNt^R_<)o%TDSFBcAQeV+EEJA2PY zuW0&zGpIa(w{`(QA}UohTz6A&_r+Z@>$pWVpzg{&`RNp-a!n8WEQQ!{|Mmq o}{vN$_$W>#7NQt}f6m!pfvCyj*vjRG~+qVo5H4TyxpdI6hP+I7;>U6V`o@cmFUT ziEP&V(&-m*%JM^p!{iN%h0tTR&Tj~?JPg NjmuGdcE(L^bfLAUYo|3 V+`8EX@3zyYq;qY47hL%WXIM^qGpI4r=RiG6!N4 zVy%xZ-T+l8sBv6c KQ8 zW$f`=J_7M(z8&E#WWHtb=nW?-eZk$Px_6Q1k%WgOcz0u&95(PT$RYC1Cb&UHldGx_ z#Jv2Pur&FK%U*t=mc9PtG>G;`DPI#SY T%h3!$WcLXZv^DSg1 zDhu;4bqBJ!u-Lq2f!<87u5AbB9g0NR5>ND7;q0;XqerbT>CuE$4Q0X0pF(_Gs1Stk zOUajTOf77~a46JvQ#jDbd}!!IU4x&|HX=^jL|15G{!3}{f5I5TJAcw1qqkM~wp=2k z ^BG`F--sM76>ONfsm~OBDNH5;i4N;m2$uPTkB 3maITdXE-y>^dmKWP$AWO<4Lx+rVK@@X(Q@ME{9gAwyYZHkmi@xO2 zg_|U7PB>?ma&1RVPq_T4RWzcd&n&szKl=DaNnbZTkS3-A_U8mL0nl>dYhnJmu}I8E zjCM)NLy`KWec$4VBC~N~x`bHr_kHile*-Gk^Sx7|#mxu`Eq0tnto{t}tOu5NAtk_J zq0X%|xVu~|`*ZF<5C`-aiRnC7Rq%1CJc66iwMA|0+HI_lQ?Tmx+GjZUIPN 7hK~{XAv!n1iQ^DTX?5DVrg9obqPa15Vzv`8URyB`iqp-{~X_2+o zOI1$wXF74h;A4CP;yruy3%~J^MlQ$zA@imqApYPaK718e_I|%e=Jev*`RNJilo&9O z4w2EDL#q|OZBa<;m0X@-G#BON#v`p=q;RIvie>bm6y6En-~OvSnDjn>MBQ3Smn44J zc&ws(17p8OwxS%C%}HKitv$1kn9Sa)<#^;18u{UVe85i}F^H^G*<)-IB+Q$>)<^Bg zW5{f(Pqi#B$iDO{xp0k?!5k>luh?v7wfyJ>I}+E8UWxDqt??-MQptNaj{? n5tq@0%k>pXgIN2c`NU%7#ye#bXMMGqiimFb z6YG148GXGZ(T3|FVOr2lcBx)#4)%Q6ZW2P?7gV^cN4m_DmQGWM7E2DC(cetv@hB#~ z!N{HAIt!gCI1t+d+xpl^?VQhlVsjmH-%Jknw}<3=utBnDoxrKMF+5?#mo+s_=vR-* z)}nWc>koK%s?Md?oBl-f>YiN43y4DrAPdV#Al~rsPpy3?a6)VRV{Lx vW# z( tM!}`*>q*B4w%DZ^32P}Aq?VfK@eL}S6 zB3qY4&O%dq%>EBz4Bt8P3*$aFRY;2oe2B+`=Y}bJnP~M=a`sJjHd79++3SZ8n!z^@ zXT6S5Pmi}}`18}|%k5PT`gcRfNtW9+q1N1gMF++I22;WTd}6fsIjpSA#7l1`s_!Rn z#rq)oUc&s6FTq{q1z)<@RA`?#3~nIYAL3aljDYdN1{9|f10R}9kfwl)U2dm|=0v8^ z?grFKPsN@( ;Dp0 zVlWo{E_@*z)tJe?+m!> )D5?F5+TkBClidF(=0!Aagi)E+3x #<`-;A)rb%pGsR6iS}95Y0l;TcND*hyelRmYYG>`FG_2cHKZ%9{?G-mb?hX6^ z`5Ab`)?CR?yG#-BTT3e&f|*?ydOyV8(B3@7%6we6Hx*c?Q@;FjDeEZD?Y{X-I$19@ zFY3_jv~fmCS+$4x7RDA%1++;di;I09Sso?y)(Ii~^Wp3vNdCt}$4Ng`^plwv&S}z& z^?8}R<^m +H%2+Uz8Djw;f2fdbAt1>Di?v#BxdT0xhT3T8n9)C{OqSkhk z-+u9WiY@zD#)1OKa%mtq+FZ-^+m8xhiH}!eu2KT&AjN_~dxe7F*L+U)uepv+YuIp~ z4RR7Ry8@U4X3v7>tXH?asyVr@s^@-(C_ %KeHT} zJ^=aW7Zk26t*j;ppymotf1D@Y)2AvGS+8;nVS(*PfYZJ33INYS4M&~GWKsg3!QQ;D zqmt#ed69W&8w-K#CR}-{xKhqzD|+DQPg?uJ9 M`4eeTQnXYR|N^+`P4-bmJ2=o*mA8>0k>{OCH#7^4`Tq z7JQ-RPFc15gdPM-Nurn2>n97*Z#%+yR0E&~ovKn08K!wjdjSUXuI~~>@|YF}Mq6>^ z*E#Y)`O_Wq0mFAAe}wjBe# W=+=;PfgR6l#W`A+n zg4|m7zN($^t@Im^-MiD9XNE{LPhCfgg0#1WThHZ?!pQ#n{CN$U0U#Aa1|PCptCqIj z@AS+*+WE894jgU+nxfU9r7lQuyc>$^dE_;4B$cH)_tYNb+q%k0Z4R6jR@goCr*C%` zdCRYsf4YT?tn3hqJ|xbRtAg+LopEFZ((6+Le){~a6BQ(%J)h>c4Kh1q4NCJ(0~otp zLAd_x{;S5`Bq8>aWFR}P?bAdJ(Yf;{2IELoNX)57hEq?bq!T0FUEz#SLyzh3AGV#a zWD|u7HEid*&P>>L*!HIiiI$|E;i%Clj6u$zk(R+HWpg+uz&k!Ws5Fi90^^d+8t%-MTZt z^yA}N (p3*+f~V>mv2c zXz_A!=SN!myqKBFu= |Ky`<|k`5G Tig=&}^T3KZob2y?VZ7Z-^^ zMTdzP266Zeo>>(jrYGz8)u}b304-tknb!f4IJO; t8VLrLB<$CVhRf{{IY?jmicGY158I^3dsDU#The>z9xmwGw z3k3l?qbEUb@k#r&m&3)_!kNx?Cn-E-23PDzOk-Gm6q!VIkZLKj@3tkkAuA`d6_gMe z709M3x6DI;T{fDy%ozY6_p3gMNX8MB%uK|6*dV~1VT3X*?YmZY*Fm-PV<7y3BU#`G z6GA8otnbg?OK1Za*ZE;(-97s+^^cq>XQ`p)mG#x_z9}Nnp?y{0H3>(E ~?^<21LouT8`uyELN0!1Q4+mb3axEo2_;{)+*FI zPDV!g(M$T!c)RnZkwbSILRVdv A+nEm*ak{+JNu1! zhLU@eUy7570G8Lo%;!4~0KW`l>$iHOE=9G%$RqT2OJL=dE+qdFxRrfl-%}H=(vCt= zrfy~ II?`6g@IOh!f9)gEIc*lHr z8Y@<}5I3~J+>$0|8j&Oyn?_jltNb)_HR2IaqX|2@2ayF1p6vl(0*SH}jW= #E?5g$=5Dhe^5CUnFXU3oq+PYu23P6{45Fsc6!ZOFWeo`ITHt=p~I_QX;m)L^p zp+ODRs^EWt9(UT~DoXk=B7@6=HK$$Wf#Z!#e}e-O?3oMQvYnr&LfGc37C}h0D}!^w zf W$hNZ+nt=&iEoN#@G!?B0+ c0N|hT+Kf^ianou=bLTUFa`AwGe={-#=Kq;zjO%`?wr@cW>0NkWhPDK+}W8Go* zP8E-S(0g4h4CuLz#-ZNu;0&ckUoE}u`?!=TR~swy8Oq+-PmqCx#i~tE-Vtk1Z{X9# z;xg0G-v2%{1H=a{gW)#OO+#dvhpSs8DgBLLq8D1Tra2&I&;)qPFSwn|%K(r1`ssR? zE4>ci>R|L;h`z}2$#*2LXze|7IjylpPg?f<8*LRx{5ppK;t+Q_azSEv^MI8Qm+%my z*;PqRkE+RIk>*F0L#b$;t;<=-@ll@56Cx@DqG2`pV67Ol_F65nRJhxL* zVRg?y4u)E3u zxKd9wRwHHjDCd{a`e(VFUjU*8z#b-DN;I(2?Gz-9jC%Z6YJLP{(tuH9@NJc1C9M5O z=s$g}y&Nz#$=f8A_2>qkYrV4-@Jg#tWLn4*$8*wn&xy u!^fOdrdCW;94 ?ZXZ%ghN1;}({tA9bxzY=l`C>tM)_?umCK zV-)T>TLQ@SmW07=5NIS4goDvQ$R_XUw3&bkGa$lFU~-f`vGxgz5(#S!e`TPFZ@iLG zf$UD{)#QS!!NrTP I8MpU7y~_FD?q%%he|+o?$K9?r4O5{mGH~ zqk0fkAgWx|CUYsmcmoOG=?1za&;2lW6T#LRt>jMEh?y)>(@@3YC FKK5EV(=)20(3xQH$(sB49$6@xEbZI5k@MgmmqYmA= zECJcp5bt NpOaHvmw-1pUg9D=CSXLu|Mhjb=}9YFjW_ z?*4SQJ|s`m>;CPSQ}7cG47HbmiIqSlrv -tqlJwQwH5)UBB1eg>J$uXD0Cjl6(^D7f>i;W2&spVt9@zWQ2{dY>oIc-%^hyD zlb#A6cQIA~BoT3$0tRh+1FenC@lRBQ!^`a$#1F;?@m_?1o#L$C?wCc=jurx^wnEY_ z(?FR!Qrm#rh592hq;pb>+4V9XFj88WANPD4LvZ7lRQ6bpe(muXnDRcEb-sy`lavX_ zb?yUF1xA3R#o2VM7n@$v#p}&(zerGEN{pr=B#Xae2B;U!0Li2gojA!lJ`e5(+!j!V z&sj1UYZ>>+X2ccm6D0TJ9+fBbgAgC~$ooThQ};$aBlk6BN{1A-b)qpR^s?^h ^#D(wiQb1|@Z1@Zs^o(*#*z+~EGN_pV{G7?TYCCjEo(@|q`deUi z>e*qUy5Q+%sos}{YGL&)eG-mnWhogPiL{9$)k`h+mf?!5f=mHe44`iq3}<7e*cR14 zpq@BbKy33ve^vSeX~%jR*a|BCK9CKe)ab8$ddHFKqZif9Ep!mswSFRLa_Z?1pkcWE zBWq%roD5GGW2P+M$%y*Hut~o!|G{2c5hmp;Zs7BuzXk<#p~kbQ ?f`{X7CYG;dQOkK sG*M4b8028+uJ!<+fAFPY*>1XFhoX#|ePZ)Y#{R2f^N$lT zrqV`-bk^F|M#JjEOM7@}W1>H4tQ)LP)}>y$I8d(H1OZB6rc1LY?F%GlX+Ea^TnBNq zc17+$-`{(b;}6%b|5KgyIhPaZz>#)JjPV8QSBtd5Xo{CNRk+k|;=V`nQ>gu3^RE{i zpWx0q?YpA!0SXW7Zd5>BiC_)=*6iN{01h(aOo)>!l9QK5K1glwMp`zJR+U1Ea{u5p zM4W<~*2zn6+f%UwzldS_ZKOg1e1DYsHSs5W>n +J4eRwzu9N4489i>6kc*762e|7UDcKLkO;?m5nA)afvqA$`)Db5dt(uiu%K= zyFD%TUEr!=yad$D@q)Fqv1+FTq%9XGSVS4eITb^tDPB&9rHwJOkg9}dQYU{8AIkf7 zEP!fSpo DY8^`Bb4-D ^WE_KnW{e`lk)m121Urx#z@#Z(4 zTVWcJcTY0+nMt9Lyfits*H-1;LV%QQa7Atk)ma4hV#R+M<2hY MdF##an5Mg%G~cR=ZNpv-uHb9BY37Q9uK5XwgA>B|5D%zuaY9ac^-UH z8XU|*Gyy^tk@vV6i8*7nATnsp3~1$y_b%U)F8jWNwV*i!gz{rcItzigPx6$dOJ`I9 z$yKwP9K4rrKCmMi?=2M1D@-V6|4Etak)GG6<$|zAqv(>!@9(aPx=K_4^t*417u)Bz zy<&;aU }RBz0H=ugtQ z8Aq2&%l1yb1a0$Nx>e@n_*MXrb2 lmw4F=F41@%VM2g8b2fQj4q!F| z|Jw%k;BN6u;BJM8+IyDfMrpJnZck^U$)dPjwW%jh7>KTTK$Nk)g`OrP=Pp8|2wf z7hHO;$b{UPa>u{>VNNb>UOJ5lRQr~0n5{PMGd-^TYTFSZrmqD`L>Y2?H;@n$$gB({ zyX# 8INy1pf1E6ZNZw>eb!M_mF|1|!&Y@#ar=tV46db88IR{I#$(W409>#FYh$ zD)QJu0dFm{K{|va;~43kx=r=&f?~8GcaF0JNTu30B fmw=FuwDXZetOXcFPYMsO;O5r*{8h z($^u$0|hMc4GsCe`Q?&I^M+&uK)PNaYDcM>p7 )5#{hA8X>swVJ MC437S-hI-r z#A>0*y-{&nd9hts>)3ufe)|Sd1rbATK>-p)ugc?D8tw0%GHiJUf`(swtF%|04H2yx zqto#kFeqnXvbOZ2e$6jksgbK{P~A>9DmT2^1<3{;Focgr0jFmIz3ub)a+3(IX!J0- zWlL *hT^H4)EE?Qd?ln?X4b4lXV(nCB>v+`2OJqEwO# z3@;F!NQ&jlJm%i8n7gY}jtOo^Ow!?!#T3%F1DZnjw@A#gqmJ1XV9;PeENXt)zmCL~ zFB7$g2=FGH>sUPp*Ue6=8p}^#0_25zFN;9&g))zf#e@1r6KkYBK0n1gN#RWV2y~Qk zWZI2(>$QuA5d7@!o=C=x8Q0nVb{>b)2*X$sujP^c2ot&fw`Y=_{L@3u-!MZfIr#Ft z^ P)N C_IdaG3BkomkMX0|Au|4uCPqfB+$}Q{e{$uWkREZsf zV#~wd%m7~JmN76e6}7!hv*EE-E_zzN-tfNR#B#PVyrIl^djXkig<`?y()-0WTqMXS zk*sWhLsHL7IK1S+CX}1#<()N*!vzIJ% t}G! z=Xiziil~0l _jvvo+Tv|0d++qI>W33m`2QJ10*|U@@zW6d Ac^ z+8eq(=%=ZFSeKx8cB9GeowDVGe%*@3!X*Egv|FtOL7ON*Yag+sj_d>IcfQ2Q<+;c9 zZ|H@JHgr+-(!20s*8~!bXq~H?K(`ftiTgs6-1~BP_Hkx;CexAv|N7el0P#!BlB^f- zj~O4Sfhw$SpWn@33HLdLQnLmOVK;x7$-#rleonL$#4wZ13GgKrkrjDqR6N85;$(AR zPaYN2H!+LW?_IJUEn!`Wt-2%QH)L)5g_P0EKByxBVAttpZ29ozqk~R`Vm(v&&BM63AOR;7px#s)FR5MmPYr1BmsCes_*2!%K^5S<}WZ z-U6ftok2JM{=7@6yREkt)1a0`Zz@&=zO@eeN4}r6qUxLa)Kp$noYmgm!)l|8XG0Qg zv1^P-n$Wx-%WeLjRibyCz|8=dsz#jY3h)L`^fhEgXyYF3C;0_Qg24<2wNmu8-7EpQ zt>>!)ZAp@YF5=x~|FeAAi>3r#;jAirp~nYJfNJUoDo`C$Z+i37Jt|q%Eh2b B>C25o#X#hTZA+vVmwWo4L{z-=yQ*5^n*P9U- zpb+IDMJ^r`9=H$3`l|viJb+5}8q%nfTTmc#UFq$YW=?EfBu`5K+L5Tm%XLUIkuI!9 zfDYxb^$twTO%O5TvqYRGGa_j~Xbb+(jf`j6Tu*G==hHK*q}OqNY2|N@*y`_iAg@&A z iR9ve8)ReB`D?4&ouU~z zT%&+hxhD>Cu`Gb#5`bGt6rkb-)>K@kOt8yk2C_2d0waZX8*ywXviiF_xDsc7S&3_8 zAt%|4?4GEWX4{pcv2xAfWFiG(ckG>BUx-dhkjgJ$DDCLC+oq~Cz-52r|G}INc>|#n zD~mm`+@RxLD&u*L-KmrxU{`$MX5R>!iFht4X9VgIZ<;m14F{q!tm-S;#w zm6w8gvF#2<3S!H5OEZSmR0v@GB4l3bhXTrKN>2NiL?8aJ9`<=l4y(y^^+TI0)?+qz zpcjgCxZ=o*hah20OJ!CU<`KOUH~`!^!xP5y r3(h?iKozC8OBo6~NCosbuhi^87PJ=}`Mtq zV!x0D_fNfhA}1a5;7Ls4WO(QVu=@j}LnPJ3m*D CP zY?BST85_Pt^4O2)hKOZcN{7e&6uaaqfY4bzQ!j!m!Io? NCR+&?kBK%x(VKR(*acy?UOnsf?EOtlC-|1LlvNsLS-8x zK>793&>S-fBgMRA&fFL`xyD!2BP>n2+p2vBLeC$LiaZV7%?)z5-XOeqC*v*%1a?}E zI(PYj!gM=+fexTNv5V&%tSB(qMHCn7JY3YsnZE|6ec%^zzh*Fs1@u1NPcez^-sC$1 zgb-qY@zC|+awuB}4o;K^9gBN5m)Nika8uJf^q6gh=#kMs1ONTr)kg?b22o#U#b!D| ztQTRRYsdmVnqe*UUKC3x_CH30!ueP|)fL^_+Km~st9)Kr+s-|0{U`VA`EFCt@#Bf7 zf!SvSPGHEo*-V~nNQ4dc85ojalTUV=f@IuA;K#G)eH}P3kcH444Z+*2ZTyibux`G8 z2V={7`k4zf&`E})D0jom*VzY%{Knv$6nKi4#R=DM_uSWKV<-b#4I~24uY;C~slVS? z4L`8ao^Y!IMu~Y@6m7MxM-Ix8kllmtsLvkz18=7LBMYdvf}%tZhvL8ncqq5jlbh&8 z#k+xmgkZj2{#LVd_R3AQzqw^C4Yd2{hSoXGTGvrKVeu#l8#bHM?%I%G12k7+PHy2r zOu1c-(Vet5>lh@27px3Oa`c|gXD;2KA->#^;C~YTpf%vY^8(8JAYcUD%6!d?d)}w7 zsZ+1>kWh5A^#n{GgKk@JIYv)35 Ey5v47k^PTZP*duVN&;4ekuVttVX!-&Ug; zt6~lK@ATYgU!<}=Ky5|(4akp3%TYx;wxN~{a5QqPL2uA_i^UGlCL#xkNkEvMTqd+f zeaO{|Mxn6yBmkzEM?JjfORBwGZsz0n!vOX@N{u`M*-3?^p54M0LK2+-^45cC&-d#3 z@AL~mcM$;s#0cFjk@JySBbD4>&y*rRM0nv1-jb``R_FGzkqJ2!3;Zqo&NSCQ)7zs! zCVJiO2BBk9)<6afwBqMjdL4s7VTA6QF-&MiGZMr^KB)uKfiQRU?bO1|4a*V=d**2C zlG?|gRz5rYjbJqE)1lpN+xTG)Ed7a}eLr9|D`Pdh-dtq!htUU!gsRH>dTL*6eom0< z66kq>%m+fakA=-@vX{d08O-3tCT}w|hu6w~QjEC}j8g5rkRrgo4CUROK!*V?nM8)d z=6ylTAhiEP^09c@^tqJC6QtMRJQ}9D+0WcMfT7PO7za1O-e6*KHhLOxFuOG~_Psd*}<4d}#*RcqeS@Yw;88$H@;6 z3^SntLC6ySln7K@Li!25(g?Pa8*7B{&-)4BdMc|F%^one1x6N=YDd{mxSx6@_S@ s*9hIC(3*l+uWGnj>0 V^3=R?G0f zwWrI 5$U&6=@BV*%`ZzICVyBeavi+ZC*p}MG?rir zgAD1}hD*V>ILrQETJvcIh}o~m%#dTaQr`bdh^{Ran<*o}a0Rzs A zH&CNv4SCGHbeLi@;50HQ93_pt)EtPgDE)?aKWB&Vv<3_uEG#%Glk$%~J!lL~7ri48 zB}E7i{m9!6d9&ILI*5isT8*v_o_E&^QIF8^F{TzqNAdPA|15;Xr@ZNO11Z$4&=44A z`N1>vGzW@qzy_anB zHw$*beShfJSei;&;8#i97GH3{O3RA@-5O$L(9pgZI`b#hXzgAwtGQja`MVo|5L=D? zpanj87dqLr=OlTSTYlJbnbHzs=Y5=VxeIdR&;<$(*9B~yZOo`d`@E!YNzmbcQj^BC zTs|6r3p#K5(r@H$F4OPayq|-8p>Kh?9;DMk4lMHrAU!CpMf?#C&GdkQKEr=6*;(V_ z eLY{OrkelP}shm~f#qSKRe zC{MuB+ $BZ5=iO;a=pM`Owb10n6i_e$ 9#tUlNU-tnqTtE>g#?>9p1|7*_aZt+;f0diwQ+myNIuIMt#5$aliP zi~%v<2XRn?LUh0&cf!5_F}Pl&?8C;;-%1Ph#?xZEK^+6((t-j;{kyBlo-|>P019xU zL?;;S|3|a~lE1nn&rHpH<2b=SM4bkK=k)8531jg7BD$W3T@L4SE !n5vrRf1I@#aO5auAInV?y^XE80iYAA?4hiqE3{G$%p8^%dVu8NI%d~Ui?l3r zmHqnt!7#P 8#hCA-kM z2A2&On*=zOFGOyqHUdJALTf!I;>9-s8w~$+Lg#Te;Q{Gl(%OtjgjQVUL*at^ja((Q z%b}Fl9v&XY)tcBM7lx^kAhz%hn85&Mz9w q~<$ z2uP%HWodG=L^SYuQ)5d2$kN`qTcsBzvh%r~iE7v{y}K%1sYt)IP7UF9$ttelfiK^A zzL>uZ+7C|N7z}PVq`a!X9(sY1pH~q4c4IgkecshpXaxTLX677jU@cO~;EwXz=7$=v zYtJHG8mZF}j~X?eeUXUf#x}XVI)z|65!kZeyR3mMshP@GM#N7>Ri$3a&>Z#1pM@s~ z#>I_|LLvvg*qwGa-FBod3+5un$rt}*L ((N*vRFhc$Z;+t#5nn2Shx1NE zP4ihXN2!_jK#JoVFsdcERk&pgf{8Dfm`ILpp$kc5eatIeYF?e56kN0}+^D8{UA|SF zR3V73iZ9EtHOBCSp9Cc=IP^SMRaM1Jho9sIVMwR*+Gu-jsi*fNeu=et0In8L!~oN| zQ2#uX?yaf<&G>=*!fmZp$K(nS!2qauUncszgfFwfmup*>HgnS>TW)7sBAT0*EHqEs zu)XbNf2!!eC99b~o6>5qQ!0u8d5D6e0?)#{yzp}4W*>HRBeikeJ3|A-g7l|89-9VT zaWB;)=Y0SQN=r&ga`W-^Y}VhN%^1UPE)Qd(JWYDjoz8Zq4tMQ*1k;~UAw59&wEU%@ zR~?5_n+u)D;HD4}BNkZfvo&iC&_xE|4;=lz?*rfP86vnfc;69-c{v;P c--s3UAJFKa5XiJ(u{V8 i!BQai`ozWaVSV7@WgQt)8CVA;4z7G`IC+LRJQ5*A }co?YKc#P4j6n;cIY*kq>@EM1 az1iCBl7^#da4$k@=L8ttyM6RPHXndWY6cjZ zA{yS&!9y@+!4y#TAN1Ls^xmz#7J(l|_0Wr6_7RejHuHL+<1_c71huFX e_Q() zpUTdD@HH`%9^{pjkd@yZ_K-(mdms3ZjEvxRY~CCXM~PneixQTXR#b46=E+t_2TDE` zi~Lr6@)d|GfjVD#j^O7lqIS=ntv?Tt%pU~S?pDtX+`ri;@MgK2%`6LHyEcH%6y>ll zbERJnI8^ZSTK~lXCE16-YN9A)DNftwww>*!-65kDdN2g{7>UoA-gL%LgZB~~v5sAe zE)azuvE6YtAH|oF64YleaKVC6#lhyI!v_`6;;)0N$Bp4HuLtZCW`kejHD566H@f!1 zZ?DgLMByu<-H6yiI2|mv`Yp$TEnFrBmZeU|gM8!!TB#vbR+G{S3csSgN_}p(jkB|H zR=xH;3}yj3(PN0))~x|eq!1keTw9c1T7oSPuIyQSnlqtkpnv*#$MM2_6EQWilJG7> z*^^5DvLE74(|WfpcjtG@XESzDF$WMTm(*_+P+qH@_GCqg5O*+qW?<4RAuVTv84V +X*{NqRKS0_t}PqwHx6yU{5MX&hV+({7g%fh!(xB z5EU$Or>lZrof_OZQKEu_M8Wf{3Piwg<3Q}al>rP+(R D0#Rb1Ay%P`+6f~w8NPWo#KlXtSOM(Pcd|!{qSy|&_6PhCZCIP^z0(U&-w)F|J zjD97sxFvne#90LZ?QyekckN?5U1 U$4|o)?$0Zs!dP&l3&2FQdJnIzJ1LjzbM_zh{EZJA~!?(^jPs;K78L zAda$dv(5)N7Nv^iqqC7HZw+YaR{lcs?#!i+?>7q#>6XaTg{h0d7gCf6-SP#kvP(UF zkI(~m_BS^}&YRDh$~-TR2pJ%7KX19aRRuZ6j|RkeaOJxb<;&Tan2$T8(a^}LNo6(; z4Q%2DlxE^`QP|?mncbZ;nIAVVFQ}F5@YFx9`YQF{xUm`kWG7shx7Q@ydr695Ov`fR zv-~7Ic)PU4yBGdc;^zDgzOW*=aQQkso3G_^(BX7k6ML%0X?p_PP~A*G%`*5vj&c~i zX$w{wI#OvnSMRweU+ kQ1=OW(VGJa+6y2j_a=HjA9G!2E4`tsW6z@I#PYb*V1P^o2sp!wkQzb)d+ zI$4YAzNHRH$$!QuWl@39srQ&aPJ0qa0pSBZRA(K`fVZ6_y0-KEBUnOx36J5Wi~g@{ zYcOyqXko5UYXH^?5fSk)dDxQ qL$g-RCKF8+7oMd`xTGuv<-tX@TReuKp+5 zOZ5DMD1q@31asgQ(}!o?HK}-cc|otjTT`;GzVi9FW01z}uTUn&StG zA6P+4;ZseYQ%$#L-RLZUlET3{@ZRs!@$vC46hNgY$I1IN0Zgq(e2V$ m+|VrUf)av7rn12fE-pYz*Okjr{o!#ZVaw+afE349I-f_4;GzBgZumv%2M{)6=h zgVNn|>uo%OyzQ3%d;%=?5crzpSHFXf+$ehjrNK%3S7(3qxeVNq_q`H26yJS*iUO%b z<>VyF)6V15ljN5l%se+03_?Gn697mvv%T-Ki?pj4rJ)+@59&dZklRu8t;OP1ASMa0 z>3Oy8F(ASe;foNt-_Cp!p#2&=vD!vCKJ)-QPrwZ*omTjb9HkF4?w@EVd3>c+y}yY- zh;EVAnJap`S-V%i9oewYc6Y?K(tN!aHe$b-lh|Df-zvqLs zM ZA$Lo~WtSvW3p7_059v4eNE5iG3%9WLs7*pvI(N9fs+$f Rhv%LN;er$?N~_kzL69ivJ}56)MMc)V@G&eV%t dK{;_-pgiN$mYehd8eGp|eg*B=%svy&f&a52P!gSB7evvikwcsuuwj7v!sKmRbm zAs@yx_1Dg#?+dN`XWuN@(;fia-z%3bH&>!W-ynY%{B^YU%VwfGoCs0#aK^5MVIlYH zSK{&d*>rO63ra~GD(FX0kj9Nzx~G1~3dvCfnL o8ebh18Kqyb2zL2vZ9c2 zp`X*{?8T?4zwc-+F6@7$z`JA+aYycc6%VwHrnZPlt-dcSD{}?KKPD+DPa7m1V2qDV z5Nfk7uC5p>MB6($VA}xsbJWdzDl0#1yng!ZS^h*eOmjPw$ozWIAA1^8Jo~ejv@~)s z^pkfUKtg3daxj&8EzV2$2?zHrPZG&bieqz)u|)si-k9a(<%3ZLwy8?Tf}*0%2)_B? zbnh#)$hd@rKy pDk_;TjOaw}-u(=@ zc@w68ZrV`DHMC#9bf=5KII`8dEpt75SESc~Ut&0t`KsWUl9G~8#FJll ~!AWW7@HMs$&G9UeLXYe1n^jqNv1k{ntzT-~~F z8Xr(c1%7WWw6x$kb0ASdyKjp)Ya%#IMmO*TbJmoR%)k*Cx4V16i )9e202W! {br z8QYF9w&uTPs7IF@>C~K@c=yj46CvN8sf?p$=P@M?7VwAOizOd^Vb rYtqj z#LSFg;C1>;I*P1VyEHFPW!lK=YBSd!*}v@*IIVhBbQQ8>9y=UB!)zO`wC$s j%uWGE`G#u!iP49_KZR`13h@DgX7~4 zu(7PU_QiSIbDk#zkSi%E*(2w}-!~nyR){v=p6S$5)!7u!IMzubPyt`|u=9zs;ofYW zeDBy7PfWGvoj3u|uIPOuFH4>(V2JGL#Z9rr4A5dVKKp7Vac+TWKO~rR34Xobvzc2M zrST;cVd@}Z(B6D->bGT8?NT6#h813CZ)4~L>~TqEc6R3vSW*D;($bPW3b@myUojfm zx^p-*EG(G!AMXU)rx>>)P#?j6f?P_k4O%0-LQ!yvSsM=|W
Iyi-io?e0Hf!@I7 z`;Q;ULyk9xJ;t5ExCK>%DSRt{V+Atl)PKz1YOEw6;eL-Z`$}Iwjq^CC31{^)fxei8 z#KZe-j>=!Xe<+J8kEE)}0c~+=7&$tZkJ%{-?#oWfAW}cjDPswIdtJqf*qw8fSzAju zMCCj1&1F7cJ9aB7H{F{UFb#XtVw4ON6qE)S0+n_k#XT~+0_g&WgajSh<5YGDgmD4+ z41dJOo&zB7-8O;kbWLQkP*9cgO*MLh(i%5-nLwjkyZ+Vcr^mT1|DTK?vI2|+TLvz! zC+R-$m%4KXKDRjnr6cdz9hRkPNzlZ}To)gtgyNHvlaqSvl-2GxUlaP_fISd1c~sV{ zq#%sv-Rw=Vg#|4TjCcU^!eY{eo#$olqzJAXcCyUjxTb+oZ!~4WX=vzUUH8o#UXuAp zx^eQm+kGFK2~&6g#+I`aAlPEIwk4J}N2`7Picef(zkMsNq|&Li=^!h@Pr2Hy%oT zDIdo6-y1$5j|;}-$PydVgWj-kRdd5gfdg8x=4@i4`9?QFKHF5wrjin@D4`=%k=qmX zobfoWzFBOG;c+z>ES@uZ?s@NRjrA1gxVEAq#?|RIiVKbZocTb~h#9KmR(>v{ q^+l?4Q8!@;(3_4TsRc?j94NFjPV}ylo4m$eHoE6L}h)P@F_2n@v iq&_z=xxRSzM*EwL6nsA04mNY z5_F1)PuB%*l1~MLgLmY#@#kDuBKl*l2eY-8g2=sF^<3t1G<^AWKoxo|xXV5MRl`T< zay8bT-Uoh}yVZ#FsM!2#Z3<`>Q@yV?wOYKJESp{;#XLC0JAsx9*R9^+@Ci;fI Re+>K)Vq1 z_perM!#a-1@%mta6VZ`3AVVu)yFP>-6q8H*M3BrV5PZ3((3g9>eKeW*^9O>I)EN#L zStQVS5&V%c)rwOT>2$p>*Z$TzQ0sQ_OzQgg_R5ZfR<0> 5jPJ+CGX3!VjB8X6t{Xtz-oUH@3Go6~QT} zt`E-7H3Ng9Nce`<%kO|9asf2SMMki#at9LM%Sn8Nj`d!FQ>_U*A^W?44Gj4>UM)4W zQ2``ezFB)j@C$do_jnf@9{$`PRgxyy9T9pV*QsZ3|H8J2i_L?3k7s@EZu^m)tkhtX z$6~s2I^*Q~P3LL9DmS*Uk`YIz1JIr<$_YFcCv#iHrA56jc1K9W JNDYKrq+ z4q~LF$m~=n_5lzC{bFrUB$8$6$k(~LJzs$QpZ0hf8k)tOis9 7JuzS$ctv>F!(k@>UF*lr@?KqM2D)ux&s%ViX4HHzNTsSpWSg zUWHFe8s6C{<@(k1R{{gez;pqU+j0oK{k+@8RDi=hRPVJbXLV%s{mD2T_sgq+c5&31 z%h@|_ZDMh$u@t~P{egOjCN3^sa0+Y!CXz7#`1Z7T$i3^eNR)##-;=q_v1Wl3`3+B? zie@440U~nilUvXa@&h)as-~8y%|{&3&z$(S!t8&Pp(YOhYhc`O5sED(2y2YrGUMNv zVgiEft_~?42OZedyu5F!^!4>4nM!Ii2eXd9?E*q7t*e`AX#=S8-$pksp|;T+1=vQ8 z!NK1$ =<4f3?EkS7m#ou~ckkMtUR+-J=J7#?C#tIHBhzE6 z$6``rI+>Z7P%p3&OdY9GJUKZDxh!q%pV5~rmseICMqUx*2cq(7`qXOY$h*jqlulG| z(7jGYRR&^IixE(-p9fN~FnQlEn(kXQdro92jEAar#?Yc!t-(zxsJ2)}-~GGVF70`_ zk(#B=mon&j*hO&I&s17FHXCIY^xT;c2-3OQu?eargcBbV2Oe1H4UV?>_@pG$r>*;A zi@fJUin-+OA@gAV?;+nbd=zC<{S3WOgWXA>?JQgMgLsE|?g3TiJca)d8z`-`2qTu` z;4Y}kbrPYAMgP3w;?IR4;o%wuqT2Ij7T6bCg{9L2(e~yr7>u`L)^tzK(vnd~574@+ zL;7)fyna&?lTY^tCtmJUM*74aXX^gk$m)dhX660Bk&+UowFwv!xweKRZBz^l4ZUi> zgH3B|3j@*UQ| z`2%m?H*M}P`uY3Y6o{^#F9Z7jDKHS(l^D%=YimoxYNaPCsODlhj4kdZx6Nk?fOH*k z4C-RgZeO)j>lkIHaYPN?xo4`WqxJ80Ud$m=Wu|?>SL0L>prezn(%04wtr!wO2mPae zp}=~Qu{9%w$?I;wMv;|iVEn}!pD@UT2s1J=4v;xfr2HbQC8D#lX#g$bdn9rHkSp*m zmPa26E8!I71W*lkm>?*U{FaXez8@t^oXH>8f4?&dobowc0W`;jB5Kki&ak!IIGQ@k zDX875>gi!Bk4;c~oVrMit$B|nZbGND&6w_sh_YN o z(|`f}+sAlhUMH%lj{OV;P8Cz(;NZ%=tm90+9;v^g23!PcM}*?7FKV!IKywz)h+{`Z z*$Xu+08z^4a7=)PdXLPG)p3g=2d7JC+*1VPMpCP)XPRpixt!?W5fHE~gI)9nGcw_l z2=r4CYPTcq69G(z=jT08m!jcYc{2N9IHRZtBh7lM+_Ycp#O&yngwK|=F~T!F-E|p_ z7AfX;2`!p9O{;=}0^)t$;kvEc=W8t6gYB@+<+zh6kEMVHFh=HWlMgL1O9VJcSOx|L zsV?(w{p)4v>FHaXXrjO}cQ=IxF$o^^Qgb+MX+V`5wDW=oA7^UpWM03Hw6t074B;(q zZS)PGaG7%=SReXkb^K0^QEcAl&Wje~9?%`}r&^{|e&Q>$`Czb>x{1I1Ue$Lc{om7j z*xxE@8PtL`n{$E$3TXf3r)ibFdjwUnL^5|)lPn(q1E`N5s}=uR{6K`aw+WbcnNbJw zxOTdT7Y;c&wjE7d4@=FgpR&lMRKB65oOX5-Xt^-N?~mB#*RQ~httC@t^+FQexk7$+ z`ix?W #2o>^@#AuNJt8`7b?u7%0n70{?OIdd*5D7Y516!o3C_W ziw5w4*g YS (Okv1G6K3TQ;PZC%+}*Gd9uC(Ei&>yr36+89(Lfv!dhb zHTUbUdbPq^>Md62RN(%FhX#Y2!-L4$0eF1Z=1NxV?>+#KVap{u{Y>t(r*0#yQ2W=f zU+UAOJlx!^Iz AX>fFQyKp`^as+Ow1`&@X`>^BBwM z(@U$vqb-8CYAwJ3ahy;f$V7FNLYOhYRdtc|C TULdr{uzP`SG_K#N^4wObypeVt%@%%_#W*zz)2?K%1BPBI9 z6Ru4yfob#)>!p~|^t-02J(y6_jw)d8zIheU@tKJg4FCl0Q*a)W3BpNXX|rWTv)8q= zu45l #H|D!fudxTH{x(C^F;4$` zVv47ir-Q^5L+wU13WwFd&%wOi-y59c_d5N<_TNmulN0=y=?{?>+tS) +I+;75_Gm%YhId*D124MHKLz=P7PPJUl$|Yd!t(OiQew?FDMmD+vidOFd*g zHof|%^94VD;?&jE6;!qPqfmpp6;IgEu3ifrb~;N&zx=R%Ul;S}Z@9hrOZG#4Tipp? zC~g;4AyWn|R?D3)HX0GUMf^hZQRYpTh)}}~nrHc% nO;zhG3Nc}3uu@tlmg+Y(cP7J^i{A@*1#&obVySt_&%JFkj8gbetyVA3gJFx^x zn~H7!sbVy#=n4=$V{PqF*y}o)&FL4xRaJn_d@*b+(b(V>U2rg#bnc&}2^~AwL&W^l z_i~I9GzboS73Raz^75gUN`OBMw;u4~!7H1wO{DvO8g+ =h=n8feU8LSa}P%-I*7mM8exi;5PGoyj anxyl|;lGK6!finjvRFcUVBfozmxKOb)>U&?B zB0YNaTq`!>?hY8@{S+>C1bBFUTtoblRSDm|ML^P^iNbm}2)ZBv&8*RJf#=VAZbB|I z3{Nkw_+L5Np%4V;aYZT$;u|k_J6&BJ)wA>yMwA_{jL=uI^>6=D)z?Hu7tkE1oD-+H z0S7vn(~gFNAGdkJ{&OP<6ZGk9*ro!JD?Dtp@nTk bTOG6u1*={!vDpZgYwA;f+v4YQJ@~ z%L sBtWH{+dykU`RW&m3n+qAT_NiRRp9UUDl+E!|_fUJS#_}8v? zZGV(Si%I*G?<$=vn_|*aQ;Q0cD0Mh-fI#5q_i((vvbFvWe*ItLJ|Zrs#bLZS_1g;L zUx<%?GzrItUbkRu#eW}hsKzM-6 d01^x30% zt#eL@ s)Co>6cM z1GEzu)%eT?&7|P`3ub@KNUm(rZg4Jk%#7Tfsz4FFyQvtpWZ~vMX{}FBCkGutaB3 Kp*GKk8kD!3PuS!8(OW^d1JSrwP!%e65iCGkIUc;N7&nCaCkFSlIy!p)XY(U4Av z^asSu2oCo59&f%$>_Gx!U9sr-ZD7+{G6j{TlJ*JYo7dhP{feg?8Z@am14@W`U`E4s zH@K%WL41RyZzqbxw)O!CV|@4NTM )-lsN}tEP_Al*NjI@pDxOFA)WO%`Tgk*fW;6*rKRgb0NA~tx>Num#c+6)g zw|85Er+-!J^^(!?EKwzsPRHV>s8-XU@}g6~HNLzdEUI G7n08^TP@f3_lN(}EvI32B^}-Er|f*mF{EGKVY1x(o(cDs&JO%xB-TlR zwB@Iws}rET?LE@=bR7Sk!XX{)4anF9cFQ0h=|V8a@=SwZv8JhoF((Z8BhS4~M*3%? z5@d?d^S|%|wMSK3`>_ixi1pTQvyS6x0JMedz}k!jWHiA@;WMq1{e`>Muhz>gfM32~ z+t zu`fA&eobp0ac^K~;%F1CR0vIjJy-a7 z=u!(m;e 7C`yu4a50X{zCQcsCq!{U&>=i#eyvu4+WHk2~^C5dR3sm8=>1>Vh+M@UGEK>JoT z(^c7!r5#LQ(E%2ViIPP0H$fM_d%y#<+p*B`XX!gOsvBxtqwoOXUh#KrS2!49WRajH z&$BI(u&_a6o~Vgjh3)p#`|+TTe5MefNMI=w!hy=FLRh)CW+WBZpL7{P1LuS<9kVT) zfO-q+y5lvzs&RLC8z7`bm2H-E#UT*NXux2;({v;!pn0m^@{T #S{6-W)#>Z)iS+b#b|cLmp>X^IaT1_rjPvP$o*s+K@EVDhykI zT&h5t_i2fYgA(Nz$*Wd_ZR{gbXq9)zk}MCl)6(NIw56mZcnufaw^>NNFO~| Z2r>WqxG_O$OYU94R4?cwpTsQ)zmc`OnIkT_5#vUw7O==iytt!R+m*4RET zFE6(T 7hVk-nc3pKkQZVqa$FFbj-<}236KuULpwz3X7_VHuo=mAUz&4SFkMsRD+V+f6BD!F@FamU7{VyUq)+ionv7A(5LY;|Zo5*^6}}P( zS568L`0$NWSyEg7d3a^#v|ja-%_YR8ee(d}KC8UiR@WS!gx6V;-s9ffTGQ$DOp;?c zv~Nr$b2tt4;N*^W_n+J(VYl7jr2@sPIH}b+ljBoxj=P)A=L;?Ss3DaYbb`WpMMVJ( zu@jobIv!r29g&BjIw|`7T z(d%#F$&T $9_;zpFW9{!yqp#mV^mJoCwdH5wN0i{;$d~JcIsMc$G*VTp zDHUqZm(Yq`)zsAw{~AXzbTp*MF=c`mAn5Aq`tBdw(-Tg_g_W9^)jCn4SE#|La()Z; zQok3|lG?AWLLhKt^v13yFXAQ>sY?aUzuyH`c4{~I zJd!xckZ)l0s7bvtS8-<#lrn$O7N$|8F;-)s%9bdnz)VhVaDpahtia6I1>}&~(7L|x zN6Dg0w^Sj$4H3jVkD=b9pehE)UnnJY>%<_zL!7n +0+K>;srf?Xbdd`n_i?x$ z7>>KEs>Y1sSphS(f4!&ybVDG$4Z}RJCEL ^I`B<6lzl z#w5HgE%rhqw}Xo%Q%mgmLX{-`7WR_8N ^>;eDkUm5qlA&=lpVI zWo27|m-fB_{#z2;x84@0H3i24--gV73OS_*L;|NLm{Yge36;}+QH)u)R>dOz71Vg{ z$(4?*%w`Z!@>D7(!bQ;tIWvP+KnrB3I9uzlFWMrYj3y44g+&c _i?brLcnh0o zUP+H0ad;b2_D0J?+@mUqV+(T5Byf`J@CXS}AxLv%8ztMS^ffnn{}H*KNs8PLos+fe za|ig}ZdkbsEsDCgtu~C>($bBpla^#TCrvDmxMw*oMHFR|W^D@#clw#3e$AR$YyaST zV13{{W0dWO|JmDD(bjxSbtD2QwGfz%e>bJ{ZA~^$3pqM+bPYW?upda_Q+8Vzjn~u< z0nUfYd8+fYMZvYkdVa65Jnvq ;UoJu-W8D+ZUV{i+V- zg^|oFug`-{*^+$)jcf9P%og9)f* @-$?>2`?G0p`1K&VpKL$riTmN)_fgED% zdUTW!Aqxfk_yLfO?+f3g<{vG8maY^W@+3n{Np}&M;f3M6$_~z}OJjpBeQxL9Tb}7W zFZv3HU=NX-`~$Q2j?0?+CYj =0pY6_| zY?0Mr{}~-+2k~G;D>xk9Z>-?Qy`@)GRV7ag*i$GcY7<~S$k=2>y7TocL;xCa>watn zG8VCE1^sPxHGLzkEcZ%ATT9ebq!(VG8#jhu;{hly)dNZG_XbGYl8f)vrba}Co@c%5 z`QBg8M=LICW$I+A6 ?&~CHz=52K8~R5CC=kLb&&dp;g`qox5IJ#%Tnq zBVd+e0Ue6!HUARQc>R=hxA6I_zovoLvoz_4>2)7AsOad{0b!3#NYI+8wn01%pH0L| z?K3V+uI&Dj{8WunqDfgI8ialx0YR>4%Yo%VI@162&e^b!YkzesaTzG{gE^(M_+&r7 zYB_-av%|!!PPBeioL@b-J)XSvlhp^a{k9{{S$zmqeD#AgkfW?`TytJcG#`0Q{Fa`} zmzs9oWt; DSm&H70-kN|QV1|t z{eb9HFi~wi >zjl4&Kw#&=J2^sXPg|_rWkV-n3o^}J00E2s20NQQ)ABT?GJ1T1T0LNw)AT%~s zEGgY2T!yJREHO*lAiS0r#Y~K-e;m@*$D62t0E%c&S(%|;1B_*rum~7ZH55K$@Y!?0 zP)p!RrR`s=MD+m5VP3(}1nP(BKQ~iTS|KHHog$g%fw(EJ1XsQm6i`0^9+a>9>w+LG z(Hr~h++1?4Sn+@4o(B((j*rF9)b;gC25zFSTZ-jz(8Lm$-}-fTOW)U+6{0HX0aAAn zI-1wwwx-pbK~>c94Y`?0sK-%a5^Pl+YPLZ
{>YGy*iQkQv(sOzqtkuXm;*UC%UieXp+ERrX~S6+3Zd zNFBcG>dyo5VIPceJ?U+xLAnG^YFxSlkbV^z#UWdAY)r${4E4Wuw&zR2Yt8UxEI_>} zMhQO~jZnuS!W@}C2o#k^J8S6_QwG1ul6 Q(>llTk8-X?MA#*r#9oDD>ES z%kGO8=$P7-8=5OF{bi)masbfx|2|HT2@d;TVn7~sIb`*piv{ACyGo@-zzu-2@AK5K zSCHXiiPa0*xu1q@52J7U2I4_HbQx;Vt*v)_ozTQr5AE0qe*$wYNpZ-^9O^7rl>((O zJ6;i0;1Uq}7%CO8T?L-W1aROrD7d^}DV@$j!p#xPn7lf-M5O| =3YSv9mGeDYa1rvaAX7p)@I{<-Oy2hJ11FhKYDV`uI1Gb{*6d7eIVT9X(1Lplq> zgvREZ&aFK)lO0r+ -?{Evcq9>dP|~kocI?wpQ@9xcCDwj`69?xTin@$KnBf zTd+nb$ohsZJb~U$F!&& 1!jwV8cIoVrRxvJz+M= L40y1Z)@vz2*m%G hws_uo=Whm*FK~&~7@#oDS{(v} z_NN1@os#xO-PWdWUTMnZ$le`1n82YTOkVSt{)RqE>W2|ZIBI59oOT2>NPb6l2@IpZ zItdnk@Cxh8h-boTUc?h{JSb!3u%cEW2zY?&4_YWG{c-P{$5MXLnO`M pHHiJCy*dS`{8HZ2`Ojayd(STGrAfL=kF2fe^=zf7;s_k-wDM7#u`Y)gK7 z`s}POl; NdRWJcqywp)l?@VR^5L;4|H3Dt3@tS7Uv zQ8)R2LQ9^x$sb{AGo+g;_&+QIas!&6Y{qel8TslLMv^)}`S@0Y2kFfI!S*M-79q5f z!zeOX;?8R#DG6Fm6JFUomxbRa38V2wW$Na@_41md64yp115k_0Bp&Q3n(Z_RjK%o2 zV>OoL$2H@B?$=J!s-VkHxaGxuNbiT 1eku31>@dE*F;;oX7p4l8OckyqeeuzttFl%mZ<_9Kl znfWl8^=xenC6uL9#n#}}U>wdJrAF4`n{E*OBq&QB{$L5Qti@ea-b!J*@pV=XE;#o_ zhMk6MS7#J=d%;N~{k|1%Z7MszM0cAsJqLANk5230oC~V#JcOa0A~}9JuPqp=yW9=> zzF(dkJotTODc%$Wby|&pO5QYHduRcBXO&1zO%06fvb{*TV^R2Gy56-jRZ#{&-c{go z+72ko_r3)a1B0rWkA{ZEryUtaF;LC2h=s_3Ed(M~VwYsmA7r~GLF_-i02@R+0sl4% zdj3PV8aTgD5m)UIu*|7!VMIZsLZ&PViYLMok*%fd7&*3@wtdT5oL0_wZsd9*oAa=p zESqQYdoLyGc#x6#CKm0%dpT`IxdUp0itd{03}w47>~swLL> %Lpo%fGR}IygRyy&*9-=zqH3Mgx6-DAm?^fh%@Zfx?d%h_|5Gths7w3^?c)7 zqSV(=Pg$KAKEj_lJdzaWidy~DE@0}NV3BSCCQT(FfoMk0F$czk(v|PTK}Oqb9tN%5 zb}P(6IbJh9Ed1Y+bD{M`E|2EptDszC^(Ew#I*PXwhHYae5`eiU7L>4GJu we5N;ybTr)m@_hoca&`P4 zMW_u@JW#L#(p8>iks2e<&QvJl08Zo*N7m0$p+9Dk)^9HL*R~VH_q?48q&Kl5c%tR7 z$%DNoW@tOs3W*t2L0`5@tAlG1uJ*cwy?5^0fA5$79 s8dSmXex*Z1m)WAoeITl;aC=2D#cYgOSGFs*ABUu_871&Zs~w&_G}LMVFB$*X znojU~FKLh%SXtjZj7s1VU%iIn5!6gf3i-%TsN%>B7mmySV*!ny#|t^|6?RP^nJ (Zg!Q1<$Gd4SpI6%&YR%}MH{U}tRv?