radreader
Read 2-D X-ray (CR / DX) and mammography (MG) DICOMs as the image a radiologist would see. Pure pydicom, following the DICOM order: rescale → window / VOI LUT → MONOCHROME1 flip → padding. radreader is also a quality checker and a fixer for broken headers.
import radreader
img = radreader.read("chest.dcm")
img.pixels # float32 (rows, cols) in [0, 1], no transpose needed
img.meta["voi"] # {'source': 'window', 'center': 2048.0, 'width': 4096.0, 'function': 'LINEAR', ...}
img.issues # [Issue(id='HDR_HIGHBIT_MISSING', severity='info', fixed_by='highbit_from_bits_stored', ...)]
Install
pip install radreader # numpy, pydicom, pylibjpeg + libjpeg (JPEG Lossless), python-gdcm
pip install "radreader[pylibjpeg]" # + pylibjpeg-openjpeg (JPEG 2000)
pip install "radreader[torch]" # radreader.torch.to_tensor
pip install "radreader[monai]" # radreader.monai.RadReader
For development: uv sync --all-extras, then .venv/bin/python -m pytest tests/.
Reading
radreader.read(path, voi=True, voi_source="auto", window_index=0, window=None,
out_range=(0.0, 1.0), qc="on", fix="header", fixers=None)
| Argument | Meaning |
|---|---|
voi |
Apply the window / VOI LUT. False: modality units (slope * raw + intercept), MONOCHROME1 still flipped, out_range ignored |
voi_source |
"auto" (VOI LUT if present, else window), "window" or "lut" |
window_index |
Which of several windows / LUTs (default 0). The standard calls them alternative views and does not name a main one |
window |
"SOFTER" (by WindowCenterWidthExplanation or LUTExplanation), or (center, width) / (center, width, "SIGMOID") |
out_range |
Output range, e.g. (-1, 1) |
qc |
"on" (header + pixel checks), "off" (skip checks; header fixes still run), "strict" (raise on warn issues) |
fix |
"header" (always on) or "image" (also image fixers, e.g. a percentile window for a clipped image) |
fixers |
Your own fixer functions, tried before the built-in ones |
path can be a file path or a binary file object. meta holds the technical tags and the choices made, including
photometric, transfer_syntax, decoder, windows (all options), voi (the one used), pixel_spacing
(PixelSpacing, else ImagerPixelSpacing) with pixel_spacing_source, geometry, view, image_laterality,
body_part, presentation_intent, vendor (cleaned, e.g. "GE Healthcare" → GE), padding_mask, steps and
issues. No patient tags.
Errors: InvalidDicomError (not DICOM, partial download), UnsupportedImageError (no pixels, multi-frame, color),
DecoderMissingError (with the install command), DecodeError. All are RadReaderError, with the step,
transfer syntax and file in the message and the original exception chained.
What it handles
| Case | What radreader does |
|---|---|
| HighBit missing | BitsStored - 1 (ITK 5.4.5 assumes 0 and loads the image blank) |
| MONOCHROME1 with a window | Window on the stored values, then flip. Flipping first picks the wrong pixels (the RadHarmony / ITK bug) |
| MONOCHROME1 flip | 1 - x on the fixed output range, never image min / max |
| No window | Full possible range from BitsStored and sign, through slope / intercept |
| Empty window tags (old Fuji CR) | Treated as no window |
| Several windows / VOI LUTs (GE mammo and knee) | Pick by index or name; meta["windows"] lists all |
| LINEAR, LINEAR_EXACT, SIGMOID | DICOM PS3.3 C.11.2.1.2 formulas, output exactly in [0, 1] |
| VOI LUT with fewer data bits than declared (GE: bits 16, max 16382) | Normalized by the real bit range, so it is not dark |
| LUT Descriptor length 0, LUTData as OW bytes | 65536 entries, read as uint16 |
| Decreasing VOI LUT (KODAK knee) | Applied as the standard says; HDR_LUT_INVERTS notes that output without it is inverted |
| Window + VOI LUT both present | VOI LUT by default (voi_source="auto"), window with voi_source="window" |
| Unusual rescale (slope 2.81525, intercept -240, RescaleType OD) | Normal Modality LUT math |
| Padding value, padding range limit (Hologic / GE mammo) | Padded pixels are black; meta["padding_mask"] |
| Raw values above BitsStored | High bits ignored (PS3.5 8.1.1) and flagged; fix="image" re-reads with the real bit depth |
| MONOCHROME2 + PresentationLUTShape INVERSE | Not flipped, flagged (0 files in the NAS scans) |
| FOR PROCESSING mammograms | Read as usual, flagged, meta["presentation_intent"] |
| JPEG Lossless (97% of mammo / knee), JPEG 2000, RLE, JPEG-LS | pydicom decoders; gdcm first (1.9x faster on JPEG Lossless, 2.8x on JPEG 2000, same output), pylibjpeg if it fails. radreader.set_decoder_preference([...]) |
| Missing BitsStored, BitsAllocated, Rows / Columns, TransferSyntaxUID, PixelRepresentation, SamplesPerPixel | Header fixers (see docs/qc_checks.md), chosen with the tag removal experiments |
| Missing PhotometricInterpretation | PresentationLUTShape INVERSE -> MONOCHROME1, else MONOCHROME2 (warn) |
| Presentation States, SR in a folder | Reported as "not an image" (FILE_NO_PIXELS), not as errors |
| Multi-frame (tomosynthesis) | Rejected with a clear error (3-D is planned) |
Spacing differs from ITK on purpose: radreader uses PixelSpacing (magnification corrected) first, ITK uses ImagerPixelSpacing for CR / DX / MG. Both raw values are in meta.
Quality checks
radreader.qc("x.dcm", level="header") # list[Issue]; levels: file, header, pixel
rows = radreader.check("/data/folder", level="pixel", workers=16, out="report.csv") # never stops on errors
issues, table = radreader.dataset_qc(rows) # duplicates, mixed bit depth per vendor, intensity outliers
for r in radreader.scan("/data/folder"): # headers only: ok / not_image / unsupported / error / hidden
print(r.path, r.status)
The CSV report holds technical tags only (safe to share), plus SOPInstanceUID and a pixel hash for the duplicate checks. All issue ids: docs/qc_checks.md.
radreader qc /data/folder --level pixel --workers 16 --out report.csv # summary by issue id x vendor
radreader fix in_folder out_folder --report changes.csv # repaired headers, pixels untouched
Fixers
Header fixers always run (the pixels need them) and are listed as issues with fixed_by. Image fixers are off
by default, so the default output stays "what the DICOM says".
@radreader.fixer("PIX_LOW_CONTRAST", kind="image")
def my_clahe(pixels, ds, meta):
...
return new_pixels
img = radreader.read(path, fixers=[my_clahe]) # or fix="image" for all registered image fixers
radreader.fix_file("in.dcm", "out.dcm") # header fixes written to a new file; UIDs kept
After a fixer, its check runs again: pass → fixed_by is set; fail → the next fixer is tried. A fixer that
raises is recorded as FIX_FAILED. Image fixers act on QC issues, so they do not run with qc="off".
PyTorch and MONAI
from radreader.torch import to_tensor
x = to_tensor(radreader.read(path)) # (1, H, W) float32
import monai as mn
from radreader.monai import RadReader
load = mn.transforms.LoadImageD(keys="img", reader=RadReader(out_range=(-1, 1)))
RadReader gives (W, H) by default (swap_ij=True), like MONAI's ITKReader and PydicomReader, so it drops into a
pipeline that already transposes. The 4x4 affine holds the spacing (DX / MG files usually have no position).
It is picklable for DataLoader workers.
Speed
All pixel math (modality, VOI, flip, padding, output range) is one lookup table over every possible raw value,
then out = lut[raw]. The table is cached by PixelParams. Each file is read once. Decoding is most of the time
(about 75% for JPEG Lossless mammograms).
Design
Three stages, so 3-D can be added without a rewrite: params = parse(ds) (header → PixelParams),
raw = decode(ds), pixels = render(raw, params) (any array shape). See docs/reading_steps.md
and docs/plan_full.md.
License
Apache-2.0
Metadata
Release files for radreader 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| radreader-0.1.0.tar.gz | 55.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| radreader-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 101.4 kB
Release files / radreader-0.1.0.tar.gz
| Download URL | radreader-0.1.0.tar.gz |
|---|---|
| Size | 55.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
fba701998ffee1c931847369170ccf6b6c613991e339b0daeff9aaab6a85c34e
|
|
BLAKE2b-256 checksum How to use checksums |
ec24a48ed5ac034e4b54d6e816d9511f8101b7df19bf248e0d3263c62971ee38
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.10.1 {"installer":{"name":"uv","version":"0.10.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / radreader-0.1.0-py3-none-any.whl
| Download URL | radreader-0.1.0-py3-none-any.whl |
|---|---|
| Size | 46.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
954da5d68fa0c958ca4ddfdcc03adb1df16353677ae432c25f4fa7128ae2646c
|
|
BLAKE2b-256 checksum How to use checksums |
ce6a195c23b19cf61e979197c714e93ecf889c8410df5b27138ace2ee256735b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.10.1 {"installer":{"name":"uv","version":"0.10.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|