DICOM & Medical Imaging Standards
If you build a healthcare ML system and you have not yet had a fight with DICOM, you have not actually shipped a healthcare ML system. Every CT, every MRI, every X-ray, every ultrasound in every hospital on earth lands in DICOM. Loving it is optional. Reading it correctly is not.
After this lesson, you will be able to:
- Explain what DICOM is, why every clinical imaging pipeline hits it, and what makes it different from PNG/JPEG
- Decode the four-level DICOM hierarchy (Patient, Study, Series, Instance) and why ML pipelines often break the Series boundary
- Read a DICOM file in Python with pydicom, including the pixel array, the rescale slope/intercept, and the window/level
- Anonymize a DICOM correctly for ML training (PHI removal per the DICOM PS3.15 Basic Profile, not just blanking PatientName)
- Handle the three pitfalls that bite every newcomer: byte order, photometric interpretation (MONOCHROME1 vs MONOCHROME2), and HU conversion
- Order CT/MRI slices correctly into a 3D volume for ML — the SliceLocation vs ImagePositionPatient trap
- Connect DICOM to HIPAA, PACS, and the C-STORE/C-FIND protocols you will see referenced in every hospital integration project
Before You Start
#Why DICOM Will Show Up On Day Three Of Every Healthcare ML Project
If your project touches any imaging modality — radiology, cardiology, ophthalmology, pathology whole-slide imaging, dental, dermoscopy, even some ultrasound and endoscopy workflows — the data does not arrive as PNG. It arrives as DICOM, or it lives in a PACS that speaks DICOM, or it transits a vendor neutral archive that stores DICOM, or it comes off the modality as DICOM and gets converted to NIfTI by a research group that promptly loses half the metadata you actually needed.
#What DICOM Actually Is
DICOM is three things at once:
- A file format. A
.dcmfile is a sequence of (tag, VR, length, value) tuples. The tag identifies the field, the VR is the "Value Representation" (UI = unique identifier, DA = date, PN = person name, US = unsigned short, etc.), the length is in bytes, and the value is the data. - A network protocol. Modalities, PACS servers, and viewers talk to each other over DIMSE (DICOM Message Service Element) on top of TCP, traditionally on port 104 or 11112. The core operations are C-STORE (push an image), C-FIND (search), C-MOVE (pull an image), and C-ECHO (ping).
- A workflow standard. DICOM defines Worklists, Modality Performed Procedure Step, Structured Reports, Presentation States, RT (radiation therapy) objects, and many other "IODs" (Information Object Definitions).
For an ML engineer, you mostly care about (1) the file format and (3) the metadata. You will brush against (2) the network protocol when integrating with a hospital, and a member of your team will spend three weeks of their life debugging it.
#The Four-Level DICOM Hierarchy
| Level | Unique ID | Real-world meaning |
|---|---|---|
| Patient | PatientID (0010,0020) | A person |
| Study | StudyInstanceUID | One imaging encounter, e.g. "Chest CT on 2025-11-04" |
| Series | SeriesInstanceUID | One acquisition or reconstruction within the study |
| Instance | SOPInstanceUID | One image file (one slice for CT, one frame for X-ray) |
#Reading a DICOM File in Python
import pydicom
# Read the file. This parses the header but lazy-loads pixel data.
ds = pydicom.dcmread("scan.dcm")
# Access metadata by name (the "keyword" form)
print(ds.PatientID) # e.g. "ANON-00451"
print(ds.Modality) # "CT", "MR", "CR", "DX", "MG", "US", ...
print(ds.SeriesDescription) # "Axial Chest 1.25mm"
print(ds.SliceThickness) # 1.25
print(ds.PixelSpacing) # [0.7, 0.7] (mm per pixel, row, column)
# Access by tag (always works even when keyword unknown)
print(ds[0x0010, 0x0020].value) # PatientID
# Pixel data
import numpy as np
arr = ds.pixel_array # shape (rows, cols) for 2D, (frames, rows, cols) for multi-frame
print(arr.dtype, arr.shape, arr.min(), arr.max())That is the easy part. Now for the parts that bite.
#Pitfall 1: Rescale Slope and Intercept (Hounsfield Units)
#Pitfall 2: Photometric Interpretation (MONOCHROME1 vs MONOCHROME2)
PhotometricInterpretation tag tells you whether the maximum pixel value is black or white.- MONOCHROME2. High pixel value = white. (Used in CT, MR, almost everywhere.)
- MONOCHROME1. High pixel value = black. Inverted. (Still occasionally used for X-rays and some legacy mammography.)
If you ignore this tag, half your X-rays will be color-inverted relative to the other half and your CNN will learn nothing.
if ds.PhotometricInterpretation == "MONOCHROME1":
arr = arr.max() - arr # invertRGB, PALETTE COLOR, YBR_FULL_422, and others for color modalities — handle them explicitly or you will silently grab garbage.#Pitfall 3: Windowing
- Lung window: WL = -600, WW = 1500. Shows lung parenchyma well.
- Soft tissue: WL = 40, WW = 400. Shows abdomen, mediastinum.
- Bone: WL = 400, WW = 1800. Shows fractures.
WindowCenter (0028,1050) and WindowWidth (0028,1051). For ML you typically either train on raw HU values (preferred for CT, no information loss) or pick a fixed window for the task (lung window for nodule detection, bone window for fracture). Never train on whatever window happens to be in the header — that is the radiologist's preference, not a feature of the image.#Building a 3D Volume from a CT Series
ImagePositionPatient (0020,0032), which gives the (x, y, z) coordinates of the slice's top-left pixel in patient coordinates.slices = [pydicom.dcmread(p) for p in glob.glob("series/*.dcm")]
slices.sort(key=lambda d: float(d.ImagePositionPatient[2]))
volume = np.stack([s.pixel_array for s in slices]) # (Z, H, W)
hu_volume = volume * slices[0].RescaleSlope + slices[0].RescaleInterceptIf slices have varying RescaleSlope/Intercept (rare but it happens with multi-vendor reformats), apply per-slice. If the Z spacing is not uniform (common with breath-held acquisitions), resample to isotropic before any 3D CNN.
#Anonymization for ML Training
Naïve approach (wrong, will get you in trouble): blanking PatientName and PatientID.
Correct approach:
- Apply the DICOM PS3.15 Basic Profile. Removes 100+ tags including the obvious ones (PatientName, PatientID, PatientBirthDate, PatientSex, PatientAddress, AccessionNumber, InstitutionName) and the less-obvious ones (ReferringPhysicianName, OperatorsName, RequestingPhysician, IssuerOfPatientID, OtherPatientIDsSequence).
- Re-map UIDs deterministically. Replace StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID with new UIDs derived from a salted hash. Keep the mapping in a separate secured database — you may need it for re-linking labels later.
- Shift dates (do not blank them). A 6-month follow-up CT compared to a baseline is clinically meaningful. Shift every date for a patient by the same random offset of 0-365 days. Preserves intervals, destroys absolute dates.
- Scrub burned-in PHI in the pixels. Old ultrasound and CR studies sometimes have the patient name typed into the image pixels themselves. You need OCR-based detection (or a hand-coded crop) for these — DICOM tag scrubbing does not touch them.
- Strip private tags by default. Vendor private tags (group number is odd) can contain raw debug data including patient identifiers. Remove unless you have audited each one.
- Document everything in a de-identification log for your IRB.
pydicom-deid and presidio libraries automate most of it.#The 60-Line Reference Script
This is a defensible starting point. It reads a DICOM, applies HU conversion, fixes photometric interpretation, de-identifies, and writes the cleaned copy.
"""Minimal DICOM anonymizer + HU normalizer for ML training pipelines."""
import hashlib
import pydicom
from pydicom.uid import generate_uid
import numpy as np
# Tags removed under DICOM PS3.15 Basic Profile (subset — full list is ~100 tags)
PHI_TAGS_TO_REMOVE = [
"PatientName", "PatientBirthDate", "PatientSex", "PatientAddress",
"PatientTelephoneNumbers", "OtherPatientIDs", "OtherPatientNames",
"ReferringPhysicianName", "RequestingPhysician", "PerformingPhysicianName",
"OperatorsName", "InstitutionName", "InstitutionAddress",
"InstitutionalDepartmentName", "AccessionNumber", "StudyID",
"DeviceSerialNumber", "StationName",
]
SALT = b"replace-with-secret-from-your-secrets-manager"
def hash_id(raw_id: str) -> str:
return hashlib.sha256(SALT + raw_id.encode()).hexdigest()[:16]
def shift_date(date_str: str, offset_days: int) -> str:
from datetime import datetime, timedelta
if not date_str or len(date_str) < 8:
return ""
d = datetime.strptime(date_str[:8], "%Y%m%d") + timedelta(days=offset_days)
return d.strftime("%Y%m%d")
def to_hu(ds: pydicom.Dataset) -> np.ndarray:
arr = ds.pixel_array.astype(np.int16)
slope = float(getattr(ds, "RescaleSlope", 1.0))
inter = float(getattr(ds, "RescaleIntercept", 0.0))
hu = arr * slope + inter
# Honor photometric interpretation for X-rays
if ds.get("PhotometricInterpretation", "") == "MONOCHROME1":
hu = hu.max() - hu
return hu.astype(np.int16)
def anonymize(in_path: str, out_path: str, date_offset_days: int = 0) -> np.ndarray:
ds = pydicom.dcmread(in_path)
# 1. Compute HU for ML before we touch metadata
hu_array = to_hu(ds)
# 2. Replace patient ID with deterministic hash
original_pid = str(ds.get("PatientID", "UNKNOWN"))
ds.PatientID = "ANON-" + hash_id(original_pid)
# 3. Re-map UIDs (keep linkage by hashing the originals)
ds.StudyInstanceUID = generate_uid(entropy_srcs=[ds.StudyInstanceUID, SALT.hex()])
ds.SeriesInstanceUID = generate_uid(entropy_srcs=[ds.SeriesInstanceUID, SALT.hex()])
ds.SOPInstanceUID = generate_uid(entropy_srcs=[ds.SOPInstanceUID, SALT.hex()])
ds.file_meta.MediaStorageSOPInstanceUID = ds.SOPInstanceUID
# 4. Shift dates
for tag in ("StudyDate", "SeriesDate", "AcquisitionDate", "ContentDate"):
if tag in ds:
setattr(ds, tag, shift_date(getattr(ds, tag), date_offset_days))
# 5. Wipe known PHI tags
for tag in PHI_TAGS_TO_REMOVE:
if tag in ds:
delattr(ds, tag)
# 6. Remove all private (odd-group) tags by default
ds.remove_private_tags()
ds.save_as(out_path)
return hu_arraypydicom-deid, audit logging, and an integration test that round-trips known PHI to verify it has actually been removed.#PACS, C-STORE, C-FIND, and DICOMweb
You will eventually need to pull data from a hospital PACS. The classical protocol is DIMSE over TCP:
- C-ECHO. Application-level ping. Always test first.
- C-FIND. Query. You send a "query Identifier" with the tags you want to match (PatientID, StudyDate, Modality) and the level (Patient/Study/Series/Image). The PACS responds with matching entries.
- C-MOVE. Instruct the PACS to push images to a third Application Entity (your storage SCP).
- C-GET. Pull images directly. Less commonly enabled.
- C-STORE. Push an image to the PACS (or to your SCP).
- QIDO-RS. Query (replaces C-FIND).
- WADO-RS. Retrieve (replaces C-MOVE/C-GET).
- STOW-RS. Store (replaces C-STORE).
If you have a choice, choose DICOMweb. Easier to firewall, easier to authenticate (OAuth2), and it returns JSON metadata. Vendors like Google Healthcare API, AWS HealthImaging, and Azure Health Data Services all expose DICOMweb endpoints.
#HIPAA, DICOM, and the Compliance Stack
| HIPAA Identifier | DICOM Tag(s) |
|---|---|
| Names | PatientName, ReferringPhysicianName, OperatorsName |
| Geographic subdivisions | PatientAddress, InstitutionAddress |
| Dates (except year) | StudyDate, SeriesDate, PatientBirthDate, AcquisitionDateTime |
| Phone, fax, email | PatientTelephoneNumbers, PatientPhoneNumbers |
| Medical record numbers | PatientID, AccessionNumber |
| Device identifiers | DeviceSerialNumber, StationName |
| Biometric identifiers | Burned-in pixels (face on head CT, retinal scans) |
| Full-face photographs | Burned-in pixels |
DICOM Standard PS3.15: Security and System Management Profiles
DICOM Standards Committee (2024)
Annex E (Attribute Confidentiality Profiles) is the canonical specification of which tags to remove for de-identification. The 'Basic Application Confidentiality Profile' is the minimum bar; the 'Clean Pixel Data' and 'Clean Recognizable Visual Features' profiles handle the burned-in-PHI and defacing cases.
#ML-Specific Considerations
pixel_array with shape (N, H, W) but the per-frame timestamps live in FrameTime, FrameTimeVector, or FrameAcquisitionDateTime.#Try It Yourself
#Quick Check
You are training a chest CT model on 10,000 DICOMs from 800 patients. What is the correct split strategy?
#Key Takeaways
- DICOM is the format AND the protocol AND the workflow standard. Every clinical imaging deployment hits it, and the metadata is often more valuable than the pixels themselves
- The Patient/Study/Series/Instance hierarchy is sacred. Always split datasets on PatientID, never on Series or Instance
- The three universal pitfalls: rescale (HU conversion), photometric interpretation (MONOCHROME1 inversion), and windowing — get these right or your model silently trains on garbage
- Anonymization is legally required under HIPAA Safe Harbor. Use the DICOM PS3.15 Basic Profile, shift dates rather than blanking them, and scrub burned-in pixel PHI separately
- PACS speaks C-STORE/C-FIND/C-MOVE over DIMSE, or DICOMweb over HTTP. Choose DICOMweb for new integrations; QIDO/WADO/STOW is much easier to operate than classical DIMSE
- 3D volumes need isotropic resampling and IPP-based sorting. Never trust InstanceNumber or file names to give you the right Z order