Metadata-Version: 2.4
Name: radreader
Version: 0.1.0
Summary: Lightweight DICOM reader for Python that returns an image a radiologist would see.
Author-email: Theo Dapamede <theo.dapamede@emory.edu>, Frank Li <frank.li@emory.edu>
License-Expression: Apache-2.0
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.24
Requires-Dist: pydicom>=3.0.2
Requires-Dist: pylibjpeg>=2.1.0
Requires-Dist: pylibjpeg-libjpeg>=2.3
Requires-Dist: python-gdcm>=3.2.6
Provides-Extra: pylibjpeg
Requires-Dist: pylibjpeg-openjpeg>=2.4; extra == "pylibjpeg"
Provides-Extra: torch
Requires-Dist: torch>=2.0; extra == "torch"
Provides-Extra: monai
Requires-Dist: monai>=1.3; extra == "monai"
Requires-Dist: torch>=2.0; extra == "monai"
Dynamic: license-file

# 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.

```python
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

```bash
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

```python
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

```python
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](docs/qc_checks.md).

```bash
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".

```python
@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

```python
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](docs/reading_steps.md)
and [docs/plan_full.md](docs/plan_full.md).

## License

Apache-2.0
