opentrons 8.7.0a7__py3-none-any.whl → 8.8.0a7__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.
Potentially problematic release.
This version of opentrons might be problematic. Click here for more details.
- opentrons/_version.py +2 -2
- opentrons/cli/analyze.py +4 -1
- opentrons/config/__init__.py +7 -0
- opentrons/drivers/asyncio/communication/serial_connection.py +8 -5
- opentrons/drivers/flex_stacker/driver.py +6 -1
- opentrons/drivers/vacuum_module/__init__.py +5 -0
- opentrons/drivers/vacuum_module/abstract.py +93 -0
- opentrons/drivers/vacuum_module/driver.py +208 -0
- opentrons/drivers/vacuum_module/errors.py +39 -0
- opentrons/drivers/vacuum_module/simulator.py +85 -0
- opentrons/drivers/vacuum_module/types.py +79 -0
- opentrons/execute.py +3 -0
- opentrons/hardware_control/backends/flex_protocol.py +2 -0
- opentrons/hardware_control/backends/ot3controller.py +35 -2
- opentrons/hardware_control/backends/ot3simulator.py +2 -0
- opentrons/hardware_control/backends/ot3utils.py +37 -0
- opentrons/hardware_control/module_control.py +23 -2
- opentrons/hardware_control/modules/mod_abc.py +1 -1
- opentrons/hardware_control/modules/types.py +1 -1
- opentrons/hardware_control/motion_utilities.py +6 -6
- opentrons/hardware_control/ot3api.py +62 -13
- opentrons/hardware_control/protocols/gripper_controller.py +1 -0
- opentrons/hardware_control/protocols/liquid_handler.py +6 -2
- opentrons/hardware_control/types.py +12 -0
- opentrons/legacy_commands/commands.py +58 -5
- opentrons/legacy_commands/module_commands.py +29 -0
- opentrons/legacy_commands/protocol_commands.py +33 -1
- opentrons/legacy_commands/types.py +75 -1
- opentrons/protocol_api/_transfer_liquid_validation.py +17 -2
- opentrons/protocol_api/_types.py +2 -0
- opentrons/protocol_api/core/engine/_default_labware_versions.py +1 -0
- opentrons/protocol_api/core/engine/deck_conflict.py +3 -1
- opentrons/protocol_api/core/engine/instrument.py +109 -26
- opentrons/protocol_api/core/engine/module_core.py +27 -3
- opentrons/protocol_api/core/engine/protocol.py +33 -1
- opentrons/protocol_api/core/engine/stringify.py +2 -0
- opentrons/protocol_api/core/instrument.py +19 -2
- opentrons/protocol_api/core/legacy/legacy_instrument_core.py +19 -2
- opentrons/protocol_api/core/legacy/legacy_module_core.py +15 -4
- opentrons/protocol_api/core/legacy/legacy_protocol_core.py +12 -0
- opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +19 -2
- opentrons/protocol_api/core/module.py +25 -2
- opentrons/protocol_api/core/protocol.py +12 -0
- opentrons/protocol_api/instrument_context.py +388 -2
- opentrons/protocol_api/labware.py +5 -2
- opentrons/protocol_api/module_contexts.py +133 -30
- opentrons/protocol_api/protocol_context.py +61 -17
- opentrons/protocol_api/robot_context.py +3 -4
- opentrons/protocol_api/validation.py +43 -2
- opentrons/protocol_engine/__init__.py +4 -0
- opentrons/protocol_engine/actions/__init__.py +2 -0
- opentrons/protocol_engine/actions/actions.py +9 -0
- opentrons/protocol_engine/commands/__init__.py +14 -0
- opentrons/protocol_engine/commands/absorbance_reader/read.py +22 -23
- opentrons/protocol_engine/commands/aspirate_while_tracking.py +52 -19
- opentrons/protocol_engine/commands/capture_image.py +302 -0
- opentrons/protocol_engine/commands/command.py +1 -0
- opentrons/protocol_engine/commands/command_unions.py +13 -0
- opentrons/protocol_engine/commands/dispense_while_tracking.py +56 -19
- opentrons/protocol_engine/commands/flex_stacker/common.py +35 -0
- opentrons/protocol_engine/commands/flex_stacker/set_stored_labware.py +7 -0
- opentrons/protocol_engine/commands/heater_shaker/set_shake_speed.py +1 -1
- opentrons/protocol_engine/commands/heater_shaker/set_target_temperature.py +1 -1
- opentrons/protocol_engine/commands/move_labware.py +3 -4
- opentrons/protocol_engine/commands/move_to_addressable_area_for_drop_tip.py +1 -1
- opentrons/protocol_engine/commands/movement_common.py +29 -2
- opentrons/protocol_engine/commands/pipetting_common.py +48 -3
- opentrons/protocol_engine/commands/thermocycler/set_target_block_temperature.py +12 -9
- opentrons/protocol_engine/commands/thermocycler/set_target_lid_temperature.py +17 -12
- opentrons/protocol_engine/commands/thermocycler/start_run_extended_profile.py +1 -1
- opentrons/protocol_engine/create_protocol_engine.py +12 -0
- opentrons/protocol_engine/engine_support.py +3 -0
- opentrons/protocol_engine/errors/__init__.py +8 -0
- opentrons/protocol_engine/errors/exceptions.py +64 -0
- opentrons/protocol_engine/execution/__init__.py +2 -0
- opentrons/protocol_engine/execution/command_executor.py +54 -1
- opentrons/protocol_engine/execution/create_queue_worker.py +4 -1
- opentrons/protocol_engine/execution/labware_movement.py +13 -4
- opentrons/protocol_engine/execution/pipetting.py +19 -25
- opentrons/protocol_engine/protocol_engine.py +62 -2
- opentrons/protocol_engine/resources/__init__.py +2 -0
- opentrons/protocol_engine/resources/camera_provider.py +110 -0
- opentrons/protocol_engine/resources/file_provider.py +133 -58
- opentrons/protocol_engine/slot_standardization.py +2 -0
- opentrons/protocol_engine/state/camera.py +54 -0
- opentrons/protocol_engine/state/commands.py +24 -4
- opentrons/protocol_engine/state/geometry.py +68 -10
- opentrons/protocol_engine/state/labware.py +10 -6
- opentrons/protocol_engine/state/labware_origin_math/stackup_origin_to_labware_origin.py +6 -1
- opentrons/protocol_engine/state/modules.py +9 -0
- opentrons/protocol_engine/state/preconditions.py +59 -0
- opentrons/protocol_engine/state/state.py +30 -0
- opentrons/protocol_engine/state/state_summary.py +2 -0
- opentrons/protocol_engine/state/update_types.py +10 -0
- opentrons/protocol_engine/types/__init__.py +14 -1
- opentrons/protocol_engine/types/command_preconditions.py +18 -0
- opentrons/protocol_engine/types/location.py +26 -2
- opentrons/protocol_engine/types/module.py +1 -1
- opentrons/protocol_runner/protocol_runner.py +14 -1
- opentrons/protocol_runner/run_orchestrator.py +31 -0
- opentrons/protocols/advanced_control/transfers/transfer_liquid_utils.py +2 -2
- opentrons/simulate.py +3 -0
- opentrons/system/camera.py +333 -3
- opentrons/system/ffmpeg.py +110 -0
- {opentrons-8.7.0a7.dist-info → opentrons-8.8.0a7.dist-info}/METADATA +4 -4
- {opentrons-8.7.0a7.dist-info → opentrons-8.8.0a7.dist-info}/RECORD +109 -97
- {opentrons-8.7.0a7.dist-info → opentrons-8.8.0a7.dist-info}/WHEEL +0 -0
- {opentrons-8.7.0a7.dist-info → opentrons-8.8.0a7.dist-info}/entry_points.txt +0 -0
- {opentrons-8.7.0a7.dist-info → opentrons-8.8.0a7.dist-info}/licenses/LICENSE +0 -0
opentrons/system/camera.py
CHANGED
|
@@ -1,10 +1,68 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import os
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
from
|
|
4
|
+
import logging
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Dict, Optional
|
|
8
|
+
from opentrons.config import ARCHITECTURE, SystemArchitecture, get_opentrons_path
|
|
6
9
|
from opentrons_shared_data.errors.exceptions import CommunicationError
|
|
7
10
|
from opentrons_shared_data.errors.codes import ErrorCodes
|
|
11
|
+
from opentrons.config import IS_ROBOT
|
|
12
|
+
from opentrons_shared_data.robot.types import RobotType, RobotTypeEnum
|
|
13
|
+
from opentrons.protocol_engine.resources.camera_provider import (
|
|
14
|
+
CameraProvider,
|
|
15
|
+
ImageParameters,
|
|
16
|
+
CameraError,
|
|
17
|
+
CameraSettings,
|
|
18
|
+
)
|
|
19
|
+
from opentrons.system import ffmpeg
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# Default System Cameras
|
|
24
|
+
DEFAULT_SYSTEM_CAMERA = "/dev/ot_system_camera"
|
|
25
|
+
|
|
26
|
+
# Stream Globals
|
|
27
|
+
DEFAULT_CONF_FILE = (
|
|
28
|
+
"/lib/systemd/system/opentrons-live-stream/opentrons-live-stream.env"
|
|
29
|
+
)
|
|
30
|
+
STREAM_CONF_FILE_KEYS = [
|
|
31
|
+
"BOOT_ID",
|
|
32
|
+
"STATUS",
|
|
33
|
+
"SOURCE",
|
|
34
|
+
"RESOLUTION",
|
|
35
|
+
"FRAMERATE",
|
|
36
|
+
"BITRATE",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# Camera Parameter Globals
|
|
40
|
+
RESOLUTION_MIN = (320, 240)
|
|
41
|
+
RESOLUTION_MAX = (7680, 4320)
|
|
42
|
+
RESOLUTION_DEFAULT = (1920, 1080)
|
|
43
|
+
ZOOM_MIN = 1.0
|
|
44
|
+
ZOOM_MAX = 2.0
|
|
45
|
+
ZOOM_DEFAULT = 1.0
|
|
46
|
+
CONTRAST_MIN = 0.0
|
|
47
|
+
CONTRAST_MAX = 2.0
|
|
48
|
+
CONTRAST_DEFAULT = 1.0
|
|
49
|
+
BRIGHTNESS_MIN = -128
|
|
50
|
+
BRIGHTNESS_MAX = 128
|
|
51
|
+
BRIGHTNESS_DEFAULT = 0
|
|
52
|
+
SATURATION_MIN = 0.0
|
|
53
|
+
SATURATION_MAX = 2.0
|
|
54
|
+
SATURATION_DEFAULT = 1.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class StreamConfigurationKeys(str, Enum):
|
|
58
|
+
"""The Configuration Key Types."""
|
|
59
|
+
|
|
60
|
+
BOOT_ID = "BOOT_ID"
|
|
61
|
+
STATUS = "STATUS"
|
|
62
|
+
SOURCE = "SOURCE"
|
|
63
|
+
RESOLUTION = "RESOLUTION"
|
|
64
|
+
FRAMERATE = "FRAMERATE"
|
|
65
|
+
BITRATE = "BITRATE"
|
|
8
66
|
|
|
9
67
|
|
|
10
68
|
class CameraException(CommunicationError):
|
|
@@ -17,7 +75,7 @@ class CameraException(CommunicationError):
|
|
|
17
75
|
|
|
18
76
|
|
|
19
77
|
async def take_picture(filename: Path) -> None:
|
|
20
|
-
"""
|
|
78
|
+
"""Legacy method to take a picture and save it to filename
|
|
21
79
|
|
|
22
80
|
:param filename: Name of file to save picture to
|
|
23
81
|
:param loop: optional loop to use
|
|
@@ -49,3 +107,275 @@ async def take_picture(filename: Path) -> None:
|
|
|
49
107
|
raise CameraException("Failed to communicate with camera", res)
|
|
50
108
|
if not filename.exists():
|
|
51
109
|
raise CameraException("Failed to save image", "")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_stream_configuration_filepath() -> Path:
|
|
113
|
+
"""Return the file path to the Opentrons Live Stream Configuration file."""
|
|
114
|
+
filepath = get_opentrons_path("live_stream_environment_file")
|
|
115
|
+
if IS_ROBOT and not os.path.exists(filepath):
|
|
116
|
+
# If the dynamic configuration file doesn't exist make it using our defaults file
|
|
117
|
+
with open(DEFAULT_CONF_FILE, "r") as default_config:
|
|
118
|
+
content = default_config.read()
|
|
119
|
+
with open(filepath, "w") as new_config_file:
|
|
120
|
+
new_config_file.write(content)
|
|
121
|
+
return filepath
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def robot_supports_livestream(robot_type: RobotType) -> bool:
|
|
125
|
+
"""Validate whether or not robot supports live streaming service."""
|
|
126
|
+
robot = RobotTypeEnum.robot_literal_to_enum(robot_type)
|
|
127
|
+
if robot == RobotTypeEnum.OT2:
|
|
128
|
+
# If we are on an OT-2 we do not support live streams
|
|
129
|
+
return False
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def update_live_stream_status(
|
|
134
|
+
robot_type: RobotType,
|
|
135
|
+
stream_status: bool,
|
|
136
|
+
camera_provider: CameraProvider,
|
|
137
|
+
override_settings: Optional[CameraSettings] = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Update and handle a change in the Opentrons Live Stream status."""
|
|
140
|
+
if not IS_ROBOT or robot_supports_livestream(robot_type) is False:
|
|
141
|
+
# If we are not on a robot we simply no-op updating the stream
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
contents = load_stream_configuration_file_data()
|
|
145
|
+
if contents is None:
|
|
146
|
+
log.error("Opentrons Live Stream Configuration file cannot be updated.")
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
# Validate the stream status
|
|
150
|
+
if override_settings is not None:
|
|
151
|
+
camera_enable_settings = override_settings
|
|
152
|
+
else:
|
|
153
|
+
camera_enable_settings = await camera_provider.get_camera_settings()
|
|
154
|
+
status = "OFF"
|
|
155
|
+
if (
|
|
156
|
+
stream_status
|
|
157
|
+
and camera_enable_settings.cameraEnabled
|
|
158
|
+
and camera_enable_settings.liveStreamEnabled
|
|
159
|
+
):
|
|
160
|
+
# Check to see if the camera device is available
|
|
161
|
+
raw_device = str(contents["SOURCE"])[1:-1]
|
|
162
|
+
if not os.path.exists(raw_device):
|
|
163
|
+
log.error(
|
|
164
|
+
f"Opentrons Live Stream cannot sample the camera. No video device found with device path: {raw_device}"
|
|
165
|
+
)
|
|
166
|
+
# Enable the stream
|
|
167
|
+
status = "ON"
|
|
168
|
+
# Overwrite the contents
|
|
169
|
+
contents["BOOT_ID"] = get_boot_id()
|
|
170
|
+
contents["STATUS"] = status
|
|
171
|
+
write_stream_configuration_file_data(contents)
|
|
172
|
+
await restart_live_stream(robot_type)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def stop_live_stream(robot_type: RobotType) -> None:
|
|
176
|
+
"""Attempt to stop the Opentrons Live Stream service."""
|
|
177
|
+
if robot_supports_livestream(robot_type) is False:
|
|
178
|
+
# No-op on OT-2 since we don't have a live stream service there
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
command = ["systemctl", "stop", "opentrons-live-stream"]
|
|
182
|
+
subprocess = await asyncio.create_subprocess_exec(
|
|
183
|
+
*command,
|
|
184
|
+
stdout=asyncio.subprocess.PIPE,
|
|
185
|
+
stderr=asyncio.subprocess.PIPE,
|
|
186
|
+
)
|
|
187
|
+
stdout, stderr = await subprocess.communicate()
|
|
188
|
+
if subprocess.returncode == 0:
|
|
189
|
+
log.info("Stopped the opentrons-live-stream service.")
|
|
190
|
+
else:
|
|
191
|
+
log.error(
|
|
192
|
+
f"Failed to stop opentrons-live-stream, returncode:{ subprocess.returncode}, stdout: {stdout.decode()}, stderr: {stderr.decode()}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def restart_live_stream(robot_type: RobotType) -> None:
|
|
197
|
+
"""Attempt to restart the Opentrons Live Stream service."""
|
|
198
|
+
if robot_supports_livestream(robot_type) is False:
|
|
199
|
+
# No-op on OT-2 since we don't have a live stream service there
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
command = ["systemctl", "restart", "opentrons-live-stream"]
|
|
203
|
+
subprocess = await asyncio.create_subprocess_exec(
|
|
204
|
+
*command,
|
|
205
|
+
stdout=asyncio.subprocess.PIPE,
|
|
206
|
+
stderr=asyncio.subprocess.PIPE,
|
|
207
|
+
)
|
|
208
|
+
stdout, stderr = await subprocess.communicate()
|
|
209
|
+
if subprocess.returncode == 0:
|
|
210
|
+
log.info("Restarted opentrons-live-stream service.")
|
|
211
|
+
else:
|
|
212
|
+
log.error(
|
|
213
|
+
f"Failed to restart opentrons-live-stream, returncode:{ subprocess.returncode}, stdout: {stdout.decode()}, stderr: {stderr.decode()}"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def load_stream_configuration_file_data() -> dict[str, str] | None:
|
|
218
|
+
"""Load the Opentrons Live Stream Conf file and return parsed data or None if an error occurs."""
|
|
219
|
+
src = get_stream_configuration_filepath()
|
|
220
|
+
if not src.exists():
|
|
221
|
+
log.error(f"Opentrons Live Stream configuration file not found: {src}")
|
|
222
|
+
return None
|
|
223
|
+
with src.open("rb") as fd:
|
|
224
|
+
try:
|
|
225
|
+
return parse_stream_configuration_file_data(fd.read())
|
|
226
|
+
except Exception as e:
|
|
227
|
+
log.error(
|
|
228
|
+
f"Opentrons Live Stream status update file parsing failed with: {e}"
|
|
229
|
+
)
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def parse_stream_configuration_file_data(data: bytes) -> Dict[str, str] | None:
|
|
234
|
+
"""
|
|
235
|
+
Parse a collect of bytes for Opentrons Live Stream Configuration data and return a dictionary of
|
|
236
|
+
results keyed by configuration constants. Returns None if an error occurred during parsing.
|
|
237
|
+
"""
|
|
238
|
+
contents: Dict[str, str] = {
|
|
239
|
+
key.decode("utf-8"): val.decode("utf-8")
|
|
240
|
+
for key, val in [line.split(b"=") for line in data.split(b"\n") if b"=" in line]
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
enum_stream_keys = {stream_key.value for stream_key in StreamConfigurationKeys}
|
|
244
|
+
if sorted(list(contents.keys())) != sorted(enum_stream_keys):
|
|
245
|
+
log.error(
|
|
246
|
+
"Opentrons Live Stream Configuration file data is incorrect or missing."
|
|
247
|
+
)
|
|
248
|
+
# We don't want to write bad or incomplete data to the file
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
# Migrate old camera default file data to new uniform default
|
|
252
|
+
if contents[StreamConfigurationKeys.SOURCE] == "NONE":
|
|
253
|
+
contents[StreamConfigurationKeys.SOURCE] = DEFAULT_SYSTEM_CAMERA
|
|
254
|
+
return contents
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def write_stream_configuration_file_data(data: Dict[str, str]) -> None:
|
|
258
|
+
src = get_stream_configuration_filepath()
|
|
259
|
+
if not src.exists():
|
|
260
|
+
log.error(f"Opentrons Live Stream configuration file not found: {src}")
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
enum_stream_keys = {stream_key.value for stream_key in StreamConfigurationKeys}
|
|
264
|
+
if sorted(list(data.keys())) != sorted(enum_stream_keys):
|
|
265
|
+
log.error(
|
|
266
|
+
"Data provided to write is not compatible with Opentrons Live Stream Configuration file."
|
|
267
|
+
)
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
with src.open("w") as fd:
|
|
271
|
+
file_lines = [
|
|
272
|
+
f"{StreamConfigurationKeys.BOOT_ID}={data[StreamConfigurationKeys.BOOT_ID]}\n",
|
|
273
|
+
f"{StreamConfigurationKeys.STATUS}={data[StreamConfigurationKeys.STATUS]}\n",
|
|
274
|
+
f"{StreamConfigurationKeys.SOURCE}={data[StreamConfigurationKeys.SOURCE]}\n",
|
|
275
|
+
f"{StreamConfigurationKeys.RESOLUTION}={data[StreamConfigurationKeys.RESOLUTION]}\n",
|
|
276
|
+
f"{StreamConfigurationKeys.FRAMERATE}={data[StreamConfigurationKeys.FRAMERATE]}\n",
|
|
277
|
+
f"{StreamConfigurationKeys.BITRATE}={data[StreamConfigurationKeys.BITRATE]}\n",
|
|
278
|
+
]
|
|
279
|
+
fd.writelines(file_lines)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
async def image_capture( # noqa: C901
|
|
283
|
+
robot_type: RobotType, parameters: ImageParameters
|
|
284
|
+
) -> bytes | CameraError:
|
|
285
|
+
"""Process an Image Capture request with a Camera utilizing a given set of parameters."""
|
|
286
|
+
camera = DEFAULT_SYSTEM_CAMERA
|
|
287
|
+
|
|
288
|
+
# We must always validate the camera exists
|
|
289
|
+
if not os.path.exists(camera):
|
|
290
|
+
return CameraError(
|
|
291
|
+
message=f"No video device found with device path {camera}", code=None
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if parameters.zoom is not None and (
|
|
295
|
+
parameters.zoom < ZOOM_MIN or parameters.zoom > ZOOM_MAX
|
|
296
|
+
):
|
|
297
|
+
potential_invalid_param = "Zoom"
|
|
298
|
+
elif parameters.contrast is not None and (
|
|
299
|
+
parameters.contrast < CONTRAST_MIN or parameters.contrast > CONTRAST_MAX
|
|
300
|
+
):
|
|
301
|
+
potential_invalid_param = "Contrast"
|
|
302
|
+
elif parameters.brightness is not None and (
|
|
303
|
+
parameters.brightness < BRIGHTNESS_MIN or parameters.brightness > BRIGHTNESS_MAX
|
|
304
|
+
):
|
|
305
|
+
potential_invalid_param = "Brightness"
|
|
306
|
+
elif parameters.saturation is not None and (
|
|
307
|
+
parameters.saturation < SATURATION_MIN or parameters.saturation > SATURATION_MAX
|
|
308
|
+
):
|
|
309
|
+
potential_invalid_param = "Saturation"
|
|
310
|
+
elif parameters.resolution is not None and (
|
|
311
|
+
parameters.resolution[0] < RESOLUTION_MIN[0]
|
|
312
|
+
or parameters.resolution[1] < RESOLUTION_MIN[1]
|
|
313
|
+
or parameters.resolution[0] > RESOLUTION_MAX[0]
|
|
314
|
+
or parameters.resolution[1] > RESOLUTION_MAX[1]
|
|
315
|
+
):
|
|
316
|
+
potential_invalid_param = "Resolution"
|
|
317
|
+
else:
|
|
318
|
+
potential_invalid_param = None
|
|
319
|
+
|
|
320
|
+
if potential_invalid_param is not None:
|
|
321
|
+
return CameraError(
|
|
322
|
+
message=f"{potential_invalid_param} parameter is outside the boundaries allowed for image capture.",
|
|
323
|
+
code="IMAGE_SETTINGS",
|
|
324
|
+
)
|
|
325
|
+
try:
|
|
326
|
+
# Always stop the live stream service to ensure the Camera is always free when attempting an image capture
|
|
327
|
+
await stop_live_stream(robot_type)
|
|
328
|
+
|
|
329
|
+
zoom = parameters.zoom if parameters.zoom is not None else ZOOM_DEFAULT
|
|
330
|
+
contrast = (
|
|
331
|
+
parameters.contrast if parameters.contrast is not None else CONTRAST_DEFAULT
|
|
332
|
+
)
|
|
333
|
+
brightness = (
|
|
334
|
+
parameters.brightness
|
|
335
|
+
if parameters.brightness is not None
|
|
336
|
+
else BRIGHTNESS_DEFAULT
|
|
337
|
+
)
|
|
338
|
+
saturation = (
|
|
339
|
+
parameters.saturation
|
|
340
|
+
if parameters.saturation is not None
|
|
341
|
+
else SATURATION_DEFAULT
|
|
342
|
+
)
|
|
343
|
+
resolution = (
|
|
344
|
+
parameters.resolution
|
|
345
|
+
if parameters.resolution is not None
|
|
346
|
+
else RESOLUTION_DEFAULT
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
result = await ffmpeg.ffmpeg_capture_image_bytes(
|
|
350
|
+
robot_type=robot_type,
|
|
351
|
+
resolution=resolution,
|
|
352
|
+
camera=camera,
|
|
353
|
+
zoom=zoom,
|
|
354
|
+
pan=parameters.pan if parameters.pan is not None else (0, 0),
|
|
355
|
+
contrast=contrast,
|
|
356
|
+
brightness=brightness,
|
|
357
|
+
saturation=saturation,
|
|
358
|
+
)
|
|
359
|
+
except Exception:
|
|
360
|
+
result = CameraError(
|
|
361
|
+
message="Exception occured during execution of system image capture.",
|
|
362
|
+
code=None,
|
|
363
|
+
)
|
|
364
|
+
finally:
|
|
365
|
+
# Restart the live stream service
|
|
366
|
+
await restart_live_stream(robot_type)
|
|
367
|
+
return result
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@lru_cache(maxsize=1)
|
|
371
|
+
def get_boot_id() -> str:
|
|
372
|
+
if IS_ROBOT:
|
|
373
|
+
return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
|
374
|
+
else:
|
|
375
|
+
return "SIMULATED_BOOT_ID"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def camera_exists() -> bool:
|
|
379
|
+
"""Validate whether or not the camera device exists."""
|
|
380
|
+
return os.path.exists(DEFAULT_SYSTEM_CAMERA)
|
|
381
|
+
# todo(chb, 2025-11-10): Eventually when we support multiple cameras this should accept a camera parameter to check for
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""opentrons.system.ffmpeg: Functions and data for interacting with FFMPEG."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Tuple
|
|
5
|
+
from opentrons.protocol_engine.resources.camera_provider import CameraError
|
|
6
|
+
from opentrons_shared_data.robot.types import RobotType
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
# === FFMPEG Filter Details ===
|
|
11
|
+
# The following filters are utilized via the '-vf' flag to manipulate the final image returned:
|
|
12
|
+
# 'crop' = [output_width]:[output_height]:x:y
|
|
13
|
+
# The crop is composed of a desired output width and height for the image, and
|
|
14
|
+
# an X/Y position to begin the crop at (becomes the top left of the new image).
|
|
15
|
+
# 'scale' = [width]:[height]
|
|
16
|
+
# The resolution of the final image to export, scales up or down based on configuration.
|
|
17
|
+
# 'lut' (Look-Up Table) = 'y' (Luminance) = 'val' (Current value of a given pixel from 0-255)
|
|
18
|
+
# The equation on the Look up table takes the current luminance value of an image per pixel
|
|
19
|
+
# and manipulates it using Contrast and Brightness settings. This is applied to the whole image.
|
|
20
|
+
# 'hue' (Image color range) = 's' (Saturation) = [range]
|
|
21
|
+
# The hue flag uses the 's' (saturation) modifier to scale image color intensity, default is 1.
|
|
22
|
+
|
|
23
|
+
# todo(chb, 2025-10-13): Right now we're just zooming towards the center of the frame. The 'pan'
|
|
24
|
+
# setting should be used on the latter half of 'crop' to determine our cropping location instead.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def ffmpeg_capture_image_bytes(
|
|
28
|
+
robot_type: RobotType,
|
|
29
|
+
resolution: Tuple[int, int],
|
|
30
|
+
camera: str,
|
|
31
|
+
zoom: float,
|
|
32
|
+
pan: Tuple[int, int],
|
|
33
|
+
contrast: float,
|
|
34
|
+
brightness: int,
|
|
35
|
+
saturation: float,
|
|
36
|
+
) -> bytes | CameraError:
|
|
37
|
+
"""Execute an FFMPEG command to capture an image based on various image parameters."""
|
|
38
|
+
if robot_type == "OT-2 Standard":
|
|
39
|
+
ot2_brightness: float = (
|
|
40
|
+
brightness / 128
|
|
41
|
+
) * -1 # OT-2's equilizer field takes a value of -1.0 to 1.0 for brightness
|
|
42
|
+
command = [
|
|
43
|
+
"ffmpeg",
|
|
44
|
+
"-hwaccel",
|
|
45
|
+
"auto",
|
|
46
|
+
"-video_size",
|
|
47
|
+
f"{resolution[0]}x{resolution[1]}",
|
|
48
|
+
"-f",
|
|
49
|
+
"v4l2",
|
|
50
|
+
"-i",
|
|
51
|
+
f"{camera}",
|
|
52
|
+
"-vf",
|
|
53
|
+
f"crop=iw/{zoom}:ih/{zoom}:(iw-iw/{zoom})/{zoom}:(ih-ih/{zoom})/{zoom},"
|
|
54
|
+
f"scale={resolution[0]}:{resolution[1]},"
|
|
55
|
+
f"eq=brightness={ot2_brightness}:contrast={contrast}:saturation={saturation}",
|
|
56
|
+
"-frames:v",
|
|
57
|
+
"1",
|
|
58
|
+
"-f",
|
|
59
|
+
"image2pipe",
|
|
60
|
+
"-vcodec",
|
|
61
|
+
"mjpeg",
|
|
62
|
+
"-",
|
|
63
|
+
]
|
|
64
|
+
else:
|
|
65
|
+
command = [
|
|
66
|
+
"ffmpeg",
|
|
67
|
+
"-hwaccel",
|
|
68
|
+
"auto",
|
|
69
|
+
"-video_size",
|
|
70
|
+
f"{resolution[0]}x{resolution[1]}",
|
|
71
|
+
"-f",
|
|
72
|
+
"v4l2",
|
|
73
|
+
"-i",
|
|
74
|
+
f"{camera}",
|
|
75
|
+
"-vf",
|
|
76
|
+
f"crop=iw/{zoom}:ih/{zoom}:(iw-iw/{zoom})/{zoom}:(ih-ih/{zoom})/{zoom},"
|
|
77
|
+
f"scale={resolution[0]}:{resolution[1]},"
|
|
78
|
+
f"lut=y=(val-128)*{contrast}+128-{brightness},"
|
|
79
|
+
f"hue=s={saturation},format=nv12",
|
|
80
|
+
"-frames:v",
|
|
81
|
+
"1",
|
|
82
|
+
"-f",
|
|
83
|
+
"image2pipe",
|
|
84
|
+
"-vcodec",
|
|
85
|
+
"mjpeg",
|
|
86
|
+
"-",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
subprocess = await asyncio.create_subprocess_exec(
|
|
90
|
+
*command,
|
|
91
|
+
stdout=asyncio.subprocess.PIPE,
|
|
92
|
+
stderr=asyncio.subprocess.PIPE,
|
|
93
|
+
)
|
|
94
|
+
stdout: bytes
|
|
95
|
+
stderr: bytes
|
|
96
|
+
stdout, stderr = await subprocess.communicate()
|
|
97
|
+
if subprocess.returncode == 0:
|
|
98
|
+
log.info("Successfully captured an image with camera.")
|
|
99
|
+
# Upon success, dump our byte stream to the result
|
|
100
|
+
return stdout
|
|
101
|
+
else:
|
|
102
|
+
log.error(
|
|
103
|
+
f"Failed to capture an image with camera, returncode:{ subprocess.returncode}, stdout: {stdout.decode()}, stderr: {stderr.decode()}"
|
|
104
|
+
)
|
|
105
|
+
return CameraError(
|
|
106
|
+
message="Failed to return bytes from FFMPEG image capture.",
|
|
107
|
+
code=str(subprocess.returncode)
|
|
108
|
+
if subprocess.returncode is not None
|
|
109
|
+
else None,
|
|
110
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: opentrons
|
|
3
|
-
Version: 8.
|
|
3
|
+
Version: 8.8.0a7
|
|
4
4
|
Summary: The Opentrons API is a simple framework designed to make writing automated biology lab protocols easy.
|
|
5
5
|
Project-URL: opentrons.com, https://www.opentrons.com
|
|
6
6
|
Project-URL: Source Code On Github, https://github.com/Opentrons/opentrons/tree/edge/api
|
|
@@ -24,7 +24,7 @@ Requires-Dist: click<9,>=8.0.0
|
|
|
24
24
|
Requires-Dist: importlib-metadata>=1.0; python_version < '3.8'
|
|
25
25
|
Requires-Dist: jsonschema<4.18.0,>=3.0.1
|
|
26
26
|
Requires-Dist: numpy<2,>=1.20.0
|
|
27
|
-
Requires-Dist: opentrons-shared-data==8.
|
|
27
|
+
Requires-Dist: opentrons-shared-data==8.8.0a7
|
|
28
28
|
Requires-Dist: packaging>=21.0
|
|
29
29
|
Requires-Dist: pydantic-settings<3,>=2
|
|
30
30
|
Requires-Dist: pydantic<3,>=2.0.0
|
|
@@ -32,6 +32,6 @@ Requires-Dist: pyserial>=3.5
|
|
|
32
32
|
Requires-Dist: pyusb==1.2.1
|
|
33
33
|
Requires-Dist: typing-extensions<5,>=4.0.0
|
|
34
34
|
Provides-Extra: flex-hardware
|
|
35
|
-
Requires-Dist: opentrons-hardware[flex]==8.
|
|
35
|
+
Requires-Dist: opentrons-hardware[flex]==8.8.0a7; extra == 'flex-hardware'
|
|
36
36
|
Provides-Extra: ot2-hardware
|
|
37
|
-
Requires-Dist: opentrons-hardware==8.
|
|
37
|
+
Requires-Dist: opentrons-hardware==8.8.0a7; extra == 'ot2-hardware'
|