Skip to content

Commit a66bb64

Browse files
authored
feat(robot-server): Implementation for preview image capture (#20368)
Covers EXEC-2129 Implements the `/camera/capturePreviewImage` and `/runs/{runId}/camera/capturePreviewImage` endpoints which captures and returns a singular photo with the embedded camera .
1 parent 38e37c3 commit a66bb64

File tree

7 files changed

+283
-17
lines changed

7 files changed

+283
-17
lines changed

api/src/opentrons/system/camera.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
# Default System Cameras
2424
DEFAULT_SYSTEM_CAMERA = "/dev/ot_system_camera"
2525

26+
# Default Preview Image Filename
27+
PREVIEW_IMAGE = "preview_image.jpeg"
28+
2629
# Stream Globals
2730
DEFAULT_CONF_FILE = (
2831
"/lib/systemd/system/opentrons-live-stream/opentrons-live-stream.env"

robot-server/robot_server/data_files/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ class FileIdNotFound(ErrorDetails):
7070
title: str = "Specified file id not found on the robot"
7171

7272

73+
class FileNotFound(ErrorDetails):
74+
"""An error returned when specified file path was not found on the robot."""
75+
76+
id: Literal["FileNotFound"] = "FileNotFound"
77+
title: str = "Specified file path not found on the robot"
78+
79+
7380
class NoImagesFound(ErrorDetails):
7481
"""An error returned when no images are found for the specified run."""
7582

robot-server/robot_server/data_files/router.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
DataFile,
3939
FileIdNotFoundError,
4040
FileIdNotFound,
41+
FileNotFound,
4142
FileInUseError,
4243
ImageFileMetadata,
4344
NoImagesFound,
@@ -75,13 +76,6 @@ class NoDataFileSourceProvided(ErrorDetails):
7576
title: str = "No data file source provided"
7677

7778

78-
class FileNotFound(ErrorDetails):
79-
"""An error returned when specified file path was not found on the robot."""
80-
81-
id: Literal["FileNotFound"] = "FileNotFound"
82-
title: str = "Specified file path not found on the robot"
83-
84-
8579
class UnexpectedFileFormat(ErrorDetails):
8680
"""An error returned when specified file is not in expected format."""
8781

robot-server/robot_server/runs/router/camera_router.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Router for /runs endpoints dealing with run specific camera settings and behavior."""
22

33
import logging
4+
import os
45
from typing import Annotated, Union
5-
6-
from fastapi import Depends, status
6+
from pathlib import Path
7+
from fastapi import Depends, status, HTTPException
8+
from fastapi.responses import FileResponse
79
from server_utils.fastapi_utils.light_router import LightRouter
810

911
from opentrons.protocol_engine.resources.camera_provider import CameraSettings
@@ -22,6 +24,9 @@
2224
get_camera_provider,
2325
)
2426
from opentrons.protocol_engine.resources.camera_provider import CameraProvider
27+
from opentrons.protocol_engine.resources.camera_provider import ImageParameters
28+
from robot_server.persistence.fastapi_dependencies import get_images_directory
29+
from robot_server.data_files.models import FileNotFound
2530

2631
from ..run_models import Run
2732
from ..run_orchestrator_store import RunOrchestratorStore
@@ -33,6 +38,7 @@
3338
CameraEnable,
3439
CameraCaptureImageSettings,
3540
)
41+
from opentrons.protocol_engine import EngineStatus
3642

3743
log = logging.getLogger(__name__)
3844
camera_router = LightRouter()
@@ -177,3 +183,92 @@ async def add_camera_capture_image_settings(
177183
content=SimpleBody.model_construct(data=request_body.data),
178184
status_code=status.HTTP_201_CREATED,
179185
)
186+
187+
188+
@camera_router.post(
189+
path="/runs/{runId}/camera/capturePreviewImage",
190+
summary="Capture a preview image based on provided settings and the run specific camera enablement.",
191+
description="Return a preview image based on provided capture image settings.",
192+
responses={
193+
status.HTTP_200_OK: {
194+
"content": {"image/jpeg": {}},
195+
"description": "Preview image taken with specific settings.",
196+
},
197+
status.HTTP_404_NOT_FOUND: {"model": ErrorBody[FileNotFound]},
198+
},
199+
)
200+
async def post_camera_preview_image(
201+
request_body: RequestModel[CameraCaptureImageSettings],
202+
run: Annotated[Run, Depends(get_run_data_from_url)],
203+
images_directory: Annotated[Path, Depends(get_images_directory)],
204+
robot_type: Annotated[RobotType, Depends(get_robot_type)],
205+
) -> FileResponse:
206+
"""Return a preview image based on the provided capture image settings and run specific enablement."""
207+
if IS_ROBOT and not camera.camera_exists():
208+
# todo(chb): Eventually we'll have mulitple camera ids that can be sent, so this should be able to verify more than just the default
209+
raise LegacyErrorResponse(
210+
message="Video device is unavailable.",
211+
errorCode=ErrorCodes.GENERAL_ERROR.value.code,
212+
).as_error(status.HTTP_503_SERVICE_UNAVAILABLE)
213+
214+
if run.status not in [
215+
EngineStatus.IDLE,
216+
EngineStatus.STOPPED,
217+
EngineStatus.FAILED,
218+
EngineStatus.SUCCEEDED,
219+
]:
220+
raise HTTPException(
221+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
222+
detail=str("Cannot capture preview photo, run is not inactive."),
223+
)
224+
225+
image_data = await camera.image_capture(
226+
robot_type=robot_type,
227+
parameters=ImageParameters(
228+
resolution=request_body.data.resolution,
229+
zoom=request_body.data.zoom,
230+
pan=request_body.data.pan,
231+
contrast=(
232+
(request_body.data.contrast / 100) * 2.0
233+
if request_body.data.contrast is not None
234+
else None
235+
),
236+
brightness=(
237+
int(((request_body.data.brightness * 256) // 100) - 128) * -1
238+
if request_body.data.brightness is not None
239+
else None
240+
),
241+
saturation=(
242+
(request_body.data.saturation / 100) * 2.0
243+
if request_body.data.saturation is not None
244+
else None
245+
),
246+
),
247+
)
248+
249+
file_path = images_directory / camera.PREVIEW_IMAGE
250+
251+
if IS_ROBOT:
252+
if isinstance(image_data, bytes):
253+
os.makedirs(os.path.dirname(file_path), exist_ok=True)
254+
255+
with open(file=file_path, mode="wb") as f:
256+
f.write(image_data)
257+
else:
258+
raise HTTPException(
259+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
260+
detail=str(
261+
f"Preview image capture failed with the following: {image_data.message}"
262+
),
263+
)
264+
265+
if not file_path.exists():
266+
raise FileNotFound(detail="Preview image file not found.").as_error(
267+
status.HTTP_404_NOT_FOUND
268+
)
269+
270+
return FileResponse(
271+
path=file_path,
272+
media_type="image/jpeg",
273+
filename=camera.PREVIEW_IMAGE,
274+
)

robot-server/robot_server/service/legacy/routers/camera.py

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,23 @@
44
import tempfile
55
from typing import Annotated
66
from pathlib import Path
7-
from fastapi import APIRouter, HTTPException, Depends
7+
from fastapi import APIRouter, HTTPException, Depends, Response
8+
from fastapi.responses import FileResponse
89
from starlette import status
910
from starlette.background import BackgroundTask
1011
from starlette.responses import StreamingResponse
1112
from opentrons.system import camera
12-
from opentrons.system.camera import StreamConfigurationKeys
13+
from opentrons.system.camera import StreamConfigurationKeys, PREVIEW_IMAGE
1314
from robot_server.errors.error_responses import LegacyErrorResponse
1415
from opentrons_shared_data.errors import ErrorCodes
16+
from robot_server.errors.error_responses import ErrorBody
1517
from robot_server.service.legacy.models.settings import (
1618
CameraEnable,
1719
LiveStreamData,
1820
LiveStreamSettings,
1921
Resolution,
2022
StreamStatusType,
23+
CameraCaptureImageSettings,
2124
)
2225
from robot_server.service.json_api import RequestModel
2326
from opentrons.config import IS_ROBOT
@@ -30,7 +33,9 @@
3033
CameraSettingStore,
3134
get_camera_setting_store,
3235
)
33-
36+
from opentrons.protocol_engine.resources.camera_provider import ImageParameters
37+
from robot_server.persistence.fastapi_dependencies import get_images_directory
38+
from robot_server.data_files.models import FileNotFound
3439

3540
log = logging.getLogger(__name__)
3641

@@ -152,6 +157,94 @@ async def get_camera(
152157
# todo(chb, 2025-09-08): Implement POST/GET /camera/picture/settings for picture taking settings when in protocol run
153158

154159

160+
@router.post(
161+
"/camera/capturePreviewImage",
162+
description="Return a preview image based on provided capture image settings.",
163+
responses={
164+
status.HTTP_404_NOT_FOUND: {"model": ErrorBody[FileNotFound]},
165+
},
166+
)
167+
async def post_camera_preview_image(
168+
request_body: RequestModel[CameraCaptureImageSettings],
169+
run_data_manager: Annotated[RunDataManager, Depends(get_run_data_manager)],
170+
camera_settings_store: Annotated[
171+
CameraSettingStore, Depends(get_camera_setting_store)
172+
],
173+
images_directory: Annotated[Path, Depends(get_images_directory)],
174+
robot_type: Annotated[RobotType, Depends(get_robot_type)],
175+
) -> Response:
176+
"""
177+
Return a preview image based on the provided capture image settings.
178+
"""
179+
if run_data_manager.current_run_id is not None and (
180+
run_data_manager.get(run_data_manager.current_run_id).status
181+
not in [EngineStatus.STOPPED, EngineStatus.FAILED, EngineStatus.SUCCEEDED]
182+
):
183+
raise HTTPException(
184+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
185+
detail=str("Cannot capture preview photo, run is active."),
186+
)
187+
188+
_validate_camera_present()
189+
190+
if not camera_settings_store.get_camera_enabled():
191+
raise HTTPException(
192+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
193+
detail=str("Cannot capture preview photo, camera is disabled on robot."),
194+
)
195+
196+
image_data = await camera.image_capture(
197+
robot_type=robot_type,
198+
parameters=ImageParameters(
199+
resolution=request_body.data.resolution,
200+
zoom=request_body.data.zoom,
201+
pan=request_body.data.pan,
202+
contrast=(
203+
(request_body.data.contrast / 100) * 2.0
204+
if request_body.data.contrast is not None
205+
else None
206+
),
207+
brightness=(
208+
int(((request_body.data.brightness * 256) // 100) - 128) * -1
209+
if request_body.data.brightness is not None
210+
else None
211+
),
212+
saturation=(
213+
(request_body.data.saturation / 100) * 2.0
214+
if request_body.data.saturation is not None
215+
else None
216+
),
217+
),
218+
)
219+
220+
file_path = images_directory / PREVIEW_IMAGE
221+
222+
if IS_ROBOT:
223+
if isinstance(image_data, bytes):
224+
os.makedirs(os.path.dirname(file_path), exist_ok=True)
225+
226+
with open(file=file_path, mode="wb") as f:
227+
f.write(image_data)
228+
else:
229+
raise HTTPException(
230+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
231+
detail=str(
232+
f"Preview image capture failed with the following: {image_data.message}"
233+
),
234+
)
235+
236+
if not file_path.exists():
237+
raise FileNotFound(detail="Preview image file not found.").as_error(
238+
status.HTTP_404_NOT_FOUND
239+
)
240+
241+
return FileResponse(
242+
path=file_path,
243+
media_type="image/jpeg",
244+
filename=PREVIEW_IMAGE,
245+
)
246+
247+
155248
@router.post(
156249
"/camera/picture",
157250
description="Capture an image from the OT-2's on-board camera " "and return it",

robot-server/tests/runs/router/test_camera_router.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
"""Tests for camera /runs/{runId}/camera routes."""
22
import pytest
33
from datetime import datetime
4+
from pathlib import Path
5+
import tempfile
46
from decoy import Decoy
57

68
from robot_server.service.json_api import RequestModel
79

8-
from robot_server.runs.router.camera_router import add_camera_capture_image_settings
9-
10-
from robot_server.service.legacy.models.settings import (
11-
CameraCaptureImageSettings,
10+
from robot_server.runs.router.camera_router import (
11+
add_camera_capture_image_settings,
12+
post_camera_preview_image,
1213
)
13-
1414
from robot_server.runs.run_models import Run
1515
from robot_server.runs.run_orchestrator_store import RunOrchestratorStore
1616
from opentrons.protocol_engine import EngineStatus
17+
from robot_server.service.legacy.models.settings import CameraCaptureImageSettings
18+
from fastapi.responses import FileResponse
1719

1820

1921
@pytest.fixture()
@@ -76,3 +78,28 @@ async def test_camera_settings(
7678

7779
assert result.content.data == image_settings
7880
assert result.status_code == 201
81+
82+
83+
async def test_camera_preview_image(
84+
decoy: Decoy,
85+
run: Run,
86+
) -> None:
87+
"""Test that we can request a preview image with a collection of image settings based on run specific enablement."""
88+
with tempfile.NamedTemporaryFile() as conf:
89+
response = await post_camera_preview_image(
90+
request_body=RequestModel(
91+
data=CameraCaptureImageSettings(
92+
cameraId=None,
93+
resolution=(720, 1280),
94+
zoom=1.5,
95+
pan=(0, 0),
96+
contrast=25.0,
97+
brightness=50.0,
98+
saturation=75.0,
99+
)
100+
),
101+
run=run,
102+
images_directory=Path(conf.name),
103+
robot_type="OT-3 Standard",
104+
)
105+
assert isinstance(response, FileResponse)

0 commit comments

Comments
 (0)