# Architecture
`dicom-dre` is a pure-Python DICOM de-identification engine. It filters images,
blanks burned-in pixel regions, and rewrites metadata tags. For each input file
it produces one of three outcomes by running three stages in sequence.
## Overview
For each input DICOM file the engine runs three stages:
1. **Filter**: a device catalog decides whether to keep or reject the image.
2. **Pixel scrub**: the engine blanks the burned-in text regions the catalog
identified. When it blanks a region, it sets `BurnedInAnnotation` (0028,0301)
to `NO` and records the Clean Pixel Data Option `113101` in the
De-identification Method Code Sequence for that instance.
3. **Metadata**: the engine rewrites DICOM tags: it removes PHI, re-derives
UIDs, and shifts or keeps dates.
Each run returns a {py:class}`~dicom_dre.result.DeidentifyResult` whose `outcome`
is one of three {py:class}`~dicom_dre.result.Outcome` values:
| Outcome | Cause |
|---------|-------|
| `DEIDENTIFIED` | The file passed the filter, and the engine wrote it out. |
| `FILTERED` | A device or exclusion rule rejected the file; the result contains the matched reason. |
| `QUARANTINED` | Processing raised an exception; the result contains the error text. |
## Pipeline
Every de-identification call enters through
{py:func}`dicom_dre.pipeline.deidentify_file`, which composes the three stages and
returns a `DeidentifyResult`.
```{mermaid}
flowchart LR
A["PHI DICOM"] --> F{"Filter
catalog.evaluate()"}
F -->|deny| X["FILTERED
(+ reason)"]
F -->|allow / scrub| S["Pixel scrub
blank_regions()"]
S --> M["Metadata
profile.apply()"]
M --> O["DEIDENTIFIED"]
F -.->|exception| Q["QUARANTINED"]
```
An exception raised in any stage does not propagate to the caller.
`deidentify_file` catches it and returns a `QUARANTINED` result instead.
## Stages
### Filter
`catalog.evaluate(DicomTags.from_file(path))` returns a
{py:class}`~dicom_dre.catalog.CatalogDecision` with an action (`allow`, `deny`, or
`scrub`), a human-readable reason, and a list of scrub regions. A `deny` action
stops the pipeline and produces a `FILTERED` result whose `filter_reason` is the
matched rule. Catalog evaluation reads tags directly with pydicom.
See [Device Catalog](device-catalog.md).
### Pixel scrub
When the decision contains scrub regions,
{py:func}`dicom_dre.pixel_blanker.blank_regions` blanks them. The engine edits
JPEG Baseline images directly in the compressed bitstream with no re-encoding.
For all other transfer syntaxes, it decodes with pydicom and numpy, blanks the
regions, and writes uncompressed Explicit VR Little Endian.
See [JPEG DCT Scrubbing](jpeg-dct.md).
### Metadata
The bound {py:class}`~dicom_dre.profile.DeidProfile` runs its tag rules on the
dataset with `DeidProfile.apply(ds, params)`, where `params` is a
{py:class}`~dicom_dre.parameters.DeidParameters` containing the per-patient values.
Rules are small closures defined in
{py:mod}`dicom_dre.actions` (for example `remove`, `empty`, `hash_uid`,
`jitter_date`, `keep`). The profile factories in {py:mod}`dicom_dre.profiles`
compose the tag sets each profile applies them to.
Each factory in `actions.py` closes over its build-time arguments at
profile-construction time and returns a
`(Dataset, BaseTag, DeidParameters) -> None` callable that reads per-patient
values from the supplied parameters. The available actions:
| Action | Effect |
|--------|--------|
| `keep()` | Leave the element unchanged. |
| `remove()` | Delete the element from the dataset. |
| `empty()` | Replace the value with a zero-length value (empty list for `SQ`). |
| `set_value(value, create_if_missing=False)` | Replace the value with a literal string, optionally creating the element when absent. |
| `set_param(field, default=None, fallback_field=None, create_if_missing=False)` | Write a per-patient value read from `DeidParameters`, with fallbacks. |
| `hash_identifier_param(field, salt, fallback_field=None, source_tag=None)` | Write a caller-supplied identifier verbatim, otherwise hash the original element value (salted with `salt` and the study identifier); write `[REDACTED]` when there is nothing to hash. |
| `hash_uid(root, use_study_salt=False)` | Re-derive a UID as an MD5 hash under `root`, optionally salted with the study identifier. |
| `jitter_date()` | Shift the `DA`/`DT` date component forward by `params.jitter` days, preserving any time/timezone remainder; the profile resolves an unset jitter to a deterministic per-patient, per-study amount before rules run. |
| `append_value(text, create_if_missing=False)` | Append `text` to a multi-valued element using `\` as separator. |
| `cap_age(threshold, replacement)` | Replace an `AS` age when its numeric part exceeds `threshold`. |
| `if_exists(inner)` | Apply `inner` only when the element is present. |
Profiles compose these actions rather than parsing a script.
### Private-tag preservation
By default the engine removes every private data element that has no explicit
tag rule. A profile declares a reviewer-approved set of private elements to
retain verbatim through its `preserved_private_specs` field (a frozenset of
`PrivateTagSpec`), keyed on the private-creator string. At apply time,
`DeidProfile.apply(ds, params)` resolves each spec's private-creator block (the
block is runtime-assigned, not fixed) and keeps both the resolved data elements
and their creator element. The engine removes all other private elements as
usual.
An offset in a spec may be wrapped in `Jitter`. A `Jitter`-flagged offset is
resolved and kept like any preserved offset, and its `DA`/`DT` value is then
shifted by the same per-study jitter applied to standard date tags. The shift
runs after the element rules and recurses into sequence items. For a
date-preserving profile the jitter is unset, so a flagged value is left
unchanged. The `default` profile flags `PulseSequenceDate (0019,xx9D)` in the
`GEMS_ACQU_01` block; `strict` inherits the same specs.
The `default` profile declares the specs; `lds` and `lds-no-dob` inherit
them, and `strict` declares the same set. The engine stamps the
De-identification Method Code Sequence `(0012,0064)` on every de-identified
instance; it adds the Retain Safe Private Option item `113111` only when the
profile removes private groups (`remove_private`) and the instance retains at
least one preserved private element. A profile that keeps all private elements
does not emit `113111`, since that is not the selective safe private option.
See [Profiles](profiles.md).
## Profiles
The `default`, `lds`, `lds-no-dob`, and `strict` profiles each map to a
factory function in {py:mod}`dicom_dre.profiles`. A profile name selects which
tags the engine keeps, removes, or shifts. [Profiles](profiles.md) describes the
behavior of each profile.
## Regression validation
A regression fixture suite guards catalog and metadata behavior against known
inputs. Each fixture pins the expected filter decision, scrub decision, scrub
regions, and de-identified metadata for a representative instance, so the test
run catches a change that alters any of them. The fixtures reside in
`tests/unit/test_catalog_regression_fixtures.py`.
See [Testing](../about/testing.md).
## Extending
- To allow or deny a new device, or to blank a new pixel region, add a `device()`
entry to {py:mod}`dicom_dre.default_catalog`. See
[Extending the catalog](../guides/extending-the-catalog.md).
- To change a tag rule or add a profile, compose actions in
{py:mod}`dicom_dre.profiles`, deriving from an existing profile with
`dataclasses.replace`.
## Module reference
| Module | Responsibility |
|--------|----------------|
| {py:mod}`dicom_dre.pipeline` | Orchestrator: composes the three stages into a `DeidentifyResult` |
| {py:mod}`dicom_dre.result` | `DeidentifyResult` and `Outcome` result types |
| {py:mod}`dicom_dre.profile` | `DeidProfile.apply()` metadata rewrite |
| {py:mod}`dicom_dre.actions` | Tag-action factories (`remove`, `hash_uid`, `jitter_date`, ...) |
| {py:mod}`dicom_dre.profiles` | `default`, `lds`, `lds-no-dob`, `strict` factories and the profile builder |
| {py:mod}`dicom_dre.catalog` | Catalog engine: `DeviceCatalog`, `CatalogDecision`, `DicomTags` |
| {py:mod}`dicom_dre.default_catalog` | Device and exclusion rule data |
| {py:mod}`dicom_dre.pixel_blanker` | `blank_regions()` pixel scrub dispatch |
| {py:mod}`dicom_dre.jpeg_dct_scrubber` | DCT-domain JPEG blanking and C accelerator |