Camera metadata includes subclass name, and test
This commit is contained in:
parent
ac9852a7ad
commit
5019884175
5 changed files with 215 additions and 26 deletions
|
|
@ -643,8 +643,12 @@ class BaseCamera(lt.Thing):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def thing_state(self) -> Mapping[str, Any]:
|
def thing_state(self) -> Mapping[str, Any]:
|
||||||
"""Empty metadata dict for subclasses to populate."""
|
"""Return camera-specific metadata.
|
||||||
return {}
|
|
||||||
|
By default, this just adds the subclass name as the camera type.
|
||||||
|
Subclasses can extend by overriding this property and calling `super()`.
|
||||||
|
"""
|
||||||
|
return {"camera": self.__class__.__name__}
|
||||||
|
|
||||||
|
|
||||||
def downsample(factor: int, image: np.ndarray) -> np.ndarray:
|
def downsample(factor: int, image: np.ndarray) -> np.ndarray:
|
||||||
|
|
|
||||||
|
|
@ -927,4 +927,9 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
"""Update generic camera metadata with Picamera-specific data."""
|
"""Update generic camera metadata with Picamera-specific data."""
|
||||||
state = dict(super().thing_state)
|
state = dict(super().thing_state)
|
||||||
state["camera_board"] = self._camera_board
|
state["camera_board"] = self._camera_board
|
||||||
|
state["tuning"] = {
|
||||||
|
"exposure_time": self.exposure_time,
|
||||||
|
"colour_gains": self.colour_gains,
|
||||||
|
"analogue_gain": self.analogue_gain,
|
||||||
|
}
|
||||||
return state
|
return state
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ from typing import Optional
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from labthings_fastapi.testing import create_thing_without_server
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def check_side_effect(caplog):
|
def check_side_effect(caplog):
|
||||||
|
|
@ -61,3 +63,25 @@ def check_side_effect(caplog):
|
||||||
assert re.match(match, record.message) is not None
|
assert re.match(match, record.message) is not None
|
||||||
|
|
||||||
return _checker
|
return _checker
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_picam_thing(mocker):
|
||||||
|
"""Import PiCamera without hardware well enough to get a ThingDescription."""
|
||||||
|
dummy_cam = mocker.Mock()
|
||||||
|
mock_picamera2 = mocker.MagicMock()
|
||||||
|
mock_picamera2.return_value.__enter__.return_value = dummy_cam
|
||||||
|
mock_picamera2.return_value.__exit__.return_value = None
|
||||||
|
|
||||||
|
mocker.patch.dict(
|
||||||
|
"sys.modules",
|
||||||
|
{
|
||||||
|
"picamera2": mock_picamera2,
|
||||||
|
"picamera2.encoders": mocker.Mock(),
|
||||||
|
"picamera2.outputs": mocker.Mock(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
from openflexure_microscope_server.things.camera.picamera import StreamingPiCamera2
|
||||||
|
|
||||||
|
return create_thing_without_server(StreamingPiCamera2)
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ Tests for specific camera hardware are in the hardware_specific_tests directory.
|
||||||
on camera functionality using the simulation camera are in "test_camera".
|
on camera functionality using the simulation camera are in "test_camera".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from labthings_fastapi.testing import create_thing_without_server
|
from labthings_fastapi.testing import create_thing_without_server
|
||||||
|
|
||||||
from openflexure_microscope_server.things.camera import BaseCamera
|
from openflexure_microscope_server.things.camera import BaseCamera
|
||||||
|
|
@ -13,28 +11,6 @@ from openflexure_microscope_server.things.camera.opencv import OpenCVCamera
|
||||||
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
|
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_picam_thing(mocker):
|
|
||||||
"""Import PiCamera without hardware well enough to get a ThingDescription."""
|
|
||||||
dummy_cam = mocker.Mock()
|
|
||||||
mock_picamera2 = mocker.MagicMock()
|
|
||||||
mock_picamera2.return_value.__enter__.return_value = dummy_cam
|
|
||||||
mock_picamera2.return_value.__exit__.return_value = None
|
|
||||||
|
|
||||||
mocker.patch.dict(
|
|
||||||
"sys.modules",
|
|
||||||
{
|
|
||||||
"picamera2": mock_picamera2,
|
|
||||||
"picamera2.encoders": mocker.Mock(),
|
|
||||||
"picamera2.outputs": mocker.Mock(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
from openflexure_microscope_server.things.camera.picamera import StreamingPiCamera2
|
|
||||||
|
|
||||||
return create_thing_without_server(StreamingPiCamera2)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_clean_camera_description(camera_thing):
|
def _get_clean_camera_description(camera_thing):
|
||||||
"""Return actions and properties for a camera Thing, separating those exposed to the UI.
|
"""Return actions and properties for a camera Thing, separating those exposed to the UI.
|
||||||
|
|
||||||
|
|
|
||||||
180
tests/unit_tests/test_metadata.py
Normal file
180
tests/unit_tests/test_metadata.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""Tests that captures have the expected metadata."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import piexif
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
import labthings_fastapi as lt
|
||||||
|
from labthings_fastapi.testing import create_thing_without_server
|
||||||
|
|
||||||
|
from openflexure_microscope_server.things.background_detect import ChannelDeviationLUV
|
||||||
|
from openflexure_microscope_server.things.camera import BaseCamera
|
||||||
|
from openflexure_microscope_server.things.camera.simulation import SimulatedCamera
|
||||||
|
from openflexure_microscope_server.things.stage.dummy import DummyStage
|
||||||
|
|
||||||
|
from ..shared_utils.lt_test_utils import LabThingsTestEnv
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_jpeg(tmp_path: Path) -> Path:
|
||||||
|
"""Create a temporary blank JPEG image."""
|
||||||
|
jpeg_path = tmp_path / "test.jpg"
|
||||||
|
image = Image.new("RGB", (10, 10), color="white")
|
||||||
|
image.save(jpeg_path, "jpeg")
|
||||||
|
return jpeg_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_metadata_to_capture(temp_jpeg):
|
||||||
|
"""Use a BaseCamera to add metadata to a tmp capture and test fields."""
|
||||||
|
base_cam = create_thing_without_server(BaseCamera)
|
||||||
|
metadata = {
|
||||||
|
"Dummy1": 1,
|
||||||
|
"Dummy2": "two",
|
||||||
|
}
|
||||||
|
|
||||||
|
current_time = datetime.now()
|
||||||
|
capture_metadata = {
|
||||||
|
"capture_time": current_time.timestamp(),
|
||||||
|
"timezone": current_time.astimezone().utcoffset(),
|
||||||
|
"make": "OpenFlexure",
|
||||||
|
"model": "OpenFlexure Microscope",
|
||||||
|
"things_states": metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
base_cam._add_metadata_to_capture(str(temp_jpeg), capture_metadata)
|
||||||
|
|
||||||
|
# Reload EXIF
|
||||||
|
exif_dict = piexif.load(str(temp_jpeg))
|
||||||
|
|
||||||
|
# Assert UserComment
|
||||||
|
user_comment_raw = exif_dict["Exif"][piexif.ExifIFD.UserComment]
|
||||||
|
assert json.loads(user_comment_raw.decode("utf-8")) == metadata
|
||||||
|
|
||||||
|
# Assert timestamps
|
||||||
|
expected_time_str = datetime.fromtimestamp(
|
||||||
|
capture_metadata["capture_time"]
|
||||||
|
).strftime("%Y:%m:%d %H:%M:%S")
|
||||||
|
|
||||||
|
assert (
|
||||||
|
exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal].decode() == expected_time_str
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
exif_dict["Exif"][piexif.ExifIFD.DateTimeDigitized].decode()
|
||||||
|
== expected_time_str
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exif_dict["0th"][piexif.ImageIFD.DateTime].decode() == expected_time_str
|
||||||
|
|
||||||
|
# Assert timezone offset
|
||||||
|
offset_original = exif_dict["Exif"][piexif.ExifIFD.OffsetTimeOriginal].decode()
|
||||||
|
offset_digitized = exif_dict["Exif"][piexif.ExifIFD.OffsetTimeDigitized].decode()
|
||||||
|
|
||||||
|
tz = capture_metadata["timezone"]
|
||||||
|
hours = int(tz.total_seconds() // 3600)
|
||||||
|
minutes = int((abs(tz.total_seconds()) % 3600) // 60)
|
||||||
|
sign = "+" if hours >= 0 else "-"
|
||||||
|
expected_offset = f"{sign}{abs(hours):02d}:{minutes:02d}"
|
||||||
|
|
||||||
|
assert offset_original == expected_offset
|
||||||
|
assert offset_digitized == expected_offset
|
||||||
|
|
||||||
|
# Assert Make and Model
|
||||||
|
assert exif_dict["0th"][piexif.ImageIFD.Make].decode() == capture_metadata["make"]
|
||||||
|
assert exif_dict["0th"][piexif.ImageIFD.Model].decode() == capture_metadata["model"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_env() -> LabThingsTestEnv:
|
||||||
|
"""Yield a test environment with the Simulated Camera and Dummy Stage."""
|
||||||
|
thing_conf = {
|
||||||
|
"camera": SimulatedCamera,
|
||||||
|
"stage": DummyStage,
|
||||||
|
"bg_channel_deviations_luv": ChannelDeviationLUV,
|
||||||
|
}
|
||||||
|
with LabThingsTestEnv(things=thing_conf) as env:
|
||||||
|
yield env
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def camera(test_env) -> lt.Thing:
|
||||||
|
"""Return the SimulatedCamera Thing set up in the test environment."""
|
||||||
|
return test_env.get_thing_by_type(SimulatedCamera)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_metadata_to_simulated_capture(camera, temp_jpeg):
|
||||||
|
"""Test metadata from simulated camera includes expected camera_board: simulator."""
|
||||||
|
metadata = camera.thing_state
|
||||||
|
current_time = datetime.now()
|
||||||
|
capture_metadata = {
|
||||||
|
"capture_time": current_time.timestamp(),
|
||||||
|
"timezone": current_time.astimezone().utcoffset(),
|
||||||
|
"make": "OpenFlexure",
|
||||||
|
"model": "OpenFlexure Microscope",
|
||||||
|
"things_states": metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
camera._add_metadata_to_capture(str(temp_jpeg), capture_metadata)
|
||||||
|
|
||||||
|
# Reload EXIF
|
||||||
|
exif_dict = piexif.load(str(temp_jpeg))
|
||||||
|
|
||||||
|
# Assert UserComment
|
||||||
|
user_comment_raw = exif_dict["Exif"][piexif.ExifIFD.UserComment]
|
||||||
|
assert json.loads(user_comment_raw.decode("utf-8")) == metadata
|
||||||
|
assert json.loads(user_comment_raw.decode("utf-8"))["camera"] == "SimulatedCamera"
|
||||||
|
|
||||||
|
|
||||||
|
def test_picamera_adds_metadata(mock_picam_thing):
|
||||||
|
"""Test PiCamera adds camera_board and tuning metadata."""
|
||||||
|
camera = mock_picam_thing
|
||||||
|
|
||||||
|
# Inject controlled values
|
||||||
|
camera._camera_board = "imx219"
|
||||||
|
camera.exposure_time = 1234
|
||||||
|
camera.colour_gains = (1.1, 1.2)
|
||||||
|
camera.analogue_gain = 2.5
|
||||||
|
|
||||||
|
state = camera.thing_state
|
||||||
|
|
||||||
|
# Assert metadata has been set
|
||||||
|
assert state["camera_board"] == "imx219"
|
||||||
|
assert state["tuning"] == {
|
||||||
|
"exposure_time": 1234,
|
||||||
|
"colour_gains": (1.1, 1.2),
|
||||||
|
"analogue_gain": 2.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_picamera_metadata_written_to_exif(mock_picam_thing, temp_jpeg, mocker):
|
||||||
|
"""Ensure PiCamera metadata is written into JPEG EXIF."""
|
||||||
|
camera = mock_picam_thing
|
||||||
|
|
||||||
|
camera._camera_board = "imx219"
|
||||||
|
camera.exposure_time = 1234
|
||||||
|
camera.colour_gains = (1.1, 1.2)
|
||||||
|
camera.analogue_gain = 2.5
|
||||||
|
|
||||||
|
# Mock the server interface to return the camera's own state
|
||||||
|
mock_interface = mocker.Mock()
|
||||||
|
mock_interface.get_thing_states.return_value = camera.thing_state
|
||||||
|
camera._thing_server_interface = mock_interface
|
||||||
|
|
||||||
|
capture_metadata = camera._capture_metadata()
|
||||||
|
|
||||||
|
camera._add_metadata_to_capture(str(temp_jpeg), capture_metadata)
|
||||||
|
|
||||||
|
exif_dict = piexif.load(str(temp_jpeg))
|
||||||
|
user_comment = json.loads(exif_dict["Exif"][piexif.ExifIFD.UserComment].decode())
|
||||||
|
|
||||||
|
assert user_comment["camera"] == "StreamingPiCamera2"
|
||||||
|
assert user_comment["camera_board"] == "imx219"
|
||||||
|
assert user_comment["tuning"] == {
|
||||||
|
"exposure_time": 1234,
|
||||||
|
"colour_gains": [1.1, 1.2],
|
||||||
|
"analogue_gain": 2.5,
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue