Repository navigation
Add DICOM RT Dose export (save_dicom_dose + export-dicom CLI) - #10
Conversation
There was a problem hiding this comment.
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.
| # 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) |
There was a problem hiding this comment.
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.
| # 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\") |
| 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") |
There was a problem hiding this comment.
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.
| 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") |
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
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
5797db9 to
6caa657
Compare
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
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.
chromadose export-dicom --dose dose.npy --pixel-size 0.353 --plan "VMAT" -o dose.dcmWhat'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, unitsGY,DoseType=PHYSICAL). Dose is stored as uint32 with aDoseGridScalingfactor, exactly as TPS systems export it, so it round-trips throughload_dicom_dosewith negligible error (~1e-9 Gy in testing).write_like_original) and 3.x (enforce_file_format) save signatures.chromadose export-dicomCLI command wrapping it.RTDose/load_dicom_dose/save_dicom_dose/resample_to_filmfromchromadose.io.Tests
New round-trip tests in
test_dicom.py(2D, 3D, zero-dose, invalid-ndim) and a CLI round-trip intest_cli.py. DICOM tests are skipped when pydicom isn't installed, preserving the existing pydicom-free test path forRTDose/resample_to_film.ruff check src/clean,mypy --strictclean (via the project'suvtoolchain, matching CI).Notes
mainand can merge in either order.🤖 Generated with Claude Code
Generated by Claude Code