Skip to content

Add DICOM RT Dose export (save_dicom_dose + export-dicom CLI) - #10

Merged
cptfinch merged 2 commits into
mainfrom
claude/chromadose-dicom-export
Jul 2, 2026
Merged

cptfinch merged 2 commits into
mainfrom
claude/chromadose-dicom-export

Conversation

@cptfinch

Copy link
Copy Markdown
Owner

Summary

Implements the DICOM RT Dose export item from #8 — the round-trip companion to the existing importer. A measured film dose map can now be written back out as a valid DICOM RT Dose file for archival or re-import into the TPS.

from chromadose.io import save_dicom_dose
save_dicom_dose(dose_map.dose, "film_dose.dcm", pixel_spacing_mm=(0.353, 0.353))
chromadose export-dicom --dose dose.npy --pixel-size 0.353 --plan "VMAT" -o dose.dcm

What's included

  • chromadose.io.save_dicom_dose — writes a 2D (H, W) single-frame or 3D (n_slices, H, W) multi-frame dose array (in Gy) as an RT Dose object (MONOCHROME2, units GY, DoseType=PHYSICAL). Dose is stored as uint32 with a DoseGridScaling factor, exactly as TPS systems export it, so it round-trips through load_dicom_dose with negligible error (~1e-9 Gy in testing).
    • Handles the all-zero dose case (no divide-by-zero).
    • Supports both pydicom 2.x (write_like_original) and 3.x (enforce_file_format) save signatures.
  • chromadose export-dicom CLI command wrapping it.
  • Re-exports RTDose / load_dicom_dose / save_dicom_dose / resample_to_film from chromadose.io.

Tests

New round-trip tests in test_dicom.py (2D, 3D, zero-dose, invalid-ndim) and a CLI round-trip in test_cli.py. DICOM tests are skipped when pydicom isn't installed, preserving the existing pydicom-free test path for RTDose/resample_to_film.

  • Full suite: 97 passed (was 92). ruff check src/ clean, mypy --strict clean (via the project's uv toolchain, matching CI).

Notes

🤖 Generated with Claude Code


Generated by Claude Code

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces DICOM RT Dose export capabilities to the chromadose library, adding the save_dicom_dose function and a corresponding export-dicom CLI command. It also includes documentation updates and test coverage for the new features. The review feedback highlights a critical bug where negative dose values (often caused by noise in film measurements) could cause an underflow wrap-around when cast to np.uint32. It is recommended to clip negative values to zero and explicitly cast the pixel data to little-endian format to ensure compatibility, along with adding a test case to verify this behavior.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread chromadose/src/chromadose/io/dicom.py Outdated
Comment on lines +178 to +182
# Scale to uint32 with a DoseGridScaling factor (as TPS systems do).
max_dose = float(dose_arr.max())
uint32_max = np.iinfo(np.uint32).max
dose_grid_scaling = max_dose / uint32_max if max_dose > 0 else 1.0
pixel_data = np.round(dose_arr / dose_grid_scaling).astype(np.uint32)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Bug (Potential underflow/wrap-around on negative dose values):\n\nIn film dosimetry, measured dose maps often contain small negative values in background or zero-dose regions due to scanner noise, background subtraction, or calibration artifacts.\n\nSince pixel_data is cast to np.uint32 (unsigned 32-bit integer), any negative values in dose_arr will underflow and wrap around to extremely large positive values (e.g., close to uint32_max), resulting in massive, incorrect dose values when the DICOM file is reloaded.\n\nAdditionally, to ensure cross-platform compatibility and strict adherence to the ExplicitVRLittleEndian transfer syntax, we should explicitly cast the array to a little-endian 32-bit unsigned integer (<u4) rather than the host's native np.uint32.\n\nRecommendation:\nClip the input dose array to be non-negative (minimum of 0.0) before scaling, and explicitly cast to little-endian <u4.

Suggested change
# Scale to uint32 with a DoseGridScaling factor (as TPS systems do).
max_dose = float(dose_arr.max())
uint32_max = np.iinfo(np.uint32).max
dose_grid_scaling = max_dose / uint32_max if max_dose > 0 else 1.0
pixel_data = np.round(dose_arr / dose_grid_scaling).astype(np.uint32)
# Scale to uint32 with a DoseGridScaling factor (as TPS systems do).\n # Clip negative values (e.g. from noise) to 0 to prevent uint32 underflow wrap-around.\n dose_arr_clipped = np.maximum(dose_arr, 0.0)\n max_dose = float(dose_arr_clipped.max())\n uint32_max = np.iinfo(np.uint32).max\n dose_grid_scaling = max_dose / uint32_max if max_dose > 0 else 1.0\n # Force little-endian 32-bit unsigned integer to match ExplicitVRLittleEndian\n pixel_data = np.round(dose_arr_clipped / dose_grid_scaling).astype(\"<u4\")

Comment on lines +149 to +152
def test_invalid_ndim(self, tmp_path) -> None: # type: ignore[no-untyped-def]
"""A 1D array should raise ValueError."""
with pytest.raises(ValueError, match="2D or 3D"):
save_dicom_dose(np.zeros(5), tmp_path / "bad.dcm")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Test Coverage:\n\nAdd a unit test to verify that negative dose values (which can occur due to noise in film measurements) are safely clipped to 0 and do not cause underflow/wrap-around when exported and reloaded.

Suggested change
def test_invalid_ndim(self, tmp_path) -> None: # type: ignore[no-untyped-def]
"""A 1D array should raise ValueError."""
with pytest.raises(ValueError, match="2D or 3D"):
save_dicom_dose(np.zeros(5), tmp_path / "bad.dcm")
def test_negative_dose(self, tmp_path) -> None: # type: ignore[no-untyped-def]\n """Negative dose values (e.g. from noise) should be clipped to 0 to prevent underflow."""\n out = tmp_path / "negative.dcm"\n dose = np.array([[-0.5, 2.0], [1.0, -0.1]])\n save_dicom_dose(dose, out)\n rt = load_dicom_dose(out)\n expected = np.array([[0.0, 2.0], [1.0, 0.0]])\n np.testing.assert_allclose(rt.slice_2d(0), expected, atol=1e-5)\n\n def test_invalid_ndim(self, tmp_path) -> None: # type: ignore[no-untyped-def]\n """A 1D array should raise ValueError."""\n with pytest.raises(ValueError, match="2D or 3D"):\n save_dicom_dose(np.zeros(5), tmp_path / "bad.dcm")

cptfinch pushed a commit that referenced this pull request Jun 22, 2026
Addresses review feedback on #10: measured film dose can contain small
negative values (scanner noise, background subtraction), which would underflow
and wrap around to near-uint32_max when cast to an unsigned integer. Clip the
dose to >= 0 before scaling. Also cast pixel data to little-endian "<u4" so the
bytes match the Explicit VR Little Endian transfer syntax regardless of host
byte order. Adds a regression test for negative input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yuan3yEbpjdBsYaCooLmB
claude added 2 commits July 2, 2026 22:33
Implements the DICOM RT Dose export item from issue #8: a measured film dose
map can now be written back out as a valid DICOM RT Dose file for archival or
round-tripping into the treatment planning system.

- chromadose.io.save_dicom_dose: writes a 2D (single-frame) or 3D
  (multi-frame) dose array in Gy as an RT Dose object. Dose is stored as uint32
  with a DoseGridScaling factor, matching how TPS systems export RT Dose, so it
  round-trips through load_dicom_dose with negligible error. Handles the
  all-zero dose case (no divide-by-zero) and supports both pydicom 2.x and 3.x
  save signatures.
- chromadose export-dicom CLI command wrapping save_dicom_dose.
- Re-export load_dicom_dose / save_dicom_dose / resample_to_film / RTDose from
  chromadose.io.

Tests: round-trip 2D/3D, zero-dose, invalid-ndim, and a CLI round-trip
(skipped when pydicom is unavailable). Full suite 97 passed; ruff (src) and
mypy strict clean. Docs (cli.md, api.md) and CHANGELOG updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yuan3yEbpjdBsYaCooLmB
Addresses review feedback on #10: measured film dose can contain small
negative values (scanner noise, background subtraction), which would underflow
and wrap around to near-uint32_max when cast to an unsigned integer. Clip the
dose to >= 0 before scaling. Also cast pixel data to little-endian "<u4" so the
bytes match the Explicit VR Little Endian transfer syntax regardless of host
byte order. Adds a regression test for negative input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yuan3yEbpjdBsYaCooLmB
@cptfinch
cptfinch force-pushed the claude/chromadose-dicom-export branch from 5797db9 to 6caa657 Compare July 2, 2026 22:34
@cptfinch
cptfinch merged commit d30d8b4 into main Jul 2, 2026
3 checks passed
cptfinch pushed a commit that referenced this pull request Jul 7, 2026
Wires the BeamGeometry data from #12 into the DICOM exports (#10/#11):

- save_dicom_dose gains an optional `geometry` argument; when given it records
  the gantry / collimator / couch angles as the standard DICOM angle attributes
  (a supplementary annotation — they belong to the RT Beam module, not the RT
  Dose IOD, but are widely readable) plus the beam name.
- save_dicom_sr gains an optional `geometry` argument; when given it adds a
  "Beam geometry" container to the SR content tree (gantry / collimator / couch
  angles in degrees via UCUM, plus beam name and energy).
- CLI: `export-dicom` and `export-sr` take `--rtplan PATH` to pull the geometry
  from an RT Plan's first treatment beam (with a note when the plan has more
  than one beam).

Tests: geometry round-trips through the RT Dose angle tags and the SR content
tree, plus a CLI test that reads geometry from an RT Plan via export-sr.
Full suite 123 passed; ruff (src) and mypy strict clean. Docs + CHANGELOG updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yuan3yEbpjdBsYaCooLmB
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants