Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

E2E dicom and metadata updates #142

Merged
merged 10 commits into from
Jul 28, 2024
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
Loading