dicom-ai-router
Get imaging AI into a live radiology workflow and back out again, without leaking PHI or slowing the scanner.
A working reference implementation of the part of clinical AI that usually breaks: the plumbing between the modality, the AI model and PACS. It receives studies over DICOM, routes them by header rules, de-identifies them, calls a model, rebuilds the answer as DICOM inside the patient's original study, and delivers it to PACS. Every hop is audited.
flowchart LR
CT["CT scanner<br/>(C-STORE)"] -->|DICOM :11112| R
subgraph R["AIROUTER"]
direction TB
RX["receive & ack<br/>immediately"] --> Q["study-complete<br/>quiet timer"] --> RU["routing rules<br/>(YAML)"]
RU --> DI["de-identify<br/>PS3.15 subset"] --> RE["re-identify via<br/>local crosswalk"] --> B["build SR +<br/>key image"]
end
DI -->|"HTTP, de-identified"| AI["AI model"]
AI -->|"findings JSON"| RE
B -->|DICOM C-STORE| PACS["PACS<br/>(Orthanc / mini-PACS)"]
RU -.->|no rule matched| X["IGNORED<br/>nothing leaves"]
Key image as it lands in PACS: the finding is circled, "NOT FOR DIAGNOSIS" is burned in, and the image sits in the real patient's study. The model itself only ever saw pseudonymous UIDs.
A daily-QA phantom with a +9 HU CT-number drift: five ROIs, a FAIL verdict and the reason, returned to PACS as a key image and SR.
Run it (about 2 minutes)
Requires Python 3.11+.
git clone https://github.com/seth2177/dicom-ai-router && cd dicom-ai-router
python -m pip install -r requirements.txt # Windows: py -3.12 -m pip install -r requirements.txt
python run_demo.py # Windows: py -3.12 run_demo.py
That one command starts a mini-PACS, the model server and the router. A synthetic CT scanner then sends a mix of chest CTs, head CTs and daily-QA water phantoms (some with injected CT-number drift or cupping):
patient truth router AI said PACS got
QA^DAILY QA FAIL (+9/2) DONE FAIL; CT +8.8 HU (water), noise 4.8 HU, uniformity OT,SR
SYNTHETIC^CARLOS 8mm right GGO DONE no finding OT,SR
SAMPLE^CARLOS 18mm left DONE pulmonary nodule, left lung; 17.2 mm; 0.99 OT,SR
TESTCASE^JANE head CT IGNORED no routing rule matched -
TESTCASE^ROBERT 14mm left GGO DONE no finding OT,SR
...
AI round-trip: median 175 ms over 11 studies
Phantoms go to ct-qa and chests go to the nodule model; the head CT matches no rule and never leaves the router.
The nodule misses are on purpose. The mock model can't see ground-glass nodules and under-measures some solid ones, and because every study carries ground truth, those errors can be measured (see Next stages).
Other things to try:
python run_demo.py --fail-rate 0.4 # flaky AI endpoint: watch retries with backoff keep studies flowing
python -m airouter status # state of every study (after a demo run)
python -m pip install -r requirements-dev.txt && python -m pytest -q
# PHI + site scrub, audit incl. negative control, receiver security,
# SR structure, QA model, routing, end to end over real DICOM networking
docker compose up --build # router + mock AI + Orthanc PACS at http://localhost:8042 (set AIROUTER_DEID_SALT first)
What it handles, and why
| Real-world problem | How the router handles it | Where |
|---|---|---|
| Phantom / daily-QA scans reaching a clinical model (junk findings, per-study billing) | QA rule first: QualityControlImage=YES plus vendor conventions; QA goes to a real ct-qa model, calibration scans are held |
router.yaml, airouter/mock_ai/qa.py |
| A slow receiver backs up the scanner's send queue | Answers C-STORE Success immediately, then does all work on worker threads |
router.py |
| DICOM has no "study finished" message | Quiet-period timer per study, tunable per site | router.py, router.yaml |
| Headers are inconsistent (blank BodyPartExamined) | Ordered regex rules with fallbacks; no match means nothing leaves | rules.py |
| PHI must not reach the model or vendor | PS3.15 Basic Profile subset: every date and every UID by VR, identity and site attributes, free-text patterns, private tags, overlays, preamble; burned-in images dropped | deid.py |
| Pseudonyms can be brute-forced if the salt is known | Keyed HMAC pseudonyms; the salt comes from AIROUTER_DEID_SALT and the router refuses to start without a strong one |
deid.py, config.py |
| Results must land on the right patient | Deterministic pseudonymous UIDs plus a local crosswalk | deid.py, store.py |
| Untrusted network input | UIDs validated before they become file paths; called AE enforced; optional calling-AE allow-list; atomic writes | router.py |
| Real studies contain localizers and dose screens | Dropped per image and audited, never failing the study; series: largest for single-volume models |
pipeline.py |
| Radiologists need to see it; systems need to parse it | Basic Text SR and a Secondary Capture key image, both in the original study | results.py |
| Picky PACS reject associations | Proposes only the SOP classes actually being sent | sender.py |
| Models and networks fail | Retry with exponential backoff; 4xx never retried; failures recorded, never dropped | ai_client.py, sender.py |
| Security review and turnaround time | JSONL audit of every hop with ms timings, no names or MRNs | audit.py |
Walkthrough of every hop: docs/HOW-IT-WORKS.md
Layout
airouter/ the router: receive, route, de-identify, infer, build results, send, audit
site_scrub.py + phi_audit.py: publish-grade site scrub and its independent audit
airouter/mock_ai/ model server: lung-nodule (explainable stand-in) and ct-qa (real phantom QA measurement)
airouter/tools/ synthetic CT scanner and mini-PACS
config/ router.yaml: AE titles, ports, endpoints, rules, de-id salt
tests/ unit and end-to-end tests over real DICOM networking on localhost
run_demo.py the whole pipeline in one command
Next stages
llm-eval-radiology. Every processed study leaves data/results/<uid>.json (what the AI said) next to data/truth/<uid>.json (what was really there). Stage 2 adds an LLM that drafts a report impression from the findings, then scores both layers against ground truth:
- Detector: sensitivity and specificity by size and density, laterality errors, measurement error
- LLM report: hallucinated or omitted findings, flipped laterality, altered measurements, missing follow-up language
hl7v2-fhir-bridge. The orders and reports around those studies still travel as HL7 v2. The bridge turns the radiology feed (ADT, ORM/OMI, ORU) into FHIR R4, so the order, the report and the imaging study the router worked on end up as linked FHIR resources.
Running the router for real
export AIROUTER_DEID_SALT=$(python -c "import secrets; print(secrets.token_hex(32))") # keep this secret, and stable
python -m airouter serve --config config/router.yaml
Windows PowerShell:
$env:AIROUTER_DEID_SALT = py -3.12 -c "import secrets; print(secrets.token_hex(32))"
py -3.12 -m airouter serve --config config/router.yaml
The demo generates a throwaway salt on every run. The service refuses to start without a real one.
Quality
- The test suite runs in CI on Linux and Windows, Python 3.11 and 3.12, plus
ruff. - Every SR and Secondary Capture the router produces validates against the DICOM standard (2026d IOD
definitions,
dicom-validator). - Adversarial testing, including attacking my own scrubber and audit, found real problems: a default salt
that made pseudonyms reversible, a multi-valued name that leaked past the audit, person names nested in
sequences reaching the model, a path traversal in the receiver, SR evidence grouped wrongly for
multi-series studies, and a dose SR that could stop a study being routed. Each is fixed and has a
regression test that fails on the old code (
tests/test_security.py). Known limits are listed under Scope and safety.
Scope and safety
Known limits: the de-identification is a documented subset of PS3.15, not a certified profile. It retains
study and series descriptions, which are pattern-scrubbed. Pixel data is never altered, so burned-in images are
dropped. The model transport is multipart/form-data, not STOW-RS. A revised result (after late images) is a new
instance in the same series; the SR does not yet reference its predecessor (PredecessorDocumentsSequence).
This is a demonstration and reference build, not a medical device. All patients, identifiers and images in the repo are synthetic. The de-identification covers a documented subset of the standard and does not alter pixel data. Don't point it at production PHI without a formal review.
Built by Seth Turnbo: 23 years on MRI/CT (GE, Philips, Siemens), multi-vendor DICOM/HL7/PACS integration. LinkedIn
Metadata
Release files for dicom-ai-router 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| dicom_ai_router-0.3.0.tar.gz | 46.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| dicom_ai_router-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 103.4 kB
Release files / dicom_ai_router-0.3.0.tar.gz
| Download URL | dicom_ai_router-0.3.0.tar.gz |
|---|---|
| Size | 46.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f7870e3fef736be062f5303783a4a400b64a3fce627f44148c4062ecf6023279
|
|
BLAKE2b-256 checksum How to use checksums |
daaf2494216c396a7e24f9fe23214e2a295094059ba9fef36cdaf97a6aa343db
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.
Transparency logRelease files / dicom_ai_router-0.3.0-py3-none-any.whl
| Download URL | dicom_ai_router-0.3.0-py3-none-any.whl |
|---|---|
| Size | 57.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0e551ba718881d991730852f89a922537de047f29692e4480560bea1112eea87
|
|
BLAKE2b-256 checksum How to use checksums |
95c6d3f9d043d976e51e44684878c26c10498117c4009849be906f64b9e7b9bf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.
Transparency log