dicom_toolkit_dart 0.2.0
dicom_toolkit_dart: ^0.2.0 copied to clipboard
Pure-Dart DICOM toolkit with an API compatible with `dicom_toolkit`. Parse, decode, render and interactively view DICOM images — no native code, MIT-licensed.
dicom_toolkit_dart #
Pure-Dart DICOM toolkit with an API compatible with dicom_toolkit. Parse, decode, render and interactively view DICOM images — no native code, MIT-licensed, safe for commercial App Store distribution.
Features #
| Capability | Description |
|---|---|
| Parsing | Full DICOM Part 10 file parser with implicit/explicit VR, little/big endian, deflated transfer syntaxes |
| Pixel decoding | Uncompressed (16-bit signed / 8-bit unsigned / RGB / Palette), RLE Lossless, JPEG Baseline (Process 1) |
| Metadata | 35+ typed getters for patient, study, series, equipment, spatial and windowing tags |
| Rendering | CPU-based pipeline: window/level, rescale slope/intercept, color LUT, rotation |
| Color maps | Grayscale, HotIron, PET, Rainbow, Cool, Bone pseudo-color |
| Multi-series loading | Scan a directory, group files by Series Instance UID, sort by instance number |
| Slice navigation | Next/previous/first/last slice, jump-to-slice, series switching |
| Viewer widget | Interactive DicomViewer with pan/zoom, window/level drag gestures, scroll-based zoom & slice navigation |
| Measurement | DicomRuler (physical mm via PixelSpacing), DicomRoi (region statistics) |
| Export | PNG export of rendered images with full rendering options |
| Pluggable decoder | Swap the pixel decoder (e.g. FFI-based) without changing the public API |
Supported Transfer Syntaxes #
| UID | Name | Decoder |
|---|---|---|
1.2.840.10008.1.2 |
Implicit VR Little Endian | Uncompressed |
1.2.840.10008.1.2.1 |
Explicit VR Little Endian | Uncompressed |
1.2.840.10008.1.2.2 |
Explicit VR Big Endian | Uncompressed |
1.2.840.10008.1.2.1.98 |
Deflated Explicit VR Little Endian | Uncompressed |
1.2.840.10008.1.2.5 |
RLE Lossless | RLE decoder |
1.2.840.10008.1.2.4.50 |
JPEG Baseline (Process 1) | JPEG decoder |
Other JPEG variants (Extended, Sequential, Progressive), JPEG-LS, JPEG 2000, MPEG-2, and DICOM Deflated Big Endian are not yet supported.
Module Structure #
The library is organized into six layers, each with clear responsibilities:
lib/
├── dicom_toolkit_dart.dart # Public API barrel export
└── src/
├── core/ # Core domain models
│ ├── dicom_parser.dart # Entry point: bytes → DicomParseResult
│ ├── dicom_metadata.dart # 35+ typed tag getters
│ ├── dicom_parse_result.dart # Parse output (metadata + pixelData)
│ ├── dicom_pixel_data.dart # Sealed pixel data hierarchy
│ ├── dicom_series.dart # DicomSlice / DicomSeries models
│ ├── dicom_series_loader.dart # Directory scanner & series grouper
│ ├── dicom_tag_id.dart # 50+ tag constants
│ ├── dicom_toolkit.dart # Top-level static facade
│ ├── dicom_color_map.dart # Color map enum
│ ├── dicom_point.dart # 2D point
│ ├── roi_statistics.dart # ROI stats model
│ └── read_dicom_info*.dart # Metadata-only fast path (IO/web)
├── parser/ # Low-level binary parsing
│ ├── byte_reader.dart # Bounds-checked byte reader
│ ├── dataset.dart # DICOM dataset with stopAfterGroup
│ ├── data_element.dart # Tag + VR + value
│ ├── dicom_file_format.dart # Part 10 preamble + meta header
│ ├── transfer_syntax.dart # UID → endianness/VR mode
│ ├── vr.dart # Value Representation enum
│ ├── tag_dictionary.dart # Tag → name/VR lookup
│ └── deflate_codec.dart # zlib inflate for Deflated TS
├── decoder/ # Pixel data decoders
│ ├── pixel_decoder.dart # Decoder interface
│ ├── pure_dart_decoder.dart # Dispatch by transfer syntax
│ ├── uncompressed_decoder.dart# INT16/UINT8/RGB/Palette
│ ├── rle_decoder.dart # RLE Lossless
│ └── jpeg_baseline_decoder.dart # JPEG Baseline (Process 1)
├── render/ # CPU rendering pipeline
│ ├── dicom_renderer.dart # Pixel → RGBA
│ ├── windowing.dart # Window center/width LUT
│ ├── color_lut.dart # Pseudo-color LUTs
│ ├── dicom_window_preset.dart # Bone/Brain/Lung/etc presets
│ ├── rotation.dart # 0/90/180/270° rotation
│ └── image_encoder.dart # RGBA → ui.Image
├── viewer/ # Interactive Flutter widget
│ ├── dicom_viewer.dart # Widget with gestures + wheel
│ ├── dicom_viewer_controller.dart # ValueNotifier controller
│ └── dicom_viewer_state.dart # Immutable state snapshot
└── tools/ # Measurement & export
├── dicom_ruler.dart # Linear distance (mm)
├── dicom_roi.dart # ROI statistics
└── dicom_export.dart # PNG export
Installation #
1. Add the dependency #
Option A — Local path (for development):
# your_app/pubspec.yaml
dependencies:
flutter:
sdk: flutter
dicom_toolkit_dart:
path: ../dicom_toolkit_dart
Option B — Git repository:
dependencies:
dicom_toolkit_dart:
git:
url: https://github.com/iplusplus812/dicom_toolkit_dart
ref: main
Option C — Pub.dev (when published):
dependencies:
dicom_toolkit_dart: ^0.1.0
2. Install packages #
flutter pub get
3. Import the library #
All public APIs are exported from the single barrel file:
import 'package:dicom_toolkit_dart/dicom_toolkit_dart.dart';
Quick Start #
1. Parse a single DICOM file (metadata + pixel data) #
import 'dart:io';
import 'package:dicom_toolkit_dart/dicom_toolkit_dart.dart';
final bytes = await File('/path/to/image.dcm').readAsBytes();
final parser = const DicomParser();
final result = await parser.parse(bytes);
// Metadata
print(result.metadata.patientName); // "JOHN DOE"
print(result.metadata.modality); // "CT"
print(result.metadata.studyDate); // "20260731"
// Pixel info
print('${result.width} × ${result.height}');
print(result.pixelData.rescaleSlope);
print(result.pixelData.rescaleIntercept);
// Generate RGBA bytes (for custom display)
final rgba = await DicomToolkit.createTexture(result);
2. Fast metadata-only extraction #
// Reads only group 0002/0008/0010/0020/0028 — skips pixel data
final metadata = await readDicomInfo('/path/to/image.dcm');
print(metadata.patientName);
print(metadata.pixelSpacing); // [0.5, 0.5] mm
3. Render to a Flutter ui.Image #
final image = await DicomToolkit.render(
result,
windowCenter: 120.0, // optional: overrides the file's default
windowWidth: 200.0,
colorMap: DicomColorMap.hotIron,
rotationDegrees: 90,
);
4. Load a directory of DICOM files (multi-series) #
final controller = DicomViewerController();
// Scan directory, group by Series Instance UID, sort by instance number
await controller.loadSeriesFromDirectory(
'/path/to/CT_study',
onProgress: (current, total) {
print('Loading $current / $total');
},
);
print('Found ${controller.allSeries.length} series');
for (final series in controller.allSeries) {
print(series.displayLabel);
// e.g. "Series 1 - CT Chest (267 slices)"
}
// Navigate series
await controller.nextSeries();
await controller.previousSeries();
await controller.loadSeriesByIndex(2);
// Navigate slices within the current series
await controller.nextSlice();
await controller.previousSlice();
await controller.goToSlice(50);
await controller.firstSlice();
await controller.lastSlice();
5. Interactive Viewer Widget #
class MyDicomPage extends StatefulWidget {
const MyDicomPage({super.key});
@override
State<MyDicomPage> createState() => _MyDicomPageState();
}
class _MyDicomPageState extends State<MyDicomPage> {
late final DicomViewerController _controller;
final TransformationController _transform = TransformationController();
@override
void initState() {
super.initState();
_controller = DicomViewerController();
_controller.loadSeriesFromDirectory('/path/to/CT_study');
}
@override
void dispose() {
_controller.dispose();
_transform.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: DicomViewer(
controller: _controller,
transformationController: _transform,
onWheelScroll: (delta) {
// Ctrl + wheel → slice navigation (default behavior)
// Override here if you need custom handling
},
),
);
}
}
Viewer Gestures & Interactions #
| Input | Action |
|---|---|
| Mouse wheel (no modifier) | Zoom in/out on the image |
| Ctrl + mouse wheel | Navigate to next/previous slice in the series |
| Single-finger drag (1× scale) | PACS-style window/level adjustment (horizontal → width, vertical → center) |
| Single-finger drag (zoomed) | Pan the image |
| Two-finger pinch | Zoom in/out |
interactive: false |
All gestures disabled — parent takes over |
The viewer uses a custom
Transform+ClipRectpipeline (not Flutter'sInteractiveViewer), so wheel events are fully under our control — Ctrl+wheel navigation never triggers accidental zoom.
6. Window Presets #
// Apply a named preset
controller.setWindow(40, 400); // Soft tissue
controller.setWindow(200, 2000); // Bone
controller.setWindow(40, 100); // Brain
controller.setWindow(-600, 1500); // Lung
// Incremental adjustment (for drag gestures)
controller.adjustWindow(dCenter: 5.0, dWidth: 10.0);
7. Measurement Tools #
final ruler = DicomRuler(result.pixelData);
final distance = ruler.measure(
DicomPoint(100, 200),
DicomPoint(300, 400),
);
print('Distance: ${distance.toStringAsFixed(2)} mm');
// ROI statistics
final roi = DicomRoi(result);
final stats = roi.measure(DicomPoint(50, 50), 100, 100);
print('Min: ${stats?.min}, Max: ${stats?.max}, Mean: ${stats?.mean}');
8. Export to PNG #
final exporter = const DicomExport();
final pngBytes = await exporter.toPng(
result,
windowCenter: 120.0,
windowWidth: 200.0,
colorMap: DicomColorMap.grayscale,
rotation: Rotation.deg90,
);
await File('output.png').writeAsBytes(pngBytes);
9. Advanced: Pluggable Decoder #
The decoder is injectable. To use a custom (e.g. FFI-based) decoder:
class MyCustomDecoder implements DicomDecoder {
const MyCustomDecoder();
@override
Future<DicomParseResult> decode(Uint8List bytes, {bool skipPixels = false}) async {
// Custom decode logic
}
}
final parser = DicomParser(decoder: MyCustomDecoder());
final result = await parser.parse(bytes);
API Reference #
Core Classes #
| Class | Description |
|---|---|
DicomToolkit |
Top-level static facade. init(), createTexture(), render() |
DicomParser |
Parses DICOM bytes into DicomParseResult. Accepts a pluggable DicomDecoder |
DicomParseResult |
Complete parse output: metadata + pixelData. Provides computeRoi() |
DicomMetadata |
35+ typed getters for patient/study/series/equipment tags |
DicomPixelData |
Sealed hierarchy: DicomInt16PixelData, DicomUint8PixelData, DicomRgbPixelData, DicomPalettePixelData |
DicomSlice |
Single image within a series: file path + pre-parsed metadata |
DicomSeries |
A series of slices grouped by Series Instance UID; provides displayLabel, sliceCount, [] |
DicomSeriesLoader |
Scans a directory, parses each .dcm, groups into DicomSeries, sorts by instance number |
DicomTagId |
Constants for 50+ common DICOM tags |
Viewer Classes #
| Class | Description |
|---|---|
DicomViewer |
Interactive viewer widget with wheel zoom, Ctrl+wheel slice nav, pan, window/level drag |
DicomViewerController |
ValueNotifier controller: loadBytes, loadSeriesFromDirectory, nextSlice, nextSeries, setWindow, rotate, reset |
DicomViewerState |
Immutable state snapshot: result, window params, color map, rotation, series, sliceIndex, totalSlices |
Tools #
| Class | Description |
|---|---|
DicomRuler |
Linear distance measurement in physical millimeters |
DicomRoi |
Region-of-interest statistics (min/max/mean/stdDev/median) |
DicomExport |
PNG export with full rendering pipeline options |
Enums #
| Enum | Values |
|---|---|
DicomColorMap |
grayscale, hotIron, pet, rainbow, cool, bone |
Rotation |
deg0, deg90, deg180, deg270 |
Architecture #
┌─────────────────────────────────────────────────────────────────┐
│ Application Layer │
│ DicomViewer · DicomViewerController · DicomToolkit │
├─────────────────────────────────────────────────────────────────┤
│ Tools Layer │
│ DicomRuler · DicomRoi · DicomExport │
├─────────────────────────────────────────────────────────────────┤
│ Render Layer │
│ DicomRenderer · Windowing · ColorLUT · Rotation · ImageEncoder │
├─────────────────────────────────────────────────────────────────┤
│ Core Layer │
│ DicomParser · DicomMetadata · DicomPixelData · DicomTagId │
│ DicomSeries · DicomSlice · DicomSeriesLoader · RoiStatistics │
├─────────────────────────────────────────────────────────────────┤
│ Decoder Layer │
│ DicomDecoder · UncompressedDecoder · RleDecoder · │
│ JpegBaselineDecoder · PureDartDicomDecoder │
├─────────────────────────────────────────────────────────────────┤
│ Parser Layer │
│ ByteReader · DataElement · Dataset · SequenceReader · │
│ TransferSyntax · VR · TagDictionary │
├─────────────────────────────────────────────────────────────────┤
│ File Format │
│ DicomFileFormat · DeflateCodec │
└─────────────────────────────────────────────────────────────────┘
Data flow for a directory load:
Directory
│
▼
DicomSeriesLoader.loadFromDirectory()
│ (scan *.dcm, parse metadata only)
▼
Group by Series Instance UID
│ (sort by InstanceNumber)
▼
List<DicomSeries>
│
▼
DicomViewerController.loadSeriesByIndex(0)
│ (lazy-load pixel data for slice 0)
▼
DicomParseResult
│
▼
DicomToolkit.render() → ui.Image → DicomViewer
Integration with GetX (Example App) #
The example/ directory contains a complete Flutter app demonstrating integration with GetX for state management, routing, internationalization, and theming. Key patterns:
class DicomController extends GetxController {
final DicomViewerController viewerController = DicomViewerController();
final TransformationController transformationController =
TransformationController();
// Rx bridging — expose ValueNotifier as GetX observables
late final Rx<DicomViewerState> state;
final RxList<DicomSeries> seriesList = <DicomSeries>[].obs;
final RxInt currentSeriesIndex = 0.obs;
@override
void onInit() {
super.onInit();
state = viewerController.value.obs;
viewerController.addListener(_syncState);
}
void _syncState() {
state.value = viewerController.value;
seriesList.assignAll(viewerController.allSeries);
currentSeriesIndex.value = viewerController.currentSeriesIndex;
}
// Zoom controls (also bound to Ctrl+/Ctrl-/Ctrl+0 keyboard shortcuts)
void zoomIn() { /* scale × 1.1 */ }
void zoomOut() { /* scale ÷ 1.1 */ }
void resetZoom() {
transformationController.value = Matrix4.identity();
}
}
See example/ for the full implementation including:
- Directory picker (
file_picker) integration - Series sidebar with selection
- Slice navigator slider + prev/next/first/last buttons
- Window width/center sliders + preset chips (Bone/Brain/Lung/etc.)
- Keyboard shortcuts (arrows, Ctrl++/-/0, R, W/S, N/M, Home/End)
- Metadata panel with patient/study/series info
- Internationalization (zh_CN / en_US) via GetX Translations
Backend Integration (Network Loading) #
When the DICOM dataset comes from a backend (PACS / DICOMweb-style server) rather than the local file system, the toolkit supports a URL lazy-loading pipeline:
- The backend returns a Study JSON describing the series structure and the per-slice download URLs (no pixel data).
- The client parses the JSON into
DicomSeries/DicomSliceobjects — each slice carries only itsurland aDicomMetadata.emptyplaceholder. - A custom
DicomSliceResolver(e.g.UrlSliceResolver) is injected into theDicomViewerController; it downloads slices on demand and caches them (LRU). - As the user navigates, the viewer fetches each slice's URL, parses the real DICOM bytes, and backfills the series metadata from the parsed tags.
This mirrors a real QIDO-RS (query structure) + WADO-RS (fetch instance) workflow.
Study JSON Structure #
{
"name": "demo-study",
"studyId": "demo-study-id",
"studyInstanceUid": "1.2.840.113619.2.55.3.604688119.971.1449590254.1",
"studyDescription": "CT CHEST WITHOUT CONTRAST",
"patientName": "Anonymized^^",
"patientId": "0",
"studyDate": "20151207",
"series": [
{
"name": "scout",
"seriesId": "1.2.840.113619.2.55.3.604688119.971.1449590254.2",
"seriesNumber": 1,
"modality": "CT",
"bodyPartExamined": "CHEST",
"slices": [
{ "url": "https://host:port/dicom/study-id/series-00000/image-00000" },
{ "url": "https://host:port/dicom/study-id/series-00000/image-00001" }
]
},
{
"name": "native",
"seriesId": "1.2.840.113619.2.55.3.604688119.971.1449590254.3",
"seriesNumber": 2,
"modality": "CT",
"bodyPartExamined": "CHEST",
"slices": [
{ "url": "https://host:port/dicom/study-id/series-00001/image-00000" },
{ "url": "https://host:port/dicom/study-id/series-00001/image-00001" }
]
}
]
}
Field reference
Study-level (applies to the whole study; copied onto every DicomSeries):
| Field | Type | Required | Description |
|---|---|---|---|
name |
String | Yes | Study display name (shown in the status bar) |
studyId |
String | Yes | Study business ID |
studyInstanceUid |
String | No | DICOM Study Instance UID (unique identifier) |
studyDescription |
String | No | Study description (read directly from JSON by the app) |
patientName |
String | No | Patient name (defaults to Unknown) |
patientId |
String | No | Patient ID (defaults to Unknown) |
studyDate |
String | No | Study date YYYYMMDD (defaults to Unknown) |
Note:
patientName/patientId/studyDatemust use these exact keys (case-sensitive).studyDescriptionandstudyInstanceUidare study-level fields the app reads directly from the JSON — they are not stored onDicomSeries(which is a series-level model).
Series-level (each entry in series[]):
| Field | Type | Required | Description |
|---|---|---|---|
name |
String | Yes | Series description → DicomSeries.seriesDescription |
seriesId |
String | Yes | Series Instance UID → DicomSeries.seriesInstanceUid (unique key for grouping/caching) |
seriesNumber |
Number | No | Series number (defaults to the 1-based index) |
modality |
String | No | Modality CT/MR/... (defaults to Unknown) — DICOM treats this as a Series-level attribute |
bodyPartExamined |
String | No | Body part examined |
slices |
Array | Yes | Slice list (see below) |
Slice-level (each entry in slices[]):
| Field | Type | Required | Description |
|---|---|---|---|
url |
String | Yes | Download URL of the .dcm instance (fetched lazily by the resolver) |
Integration Steps #
// 1. Fetch the study JSON from the backend (structure only, no pixel data).
// In production replace with: final res = await http.get(Uri.parse('$baseUrl/studies/$studyId'));
final studyJson = await _fetchStudyJson();
// 2. Parse the JSON into DicomSeries/DicomSlice.
// Each slice carries only its `url`; metadata is `DicomMetadata.empty`
// until the slice is actually downloaded & parsed.
final seriesList = _parseStudyJson(studyJson);
// 3. Inject a URL-aware resolver that downloads by URL and caches (LRU).
final resolver = UrlSliceResolver();
final controller = DicomViewerController(resolver: resolver);
// 4. Hand the series to the viewer. The first slice downloads on demand;
// navigation to other slices triggers lazy fetch + cache.
controller.allSeries.addAll(seriesList);
controller.currentSeriesIndex = 0;
await controller.loadSeries(seriesList.first, index: 0);
Metadata Backfill #
Because slices start with DicomMetadata.empty, the viewer backfills real metadata after the first slice of each series is downloaded and parsed:
| Stage | Source | What's available |
|---|---|---|
| Initial (before any download) | Study JSON | modality, bodyPartExamined, seriesNumber, patientName, patientId, studyDate — series list shows these immediately |
| After first slice parsed | Real DICOM tags | Overwrites the above with actual values (if present in the file) |
| Image-level (always from DICOM) | Parsed tags | rows, columns, windowCenter, windowWidth, sliceThickness, sliceLocation, photometricInterpretation — these cannot come from JSON, only from the parsed file |
The backfill is handled automatically by DicomViewerController._maybeBackfillSeries — it triggers only when a series was constructed with placeholder metadata (modality == 'Unknown'), so file-system/bytes-based loading is unaffected.
Caching #
UrlSliceResolver keeps an LRU cache of downloaded slice bytes. Revisiting a previously loaded slice is instant (no re-download). Cache size is exposed via resolver.cacheSize for display (e.g. a chip in the status bar). Call resolver.clearCache() when the study is closed to release memory.
Prefetching (Background Warm-up) #
To make slice navigation feel instant, the resolver can prefetch slices in the background after the first slice of a series is displayed:
// After the first slice is rendered, prefetch the rest of the series.
// Downloads are serial (bandwidth-friendly) and skip already-cached slices.
resolver.prefetch(series.slices.skip(1));
Behavior:
- Serial downloads — one slice at a time, so prefetch never saturates the network.
- Shared in-flight dedup — prefetch and the user's
resolve()calls share the same in-flight map, so the same URL is never downloaded twice. A user navigating to a slice being prefetched just awaits the in-flight download. - Cancellation — calling
prefetch()again (e.g. when the user switches series) orcancelPrefetch()cancels the previous job via a generation counter; the in-flight slice finishes but no further prefetch downloads start. - Best-effort — prefetch errors are swallowed silently; if the user later opens a failed slice,
resolve()re-downloads and surfaces the error. - Progress —
prefetch(slices, onProgress: (done, total) => ...)reports progress for a status-bar chip.
The example app wires this up automatically: NetworkDemoController prefetches the current series' remaining slices right after the first slice renders, and re-prefetches on series switch (cancelling the old job first).
See example/lib/app/modules/network_demo/ for a complete, runnable demo of this pipeline.
Why Pure Dart? #
| Concern | Pure Dart (This Library) | FFI/Native (dicom_toolkit) |
|---|---|---|
| License | MIT — safe for commercial App Store distribution | GPL v3 — requires entire app to be open-sourced |
| Build complexity | flutter pub get only |
Requires Rust toolchain + native compilation per platform |
| Platform support | All Flutter platforms (mobile, web, desktop) | Mobile + desktop only (no web) |
| Size | ~200 KB Dart source | ~5–10 MB native binary per architecture |
| Debugging | Full Dart stack traces | Mixed Dart/Rust stack traces |
| Hot reload | Works | Requires full restart |
Roadmap #
- ✅ DICOM Part 10 file parsing (implicit/explicit VR, LE/BE, deflated)
- ✅ Uncompressed pixel decoding (16-bit, 8-bit, RGB, Palette)
- ✅ RLE Lossless pixel decoding
- ✅ JPEG Baseline (Process 1) pixel decoding
- ✅ Metadata extraction (35+ typed getters)
- ✅ CPU rendering pipeline (windowing, color LUT, rotation)
- ✅ Interactive viewer widget with gesture control
- ✅ Multi-series directory loading & slice navigation
- ✅ Mouse wheel zoom + Ctrl+wheel slice navigation
- ✅ Measurement tools (ruler, ROI statistics)
- ✅ PNG export
- ❌ JPEG Extended / Sequential / Lossless transfer syntaxes
- ❌ JPEG 2000 transfer syntax
- ❌ Multi-frame support (currently decodes frame 0)
- ❌ DICOM network protocols (C-STORE/C-FIND/C-MOVE)
- ❌ DICOMweb (WADO-RS / STOW-RS / QIDO-RS)
- ❌ 3D volume rendering (MIP, VR, SSD)
License #
This project is licensed under the MIT License — see the LICENSE file for details.
Acknowledgments #
- DCMTK for the reference implementation and PS3.5/PS3.7 specifications
dicom_toolkitfor the API design this library is compatible withimagepackage for pure-Dart JPEG/PNG codec support