Rust workspace for reading, writing, transcoding, and converting DICOM data.
🔗 Live demo: try the Node bindings in your browser — upload a .dcm file (or pick a bundled
sample) and see its JSON metadata, a JPEG of every frame, and per-step timings.
This repository contains:
dcmnorm: a library crate with DICOM file, memory, JSON conversion, and DIMSE network helpersexec/dcmnorm: a CLI for converting between DICOM, transcoded DICOM, JSON, and rendered images/raw framesexec/dcmtalk: a DIMSE network CLI (C-ECHO/C-STORE/C-FIND/C-MOVE SCU plus a storage SCP), covering the same ground as dcmtk'sechoscu/storescu/findscu/movescu/storescpbindings/node: Node.js bindings (@pohcee/dcmnorm-node) that call the library in-process via napi-rs — see that package's own README for its APIbindings/node/examples/test-website: the source for the live demo above — also runnable locally, in Docker, or deployed to your own Cloud Run project
bindings/python: Python bindings (dcmnorm-python) that call the library in-process via PyO3 — see that package's own README for its API
- Workspace Layout
- Build
- Install
- Docker
- Test
- Benchmarks
- Releasing
- dcmnorm CLI Usage
- dcmtalk CLI Usage
- Thanks
.
├── Cargo.toml
├── src/ # dcmnorm library crate
├── exec/
│ ├── dcmnorm/ # dcmnorm-cli package (the `dcmnorm` binary)
│ └── dcmtalk/ # dcmtalk package (the `dcmtalk` binary)
├── bindings/
│ ├── node/ # @pohcee/dcmnorm-node napi-rs bindings
│ └── python/ # dcmnorm-python PyO3 bindings
├── scripts/ # install / release helper scripts
└── test/
└── files/ # sample DICOM fixtures used by docs and tests
Default builds enable the MPEG and JPEG-LS codec features. Native prerequisites for the default build on Debian or Ubuntu are:
build-essentialclangcmakelibc6-devlibclang-devpkg-configlibavutil-devlibavcodec-devlibavformat-devlibswscale-devlibswresample-dev
The FFmpeg integration is built with a reduced ffmpeg-next feature set, so
libavfilter-dev and libavdevice-dev are not required for the current build.
Example install command:
sudo apt-get update
sudo apt-get install -y \
build-essential \
clang \
cmake \
libc6-dev \
libclang-dev \
pkg-config \
libavutil-dev \
libavcodec-dev \
libavformat-dev \
libswscale-dev \
libswresample-dev# whole workspace, debug
cargo build --workspace
# whole workspace, release
cargo build --workspace --release
# without the default MPEG and JPEG-LS codec features
cargo build --workspace --no-default-featuresRelease binaries are written to target/release/.
# just dcmnorm
cargo build -p dcmnorm-cli
# just dcmtalk
cargo build -p dcmtalk
# both, release mode
cargo build -p dcmnorm-cli -p dcmtalk --releaseBy default, JPEG 2000 decoding uses the bundled OpenJPEG path. To enable the optional Kakadu FFI bridge instead:
cargo build --workspace --features kakadu-ffiThis requires Kakadu headers in a normal include location (~/.local/include/kakadu,
/usr/local/include/kakadu, or /usr/include/kakadu) so the C++ bridge can compile
automatically. If your headers live elsewhere, point the build at them explicitly:
KAKADU_INCLUDE_DIR=$HOME/.local/include/kakadu \
KAKADU_LIB_DIR=$HOME/.local/lib \
cargo build --workspace --features kakadu-ffiBuild-time environment variables for this feature:
KAKADU_INCLUDE_DIR— explicit include directory containing Kakadu headersKAKADU_LIB_DIR— explicit library directory containinglibkdu*.soKAKADU_LIB_NAME— optional Kakadu library base name override for linker configuration
See JPEG 2000 codec selection for the corresponding runtime behavior.
cargo install --path exec/dcmnormTo install every CLI under exec/ with one command, use the helper script instead:
./scripts/install-source.shThis script auto-detects Kakadu headers/libraries and enables kakadu-ffi when available,
and verifies the default codec toolchain (pkg-config, clang, standard C headers, and the
FFmpeg development packages above) before invoking Cargo. If it detects Claude Code and/or
Gemini CLI on the machine (an existing ~/.claude and/or ~/.gemini directory), it also
installs the skills/dcmnorm skill to ~/.claude/skills/dcmnorm and/or
~/.gemini/skills/dcmnorm respectively — set DCMNORM_SKIP_SKILL=1 to skip this, or
CLAUDE_SKILLS_DIR/GEMINI_SKILLS_DIR to install elsewhere (and to force the install even
without the corresponding ~/.claude/~/.gemini directory).
Either method installs into Cargo's bin directory, usually ~/.cargo/bin. If that isn't on
your PATH yet, add:
export PATH="$HOME/.cargo/bin:$PATH"If you'd rather not use cargo install, build the release binaries and copy them yourself:
cargo build --workspace --release
mkdir -p ~/.local/bin
cp target/release/dcmnorm target/release/dcmtalk ~/.local/bin/If ~/.local/bin isn't on your PATH yet, add:
export PATH="$HOME/.local/bin:$PATH"To install the latest published release binary from GitHub (or a specific version):
./scripts/install-release.shOr, generally:
curl -sSL pohcee.com/dcmnorm | shThis script also installs the dcmnorm skill (downloaded from this repo's
skills/dcmnorm/SKILL.md) for any of Claude Code, Gemini CLI, or Codex CLI it detects on the
machine — same DCMNORM_SKIP_SKILL/CLAUDE_SKILLS_DIR/GEMINI_SKILLS_DIR/CODEX_SKILLS_DIR
overrides as above.
On Debian/Ubuntu (linux-x86_64 only), pass --deb to install a downloaded .deb package via
apt install instead of copying binaries into INSTALL_DIR — this resolves runtime dependencies
(ffmpeg, ca-certificates) automatically and needs root (the script sudos automatically if not
already running as root):
./scripts/install-release.sh --deb
./scripts/install-release.sh 0.2.1 --debThis repository includes a multi-stage Dockerfile that builds dcmnorm and dcmtalk in a
toolchain stage and copies only the release binaries into a slim runtime stage. The final
runtime image installs ca-certificates, ffmpeg, and libstdc++6; build-only dependencies
(clang, cmake, pkg-config, FFmpeg -dev packages) stay in the builder stage.
Kakadu is not included in the image — see JPEG 2000 codec selection if you need Kakadu support and are willing to provide the headers/libraries yourself.
Build the image:
docker build -t dcmnorm .Run the CLI (the image's default entrypoint is dcmnorm):
docker run --rm dcmnormConvert a file from a bind-mounted working directory:
docker run --rm \
-v "$PWD":/work \
-w /work \
dcmnorm \
test/files/dx.dcmRun dcmtalk instead by overriding the entrypoint:
docker run --rm --entrypoint dcmtalk dcmnorm echoscu somepacs.example.com:11112
# storescp needs its listening port published
docker run --rm --entrypoint dcmtalk -p 11112:11112 \
-v "$PWD/received":/data \
dcmnorm storescp 11112 --cache-path /datacargo test --workspacebenchmarks/ compares dcmnorm against dcmtk 3.6.7 and
dcm4che 5.35.1 on parsing (DICOM → JSON), rendering
(pixel data → PNG), and transcoding (→ Explicit VR Little Endian, decompressing
JPEG/JPEG2000 sources along the way).
All three tools run inside the same Docker container
(benchmarks/Dockerfile: debian:bookworm-slim, dcmtk from apt, dcm4che's
official binary distribution, dcmnorm built from this source tree) so the
comparison isn't skewed by different host installs, library versions, or
filesystems. The container is capped to 4 CPUs (docker run --cpus=4) for a
consistent, resource-isolated run. Each (operation, fixture, tool) combination
is timed with hyperfine (2 warmup runs
- at least 8 measured runs, reporting mean/stddev/median/min/max wall time). Reproduce with:
docker build -f benchmarks/Dockerfile -t dcmnorm-bench .
docker run --rm --cpus=4 \
-v "$(pwd)/test/files":/fixtures:ro \
-v "$(pwd)/benchmarks/results":/results \
dcmnorm-bench bash /repo/benchmarks/run.sh| File | Transfer syntax | Dimensions | Size |
|---|---|---|---|
mr.dcm |
Explicit VR LE (uncompressed) | 512×512, 1 frame | 526 KB |
us2.dcm |
Explicit VR LE (uncompressed) | 360×360, 227 frames | 29.4 MB |
wsi.dcm |
JPEG Baseline | 240×240, 96 frames | 1.5 MB |
ct.dcm |
JPEG 2000 | 512×512, 1 frame | 90 KB |
dx2.dcm |
JPEG 2000 (Lossless-only) | 1736×2022, 1 frame | 3.6 MB |
Run 2026-10-04 (dcmnorm 0.3.2 + the single-frame/zero-copy read path, dcmtk 3.6.7, dcm4che 5.35.1).
Parse (dcm2json / dcm2json / dcmnorm <file>)
| Fixture | dcmtk | dcm4che | dcmnorm |
|---|---|---|---|
| mr.dcm | 16.8 ± 1.5 | 213.5 ± 4.2 | 3.2 ± 0.5 |
| us2.dcm | 285.9 ± 6.6¹ | 213.6 ± 4.6 | 28.4 ± 2.9 |
| wsi.dcm | n/a¹ | 232.8 ± 6.2 | 5.1 ± 0.7 |
| ct.dcm | n/a¹ | 215.5 ± 4.9 | 3.0 ± 0.6 |
| dx2.dcm | n/a¹ | 215.4 ± 6.5 | 7.7 ± 1.0 |
Render one frame (dcmj2pnm +F N --write-png / dcm2jpg --frame N -F png / dcmnorm <file> <out.png> --render-frame N-1)
Every tool renders exactly one frame, the same one: the middle frame of the multi-frame fixtures
(us2.dcm frame 114 of 227, wsi.dcm frame 49 of 96), else the only frame. For us2.dcm,
dcmtk's and dcmnorm's decoded pixels for that frame were checked to be byte-identical.
| Fixture | dcmtk | dcm4che | dcmnorm |
|---|---|---|---|
| mr.dcm | 29.3 ± 2.5 | 314.8 ± 12.6 | 5.8 ± 1.0 |
| us2.dcm | 18.4 ± 1.6 | 309.5 ± 17.8 | 4.1 ± 0.7 |
| wsi.dcm | 18.1 ± 1.5 | 332.5 ± 9.1 | 5.8 ± 0.8 |
| ct.dcm | n/a² | 335.7 ± 9.2 | 18.5 ± 1.8 |
| dx2.dcm | n/a² | 485.6 ± 21.8 | 440.2 ± 8.2 |
Transcode → Explicit VR LE (dcmconv +te / dcmdjpeg³ / dcm2dcm -t ... / dcmnorm <in> <out> --transfer-syntax ...)
| Fixture | dcmtk | dcm4che | dcmnorm |
|---|---|---|---|
| mr.dcm | 12.5 ± 1.5 | 255.2 ± 8.1 | 4.7 ± 0.8 |
| us2.dcm | 36.6 ± 3.2 | 304.7 ± 16.3 | 26.6 ± 2.4 |
| wsi.dcm | 60.0 ± 2.4 | 440.7 ± 20.9 | 36.7 ± 1.9 |
| ct.dcm | n/a² | 319.8 ± 25.9 | 18.1 ± 1.4 |
| dx2.dcm | n/a² | 463.4 ± 13.3 | 430.7 ± 6.7 |
¹ dcmtk's dcm2json (this build) has no bulk-data-by-reference/exclude option
— unlike dcm4che's -B/--no-bulkdata or dcmnorm's default bulkData: uri
mode, it always inlines PixelData as base64, and fails outright
("JSON InlineBinary encoding not supported for compressed pixel data") on any
compressed source. Confirmed by running it directly outside the benchmark
harness, not a harness bug. Its us2.dcm parse is therefore not like-for-like:
it's the only tool base64-encoding all 29MB of pixel data inline, where dcmnorm
and dcm4che both emit a reference instead.
² dcmtk's apt-packaged build (dcmdjp2k is not installed alongside dcmtk,
and dcmj2pnm/dcmconv have no JPEG2000 codec registered) cannot decode or
transcode JPEG2000 at all — confirmed via dcmconv +te on ct.dcm:
E: Pixel representation cannot be changed.
³ wsi.dcm (JPEG Baseline) uses dcmtk's dedicated dcmdjpeg decompressor
rather than dcmconv +te, matching how dcmtk itself expects JPEG sources to
be decompressed; dcm2dcm and dcmnorm --transfer-syntax handle both the
plain VR/endian conversion and JPEG/JPEG2000 decompression through the same
one invocation.
run.sh discards any timing whose command didn't produce its output file, so a
tool can't "win" by failing fast (e.g. a dcmtk built without libpng rejects
--write-png immediately, which would otherwise time as a ~4ms render).
- dcmnorm is fastest in every row — including
us2.dcm, a 227-frame, 29MB native ultrasound cine, where the previous run (2026-08-29) had it losing to dcmtk on render (52.6 vs 18.1ms) and transcode (104.4 vs 61.9ms). That gap was never the DICOM work itself (decode + window + PNG for one frame is ~1ms); it was overhead that grew with file size, fixed since:- every invocation SHA-256'd the whole (statically linked, ~30MB) binary
to build its
--versionstring — now only done for--version; - PixelData was copied 3-4 times between
fs::read, the parser's scratch buffer, a same-transfer-syntax "transcode" clone, and the writer cloning each value to serialize it — now read once and written by reference; - single-frame render read all 227 frames to render one — it now reads
just the requested frame, as dcmtk's
dcmj2pnmdoes.
- every invocation SHA-256'd the whole (statically linked, ~30MB) binary
to build its
- dcm4che's numbers are dominated by JVM cold-start (~200-300ms of every single-invocation timing here is the JVM spinning up, not DICOM work) — this benchmark reflects a CLI invoked once per file, not a long-running server reusing a warm JVM, which would look very different. Not a fair "dcm4che the library is slow" conclusion; it's specifically a CLI-cold-start cost.
dx2.dcm(1736×2022 JPEG 2000) is decode-bound (~430ms in OpenJPEG for both render and transcode); it's the one fixture where dcmnorm's lead over dcm4che is narrow, since the codec dominates both.- dcmtk's apt-packaged build has real capability gaps: no
bulk-data-reference JSON mode, no JPEG2000 support at all. Both are almost
certainly build-configuration choices (dcmtk itself supports JPEG2000 when
compiled with the right codec module) rather than fundamental limitations
of the toolkit — but they're what ships via
apt, which is what most deployments actually run.
This repository uses two GitHub Actions workflows for SemVer-based CLI releases:
.github/workflows/semver-tag.yml: manually creates and pushes the nextvX.Y.Ztag from the latest existingv*tag.github/workflows/release.yml: runs on pushed version tags, builds the CLI, and creates a GitHub Release with artifacts
Release flow:
- Run the SemVer Tag workflow from the Actions tab and choose
patch,minor, ormajor. - The workflow pushes a new version tag (for example
v0.1.1). - The Build and Release CLIs workflow is triggered by that tag and publishes, for each of
dcmnormanddcmtalk:<name>-<tag>-linux-x86_64.tar.gz(+.sha256)<name>-<tag>-linux-x86_64.deb(+.sha256) — built withcargo-debfrom each exec crate's[package.metadata.deb],Depends:onffmpeg/ca-certificatesplus whatevercargo-deb's$autodetects from the linked shared libraries<name>-linux-x86_64.tar.gz/.deb+.sha256(rolling "latest" aliases, overwritten each release)
Prereleases are supported in the SemVer tag workflow via the prerelease input.
If you prefer not to manually run the tag workflow in GitHub, use the local helper script:
./scripts/release-tag.sh patch # bump types: patch, minor, major
./scripts/release-tag.sh minor --prerelease rc
./scripts/release-tag.sh patch --dry-run # preview the computed next tag onlyThe script updates versions in Cargo.toml, exec/dcmnorm/Cargo.toml, and
exec/dcmtalk/Cargo.toml, then creates a release commit and pushes both the commit and the
version tag to origin. The pushed tag triggers .github/workflows/release.yml automatically.
If no v* tags exist yet, the script uses the root Cargo.toml package.version as the
baseline for computing the next version.
Get the full option reference from either help form:
dcmnorm -h
dcmnorm --helpCommand shape:
dcmnorm [OPTIONS] [INPUT]... [OUTPUT]
dcmnorm infers the conversion direction from the input and output file types:
- DICOM input + JSON output, or no output, runs DICOM to JSON
- DICOM input + DICOM output with
--transfer-syntax <UID>runs DICOM to DICOM transcoding - DICOM input +
.png/.jpg/.jpeg/.rawoutput runs DICOM frame rendering - JSON input + DICOM output runs JSON to DICOM (requires an output path)
Positional arguments - a single trailing list, split by convention rather than as two separate
flags (shell globs work as usual, since the shell expands them before dcmnorm ever sees them):
- 0 or 1 path:
[INPUT], with OUTPUT defaulting to stdout JSON - e.g.dcmnorm in.dcm - 2 paths:
[INPUT] [OUTPUT]- e.g.dcmnorm in.dcm out.png - 3+ paths, no
--mpr: every path is an independent[INPUT], each processed on its own (same as piping via-I/--stdin-paths- see Batch mode below) - e.g.dcmnorm *.dcm --set SOPClassUID=... --overwrite - 2+ paths with
--mpr: every path but the last is an[INPUT]slice combined into one volume, the last is[OUTPUT]- see Render a Multiplanar Reformation (MPR)
General:
-h,--help-V,--version--list-transfer-syntaxes--check-dicom--check-dicom-logic/--strict--jpeg2000-codec <auto|openjpeg|kakadu>--verbose-I,--stdin-paths--filter <KEY>--overwrite--input-type <dicom|json>--output-type <dicom|json|raw|png|jpeg|mpeg4|texture>
DICOM editing:
--set <KEY=VALUE>--remove <KEY>--remove-private-tags
JSON conversion:
--format <flat|standard>--keys <name|hex>--bulk-data <inline|uri>--bulk-data-source [<SOURCE>]
DICOM transcoding:
--transfer-syntax <UID>
Rendering:
--render-frame <N>--render-all-frames--render-fps <FPS>--no-modality-lut--no-voi-lut--no-icc-profile--window-center <FLOAT>--window-width <FLOAT>--jpeg-quality <1-100>--output-width <PIXELS>--output-height <PIXELS>--scale-max-size <PIXELS>--redact-box <X,Y,W,H>--redact-color <R,G,B|#RRGGBB>--pad--pad-color <R,G,B|#RRGGBB>--no-overlays--overlay-index <N>--overlay-color <R,G,B|#RRGGBB>
MPR (Multiplanar Reformation):
--mpr <axial|coronal|sagittal|YAW,PITCH,ROLL>--mpr-origin <X,Y,Z>--mpr-depth <MM>--mpr-spacing <MM>--mpr-thickness <MM>--mpr-projection <mip|minip|average>
Texture Export:
--texture-max-dim <N>--texture-compression <none|gzip>
Histogram:
--histogram--histogram-bins <N>--histogram-frame <N>--histogram-min <FLOAT>--histogram-max <FLOAT>
DICOM to JSON defaults to:
- flattened JSON output
- named lookup keys where possible
- relative
BulkDataURIbulk data output (?offset=...&length=...) file://BulkDataURIoutput when--bulk-data-sourceis passed without a value- automatic
InlineBinaryfallback for bulk values of 32 bytes or less
JSON to DICOM defaults to:
- flattened JSON input
- optional
--bulk-data-sourcewhen resolvingBulkDataURI
DCMNORM_PERF— enables scoped performance timing logs to stderr. Truthy values:1,true,yes,on.DCMNORM_JPEG2000_CODEC— JPEG 2000 decoder preference:auto,openjpeg, orkakadu. The CLI always sets this from--jpeg2000-codec(defaultauto).DCMNORM_JPEG2000_DEBUG— enables JPEG 2000 debug logging when truthy.--verbosesets this to1.LD_LIBRARY_PATH— used to discover Kakadu shared libraries (libkdu*.so) at runtime.
(Build-time Kakadu variables are covered under Kakadu FFI.)
Convert a DICOM file to flattened JSON using named keys:
cargo run -p dcmnorm-cli -- test/files/dx.dcmConvert a DICOM file to standard JSON with hex keys and write to a file:
cargo run -p dcmnorm-cli -- test/files/dx.dcm out.json --format standard --keys hexConvert JSON back to a DICOM file:
cargo run -p dcmnorm-cli -- out.json out.dcmConvert JSON with BulkDataURI references back to DICOM using a source file:
cargo run -p dcmnorm-cli -- out.json out.dcm --bulk-data-source test/files/dx.dcmFilter DICOM attributes before conversion (only filtered tags are parsed and emitted):
cargo run -p dcmnorm-cli -- test/files/dx.dcm --filter StudyInstanceUIDUse multiple filters (repeat --filter or comma-separate values):
cargo run -p dcmnorm-cli -- test/files/dx.dcm out.json --filter StudyInstanceUID,PatientID--filter applies only to DICOM input. The parser reads until the requested attributes are
available, drops non-filtered attributes, and then continues with the normal conversion
pipeline (for example, DICOM to JSON output).
By default, bulk data is emitted as relative BulkDataURI values (?offset=...&length=...)
when converting DICOM to JSON, and values of 32 bytes or less are automatically emitted as
InlineBinary.
To embed absolute file:// URIs in BulkDataURI, pass --bulk-data-source without a value:
cargo run -p dcmnorm-cli -- test/files/dx.dcm --bulk-data uri --bulk-data-sourceChecks for a Part 10 header first, then falls back to dataset parsing up to SOPClassUID
for streams without file meta.
Single file:
cargo run -p dcmnorm-cli -- --check-dicom test/files/dx.dcmRead paths from stdin (-I / --stdin-paths) and print only valid DICOM paths:
find . -type f | dcmnorm -I --check-dicomBehavior:
- prints only successful (valid DICOM) paths to stdout
- suppresses per-file failure messages
- returns exit code
0when all inputs are valid - returns exit code
1if any input is invalid, unreadable, or not a regular file
Checks that a parseable DICOM file's metadata is internally consistent - as opposed to
--check-dicom, which only checks that the bytes parse as DICOM at all. Catches the kind of
mismatch that will break downstream decompress/render/routing but doesn't stop the file from
parsing: BitsStored/HighBit/BitsAllocated relationships, SamplesPerPixel vs
PhotometricInterpretation, native PixelData length vs Rows/Columns/NumberOfFrames,
an encapsulated frame's first bytes not matching what its transfer syntax claims (e.g. a file
labeled JPEG 2000 whose fragment doesn't start with a JPEG 2000 marker), malformed or
mismatched UIDs, and (checked but non-fatal by default) suspect geometry/window/CT-rescale
metadata. No pixel codec ever runs, so it stays cheap enough for a full-archive batch scan.
cargo run -p dcmnorm-cli -- --check-dicom-logic --verbose test/files/dx.dcmAdd --output-type json for a structured report (NDJSON, one object per line, when combined
with -I/--stdin-paths) instead of the one-line-per-file text summary:
cargo run -p dcmnorm-cli -- --check-dicom-logic --output-type json test/files/dx.dcmBehavior:
- prints
OK <path>/WARN <path>: N warning(s)/FAIL <path>: N error(s), M warning(s)per file;--verbosealso prints each finding's rule ID and message - findings are
error(will plausibly break decompress/render/routing) orwarning(suspicious/non-conformant but usually survivable) severity - returns exit code
0unless any file has anerror-level finding (or fails to parse);--strictalso fails onwarning-only files - supports
-I/--stdin-pathsfor batch scans, same as--check-dicom
Computes a value histogram per frame (Hounsfield units for CT, the rescaled physical value
otherwise - the same modality-LUT-applied values rendering and --render-frame use) and prints
it as JSON to OUTPUT or stdout, instead of the default DICOM/JSON conversion. Bin range defaults
to each frame's own observed min/max; --histogram-min/--histogram-max (must be given together)
pin it to a fixed range instead, e.g. to compare several frames or instances on the same axis.
For an RGB/color frame (SamplesPerPixel 3 - YBR variants are converted to RGB first, same as 2D
rendering), each frame produces three entries instead of one - one per "red"/"green"/
"blue" channel - each over that channel's own decoded 8-bit (0-255) samples, defaulting the
bin range to the full 0-255 domain (not each channel's own min/max) so all three stay directly
comparable. A grayscale frame's entry has channel: null.
Every frame in the instance:
cargo run -p dcmnorm-cli -- --histogram test/files/ct.dcmOne frame, with a custom bin count, written to a file:
cargo run -p dcmnorm-cli -- --histogram --histogram-frame 0 --histogram-bins 64 test/files/ct.dcm histogram.jsonOutput shape ({"frames": [...]}, one entry per computed frame - or three, for an RGB/color
frame; counts has binCount entries):
{
"frames": [
{
"frameIndex": 0,
"channel": null,
"binCount": 8,
"rangeMin": -1000.0,
"rangeMax": 2540.0,
"binWidth": 442.5,
"counts": [130793, 6601, 116719, 7134, 883, 4, 3, 7],
"pixelCount": 262144,
"minValue": -1000.0,
"maxValue": 2540.0,
"mean": -469.71,
"stdDev": 530.11
}
]
}Useful for files with no extension or a misleading one. Supported --output-type values are
dicom, json, raw, png, jpeg, mpeg4, texture.
# Convert a DICOM file with no extension to JSON
cargo run -p dcmnorm-cli -- dicom_data --input-type dicom
# Write DICOM output without an extension
cargo run -p dcmnorm-cli -- input.json output --output-type dicom
# Render a DICOM file to an arbitrary extension as PNG
cargo run -p dcmnorm-cli -- test/files/dx.dcm frame.img --output-type png
# Render a DICOM file as MPEG4 without a recognized extension
cargo run -p dcmnorm-cli -- test/files/ct.dcm output.video --output-type mpeg4 --render-fps 24Set one or more DICOM element values while converting by repeating --set KEY=VALUE. KEY
can be a DICOM keyword (for example, SOPClassUID) or a tag expression (for example,
(0008,0016)):
cargo run -p dcmnorm-cli -- test/files/dx.dcm out.dcm --transfer-syntax 1.2.840.10008.1.2.1 --set SOPClassUID=1.2.840.10008.5.1.4.1.1.2 --set StudyDescription=NormalizedUse --overwrite to write DICOM output back to the input path — useful for in-place edits:
cargo run -p dcmnorm-cli -- test/files/dx.dcm --set SOPClassUID=1.2.840.10008.5.1.4.1.1.2 --overwriteRender the first frame of a DICOM file to PNG:
cargo run -p dcmnorm-cli -- test/files/dx.dcm out.pngRender frame 2 to JPEG with explicit quality:
cargo run -p dcmnorm-cli -- test/files/ct.dcm out.jpg --render-frame 1 --jpeg-quality 95Render to raw 8-bit frame bytes:
cargo run -p dcmnorm-cli -- test/files/dx.dcm out.rawRender all frames from a multiframe dataset to numbered PNG files (out_000001.png, out_000002.png, ...):
cargo run -p dcmnorm-cli -- test/files/ct.dcm out.png --render-all-framesRender all frames from a multiframe dataset to a single .mp4 video:
cargo run -p dcmnorm-cli -- test/files/ct.dcm out.mp4 --render-fps 24If --render-fps is omitted for .mp4 output, dcmnorm uses frame-rate metadata from the
DICOM instance when available (RecommendedDisplayFrameRate, CineRate, FrameTime, or
FrameTimeVector) and falls back to 24 FPS otherwise. .mp4 output requires ffmpeg
installed and available on PATH.
Rendering supports 1-bit, 8-bit, and 16-bit monochrome pixel data, as well as RGB data. The
render pipeline includes decompression when needed and applies modality LUT and VOI
LUT/windowing by default. Use --no-modality-lut and/or --no-voi-lut to disable those
steps, and --window-center / --window-width to override VOI windowing.
Photometric interpretations supported by rendering:
MONOCHROME1MONOCHROME2PALETTE COLORRGB
Both planar configurations are supported for RGB rendering (PlanarConfiguration 0 and 1).
--output-type texture (or a .gputex output extension) packs a frame or volume as a lossless,
GPU-upload-ready payload instead of an 8-bit windowed render: the raw int16/uint16 sample
lattice (row-major, little-endian, gzip-compressed by default), plus a rescaleSlope/
rescaleIntercept pair for recovering physical values (e.g. HU). This lets a client do its own
window/level and oblique reslicing in a GPU shader instead of round-tripping to the server per
interaction - see dicom_io::texture_export's module doc for the full rationale.
Every export writes two files: OUTPUT (the payload bytes) and OUTPUT.json (a TextureMeta
sidecar - dimensions, physical spacing/origin/orientation, rescale slope/intercept, a default
window/level, and whether/how the payload is compressed). Always read compression from the
sidecar rather than assuming gzip - --texture-compression none disables it per export.
Export a single frame as a depth-1 texture:
cargo run -p dcmnorm-cli -- test/files/dx.dcm frame.gputexExport a specific frame with an explicit default window, capping the longest axis at 1024 samples:
cargo run -p dcmnorm-cli -- test/files/ct.dcm frame.gputex --render-frame 1 --window-center 40 --window-width 400 --texture-max-dim 1024Export a whole CT/MR series as one volume texture (its own native voxel lattice, not a reformatted
plane - --mpr's plane/depth/spacing flags don't apply here, only the volume-building/plane-value
is needed to select --mpr's multi-file mode):
dcmnorm --mpr axial series_dir/*.dcm volume.gputexSkip gzip compression (e.g. when the transport already compresses, such as a websocket permessage-deflate connection):
dcmnorm --mpr axial series_dir/*.dcm volume.gputex --texture-compression noneA third texture kind, ContentKind::FrameStack ("framestack" in the sidecar), packs several
independent original frames — a cine instance's own frames, or one file per instance in a
non-MPR-eligible multi-image series — as one texture-array upload with no resampling and no
physical geometry (rowSpacing/origin/etc. are meaningless for this kind). It has no CLI
invocation today; it's exposed only via the Node bindings' exportFrameStackTexture — see
bindings/node's own README.
MPR builds one 3D volume from multiple DICOM slice files (sharing a parallel stack - e.g. one
CT/MR series) and reformats it into either one 2D plane or a STACK of slices, honoring each
slice's real ImagePositionPatient/ImageOrientationPatient/PixelSpacing/SliceThickness
rather than just stacking images. Depending on OUTPUT's extension, the result is a rendered
2D image (or a numbered series of them), a proper multi-instance DICOM series, or a single
whole-volume NIfTI/NRRD file - see Reformatting a STACK of
slices below. This is the same
dicom_io::volume code the Node bindings use for the interactive viewer, exposed directly for
scripting/testing. The whole file set is always read and combined within this single dcmnorm
process - one build_volume call sees every slice given - so a series can never end up silently
split across separate volumes.
--mpr supplies its input files the same way as any other multi-file operation (see
Batch mode above) - directly as positional
arguments (shell globs work as usual) or piped via -I/--stdin-paths. Either way, --mpr is
what says "combine these into one volume, with the last path as OUTPUT" instead of the default
"process each independently" - its value is either a canonical view or an oblique rotation, not
both combined:
dcmnorm --mpr axial series_dir/*.dcm out.png
find series_dir -name "*.dcm" | dcmnorm -I --mpr axial out.pngThe three canonical, patient-anatomy-aligned views:
dcmnorm --mpr coronal series_dir/*.dcm coronal.png
dcmnorm --mpr sagittal series_dir/*.dcm sagittal.pngAn arbitrary oblique camera angle - YAW,PITCH,ROLL (degrees, about the patient's Z/X/Y axes
respectively), applied to the volume's own native acquisition basis:
dcmnorm --mpr 15,30,0 series_dir/*.dcm oblique.png--mpr-origin X,Y,Z (patient/LPS millimeters) recenters the reformat; it defaults to the volume's
own physical center. For the common case of stepping along the CURRENT view's own depth axis,
--mpr-depth MM is more convenient than recomputing a 3D point - it offsets --mpr-origin (or
the default center) along the resolved plane's own normal:
dcmnorm --mpr coronal --mpr-depth -15 series_dir/*.dcm coronal-anterior.pngBy default MPR reformats an infinitely-thin plane (one voxel thick). --mpr-thickness MM
reformats a thick slab instead - multiple depths spanning that many millimeters, centered on the
plane, combined per --mpr-projection (mip, the default - maximum intensity projection, the
radiology-standard way to make a thin vessel or bright structure visible across a slab even if it
only crosses the exact center plane at one point; minip - minimum intensity projection; or
average):
dcmnorm --mpr coronal --mpr-thickness 20 --mpr-projection mip series_dir/*.dcm coronal-mip.png--mpr-spacing MM sets the physical size of one output pixel (the same in both axes, so the
reformat is never distorted even when slice spacing differs from in-plane pixel spacing); it
defaults to the volume's own smallest voxel dimension. --output-width/--output-height (shared
with normal rendering) size the output image, defaulting to the volume's own row/column count.
--window-center/--window-width apply VOI windowing exactly as they do for a normal 2D render
(for .png/.jpg/.dcm output - see below, VOI windowing never applies to .nii/.nii.gz/
.nrrd, which always carry true rescaled values).
--mpr-origin/--mpr-depth/--mpr-spacing/--mpr-thickness/--mpr-projection all require
--mpr itself (they carry no "which plane" information on their own, so using them without it is
a clear error rather than a silently-ignored flag); --mpr-projection additionally requires a
positive --mpr-thickness. MPR mode is incompatible with --filter/--transfer-syntax/--set/
--remove/--render-all-frames/--render-fps/--scale-max-size, and fails with a clear error
(rather than a silently wrong reformat) if the given files don't share a consistent
ImageOrientationPatient - e.g. a genuinely gantry-tilt-inconsistent stack or an accidentally
mixed set of series.
A bare --mpr-depth MM (or omitting it, default 0) offsets a single plane, as above - exactly
one output slice. --mpr-depth also accepts a RANGE, which switches the whole --mpr invocation
from "one reformatted plane" to "a stack of slices spanning that depth range":
--mpr-depth START:END # e.g. -20:20
--mpr-depth START:END:STEP # explicit step, e.g. -20:20:2
--mpr-depth all # the volume's own full extent along the plane's normal
--mpr-depth all:STEP # full extent, explicit stepThe step defaults to --mpr-thickness (contiguous, non-overlapping slabs - the natural default
when you're already asking for a thick reformat) if it's set, else --mpr-spacing. all computes
the volume's own physical extent along the resolved plane's normal by projecting its bounding box
onto that normal - correct even for an oblique (rotated) plane whose normal isn't the volume's own
acquisition axis.
What a multi-slice stack produces depends entirely on OUTPUT's extension:
.png/.jpg/.jpeg: one numbered file per slice -OUTPUT_000001.png,OUTPUT_000002.png, ... (the same{stem}_{NNNNNN}.{ext}convention--render-all-framesalready uses) - each one an independent, VOI-windowed, 8-bit render exactly like a single-plane--mproutput..dcm/.dicom: one numbered, spatially-valid DICOM file per slice (see below) sharing a singleSeriesInstanceUID, so any PACS/viewer groups them as one loadable series..nii/.nii.gz/.nrrd: the entire stack as ONE whole-volume file (see below).
A single depth (the default, or an explicit non-range --mpr-depth MM) still writes exactly one
file at OUTPUT for every extension, including .dcm/.nii/.nrrd.
# 41 coronal slices, 2mm apart, as a numbered PNG stack:
dcmnorm --mpr coronal --mpr-depth -40:40:2 series_dir/*.dcm coronal.png
# The same slices as a proper multi-instance DICOM series:
dcmnorm --mpr coronal --mpr-depth -40:40:2 series_dir/*.dcm coronal.dcm
# The whole volume, reformatted to a 1mm-isotropic coronal-oriented NIfTI:
dcmnorm --mpr coronal --mpr-depth all --mpr-spacing 1 series_dir/*.dcm coronal.nii.gz--output-type is not valid with .nii/.nii.gz/.nrrd/.dcm output - the format is
determined entirely by OUTPUT's extension for these.
.nii/.nii.gz (gzip-compressed) and .nrrd write the ENTIRE reformatted stack as a single
volumetric file - float32 voxels carrying true rescaled values (e.g. Hounsfield units for CT),
never VOI-windowed or 8-bit-encoded, so the export is suitable for real volumetric analysis (3D
Slicer, ITK-SNAP, FSL, etc.), not just visual inspection. Neither format is available via an
existing well-maintained Rust crate, so both are written directly by dcmnorm:
- NIfTI-1 (
.nii/.nii.gz) usessformfor orientation. NIfTI's coordinate convention is RAS+ (Right/Anterior/Superior), while DICOM (anddcmnorm's own geometry) is LPS+ (Left/Posterior/Superior) - every direction vector and the origin get their X/Y components negated on export, the same conventiondcm2niix/nibabeluse for DICOM-derived NIfTI files. - NRRD (
.nrrd) names its space directly (space: left-posterior-superior), so no axis flip is needed - arguably the more direct/less error-prone choice for a DICOM-derived export.
.dcm/.dicom output wraps each reformatted slice in a standalone, spatially-valid DICOM object
using Multi-frame Grayscale Word Secondary Capture Image Storage
(1.2.840.10008.5.1.4.1.1.7.3, NumberOfFrames=1 per file) - not plain "Secondary Capture Image
Storage", which is 8-bit-only per its IOD and can't carry signed 16-bit rescaled values. Each
slice's ImagePositionPatient/ImageOrientationPatient/PixelSpacing/SliceThickness reflect
its actual reformatted geometry (not copied from the source series), so the output is a genuinely
reconstructable spatial series, not just a flat picture with DICOM headers bolted on. PixelData
is stored as signed 16-bit with RescaleSlope=1/RescaleIntercept=0 - the stored value IS the
physical value - so any DICOM viewer can window it however it likes, rather than getting a
pre-baked 8-bit render. Patient/study-identifying attributes (PatientName, PatientID,
StudyInstanceUID, Modality, etc.) are copied best-effort from the first input file, so the
derived series stays associated with its source study; SeriesDescription is fixed to
"MPR Reformat" and SeriesNumber to 9901, deliberately unlikely to collide with a real
acquired series. SeriesInstanceUID is generated once per --mpr invocation and shared across
every file in the output stack; SOPInstanceUID/StudyInstanceUID (when not copied from the
source) use the UUID-derived DICOM UID scheme (PS3.5 Annex B, 2.25.<uuid>), needing no
registered organization root.
DICOM overlay planes (group 60xx, up to 16 per instance: 6000,eeee .. 601E,eeee) are
composited onto the rendered image. Both encodings defined by the standard are supported:
- Distinct
OverlayData(60xx,3000, current standard):OverlayBitsAllocated=1, a separately-stored 1-bit-per-pixel bitmap, packed LSB-first. - Embedded in
PixelData(legacy CR/DX):OverlayBitsAllocatedequals the image's ownBitsAllocated, andOverlayBitPositionnames a specific high bit unused byBitsStored.
If an instance has one or more overlays, the first available overlay (ascending by DICOM group) renders by default:
cargo run -p dcmnorm-cli -- test/files/overlay.dcm out.pngSelect a different overlay by its 0-based index (ordinal among the overlays present, not the raw DICOM group), or disable overlay rendering entirely:
cargo run -p dcmnorm-cli -- test/files/overlay_multi.dcm out.png --overlay-index 1
cargo run -p dcmnorm-cli -- test/files/overlay.dcm out.png --no-overlaysOverlay pixels render in a fill color, R,G,B (0-255 each) or #RRGGBB hex, defaulting to
green (0,255,0):
cargo run -p dcmnorm-cli -- test/files/overlay.dcm out.png --overlay-color 255,0,0--overlay-index/--overlay-color require overlays to be enabled (they conflict with
--no-overlays), and an --overlay-index beyond the number of overlays present is an error
rather than being silently clamped.
Three small synthetic (no PHI, not derived from any real study) fixtures exercise overlay
rendering: test/files/overlay.dcm (one overlay, distinct OverlayData), test/files/ overlay_multi.dcm (two overlays, distinct OverlayData), and test/files/overlay_embedded.dcm
(one overlay, legacy embedded-in-PixelData encoding).
Use --verbose to print render/conversion diagnostics — without it, external tool output
such as ffmpeg is suppressed unless an error occurs. For stage-by-stage performance timing,
set DCMNORM_PERF=1 (or true/yes/on):
DCMNORM_PERF=1 dcmnorm test/files/mr.dcm out.jpg --output-width 920 --output-height 758
# or with an explicit render format for a file without a recognized extension
DCMNORM_PERF=1 dcmnorm test/files/mr.dcm output.img --output-type jpeg --output-width 920 --output-height 758dcmnorm has two interchangeable ways to run the same options across multiple input files
instead of just one - pick whichever fits how the file list is produced. Both apply the same
options to every path; errors for individual files are printed to stderr with the filename, and
dcmnorm exits non-zero if any file fails. (--mpr repurposes the same file list to mean
something different - combine every file into one volume instead of processing each
independently - see Render a Multiplanar Reformation (MPR).)
Give 3 or more files directly as positional arguments - no special flag needed, shell globs work
as usual since the shell expands them into separate arguments before dcmnorm ever sees them:
dcmnorm *.dcm(2 or fewer positional arguments are always [INPUT] [OUTPUT], per the single-file convention
above - batch mode only kicks in at 3+, since that shape was otherwise always a "too many
arguments" error.)
Or pipe input paths from stdin, one path per line, via -I/--stdin-paths - best for a file list
produced by another command (find, a database query, ...) or too large for a shell command line:
find . -name "*.dcm" | dcmnorm -I--set also applies in batch mode, and combines with --overwrite to update each file in place:
find . -name "*.dcm" | dcmnorm -I --set SOPClassUID=1.2.840.10008.5.1.4.1.1.2
find . -name "*.dcm" | dcmnorm -I --set SOPClassUID=1.2.840.10008.5.1.4.1.1.2 --overwriteTo emit file:// BulkDataURI values in batch mode, also pass --bulk-data-source without a value:
find . -name "*.dcm" | dcmnorm -I --bulk-data uri --bulk-data-sourceTranscode a DICOM file to Explicit VR Big Endian:
cargo run -p dcmnorm-cli -- test/files/dx.dcm out.dcm --transfer-syntax 1.2.840.10008.1.2.2List the transfer syntaxes known to the current build and whether dataset read/write and pixel decode/encode are available:
cargo run -p dcmnorm-cli -- --list-transfer-syntaxesTransfer-syntax support is build-specific. The default build in this repository enables the MPEG and JPEG-LS codec features in addition to the DICOM library support that is available without extra native imaging libraries:
- native uncompressed syntaxes
- deflated dataset syntaxes
- encapsulated uncompressed pixel data
- MPEG transfer syntax support via FFmpeg-backed build integration
- JPEG baseline decode/encode
- JPEG extended and JPEG lossless decode-only
- JPEG-LS transfer syntax support via CharLS-backed build integration
- JPEG 2000 decode-only
- RLE lossless decode-only
Transfer syntaxes which the current build cannot encode or decode are reported explicitly by
--list-transfer-syntaxes and by transcoding errors.
dcmnorm checks LD_LIBRARY_PATH at runtime for Kakadu libraries (libkdu*.so). Kakadu use
is FFI-only (Rust → C++ interop), not CLI-based, and requires the kakadu-ffi build feature
(see Kakadu FFI). If Kakadu FFI is not enabled or Kakadu is
unavailable, the OpenJPEG-based path remains in use. --jpeg2000-codec/DCMNORM_JPEG2000_CODEC
select between auto, openjpeg, and kakadu at runtime.
dcmtalk is a DIMSE (DICOM network) client/server covering the same ground as dcmtk's
echoscu/storescu/findscu/movescu/storescp, built on this repository's own DICOM
Upper Layer implementation (no dcmtk dependency).
Get the full option reference from either help form, for the tool itself or any subcommand:
dcmtalk -h
dcmtalk --help
dcmtalk echoscu --helpCommand shape:
dcmtalk <SUBCOMMAND> [OPTIONS] <ARGS>
Every SCU subcommand (echoscu/storescu/findscu/movescu) shares:
<DESTINATION>: peer address asHOST:PORT-a,--calling-aet <AE>: our AE title (defaultDCMTALK)-c,--called-aet <AE>: the peer's AE title, if it requires one to match--timeout <SECONDS>: absolute timeout for the whole operation (connect through release)-v,--verbose: log association negotiation, presentation contexts, and each DIMSE command/response to stderr
dcmtalk echoscu somepacs.example.com:11112
dcmtalk echoscu --verbose somepacs.example.com:11112Sends one or more DICOM files; directories are scanned recursively. Files are sent under
their native transfer syntax when the peer accepts it, transcoded to Explicit/Implicit VR
Little Endian otherwise (unless --never-transcode):
dcmtalk storescu somepacs.example.com:11112 test/files/dx.dcm
dcmtalk storescu somepacs.example.com:11112 test/files/ --max-pdu 65536Query keys are DICOM keywords as KEY=VALUE (match) or bare KEY (return key, universal
match), repeatable. Matches print as one DICOM JSON line per study to stdout:
dcmtalk findscu somepacs.example.com:11112 -k PatientID=12345 -k StudyDateAsks the peer to push a study to another AE title it already knows how to reach:
dcmtalk movescu somepacs.example.com:11112 MY_STORE_AE 1.2.840.113619.2.55.3.604688119.971.1600000000.123Listens for inbound associations and writes C-STORE'd instances under --cache-path as
S_<StudyInstanceUID>/<Modality>_<SOPInstanceUID>.dcm. C-FIND/C-MOVE requests are answered
"unable to process" — this is a receive-only SCP, not a full PACS:
dcmtalk storescp 11112 --ae-title MY_STORE_AE --cache-path ./receivedUse port 0 to bind an ephemeral port (useful for tests):
dcmtalk storescp 0 --verboseThis workspace is built on the DICOM-rs project's Rust
DICOM crates (dicom-core, dicom-object, dicom-ul, and others) for DICOM parsing, encoding,
and the Upper Layer/association protocol.
exec/dcmtalk's subcommands (echoscu/storescu/findscu/movescu/storescp) follow the
naming and behavior established by DCMTK, the long-standing reference
DICOM toolkit.
Thanks to both projects and their maintainers.