Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions openscan_firmware/controllers/hardware/cameras/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions openscan_firmware/controllers/hardware/cameras/picamera2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
13 changes: 11 additions & 2 deletions openscan_firmware/routers/next/cameras.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
13 changes: 11 additions & 2 deletions openscan_firmware/routers/v0_8/cameras.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
12 changes: 12 additions & 0 deletions tests/controllers/hardware/picamera2/test_picamera2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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")

Expand Down
77 changes: 77 additions & 0 deletions tests/routers/test_cameras_router.py
Original file line number Diff line number Diff line change
@@ -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"