diff --git a/oct_converter/dicom/dicom.py b/oct_converter/dicom/dicom.py index 0e9e017..16cf6b5 100644 --- a/oct_converter/dicom/dicom.py +++ b/oct_converter/dicom/dicom.py @@ -122,6 +122,8 @@ def populate_opt_series(ds: Dataset, meta: DicomMetadata) -> Dataset: ds.StudyInstanceUID = generate_uid() ds.SeriesInstanceUID = generate_uid() ds.Laterality = meta.series_info.laterality + ds.ProtocolName = meta.series_info.protocol + ds.SeriesDescription = meta.series_info.description # Ophthalmic Tomography Series PS3.3 C.8.17.6 ds.Modality = "OPT" ds.SeriesNumber = int(meta.series_info.series_id) @@ -201,7 +203,9 @@ def write_opt_dicom( ds.ImageType = ["DERIVED", "SECONDARY"] ds.SamplesPerPixel = 1 if meta.series_info.acquisition_date: - ds.AcquisitionDateTime = meta.series_info.acquisition_date.strftime("%Y%m%d%H%M%S.%f") + ds.AcquisitionDateTime = meta.series_info.acquisition_date.strftime( + "%Y%m%d%H%M%S.%f" + ) else: ds.AcquisitionDateTime = "" @@ -269,10 +273,19 @@ def write_fundus_dicom( ds = populate_opt_series(ds, meta) ds.Modality = "OP" ds = populate_ocular_region(ds, meta) - ds = opt_shared_functional_groups(ds, meta) + + ds.PixelSpacing = meta.image_geometry.pixel_spacing + ds.ImageOrientationPatient = meta.image_geometry.image_orientation # OPT Image Module PS3.3 C.8.17.7 ds.ImageType = ["DERIVED", "SECONDARY"] + enface_to_type = { + "IR": "RED", + "FA": "BLUE", + "ICGA": "GREEN", + } + if ds.ProtocolName in enface_to_type: + ds.ImageType.append(enface_to_type.get(ds.ProtocolName)) ds.SamplesPerPixel = 1 ds.AcquisitionDateTime = ( meta.series_info.acquisition_date.strftime("%Y%m%d%H%M%S.%f") @@ -323,10 +336,19 @@ def write_color_fundus_dicom( ds = populate_opt_series(ds, meta) ds.Modality = "OP" ds = populate_ocular_region(ds, meta) - ds = opt_shared_functional_groups(ds, meta) + + ds.PixelSpacing = meta.image_geometry.pixel_spacing + ds.ImageOrientationPatient = meta.image_geometry.image_orientation # OPT Image Module PS3.3 C.8.17.7 ds.ImageType = ["DERIVED", "SECONDARY"] + enface_to_type = { + "IR": "RED", + "FA": "BLUE", + "ICGA": "GREEN", + } + if ds.ProtocolName in enface_to_type: + ds.ImageType.append(enface_to_type.get(ds.ProtocolName)) ds.SamplesPerPixel = 1 ds.AcquisitionDateTime = ( meta.series_info.acquisition_date.strftime("%Y%m%d%H%M%S.%f") @@ -368,6 +390,8 @@ def create_dicom_from_oct( interlaced: bool = False, diskbuffered: bool = False, extract_scan_repeats: bool = False, + scalex: float = 0.01, + slice_thickness: float = 0.05, ) -> list: """Creates a DICOM file with the data parsed from the input file. @@ -382,6 +406,8 @@ def create_dicom_from_oct( interlaced: If .img file, allows for setting interlaced diskbuffered: If Bioptigen .OCT, allows for setting diskbuffered extract_scan_repeats: If .e2e file, allows for extracting all scan repeats + scalex: If .e2e file, allows for manually setting x scale (in mm) + slice_thickness: If .e2e file, allows for manually setting z scale (in mm) Returns: list: list of Path(s) to DICOM file @@ -410,7 +436,13 @@ def create_dicom_from_oct( # if BOCT raises, treat as POCT files = create_dicom_from_poct(input_file, output_dir) elif file_suffix == "e2e": - files = create_dicom_from_e2e(input_file, output_dir, extract_scan_repeats) + files = create_dicom_from_e2e( + input_file, + output_dir, + extract_scan_repeats, + scalex, + slice_thickness, + ) else: raise TypeError( f"DICOM conversion for {file_suffix} is not supported. " @@ -471,7 +503,11 @@ def create_dicom_from_boct( def create_dicom_from_e2e( - input_file: str, output_dir: str = None, extract_scan_repeats: bool = False + input_file: str, + output_dir: str = None, + extract_scan_repeats: bool = False, + scalex: float = 0.01, + slice_thickness: float = 0.05, ) -> list: """Creates DICOM file(s) with the data parsed from the input file. @@ -480,13 +516,17 @@ def create_dicom_from_e2e( input_file: E2E file with OCT data output_dir: Output directory extract_scan_repeats: If True, will extract all scan repeats + scalex: Manually set scale of x axis + slice_thickness: Manually set scale of z axis Returns: list: List of path(s) to DICOM file(s) """ e2e = E2E(input_file) - oct_volumes = e2e.read_oct_volume() - fundus_images = e2e.read_fundus_image(extract_scan_repeats=extract_scan_repeats) + oct_volumes = e2e.read_oct_volume(scalex=scalex, slice_thickness=slice_thickness) + fundus_images = e2e.read_fundus_image( + extract_scan_repeats=extract_scan_repeats, scalex=scalex + ) if len(oct_volumes) == 0 and len(fundus_images) == 0: raise ValueError("No OCT volumes or fundus images found in e2e input file.") diff --git a/oct_converter/dicom/e2e_meta.py b/oct_converter/dicom/e2e_meta.py index f4fba75..d3dccbb 100644 --- a/oct_converter/dicom/e2e_meta.py +++ b/oct_converter/dicom/e2e_meta.py @@ -37,13 +37,14 @@ def e2e_patient_meta(meta: dict) -> PatientMeta: return patient -def e2e_series_meta(id, laterality, acquisition_date) -> SeriesMeta: +def e2e_series_meta(id, laterality, acquisition_date, metadata) -> SeriesMeta: """Creates SeriesMeta from info parsed by the E2E reader Args: id: Equivalent to oct.volume_id or fundus.image_id laterality: R or L, from image.laterality acquisition_date: Scan date for OCT, or None for fundus + metadata: Additional metadata Returns: SeriesMeta: Series metadata populated by oct """ @@ -56,6 +57,16 @@ def e2e_series_meta(id, laterality, acquisition_date) -> SeriesMeta: series.laterality = laterality series.acquisition_date = acquisition_date series.opt_anatomy = OPTAnatomyStructure.Retina + if metadata.get("examined_structure", {}).get(id): + structure = metadata["examined_structure"][id] + try: + series.opt_anatomy = getattr(OPTAnatomyStructure, structure) + except AttributeError: + series.opt_anatomy = OPTAnatomyStructure.Unspecified + if metadata.get("enface_modality", {}).get(id): + series.protocol = metadata["enface_modality"][id] + if metadata.get("scan_pattern", {}).get(id): + series.description = metadata["scan_pattern"][id] return series @@ -88,7 +99,8 @@ def e2e_image_geom(pixel_spacing: list) -> ImageGeometry: """ image_geom = ImageGeometry() image_geom.pixel_spacing = [pixel_spacing[1], pixel_spacing[0]] - image_geom.slice_thickness = pixel_spacing[2] + if len(pixel_spacing) == 3: + image_geom.slice_thickness = pixel_spacing[2] image_geom.image_orientation = [1, 0, 0, 0, 1, 0] return image_geom @@ -135,11 +147,19 @@ def e2e_dicom_metadata( meta.oct_image_params = e2e_image_params() if type(image) == OCTVolumeWithMetaData: meta.series_info = e2e_series_meta( - image.volume_id, image.laterality, image.acquisition_date + image.volume_id, + image.laterality, + image.acquisition_date, + image.metadata, ) meta.image_geometry = e2e_image_geom(image.pixel_spacing) else: # type(image) == FundusImageWithMetaData - meta.series_info = e2e_series_meta(image.image_id, image.laterality, None) - meta.image_geometry = e2e_image_geom([1, 1, 1]) + meta.series_info = e2e_series_meta( + image.image_id, + image.laterality, + None, + image.metadata, + ) + meta.image_geometry = e2e_image_geom(image.pixel_spacing) return meta diff --git a/oct_converter/dicom/metadata.py b/oct_converter/dicom/metadata.py index 6b5df11..abd249c 100644 --- a/oct_converter/dicom/metadata.py +++ b/oct_converter/dicom/metadata.py @@ -96,6 +96,9 @@ class SeriesMeta: acquisition_date: t.Optional[datetime.datetime] = None # Anatomy opt_anatomy: OPTAnatomyStructure = OPTAnatomyStructure.Unspecified + # Scan + protocol: str = "" + description: str = "" @dataclasses.dataclass diff --git a/oct_converter/image_types/fundus.py b/oct_converter/image_types/fundus.py index 4e6bbb8..2b87a68 100644 --- a/oct_converter/image_types/fundus.py +++ b/oct_converter/image_types/fundus.py @@ -23,6 +23,8 @@ class FundusImageWithMetaData(object): patient_id: patient ID. image_id: image ID. DOB: patient date of birth. + metadata: all metadata parsed from the original file. + pixel_spacing: [x, y] pixel spacing in mm """ def __init__( @@ -33,6 +35,7 @@ def __init__( image_id: str | None = None, patient_dob: str | None = None, metadata: dict | None = None, + pixel_spacing: list[float] | None = None, ) -> None: self.image = image self.laterality = laterality @@ -40,6 +43,7 @@ def __init__( self.image_id = image_id self.DOB = patient_dob self.metadata = metadata + self.pixel_spacing = pixel_spacing def save(self, filepath: str | Path) -> None: """Saves fundus image. diff --git a/oct_converter/readers/binary_structs/e2e_binary.py b/oct_converter/readers/binary_structs/e2e_binary.py index fb0e48f..63fb7c2 100644 --- a/oct_converter/readers/binary_structs/e2e_binary.py +++ b/oct_converter/readers/binary_structs/e2e_binary.py @@ -1,6 +1,7 @@ from construct import ( Array, Float32l, + Float64l, Int8un, Int16un, Int32sn, @@ -8,6 +9,7 @@ Int64un, PaddedString, Struct, + this, ) # Mostly based on description of .e2e file format here: @@ -77,7 +79,9 @@ "patient_id" / PaddedString(25, "ascii"), ) lat_structure = Struct( - "unknown" / Array(14, Int8un), "laterality" / Int8un, "unknown2" / Int8un + "unknown" / Array(14, Int8un), + "laterality" / PaddedString(1, "ascii"), + "unknown2" / Int8un, ) contour_structure = Struct( "unknown0" / Int32un, @@ -114,3 +118,98 @@ "numAve" / Int32un, "imgQuality" / Float32l, ) + +# Chunk 7: Eye Data (libE2E) +eye_data = Struct( + "eyeSide" / PaddedString(1, "ascii"), + "iop_mmHg" / Float64l, + "refraction_dpt" / Float64l, + "c_curve_mm" / Float64l, + "vfieldMean" / Float64l, + "vfieldVar" / Float64l, + "cylinder_dpt" / Float64l, + "axis_deg" / Float64l, + "correctiveLens" / Int16un, + "pupilSize_mm" / Float64l, +) + +# 9001 Device Name +# Files examined have n_strings=3, string_size=256, +# text=["Heidelberg Retina Angiograph", "HRA", ""] +device_name = Struct( + "n_strings" / Int32un, + "string_size" / Int32un, + "text" / Array(this.n_strings, PaddedString(this.string_size, "u16")), +) + +# 9005 Examined Structure +# Files examined have n_strings=1, string_size=256, +# text=["Retina"] +examined_structure = Struct( + "n_strings" / Int32un, + "string_size" / Int32un, + "text" / Array(this.n_strings, PaddedString(this.string_size, "u16")), +) + +# 9006 Scan Pattern +# Files examined have n_strings=2, string_size=256, +# and scan patterns including "OCT Art Volume", "Images", "OCT B-SCAN", +# "3D Volume", "OCT Star Scan" +scan_pattern = Struct( + "n_strings" / Int32un, + "string_size" / Int32un, + "text" / Array(this.n_strings, PaddedString(this.string_size, "u16")), +) + +# 9007 Enface Modality +# Files examined have n_strings=2, string_size=256, +# and modalities including ["Infra-Red", "IR"], +# ["Fluroescein Angiography", "FA"], ["ICG Angiography", "ICGA"] +enface_modality = Struct( + "n_strings" / Int32un, + "string_size" / Int32un, + "text" / Array(this.n_strings, PaddedString(this.string_size, "u16")), +) + +# 9008 OCT Modality +# Files examined have n_strings=2, string_size=256, text=["OCT", "OCT"] +oct_modality = Struct( + "n_strings" / Int32un, + "string_size" / Int32un, + "text" / Array(this.n_strings, PaddedString(this.string_size, "u16")), +) + +# 10025 Localizer +# From eyepy; "transform" is described as "Parameters of affine transformation" +localizer = Struct( + "unknown" / Array(6, Float32l), + "windate" / Int32un, + "transform" / Array(6, Float32l), +) + +# 3 seems to indicate the start of the chunk pattern +# Examined files seem to have a mostly-regular pattern of 3, 2, ..., 5, 39 +# Both chunks 3 and 5 seem to include laterality info +pre_data = Struct( + "unknown" / Int32un, + "laterality" / PaddedString(1, "ascii"), + # There's more here that I'm unsure of. + # There seems to be an "ART" in this chunk. +) + +# 39 has some time zone data +time_data = Struct( + "unknown" / Array(46, Int32un), + "timezone1" / PaddedString(66, "u16"), + "unknown2" / Array(9, Int16un), + "timezone2" / PaddedString(66, "u16"), + # There's more in this chunk (possibly datetimes, given tz) + # and the chunk size varies. +) + +# 52, 54, 1000, 1001 seem to be UIDs with padded strings +# 1000 may be StudyInstanceUID +uid_data = Struct("uid" / PaddedString(64, "ascii")) + +# 1007 padded string with a brand name +unknown_data = Struct("unknown" / PaddedString(64, "ascii")) diff --git a/oct_converter/readers/boct.py b/oct_converter/readers/boct.py index 1b45ded..8c7b479 100644 --- a/oct_converter/readers/boct.py +++ b/oct_converter/readers/boct.py @@ -8,7 +8,7 @@ import h5py import numpy as np -from construct import Struct, StringError +from construct import StringError, Struct from numpy.typing import NDArray from oct_converter.exceptions import InvalidOCTReaderError diff --git a/oct_converter/readers/e2e.py b/oct_converter/readers/e2e.py index 45296dd..8a1e68e 100644 --- a/oct_converter/readers/e2e.py +++ b/oct_converter/readers/e2e.py @@ -63,12 +63,17 @@ def __init__(self, filepath: str | Path) -> None: current = directory_chunk.prev def read_oct_volume( - self, legacy_intensity_transform: bool = False + self, + legacy_intensity_transform: bool = False, + scalex: float = 0.01, + slice_thickness: float = 0.05, ) -> list[OCTVolumeWithMetaData]: """Reads OCT data. Args: - legacy_intensity_transform: if True, use intensity transform used in v<=0.5.7. Defaults to False. + legacy_intensity_transform: if True, use intensity transform used in v<=0.5.7. Defaults to False. + scalex: Manually set scale of x axis + slice_thickness: Manually set scale of z axis Returns: A list of OCTVolumeWithMetaData. @@ -160,19 +165,25 @@ def _make_lut(): self.acquisition_date = utc_time_string if self.pixel_spacing is None: # scaley found, x and z not yet found in file - # but taken from E2E reader settings - self.pixel_spacing = [0.011484, bscan_metadata.scaley, 0.244673] - - elif chunk.type == 11: # laterality data - raw = f.read(20) + self.pixel_spacing = [ + scalex, + bscan_metadata.scaley, + slice_thickness, + ] + + elif chunk.type == 3: # scan preamble data + raw = f.read(chunk.size) try: - laterality_data = e2e_binary.lat_structure.parse(raw) - if laterality_data.laterality == 82: - laterality = "R" - elif laterality_data.laterality == 76: - laterality = "L" + pre_data = e2e_binary.pre_data.parse(raw) + if pre_data.laterality in ["R", "L"]: + laterality = pre_data.laterality except Exception: laterality = None + volume_string = "{}_{}_{}".format( + chunk.patient_db_id, chunk.study_id, chunk.series_id + ) + if laterality and (volume_string not in laterality_dict): + laterality_dict[volume_string] = laterality elif chunk.type == 10019: # contour data raw = f.read(16) @@ -252,9 +263,6 @@ def _make_lut(): volume_array_dict_additional[volume_string] = [ image ] - # here assumes laterality stored in chunk before the image itself - if laterality and volume_string not in laterality_dict: - laterality_dict[volume_string] = laterality contour_data = {} for volume_id, contours in contour_dict.items(): @@ -301,12 +309,15 @@ def _make_lut(): return oct_volumes def read_fundus_image( - self, extract_scan_repeats: bool = False + self, + extract_scan_repeats: bool = False, + scalex: float = 0.01, ) -> list[FundusImageWithMetaData]: """Reads fundus data. Args: extract_scan_repeats: if True, extract all fundus images, including those that appear repeated. Defaults to False. + scalex: Manually set scale of x axis Returns: A sequence of FundusImageWithMetaData. @@ -349,18 +360,21 @@ def read_fundus_image( except Exception: pass - if chunk.type == 11: # laterality data - raw = f.read(20) + elif chunk.type == 3: # scan preamble data + raw = f.read(chunk.size) try: - laterality_data = e2e_binary.lat_structure.parse(raw) - if laterality_data.laterality == 82: - laterality = "R" - elif laterality_data.laterality == 76: - laterality = "L" + pre_data = e2e_binary.pre_data.parse(raw) + if pre_data.laterality in ["R", "L"]: + laterality = pre_data.laterality except Exception: laterality = None + volume_string = "{}_{}_{}".format( + chunk.patient_db_id, chunk.study_id, chunk.series_id + ) + if laterality and (volume_string not in laterality_dict): + laterality_dict[volume_string] = laterality - if chunk.type == 1073741824: # image data + elif chunk.type == 1073741824: # image data raw = f.read(20) image_data = e2e_binary.image_structure.parse(raw) count = image_data.height * image_data.width @@ -386,8 +400,6 @@ def read_fundus_image( is_in_keys = False image_array_dict[image_string] = image - # here assumes laterality stored in chunk before the image itself - laterality_dict[image_string] = laterality # Read metadata to attach to FundusImageWithMetaData metadata = self.read_all_metadata() @@ -403,6 +415,7 @@ def read_fundus_image( if key in laterality_dict.keys() else None, metadata=metadata, + pixel_spacing=[scalex, scalex], ) ) @@ -433,6 +446,16 @@ def _convert_to_dict(container): metadata["laterality_data"] = [] metadata["contour_data"] = [] metadata["fundus_data"] = [] + metadata["device_data"] = [] + metadata["examined_structure"] = {} + metadata["scan_pattern"] = {} + metadata["enface_modality"] = {} + metadata["oct_modality"] = {} + metadata["localizer"] = [] + metadata["eye_data"] = [] + metadata["uid_data"] = [] + metadata["time_data"] = [] + metadata["additional_device_data"] = [] with open(self.filepath, "rb") as f: # get all subdirectories @@ -455,6 +478,10 @@ def _convert_to_dict(container): raw = f.read(60) chunk = e2e_binary.chunk_structure.parse(raw) + image_string = "{}_{}_{}".format( + chunk.patient_db_id, chunk.study_id, chunk.series_id + ) + if chunk.type == 9: # patient data raw = f.read(127) try: @@ -468,7 +495,7 @@ def _convert_to_dict(container): bscan_metadata = e2e_binary.bscan_metadata.parse(raw) metadata["bscan_data"].append(_convert_to_dict(bscan_metadata)) - if chunk.type == 1073741824: # fundus data + elif chunk.type == 1073741824: # fundus data raw = f.read(20) fundus_data = e2e_binary.image_structure.parse(raw) metadata["fundus_data"].append(_convert_to_dict(fundus_data)) @@ -490,6 +517,75 @@ def _convert_to_dict(container): image_data = e2e_binary.image_structure.parse(raw) metadata["image_data"].append(_convert_to_dict(image_data)) + elif chunk.type == 9001: # device data ("Heidelberg Retina Angiograph") + raw = f.read(chunk.size) + device_data = e2e_binary.device_name.parse(raw) + metadata["device_data"].append(_convert_to_dict(device_data)) + + elif chunk.type == 9005: # examined structure ("Retina") + raw = f.read(chunk.size) + structure_data = e2e_binary.examined_structure.parse(raw) + if image_string not in metadata["examined_structure"]: + metadata["examined_structure"][ + image_string + ] = structure_data.text[0] + + elif chunk.type == 9006: # scan pattern + raw = f.read(chunk.size) + scan_pattern = e2e_binary.scan_pattern.parse(raw) + if image_string not in metadata["scan_pattern"]: + metadata["scan_pattern"][image_string] = scan_pattern.text[0] + + elif chunk.type == 9007: # enface_modality (i.e. IR, FA, ICGA) + raw = f.read(chunk.size) + enface = e2e_binary.enface_modality.parse(raw) + if image_string not in metadata["enface_modality"]: + metadata["enface_modality"][image_string] = enface.text[1] + + elif chunk.type == 9008: + raw = f.read(chunk.size) + oct_modality = e2e_binary.oct_modality.parse(raw) + if image_string not in metadata["oct_modality"]: + metadata["oct_modality"][image_string] = oct_modality.text[0] + + elif chunk.type == 10025: + raw = f.read(chunk.size) + localizer = e2e_binary.localizer.parse(raw) + metadata["localizer"].append(_convert_to_dict(localizer)) + + elif chunk.type == 7: # eye data + raw = f.read(chunk.size) + eye_data = e2e_binary.eye_data.parse(raw) + metadata["eye_data"].append(_convert_to_dict(eye_data)) + + elif chunk.type == 39: # time zone, possibly timestamps + raw = f.read(chunk.size) + time_data = e2e_binary.time_data.parse(raw) + metadata["time_data"].append(_convert_to_dict(time_data)) + + elif chunk.type in [52, 54, 1000, 1001]: # various UIDs + raw = f.read(chunk.size) + uid_data = e2e_binary.uid_data.parse(raw) + metadata["uid_data"].append( + {chunk.type: _convert_to_dict(uid_data)} + ) + + # Chunks 1005, 1006, and 1007 seem to contain strings of device data, + # including some servicers and distributors and other entities, + # but not always in the same order. + elif chunk.type in [1005, 1006]: + raw = f.read(chunk.size) + metadata["additional_device_data"].append( + {chunk.type: raw.decode()} + ) + + elif chunk.type == 1007: + raw = f.read(chunk.size) + unknown_data = e2e_binary.unknown_data.parse(raw) + metadata["additional_device_data"].append( + {chunk.type: _convert_to_dict(unknown_data)} + ) + return metadata def read_custom_float(self, bytes: str) -> float: