Extracting ECG data from DICOM files

The ecgprocess.py module wraps pydicom to standardise and extract patient information tables, as well as lead-specific waveforms and median beats.

[1]:
import os
import tempfile
import numpy as np
import pandas as pd
import ecgprocess.process_dicoms as pdicom
from ecgprocess.process_dicoms import (
    ECGDICOMReader,
    ECGDICOMTable,

)
from ecgprocess.example_data.examples import (
    list_data_paths,
)

# Example file
dicom_file_path = list_data_paths()['example_dicom_2']

The ECGDICOMReader dataclass

[2]:
ECG_DCM = ECGDICOMReader(retain_raw=False)
dicom_record = ECG_DCM(path=dicom_file_path)

Participant information

[3]:
# patient information
pd.Series(dicom_record.GeneralInfo)
[3]:
SOPinstanceUID          1.3.6.1.4.1.40744.65.2218354498692802781165553...
SERIESinstanceUID       1.3.6.1.4.1.40744.65.2534925851648250593315477...
STUDYinstanceUID        1.3.6.1.4.1.40744.65.1513822871218815858359656...
PatientID                                                           66954
StudyDate                                                      2006-02-01
                                              ...
T Offset UNIT                                                         NaN
PR Interval                                                           0.0
PR Interval UNIT                                              millisecond
UnformattedTextValue
Duration                                                             10.0
Length: 75, dtype: object

Waveforms and Median beats

[4]:
dicom_record.Waveforms
[4]:
{'I (Einthoven)': array([-105., -105., -105., ...,  -75.,  -80.,  -85.]),
 'II': array([-185., -185., -185., ..., -215., -220., -220.]),
 'III': array([ -75.,  -75.,  -75., ..., -140., -140., -135.]),
 'aVR': array([145., 145., 145., ..., 145., 150., 155.]),
 'aVL': array([-10., -10., -10., ...,  25.,  25.,  20.]),
 'aVF': array([-130., -130., -130., ..., -180., -185., -180.]),
 'V1': array([65., 65., 65., ..., 40., 45., 45.]),
 'V2': array([-175., -175., -175., ..., -250., -245., -240.]),
 'V3': array([ -55.,  -55.,  -55., ..., -155., -150., -145.]),
 'V4': array([-210., -210., -210., ..., -125., -125., -125.]),
 'V5': array([-145., -145., -145., ..., -165., -165., -165.]),
 'V6': array([-125., -125., -125., ..., -190., -185., -185.])}
[5]:
dicom_record.MedianWaveforms['II'][0:20]
[5]:
array([20., 15., 10., 10., 10., 10.,  5.,  0.,  0.,  0.,  0.,  5.,  5.,
        5.,  5.,  5.,  0.,  0.,  0.,  0.])

Custom behaviour

The class object has a number of attributes (dictionaries) which can be used to extract more (or less) data from pydicom instance.

[6]:
# extract `any comments` - which is rather uninformative in this case: `nan`.

# add the comments name to the METADATA dict
# here the value should map to the pydicom attribute name
# and the key to the new name we want to use internally
ECG_DCM.METADATA['Image Comment'] = 'ImageComments'
dicom_record_new = ECG_DCM(path=dicom_file_path)
dicom_record_new.GeneralInfo['Image Comment']
[6]:
nan

Exclusively extract metadata

Some dicom files will only have metadata. These can also be processed ECGDICOMReader.

[7]:
_, metadata, _  = ECG_DCM.get_metadata(path=dicom_file_path)
print(pd.Series(metadata))
SOPinstanceUID           1.3.6.1.4.1.40744.65.2218354498692802781165553...
SERIESinstanceUID        1.3.6.1.4.1.40744.65.2534925851648250593315477...
STUDYinstanceUID         1.3.6.1.4.1.40744.65.1513822871218815858359656...
PatientID                                                            66954
StudyDate                                                         20060201
StudyTime                                                           102403
AccessionNumber                                                 ST20060201
PatientBirthDate
PatientName                                                (6, 6, 9, 5, 4)
PatientSex
StudyDescription                                                      DT4H
AcquisitionDateTime                                         20060201102403
AcquisitionTimeZone                                                  +0100
Manufacturer                                                            GE
ManufacturerModelName                                                MAC55
SoftwareVersions                                                       NaN
DataExportedBy                                                    AUMC^EVA
Image Comment                                                          NaN
dtype: object

ECGDICOMTable

A class to extract data from multiple records and map these to tabular data.

We will first simply initilise the class and differentiate between valid and invalid files.

[8]:

dicom_path_list = [ list_data_paths()['example_dicom_1'], list_data_paths()['example_dicom_2'], ] ecgreader = ECGDICOMReader() ecgtable = ECGDICOMTable(ecgreader, path_list=dicom_path_list) res = ecgtable(verbose=True) print('Failed paths: {0}\nCurated paths: {1}.'.format( None if len(res.FailedPathList) == 0 else res.FailedPathList, res.CuratedPathList))
Failed paths: None
Curated paths: ['/home/amand/google_drive/Research/ECGProcess/ecgprocess/example_data/example_datasets/example-DICOM1.dcm', '/home/amand/google_drive/Research/ECGProcess/ecgprocess/example_data/example_datasets/example-DICOM2.dcm'].

Process the two files

Our first try at this will raise an error because the headers do not match.

[9]:
try:
    res.get_table()
except Exception as e:
    print(f"\n\nAn error occurred: {e}")
Processing path: /home/amand/google_drive/Research/ECGProcess/ecgprocess/example_data/example_datasets/example-DICOM1.dcm.
Processing path: /home/amand/google_drive/Research/ECGProcess/ecgprocess/example_data/example_datasets/example-DICOM2.dcm.


An error occurred: "The dictionaries contain distinct keys. The last set of valid keys was ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6'], compared to ['I (Einthoven)', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6']."

Next we will solve this by providing a dictionary mapping the old name to the new name. These dictionaries can be expanded for multiple potential names and ignored if there are no matches. This will results in a long table with multiple files row-binded.

[10]:
combined=res.get_table(update_keys={'I (Einthoven)': 'I'})
combined.WaveFormsTable
Processing path: /home/amand/google_drive/Research/ECGProcess/ecgprocess/example_data/example_datasets/example-DICOM1.dcm.
Processing path: /home/amand/google_drive/Research/ECGProcess/ecgprocess/example_data/example_datasets/example-DICOM2.dcm.
[10]:
SOPinstanceUID SamplingSequence Lead Voltage Waveform type
0 2.25.269796857626990821315969488216511468638 0 I 0.00 rhythm
1 2.25.269796857626990821315969488216511468638 1 I 0.00 rhythm
2 2.25.269796857626990821315969488216511468638 2 I 29.28 rhythm
3 2.25.269796857626990821315969488216511468638 3 I 43.92 rhythm
4 2.25.269796857626990821315969488216511468638 4 I 48.80 rhythm
... ... ... ... ... ...
119995 1.3.6.1.4.1.40744.65.2218354498692802781165553... 4995 V6 -195.00 rhythm
119996 1.3.6.1.4.1.40744.65.2218354498692802781165553... 4996 V6 -190.00 rhythm
119997 1.3.6.1.4.1.40744.65.2218354498692802781165553... 4997 V6 -190.00 rhythm
119998 1.3.6.1.4.1.40744.65.2218354498692802781165553... 4998 V6 -185.00 rhythm
119999 1.3.6.1.4.1.40744.65.2218354498692802781165553... 4999 V6 -185.00 rhythm

120000 rows × 5 columns

Write to disk

These tables can of course be written to disk. However, loading data from multiple files to simply write these to disk will be ineffecient. A more memory efficient solution is to extract each individual DICOM and append results one-at-a-time to a directory file. This is what write_ecg does … Actually it writes four files to disk!

[11]:
with tempfile.TemporaryDirectory() as temp_dir:
    combined=res.write_ecg(update_keys={'I (Einthoven)': 'I'}, target_path=temp_dir)
    # list saved files
    print('Directory content:')
    for file_name in os.listdir(getattr(combined, 'target_path')):
        print('   - {}'.format(file_name))
Processing path: /home/amand/google_drive/Research/ECGProcess/ecgprocess/example_data/example_datasets/example-DICOM1.dcm.
Processing path: /home/amand/google_drive/Research/ECGProcess/ecgprocess/example_data/example_datasets/example-DICOM2.dcm.
Directory content:
   - GeneralInfoTable.tsv.gz
   - WaveFormsTable.tsv.gz
   - FailedFiles.txt
   - MedianWaveTable.tsv.gz
[12]:
help(pdicom)
Help on module ecgprocess.process_dicoms in ecgprocess:

NAME
    ecgprocess.process_dicoms

DESCRIPTION
    A module to extract median beats and raw waveforms from DICOM ECGs wrapping
    pydicom.

CLASSES
    builtins.object
        BaseECGDICOMReader
            ECGDICOMReader
        ECGDICOMTable

    class BaseECGDICOMReader(builtins.object)
     |  BaseECGDICOMReader(lead_voltages=None, lead_voltages2=None, results_dict=None)
     |
     |  An ECGDICOMReader base class implementing the more efficient __slots__ for
     |  the waveform arrays, while still retaining __dict__ dynamic attribute
     |  creation.
     |
     |  Methods defined here:
     |
     |  __init__(self, lead_voltages=None, lead_voltages2=None, results_dict=None)
     |      Initialises slots entries to `None`.
     |
     |  ----------------------------------------------------------------------
     |  Data descriptors defined here:
     |
     |  GeneralInfo
     |
     |  MedianWaveforms
     |
     |  Waveforms

    class ECGDICOMReader(BaseECGDICOMReader)
     |  ECGDICOMReader(augment_leads: bool = False, resample_500: bool = True, retain_raw: bool = False) -> None
     |
     |  Takes an ECG DICOM file and extracts metadata, median beats (if available)
     |  and raw waveforms.
     |
     |  Parameters
     |  ----------
     |  augment_leads : bool, default `False`
     |      Whether the augmented leads are available in the DICOM, if not
     |      these are calculated.
     |  resample : bool, default `True`
     |      Whether to resample the ECG to a frequency of 500 Hertz.
     |  retain_raw : bool, default `False`
     |      Whether the raw pydicom instance and raw waveforms should be retained.
     |      Set to `False` to decrease memory usage. Set to `True` to explore the
     |      orignal pydicom instance. For example, use this one a few files to
     |      identify none-standard information to extract.
     |
     |  Attributes
     |  ----------
     |  augment_leads : bool
     |      Whether the augmented leads were calculated if these were unavailable.
     |  resample : bool
     |      Whether the ECG was resampled to a 500 Hertz frequency.
     |  retain_raw : bool
     |      Whether the raw pydicom data was retained.
     |  METADATA : dict [`str`, `str`]
     |      A dictionary describing the metadata one wants to extract from a
     |      DICOM. The dictionary keys represents the `target` (new) name and the
     |      dictionary values the `source` (old) names.
     |  ECG_TRAIT_DICT : dict [`str`, `list[str]`]
     |      A dictionary with the keys reflecting the desired name of the ECG trait.
     |      Each key will have a list of strings as a value. These strings will be
     |      compared to the names in `WaveformAnnotationSequence` attribute.
     |      Matching is done without case-sensitivity.  If for any key there are
     |      multiple matching strings the algorithm will check if the extracted
     |      values are all the same, if not multiple entries will be returned for
     |      the user to decide what to do next. The extracted ECG traits will be
     |      included with the extracted METADATA.
     |  STUDY_DATE_SOURCE : str
     |      The study date format of the source DICOM.  This will be used in
     |      `datetime.strptime` as format argument.
     |  STUDY_DATE_TARGET : str
     |      The target formatting of the study date.  This will be used in
     |      `datetime.strptime` as format argument.
     |  ACQUISITION_DATE_SOURCE : str
     |      The acquisition date and time format of the source DICOM.  This will be
     |      used in `datetime.strptime` as format argument.
     |  ACQUISITION_DATE_TARGET : str
     |      The target formatting of the acquisition date and time.  This will be
     |      used in `datetime.strptime` as format argument.
     |
     |  Methods
     |  -------
     |  get_metadata(path, skip_empty)
     |      Extract the dicom metadata.
     |  make_leadvoltages(waveform_array, lead_info, augment_leads)
     |      Extracts the voltages from a DICOM file. Will automatically extract the
     |      limb leads if missing.
     |
     |  Notes
     |  -----
     |  The waveforms are stored using `__slots__` to decrease memory usage, which
     |  is further improved by setting `retain_raw` to `False` - the default
     |  behaviour.
     |
     |  Method resolution order:
     |      ECGDICOMReader
     |      BaseECGDICOMReader
     |      builtins.object
     |
     |  Methods defined here:
     |
     |  __call__(self, path: str, skip_empty: bool = True, verbose: bool = False) -> Self
     |      Read a `.dcm` DICOM file and extracts metadata, raw waveforms, and
     |      median beats.
     |
     |      see `constants.DICOMTags` for the `METADATA`, `WAVE_FORMS`, and
     |      `MEADIAN_BEATS` tags looked for.
     |
     |      Parameters
     |      ----------
     |      path : str
     |          The path to the .dcm file.
     |      skip_empty : bool, default `True`
     |          Whether empty tags should be skipped or throw an error.
     |      verbose : bool, default `False`
     |          Prints missing tags if skip_empty is set to `True`.
     |
     |      Attributes
     |      ----------
     |      GeneralInfo : list [`str`]
     |          A list of dcmread extracted attributes.
     |      Waveforms : dict [`str`, `np.array`]
     |          The lead specific ECG waveforms.
     |      MedianWaveforms : dict [`str`, `np.array`]
     |          The lead specific ECG median beats.
     |
     |      Returns
     |      -------
     |      self : `ECGDICOMReader` instance
     |          Returns the class instance with updated attributes extracted
     |          from `dcmread`.
     |
     |  __eq__(self, other)
     |      Return self==value.
     |
     |  __init__(self, augment_leads: bool = False, resample_500: bool = True, retain_raw: bool = False) -> None
     |      Initialises slots entries to `None`.
     |
     |  __repr__(self)
     |      Return repr(self).
     |
     |  get_metadata(self, path: str | None = None, dicom_instance: pydicom.dataset.FileDataset | None = None, skip_empty: bool = True) -> tuple[pydicom.dataset.FileDataset, dict[str, typing.Any], list[str]]
     |      Takes a dicom file and extracts its metedata
     |
     |      Parameters
     |      ----------
     |      path : str, default `NoneType`.
     |          The path to the .dcm file.
     |      dicom_instance : DCM_Class, default `NoneType`.
     |          A DCM_Class instance.
     |
     |      Returns
     |      -------
     |      results : dict,DCM_Class
     |          - A `DCM_Class` instance.
     |          - A dictionary with extracted metadata.
     |          - A list of missing `DCM_Class` attribute names.
     |
     |      Notes
     |      -----
     |      Either supply a path to a dicom file or a DCM_Class instance
     |
     |  make_leadvoltages(self, waveform_array: numpy.ndarray, lead_info: Dict[int, str], augment_leads: bool) -> Dict[str, numpy.ndarray]
     |      Extracts the voltages from a DICOM file. Will automatically extract the
     |      limb leads if missing, please see:
     |      `url <https://ecgwaves.com/topic/ekg-ecg-leads-electrodes-systems-limb-chest-precordial/>`_.
     |
     |      Parameters
     |      ----------
     |      waveform_array : np.ndarray
     |          An array with the waveforms.
     |      lead_info: dict [`int`, `str`]
     |          A dictionary where the keys match the `waveform_array` rows and
     |          the dictionary values contain the lead name strings.
     |      augment_leads : bool
     |          Whether to calculate the additional augmented leads. Will only work
     |          if there are exactly 8 leads available.
     |
     |      Returns
     |      -------
     |      leads: dict [`str`, `np.ndarray`]
     |          A dictionary with the lead name string as keys and leads as
     |          np.ndarray values.
     |
     |  ----------------------------------------------------------------------
     |  Data descriptors defined here:
     |
     |  __dict__
     |      dictionary for instance variables
     |
     |  __weakref__
     |      list of weak references to the object
     |
     |  ----------------------------------------------------------------------
     |  Data and other attributes defined here:
     |
     |  ACQUISITION_DATE_SOURCE = '%Y%m%d%H%M%S'
     |
     |  ACQUISITION_DATE_TARGET = '%Y-%m-%d %H:%M:%S'
     |
     |  ECG_TRAIT_DICT = {'ARate': ['Atrial Heart Rate'], 'P Axis': ['P Axis']...
     |
     |  MEDIAN_BEATS = {'ChannelBaseline (MEDIAN)': 'ChannelBaseline', 'Channe...
     |
     |  MEDIAN_BEATS_DICT = {'MedianWaveforms': ['make_leadvoltages', 1], 'Raw...
     |
     |  METADATA = {'AccessionNumber': 'AccessionNumber', 'AcquisitionDateTime...
     |
     |  STUDY_DATE_SOURCE = '%Y%m%d'
     |
     |  STUDY_DATE_TARGET = '%Y-%m-%d'
     |
     |  WAVE_FORMS = {'ChannelBaseline': 'ChannelBaseline', 'ChannelNumber': '...
     |
     |  WAVE_FORMS_DICT = {'Raw_Waveforms': ['waveform_array', 0]}
     |
     |  __annotations__ = {'augment_leads': <class 'bool'>, 'resample_500': <c...
     |
     |  __dataclass_fields__ = {'augment_leads': Field(name='augment_leads',ty...
     |
     |  __dataclass_params__ = _DataclassParams(init=True,repr=True,eq=True,or...
     |
     |  __hash__ = None
     |
     |  __match_args__ = ('augment_leads', 'resample_500', 'retain_raw')
     |
     |  augment_leads = False
     |
     |  resample_500 = True
     |
     |  retain_raw = False
     |
     |  ----------------------------------------------------------------------
     |  Data descriptors inherited from BaseECGDICOMReader:
     |
     |  GeneralInfo
     |
     |  MedianWaveforms
     |
     |  Waveforms

    class ECGDICOMTable(builtins.object)
     |  ECGDICOMTable(ecgdicomreader: ecgprocess.process_dicoms.ECGDICOMReader, path_list: List[str]) -> None
     |
     |  Takes an `ECGDICOMReader` instance and loops over a list of dicom paths
     |  and maps these to which can be used in analyses or saved to disk.
     |
     |  Attributes
     |  ----------
     |  RawPathList : list [`str`]
     |      A list of dcm file paths.
     |
     |  Methods
     |  -------
     |  get_table(update_keys,**kwargs)
     |      extracts data from multiple dicom files and maps these to
     |      pandas.DataFrames.
     |  write_ecg(target_tar, target_path, sep, mode, compression, update_keys,
     |  **kwargs)
     |      writes each dicom file to a single sets of files (metadata,
     |      waveforms, median beats). This is done by appending the extracted
     |      data from each file the target file set, minimising the memory
     |      footprint.
     |
     |  Methods defined here:
     |
     |  __call__(self, skip_missing: Literal['Permissions', 'Data', 'None'] = 'Permissions', verbose: bool = False) -> Self
     |      Will take a ECGDICOMReader and loops over a list of dcm file paths and
     |      and confirm the files exist and have appropriate read permission
     |
     |      Parameters
     |      ----------
     |      skip_missing : {'Permissions', 'Data', 'None'}, default `Permissions`
     |          Whether files with a `PermissionError` or `AttributeError` should
     |          be skipped, or whether all errors should be raised.
     |          A PermissionError implies either the file is absent or it cannot
     |          be read. An AttributeError indicates the waveform_array
     |          attribute is absent from the pydicom instance.
     |          `Permissions` will only skip PermissionErrors, while `Data` will
     |          skip both PermssionErrors and AttributeErrors (due to a missing
     |          waveform_array), `None` will not skip errors.
     |      verbose : bool, default `False`
     |          Prints missing files if skip_missing is set to `True`.
     |
     |      Attributes
     |      ----------
     |      FailedPathList : list [`str`]
     |          File paths which were either absent or without read permission.
     |      CuratedPathList : list [`str`]
     |          File paths which are readable.
     |
     |      Returns
     |      -------
     |      self : `ECGDICOMTable` instance
     |          Returns the class instance with updated attributes.
     |
     |  __init__(self, ecgdicomreader: ecgprocess.process_dicoms.ECGDICOMReader, path_list: List[str]) -> None
     |      Initialises a new instance of `ECGDICOMTable`.
     |
     |      Parameters
     |      ----------
     |      ecgdicomreader : ECGDICOMReader
     |          An instance of the ECGDICOMReader data class.
     |      path_list : list [`str`]
     |          A list of paths to one or more .dcm files.
     |
     |  __repr__(self)
     |      Return repr(self).
     |
     |  __str__(self)
     |      Return str(self).
     |
     |  get_table(self, update_keys: Optional[Dict[str, str]] = None, **kwargs: Optional[Any]) -> Self
     |      Will extracted dicom data to tables.
     |
     |      Parameters
     |      ----------
     |      update_keys: dict [`str`, `str`], default `NoneType`
     |          A dictionary to remap lead names: [`old`, `new`]
     |      **kwargs: optional
     |          Keyword arguments used in the call method of a `ECGDICOMReader`
     |          instance
     |
     |      Attributes
     |      ----------
     |      IndexList: list [`str`]
     |          A list of unique identifiers.
     |      GeneralInfoTable: pandas.DataFrame
     |          A table of the dicom metadata.
     |      WaveFormsTable: pandas.DataFrame
     |          A  long-format table with waveforms.
     |      MedianWaveTable: pandas.DataFrame
     |          A long-format table with the median beat waveforms.
     |      NoDataList : `list` [`str`]
     |          A list of dicom files without a waveform_array.
     |
     |      Returns
     |      -------
     |      self : `ECGDICOMTable` instance
     |          Returns the class instance with updated attributes.
     |
     |  write_ecg(self, target_tar: Optional[str] = None, target_path: str = '.', sep: str = '\t', mode: str = 'w:gz', compression: str = 'gzip', update_keys: Optional[Dict[str, str]] = None, **kwargs: Optional[Any])
     |      Extracts dicom files, and write these one by one to a set of target
     |      files. By appending each dicom individually to the target files the
     |      memory footprint of this function will be minimal.
     |
     |      Parameters
     |      ----------
     |      target_tar : str, default `NoneType`
     |          The `name` of an optional tarfile where the individual files will
     |          be written to. The target_tar will be concatenated to
     |          `target_path` and depending on `mode` this directory will be
     |          tar.gz compressed. Set `target_tar` to `NoneType` to simply add
     |          the files directly to `target_path`. Note this will overwrite
     |          any potential directory or files with the identical names.
     |      target_path : str, default '.'
     |          The full path where the files should be written to. if provided
     |          `target_tar` will be created underneath this path, otherwise the
     |          files will be directly written to the `target_path` terminal
     |          directory (assuming this is writable).
     |      sep : str, default '    '`
     |          The file separator, which will be passed to
     |          pandas.DataFrame.to_csv.
     |      mode : str, default `w:gz`
     |          The tarfile.open mode.
     |      compression : str, default `gzip`
     |          The file compression passed to pandas.DataFrame.write_csv.
     |      update_keys : dict [`str`, `str`], default `NoneType`
     |          A dictionary to remap lead names: [`old`, `new`]
     |      **kwargs : Optional[Any],
     |          Keyword arguments used in the call method of a `ECGDICOMReader`
     |          instance.
     |
     |      Attributes
     |      ----------
     |      target_path : str
     |          The directory or tar file path were the files are written to.
     |      NoDataList : `list` [`str`]
     |          A list of dicom files without a waveform_array.
     |
     |      Returns
     |      -------
     |      None
     |
     |      Notes
     |      -----
     |      This method writes the following files to disk:
     |      - `GeneralInfoTable.tsv`
     |      - `WaveFormsTable.tsv`
     |      - `MedianWaveTable.tsv`
     |      - `FailedFiles.txt`
     |
     |      Raises
     |      ------
     |      NotADirectoryError or PermissionError
     |          If the target directory does not exist or is not writable.
     |
     |  ----------------------------------------------------------------------
     |  Data descriptors defined here:
     |
     |  __dict__
     |      dictionary for instance variables
     |
     |  __weakref__
     |      list of weak references to the object

DATA
    Dict = typing.Dict
        A generic version of dict.

    List = typing.List
        A generic version of list.

    Literal = typing.Literal
        Special typing form to define literal types (a.k.a. value types).

        This form can be used to indicate to type checkers that the corresponding
        variable or function parameter has a value equivalent to the provided
        literal (or one of several literals)::

            def validate_simple(data: Any) -> Literal[True]:  # always returns True
                ...

            MODE = Literal['r', 'rb', 'w', 'wb']
            def open_helper(file: str, mode: MODE) -> str:
                ...

            open_helper('/some/path', 'r')  # Passes type check
            open_helper('/other/path', 'typo')  # Error in type checker

        Literal[...] cannot be subclassed. At runtime, an arbitrary value
        is allowed as type argument to Literal[...], but type checkers may
        impose restrictions.

    Optional = typing.Optional
        Optional[X] is equivalent to Union[X, None].

    Self = typing.Self
        Used to spell the type of "self" in classes.

        Example::

            from typing import Self

            class Foo:
                def return_self(self) -> Self:
                    ...
                    return self

        This is especially useful for:
            - classmethods that are used as alternative constructors
            - annotating an `__enter__` method which returns self

    Tuple = typing.Tuple
        Deprecated alias to builtins.tuple.

        Tuple[X, Y] is the cross-product type of X and Y.

        Example: Tuple[T1, T2] is a tuple of two elements corresponding
        to type variables T1 and T2.  Tuple[int, float, str] is a tuple
        of an int, a float and a string.

        To specify a variable-length tuple of homogeneous type, use Tuple[T, ...].

    Type = typing.Type
        Deprecated alias to builtins.type.

        builtins.type or typing.Type can be used to annotate class objects.
        For example, suppose we have the following classes::

            class User: ...  # Abstract base for User classes
            class BasicUser(User): ...
            class ProUser(User): ...
            class TeamUser(User): ...

        And a function that takes a class argument that's a subclass of
        User and returns an instance of the corresponding class::

            def new_user[U](user_class: Type[U]) -> U:
                user = user_class()
                # (Here we could write the user object to a database)
                return user

            joe = new_user(BasicUser)

        At this point the type checker knows that joe has type BasicUser.

    Union = typing.Union
        Union type; Union[X, Y] means either X or Y.

        On Python 3.10 and higher, the | operator
        can also be used to denote unions;
        X | Y means the same thing to the type checker as Union[X, Y].

        To define a union, use e.g. Union[int, str]. Details:
        - The arguments must be types and there must be at least one.
        - None as an argument is a special case and is replaced by
          type(None).
        - Unions of unions are flattened, e.g.::

            assert Union[Union[int, str], float] == Union[int, str, float]

        - Unions of a single argument vanish, e.g.::

            assert Union[int] == int  # The constructor actually returns int

        - Redundant arguments are skipped, e.g.::

            assert Union[int, str, int] == Union[int, str]

        - When comparing unions, the argument order is ignored, e.g.::

            assert Union[int, str] == Union[str, int]

        - You cannot subclass or instantiate a union.
        - You can use Optional[X] as a shorthand for Union[X, None].

FILE
    /home/amand/google_drive/Research/ECGProcess/ecgprocess/process_dicoms.py