Merge branch 'metadata-testing' into 'v3'

Metadata testing

Closes #576 and #575

See merge request openflexure/openflexure-microscope-server!519
This commit is contained in:
Julian Stirling 2026-03-04 17:19:02 +00:00
commit 2c2a52cb97
7 changed files with 251 additions and 26 deletions

Binary file not shown.

View file

@ -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().thing_state.
"""
return {"camera": self.__class__.__name__}
def downsample(factor: int, image: np.ndarray) -> np.ndarray: def downsample(factor: int, image: np.ndarray) -> np.ndarray:

View file

@ -718,6 +718,11 @@ class StreamingPiCamera2(BaseCamera):
copy_from=self.default_tuning, copy_from=self.default_tuning,
) )
@lt.property
def gamma_correction(self) -> list[int]:
"""Return the gamma correction curve from the tuning file."""
return tf_utils.get_gamma_curve(self.tuning)
@lt.action @lt.action
def set_static_green_equalisation(self, offset: int = 65535) -> None: def set_static_green_equalisation(self, offset: int = 65535) -> None:
"""Set the green equalisation to a static value. """Set the green equalisation to a static value.
@ -927,4 +932,10 @@ 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,
"gamma_correction": self.gamma_correction,
}
return state return state

View file

@ -186,6 +186,27 @@ def get_lst(tuning: dict) -> LensShadingModel:
) )
def get_gamma_curve(tuning: dict) -> list[int]:
"""Return the gamma curve from the rpi.contrast section of the tuning file.
Returns a list where each two elements are the input and output level.
Defaults to [] if gamma curve is missing.
"""
contrast = find_tuning_algo(tuning, "rpi.contrast")
return contrast.get("gamma_curve", [])
def set_gamma_curve(tuning: dict, gamma_curve: list[int]) -> dict:
"""Set the gamma curve in the rpi.contrast section of the tuning file.
Returns a new tuning dictionary with the updated gamma curve.
"""
output_tuning = deepcopy(tuning)
contrast = find_tuning_algo(output_tuning, "rpi.contrast")
contrast["gamma_curve"] = gamma_curve
return output_tuning
def get_colour_gains_from_lst(tuning: dict) -> tuple[float, float]: def get_colour_gains_from_lst(tuning: dict) -> tuple[float, float]:
"""Get the colour gains that are needed from the lens shading tables. """Get the colour gains that are needed from the lens shading tables.

View file

@ -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)

View file

@ -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.
@ -122,6 +98,7 @@ def test_thing_description_equivalence(mock_picam_thing):
picamera_extra_props = { picamera_extra_props = {
"mjpeg_bitrate", "mjpeg_bitrate",
"colour_correction_matrix", "colour_correction_matrix",
"gamma_correction",
"lens_shading_tables", "lens_shading_tables",
"sensor_resolution", "sensor_resolution",
"capture_metadata", "capture_metadata",

View file

@ -0,0 +1,188 @@
"""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 import (
picamera_tuning_file_utils as tf_utils,
)
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
camera.tuning = tf_utils.set_gamma_curve(camera.tuning, [0, 0, 5, 50])
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,
"gamma_correction": [0, 0, 5, 50],
}
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
camera.tuning = tf_utils.set_gamma_curve(camera.tuning, [0, 0, 5, 50])
# 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"
# gamma_correction keys are cast to strings
assert user_comment["tuning"] == {
"exposure_time": 1234,
"colour_gains": [1.1, 1.2],
"analogue_gain": 2.5,
"gamma_correction": [0, 0, 5, 50],
}