puda-drivers 0.0.7__py3-none-any.whl → 0.0.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,434 @@
1
+ """
2
+ Camera controller for image and video capture.
3
+
4
+ This module provides a Python interface for controlling cameras and webcams
5
+ to capture images and videos for laboratory automation applications using OpenCV.
6
+ """
7
+
8
+ import logging
9
+ import time
10
+ import threading
11
+ from datetime import datetime
12
+ from typing import Optional, Union, List, Tuple
13
+ from pathlib import Path
14
+ import cv2
15
+ import numpy as np
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def list_cameras(max_index: int = 10) -> List[Tuple[int, bool, Optional[tuple[int, int]]]]:
21
+ """
22
+ Lists available cameras on the system by testing camera indices.
23
+
24
+ This is a utility function that can be used independently of any controller instance.
25
+ It's useful for discovering available cameras before initializing a controller.
26
+
27
+ Args:
28
+ max_index: Maximum camera index to test (default: 10). The function will test
29
+ indices from 0 to max_index-1.
30
+
31
+ Returns:
32
+ List of tuples, where each tuple contains (index, is_available, resolution).
33
+ resolution is a (width, height) tuple if available, None otherwise.
34
+ """
35
+ available_cameras = []
36
+
37
+ for index in range(max_index):
38
+ cap = cv2.VideoCapture(index)
39
+ if cap.isOpened():
40
+ # Try to read a frame to confirm it's actually working
41
+ ret, _ = cap.read()
42
+ if ret:
43
+ # Get resolution
44
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
45
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
46
+ resolution = (width, height) if width > 0 and height > 0 else None
47
+ available_cameras.append((index, resolution))
48
+ cap.release()
49
+ else:
50
+ cap.release()
51
+
52
+ return available_cameras
53
+
54
+
55
+ class CameraController:
56
+ """
57
+ Controller for cameras and webcams using OpenCV.
58
+
59
+ This class provides methods for capturing images and videos from cameras.
60
+ Images can be returned as numpy arrays and optionally saved to the captures folder.
61
+ Videos can be recorded for a specified duration or controlled manually with start/stop.
62
+
63
+ Attributes:
64
+ camera_index: Index or identifier for the camera device
65
+ resolution: Camera resolution as (width, height) tuple
66
+ captures_folder: Path to the folder where captured images and videos are saved
67
+ """
68
+
69
+ DEFAULT_CAPTURES_FOLDER = "captures"
70
+
71
+ def __init__(
72
+ self,
73
+ camera_index: Union[int, str] = 0,
74
+ resolution: Optional[tuple[int, int]] = None,
75
+ captures_folder: Union[str, Path, None] = None,
76
+ ):
77
+ """
78
+ Initialize the camera controller.
79
+
80
+ Args:
81
+ camera_index: Camera device index (0 for default) or device path/identifier
82
+ resolution: Optional resolution as (width, height) tuple
83
+ captures_folder: Path to folder for saving captured images.
84
+ Defaults to "captures" in the current working directory.
85
+ """
86
+ self.camera_index = camera_index
87
+ self.resolution = resolution
88
+ self.captures_folder = Path(captures_folder) if captures_folder else Path(self.DEFAULT_CAPTURES_FOLDER)
89
+ self._logger = logging.getLogger(__name__)
90
+ self._camera: Optional[cv2.VideoCapture] = None
91
+ self._is_connected = False
92
+
93
+ # Video recording state
94
+ self._video_writer: Optional[cv2.VideoWriter] = None
95
+ self._is_recording = False
96
+ self._video_file_path: Optional[Path] = None
97
+ self._fps: float = 30.0 # Default FPS for video recording
98
+ self._recording_thread: Optional[threading.Thread] = None
99
+ self._stop_recording_event: Optional[threading.Event] = None
100
+
101
+ # Create captures folder if it doesn't exist
102
+ self.captures_folder.mkdir(parents=True, exist_ok=True)
103
+
104
+ self._logger.info(
105
+ "Camera Controller initialized with camera_index='%s', resolution=%s, captures_folder='%s'",
106
+ camera_index,
107
+ resolution,
108
+ self.captures_folder,
109
+ )
110
+
111
+ def connect(self) -> None:
112
+ """
113
+ Connect to the camera device.
114
+
115
+ Raises:
116
+ IOError: If camera connection fails
117
+ """
118
+ if self._is_connected:
119
+ self._logger.warning("Camera already connected. Disconnecting and reconnecting...")
120
+ self.disconnect()
121
+
122
+ self._logger.info("Connecting to camera %s...", self.camera_index)
123
+
124
+ try:
125
+ self._camera = cv2.VideoCapture(self.camera_index)
126
+
127
+ if not self._camera.isOpened():
128
+ raise IOError(f"Could not open camera {self.camera_index}")
129
+
130
+ # Set resolution if specified
131
+ if self.resolution:
132
+ width, height = self.resolution
133
+ self._camera.set(cv2.CAP_PROP_FRAME_WIDTH, width)
134
+ self._camera.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
135
+ self._logger.info("Resolution set to %dx%d", width, height)
136
+
137
+ self._is_connected = True
138
+ self._logger.info("Successfully connected to camera %s", self.camera_index)
139
+
140
+ except Exception as e:
141
+ self._camera = None
142
+ self._is_connected = False
143
+ self._logger.error("Error connecting to camera %s: %s", self.camera_index, e)
144
+ raise IOError(f"Error connecting to camera {self.camera_index}: {e}") from e
145
+
146
+ def disconnect(self) -> None:
147
+ """
148
+ Disconnect from the camera device.
149
+ """
150
+ # Stop any ongoing video recording before disconnecting
151
+ if self._is_recording:
152
+ self._logger.warning("Stopping video recording before disconnecting...")
153
+ self.stop_video_recording()
154
+
155
+ if self._camera is not None:
156
+ self._logger.info("Disconnecting from camera %s...", self.camera_index)
157
+ self._camera.release()
158
+ self._camera = None
159
+ self._is_connected = False
160
+ self._logger.info("Camera disconnected")
161
+ else:
162
+ self._logger.warning("Camera already disconnected or was never connected")
163
+
164
+ @property
165
+ def is_connected(self) -> bool:
166
+ """
167
+ Check if the camera is currently connected.
168
+
169
+ Returns:
170
+ True if connected, False otherwise
171
+ """
172
+ return self._is_connected and self._camera is not None and self._camera.isOpened()
173
+
174
+ def capture_image(
175
+ self,
176
+ save: bool = False,
177
+ filename: Optional[Union[str, Path]] = None
178
+ ) -> np.ndarray:
179
+ """
180
+ Capture a single image from the camera.
181
+
182
+ Args:
183
+ save: If True, save the image to the captures folder
184
+ filename: Optional filename for the saved image. If not provided and save=True,
185
+ a timestamped filename will be generated. If provided without extension,
186
+ .jpg will be added.
187
+
188
+ Returns:
189
+ Captured image as a numpy array (BGR format)
190
+
191
+ Raises:
192
+ IOError: If camera is not connected or capture fails
193
+ """
194
+ if not self.is_connected:
195
+ raise IOError("Camera is not connected. Call connect() first.")
196
+
197
+ self._logger.info("Capturing image...")
198
+
199
+ ret, frame = self._camera.read()
200
+
201
+ if not ret or frame is None:
202
+ self._logger.error("Failed to capture frame from camera")
203
+ raise IOError("Failed to capture frame from camera")
204
+
205
+ self._logger.info("Image captured successfully (shape: %s)", frame.shape)
206
+
207
+ # Save image if requested
208
+ if save:
209
+ if filename is None:
210
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
211
+ filename = f"capture_{timestamp}.jpg"
212
+
213
+ file_path = Path(filename)
214
+ # If filename doesn't have an extension, add .jpg
215
+ if not file_path.suffix:
216
+ file_path = file_path.with_suffix(".jpg")
217
+
218
+ # If filename is not absolute, save to captures folder
219
+ if not file_path.is_absolute():
220
+ file_path = self.captures_folder / file_path
221
+
222
+ # Ensure parent directory exists
223
+ file_path.parent.mkdir(parents=True, exist_ok=True)
224
+
225
+ cv2.imwrite(str(file_path), frame)
226
+ self._logger.info("Image saved to %s", file_path)
227
+
228
+ return frame
229
+
230
+ def set_resolution(self, width: int, height: int) -> None:
231
+ """
232
+ Set the camera resolution.
233
+
234
+ Args:
235
+ width: Image width in pixels
236
+ height: Image height in pixels
237
+ """
238
+ self.resolution = (width, height)
239
+ self._logger.info("Resolution set to %dx%d", width, height)
240
+
241
+ # Apply resolution to camera if connected
242
+ if self.is_connected:
243
+ self._camera.set(cv2.CAP_PROP_FRAME_WIDTH, width)
244
+ self._camera.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
245
+ self._logger.info("Resolution applied to camera")
246
+
247
+ def start_video_recording(
248
+ self,
249
+ filename: Optional[Union[str, Path]] = None,
250
+ fps: Optional[float] = None
251
+ ) -> Path:
252
+ """
253
+ Start recording a video.
254
+
255
+ Args:
256
+ filename: Optional filename for the video. If not provided, a timestamped
257
+ filename will be generated. If provided without extension, .mp4 will be added.
258
+ fps: Optional frames per second for the video. Defaults to 30.0 if not specified.
259
+
260
+ Returns:
261
+ Path to the video file where recording is being saved
262
+
263
+ Raises:
264
+ IOError: If camera is not connected or recording fails to start
265
+ ValueError: If already recording
266
+ """
267
+ if not self.is_connected:
268
+ raise IOError("Camera is not connected. Call connect() first.")
269
+
270
+ if self._is_recording:
271
+ raise ValueError("Video recording is already in progress. Call stop_video_recording() first.")
272
+
273
+ # Generate filename if not provided
274
+ if filename is None:
275
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
276
+ filename = f"video_{timestamp}.mp4"
277
+
278
+ file_path = Path(filename)
279
+ # If filename doesn't have an extension, add .mp4
280
+ if not file_path.suffix:
281
+ file_path = file_path.with_suffix(".mp4")
282
+
283
+ # If filename is not absolute, save to captures folder
284
+ if not file_path.is_absolute():
285
+ file_path = self.captures_folder / file_path
286
+
287
+ # Ensure parent directory exists
288
+ file_path.parent.mkdir(parents=True, exist_ok=True)
289
+
290
+ # Get current camera resolution
291
+ width = int(self._camera.get(cv2.CAP_PROP_FRAME_WIDTH))
292
+ height = int(self._camera.get(cv2.CAP_PROP_FRAME_HEIGHT))
293
+
294
+ # Use provided FPS or default
295
+ fps = fps if fps is not None else self._fps
296
+
297
+ # Define codec and create VideoWriter
298
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
299
+ self._video_writer = cv2.VideoWriter(str(file_path), fourcc, fps, (width, height))
300
+
301
+ if not self._video_writer.isOpened():
302
+ self._video_writer = None
303
+ raise IOError(f"Failed to initialize video writer for {file_path}")
304
+
305
+ self._is_recording = True
306
+ self._video_file_path = file_path
307
+ self._fps = fps
308
+
309
+ # Start background thread to continuously capture frames
310
+ self._stop_recording_event = threading.Event()
311
+ self._recording_thread = threading.Thread(target=self._capture_frames_loop, daemon=True)
312
+ self._recording_thread.start()
313
+
314
+ self._logger.info("Started video recording to %s (FPS: %.1f, Resolution: %dx%d)",
315
+ file_path, fps, width, height)
316
+
317
+ return file_path
318
+
319
+ def _capture_frames_loop(self) -> None:
320
+ """
321
+ Internal method that continuously captures frames and writes them to the video.
322
+ Runs in a background thread while recording is active.
323
+ """
324
+ frame_interval = 1.0 / self._fps if self._fps > 0 else 0.033 # Default to ~30 FPS
325
+
326
+ while self._is_recording and not self._stop_recording_event.is_set():
327
+ if self._video_writer is None or self._camera is None:
328
+ break
329
+
330
+ ret, frame = self._camera.read()
331
+ if ret and frame is not None:
332
+ self._video_writer.write(frame)
333
+
334
+ time.sleep(frame_interval)
335
+
336
+ self._logger.debug("Frame capture loop stopped")
337
+
338
+ def stop_video_recording(self) -> Optional[Path]:
339
+ """
340
+ Stop recording a video.
341
+
342
+ Returns:
343
+ Path to the saved video file, or None if no recording was in progress
344
+
345
+ Raises:
346
+ IOError: If video writer fails to release
347
+ """
348
+ if not self._is_recording:
349
+ self._logger.warning("No video recording in progress")
350
+ return None
351
+
352
+ self._logger.info("Stopping video recording...")
353
+
354
+ # Signal the recording thread to stop
355
+ self._is_recording = False
356
+ if self._stop_recording_event is not None:
357
+ self._stop_recording_event.set()
358
+
359
+ # Wait for the recording thread to finish
360
+ if self._recording_thread is not None and self._recording_thread.is_alive():
361
+ self._recording_thread.join(timeout=2.0)
362
+
363
+ # Release video writer
364
+ if self._video_writer is not None:
365
+ self._video_writer.release()
366
+ self._video_writer = None
367
+
368
+ file_path = self._video_file_path
369
+ self._video_file_path = None
370
+ self._recording_thread = None
371
+ self._stop_recording_event = None
372
+
373
+ if file_path and file_path.exists():
374
+ self._logger.info("Video saved to %s", file_path)
375
+ else:
376
+ self._logger.warning("Video file may not have been saved correctly")
377
+
378
+ return file_path
379
+
380
+ def record_video(
381
+ self,
382
+ duration_seconds: float,
383
+ filename: Optional[Union[str, Path]] = None,
384
+ fps: Optional[float] = None
385
+ ) -> Path:
386
+ """
387
+ Record a video for a specified duration.
388
+
389
+ Args:
390
+ duration_seconds: Duration of the video in seconds
391
+ filename: Optional filename for the video. If not provided, a timestamped
392
+ filename will be generated. If provided without extension, .mp4 will be added.
393
+ fps: Optional frames per second for the video. Defaults to 30.0 if not specified.
394
+
395
+ Returns:
396
+ Path to the saved video file
397
+
398
+ Raises:
399
+ IOError: If camera is not connected or recording fails
400
+ ValueError: If duration is not positive
401
+ """
402
+ if not self.is_connected:
403
+ raise IOError("Camera is not connected. Call connect() first.")
404
+
405
+ if duration_seconds <= 0:
406
+ raise ValueError(f"Duration must be positive, got {duration_seconds}")
407
+
408
+ # Start recording
409
+ file_path = self.start_video_recording(filename=filename, fps=fps)
410
+
411
+ self._logger.info("Recording video for %.2f seconds...", duration_seconds)
412
+
413
+ start_time = time.time()
414
+ frame_count = 0
415
+
416
+ try:
417
+ while time.time() - start_time < duration_seconds:
418
+ ret, frame = self._camera.read()
419
+ if ret and frame is not None:
420
+ self._video_writer.write(frame)
421
+ frame_count += 1
422
+ else:
423
+ self._logger.warning("Failed to read frame during video recording")
424
+ break
425
+ finally:
426
+ # Always stop recording, even if there was an error
427
+ self.stop_video_recording()
428
+
429
+ actual_duration = time.time() - start_time
430
+ self._logger.info("Video recording completed: %.2f seconds, %d frames (%.1f FPS actual)",
431
+ actual_duration, frame_count, frame_count / actual_duration if actual_duration > 0 else 0)
432
+
433
+ return file_path
434
+
@@ -0,0 +1,9 @@
1
+ # puda_drivers/labware/__init__.py
2
+
3
+ # Import from the sub-packages (folders)
4
+ from .labware import StandardLabware
5
+
6
+ # Export get_available_labware as a standalone function
7
+ get_available_labware = StandardLabware.get_available_labware
8
+
9
+ __all__ = ["StandardLabware", "get_available_labware"]
@@ -0,0 +1,157 @@
1
+ # src/puda_drivers/labware/labware.py
2
+
3
+ import json
4
+ import inspect
5
+ from pathlib import Path
6
+ from typing import Dict, Any
7
+ from abc import ABC
8
+ from typing import List
9
+ from puda_drivers.core import Position
10
+
11
+
12
+ class StandardLabware(ABC):
13
+ """
14
+ Generic Parent Class for all Labware on a microplate
15
+ """
16
+ def __init__(self, labware_name: str):
17
+ """
18
+ Initialize the labware.
19
+ Args:
20
+ name: The name of the labware.
21
+ rows: The number of rows in the labware.
22
+ cols: The number of columns in the labware.
23
+ """
24
+ self._definition = self.load_definition(file_name=labware_name + ".json")
25
+ self.name = self._definition.get("metadata", {}).get("displayName", "displayName not found")
26
+ self._wells = self._definition.get("wells", {})
27
+
28
+ @staticmethod
29
+ def get_available_labware() -> List[str]:
30
+ """
31
+ Get all available labware names from JSON definition files.
32
+
33
+ Returns:
34
+ Sorted list of labware names (without .json extension) found in the labware directory.
35
+ """
36
+ labware_dir = Path(__file__).parent
37
+ json_files = sorted(labware_dir.glob("*.json"))
38
+ return [f.stem for f in json_files]
39
+
40
+ def load_definition(self, file_name: str = "definition.json") -> Dict[str, Any]:
41
+ """
42
+ Load a definition.json file from the class's module directory.
43
+
44
+ This method automatically finds the definition.json file in the
45
+ same directory as the class that defines it.
46
+
47
+ Args:
48
+ file_name: Name of the definition file (default: "definition.json")
49
+
50
+ Returns:
51
+ Dictionary containing the labware definition
52
+
53
+ Raises:
54
+ FileNotFoundError: If the definition file doesn't exist
55
+ """
56
+ # Get the file path of the class that defines this method
57
+ class_file = Path(inspect.getfile(self.__class__))
58
+ definition_path = class_file.parent / file_name
59
+
60
+ if not definition_path.exists():
61
+ raise FileNotFoundError(
62
+ f"Definition file '{file_name}' not found in {class_file.parent}"
63
+ )
64
+
65
+ with open(definition_path, "r", encoding="utf-8") as f:
66
+ return json.load(f)
67
+
68
+ def __str__(self):
69
+ """
70
+ Return a string representation of the labware.
71
+ """
72
+ lines = [
73
+ f"Labware name: {self.name}",
74
+ f"Height in mm: {self.get_height()}",
75
+ "Distances away from origin (0,0) for each well in mm"
76
+ ]
77
+
78
+ for well_id, well_data in self._wells.items():
79
+ x, y, z = well_data.get("x"), well_data.get("y"), well_data.get("z")
80
+
81
+ if x is None or y is None or z is None:
82
+ raise KeyError(f"Well '{well_id}' has missing coordinates in labware definition")
83
+
84
+ lines.append(f"Well {well_id}: x:{x}, y:{y}, z:{z}")
85
+ return "\n".join(lines)
86
+
87
+ @property
88
+ def wells(self) -> List[str]:
89
+ """
90
+ Get a list of all well IDs in the labware.
91
+
92
+ Returns:
93
+ List of well identifiers (e.g., ["A1", "A2", "B1", ...])
94
+ """
95
+ return list(self._wells.keys())
96
+
97
+ def get_well_position(self, well_id: str) -> Position:
98
+ """
99
+ Get the position of a well from definition.json.
100
+
101
+ Args:
102
+ well_id: Well identifier (e.g., "A1", "H12")
103
+
104
+ Returns:
105
+ Position with x, y, z coordinates
106
+
107
+ Raises:
108
+ KeyError: If well_id doesn't exist in the tip rack
109
+ """
110
+ # Validate location exists in JSON definition
111
+ well_id_upper = well_id.upper()
112
+ if well_id_upper not in self._wells:
113
+ raise KeyError(f"Well '{well_id}' not found in tip rack definition")
114
+
115
+ # Get the well data from the definition
116
+ well_data = self._wells.get(well_id_upper, {})
117
+
118
+ # Return position of the well (x, y are already center coordinates)
119
+ return Position(
120
+ x=well_data.get("x", 0.0),
121
+ y=well_data.get("y", 0.0),
122
+ z=well_data.get("z", 0.0),
123
+ )
124
+
125
+ def get_height(self) -> float:
126
+ """
127
+ Get the height of the labware.
128
+
129
+ Returns:
130
+ Height of the labware (zDimension)
131
+
132
+ Raises:
133
+ KeyError: If dimensions or zDimension is not found in the definition
134
+ """
135
+ dimensions = self._definition.get("dimensions")
136
+ if dimensions is None:
137
+ raise KeyError("'dimensions' not found in labware definition")
138
+
139
+ if "zDimension" not in dimensions:
140
+ raise KeyError("'zDimension' not found in labware dimensions")
141
+
142
+ return dimensions["zDimension"]
143
+
144
+ def get_insert_depth(self) -> float:
145
+ """
146
+ Get the insert depth of the labware.
147
+
148
+ Returns:
149
+ Insert depth of the labware
150
+
151
+ Raises:
152
+ KeyError: If insert_depth is not found in the definition
153
+ """
154
+ if "insert_depth" not in self._definition:
155
+ raise KeyError("'insert_depth' not found in labware definition")
156
+
157
+ return self._definition["insert_depth"]