openflexure-microscope-server/tests/unit_tests/test_metadata.py
2026-03-03 14:45:32 +00:00

188 lines
6.2 KiB
Python

"""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],
}