ScanLint
A browser-based DICOM inspector that reads a single file or a whole study, flags the metadata which could identify a patient against the de-identification profile in the DICOM standard, and draws the image so you can check the one thing the metadata cannot tell you.
- Role
- Solo developer — architecture, build, deployment
- Status
- Stages 1–3 complete; single files, whole folders, and the image itself
- Stack
- Next.js 16, React 19, TypeScript, Tailwind CSS 4, Web Workers, dicom-parser, Vitest, Cloudflare Workers
- Sample data
- Synthetic. Eleven image fixtures across five transfer syntaxes, all generated with invented patient details.
- Live
- https://scanlint.raihanvaheed.dev
The problem
I spent three research roles working with neuroimaging data, where "is this scan de-identified" is usually answered by trusting whatever tool last touched it. The metadata is right there and readable, but checking it means installing something, writing a script, or uploading a patient file to a website. And a study is not one file — it is hundreds of slices, any one of which can carry something the others do not.
What I built
- Reads a file, or a whole folder, locally — there is no upload path in the code
- Names every identifying field, with the action a de-identification profile prescribes for it
- For a folder: groups into studies and series, orders slices by geometry, and gives one answer for the whole study
- Flags every file that differs from the rest — a field present on one slice only, or a value that changes between slices
- Draws the pixels, with adjustable window and level, so burned-in text can be checked rather than assumed
- Steps through a series in its true order, and through every frame of a multi-frame file
- Flagged values masked by default, revealed one row at a time or all at once
- Exports the answer as a report that excludes the field values unless you ask for them
Decisions worth talking about
The rules come from the standard, not a hand-written list. Two scripts pull NEMA's own XML: the confidentiality profile in PS3.15 Annex E, giving 653 attributes and four wildcard patterns, and the data dictionary in PS3.6, giving 5,214. Both record their edition, and a test asserts the two agree.
Slices are ordered by geometry, not by the number that looks like it means order. InstanceNumber is right often enough to pass a demo and wrong whenever a series has been renumbered, merged or reconstructed. The real order comes from projecting each slice's position onto the plane normal derived from its orientation. The test fixture plants a deliberately wrong instance number and shuffles the filenames, so neither shortcut can pass.
The privacy claim is a test, not a promise. A test scans the source for fetch, XMLHttpRequest, sendBeacon, WebSocket and EventSource. One file is allowlisted — the bundled sample loader — pinned to a single entry and matched against a pattern that rejects a second argument, so the call cannot quietly become a POST.
The expensive image decoders were left out on purpose, after working out which files actually need them. Displaying JPEG 2000 or JPEG-LS in a browser means shipping megabytes of WebAssembly to everyone who opens the page. But the scans that carry a name printed into the pixels are ultrasound frames, secondary captures and scanned requisitions — and those are uncompressed or JPEG baseline, which the browser decodes for free. JPEG 2000 is what hospitals store CT and MR in, and those slices almost never have text burned on, because their identifiers sit in the metadata the tool already reads. The expensive dependency would have served the files least likely to need the feature. The rest are named and refused instead, and the whole decode path is 15.7 KB.
What went wrong
The findings view shipped looking correct and was wrong on a common class of file. Implicit VR Little Endian is DICOM's default transfer syntax and carries no data types in its byte stream — a reader supplies them from the dictionary. Mine didn't, so every value in such a file read as binary while the findings above stayed confidently correct. It failed silently, which is worse than failing loudly. The fix fed the dictionary's value representations into the parser, proved by generating the same study in two transfer syntaxes and asserting both parses agree field for field. Three more bugs in the folder work surfaced only by running the built application, and none of them by any of 659 automated tests. The preview could at first show only the first frame of a multi-frame file, with nothing on screen saying there were more — so an ultrasound loop would have shown one clean frame and let a reader conclude the whole file was clean. No test caught that. It was caught by asking which kinds of file actually carry burned-in text, and noticing they are mostly the multi-frame ones.
Quality
815 unit and component tests. Test files are generated alongside a manifest of exactly what was planted in them, so tests assert against a known answer rather than whatever the parser produced. The same study is generated in two transfer syntaxes and both parses must agree field for field. One claim the suite cannot make is pixel correctness for JPEG, because Node has no JPEG decoder — so those values are read off the canvas in a real browser and compared against the manifest exactly, and the greyscale fixtures come back with zero deviation.
Honest limitations
- The field that declares burned-in text is unreliable, so you have to look. ScanLint reports what the file claims and draws the pixels so you can check for yourself. One of the two samples on the site is a file that declares no burned-in annotation while its pixels spell out a patient's name.
- JPEG 2000, JPEG-LS and the video formats are not displayed. They are named and refused rather than decoded, for the reason in the decisions above. The metadata is read either way.
- Private tags are reported, not interpreted. In implicit-VR files their contents cannot be read at all.
- Flagging is not anonymising. It tells you what a profile would act on; it does not change your file.
What's next
A cleaning plan: what the standard's de-identification profile would do to every field, and how each identifier would be remapped consistently across a whole study — shown, not applied. Nothing gets written, because a file that looks cleaned is a file someone will trust.
Screenshots



