puda-drivers 0.0.8__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.
- puda_drivers/cv/__init__.py +4 -0
- puda_drivers/cv/camera.py +434 -0
- puda_drivers/machines/first.py +15 -2
- {puda_drivers-0.0.8.dist-info → puda_drivers-0.0.9.dist-info}/METADATA +2 -1
- {puda_drivers-0.0.8.dist-info → puda_drivers-0.0.9.dist-info}/RECORD +7 -5
- {puda_drivers-0.0.8.dist-info → puda_drivers-0.0.9.dist-info}/WHEEL +0 -0
- {puda_drivers-0.0.8.dist-info → puda_drivers-0.0.9.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
+
|
puda_drivers/machines/first.py
CHANGED
|
@@ -8,16 +8,17 @@ This class demonstrates the integration of:
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import time
|
|
11
|
-
from typing import Optional, Dict, Tuple, Type
|
|
11
|
+
from typing import Optional, Dict, Tuple, Type, Union
|
|
12
12
|
from puda_drivers.move import GCodeController, Deck
|
|
13
13
|
from puda_drivers.core import Position
|
|
14
14
|
from puda_drivers.transfer.liquid.sartorius import SartoriusController
|
|
15
15
|
from puda_drivers.labware import StandardLabware
|
|
16
|
+
from puda_drivers.cv import CameraController
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
class First:
|
|
19
20
|
"""
|
|
20
|
-
First machine class integrating motion control, deck management,
|
|
21
|
+
First machine class integrating motion control, deck management, liquid handling, and camera.
|
|
21
22
|
|
|
22
23
|
The deck has 16 slots arranged in a 4x4 grid (A1-D4).
|
|
23
24
|
Each slot's origin location is stored for absolute movement calculation.
|
|
@@ -31,6 +32,8 @@ class First:
|
|
|
31
32
|
DEFAULT_SARTORIUS_PORT = "/dev/ttyUSB0"
|
|
32
33
|
DEFAULT_SARTORIUS_BAUDRATE = 9600
|
|
33
34
|
|
|
35
|
+
DEFAULT_CAMERA_INDEX = 0
|
|
36
|
+
|
|
34
37
|
# origin position of Z and A axes
|
|
35
38
|
Z_ORIGIN = Position(x=0, y=0, z=0)
|
|
36
39
|
A_ORIGIN = Position(x=60, y=0, a=0)
|
|
@@ -69,6 +72,7 @@ class First:
|
|
|
69
72
|
self,
|
|
70
73
|
qubot_port: Optional[str] = None,
|
|
71
74
|
sartorius_port: Optional[str] = None,
|
|
75
|
+
camera_index: Optional[Union[int, str]] = None,
|
|
72
76
|
axis_limits: Optional[Dict[str, Tuple[float, float]]] = None,
|
|
73
77
|
):
|
|
74
78
|
"""
|
|
@@ -77,6 +81,8 @@ class First:
|
|
|
77
81
|
Args:
|
|
78
82
|
qubot_port: Serial port for GCodeController (e.g., '/dev/ttyACM0')
|
|
79
83
|
sartorius_port: Serial port for SartoriusController (e.g., '/dev/ttyUSB0')
|
|
84
|
+
camera_index: Camera device index (0 for default) or device path/identifier.
|
|
85
|
+
Defaults to 0.
|
|
80
86
|
axis_limits: Dictionary mapping axis names to (min, max) limits.
|
|
81
87
|
Defaults to DEFAULT_AXIS_LIMITS.
|
|
82
88
|
"""
|
|
@@ -97,15 +103,22 @@ class First:
|
|
|
97
103
|
port_name=sartorius_port or self.DEFAULT_SARTORIUS_PORT,
|
|
98
104
|
)
|
|
99
105
|
|
|
106
|
+
# Initialize camera
|
|
107
|
+
self.camera = CameraController(
|
|
108
|
+
camera_index=camera_index if camera_index is not None else self.DEFAULT_CAMERA_INDEX,
|
|
109
|
+
)
|
|
110
|
+
|
|
100
111
|
def connect(self):
|
|
101
112
|
"""Connect all controllers."""
|
|
102
113
|
self.qubot.connect()
|
|
103
114
|
self.pipette.connect()
|
|
115
|
+
self.camera.connect()
|
|
104
116
|
|
|
105
117
|
def disconnect(self):
|
|
106
118
|
"""Disconnect all controllers."""
|
|
107
119
|
self.qubot.disconnect()
|
|
108
120
|
self.pipette.disconnect()
|
|
121
|
+
self.camera.disconnect()
|
|
109
122
|
|
|
110
123
|
def load_labware(self, slot: str, labware_name: str):
|
|
111
124
|
"""Load a labware object into a slot."""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: puda-drivers
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.9
|
|
4
4
|
Summary: Hardware drivers for the PUDA platform.
|
|
5
5
|
Project-URL: Homepage, https://github.com/zhao-bears/puda-drivers
|
|
6
6
|
Project-URL: Issues, https://github.com/zhao-bears/puda-drivers/issues
|
|
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
15
15
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
16
|
Classifier: Topic :: System :: Hardware
|
|
17
17
|
Requires-Python: >=3.8
|
|
18
|
+
Requires-Dist: opencv-python>=4.12.0.88
|
|
18
19
|
Requires-Dist: pyserial~=3.5
|
|
19
20
|
Description-Content-Type: text/markdown
|
|
20
21
|
|
|
@@ -4,13 +4,15 @@ puda_drivers/core/__init__.py,sha256=XbCdXsU6NMDsmEAtavAGiSZZPla5d7zc2L7Qx9qKHdY
|
|
|
4
4
|
puda_drivers/core/logging.py,sha256=prOeJ3CGEbm37TtMRyAOTQQiMU5_ImZTRXmcUJxkenc,2892
|
|
5
5
|
puda_drivers/core/position.py,sha256=f4efmDSrKKCtqrR-GUJxVitPG20MiuGSDOWt-9TVISk,12628
|
|
6
6
|
puda_drivers/core/serialcontroller.py,sha256=38mKas1iJaOkAE0_V4tmqgZz7RxMMEWfGqA0Ma_Dt2A,8604
|
|
7
|
+
puda_drivers/cv/__init__.py,sha256=DYiPwYOLSUsZ9ivWiHoMGGD3MZ3Ygu9qLYja_OiUodU,100
|
|
8
|
+
puda_drivers/cv/camera.py,sha256=Tzxt1h3W5Z8XFanud_z-PhhJ2UYsC4OEhcak9TVu8jM,16490
|
|
7
9
|
puda_drivers/labware/__init__.py,sha256=RlRxrJn2zyzyxv4c1KGt8Gmxv2cRO8V4zZVnnyL-I00,288
|
|
8
10
|
puda_drivers/labware/labware.py,sha256=hZhOzSyb1GP_bm1LvQsREx4YSqLCWBTKNWDgDqfFavI,5317
|
|
9
11
|
puda_drivers/labware/opentrons_96_tiprack_300ul.json,sha256=jmNaworu688GEgFdxMxNRSaEp4ZOg9IumFk8bVHSzYY,19796
|
|
10
12
|
puda_drivers/labware/polyelectric_8_wellplate_30000ul.json,sha256=NwXMgHBYnIIFasmrthTDTTV7n1M5DYQBDWh-KYsq1gI,3104
|
|
11
13
|
puda_drivers/labware/trash_bin.json,sha256=X4TDNDzGbCtJSWlYgGYHUzdnVKj62SggGDNf7z5S0OE,581
|
|
12
14
|
puda_drivers/machines/__init__.py,sha256=zmIk_r2T8nbPA68h3Cko8N6oL7ncoBpmvhNcAqzHmc4,45
|
|
13
|
-
puda_drivers/machines/first.py,sha256=
|
|
15
|
+
puda_drivers/machines/first.py,sha256=QcmKiMunJ-Mgp_B39-45xKldCuXinpimlkki3fiUk6U,9019
|
|
14
16
|
puda_drivers/move/__init__.py,sha256=NKIKckcqgyviPM0EGFcmIoaqkJM4qekR4babfdddRzM,96
|
|
15
17
|
puda_drivers/move/deck.py,sha256=yq2B4WMqj0hQvHt8HoJskP10u1DUyKwUnjP2c9gJ174,1397
|
|
16
18
|
puda_drivers/move/gcode.py,sha256=Aw7la4RkPw737hW5sKl6WiPCmmTnsjLvG4mOb-RwVSc,22592
|
|
@@ -21,7 +23,7 @@ puda_drivers/transfer/liquid/sartorius/__init__.py,sha256=QGpKz5YUwa8xCdSMXeZ0iR
|
|
|
21
23
|
puda_drivers/transfer/liquid/sartorius/api.py,sha256=jxwIJmY2k1K2ts6NC2ZgFTe4MOiH8TGnJeqYOqNa3rE,28250
|
|
22
24
|
puda_drivers/transfer/liquid/sartorius/constants.py,sha256=mcsjLrVBH-RSodH-pszstwcEL9wwbV0vOgHbGNxZz9w,2770
|
|
23
25
|
puda_drivers/transfer/liquid/sartorius/sartorius.py,sha256=bW838jYOAfLlbUqtsKRipZ-RLjrNTcZ7riYV6I4w8G8,13728
|
|
24
|
-
puda_drivers-0.0.
|
|
25
|
-
puda_drivers-0.0.
|
|
26
|
-
puda_drivers-0.0.
|
|
27
|
-
puda_drivers-0.0.
|
|
26
|
+
puda_drivers-0.0.9.dist-info/METADATA,sha256=MJGTwICCqHlZXICDBLN_QD_wDSnFApH1n172rOSoYxk,8637
|
|
27
|
+
puda_drivers-0.0.9.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
28
|
+
puda_drivers-0.0.9.dist-info/licenses/LICENSE,sha256=7EI8xVBu6h_7_JlVw-yPhhOZlpY9hP8wal7kHtqKT_E,1074
|
|
29
|
+
puda_drivers-0.0.9.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|