import logging
import os
import sys
import typing as ty
from pathlib import Path
from fileformats.core import (
FileSet,
Loaded,
extra,
mtime_cached_property,
staged_out_dir,
)
from fileformats.core.mixin import WithClassifiers
from .contents import ContentsClassifier
from .contents.anatomical_entity import AnatomicalEntity
from .contents.imaging.derivatives import Derivative
from .contents.imaging.modality import ImagingModality
logger = logging.getLogger("fileformats")
if sys.version_info >= (3, 9):
from typing import TypeAlias
else:
from typing_extensions import TypeAlias
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
if ty.TYPE_CHECKING:
import numpy.typing # noqa: F401
# =====================================================================
# Custom loader functions for different image types
# =====================================================================
DataArrayType: TypeAlias = (
ty.Any
) # In Py<3.9 this is problematic "numpy.typing.NDArray[typing.Union[numpy.floating[typing.Any], numpy.integer[typing.Any]]]"
[docs]
class MedicalImagingData(FileSet):
"""Base class for all medical imaging data including pre-image raw data and
associated data"""
# Whether the files of the format may contain protected health information (PHI).
# "nested" means that the format's own files don't, but formats nested within it
# (i.e. returned by its validated properties) may, so deidentifying it deidentifies
# them (see `deidentify_nested`). Being truthy, it is still treated as holding PHI
contains_phi: bool | ty.Literal["nested"] = True
[docs]
@extra(wrapper=staged_out_dir)
def deidentify(
self,
out_dir: os.PathLike[str],
recipe: Loaded[FileSet] | None = None,
**kwargs: ty.Any,
) -> Self:
"""
Deidentifies the image by stripping any subject-identifying information from the
image header. The exact implementation of this method will depend on the
specific image format and the type of identifying information that is present. The
output files should be named with a new file path(s) that is derived from the metadata,
such that it doesn't contain any subject-identifying information within it.
Implementations are passed a hidden staging directory as `out_dir`, the
contents of which are only moved into the requested directory once they have
returned, so they can copy files there and deidentify them in place without
any risk of partially deidentified files being left in the output if they fail.
Implementations only need to strip the identifying data -- they don't need to
track/report what was changed. Callers that need that (e.g. for re-identification
audit trails) can diff `metadata` before and after calling this method instead,
since that works uniformly across formats without extra bookkeeping in each
implementation.
Parameters
----------
out_dir: PathLike[str]
The directory where the deidentified image should be saved
recipe: Loaded[FileSet], optional
A recipe for the deidentification process, loaded from a format that depends
on the implementation (e.g. `DeidRecipe` for DICOM), which may include
details on which fields to remove or how to handle certain types of data.
Implementations annotate it with ``Loaded[<recipe format>]``, or a union of
them in order of preference (e.g. ``Loaded[DeidRecipeX] | Loaded[DeidRecipe]``,
for recipes with and without side-cars), or ``None`` if they don't accept a
recipe. Callers can find the formats to load the recipe from with
``LoadedMarker.all_from_hint(get_type_hints(find_extra_implementation(
MedicalImagingData.deidentify, type(image)), include_extras=True)["recipe"])``
Formats nested within one another are passed the same recipe, so they need
to be able to read a common recipe format.
**kwargs: Any
Additional format-specific keyword arguments (e.g. concurrency options),
which implementations that don't use them should accept and ignore
Returns
-------
Self
A new instance of the image with any subject-identifying information stripped from
the image header.
Formats whose `contains_phi` is "nested" are deidentified by
`deidentify_nested` if no implementation is registered for them.
"""
if self.contains_phi == "nested":
return self.deidentify_nested(out_dir, recipe=recipe, **kwargs)
raise NotImplementedError
def deidentify_nested(
self,
out_dir: os.PathLike[str],
recipe: Loaded[FileSet] | None = None,
**kwargs: ty.Any,
) -> Self:
"""Deidentifies the medical imaging data nested within the fileset (i.e.
returned by its validated properties) with their own implementations, and
copies its remaining files unchanged, keeping the same layout (see
`FileSet.map_nested`).
Nested filesets of other types are deidentified too if they have a truthy
`contains_phi` attribute, which they then need a `deidentify` method for
(taking the same arguments), so formats from other packages that follow the
same convention can be nested within each other.
For use within `deidentify` implementations of formats that contain others,
which can then deidentify any of the format's own files that may contain PHI
(e.g. logs) in place in the returned copy. As `deidentify` stages the output
of its implementations, they are only moved to the requested output directory
once that is done.
Parameters
----------
out_dir : PathLike[str]
the directory to write the deidentified fileset to
recipe : Loaded[FileSet], optional
the recipe passed on to the deidentification of each nested fileset
**kwargs : Any
passed on to the deidentification of each nested fileset
Returns
-------
Self
the copy of the fileset with the nested filesets deidentified
"""
def deidentify(nested: FileSet, dest_dir: Path) -> FileSet | None:
# Duck-typed, so nested formats from other families that follow the same
# `contains_phi`/`deidentify` convention (e.g. medical imaging data and
# biosignals) are also deidentified. One that may contain PHI but doesn't
# have a `deidentify` method raises, rather than being copied
if getattr(nested, "contains_phi", False):
deidentified: FileSet = nested.deidentify( # type: ignore[attr-defined]
dest_dir, recipe=recipe, **kwargs
)
return deidentified
return None # copied unchanged
return self.map_nested(out_dir, deidentify)
[docs]
class MedicalImage(WithClassifiers, MedicalImagingData):
INCLUDE_HDR_KEYS: ty.Optional[ty.Tuple[str, ...]] = None
IGNORE_HDR_KEYS: ty.Optional[ty.Tuple[str, ...]] = None
binary = True
classifiers_attr_name = "image_contents"
image_contents = ()
allowed_classifiers = (ContentsClassifier,)
exclusive_classifiers = (ImagingModality, AnatomicalEntity, Derivative)
[docs]
@extra
def read_array(self) -> DataArrayType:
"""
Returns the binary data of the image in a numpy array
"""
@mtime_cached_property
def data_array(self) -> DataArrayType:
return self.read_array()
[docs]
@extra
def vox_sizes(self) -> ty.Tuple[float, float, float]:
"""The length of the voxels along each dimension"""
raise NotImplementedError
[docs]
@extra
def dims(self) -> ty.Tuple[int, int, int]:
"""The dimensions of the image"""
raise NotImplementedError