dicom_toolkit
A composable DICOM imaging toolkit for Flutter. GPU-accelerated rendering, interactive measurement, and clinical precision — across mobile, desktop, and web.
Forked from MostafaSensei106/Flutter-Dicom — the original Rust+DICOM FFI bridge, GPU shader, and 16-bit precision pipeline. This project extends it with a composable toolkit API, web support, measurement tools, rotation, pixel-spacing-aware rulers, and modality-agnostic windowing presets. Licensed under GPL v3 in compliance with the original.
Live example → dicomtoolkit.pages.dev — try the toolkit in your browser.
What's next? See NEXT.md for the roadmap.
The kit
| Tool | What it does |
|---|---|
DicomToolkit |
Entry point — init() loads the native engine, render() is a one-liner |
DicomParser |
Parses DICOM bytes (or files) into DicomParseResult (metadata + pixels) |
DicomDecoder |
Pluggable decoder interface — default RustDecoder hits the Rust FFI |
DicomRenderer |
GPU pipeline: compile shader → pack 16-bit texture → apply windowing + color LUT |
DicomRoi |
Draw a rectangle, get RoiStatistics (min, max, mean, stdDev, median) |
DicomRuler |
Measure distance in mm using pixel spacing (with Imager Pixel Spacing fallback for X-ray) |
DicomExport |
Render + encode to PNG bytes — works on web (no dart:io) |
DicomWindowPreset |
Named presets: CT Hounsfield + modality-agnostic forImage() from pixel range |
DicomViewerController |
Reactive state manager — loading, windowing, color, invert, rotation |
DicomViewer |
Interactive widget: GPU rendering, pinch-zoom, windowing drag, rotation |
Data types
| Type | Description |
|---|---|
DicomTagId |
Immutable DICOM tag (group, element) — 31 predefined constants |
DicomMetadata |
35 typed getters + generic tag() lookup + bestDate resolution |
DicomPixelData |
Sealed class — DicomInt16PixelData for 16-bit monochrome |
DicomParseResult |
Parsed frame: metadata + pixelData + frame() + computeRoi() |
DicomColorMap |
Enum: grayscale, hotIron, pet, rainbow, cool, bone |
RoiStatistics |
pixelCount, min, max, mean, stdDev, median |
Quick start
import 'package:dicom_toolkit/dicom_toolkit.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await DicomToolkit.init();
runApp(const MyApp());
}
One-liner render
final parser = const DicomParser();
final result = await parser.parse(fileBytes);
final image = await DicomToolkit.render(result,
windowCenter: 40, windowWidth: 400,
);
Full viewer
final controller = DicomViewerController();
await controller.loadFromBytes(bytes: fileBytes);
// In your widget tree:
DicomViewer(controller: controller)
// Don't forget:
@override void dispose() { controller.dispose(); super.dispose(); }
Metadata-only (no GPU)
final info = await readDicomInfo('/path/to/scan.dcm');
print(info.metadata.patientName); // "John Doe"
print(info.metadata.modality); // "CT"
Every tool, with examples
DicomParser — parse DICOM bytes
const parser = DicomParser(); // default: RustDecoder
// const parser = DicomParser(decoder: myDecoder); // inject custom backend
final result = await parser.parse(bytes); // DicomParseResult (metadata + pixels)
final meta = await parser.parseMetadata(bytes); // DicomMetadata only — skipPixels: true, no GPU needed
DicomRenderer — GPU rendering pipeline
final renderer = DicomRenderer(
colorMap: DicomColorMap.grayscale,
invert: false,
);
final texture = await renderer.createTexture(result); // pack 16-bit → RGBA
final image = await renderer.render(result, // full pipeline
windowCenter: 40, windowWidth: 400,
colorMap: DicomColorMap.hotIron,
rotationSteps: 1, // 90° CW
);
DicomWindowPreset — modality-agnostic presets
// Image-adaptive presets (works on CT, MR, X-ray, US):
final presets = DicomWindowPreset.forImage(result);
// → [Full Range, DICOM Default, Mid 50%, Narrow 25%, High, Low]
// Or use CT-specific Hounsfield presets:
const bone = DicomWindowPreset.bone(); // W:2000, C:500
const lung = DicomWindowPreset.lung(); // W:1500, C:-500
// All built-in CT presets:
DicomWindowPreset.all; // bone, lung, softTissue, brain, liver, mediastinum, spine
DicomRoi — region of interest statistics
const roi = DicomRoi(x: 100, y: 100, width: 50, height: 50);
final stats = roi.compute(result);
// or: final stats = result.computeRoi(roi);
print(stats.mean); // 342.5
print(stats.stdDev); // 87.3
print(stats.min); // 102
print(stats.max); // 1892
print(stats.median); // 298
print(stats.pixelCount); // 2500
DicomRuler — real-world distance
const ruler = DicomRuler();
// Measure between two pixel points:
final mm = ruler.measure(result.metadata,
(x: 100, y: 200), // DicomPoint = ({double x, double y})
(x: 350, y: 200),
);
print('${mm.toStringAsFixed(1)} mm'); // e.g. "125.3 mm"
// Uses Pixel Spacing (0028,0030) — falls back to Imager Pixel Spacing
// (0018,1164) for CR/DX X-ray files. Falls back to pixel units if neither
// tag is present.
DicomExport — PNG output (web-safe)
const exporter = DicomExport();
final pngBytes = await exporter.toPngBytes(result,
windowCenter: 40,
windowWidth: 400,
colorMap: DicomColorMap.grayscale,
invert: false,
rotationSteps: controller.rotationSteps, // matches display
);
// Write to disk, share, or trigger a browser download — your choice.
DicomViewerController — reactive state
final controller = DicomViewerController();
// Windowing
controller.updateWindowing(center: 40, width: 400);
controller.adjustWindowing(deltaX: 10, deltaY: -5); // drag gesture
controller.resetWindowing(); // back to DICOM defaults
controller.applyPreset(center: preset.center, width: preset.width);
// Rotation
controller.rotateClockwise(); // 0° → 90° → 180° → 270° → 0°
controller.rotateCounterClockwise(); // reverse
controller.rotationSteps; // 0–3
// Display
await controller.setColorMap(DicomColorMap.hotIron);
controller.toggleInvert();
controller.isMonochrome1; // true for CR/DX
// Lifecycle
controller.clear(); // frees current image
controller.dispose(); // frees GPU resources
DicomViewer — interactive widget
DicomViewer(
controller: controller,
interactive: true, // enables drag-windowing + pinch-zoom
fit: BoxFit.contain,
)
// Set interactive: false when an outer widget needs to capture
// taps/pans for measurement tools.
DicomTagId — tag constants for generic lookup
// 31 predefined constants:
DicomTagId.patientName // (0010,0010)
DicomTagId.modality // (0008,0060)
DicomTagId.pixelSpacing // (0028,0030)
DicomTagId.imagerPixelSpacing // (0018,1164)
DicomTagId.toothNumber // (0018,6032) — ISO 3950 FDI
// Create custom tags:
const tag = DicomTagId.fromParts(0x0020, 0x0032); // ImagePositionPatient
final tag = DicomTagId.fromHex('00100010'); // PatientName
// Generic lookup:
meta.tag(DicomTagId.patientName); // "John Doe"
meta.allTags; // Map<DicomTagId, String>
bestDate — resolve the most relevant date
// DICOM files can carry up to four date tags: Study Date (0008,0020),
// Series Date (0008,0021), Acquisition Date (0008,0022), and
// Content Date (0008,0023). `bestDate` returns the most recent valid
// one that is not in the future:
final date = result.metadata.bestDate; // DateTime? — null if none valid
if (date != null) {
print('${date.year}-${date.month.toString().padLeft(2, '0')}-'
'${date.day.toString().padLeft(2, '0')}');
}
// All four raw date strings are also available as typed getters:
print(result.metadata.studyDate); // "20240315" or "Unknown"
print(result.metadata.seriesDate); // "20240314" or "Unknown"
print(result.metadata.acquisitionDate); // "20240314" or "Unknown"
print(result.metadata.contentDate); // "20240315" or "Unknown"
37 extracted DICOM tags
| Category | Tags |
|---|---|
| Patient | patientId, patientName, studyDescription |
| Dates | studyDate, seriesDate, acquisitionDate, contentDate |
| Dental | toothInfo (tries standard Tooth Number, Tooth Region, then 6 vendor private tags) |
| Equipment | modality, manufacturer, manufacturerModelName, institutionName |
| UIDs | studyInstanceUid, seriesInstanceUid, sopInstanceUid |
| Acquisition | seriesDescription, bodyPartExamined, sliceThickness, instanceNumber |
| Image | width, height, samplesPerPixel, bitsAllocated, bitsStored, highBit, pixelRepresentation, photometricInterpretation, pixelSpacing |
| Spatial | imagePositionPatient, imageOrientationPatient, sliceLocation, spacingBetweenSlices |
| Volume | numberOfFrames |
| Windowing | windowCenter, windowWidth, rescaleIntercept, rescaleSlope |
Computed properties
| Property | Returns | Logic |
|---|---|---|
isMultiFrame |
bool |
numberOfFrames > 1 |
hasSpatialInfo |
bool |
imagePositionPatient non-empty AND (spacingBetweenSlices > 0 OR sliceThickness > 0) |
isVolumetric |
bool |
isMultiFrame OR hasSpatialInfo — true for multi-frame cine/MFSC, enhanced CT/MR, or single slices with 3D placement |
Web support
The web target compiles Rust to WASM with threading (SharedArrayBuffer). This requires three things:
1. Prerequisites
Nightly Rust (for build-std with atomics) and wasm-pack:
rustup toolchain install nightly
rustup +nightly target add wasm32-unknown-unknown
cargo install wasm-pack
2. Build WASM
After any change to rust/src/api/**, rebuild the WASM binary:
.\tool\rebuild_wasm.ps1 # debug (fast compile)
.\tool\rebuild_wasm.ps1 -Release # release (smaller .wasm, ~1.3 MB)
The script compiles the Rust crate to WASM, copies the artifacts to web/pkg/ and rust/pkg/, and strips the .gitignore that wasm-pack creates.
WASM artifacts are committed to git — consumers get them automatically via Flutter's asset system. No manual copy step, no Rust toolchain required on the consumer side.
3. Build + serve the Flutter app
cd example
flutter build web
python serve_debug.py # starts http://localhost:8080
The serve_debug.py script injects the required cross-origin isolation headers:
Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp
Without these, SharedArrayBuffer is disabled by the browser and the WASM worker pool crashes with DataCloneError.
Web performance note: The SSE bridge copies every
List<int>argument from JS→WASM. For large multi-frame DICOMs (150+ MB), this adds seconds of latency and ~2 GB peak RAM. On native platforms, DCO passes a pointer with zero copy. flutter_rust_bridge 2.12 does not auto-select DCO on web even whencrossOriginIsolated: true— see the upstream issue tracker. As a mitigation, the Rust decoder only extracts the first frame's pixel data (raw-byte fast path for uncompressed DICOM, decoder fallback for compressed).
Shader fallback:
FragmentProgram.fromAsset()is unsupported on CanvasKit (web). The renderer catches the compile failure and applies windowing in pure Dart, displaying the result viaRawImageinstead of the GPU shader.
VS Code launch config
Alternatively, use this launch configuration to serve from VS Code:
{
"name": "dicom_toolkit (web)",
"type": "dart",
"request": "launch",
"program": "example/lib/main.dart",
"deviceId": "chrome",
"args": [
"--web-header=Cross-Origin-Opener-Policy=same-origin",
"--web-header=Cross-Origin-Embedder-Policy=require-corp"
]
}
License
GPL v3 — see LICENSE.
Original work copyright (c) 2024 MostafaSensei106 (Flutter-Dicom). Modifications and extensions copyright (c) 2026 dicom_toolkit contributors.