Case study

A DICOM viewer that never sees your files

The viewer has two paths. The sample studies, public CT, MRI, ultrasound and X-ray scans, are converted once with the OFFIS DICOM Toolkit (DCMTK) and served as static files. A visitor’s own files are read by a small DICOM reader in the browser tab, so nothing is uploaded and there is nothing to delete on a server. The reader is tested pixel for pixel against DCMTK and against hostile files: huge lengths, deep nesting, a deflate bomb and 1,400 damaged copies of real files.

Last updated

Samples: prepared once with DCMTKYour files: read in the browser, never uploaded

We wanted to show, not tell, that we can build medical imaging into a web application: a viewer anyone can try, on a site that is pre-rendered for search engines, served as static files and locked down by a strict Content Security Policy. The result is a free DICOM viewer with seven sample studies, and it opens a visitor's own files without uploading them.

0 bytesof a visitor's own files sent to the server
7sample studies: CT, 4D CT, MRI, ultrasound and X-ray
6transfer syntaxes read in the browser
1,400broken files thrown at the reader in its tests
A real project

This is our own tool, live on this site, not a fictional client. The first version was planned, built and deployed on October 29, 2026, with an AI coding agent doing much of the typing under review. Every number on this page comes from the code and its tests.

Architecture

Two paths to one viewer, and the server only serves files.

The sample studies and a visitor's own files reach the same viewer by different roads. The samples are converted once with the OFFIS DICOM Toolkit (DCMTK), and the results are committed with the site. A visitor's own files never leave the browser tab. The site's API takes no part.

Sample studies are prepared once with DCMTK and served as static files; a visitor's own files are read in the browser; both reach the same viewer, and the API is not involvedSample studies · prepared oncePublic research scansTCIA and Zenodo, CC BYDCMTK 3.7.1, on a Macdcmdump · dcmscale · dcm2imgCommitted files16-bit PNGs, previews, tagsnginxstatic files onlyframes, previews, tagsYour own files · never uploadedFiles, a folder, or a dropstay on your deviceReader in the browser tabtags, pixels, RLE, deflate, JPEGframes, tagsViewer: React page, canvas drawingwindow and level, cine, measuring, every tagASP.NET Core APInot involvedDCMTK never runs on the server. Closing a file deletes it from the page; leaving the page does too.
Where each step runs. DCMTK 3.7.1 runs only on the developer's machine; the server hands out static files.

Decisions

Each choice keeps medical files off the server and the page fast.

DecisionWhat we choseWhy
Where DCMTK runsOnce, on the developer's machine, to prepare the samples. The output is committed with the site.The server needs no imaging software, the pages stay static and fast, and no medical files ever sit on our server.
A visitor's own filesRead in the browser tab by a small DICOM reader written for the page.Nothing is uploaded, so there is nothing to store, secure or delete. Close the file and it is gone.
Getting 16-bit pixels to the browserEach stored value packed into the red (high byte) and green (low byte) of a PNG.Lossless and compressed, and the browser decodes PNG itself. A JPEG would change the values the window and the Hounsfield units depend on.
Window and levelA lookup table for every stored value, built with DICOM's linear window function each time the window changes.Dragging stays smooth, and the value under the pointer is the real modality value, in Hounsfield units for CT.
Tag namesDCMTK's data dictionary exported to a JSON file: 5,348 tags.Fetched only when someone opens their own file; the samples carry their names already.
Security headersNo inline scripts or styles, no WebAssembly: canvas drawing and plain TypeScript.The site's strict Content Security Policy applies to this page unchanged.
A stored value of 1,076 is packed as red 4 and green 52, unpacked to 1,076, rescaled to 52 Hounsfield units and shown as gray 135Stored value1,076PNG pixelR 4 · G 52 · B 0Unpacked4 × 256 + 52 = 1,076Rescaled1,076 − 1,024 = 52 HUSoft-tissue window40 / 400 → gray 135Blue: written by the build script with DCMTK. The rest happens in the browser; amber is what you see.
One CT pixel, from the file to the screen. The PNG carries the exact stored value. The rescale (here −1024) turns it into Hounsfield units, and the window turns those into a gray level.

Data

Real scans, openly licensed, credited on every view.

Every sample is public research data under a Creative Commons Attribution license, with the collection and license shown beside the image. The human scans were de-identified by The Cancer Imaging Archive before publication. Three of them move: a 4D CT rebuilt as a front view for each of ten phases of a breath, an ultrasound clip of 38 frames, and a pig heart MRI. That dataset keeps two moments of each heartbeat, squeezed and relaxed, so the heart beats in two steps, and the page says so.

SampleSourceLicense
Chest CT, chest X-rays (CR and DX)COVID-19-AR, The Cancer Imaging ArchiveCC BY 4.0
Breathing chest CT (4D)4D-Lung, The Cancer Imaging ArchiveCC BY 3.0
Brain MRI (FLAIR)UPENN-GBM, The Cancer Imaging ArchiveCC BY 4.0
Liver ultrasound clipB-mode-and-CEUS-Liver, The Cancer Imaging ArchiveCC BY 4.0
Beating heart MRI (pig, 7 T)Cardiac Cine MRI of Sus scrofa at 7T, Lohr et al., ZenodoCC BY 4.0

Testing

People will try to break an upload, so we tried first.

A reader that accepts any file is an attack surface, even when it runs in the visitor's own browser: a broken header could freeze the tab or make it reserve gigabytes. The test suite reads small files made with DCMTK in every supported format, compares the result pixel for pixel with DCMTK's own export, and then throws hostile files at it.

What we threw at itWhat happens
Not DICOM at all: empty files, text, PNG, ZIP, random bytesRefused: "not a DICOM file"
A length field claiming 4 GBRefused before reading past the end of the file
Sequences nested 5,000 deepRefused at 64 levels, the same default limit DCMTK uses
A 65,535 × 65,535 image, or 100 million framesRefused before any memory is reserved
A deflated file that would expand to 620 MBStopped while decompressing, at a fixed limit
An RLE frame claiming 4 billion segmentsRefused: the count must match the image
HTML and script in a tag valueShown as plain text; the page escapes it
1,400 randomly damaged copies of real filesEach read or refused with a reason in milliseconds; no hang, no crash
2 bugs

found by the tests before visitors could find them: a damaged deflate stream let a bare browser error through instead of a reason, and the tag table's folding tripped over the way dcmdump indents sequence items, which broke the page build. Both were fixed the same day.

76 checks

in two test files: the viewer's image maths and samples, then the reader's formats, the samples against DCMTK's export, the hostile files and the fuzzing, which runs in a separate process with a time limit so a hang fails the test instead of freezing it.

Limits and next steps

What the demo doesn't do yet

  • JPEG lossless and JPEG-LS are common in hospital exports. The browser reader shows their tags and the DCMTK command that decompresses them; a server step running DCMTK, with the visitor's consent and the file deleted right after, would show them directly.
  • JPEG 2000 needs a decoder the browser lacks, such as OpenJPEG compiled to WebAssembly, and a CSP change for this one page.
  • A PACS connection: a portal would fetch studies from the hospital's archive. DCMTK's tools speak classic DICOM networking (storescu, findscu, movescu), so a small service can bridge the archive and the web page.
  • Not a medical device. The viewer is a technology demonstration; diagnostic use needs regulatory clearance and clinical validation.

Need imaging in your own portal?

I build web applications for clinics, imaging centers and health-tech teams: patient and referrer portals that show studies in the browser, connections to a PACS, and the APIs between them. Every engagement starts with a free 30-minute call.

Sources