Skip to content

Commit

Permalink
Merge pull request #142 from sustrev/main
Browse files Browse the repository at this point in the history
E2E dicom and metadata updates
  • Loading branch information
marksgraham authored Jul 28, 2024
2 parents 576b9c6 + f966514 commit 936f06d
Show file tree
Hide file tree
Showing 7 changed files with 303 additions and 41 deletions.
54 changes: 47 additions & 7 deletions oct_converter/dicom/dicom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 = ""

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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. "
Expand Down Expand Up @@ -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.
Expand All @@ -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.")

Expand Down
30 changes: 25 additions & 5 deletions oct_converter/dicom/e2e_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions oct_converter/dicom/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions oct_converter/image_types/fundus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand All @@ -33,13 +35,15 @@ 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
self.patient_id = patient_id
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.
Expand Down
101 changes: 100 additions & 1 deletion oct_converter/readers/binary_structs/e2e_binary.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
from construct import (
Array,
Float32l,
Float64l,
Int8un,
Int16un,
Int32sn,
Int32un,
Int64un,
PaddedString,
Struct,
this,
)

# Mostly based on description of .e2e file format here:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"))
2 changes: 1 addition & 1 deletion oct_converter/readers/boct.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 936f06d

Please sign in to comment.