Deidentification
================
All formats that hold medical imaging data derive from ``MedicalImagingData``, which
declares a ``deidentify`` hook. An implementation strips identifying information from
the data and writes the result to a new location. The fileformats-medimage-extras
package implements it for DICOM using the `deid`_ package, and other packages can
implement it for other formats, with recipes in whichever format suits them.
.. code-block:: python
class MedicalImagingData(FileSet):
@extra
def deidentify(
self,
out_dir: os.PathLike[str],
recipe: Loaded[FileSet] | None = None,
**kwargs: Any,
) -> Self:
...
``out_dir``
the directory the deidentified data is written to
``recipe``
the deidentification recipe, *already loaded* from the recipe format that the
implementation for the data's format expects (see :ref:`deidentification:Recipes`)
``**kwargs``
format-specific options, e.g. ``max_workers`` for DICOM collections.
Implementations ignore options they don't use.
Implementations only need to strip the identifying data. They don't report what
they changed. Callers that need a record of the changes, e.g. for re-identification,
can compare ``metadata`` before and after deidentification, which works the same for
every format.
Deidentifying DICOM
-------------------
DICOM files are deidentified with `deid recipes `__,
which list actions to apply to the header fields, e.g.
.. code-block:: text
FORMAT dicom
%header
ADD PatientIdentityRemoved YES
REPLACE PatientName ANONYMOUS
REPLACE PatientID var:anon_patient_id
BLANK AccessionNumber
JITTER endswith:Date var:date_jitter
REMOVE startswith:Referring
The recipe is loaded and passed to ``deidentify``, which can be called on a single
``DicomImage`` or on a whole ``DicomSeries``/``DicomDir``. Collections are deidentified
file by file, in parallel threads (``max_workers`` sets how many, defaulting to that of
``concurrent.futures.ThreadPoolExecutor``)
.. code-block:: python
>>> from fileformats.medimage import DicomSeries, DeidRecipeX
>>> recipe = DeidRecipeX("/path/to/specs/dicom-series.deid").load()
>>> deidentified = series.deidentify("/path/to/output", recipe=recipe, max_workers=4)
The deidentified files keep their names. A ``DicomDir`` is written to a
sub-directory of ``out_dir`` with the same name as the original directory.
A recipe is required for DICOM. Header fields that the recipe doesn't mention are
left as they are, and so are **private tags and sequences** (the recipe is applied
with deid's ``remove_private`` and ``strip_sequences`` turned off), so the recipe needs
to account for every field that may hold identifying information, including private
tags.
Recipe formats
~~~~~~~~~~~~~~
Two formats are defined for deid recipes:
``DeidRecipe`` (``medimage/deid-recipe``)
a ``deid`` recipe file on its own. Recipes don't have a standard extension (they are
typically named ``deid.dicom`` or ``.deid``), so they are identified by the
``FORMAT `` line they start with. The recipe can't use ``var:`` or
``func:`` values, since nothing defines them.
``DeidRecipeX`` (``medimage/deid-recipe-x``)
a deid recipe with side-cars that share its stem:
* ``.transforms.yaml`` (``DeidTransforms``, required) declares the values of
the ``var:`` and ``func:`` references in the recipe, e.g. a pseudonym hashed
from the patient ID, or a date shift read from an environment variable. They are
written in a small, declarative language rather than as code, so that recipes
can be distributed to and run at sites safely (see :ref:`deid_transforms:Deid transforms specification, version 0.1`).
* ``.salt`` (``DeidSalt``, optional) holds a secret key that hashes in the
transforms are salted with. A warning is logged if other users can read it.
For example, ``dicom-series.deid``, ``dicom-series.transforms.yaml`` and
``dicom-series.salt``.
Both are loaded as ``DeidRecipeWithTransforms``, a subclass of
``deid.config.DeidRecipe`` that holds the compiled transforms too. Loading fails if
the recipe references a ``var:`` or ``func:`` that isn't defined, or if the transforms
are invalid.
The DICOM implementation accepts either. Its ``recipe`` argument is annotated with
``Loaded[DeidRecipeX] | Loaded[DeidRecipe] | None``, in order of preference, so a
recipe with side-cars is loaded as ``DeidRecipeX``, and one without them as
``DeidRecipe`` (see :ref:`deidentification:Finding the recipe format for a datatype`).
.. code-block:: python
>>> from fileformats.medimage import DeidRecipe, DeidRecipeX
>>> DeidRecipeX.matches("/path/to/specs/dicom-series.deid") # has side-cars
True
>>> DeidRecipe("/path/to/specs/deid.dicom").load() # no side-cars needed
Recipes
-------
The recipe that ``deidentify`` takes depends on the format being deidentified. DICOM
uses deid recipes, but a recipe for raw scanner data, or for a vendor's proprietary
format, could be completely different. So the hook doesn't fix the recipe type.
Instead, each implementation declares which format(s) its recipe is loaded from, by
annotating the ``recipe`` argument with ``Loaded[]``.
``Loaded`` and ``loaded_type``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Every format has a ``loaded_type`` class attribute, the type of the object its
``load()`` method returns. ``Loaded[]`` is an annotation meaning "data loaded
from ````". At runtime it evaluates to
``Annotated[.loaded_type, LoadedMarker()]``, so
* type checkers and most tools see the loaded type itself (static checkers treat it
as ``Any``),
* code that inspects the signature can recover the *format* the data should be
loaded from, from the ``LoadedMarker``,
* ``FileSet.load`` checks that implementations return an instance of the
``loaded_type``, and implementations can check the data they are passed with
``check_loaded(, data)``.
The base ``deidentify`` hook annotates ``recipe`` with ``Loaded[FileSet]``, which
accepts ``Loaded[]`` in implementations. ``LoadedMarker`` is covariant,
so data loaded from a subclass of the annotated format is accepted too.
``loaded_type`` can be a class, or a dotted-path string that is only imported when
it is needed. Use a string when the class lives in an extras package, so that the
format classes don't depend on it, e.g.
.. code-block:: python
class DeidRecipe(UnicodeFile):
loaded_type = "fileformats.extras.medimage.deid_recipe.DeidRecipeWithTransforms"
See `Loaded data `__
in the *FileFormats* docs for more details.
Finding the recipe format for a datatype
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Callers that deidentify data of many formats, e.g. an ingestion pipeline with a
directory of recipes, don't need to know which recipe format each one uses. They
can read it from the ``recipe`` annotation of the implementation for the datatype
.. code-block:: python
import typing as ty
from fileformats.core import LoadedMarker, find_extra_implementation
from fileformats.medimage import MedicalImagingData
def load_recipe(datatype: type[MedicalImagingData], recipe_path: Path) -> ty.Any:
impl = find_extra_implementation(MedicalImagingData.deidentify, datatype)
hint = ty.get_type_hints(impl, include_extras=True)["recipe"]
markers = LoadedMarker.all_from_hint(hint) # in order of preference
if markers is None:
raise ValueError(f"The deidentify implementation for {datatype} doesn't take a recipe")
# Load the recipe from the first of the formats it matches
recipe_format = next(m.format for m in markers if m.format.matches(recipe_path))
return recipe_format(recipe_path).load()
recipe = load_recipe(type(series), Path("/path/to/specs/dicom-series.deid"))
deidentified = series.deidentify("/path/to/output", recipe=recipe)
``LoadedMarker.all_from_hint`` returns the formats of a ``Loaded[X] | Loaded[Y] | None``
annotation in order, or ``None`` if the implementation doesn't take a recipe (i.e. its
``recipe`` argument is annotated with ``None``). The hints must be retrieved with
``include_extras=True``, otherwise the ``LoadedMarker`` metadata is stripped.
`xnat-ingest `__ uses this to
pick the recipe for each format from a directory of recipes named after their formats
(e.g. ``medimage/dicom-series``).
Custom recipes
--------------
There are two ways to use your own recipe format: use it with the DICOM
implementation, or use it to deidentify a format of your own.
Extending the DICOM recipe
~~~~~~~~~~~~~~~~~~~~~~~~~~
The DICOM implementation can be passed data loaded from a subclass of ``DeidRecipe``
or ``DeidRecipeX``, as long as it loads to a subclass of ``DeidRecipeWithTransforms``.
The implementation checks this with ``check_loaded(DeidRecipe, recipe)``, and calls
the ``parser`` method of the loaded recipe.
For example, a recipe that also records the site it was written for. The format
goes in your extension package, with its ``loaded_type`` given as a dotted path to the
class in your extras package
.. code-block:: python
# fileformats/myvendor/deid.py
from fileformats.medimage import DeidRecipeX
class SiteDeidRecipe(DeidRecipeX):
"""A DeidRecipeX that is specific to the site it is in a directory of"""
loaded_type = "fileformats.extras.myvendor.deid.SiteRecipe"
The loaded type and the ``load`` implementation go in your extras package. The
implementation constructs the loaded type directly. It doesn't call
``DeidRecipeX.load(recipe)``, which would dispatch back to itself.
.. code-block:: python
# fileformats/extras/myvendor/deid.py
import typing as ty
from fileformats.core import FileSet, Loaded, extra_implementation
from fileformats.extras.medimage.deid_recipe import DeidRecipeWithTransforms
from fileformats.myvendor import SiteDeidRecipe
class SiteRecipe(DeidRecipeWithTransforms):
def __init__(self, deid: str, site: str, **kwargs: ty.Any) -> None:
super().__init__(deid, **kwargs)
self.site = site
@extra_implementation(FileSet.load)
def load_site_recipe(recipe: SiteDeidRecipe, **kwargs: ty.Any) -> Loaded[SiteDeidRecipe]:
salt = recipe.salt_file.load() if recipe.salt_file is not None else None
return SiteRecipe(
str(recipe.fspath),
site=recipe.fspath.parent.name,
transforms=recipe.transforms_file.load(salt=salt),
)
The return annotation of a ``load`` implementation has to be ``Loaded[]`` or
the ``loaded_type`` itself, and ``load`` raises a ``TypeError`` if it returns anything
else. The recipe can then be loaded and passed to the DICOM implementation
.. code-block:: python
>>> recipe = SiteDeidRecipe("/path/to/site-a/dicom-series.deid").load()
>>> recipe.site
'site-a'
>>> deidentified = series.deidentify("/path/to/output", recipe=recipe)
Callers that find the recipe format from the annotation (see
:ref:`deidentification:Finding the recipe format for a datatype`) will still load
``DeidRecipeX``/``DeidRecipe``. To have them use your format, implement ``deidentify``
for the datatype, with ``recipe: Loaded[SiteDeidRecipe]``, as below.
Recipes for other formats
~~~~~~~~~~~~~~~~~~~~~~~~~
To deidentify a format of your own, define a recipe format for it with a
``loaded_type``, implement ``load`` for the recipe format, and implement
``deidentify`` for the data format, annotating ``recipe`` with
``Loaded[]``. For example, for a raw data format whose header is
``key=value`` lines, with a YAML recipe listing the fields to blank
.. code-block:: python
# fileformats/myvendor/__init__.py (no dependencies)
from fileformats.generic import BinaryFile, UnicodeFile
from fileformats.medimage import MedicalImagingData
class RawDeidRecipe(UnicodeFile):
"""Lists the header fields of MyRawData files to blank"""
ext = ".raw-deid.yaml"
loaded_type = "fileformats.extras.myvendor.deid.RawDeidRules"
class MyRawData(MedicalImagingData, BinaryFile):
ext = ".myraw"
.. code-block:: python
# fileformats/extras/myvendor/deid.py
import os
import typing as ty
from dataclasses import dataclass, field
from pathlib import Path
import yaml
from fileformats.core import FileSet, Loaded, check_loaded, extra_implementation
from fileformats.medimage import MedicalImagingData
from fileformats.myvendor import MyRawData, RawDeidRecipe
@dataclass
class RawDeidRules:
blank: list[str] = field(default_factory=list)
@extra_implementation(FileSet.load)
def load_raw_deid_recipe(recipe: RawDeidRecipe, **kwargs: ty.Any) -> Loaded[RawDeidRecipe]:
with recipe.open() as f:
spec = yaml.safe_load(f) or {}
return RawDeidRules(blank=list(spec.get("blank", [])))
@extra_implementation(MedicalImagingData.deidentify)
def my_raw_deidentify(
data: MyRawData,
out_dir: os.PathLike[str],
recipe: Loaded[RawDeidRecipe] | None = None,
**kwargs: ty.Any,
) -> MyRawData:
if recipe is None:
raise ValueError("A recipe is required to deidentify MyRawData")
check_loaded(RawDeidRecipe, recipe) # e.g. not a recipe for another format
header = []
for line in data.fspath.read_text().splitlines():
key = line.split("=", 1)[0]
header.append(f"{key}=" if key in recipe.blank else line)
out = Path(out_dir) / data.fspath.name
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("\n".join(header))
return MyRawData(out)
Notes on the implementation's signature:
* the first argument is the format it is implemented for, and the return type has
to match it,
* ``recipe`` can be annotated with a union of formats in order of preference, e.g.
``Loaded[RawDeidRecipeV2] | Loaded[RawDeidRecipe] | None``, and with ``None``
alone (``recipe: None = None``) if the implementation doesn't take a recipe,
* other options are keyword arguments with defaults, and ``**kwargs`` takes the
options meant for other implementations.
Callers can then use your recipe without knowing about it, e.g. with the
``load_recipe`` function above
.. code-block:: python
>>> recipe = load_recipe(MyRawData, Path("/path/to/rules.raw-deid.yaml"))
>>> recipe
RawDeidRules(blank=['PatientName'])
>>> MyRawData("/path/to/scan.myraw").deidentify("/path/to/output", recipe=recipe)
.. _deid: https://pydicom.github.io/deid/