diff --git a/openscan_firmware/controllers/hardware/cameras/camera.py b/openscan_firmware/controllers/hardware/cameras/camera.py index 74d1941..9f45aab 100644 --- a/openscan_firmware/controllers/hardware/cameras/camera.py +++ b/openscan_firmware/controllers/hardware/cameras/camera.py @@ -121,6 +121,7 @@ def photo(self, image_format: str = "jpeg") -> PhotoData: """ handler = { "jpeg": self.capture_jpeg, + "grayscale_jpeg": self.capture_grayscale_jpeg, "dng": self.capture_dng, "rgb_array": self.capture_rgb_array, "yuv_array": self.capture_yuv_array, @@ -182,6 +183,15 @@ def capture_jpeg(self) -> PhotoData: """Capture an image and encode it to jpeg.""" raise NotImplementedError + def capture_grayscale_jpeg(self) -> PhotoData: + """Capture a grayscale JPEG. + + Converts to grayscale on the device before returning, reducing transfer + size significantly compared to a full-colour JPEG. Default implementation + raises NotImplementedError; override in subclasses that support it. + """ + raise NotImplementedError("This camera controller does not support grayscale_jpeg capture.") + def _create_camera_controller_instance(camera: Camera) -> 'CameraController': """Create a camera controller instance based on the camera type. diff --git a/openscan_firmware/controllers/hardware/cameras/picamera2.py b/openscan_firmware/controllers/hardware/cameras/picamera2.py index 9224191..f29462d 100644 --- a/openscan_firmware/controllers/hardware/cameras/picamera2.py +++ b/openscan_firmware/controllers/hardware/cameras/picamera2.py @@ -533,6 +533,27 @@ def capture_jpeg(self, optional_exif_data: dict = None) -> PhotoData: return self._create_artifact(jpeg_data, "jpeg", cam_metadata) + def capture_grayscale_jpeg(self) -> PhotoData: + """Capture a grayscale JPEG using the YUV config. + + Captures in YUV420 format and extracts the luma (Y) plane, producing a + grayscale JPEG without ever allocating colour data. Significantly reduces + transfer size for pipelines that only need luminance (e.g. photogrammetry). + + Returns: + PhotoData: Grayscale JPEG data. + """ + array, cam_metadata = self._capture_array(self.yuv_config) + # YUV420 planar layout: first (H) rows are the Y plane, next H//2 rows are U+V + h = array.shape[0] * 2 // 3 + y_plane = array[:h, :] + _, jpeg_buf = cv2.imencode( + '.jpg', y_plane, + [cv2.IMWRITE_JPEG_QUALITY, self.settings.jpeg_quality] + ) + return self._create_artifact(io.BytesIO(jpeg_buf.tobytes()), "jpeg", cam_metadata) + + def capture_dng(self) -> PhotoData: """Capture a dng. diff --git a/openscan_firmware/routers/next/cameras.py b/openscan_firmware/routers/next/cameras.py index f5db70a..4afae6a 100644 --- a/openscan_firmware/routers/next/cameras.py +++ b/openscan_firmware/routers/next/cameras.py @@ -131,18 +131,27 @@ async def generate(): @router.get("/{camera_name}/photo") -async def get_photo(camera_name: str): +async def get_photo( + camera_name: str, + grayscale: bool = Query( + default=False, + description="Return a grayscale JPEG. Conversion happens on the device, " + "reducing transfer size significantly. Recommended for photogrammetry pipelines.", + ), +): """Get a camera photo Args: camera_name: The name of the camera to get the photo from + grayscale: If true, return a grayscale JPEG (converted on-device) Returns: Response: A response containing the photo """ controller = get_camera_controller(camera_name) try: - photo = await controller.photo_async() + image_format = "grayscale_jpeg" if grayscale else "jpeg" + photo = await controller.photo_async(image_format) return Response(content=photo.data.getvalue(), media_type="image/jpeg") except Exception as e: return Response(status_code=500, content=str(e)) diff --git a/openscan_firmware/routers/v0_8/cameras.py b/openscan_firmware/routers/v0_8/cameras.py index f5db70a..4afae6a 100644 --- a/openscan_firmware/routers/v0_8/cameras.py +++ b/openscan_firmware/routers/v0_8/cameras.py @@ -131,18 +131,27 @@ async def generate(): @router.get("/{camera_name}/photo") -async def get_photo(camera_name: str): +async def get_photo( + camera_name: str, + grayscale: bool = Query( + default=False, + description="Return a grayscale JPEG. Conversion happens on the device, " + "reducing transfer size significantly. Recommended for photogrammetry pipelines.", + ), +): """Get a camera photo Args: camera_name: The name of the camera to get the photo from + grayscale: If true, return a grayscale JPEG (converted on-device) Returns: Response: A response containing the photo """ controller = get_camera_controller(camera_name) try: - photo = await controller.photo_async() + image_format = "grayscale_jpeg" if grayscale else "jpeg" + photo = await controller.photo_async(image_format) return Response(content=photo.data.getvalue(), media_type="image/jpeg") except Exception as e: return Response(status_code=500, content=str(e)) diff --git a/tests/controllers/hardware/picamera2/test_picamera2.py b/tests/controllers/hardware/picamera2/test_picamera2.py index 8b8bd00..1bb1c6f 100644 --- a/tests/controllers/hardware/picamera2/test_picamera2.py +++ b/tests/controllers/hardware/picamera2/test_picamera2.py @@ -50,6 +50,7 @@ def test_capture_jpg(): assert artifact.data is not None assert artifact.camera_metadata is not None + assert artifact.data.getvalue().startswith(b"\xff\xd8") def test_capture_dng(): start = time.time() @@ -86,6 +87,17 @@ def test_capture_yuv_array(): assert artifact.camera_metadata is not None +def test_capture_grayscale_jpeg(): + start = time.time() + artifact = camera_controller.capture_grayscale_jpeg() + print(f"Captured grayscale jpeg in {time.time() - start} seconds.") + + print(artifact.camera_metadata) + + assert artifact.data is not None + assert artifact.camera_metadata is not None + + def test_settings_change(mocker): spy_focus = mocker.spy(camera_controller, "_configure_focus") diff --git a/tests/routers/test_cameras_router.py b/tests/routers/test_cameras_router.py new file mode 100644 index 0000000..3c6b573 --- /dev/null +++ b/tests/routers/test_cameras_router.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import io +from unittest.mock import AsyncMock + +import pytest + +from openscan_firmware.config.camera import CameraSettings +from openscan_firmware.models.camera import CameraMetadata, PhotoData +import openscan_firmware.routers.next.cameras as cameras_next_module +import openscan_firmware.routers.v0_8.cameras as cameras_v0_8_module + + +def _make_photo_data(payload: bytes = b"jpeg-bytes") -> PhotoData: + return PhotoData( + data=io.BytesIO(payload), + format="jpeg", + camera_metadata=CameraMetadata( + camera_name="mock_camera", + camera_settings=CameraSettings(shutter=400), + raw_metadata={}, + ), + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("module", "grayscale", "expected_format"), + [ + (cameras_next_module, False, "jpeg"), + (cameras_next_module, True, "grayscale_jpeg"), + (cameras_v0_8_module, False, "jpeg"), + (cameras_v0_8_module, True, "grayscale_jpeg"), + ], +) +async def test_get_photo_selects_requested_format( + monkeypatch: pytest.MonkeyPatch, + module, + grayscale: bool, + expected_format: str, +): + controller = AsyncMock() + controller.photo_async.return_value = _make_photo_data() + + monkeypatch.setattr( + module, + "get_camera_controller", + lambda camera_name: controller, + raising=False, + ) + + response = await module.get_photo("mock_camera", grayscale=grayscale) + + controller.photo_async.assert_awaited_once_with(expected_format) + assert response.status_code == 200 + assert response.body == b"jpeg-bytes" + assert response.media_type == "image/jpeg" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("module", [cameras_next_module, cameras_v0_8_module]) +async def test_get_photo_returns_500_on_capture_error(monkeypatch: pytest.MonkeyPatch, module): + controller = AsyncMock() + controller.photo_async.side_effect = RuntimeError("camera exploded") + + monkeypatch.setattr( + module, + "get_camera_controller", + lambda camera_name: controller, + raising=False, + ) + + response = await module.get_photo("mock_camera", grayscale=True) + + controller.photo_async.assert_awaited_once_with("grayscale_jpeg") + assert response.status_code == 500 + assert response.body == b"camera exploded"