Deleting the patient name from a DICOM file feels like anonymization. It is not.
A cardiac CT study I once pulled for a teaching file had a blank PatientName, a scrubbed PatientID, and a perfectly readable date of birth sitting three tags down in a private block the vendor never documented. The header looked clean. It was not. And this is the recurring trap: DICOM was designed to carry identity, not to shed it. Anyone who has tried to share imaging for a multicentre study, a public dataset, a conference talk, or an AI training pipeline runs into the same wall. The name is the easy part.
This piece is for people who write the pipeline, not just click “anonymize” in a viewer. What follows: why de-identification is a legal and technical obligation, exactly where protected health information (PHI) hides inside a DICOM object, the DICOM standard’s own framework for cleaning it, the pixel-level identity problem that headers cannot touch, the truth about “anonymous” formats like NIfTI, and Python you can actually run.
Why bother at all?
Three forces converge here, and they rarely align neatly.
The first is regulatory. Under the GDPR, truly anonymous data falls outside the Regulation entirely (Recital 26), while pseudonymized data remains personal data with all the obligations that implies. That distinction is not pedantry. It decides whether you need a legal basis, a data protection impact assessment, and a data processing agreement, or whether you are free to publish. In the United States, HIPAA offers two roads: Safe Harbor, which enumerates eighteen identifier categories to strip, and Expert Determination, a statistical risk argument certified by a qualified person. Europe has no equivalent bright-line list, which is why so many EU imaging pipelines borrow the Safe Harbor eighteen as a working floor.
The second force is scientific reproducibility. Journals, funders, and registries increasingly demand shareable data. You cannot share what you cannot de-identify defensibly.
The third is the AI pipeline. Training a model on cardiac imaging means moving thousands of studies across trust boundaries, often to cloud compute. Every hop is an exposure.
So the question is not whether to de-identify. It is whether your de-identification survives an adversary who actually tries. Most do not.
Anonymization, pseudonymization, de-identification: not synonyms
These words get used interchangeably, and the sloppiness has consequences.
Anonymization aims to make re-identification impossible, or at least not reasonably likely, with no key retained anywhere. Pseudonymization replaces identifiers with a code while a re-linking key is held separately, under access control. Most clinical research uses the latter and calls it the former. That mislabelling is where audits go badly.
De-identification is the umbrella process, and DICOM uses this term deliberately. The standard is blunt about it: applying the confidentiality profiles “does not guarantee that all individually identifying information will be removed.” De-identifying the attributes does not de-identify the information object. Read that twice. The standard authors knew that a conformant header scrub is necessary but not sufficient.
For a coronary registry submission versus a public dataset versus an internal model, the acceptable residual risk differs. One profile does not fit all. David Clunie, who edited the DICOM supplement that became PS3.15 Annex E, made exactly this critique of blanket tool comparisons: overzealous stripping produces data that is safe and useless, while lax stripping produces data that is useful and dangerous. Judging a tool by its defaults, he argued, misses how easily those defaults can be reconfigured for the use case at hand. The engineering lives in that tension.
Where PHI actually hides
If you only scrub the obvious tags, you will leak. Here is the real surface area.
Standard header attributes. PatientName (0010,0010), PatientID (0010,0020), PatientBirthDate (0010,0030), PatientAddress, ReferringPhysicianName, InstitutionName, AccessionNumber, StationName, and dozens more. These are the ones every tool handles.
Dates and times. Acquisition, study, series, and content dates let an adversary reconstruct a timeline and cross-reference it with, say, a press release about a public figure’s surgery. The DICOM value representations DA, DT, and TM are worth handling by VR, not by a hand-maintained tag list, because new date tags appear as the standard evolves.
UIDs. StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID, and FrameOfReferenceUID are globally unique. If any un-remapped UID also lives in the source PACS, you have a join key straight back to the record. Remap them, but remap them consistently, or you shatter the study/series/instance hierarchy.
Private tags. Vendor-specific, odd-group, frequently undocumented. This is where my “clean” cardiac CT hid its date of birth. The safe default is to remove all private elements unless a specific one is known safe and needed.
Structured content and overlays. Structured Reports, curve data, graphic annotations, and overlay planes can carry names and free text. Radiotherapy and echo objects are especially prone to this.
Burned-in pixel PHI. Ultrasound frames, secondary captures, and screen grabs routinely stamp the patient’s name and MRN directly into the pixels. The BurnedInAnnotation (0028,0301) flag is supposed to warn you. Would you trust a flag a technologist may never have set? I would not.
File meta and the 128-byte preamble. The standard requires replacing the File Meta Information, including the preamble, because application entity titles and implementation details leak there too.
Miss any one of these categories and the header “looks” anonymous while remaining trivially reversible.
The standard has already thought about this: PS3.15 Annex E
DICOM does not leave you to invent a scheme. Part 15, Annex E defines the Basic Application Level Confidentiality Profile plus a set of options you compose on top of it. Table E.1-1 lists every attribute and the action to apply.
The action codes are the vocabulary worth memorizing:
- D: replace with a non-zero dummy value
- Z: replace with zero-length or dummy
- X: remove the attribute entirely
- K: keep unchanged
- C: clean (retain but scrub embedded identifiers)
- U: replace UID with a consistently remapped one
The options let you tune the profile to the use case rather than nuking everything:
- Clean Pixel Data: deal with burned-in identifiers
- Clean Recognizable Visual Features: the defacing hook
- Retain Longitudinal Temporal Information (with Modified Dates): preserve intervals for time-series work while shifting absolute dates
- Retain UIDs: when downstream linkage is legitimately needed
- Retain Safe Private: keep vendor acquisition parameters flagged safe
- Retain Patient Characteristics: keep age, sex, weight for analysis
Conform to the Basic Profile, then declare which options you applied, and set PatientIdentityRemoved (0012,0062) to YES with a machine-readable DeidentificationMethodCodeSequence. That declaration is what makes your output auditable. Why reinvent a tag list when NEMA maintains a normative one that tracks the standard?
The part headers cannot fix: pixel-level identity
Here is the finding that should unsettle anyone building head-and-neck or cardiac-thoracic pipelines.
Schwarz and colleagues, writing in the New England Journal of Medicine in 2019, took 84 volunteers, reconstructed 3D facial surfaces from their otherwise de-identified brain MRIs, and ran commercial face-recognition software against ordinary photographs. The correct scan was the top match for 70 of 84 people. That is 83%. The correct scan sat in the top five for 80 of 84, or 95%. A perfectly de-identified header protected none of them, because their faces were in the voxels.
This is not limited to the brain. Cardiac and thoracic CT frequently includes the mandible, orbits, and facial soft tissue at the top of the volume. Chest imaging carries a subtler problem: Packhäuser and colleagues showed in 2022 that deep learning can re-identify patients from chest X-rays using the biometric signature of the anatomy itself, no face required.
The mitigations are volumetric, not header-based. Defacing removes or blurs facial surface voxels. Skull-stripping (brain extraction) discards everything outside the brain. Both degrade the image for anything that needs facial or sinonasal anatomy, which is the recurring complaint from the ENT and maxillofacial side. For a coronary CTA you rarely need the face; for a study of aortic root geometry that extends cranially, you might have to think harder. There is no free lunch here, and pretending otherwise is how public datasets end up re-identifiable.
Are “anonymous” formats like NIfTI a solution?
Short answer: no, and believing so is dangerous.
NIfTI (and Analyze before it, and MINC alongside) was built for neuroimaging analysis, not for privacy. Its appeal for de-identification is incidental: the format simply cannot represent most DICOM header fields, so converting DICOM to NIfTI drops PatientName, PatientID, dates, and the whole private-tag zoo by omission. It looks anonymous because it is impoverished.
Three problems follow.
First, conversion does nothing to the pixels. The face is still in the volume. A NIfTI of a head MRI is exactly as re-identifiable as the DICOM it came from, per Schwarz.
Second, the metadata does not vanish, it relocates. The dominant conversion tool, dcm2niix, emits a JSON sidecar (the BIDS convention) that can carry acquisition dates, device serial numbers, and institution strings. De-identify the NIfTI and forget the sidecar, and you have leaked through the side door. I have seen exactly this in a shared dataset.
Third, filenames. Pipelines love to name files Rossi_Mario_20240312.nii.gz. The format is anonymous; your naming convention is not.
NIfTI is a fine analysis format and a poor anonymization strategy. Treat conversion as one step, never the whole story.
Techniques and working code
Enough theory. Here is the operational core, in Python, using pydicom.
1. Before anything else: look at what is actually in the file
Every de-identification failure I have seen started the same way, with someone scrubbing a tag list they assumed was complete. You cannot clean what you have never inspected. So the first script in any pipeline is not a scrubber, it is an auditor.
The crude version is one line:
import pydicom
ds = pydicom.dcmread("input.dcm")
print(ds) # full dataset dump, human readable
That prints everything, which for a multi-frame cardiac study means thousands of lines you will not read. Useful once. Useless as a habit.
What you actually want is a structured walk that recurses into sequences, flags private blocks, and separates the elements by risk category:
import pydicom
from pydicom.dataset import Dataset
def inspect(ds: Dataset, show_pixel_data: bool = False) -> None:
"""Recursive audit of a DICOM dataset, sequences included."""
def _walk(dataset, depth=0):
pad = " " * depth
for elem in dataset:
# PixelData is megabytes of noise in a console
if elem.tag == 0x7FE00010 and not show_pixel_data:
print(f"{pad}{elem.tag} PixelData -> "
f"[{len(elem.value)} bytes, suppressed]")
continue
private = "PRIV" if elem.tag.is_private else " "
print(f"{pad}{private} {elem.tag} {elem.VR} "
f"{elem.name:<40} = {str(elem.value)[:60]}")
# Sequences nest. PHI hides in the nesting.
if elem.VR == "SQ":
for i, item in enumerate(elem.value):
print(f"{pad} -- item {i} --")
_walk(item, depth + 2)
print("=== FILE META ===")
_walk(ds.file_meta)
print("\n=== DATASET ===")
_walk(ds)
ds = pydicom.dcmread("input.dcm")
inspect(ds)
The recursion matters. PS3.15 requires acting on the listed attributes “whether contained in the main dataset or embedded in an Item of a Sequence of Items”, and a flat loop over ds silently skips every nested item. Referenced study sequences, request attributes, and source image sequences are classic hiding places.
Now the part that earns its keep: a risk triage that tells you where to look, rather than dumping everything.
from collections import defaultdict
# Names that should never survive de-identification
DIRECT_IDENTIFIERS = {
"PatientName", "PatientID", "PatientBirthDate", "PatientAddress",
"PatientTelephoneNumbers", "OtherPatientIDs", "OtherPatientNames",
"ReferringPhysicianName", "PerformingPhysicianName", "OperatorsName",
"PhysiciansOfRecord", "NameOfPhysiciansReadingStudy",
"InstitutionName", "InstitutionAddress", "StationName",
"AccessionNumber", "StudyID", "IssuerOfPatientID",
}
# Free-text fields where operators type anything, names included
FREE_TEXT = {
"StudyDescription", "SeriesDescription", "ImageComments",
"PatientComments", "AdditionalPatientHistory", "RequestedProcedureDescription",
"PerformedProcedureStepDescription", "DerivationDescription",
}
def audit(ds: Dataset) -> dict:
findings = defaultdict(list)
def _scan(dataset, path=""):
for elem in dataset:
loc = f"{path}{elem.name}"
if elem.tag.is_private:
findings["private"].append((str(elem.tag), loc, str(elem.value)[:50]))
elif elem.name in DIRECT_IDENTIFIERS and elem.value not in ("", None):
findings["direct"].append((str(elem.tag), loc, str(elem.value)[:50]))
elif elem.name in FREE_TEXT and elem.value:
findings["free_text"].append((str(elem.tag), loc, str(elem.value)[:50]))
elif elem.VR in ("DA", "DT", "TM") and elem.value:
findings["temporal"].append((str(elem.tag), loc, str(elem.value)))
elif elem.VR == "UI" and elem.name.endswith("UID"):
findings["uid"].append((str(elem.tag), loc, str(elem.value)))
if elem.VR == "SQ":
for i, item in enumerate(elem.value):
_scan(item, path=f"{loc}[{i}]/")
_scan(ds)
# Pixel-level risk flags
if ds.get("BurnedInAnnotation", "").upper() == "YES":
findings["pixel"].append(("(0028,0301)", "BurnedInAnnotation", "YES"))
if ds.get("Modality") in ("US", "SC", "XC", "OT"):
findings["pixel"].append(("(0008,0060)", "Modality",
f"{ds.Modality}: burned-in text likely"))
if "OverlayData" in ds or (0x6000, 0x3000) in ds:
findings["pixel"].append(("(6000,3000)", "OverlayData", "overlay plane present"))
return dict(findings)
report = audit(ds)
for category, items in report.items():
print(f"\n### {category.upper()} ({len(items)} findings)")
for tag, name, value in items:
print(f" {tag} {name} = {value}")
Run this on a handful of studies from each scanner in your institution before you write a single line of scrubbing code. The output is frequently sobering. On our CT scanners the private block alone routinely holds forty or more undocumented elements, and reading them is how I found that date of birth.
Two habits worth building. First, run the audit again after de-identification on the output files: the direct, private, and temporal buckets should be empty or deliberately justified. That closes the loop, and it is the same code. Second, if you want the standard’s own view rather than a hand-rolled list, pydicom exposes the confidentiality profiles directly:
from pydicom._dicom_dict import DicomDictionary
# Which tags does PS3.15 Basic Profile actually touch?
# The Stanford `deid` package ships machine-readable recipes for this.
from deid.config import DeidRecipe
recipe = DeidRecipe() # loads the default PS3.15-derived recipe
print(recipe.get_actions()[:10])
For a one-off visual check outside Python, dcmdump (from DCMTK) and the gdcmdump --print command both give a fast, complete textual dump, and DCMTK’s dcmdump +P lets you query single tags in a shell loop. Handy for scripting a quick institutional survey.
2. Header scrubbing with a proper de-identification declaration
import pydicom
from pydicom.dataset import Dataset
# Direct identifiers to empty (Z-style) or remove (X-style)
BLANK_TAGS = [
"PatientName", "PatientID", "PatientBirthDate", "PatientSex",
"OtherPatientIDs", "OtherPatientNames", "PatientAddress",
"PatientTelephoneNumbers", "PatientMotherBirthName",
"ReferringPhysicianName", "PerformingPhysicianName",
"PhysiciansOfRecord", "OperatorsName", "NameOfPhysiciansReadingStudy",
"InstitutionName", "InstitutionAddress", "InstitutionalDepartmentName",
"StationName", "AccessionNumber", "StudyID",
]
def deidentify_header(ds: Dataset) -> Dataset:
# 1. Remove ALL private tags. The single most common leak source.
ds.remove_private_tags()
# 2. Blank direct identifiers that exist in this object
for tag in BLANK_TAGS:
if tag in ds:
ds.data_element(tag).value = ""
# 3. Strip anything with a date/time VR by VR, not by name,
# so new date tags in future IODs are still caught.
def _scrub_dates(dataset, elem):
if elem.VR in ("DA", "DT", "TM"):
elem.value = ""
ds.walk(_scrub_dates)
# 4. Declare what we did (PS3.15 conformance signal)
ds.PatientIdentityRemoved = "YES"
ds.DeidentificationMethod = "Custom pydicom pipeline, PS3.15 Basic Profile"
return ds
ds = pydicom.dcmread("input.dcm")
ds = deidentify_header(ds)
ds.save_as("output_deid.dcm")
Note what this does not do yet: it flattens all dates, which breaks longitudinal analysis. In practice you almost never want that. Keep reading.
3. Consistent UID remapping (preserve the study hierarchy)
If you randomize UIDs independently, series stop belonging to studies and instances stop belonging to series. Use deterministic generation so the same source UID always maps to the same new UID across every file.
from pydicom.uid import generate_uid
# Your organization's registered UID root. Do not use a made-up one
# in production. Register through your national body or IANA.
ORG_ROOT = "1.2.826.0.1.3680043.10.9999"
PROJECT_SALT = "cardiac-registry-2026" # keep secret if you want irreversibility
def remap_uid(original_uid: str) -> str:
# entropy_srcs makes the output deterministic AND collision-resistant:
# same inputs -> same UID, every time, in every file.
return generate_uid(prefix=ORG_ROOT + ".",
entropy_srcs=[PROJECT_SALT, original_uid])
for uid_tag in ["StudyInstanceUID", "SeriesInstanceUID",
"SOPInstanceUID", "FrameOfReferenceUID"]:
if uid_tag in ds:
ds.data_element(uid_tag).value = remap_uid(ds.data_element(uid_tag).value)
# The SOPInstanceUID also lives in file meta; keep them in sync
ds.file_meta.MediaStorageSOPInstanceUID = ds.SOPInstanceUID
Keep the salt secret and unrecoverable and this is anonymization. Store it in a key vault and it is pseudonymization. The code is identical; the governance is not.
4. Date shifting instead of date deletion
The “Retain Longitudinal Temporal Information with Modified Dates” option, done right. Every date for a given patient shifts by the same random offset, so intervals between visits are preserved while absolute dates become meaningless.
import hashlib
from datetime import datetime, timedelta
def patient_offset(patient_uid: str, secret: str, max_days: int = 730) -> int:
# Deterministic per-patient shift in [-max_days, 0]
h = int(hashlib.sha256((secret + patient_uid).encode()).hexdigest(), 16)
return -(h % max_days)
def shift_da(da: str, offset_days: int) -> str:
if not da:
return da
d = datetime.strptime(da, "%Y%m%d")
return (d + timedelta(days=offset_days)).strftime("%Y%m%d")
offset = patient_offset(original_patient_id, PROJECT_SALT)
for tag in ["StudyDate", "SeriesDate", "AcquisitionDate", "ContentDate"]:
if tag in ds and ds.data_element(tag).value:
ds.data_element(tag).value = shift_da(ds.data_element(tag).value, offset)
For a serial imaging study of ventricular remodelling, this is the difference between usable and destroyed data.
5. Burned-in pixel PHI: detect and redact
Do not trust BurnedInAnnotation. For modalities that stamp text (US, SC, screen captures), OCR the frame and black out any text region.
import numpy as np
import pytesseract
from PIL import Image
def redact_burned_in_text(ds: Dataset) -> Dataset:
arr = ds.pixel_array
# Normalize to 8-bit grayscale for the OCR engine
lo, hi = float(arr.min()), float(arr.max())
img8 = np.zeros_like(arr, dtype=np.uint8) if hi == lo else \
(255 * (arr.astype(np.float32) - lo) / (hi - lo)).astype(np.uint8)
data = pytesseract.image_to_data(
Image.fromarray(img8), output_type=pytesseract.Output.DICT
)
redacted = False
for i, txt in enumerate(data["text"]):
if txt.strip() and int(data["conf"][i]) > 40:
x, y, w, h = (data["left"][i], data["top"][i],
data["width"][i], data["height"][i])
arr[y:y+h, x:x+w] = arr.min() # blackout
redacted = True
if redacted:
ds.PixelData = arr.tobytes()
ds.BurnedInAnnotation = "NO"
return ds
This is a first pass, not a guarantee. OCR misses stylized fonts and low-contrast overlays. Human review of a sample is not optional for anything you publish.
6. Defacing and format conversion, at the edge of a header pipeline
For head-inclusive volumes, chain a defacing step (pydeface, mri_deface, or afni 3dSkullStrip) before release, and treat DICOM to NIfTI conversion as its own auditable stage:
# dcm2niix: convert, and crucially, anonymize the BIDS JSON sidecar too
dcm2niix -ba y -f "%i_%p" -o ./nifti_out ./dicom_in
# -ba y : anonymize BIDS sidecar (strip patient/date fields)
# then defacing on the volume itself:
pydeface ./nifti_out/sub01.nii.gz --outfile ./nifti_out/sub01_defaced.nii.gz
The -ba y flag is the one people forget. Without it, the JSON sidecar undoes your header work.
A note on not building this yourself
For production, lean on tools that encode the standard’s intent. The Stanford deid library ships editable recipes mapping directly to PS3.15 actions. RSNA’s CTP (Clinical Trial Processor) is the reference pipeline for multicentre trials. dcm4che and GDCM provide battle-tested command-line anonymizers. Aryanto and colleagues tested ten free toolkits and found that, with default settings, only one removed every required element. Defaults lie. Configure explicitly, then verify.
Verification: the step everyone skips
Would you trust a de-identification pipeline you never audited against an adversary? A defensible workflow closes with a re-identification attempt, not a checkbox. Diff the output header against a known identifier list. Re-run OCR on a pixel sample. For head imaging, attempt a face reconstruction on a handful of cases and see whether it renders a usable surface. The 2015 tooling comparison and the 2019 face-recognition study exist precisely because the “it looked clean” assumption keeps failing in the literature.
De-identification is not a filter you run once. It is a risk position you defend.
Key takeaways
- Start by auditing, not scrubbing. Dump and triage the real content of files from every scanner you draw from, recursing into sequences, before you write a tag list.
- Blanking the patient name is roughly 5% of the job. PHI hides in dates, UIDs, private tags, structured content, the file preamble, and the pixels themselves.
- Use the DICOM PS3.15 Annex E Basic Profile plus explicit options as your framework, and declare conformance with
PatientIdentityRemovedand aDeidentificationMethodCodeSequence. - Remap UIDs consistently and shift dates consistently per patient to preserve analytic value without leaking identity.
- Headers cannot protect faces. Brain and head-inclusive volumes need defacing or skull-stripping; even chest X-rays carry biometric re-identification risk.
- NIfTI is not an anonymizer. It drops metadata by omission, but the face stays in the voxels and identifiers migrate to sidecars and filenames.
- Anonymization versus pseudonymization is a governance decision about the key, not a code difference. Know which one you are actually doing.
- Never trust default settings. Configure, then attempt re-identification before you release anything.
References
- NEMA. DICOM PS3.15: Security and System Management Profiles, Annex E, Attribute Confidentiality Profiles. dicom.nema.org
- Schwarz CG, Kremers WK, Therneau TM, et al. Identification of Anonymous MRI Research Participants with Face-Recognition Software. N Engl J Med. 2019;381(17):1684–1686. doi:10.1056/NEJMc1908881
- Packhäuser K, Gündel S, Münster N, et al. Deep learning-based patient re-identification is able to exploit the biometric nature of medical chest X-ray data. Sci Rep. 2022;12:14851. doi:10.1038/s41598-022-19045-3
- Aryanto KYE, Oudkerk M, van Ooijen PMA. Free DICOM de-identification tools in clinical research: functioning and safety of patient privacy. Eur Radiol. 2015;25(12):3685–3695. doi:10.1007/s00330-015-3794-0
- Clunie DA. Letter: Free DICOM de-identification tools in clinical research: functioning and safety of patient privacy. European Radiology, Opinions section (online correspondence), 20 April 2016. journals.myesr.org
- Aryanto KYE, Oudkerk M, van Ooijen PMA. Reply to: Free DICOM de-identification tools in clinical research. European Radiology, Opinions section (online correspondence), 2016. journals.myesr.org
- Moore SM, Maffitt DR, Smith KE, et al. De-identification of medical images with retention of scientific research value. RadioGraphics. 2015;35(3):727–735.
- Larobina M, Murino L. Medical image file formats. J Digit Imaging. 2014;27(2):200–206. doi:10.1007/s10278-013-9657-9
- Bischoff-Grethe A, Ozyurt IB, Busa E, et al. A technique for the de-identification of structural brain MR images. Hum Brain Mapp. 2007;28(9):892–903.
- pydicom documentation: de-identification and anonymization. pydicom.github.io
- Stanford
deid: DICOM de-identification with editable recipes. github.com/pydicom/deid - RSNA Clinical Trial Processor (CTP). mircwiki.rsna.org
- Rorden C.
dcm2niix: DICOM to NIfTI conversion. github.com/rordenlab/dcm2niix










































