Skip to main content

dicom-ai-router

CI

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 &amp; 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"]

AI key image returned to PACS

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.

ct-qa key image 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)

Source distribution for dicom-ai-router 0.3.0
File Size Uploaded
dicom_ai_router-0.3.0.tar.gz 46.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for dicom-ai-router 0.3.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page