Start splitting ScanData into workflow specific data.

This commit is contained in:
Julian Stirling 2026-01-15 12:57:16 +00:00
parent 5ce74cad8a
commit b6343362b2
4 changed files with 249 additions and 161 deletions

View file

@ -8,7 +8,7 @@ import shutil
import threading import threading
import zipfile import zipfile
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Mapping, Optional from typing import Any, Mapping, Optional, Self
from pydantic import ( from pydantic import (
BaseModel, BaseModel,
@ -16,6 +16,7 @@ from pydantic import (
ValidationError, ValidationError,
field_serializer, field_serializer,
field_validator, field_validator,
model_validator,
) )
from openflexure_microscope_server.utilities import make_name_safe, requires_lock from openflexure_microscope_server.utilities import make_name_safe, requires_lock
@ -29,6 +30,7 @@ STITCH_REGEX = re.compile(r"stitched\.jpe?g$")
IMAGE_REGEX = re.compile(r"-?[0-9]+_-?[0-9]+\.jpe?g$") IMAGE_REGEX = re.compile(r"-?[0-9]+_-?[0-9]+\.jpe?g$")
SCAN_DATA_FILENAME = "scan_data.json" SCAN_DATA_FILENAME = "scan_data.json"
SCAN_DATA_SCHEMA_VERSION = 2
class NotEnoughFreeSpaceError(IOError): class NotEnoughFreeSpaceError(IOError):
@ -47,6 +49,56 @@ class ScanInfo(BaseModel):
dzi: Optional[str] dzi: Optional[str]
class StitchingData(BaseModel):
"""The data needed to stitch a scan."""
correlation_resize: float
"""The resize factor applied to images when the stitching program is correlating."""
overlap: float
"""The overlap between adjacent images as a fraction of the image size."""
def _coerce_lecacy_scan_data(data: dict) -> dict:
"""Coerce any scan data from before version 2 into the version 2 format."""
# Before the current version no schema_version was set
if "schema_version" in data:
return data
if "correlation_resize" and "overlap" in data:
correlation_resize = data.pop("correlation_resize")
# Note we don't pop overlap is a setting for the legacy workflow as well
# as a stitching setting.
# This is done because in future workflows the stitching overlap may be a
# directly set setting or something that is calculated from other settings.
overlap = data["overlap"]
data["stitching_settings"] = StitchingData(
correlation_resize=correlation_resize,
overlap=overlap,
)
else:
data["stitching_settings"] = None
# Add any legacy workflow settings that are found
legacy_keys = [
"overlap",
"max_dist",
"dx",
"dy",
"autofocus_dz",
"autofocus_on",
"skip_background",
]
workflow_settings = {}
for key in legacy_keys:
if key in data:
workflow_settings[key] = data.pop(key)
data["workflow"] = "Legacy"
data["workflow_settings"] = workflow_settings
return data
class ScanData(BaseModel): class ScanData(BaseModel):
"""Data about a scan to be saved to a JSON file in the directory. """Data about a scan to be saved to a JSON file in the directory.
@ -60,42 +112,20 @@ class ScanData(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
schema_version: int = SCAN_DATA_SCHEMA_VERSION
scan_name: str scan_name: str
"""The name of the scan i.e. scan_0001""" """The name of the scan i.e. scan_0001"""
starting_position: Mapping[str, int] starting_position: Mapping[str, int]
"""The starting position in dictionary format.""" """The starting position in dictionary format."""
overlap: float
"""The overlap between adjacent images as a fraction of the image size."""
max_dist: int
"""The maximum distance the scan could move (in steps) from the starting position."""
dx: int
"""The number of steps between adjacent images in x."""
dy: int
"""The number of steps between adjacent images in y."""
autofocus_dz: int
"""The z range used for autofocus (in steps)."""
autofocus_on: bool
"""Whether autofocus is on."""
start_time: datetime start_time: datetime
"""The time the scan started.""" """The time the scan started."""
skip_background: bool
"""Whether automatic background detection is on, skipping locations with no sample."""
stitch_automatically: bool stitch_automatically: bool
"""Whether the scan is set to automatically stitch when complete.""" """Whether the scan is set to automatically stitch when complete."""
correlation_resize: float
"""The resize factor applied to images when the stitching program is correlating."""
save_resolution: tuple[int, int] save_resolution: tuple[int, int]
"""The resolution that scan images are saved at.""" """The resolution that scan images are saved at."""
@ -114,6 +144,18 @@ class ScanData(BaseModel):
This should be set with ``set_final_data()`` to ensure duration is set. This should be set with ``set_final_data()`` to ensure duration is set.
""" """
workflow: str
"""The class name of the workflow Thing."""
workflow_settings: dict[str, Any]
"""The settings for this workflow."""
stitching_settings: Optional[StitchingData]
"""The data needed to stitch a scan.
Set to None for types of scan that cannot be stitched.
"""
def set_final_data(self, result: str) -> None: def set_final_data(self, result: str) -> None:
"""Set the final data for the scan, scan duration is automatically calculated. """Set the final data for the scan, scan duration is automatically calculated.
@ -122,6 +164,22 @@ class ScanData(BaseModel):
self.duration = datetime.now() - self.start_time self.duration = datetime.now() - self.start_time
self.scan_result = result self.scan_result = result
@model_validator(mode="before")
@classmethod
def coerce_legacy(cls, data: dict) -> dict:
"""Coerce any legacy data."""
return _coerce_lecacy_scan_data(data)
@model_validator(mode="after")
def validate_schema_version(self) -> Self:
"""Validate the schema version is as the current one."""
if self.schema_version != SCAN_DATA_SCHEMA_VERSION:
raise ValueError(
f"Unsupported schema version {self.schema_version}, "
f"expected {SCAN_DATA_SCHEMA_VERSION}"
)
return self
@field_validator("start_time", mode="before") @field_validator("start_time", mode="before")
@classmethod @classmethod
def parse_timestamp(cls, value: str | datetime) -> datetime: def parse_timestamp(cls, value: str | datetime) -> datetime:

View file

@ -2,12 +2,15 @@ from typing import Mapping, Optional
import labthings_fastapi as lt import labthings_fastapi as lt
from openflexure_microscope_server.scan_directories import StitchingData
from openflexure_microscope_server.scan_planners import ScanPlanner, SmartSpiral from openflexure_microscope_server.scan_planners import ScanPlanner, SmartSpiral
from openflexure_microscope_server.stitching import STITCHING_RESOLUTION
from openflexure_microscope_server.things.autofocus import AutofocusThing from openflexure_microscope_server.things.autofocus import AutofocusThing
from openflexure_microscope_server.things.background_detect import ( from openflexure_microscope_server.things.background_detect import (
BackgroundDetectAlgorithm, BackgroundDetectAlgorithm,
ChannelDeviationLUV, ChannelDeviationLUV,
) )
from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper
class ScanWorkflow(lt.Thing): class ScanWorkflow(lt.Thing):
@ -17,9 +20,26 @@ class ScanWorkflow(lt.Thing):
scan planning, aquisition routine. scan planning, aquisition routine.
""" """
_detector: Optional[BackgroundDetectAlgorithm | Mapping[str, BackgroundDetectAlgorithm]] = lt.thing_slot() # All workdlows must have at least one background detector and a set class for scan
# planning
_background_detector: Optional[
BackgroundDetectAlgorithm | Mapping[str, BackgroundDetectAlgorithm]
] = lt.thing_slot()
_planner_cls: type[ScanPlanner] _planner_cls: type[ScanPlanner]
# All workdlows set a save resolution
save_resolution: tuple[int, int] = lt.setting(default=(1640, 1232))
"""A tuple of the image resolution to capture."""
def check_before_start(self, scan_name: str) -> None:
"""Check before the scan starts. Throw an error if the scan shouldn't start.
The scan_name is passed to this function to enable workflows to validate the
scan name if needed.
"""
raise NotImplementedError(
"Each specific ScanWorkflow must implement a check_before_start."
)
@lt.property @lt.property
def ready(self) -> bool: def ready(self) -> bool:
@ -28,23 +48,141 @@ class ScanWorkflow(lt.Thing):
"Each specific ScanWorkflow must implement a ready property." "Each specific ScanWorkflow must implement a ready property."
) )
def all_settings(self) -> tuple[dict, Optional[StitchingData]]:
"""Return the scan settings and the stitching settings.
- The specific settings for this scan workflow are returned as a dict.
- Stitiching settings are returned either as a StitchingData object or None
is returned if it is not possible to stitch the scan.
"""
raise NotImplementedError(
"Each specific ScanWorkflow must implement a `all_settings`. "
"method."
)
def pre_scan_routine(self) -> None: def pre_scan_routine(self) -> None:
raise NotImplementedError( raise NotImplementedError(
"Each specific ScanWorkflow must implement a pre-scan routine." "Each specific ScanWorkflow must implement a pre-scan routine."
) )
class HistoScanWorkflow(ScanWorkflow): class HistoScanWorkflow(ScanWorkflow):
_detector: ChannelDeviationLUV = lt.thing_slot() # Thing Slots
_background_detector: ChannelDeviationLUV = lt.thing_slot()
_csm: CameraStageMapper = lt.thing_slot()
_planner_cls: type[ScanPlanner] = SmartSpiral _planner_cls: type[ScanPlanner] = SmartSpiral
_autofocus: AutofocusThing = lt.thing_slot() _autofocus: AutofocusThing = lt.thing_slot()
# Scan settings
skip_background: bool = lt.setting(default=True)
"""Whether to detect and skip empty fields of view.
This uses the settings from the ``BackgroundDetectThing``.
"""
autofocus_dz: int = lt.setting(default=1000) autofocus_dz: int = lt.setting(default=1000)
"""The z distance to perform an autofocus in steps.""" """The z distance to perform an autofocus in steps."""
max_range: int = lt.setting(default=45000)
"""The maximum distance in steps from the centre of the scan."""
overlap: float = lt.setting(default=0.45)
"""The fraction (0-1) that adjacent images should overlap in x or y."""
# noqa, scan_name is unused but is needed for equivalence with other workflows.
def check_before_start(self, scan_name: str) -> None: # noqa: ARG002
"""Before starting a scan, check that background and camera-stage-mapping are set.
Raise error if:
- background is to be skipped but is not set
- camera stage mapping is not set
Raise warning if not using background detect that scan will go on until max steps reached
"""
if self._csm.calibration_required:
raise RuntimeError("Camera Stage Mapping is not calibrated.")
if self.skip_background:
if not self.background_detector.ready:
raise RuntimeError(
"Background is not set: you need to calibrate background detection."
)
else:
self.logger.warning(
"This scan will run in a spiral from the starting point "
f"until you cancel it, or until it has moved by {self.max_range} steps "
"in every direction. Make sure you watch it run to stop it leaving "
"the area of interest, or (worse) leading the microscope's range "
"of motion."
)
@lt.property @lt.property
def ready(self) -> bool: def ready(self) -> bool:
"""Whether this scanworkflow is ready to start.""" """Whether this scanworkflow is ready to start."""
return self._detector.ready if self._csm.calibration_required:
return False
if not self.skip_background:
return True
return self._background_detector.ready
def all_settings(self) -> tuple[dict, StitchingData]:
stitching_settings = StitchingData(
overlap=self.overlap,
correlation_resize=STITCHING_RESOLUTION[0] / self.save_resolution[0],
)
dx, dy = self._calc_displacement_from_test_image(self.overlap)
self.logger.info(
f"Based on an overlap of {self.overlap}, the stage will make steps of "
f"{dx}, {dy}"
)
autofocus_dz = self.autofocus_dz
if autofocus_dz == 0:
self.logger.info("Running scan without autofocus")
elif autofocus_dz <= 200:
self.logger.warning(
f"Your autofocus range is {autofocus_dz} steps, which is too short to "
"attempt to focus. Running without autofocus"
)
autofocus_dz = 0
scan_settings = {
"overlap": self.overlap,
"max_dist": self.max_range,
"dx": dx,
"dy": dy,
"autofocus_dz": autofocus_dz,
"autofocus_on": bool(autofocus_dz),
"skip_background": self.skip_background,
}
return scan_settings, stitching_settings
def _calc_displacement_from_overlap(self, overlap: float) -> tuple[int, int]:
"""Take a test image and use camera stage mapping to calculate x and y displacement.
:param overlap: The desired overlap as a fraction of the image. i.e. 0.5 means
that each image should overlap its nearest neighbour by 50%.
:returns: (dx, dy) - the x and y displacements in steps
"""
csm_image_res = [int(i) for i in self._csm.image_resolution]
# Calculate displacements in image coordinates
dx_img = csm_image_res[1] * (1 - overlap)
dy_img = csm_image_res[0] * (1 - overlap)
x_move_stage = self._csm.convert_image_to_stage_coordinates(x=dx_img, y=0)
y_move_stage = self._csm.convert_image_to_stage_coordinates(x=0, y=dy_img)
# Assume no rotation or skew and take only the aligned axis of vector.
# Coerce to positive integer, but correct if x and y are flipped
if abs(x_move_stage["x"]) > abs(x_move_stage["y"]):
return x_move_stage["x"], y_move_stage["y"]
# If not use the other stage axes
return x_move_stage["y"], y_move_stage["x"]
def pre_scan_routine(self) -> None: def pre_scan_routine(self) -> None:
self._autofocus.looping_autofocus(dz=self.autofocus_dz, start="centre") self._autofocus.looping_autofocus(dz=self.autofocus_dz, start="centre")

View file

@ -179,11 +179,11 @@ class SmartScanThing(lt.Thing):
@lt.action @lt.action
def sample_scan(self, scan_name: str = "") -> None: def sample_scan(self, scan_name: str = "") -> None:
"""Move the stage to cover an area, taking images that can be tiled together. """Move the stage to cover an area, taking images.
The stage will move in a pattern that grows outwards from the starting point, Depending on the way the stage moves depends on the selected worflow.
stopping once it is surrounded by "background" (as detected by the If images overlap for a scan workdlow then the images can be stitched together
camera Thing) or reaches the "max_range" measured in steps. into a larger composite image.
""" """
got_lock = self._scan_lock.acquire(timeout=0.1) got_lock = self._scan_lock.acquire(timeout=0.1)
if not got_lock: if not got_lock:
@ -193,11 +193,13 @@ class SmartScanThing(lt.Thing):
# the presence of `scan_data` is used during error handling to # the presence of `scan_data` is used during error handling to
# determine whether the scan started. # determine whether the scan started.
self._scan_data = None self._scan_data = None
# probably make workflow a context manager with a lock?
workflow = self._workflow
try: try:
self._check_background_and_csm_set() workflow.check_before_start(scan_name)
self._ongoing_scan = self._scan_dir_manager.new_scan_dir(scan_name) self._ongoing_scan = self._scan_dir_manager.new_scan_dir(scan_name)
self._latest_scan_name = self.ongoing_scan.name self._latest_scan_name = self.ongoing_scan.name
self._run_scan() self._run_scan(workflow)
except Exception as e: except Exception as e:
# If _scan_data is set then scan started # If _scan_data is set then scan started
if self._scan_data is not None: if self._scan_data is not None:
@ -222,35 +224,6 @@ class SmartScanThing(lt.Thing):
# Remove any scan folders containing zero images. # Remove any scan folders containing zero images.
self.purge_empty_scans() self.purge_empty_scans()
@_scan_running
def _check_background_and_csm_set(self) -> None:
"""Before starting a scan, check that background and camera-stage-mapping are set.
Raise error if:
- background is to be skipped but is not set
- camera stage mapping is not set
Raise warning if not using background detect that scan will go on until max steps reached
"""
self._csm.assert_calibration()
if self.skip_background:
if (
self._cam.background_detector is None
or not self._cam.background_detector.ready
):
raise RuntimeError(
"Background is not set: you need to calibrate background detection."
)
else:
self.logger.warning(
"This scan will run in a spiral from the starting point "
f"until you cancel it, or until it has moved by {self.max_range} steps "
"in every direction. Make sure you watch it run to stop it leaving "
"the area of interest, or (worse) leading the microscope's range "
"of motion."
)
@_scan_running @_scan_running
def _move_to_next_point( def _move_to_next_point(
self, next_point: tuple[int, int], z_estimate: Optional[int] = None self, next_point: tuple[int, int], z_estimate: Optional[int] = None
@ -276,90 +249,26 @@ class SmartScanThing(lt.Thing):
return (next_point[0], next_point[1], z_estimate) return (next_point[0], next_point[1], z_estimate)
@_scan_running @_scan_running
def _calc_displacement_from_test_image(self, overlap: float) -> tuple[int, int]: def _collect_scan_data(self, workflow: ScanWorkflow) -> scan_directories.ScanData:
"""Take a test image and use camera stage mapping to calculate x and y displacement.
:param overlap: The desired overlap as a fraction of the image. i.e. 0.5 means
that each image should overlap its nearest neighbour by 50%.
:returns: (dx, dy) - the x and y displacements in steps
"""
if (
self._csm.image_resolution is None
or self._csm.image_to_stage_displacement_matrix is None
):
raise CSMUncalibratedError("Camera stage mapping is not calibrated")
test_image = self._cam.grab_as_array()
test_image_res = list(test_image.shape)
csm_image_res = [int(i) for i in self._csm.image_resolution]
# If current stream width is different to csm calibration width,
# perform the conversion here
res_ratio = csm_image_res[0] / test_image_res[0]
# get displacement matrix. note it is for (y, x) not (x, y) coordinates
csm_disp_matrix = np.array(self._csm.image_to_stage_displacement_matrix)
csm_disp_matrix *= res_ratio
# Calculate displacements in image coordinates
dx_img = test_image.shape[1] * (1 - overlap)
dy_img = test_image.shape[0] * (1 - overlap)
# Calculate displacements in steps as vectors using a dot product with the matrix
dx_vec = np.dot(np.array([0, dx_img]), csm_disp_matrix)
dy_vec = np.dot(np.array([dy_img, 0]), csm_disp_matrix)
# Assume no rotation or skew and take only the aligned axis of vector.
# Coerce to positive integer
dx = int(np.abs(dx_vec[0]))
dy = int(np.abs(dy_vec[1]))
return dx, dy
@_scan_running
def _collect_scan_data(self) -> scan_directories.ScanData:
"""Collect and return the data for this scan so it cannot be changed mid-scan.""" """Collect and return the data for this scan so it cannot be changed mid-scan."""
# Record starting position so it can be returned to at end of scan. # Record starting position so it can be returned to at end of scan.
starting_position = self._stage.position starting_position = self._stage.position
overlap = self.overlap
dx, dy = self._calc_displacement_from_test_image(overlap)
correlation_resize = stitching.STITCHING_RESOLUTION[0] / self.save_resolution[0]
self.logger.debug( workflow_settings, stitching_settings = workflow.all_settings()
f"Resizing images when correlating by a factor of {correlation_resize}"
)
self.logger.info( # If stitching settings is None then this workflow doesn't support stitching.
f"Based on an overlap of {overlap}, we will make steps of {dx}, {dy}" auto_stitch = self.stitch_automatically and stitching_settings is not None
)
autofocus_dz = self.autofocus_dz
if autofocus_dz == 0:
self.logger.info("Running scan without autofocus")
elif autofocus_dz <= 200:
self.logger.warning(
f"Your autofocus range is {autofocus_dz} steps, which is too short to "
"attempt to focus. Running without autofocus"
)
autofocus_dz = 0
# Fix scan parameters in case UI is updated during scan. # Fix scan parameters in case UI is updated during scan.
return scan_directories.ScanData( return scan_directories.ScanData(
scan_name=self.ongoing_scan.name, scan_name=self.ongoing_scan.name,
starting_position=starting_position, starting_position=starting_position,
overlap=overlap,
max_dist=self.max_range,
dx=dx,
dy=dy,
autofocus_dz=autofocus_dz,
autofocus_on=bool(autofocus_dz),
start_time=datetime.now(), start_time=datetime.now(),
skip_background=self.skip_background, stitch_automatically=auto_stitch,
stitch_automatically=self.stitch_automatically, save_resolution=workflow.save_resolution,
correlation_resize=correlation_resize, workflow=type(workflow).__name__,
save_resolution=self.save_resolution, workflow_settings=workflow_settings,
stitching_settings=stitching_settings,
) )
@_scan_running @_scan_running
@ -381,7 +290,7 @@ class SmartScanThing(lt.Thing):
self.preview_stitcher.start() self.preview_stitcher.start()
@_scan_running @_scan_running
def _run_scan(self) -> None: def _run_scan(self, workflow: ScanWorkflow) -> None:
"""Prepare and run the main scan, and perform final actions on completion. """Prepare and run the main scan, and perform final actions on completion.
The result (or exception) from the main scan loop determines whether the The result (or exception) from the main scan loop determines whether the
@ -389,10 +298,8 @@ class SmartScanThing(lt.Thing):
starting x,y,z position. starting x,y,z position.
""" """
try: try:
# probably make workflow a context manager with a lock?
workflow = self._workflow
self._cam.start_streaming(main_resolution=(3280, 2464)) self._cam.start_streaming(main_resolution=(3280, 2464))
self._scan_data = self._collect_scan_data() self._scan_data = self._collect_scan_data(workflow)
workflow.pre_scan_routine(self._scan_data) workflow.pre_scan_routine(self._scan_data)
self.ongoing_scan.save_scan_data(self._scan_data) self.ongoing_scan.save_scan_data(self._scan_data)
images_dir = self.ongoing_scan.images_dir images_dir = self.ongoing_scan.images_dir
@ -572,24 +479,9 @@ class SmartScanThing(lt.Thing):
raise HTTPException(404, "File not found") raise HTTPException(404, "File not found")
return FileResponse(preview_path) return FileResponse(preview_path)
save_resolution: tuple[int, int] = lt.setting(default=(1640, 1232))
"""A tuple of the image resolution to capture."""
max_range: int = lt.setting(default=45000)
"""The maximum distance in steps from the centre of the scan."""
stitch_tiff: bool = lt.setting(default=False) stitch_tiff: bool = lt.setting(default=False)
"""Whether or not to also produce a pyramidal tiff at the end of a scan.""" """Whether or not to also produce a pyramidal tiff at the end of a scan."""
skip_background: bool = lt.setting(default=True)
"""Whether to detect and skip empty fields of view.
This uses the settings from the ``BackgroundDetectThing``.
"""
overlap: float = lt.setting(default=0.45)
"""The fraction (0-1) that adjacent images should overlap in x or y."""
stitch_automatically: bool = lt.setting(default=True) stitch_automatically: bool = lt.setting(default=True)
"""Whether to run a final stitch at the end of a successful scan.""" """Whether to run a final stitch at the end of a successful scan."""

View file

@ -28,8 +28,8 @@ MOCK_END_TIME = datetime(
) )
def _fake_scan_data(**kwargs) -> ScanData: def _fake_legacy_scan_data(**kwargs) -> ScanData:
"""Make fake scan data, the start time is now. Final properties are not added. """Make fake legacy scan data, the start time is now. Final properties are not added.
:param **kwargs: Key word arguments can be used to override other values. :param **kwargs: Key word arguments can be used to override other values.
""" """
@ -55,7 +55,7 @@ def _fake_scan_data(**kwargs) -> ScanData:
def test_set_final_data(): def test_set_final_data():
"""Check that adding final data to a ScanData object works as expected.""" """Check that adding final data to a ScanData object works as expected."""
scan_data = _fake_scan_data() scan_data = _fake_legacy_scan_data()
assert scan_data.image_count == 0 assert scan_data.image_count == 0
assert scan_data.duration is None assert scan_data.duration is None
@ -76,7 +76,7 @@ def test_set_final_data():
def test_custom_serialisation(): def test_custom_serialisation():
"""Check that the custom serialisation in ScanData works as expected.""" """Check that the custom serialisation in ScanData works as expected."""
scan_data = _fake_scan_data() scan_data = _fake_legacy_scan_data()
# Serialise to string then load directly as json # Serialise to string then load directly as json
scan_data_dict = json.loads(scan_data.model_dump_json()) scan_data_dict = json.loads(scan_data.model_dump_json())
assert scan_data_dict["start_time"] == "2024-12-25_11:00:00" assert scan_data_dict["start_time"] == "2024-12-25_11:00:00"
@ -96,7 +96,7 @@ def test_custom_serialisation():
def test_round_trip_not_finalised(): def test_round_trip_not_finalised():
"""Check that ScanData without final data can be serialised and deserialised.""" """Check that ScanData without final data can be serialised and deserialised."""
scan_data = _fake_scan_data() scan_data = _fake_legacy_scan_data()
scan_data_dict = json.loads(scan_data.model_dump_json()) scan_data_dict = json.loads(scan_data.model_dump_json())
scan_data_reloaded = ScanData(**scan_data_dict) scan_data_reloaded = ScanData(**scan_data_dict)
@ -108,7 +108,7 @@ def test_round_trip_not_finalised():
def test_round_trip_finalised(): def test_round_trip_finalised():
"""Check that finalised ScanData can be serialised and deserialised.""" """Check that finalised ScanData can be serialised and deserialised."""
scan_data = _fake_scan_data() scan_data = _fake_legacy_scan_data()
# Finalise the data. # Finalise the data.
scan_data.image_count += 123 scan_data.image_count += 123
scan_data.set_final_data(result="Success") scan_data.set_final_data(result="Success")