Skip to content

Commit

Permalink
Add InstrumentLog to unify instrument log file loading (#1955)
Browse files Browse the repository at this point in the history
  • Loading branch information
JackEAllen authored Oct 20, 2023
2 parents c8d8ddb + 929506b commit cc0289d
Show file tree
Hide file tree
Showing 14 changed files with 330 additions and 37 deletions.
1 change: 1 addition & 0 deletions docs/release_notes/next/dev-1955-instrument-log
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#1955 : Add InstrumentLog as new log reader
10 changes: 5 additions & 5 deletions mantidimaging/core/data/imagestack.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from mantidimaging.core.utility.leak_tracker import leak_tracker

if TYPE_CHECKING:
from mantidimaging.core.utility.imat_log_file_parser import IMATLogFile
from mantidimaging.core.io.instrument_log import InstrumentLog


class ImageStack:
Expand Down Expand Up @@ -55,7 +55,7 @@ def __init__(self,
self._is_sinograms = sinograms

self._proj180deg: Optional[ImageStack] = None
self._log_file: Optional[IMATLogFile] = None
self._log_file: InstrumentLog | None = None
self._projection_angles: Optional[ProjectionAngles] = None

if name is None:
Expand Down Expand Up @@ -286,11 +286,11 @@ def is_sinograms(self) -> bool:
return self._is_sinograms

@property
def log_file(self):
def log_file(self) -> InstrumentLog | None:
return self._log_file

@log_file.setter
def log_file(self, value: IMATLogFile):
def log_file(self, value: InstrumentLog | None) -> None:
if value is not None:
self.metadata[const.LOG_FILE] = str(value.source_file)
elif value is None:
Expand All @@ -311,7 +311,7 @@ def real_projection_angles(self) -> Optional[ProjectionAngles]:
"""
if self._projection_angles is not None:
return self._projection_angles
if self._log_file is not None:
if self._log_file is not None and self._log_file.has_projection_angles():
return self._log_file.projection_angles()
return None

Expand Down
11 changes: 6 additions & 5 deletions mantidimaging/core/data/test/fake_logfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
from __future__ import annotations
from pathlib import Path

from mantidimaging.core.utility.imat_log_file_parser import CSVLogParser, IMATLogFile, TextLogParser
from mantidimaging.core.io.instrument_log import InstrumentLog
from mantidimaging.core.utility.imat_log_file_parser import CSVLogParser, TextLogParser


def generate_txt_logfile() -> IMATLogFile:
def generate_txt_logfile() -> InstrumentLog:
data = [
TextLogParser.EXPECTED_HEADER_FOR_IMAT_TEXT_LOG_FILE, # checked if exists, but skipped
"", # skipped when parsing
Expand All @@ -22,10 +23,10 @@ def generate_txt_logfile() -> IMATLogFile:
"Sun Feb 10 00:26:29 2019 Projection: 8 angle: 2.5216 Monitor 3 before: 5786535 Monitor 3 after: 5929002", # noqa: E501
"Sun Feb 10 00:27:02 2019 Projection: 9 angle: 2.8368 Monitor 3 before: 5938142 Monitor 3 after: 6078866", # noqa: E501
]
return IMATLogFile(data, Path("/tmp/fake"))
return InstrumentLog(data, Path("/tmp/fake.txt"))


def generate_csv_logfile() -> IMATLogFile:
def generate_csv_logfile() -> InstrumentLog:
data = [
CSVLogParser.EXPECTED_HEADER_FOR_IMAT_CSV_LOG_FILE,
"Sun Feb 10 00:22:04 2019,Projection,0,angle: 0.0,Monitor 3 before: 4577907,Monitor 3 after: 4720271",
Expand All @@ -39,4 +40,4 @@ def generate_csv_logfile() -> IMATLogFile:
"Sun Feb 10 00:26:29 2019,Projection,8,angle: 2.5216,Monitor 3 before: 5786535,Monitor 3 after: 5929002",
"Sun Feb 10 00:27:02 2019,Projection,9,angle: 2.8368,Monitor 3 before: 5938142,Monitor 3 after: 6078866",
]
return IMATLogFile(data, Path("/tmp/fake"))
return InstrumentLog(data, Path("/tmp/fake.csv"))
4 changes: 2 additions & 2 deletions mantidimaging/core/data/test/image_stack_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pathlib import Path
from unittest import mock

from mantidimaging.core.io.instrument_log import InstrumentLog
from mantidimaging.core.utility.data_containers import ProjectionAngles
import unittest

Expand All @@ -15,7 +16,6 @@
from mantidimaging.core.data.test.fake_logfile import generate_csv_logfile, generate_txt_logfile
from mantidimaging.core.operations.crop_coords import CropCoordinatesFilter
from mantidimaging.core.operation_history import const
from mantidimaging.core.utility.imat_log_file_parser import IMATLogFile
from mantidimaging.core.utility.sensible_roi import SensibleROI
from mantidimaging.test_helpers.unit_test_helper import generate_images

Expand Down Expand Up @@ -73,7 +73,7 @@ def test_loading_metadata_preserves_existing(self):
def test_loading_metadata_preserves_existing_log(self):
json_file = io.StringIO('{"pixel_size": 30.0, "log_file": "/old/logfile"}')
mock_log_path = Path("/aaa/bbb")
mock_log_file = mock.create_autospec(IMATLogFile, source_file=mock_log_path)
mock_log_file = mock.create_autospec(InstrumentLog, source_file=mock_log_path)

imgs = ImageStack(np.asarray([1]))
self.assertEqual({}, imgs.metadata)
Expand Down
2 changes: 1 addition & 1 deletion mantidimaging/core/io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
from __future__ import annotations

from . import ( # noqa: F401
saver, loader, utility)
saver, loader, utility, instrument_log, instrument_log_implmentations)
134 changes: 134 additions & 0 deletions mantidimaging/core/io/instrument_log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Copyright (C) 2023 ISIS Rutherford Appleton Laboratory UKRI
# SPDX - License - Identifier: GPL-3.0-or-later
from __future__ import annotations

from abc import ABC, abstractmethod
from enum import Enum, auto
from pathlib import Path
from typing import ClassVar, Type

import numpy as np

from mantidimaging.core.utility.data_containers import ProjectionAngles, Counts


class LogColumn(Enum):
TIMESTAMP = auto()
IMAGE_TYPE_IMAGE_COUNTER = auto()
PROJECTION_NUMBER = auto()
PROJECTION_ANGLE = auto()
COUNTS_BEFORE = auto()
COUNTS_AFTER = auto()
TIME_OF_FLIGHT = auto()
SPECTRUM_COUNTS = auto()


LogDataType = dict[LogColumn, list[float | int]]


class NoParserFound(RuntimeError):
pass


class InvalidLog(RuntimeError):
pass


class InstrumentLogParser(ABC):
"""
Base class for parsers
"""

def __init__(self, lines: list[str]):
self.lines = lines

def __init_subclass__(subcls) -> None:
"""Automatically register subclasses"""
InstrumentLog.register_parser(subcls)

@classmethod
@abstractmethod
def match(cls, lines: list[str], filename: str) -> bool:
"""Check if the name and content of the file is likely to be readable by this parser."""
...

@abstractmethod
def parse(self) -> LogDataType:
"""Parse the log file"""
...

def cleaned_lines(self) -> list[str]:
return [line for line in self.lines if line.strip() != ""]


class InstrumentLog:
"""Multiformat instrument log reader
New parsers can be implemented by subclassing InstrumentLogParser
"""
parsers: ClassVar[list[Type[InstrumentLogParser]]] = []

parser: Type[InstrumentLogParser]
data: LogDataType
length: int

def __init__(self, lines: list[str], source_file: Path):
self.lines = lines
self.source_file = source_file

self._find_parser()
self.parse()

def _find_parser(self) -> None:
for parser in self.parsers:
if parser.match(self.lines, self.source_file.name):
self.parser = parser
return
raise NoParserFound

def parse(self) -> None:
self.data = self.parser(self.lines).parse()

lengths = [len(val) for val in self.data.values()]
if len(set(lengths)) != 1:
raise InvalidLog(f"Mismatch in column lengths: {lengths}")
self.length = lengths[0]

@classmethod
def register_parser(cls, parser: Type[InstrumentLogParser]) -> None:
cls.parsers.append(parser)

def get_column(self, key: LogColumn) -> list[float]:
return self.data[key]

def projection_numbers(self) -> np.array:
return np.array(self.get_column(LogColumn.PROJECTION_NUMBER), dtype=np.uint32)

def has_projection_angles(self) -> bool:
return LogColumn.PROJECTION_ANGLE in self.data

def projection_angles(self) -> ProjectionAngles:
angles = np.array(self.get_column(LogColumn.PROJECTION_ANGLE), dtype=np.float64)
return ProjectionAngles(np.deg2rad(angles))

def raise_if_angle_missing(self, image_filenames: list[str]) -> None:
image_numbers = [ifile[ifile.rfind("_") + 1:] for ifile in image_filenames]

if self.length != len(image_numbers):
RuntimeError(f"Log size mismatch. Found {self.length} log entries,"
f"but {len(image_numbers)} images")

if LogColumn.PROJECTION_NUMBER in self.data:
for projection_num, image_num in zip(self.projection_numbers(), image_numbers, strict=True):
if str(projection_num) not in image_num:
raise RuntimeError(f"Mismatching angle for projection {projection_num} "
f"was going to be used for image file {image_num}")

def counts(self) -> Counts:
if not (LogColumn.COUNTS_BEFORE in self.data and LogColumn.COUNTS_AFTER in self.data):
raise ValueError("Log does not have counts")

counts_before = np.array(self.get_column(LogColumn.COUNTS_BEFORE))
counts_after = np.array(self.get_column(LogColumn.COUNTS_AFTER))

return Counts(counts_after - counts_before)
94 changes: 94 additions & 0 deletions mantidimaging/core/io/instrument_log_implmentations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Copyright (C) 2023 ISIS Rutherford Appleton Laboratory UKRI
# SPDX - License - Identifier: GPL-3.0-or-later
from __future__ import annotations

import csv
import locale
from datetime import datetime
from pathlib import Path

from mantidimaging.core.io.instrument_log import (InstrumentLogParser, LogColumn, LogDataType)
from mantidimaging.core.utility.imat_log_file_parser import IMATLogFile, IMATLogColumn


class LegacySpectraLogParser(InstrumentLogParser):
"""
Parser for spectra files without a header
Tab separated columns of Time of flight, Counts
"""
delimiter = '\t'

@classmethod
def match(cls, lines: list[str], filename: str) -> bool:
if not filename.lower().endswith("spectra.txt"):
return False
for line in lines[:2]:
if not len(line.split(cls.delimiter)) == 2:
return False
return True

def parse(self) -> LogDataType:
data: LogDataType = {LogColumn.TIME_OF_FLIGHT: [], LogColumn.SPECTRUM_COUNTS: []}
for row in csv.reader(self.cleaned_lines(), delimiter=self.delimiter):
data[LogColumn.TIME_OF_FLIGHT].append(float(row[0]))
data[LogColumn.SPECTRUM_COUNTS].append(int(row[1]))
return data


class LegacyIMATLogFile(InstrumentLogParser):
"""Wrap existing IMATLogFile class"""

@classmethod
def match(cls, lines: list[str], filename: str) -> bool:
if filename.lower()[-4:] not in [".txt", ".csv"]:
return False

has_header = False
for line in lines:
if not has_header and cls._has_imat_header(line):
has_header = True
elif has_header and cls._has_imat_data_line(line):
return True

return False

def parse(self) -> LogDataType:
imat_log_file = IMATLogFile(self.lines, Path(""))
data: LogDataType = {}
data[LogColumn.TIMESTAMP] = imat_log_file._data[IMATLogColumn.TIMESTAMP]
data[LogColumn.PROJECTION_NUMBER] = imat_log_file._data[IMATLogColumn.PROJECTION_NUMBER]
data[LogColumn.PROJECTION_ANGLE] = imat_log_file._data[IMATLogColumn.PROJECTION_ANGLE]
data[LogColumn.COUNTS_BEFORE] = imat_log_file._data[IMATLogColumn.COUNTS_BEFORE]
data[LogColumn.COUNTS_AFTER] = imat_log_file._data[IMATLogColumn.COUNTS_AFTER]
return data

@staticmethod
def read_imat_date(time_stamp: str) -> datetime:
lc = locale.setlocale(locale.LC_TIME)
try:
locale.setlocale(locale.LC_TIME, "C")
return datetime.strptime(time_stamp, "%c")
finally:
locale.setlocale(locale.LC_TIME, lc)

@staticmethod
def _has_imat_header(line: str):
HEADERS = [
"TIME STAMP,IMAGE TYPE,IMAGE COUNTER,COUNTS BM3 before image,COUNTS BM3 after image",
"TIME STAMP IMAGE TYPE IMAGE COUNTER COUNTS BM3 before image COUNTS BM3 after image",
]
return line.strip() in HEADERS

@classmethod
def _has_imat_data_line(cls, line: str):
try:
_ = cls.read_imat_date(line[:24])
except ValueError:
return False

if not ("Projection" in line or "Radiography" in line):
return False

return True
20 changes: 11 additions & 9 deletions mantidimaging/core/io/loader/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
import astropy.io.fits as fits
from tifffile import tifffile

from mantidimaging.core.io.instrument_log import InstrumentLog
from mantidimaging.core.io.loader import img_loader
from mantidimaging.core.io.utility import find_first_file_that_is_possibly_a_sample
from mantidimaging.core.utility.data_containers import Indices, FILE_TYPES, ProjectionAngles
from mantidimaging.core.utility.imat_log_file_parser import IMATLogFile
from mantidimaging.core.io.filenames import FilenameGroup

if TYPE_CHECKING:
Expand Down Expand Up @@ -91,9 +91,9 @@ def read_image_dimensions(file_path: Path) -> Tuple[int, int]:
return img.shape


def load_log(log_file: Path) -> IMATLogFile:
def load_log(log_file: Path) -> InstrumentLog:
with open(log_file, 'r') as f:
return IMATLogFile(f.readlines(), log_file)
return InstrumentLog(f.readlines(), log_file)


def load_stack_from_group(group: FilenameGroup, progress: Optional[Progress] = None) -> ImageStack:
Expand Down Expand Up @@ -137,17 +137,19 @@ def load(filename_group: FilenameGroup,

if log_file is not None:
log_data = load_log(log_file)
angles = log_data.projection_angles().value
angle_order = np.argsort(angles)
angles = angles[angle_order]
file_names = [file_names[i] for i in angle_order]
if log_data.has_projection_angles():
angles = log_data.projection_angles().value
angle_order = np.argsort(angles)
angles = angles[angle_order]
file_names = [file_names[i] for i in angle_order]

image_stack = img_loader.execute(load_func, file_names, in_format, dtype, indices, progress)

if log_file is not None:
image_stack.log_file = log_data
angles = angles[indices[0]:indices[1]:indices[2]] if indices else angles
image_stack.set_projection_angles(ProjectionAngles(angles))
if log_data.has_projection_angles():
angles = angles[indices[0]:indices[1]:indices[2]] if indices else angles
image_stack.set_projection_angles(ProjectionAngles(angles))

# Search for and load metadata file
metadata_filename = filename_group.metadata_path
Expand Down
Loading

0 comments on commit cc0289d

Please sign in to comment.