A private, self-hosted medical imaging viewer for the web.
Upload the CD or DVD handed to you by a radiology department and explore it directly in your browser — no desktop software, cloud account, or data leaving your machine.
Quick start · Features · Roadmap · Architecture · AGPL-3.0
Runs as a single Docker container. Files are stored on a plain mounted volume, in a
directory tree you can read with ls.
The viewer. On CT, ROI statistics read in real Hounsfield units.
MPR — one volume, three linked planes, crosshairs in the radiology convention.
Your library. Drop a DVD on it and it sorts itself out. |
Your avatar, named passkeys, TOTP and recovery codes. |
The exams pictured are real scans whose identifying tags were rewritten before they were loaded. The patients shown are fictional.
Viewer — a dark, Weasis-style workstation.
- Stack scrolling, pan, zoom, window/level, with CT presets (lung, bone, brain, abdomen…)
- Measurements: length, angle, rectangle/ellipse ROI (mean, SD, min, max, area), probe. On CT these read in Hounsfield units — an ROI over air reads about −1000 HU.
- Viewport grids from 1×1 to 3×3; drag any series into any cell
- MPR: reconstruct a volume into linked axial / sagittal / coronal planes with crosshairs
- Cine playback — press Space to play any stack as a loop. The speed defaults to
Auto, which is the rate the DICOM header itself asks for
(
RecommendedDisplayFrameRate, elseCineRate, elseFrameTime), so an ultrasound loop plays back at the rate it was recorded rather than at a number someone guessed. A plain CT carries no timing, so it falls back to a comfortable scrolling speed. Override it from the toolbar (10–60 fps). - Searchable DICOM tag inspector, and PNG export with the measurements drawn on
Library — the study list is searched and paged on the server, so a large library stays fast and, more to the point, honest: the header count is the real total, not the number of rows that happened to fit on the page. (It used to say "100 exams" to anyone with more than a hundred.) Search covers patient name, patient ID, accession number and study description.
Ingest — built for the mess that real burned discs actually contain.
- Accepts a ZIP or TAR of a disc, a dropped folder, or loose files
- Detects DICOM by content, not extension (files on a real DVD are named
A0001,B0001… with no extension at all) - Uses
DICOMDIRwhen present, but always walks the whole tree too — burned-CD DICOMDIRs are frequently stale or incomplete - Silently ignores the bundled viewers, DLLs, autorun stubs and PDFs that ship on every disc, instead of reporting 40 "errors"
- Re-uploading the same disc is a no-op (deduplicated on SOP Instance UID)
- Runs in the background with a live progress bar and a per-file error report
Accounts — self-registration; the first account created becomes the administrator. Each user sees only their own exams, and every API path is scoped to them.
Sign-in — password, plus two optional factors you enable per account from /account:
- Passkeys. Fingerprint, face or device PIN — and that's the whole sign-in. No email,
no password, no code. A passkey is already two factors (the device you hold, and the
biometric that unlocks it), so it deliberately does not then ask for a TOTP code as well.
Each one gets a name — suggested from your device ("iPhone", "Windows PC"…) when you add
it, yours to edit, and renameable later by clicking it on
/account. - Authenticator app (TOTP). A 6-digit code after your password, with 10 one-time recovery codes for when your phone isn't to hand.
Browsers only permit passkeys in a secure context: HTTPS, or
localhost. They will not work over plain HTTP on a LAN address — the UI says so rather than failing silently. Password and TOTP work everywhere.
Profile — the header carries a user menu now: your avatar, email, role, and quick links
to Account & security and, for admins, Administration. Avatars are initials on a coloured
circle — 8 colours × 4 styles, picked on /account — and an untouched account still gets a
stable one, derived from a hash of your email, so nothing looks unconfigured. A Gravatar
opt-in sits next to it, off by default: turning it on sends a hash of your email to
gravatar.com and reveals your IP to them. It's the one exception to "no data leaving your
machine" at the top of this page — everything else genuinely makes zero third-party requests.
Languages — English, French, German, Spanish and Italian, switchable from the user menu. It follows your browser until you choose, and the choice is stored on your account, so it survives a reload and follows you to another device.
This is more than swapped strings. Numbers and dates go through Intl, so a length reads
57,3 mm in French and 57.3 mm in English — a decimal separator misread in a
measurement is a real error, not a cosmetic one. The labels drawn onto the image are
translated too, not just the panel beside them. Each language picks its own keyboard
shortcuts, because W for Window means nothing once the tool is called Fenêtrage. And the
window presets use the term a radiologist in that language actually says — French says
Parenchyme pulmonaire, not a literal Poumon.
Some things are deliberately not translated, and translating them would be worse than
leaving them alone: modality codes (CT, MR, SR) and DICOM tag keywords are the standard's
own identifiers; HU is an eponym and is HU in every language; mm, mm² and ° are SI.
The clinical vocabulary was chosen carefully but has not been reviewed by a native-speaking radiologist. If a preset or a measurement label reads wrong to you in your language, that is worth an issue — see the note on who wrote this.
Nothing below is promised on a date. It is an honest list of what is missing, roughly in the order it is likely to be worth doing. Contributions and opinions on the ordering are welcome.
Small, visible gaps you notice within a few minutes of using it.
- Touch and a responsive layout. Today it assumes a mouse and a wide screen. Tablets are the obvious place to read a scan on the sofa.
- Window presets beyond CT. The presets are Hounsfield windows, which only mean anything on CT — so MR and ultrasound get a greyed-out button and nothing else.
- More languages, and a native review of the five that exist. The machinery is done —
a new language is a directory of JSON under
frontend/src/locales/and one line inlib/i18n.ts. What it cannot supply is a radiologist who reads scans in that language and can say whether the window presets are named the way they are named in the clinic.
Right now DICOMium is a one-way door: exams go in and can be read, but they cannot come back out. For something whose whole promise is your data stays yours, that is the wrong shape.
- Export a study — download it back as a plain zip of DICOM files.
- De-identified export, for handing a scan to a doctor, a second opinion, or a researcher without handing over the patient with it.
- STOW-RS, so other tools can push studies in over the standard rather than through the browser. (The DICOMweb API is read-only today: query and retrieve, no store.)
- DIMSE — receive studies directly from a modality or another archive.
Several object types are already ingested, indexed and listed — they simply have no renderer, so they sit greyed out in the series panel.
- Structured reports and dose reports, encapsulated PDF, and ECG waveforms. Your disc almost certainly contains at least one of these.
- Segmentation overlays.
- MIP and 3D volume rendering. MPR is done; these are the natural next step from it.
- Measurements that survive a reload. They currently live only in the browser: close the tab and they are gone. They should be saved against the study.
- Sharing — with another user of the same instance, or via an expiring read-only link. Studies are strictly private today, with no way to share at all.
- OIDC / forward-auth, so DICOMium can sit behind an SSO you already run.
- A Prometheus
/metricsendpoint, for anyone who monitors their own boxes.
Deliberately out of scope, so nobody waits for them:
- It is not a certified diagnostic device, and will not seek to become one. Read your own scans with it; do not make clinical decisions on it.
- It is not a full PACS. No worklist, no scheduling, no reporting workflow.
- It is not multi-tenant SaaS. It is built to be run by one person or one household, on their own machine.
The image is published on Docker Hub as
acaranta/dicomium, built for amd64 and
arm64 — so it runs on a normal server, an Apple Silicon Mac, or a Raspberry Pi.
curl -O https://raw.githubusercontent.com/acaranta/DICOMium/main/docker-compose.yml
docker compose up -dOpen http://localhost:8080, create an account (the first one becomes the administrator), and drag an exam onto the drop zone.
Or without compose at all:
docker run -d --name dicomium -p 8080:8080 \
-v ./data:/data \
-v ./dicomfiles:/dicomfiles \
acaranta/dicomium:latestTwo volumes, and that is the whole story:
/dicomfiles |
your DICOM files, in a plain browsable tree. Point this at a real disk — one exam DVD is ~600 MB. |
/data |
the SQLite index, thumbnails, and the session secret. Rebuildable from /dicomfiles; the images are the source of truth. |
All configuration lives in the environment: block of docker-compose.yml — there is no
separate .env file, so there is exactly one place to look.
To update:
docker compose pull && docker compose up -dIf you would rather build from source than trust a prebuilt image — entirely reasonable for something you are pointing at your medical records:
git clone https://github.com/acaranta/DICOMium.git && cd DICOMium
docker compose -f docker-compose.yml -f docker-compose.build.yml up --buildThat reuses the same ports, volumes and environment from docker-compose.yml, and simply
builds the image locally instead of pulling it.
DICOM files land on the /dicomfiles volume in a human-browsable tree:
/dicomfiles/<user>/<Patient>__<PatientID>/<StudyDate>_<Description>_<hash>/
<SeriesNo>_<Description>_<hash>/<SOPInstanceUID>.dcm
for example:
/dicomfiles/jane/DOE_JANE__ANON-00417/20240115_ABDO_PELVIS_f9d6120c/
002_AP_Sans_IV_ff4531df/1.2.840.113619.2.5.166636469.65900.1615815612.746.dcm
Every path component is sanitized to ASCII — DICOM descriptions contain slashes
(ABDO/PELVIS), accents and trailing spaces, none of which are safe as directory names.
The trailing hash makes two same-day studies with the same description distinct.
The SQLite index, thumbnails and session secret live on the separate /data volume. The
index is a cache: the DICOM files themselves are the source of truth.
Set these in the environment: block of docker-compose.yml. Every one has a working
default.
| Variable | Default | |
|---|---|---|
REGISTRATION_ENABLED |
true |
Set false to close signups once your users exist |
COOKIE_SECURE |
false |
Set true when serving over HTTPS |
SESSION_TTL_HOURS |
168 |
How long a sign-in lasts |
MAX_UPLOAD_MB |
8192 |
A burned DVD is typically 300–700 MB |
MAX_EXTRACT_MB |
20480 |
Zip-bomb guard |
DICOMWEB_TRANSCODE |
auto |
auto streams compressed frames straight to the browser |
ADMIN_EMAIL / ADMIN_PASSWORD |
— | Optional: pre-create an admin instead of self-registering |
TOTP_ISSUER |
DICOMium |
The label your authenticator app shows |
WEBAUTHN_RP_ID / WEBAUTHN_ORIGIN |
— | Optional: pin the passkey domain. Derived from the request otherwise |
Recovery codes are the normal way back in. If a sole administrator loses their authenticator and their recovery codes, clear their factors from inside the container:
docker compose exec dicomium python -m app.cli list-users
docker compose exec dicomium python -m app.cli reset-mfa you@example.comThey can then sign in with their password alone, and re-enrol.
One container, two processes under supervisor: nginx serves the SPA and reverse-proxies
uvicorn. Because they share an origin, the HttpOnly session cookie rides along on the
image requests with no CORS and no token plumbing.
browser ──┬─ / → nginx → static SPA (React, Cornerstone3D)
├─ /api/* → uvicorn (auth, upload, library)
└─ /dicomweb/* → uvicorn (QIDO-RS, WADO-RS) ← the viewer loads pixels here
│
SQLite index (/data) DICOM files (/dicomfiles)
The image API is a minimal DICOMweb subset (QIDO-RS + WADO-RS), which is what Cornerstone3D speaks natively — so it streams frames per-slice rather than downloading whole files, and you could point another DICOMweb client at the same server.
Compressed frames (JPEG, JPEG-LS, JPEG 2000, HTJ2K, RLE) are passed through to the browser's wasm decoders untouched, costing the server no CPU. Only syntaxes the browser cannot decode are decoded server-side.
Stack: FastAPI · pydicom · SQLAlchemy 2.0 (async) · SQLite · uv — React 18 · Vite · TypeScript · Tailwind · Cornerstone3D 5.
GET /api/studies returns a page, not a bare array:
{ "items": [ … ], "total": 2421, "limit": 50, "offset": 0 }total counts the studies matching the current filters, which is what lets the interface state
a real number instead of counting the rows it was handed. limit is bounded to 1–500. The
DICOMweb API under /dicomweb/* is untouched and keeps QIDO-RS's standard bare-list shape.
Backend and frontend run separately, with Vite proxying the API so the two stay same-origin (exactly as nginx does in production):
# terminal 1
cd backend
DATA_DIR=../data DICOM_ROOT=../dicomfiles uv run uvicorn app.main:app --reload --port 8000
# terminal 2
cd frontend
npm install
npm run dev # http://localhost:5173If port 8000 is taken, run the backend elsewhere and point the proxy at it:
BACKEND_URL=http://127.0.0.1:8199 npm run dev.
cd backend && uv run pytest # tests
cd frontend && npm run typecheckThe schema is under Alembic. Changing a model means adding a migration
(uv run alembic revision --autogenerate -m "…") — and check what it generates: for a new
column on a populated table it will happily emit nullable=False with no default, which fails
on every database that already has rows in it. A database from before Alembic existed is
adopted automatically on boot: it gets stamped at the baseline, then migrated forward.
Copy frontend/src/locales/en/ to a new two-letter directory, translate the JSON, and add the
code to LANGUAGES and LANGUAGE_NAMES in lib/i18n.ts plus
INTL_LOCALE in lib/format.ts (which maps a UI language to the
regional locale Intl should use — en means en-GB here, not en-US). The backend accepts
whatever is listed in LANGUAGES in app/models/preferences.py, so add it there too.
Three things are worth getting right rather than translating literally:
viewer.json→presetsare radiological terms, not words. Use what a radiologist in that language says in the clinic.viewer.json→tools.*.keyare keyboard shortcuts. Pick a mnemonic that fits the translated name, and keep the nine distinct.- Units are not translated.
HU,mm,mm²,°stay as they are, and so do the modality codes and DICOM tag keywords.
The test suite encodes what real discs actually do, because that is where the bodies are buried:
test_slug.py—ABDO/PELVIS -must becomeABDO_PELVIS, not a nested directorytest_geometry.py— a reformat series with a few embedded off-plane reference images is still reconstructable; MPR just excludes themtest_multipart.py— golden bytes for the WADO-RS frame response. Cornerstone's multipart parser is a hand-rolled byte scanner, not a MIME parser: no preamble, a realContent-Typeheader line per part, CRLF before the closing boundary. "Tidying" any of those silently feeds the browser garbage pixels.test_mfa_login.py— while a user owes a second factor they must hold no session at all, so the tests try to reach a protected route from that half-authenticated state and assert 401. A "pending" flag on the real session would fail open the first time a route forgot to check it; a separate short-lived cookie cannot.test_totp.py— a code cannot be replayed inside its own 30-second window. Verifying a code is the easy half; recording which step it consumed is the half that gets skipped, and skipping it turns a second factor back into a single one.
This is a personal tool for reading your own exams. It is not certified for diagnostic use, and nothing here should be relied on for clinical decisions.
Most of the code here was written by an AI, closely supervised by a bearded nerd who read the diffs, pushed back on plenty of them, and refused to believe any of it worked until it had been run against real burned exam DVDs — the messy kind, with no file extensions and a Windows viewer bundled on the disc.
That bearded nerd is a systems engineer. He is not a doctor, not a radiologist, and not anything within shouting distance of either. DICOMium exists because opening the DVD a hospital hands you should not require installing someone's desktop software — not because anyone involved is qualified to tell you what is in the pictures.
So: the software will happily show you your scan and measure it in real Hounsfield units. Interpreting it is a job for someone who went to medical school. Please see Not a medical device, and take it literally.
Copyright © 2026 Arthur Caranta.
GNU Affero General Public License v3.0 or later — see LICENSE.
The AGPL is the GPL plus one extra obligation, and it is the one that matters for a self-hosted web app: under section 13, if you run a modified version of dicomium as a network service, you must offer its source code to the people using it over the network. Running it unmodified — for yourself, your family, your clinic — obliges you nothing beyond the usual GPL terms.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

