DICOM-discovery 0.11.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dicom_discovery-0.11.0/LICENSE +26 -0
- dicom_discovery-0.11.0/PKG-INFO +338 -0
- dicom_discovery-0.11.0/README.md +300 -0
- dicom_discovery-0.11.0/pyproject.toml +81 -0
- dicom_discovery-0.11.0/setup.cfg +4 -0
- dicom_discovery-0.11.0/src/DICOM_discovery/__init__.py +41 -0
- dicom_discovery-0.11.0/src/DICOM_discovery/__main__.py +5 -0
- dicom_discovery-0.11.0/src/DICOM_discovery/cli.py +339 -0
- dicom_discovery-0.11.0/src/DICOM_discovery/completeness.py +202 -0
- dicom_discovery-0.11.0/src/DICOM_discovery/contract.py +115 -0
- dicom_discovery-0.11.0/src/DICOM_discovery/doctor.py +148 -0
- dicom_discovery-0.11.0/src/DICOM_discovery/fsutil.py +73 -0
- dicom_discovery-0.11.0/src/DICOM_discovery/gui.py +116 -0
- dicom_discovery-0.11.0/src/DICOM_discovery/indexer.py +526 -0
- dicom_discovery-0.11.0/src/DICOM_discovery/job.py +279 -0
- dicom_discovery-0.11.0/src/DICOM_discovery/report_cohort.py +1021 -0
- dicom_discovery-0.11.0/src/DICOM_discovery/report_map.py +142 -0
- dicom_discovery-0.11.0/src/DICOM_discovery/rt_integrity.py +424 -0
- dicom_discovery-0.11.0/src/DICOM_discovery/synthetic.py +290 -0
- dicom_discovery-0.11.0/src/DICOM_discovery/tg263.py +43 -0
- dicom_discovery-0.11.0/src/DICOM_discovery.egg-info/PKG-INFO +338 -0
- dicom_discovery-0.11.0/src/DICOM_discovery.egg-info/SOURCES.txt +41 -0
- dicom_discovery-0.11.0/src/DICOM_discovery.egg-info/dependency_links.txt +1 -0
- dicom_discovery-0.11.0/src/DICOM_discovery.egg-info/entry_points.txt +2 -0
- dicom_discovery-0.11.0/src/DICOM_discovery.egg-info/requires.txt +9 -0
- dicom_discovery-0.11.0/src/DICOM_discovery.egg-info/top_level.txt +1 -0
- dicom_discovery-0.11.0/tests/test_cli_encoding.py +56 -0
- dicom_discovery-0.11.0/tests/test_cli_report.py +132 -0
- dicom_discovery-0.11.0/tests/test_completeness.py +93 -0
- dicom_discovery-0.11.0/tests/test_contract.py +112 -0
- dicom_discovery-0.11.0/tests/test_deploy_config.py +81 -0
- dicom_discovery-0.11.0/tests/test_gui_launcher.py +98 -0
- dicom_discovery-0.11.0/tests/test_indexer.py +215 -0
- dicom_discovery-0.11.0/tests/test_job.py +230 -0
- dicom_discovery-0.11.0/tests/test_mr_planning.py +78 -0
- dicom_discovery-0.11.0/tests/test_nas_indexer.py +203 -0
- dicom_discovery-0.11.0/tests/test_packaging.py +98 -0
- dicom_discovery-0.11.0/tests/test_portability.py +123 -0
- dicom_discovery-0.11.0/tests/test_real_data.py +63 -0
- dicom_discovery-0.11.0/tests/test_report_cohort.py +531 -0
- dicom_discovery-0.11.0/tests/test_rollup_review.py +66 -0
- dicom_discovery-0.11.0/tests/test_rt_integrity.py +198 -0
- dicom_discovery-0.11.0/tests/test_tg263.py +53 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vivian Metzger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Research Use Only. This software is a research data-curation tool. It is not a
|
|
26
|
+
medical device and must not be used for diagnosis or treatment decisions.
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: DICOM_discovery
|
|
3
|
+
Version: 0.11.0
|
|
4
|
+
Summary: Cohort-level QC for longitudinal radiotherapy (DICOM-RT) data.
|
|
5
|
+
Author: Vivian Metzger
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/vivirtuose/DICOM_discovery
|
|
8
|
+
Project-URL: Source, https://github.com/vivirtuose/DICOM_discovery/tree/master/DICOM_discovery
|
|
9
|
+
Project-URL: Changelog, https://github.com/vivirtuose/DICOM_discovery/blob/master/DICOM_discovery/CHANGELOG.md
|
|
10
|
+
Project-URL: Issues, https://github.com/vivirtuose/DICOM_discovery/issues
|
|
11
|
+
Project-URL: NAS deployment guide, https://github.com/vivirtuose/DICOM_discovery/blob/master/DICOM_discovery/docs/NAS_DEPLOYMENT.md
|
|
12
|
+
Keywords: dicom,radiotherapy,dicom-rt,data-curation,quality-control,oncology
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Healthcare Industry
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
25
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: pandas>=1.3
|
|
30
|
+
Requires-Dist: pydicom>=2.3
|
|
31
|
+
Requires-Dist: jsonschema>=4.0
|
|
32
|
+
Requires-Dist: pyyaml>=5.1
|
|
33
|
+
Requires-Dist: plotly>=5.0
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
36
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
37
|
+
Dynamic: license-file
|
|
38
|
+
|
|
39
|
+
# DICOM_discovery
|
|
40
|
+
|
|
41
|
+
[](https://github.com/vivirtuose/DICOM_discovery/actions/workflows/ci.yml)
|
|
42
|
+

|
|
43
|
+

|
|
44
|
+

|
|
45
|
+
|
|
46
|
+
**Cohort-level quality control for longitudinal radiotherapy (DICOM-RT) data.**
|
|
47
|
+
|
|
48
|
+
Before any radiomics or dose–outcome study can run, someone has to prove the cohort is
|
|
49
|
+
actually usable: that every patient has a complete, internally consistent RT chain
|
|
50
|
+
(`CT → RTSTRUCT → RTPLAN → RTDOSE`), sharing one frame of reference, with the target
|
|
51
|
+
volumes contoured. On a real clinical PACS this is rarely true — exports are duplicated,
|
|
52
|
+
references dangle, frames of reference drift. This tool finds those problems **at the
|
|
53
|
+
cohort level** and explains each one, so they are caught *before* they silently corrupt a
|
|
54
|
+
study (e.g. a patient analysed against the wrong structure set).
|
|
55
|
+
|
|
56
|
+
This is not a DICOM viewer and not a PACS. It is the unglamorous data-curation step that
|
|
57
|
+
precedes modelling — deliberately, because that step is where clinical cohorts break.
|
|
58
|
+
|
|
59
|
+
## Architecture — why it adapts to any server
|
|
60
|
+
|
|
61
|
+
A single **indexer** walks any file tree and reads DICOM *headers* into one canonical
|
|
62
|
+
table (`patient · study · series · modality · FrameOfReferenceUID · referenced UIDs ·
|
|
63
|
+
path`). Every analysis (RT integrity, completeness) consumes **only that table** and never
|
|
64
|
+
touches the filesystem. So the file path stops being *data* and becomes an opaque pointer:
|
|
65
|
+
patient/study/timepoint identity comes from DICOM tags, not folder positions, and the tool
|
|
66
|
+
works the same on a tidy export, a flat dump, or a different hospital's layout. DICOM files
|
|
67
|
+
are detected by **content** (the `DICM` preamble / a parseable SOPClassUID), not by a
|
|
68
|
+
`.dcm` extension — real exports are often extensionless.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
Runs on **Python 3.9–3.14**, tested in CI on Linux, macOS and Windows. One command, straight from GitHub —
|
|
75
|
+
no clone needed (the package lives in the `DICOM_discovery/` subdirectory of the repo):
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# From PyPI — available once the first release is published (see docs/RELEASING.md):
|
|
79
|
+
pip install dicom-discovery # or: pipx install dicom-discovery (isolated CLI)
|
|
80
|
+
|
|
81
|
+
# Until then (or to track master), straight from GitHub — no clone needed:
|
|
82
|
+
pip install "git+https://github.com/vivirtuose/DICOM_discovery.git#subdirectory=DICOM_discovery"
|
|
83
|
+
|
|
84
|
+
dicom-discovery --help
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
No Python on the target machine? Every release also ships a **standalone binary** for
|
|
88
|
+
Windows, macOS and Linux — unzip, double-click, pick the two folders
|
|
89
|
+
([docs/STANDALONE.md](https://github.com/vivirtuose/DICOM_discovery/blob/master/DICOM_discovery/docs/STANDALONE.md)).
|
|
90
|
+
No internet either? Every release ships `docker load`-able images and offline
|
|
91
|
+
pip wheelhouses for Linux, Windows and macOS — see the
|
|
92
|
+
[NAS deployment guide](https://github.com/vivirtuose/DICOM_discovery/blob/master/DICOM_discovery/docs/NAS_DEPLOYMENT.md).
|
|
93
|
+
|
|
94
|
+
The interactive Plotly report is a **core dependency**, so `dicom-discovery report` works out
|
|
95
|
+
of the box — there is no separate visualisation step to install. For development, clone the
|
|
96
|
+
repo and use an editable install with the test extras (`pip install -e ".[dev]"`).
|
|
97
|
+
|
|
98
|
+
## Run it on a hospital NAS (unattended)
|
|
99
|
+
|
|
100
|
+
`dicom-discovery job` is the scheduled, unattended run: it scans the share **read-only**,
|
|
101
|
+
writes a timestamped run folder, keeps `latest/` pointing at the last usable report, records
|
|
102
|
+
a PHI-free `last_run.json` for monitoring, refuses to overlap a running scan, prunes old runs
|
|
103
|
+
and re-uses an index cache so nightly re-scans only read new files. NAS recycle bins,
|
|
104
|
+
snapshots and thumbnail folders are skipped; directories it cannot list are reported
|
|
105
|
+
(`PARTIAL`, exit code 3), never silently ignored.
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
dicom-discovery job --root /mnt/dicom --output-dir /srv/qc --dry-run # preflight, writes nothing
|
|
109
|
+
dicom-discovery job --root /mnt/dicom --output-dir /srv/qc # exit 0/1/2/3/75
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Two air-gapped deployment paths, both built and tested in CI (workflow **NAS bundle**) and
|
|
113
|
+
downloadable as artifacts:
|
|
114
|
+
|
|
115
|
+
- **Container on the NAS** (Synology Container Manager, QNAP, TrueNAS): a hardened image
|
|
116
|
+
(`amd64` + `arm64`) run via [`deploy/nas/docker-compose.yml`](https://github.com/vivirtuose/DICOM_discovery/blob/master/DICOM_discovery/deploy/nas/docker-compose.yml)
|
|
117
|
+
with no network, the DICOM share mounted read-only, a read-only root filesystem and a
|
|
118
|
+
non-root user.
|
|
119
|
+
- **Linux server mounting the share**: an offline pip wheelhouse per Python version +
|
|
120
|
+
[`install-offline.sh`](https://github.com/vivirtuose/DICOM_discovery/blob/master/DICOM_discovery/deploy/nas/install-offline.sh) and systemd timer units.
|
|
121
|
+
|
|
122
|
+
Step-by-step guide (French, for the hospital team): [`docs/NAS_DEPLOYMENT.md`](https://github.com/vivirtuose/DICOM_discovery/blob/master/DICOM_discovery/docs/NAS_DEPLOYMENT.md).
|
|
123
|
+
|
|
124
|
+
## On an unfamiliar machine
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
dicom-discovery --version
|
|
128
|
+
dicom-discovery doctor --root /mnt/dicom --output-dir /srv/qc
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`doctor` prints the tool, Python, platform and dependency versions, the stdout / filesystem /
|
|
132
|
+
locale encodings and — on Windows — whether long paths (>260 characters) are enabled, then
|
|
133
|
+
probes the share for readability and the output folder for writability. It exits 1 when a
|
|
134
|
+
check fails, so a scheduler can run it as a gate before a 40-minute scan that would write
|
|
135
|
+
nothing. CSV exports carry a UTF-8 BOM so accented verdict actions open correctly in Excel;
|
|
136
|
+
`verdicts.json` stays BOM-free for strict parsers.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Quickstart
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
pip install -e ".[dev]"
|
|
144
|
+
|
|
145
|
+
# One command: generate synthetic cohorts, run both engines, write the completeness map.
|
|
146
|
+
dicom-discovery demo # outputs land in ./examples/
|
|
147
|
+
xdg-open examples/completeness_map.html
|
|
148
|
+
|
|
149
|
+
# Point the tools at any DICOM tree (recurses; reads headers only):
|
|
150
|
+
dicom-discovery index --root /data/COHORT --dry-run # preflight: what's there?
|
|
151
|
+
dicom-discovery rt-check --root /data/COHORT --out-csv rt.csv # + rt_by_patient.csv
|
|
152
|
+
dicom-discovery completeness --root /data/COHORT --out map.html
|
|
153
|
+
|
|
154
|
+
# Unified RT-integrity + completeness report in one self-contained HTML (RUO; opens air-gapped):
|
|
155
|
+
dicom-discovery report --root /data/COHORT --out cohort_report.html
|
|
156
|
+
dicom-discovery report --root /data/COHORT --dry-run # preflight only, no output
|
|
157
|
+
|
|
158
|
+
# Emit the verdict as a versioned, schema-validated artifact (the HTML is one projection of it):
|
|
159
|
+
dicom-discovery report --root /data/COHORT --out cohort_report.html --json verdicts.json
|
|
160
|
+
dicom-discovery rt-check --root /data/COHORT --json verdicts.json
|
|
161
|
+
|
|
162
|
+
pytest # run the test suite
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### The verdict contract (`--json`)
|
|
166
|
+
|
|
167
|
+
`--json` writes a `verdicts.json` validated against a versioned JSON Schema
|
|
168
|
+
(`schema_version`). It carries the tool version, a UTC timestamp, the protocol, a **run
|
|
169
|
+
manifest with a content-hash provenance fingerprint**, and one entry per patient
|
|
170
|
+
(`verdict`, `reason`, recommended `action`, chain/ROI flags, TG-263 `n_roi_nonstandard`).
|
|
171
|
+
This is what makes the output a *reusable, re-runnable, diffable* artifact rather than a
|
|
172
|
+
report to read by eye — a downstream pipeline consumes the JSON; the HTML is one projection
|
|
173
|
+
of the same payload. **RUO — not for diagnostic use.**
|
|
174
|
+
|
|
175
|
+
ROI names are checked against a bounded subset of **AAPM TG-263** nomenclature (Mayo et al.,
|
|
176
|
+
2018) as an additive signal; it never changes the OK/WARN/INCOMPLETE verdict. Roadmap:
|
|
177
|
+
provenance down to the SOPInstanceUID, DICOM-SR (PS3.16) / FHIR export, IHE-RO alignment.
|
|
178
|
+
|
|
179
|
+
`--group-by dicom` (default) keys patients by the `PatientID` tag; `--group-by folder`
|
|
180
|
+
keys by the top folder (use it when `PatientID` is a hospital MRN rather than the study id).
|
|
181
|
+
`--dry-run` runs a preflight that reports what was found and **refuses to emit a report on
|
|
182
|
+
an empty scan** — so the tool never produces a falsely-confident result on a tree it did
|
|
183
|
+
not understand. `dicom-discovery` ≡ `python -m DICOM_discovery`.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## What it catches — a real example
|
|
188
|
+
|
|
189
|
+
The QC grades every patient `OK` / `WARN` / `INCOMPLETE` and attaches *explainable*
|
|
190
|
+
findings, each with a **severity** (`ERROR`/`WARNING`/`INFO`) and a **confidence**
|
|
191
|
+
(`HIGH` for facts derived from referenced UIDs, `HEURISTIC` for guesses). The headline
|
|
192
|
+
case — the kind of bug that would otherwise pass unnoticed into a study:
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
P003 WARN [ERROR/HIGH] PLAN_STRUCT_LINK_BROKEN:
|
|
196
|
+
RTPLAN references a RTSTRUCT SOPInstanceUID absent from this patient
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
That patient *looks* complete (it has a CT, an RTSTRUCT, an RTPLAN and an RTDOSE), but the
|
|
200
|
+
plan was contoured against a structure set that is no longer in the folder. A naive file
|
|
201
|
+
count says "fine"; the dosimetry is not.
|
|
202
|
+
|
|
203
|
+
## Validation: case → expected → detected
|
|
204
|
+
|
|
205
|
+
The synthetic cohort carries its own ground truth, so the QC is validated end-to-end
|
|
206
|
+
(this table is produced by `pytest` and the demo above):
|
|
207
|
+
|
|
208
|
+
| Patient | Scenario | Expected | Detected |
|
|
209
|
+
|---------|----------|----------|----------|
|
|
210
|
+
| P001 | Complete chain, consistent FoR, all targets | `OK` | `OK` ✅ |
|
|
211
|
+
| P002 | Two RTSTRUCT re-exports, plan links the 2nd | `OK` | `OK` ✅ |
|
|
212
|
+
| P003 | RTPLAN references an **absent** RTSTRUCT | `WARN` (`PLAN_STRUCT_LINK_BROKEN`) | ✅ |
|
|
213
|
+
| P004 | Orphan RTDOSE → absent RTPLAN | `WARN` (`DOSE_PLAN_LINK_BROKEN`) | ✅ |
|
|
214
|
+
| P005 | RTDOSE FrameOfReference ≠ RTSTRUCT/RTPLAN | `WARN` (`FOR_INCONSISTENT_RT`) | ✅ |
|
|
215
|
+
| P006 | No RTDOSE | `INCOMPLETE` (`MISSING_RTDOSE`) | ✅ |
|
|
216
|
+
| P007 | Complete chain but no PTV | `WARN` (`MISSING_TARGET_ROI`) | ✅ |
|
|
217
|
+
|
|
218
|
+
## On a real cohort (98 patients, ~1M files)
|
|
219
|
+
|
|
220
|
+
Run against a real brain-RT trial export, the tool surfaced things a naive scan hides:
|
|
221
|
+
|
|
222
|
+
- The tree holds **1,028,034 files** — only ~47k carry a `.dcm` extension; **~970k are
|
|
223
|
+
extensionless DICOM** (`image (0128)` …). The earlier extension-filtered scan was blind
|
|
224
|
+
to ~95 % of the imaging; content-detection recovers it.
|
|
225
|
+
- The DICOM `PatientID` tag is the **hospital MRN**, not the study pseudonym `IC NNN`
|
|
226
|
+
(the folder). The `--dry-run` preflight flags this *before* any report, so you switch to
|
|
227
|
+
`--group-by folder` rather than silently keying patients by MRN.
|
|
228
|
+
- Patients have **7.5 studies on average** (longitudinal MR follow-up). Only **102 of 731
|
|
229
|
+
studies actually contain an RT object**; the other 629 are follow-up MR and are reported
|
|
230
|
+
as `NOT_RT` (out of scope) instead of being wrongly flagged "incomplete RT chain".
|
|
231
|
+
- **Verdicts of that run (June 2026):** **96 OK / 3 WARN / 3 INCOMPLETE** over the **102 RT
|
|
232
|
+
studies**; per patient, **97 of the 98 carry RT objects** and 1 is imaging-only (`NO_RT`).
|
|
233
|
+
The per-patient rollup — the unit a PI cares about, and what the report leads with — was
|
|
234
|
+
not recorded here; re-run `rt-check` on the cohort to quote it.
|
|
235
|
+
- One patient's RT chain is *fragmented* across several studies — reported as a single honest
|
|
236
|
+
verdict ("`INCOMPLETE: missing RTDOSE; FRAGMENTED across N studies`"), not as confusing
|
|
237
|
+
per-study noise, by reconciling the chain through resolved referenced UIDs.
|
|
238
|
+
|
|
239
|
+
## Longitudinal completeness — observed vs. expected
|
|
240
|
+
|
|
241
|
+
"Complete" means nothing without a protocol to compare against. Given a `Protocol`
|
|
242
|
+
(timepoints + the modalities required at each — editable as YAML, see
|
|
243
|
+
`protocol.brain_rt_followup.yaml`), every `(patient, timepoint, modality)` cell is graded
|
|
244
|
+
and rendered as a self-contained heatmap whose colours make the one distinction a file
|
|
245
|
+
count cannot:
|
|
246
|
+
|
|
247
|
+
| Colour | State | Meaning |
|
|
248
|
+
|--------|-------|---------|
|
|
249
|
+
| 🟩 green | `PRESENT` | expected and found |
|
|
250
|
+
| 🟥 red | `MISSING` | **expected but absent** — the actionable cell |
|
|
251
|
+
| 🟦 blue | `EXTRA` | found but not in the protocol |
|
|
252
|
+
| ⬜ grey | `N/A` | not expected (an empty cell that is *fine*) |
|
|
253
|
+
|
|
254
|
+
An empty-but-expected cell (red) is visually distinct from a merely-irrelevant cell
|
|
255
|
+
(grey): that separation is the whole point. The map embeds Plotly inline (no CDN, opens on
|
|
256
|
+
an air-gapped clinical network) and is accompanied by a per-patient completeness table:
|
|
257
|
+
|
|
258
|
+
```
|
|
259
|
+
patient n_expected n_present n_missing pct_complete
|
|
260
|
+
L001 8 8 0 100.0
|
|
261
|
+
L002 8 7 1 87.5 # follow-up gap (no M6 MR)
|
|
262
|
+
L003 8 6 2 75.0 # no baseline RTDOSE; no M12 MR
|
|
263
|
+
L004 8 5 3 62.5 # baseline only, lost to follow-up
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## What changed vs the previous version (and why)
|
|
267
|
+
|
|
268
|
+
The earlier prototype inspected a *single* object per modality (`df.iloc[0]`), which
|
|
269
|
+
produced two documented false positives:
|
|
270
|
+
|
|
271
|
+
- **Multi-export RTSTRUCT** (P002): a patient with several structure-set re-exports was
|
|
272
|
+
wrongly flagged "RTPLAN does not reference the RTSTRUCT" whenever the plan pointed at a
|
|
273
|
+
different export. v2 resolves the link against the **set of all present**
|
|
274
|
+
SOPInstanceUIDs, so P002 is correctly `OK`. (`tests/test_rt_integrity.py::test_p002_no_multi_struct_false_positive`
|
|
275
|
+
is a regression test for exactly this.)
|
|
276
|
+
- **Planning-CT guess**: the CT/FoR check used `"RT" in filepath`, yielding spurious "FoR
|
|
277
|
+
RT ≠ FoR CT". v2 keeps this check but downgrades it to a `HEURISTIC`/`INFO` hint that
|
|
278
|
+
never, on its own, fails a patient — because *which* CT is the planning CT is genuinely
|
|
279
|
+
uncertain from headers alone.
|
|
280
|
+
|
|
281
|
+
The point: a QC that cries wolf is worse than no QC. Findings are now graded so a reader
|
|
282
|
+
can separate a hard chain break (`ERROR`/`HIGH`) from a low-confidence hint.
|
|
283
|
+
|
|
284
|
+
Later, running at full cohort scale surfaced two more: integrity is assessed **per study**
|
|
285
|
+
(so a re-plan with its own frame of reference is not a false inconsistency), studies with
|
|
286
|
+
no RT object are scoped out as `NOT_RT`, and a **per-patient rollup** reconciles the chain
|
|
287
|
+
across studies via resolved referenced UIDs — distinguishing a genuinely missing link from
|
|
288
|
+
a merely *fragmented* one, without the naive object-union that would produce a falsely
|
|
289
|
+
reassuring `OK`.
|
|
290
|
+
|
|
291
|
+
## Known limitations
|
|
292
|
+
|
|
293
|
+
This is a research data-curation tool (**Research Use Only**), not a medical device and
|
|
294
|
+
not a clinical safety check.
|
|
295
|
+
|
|
296
|
+
- The planning-CT ↔ RT frame-of-reference match is heuristic (`INFO` only).
|
|
297
|
+
- The per-patient rollup reconciles the chain through *resolved* referenced UIDs but does
|
|
298
|
+
not build a general connected-components graph; a deeply fragmented chain is flagged
|
|
299
|
+
`FRAGMENTED` for manual review rather than fully reconstructed. (Deliberate scope choice.)
|
|
300
|
+
- Scanning is header-only but the first scan reads every file once; on a ~1M-file tree over
|
|
301
|
+
NFS this takes a while. Later runs re-use the index cache (`--cache`, on by default in
|
|
302
|
+
`job`) and only read new or changed files.
|
|
303
|
+
- Validated on one centre's cohort and a synthetic set; other vendors' exports (private
|
|
304
|
+
tags, transfer syntaxes) are not yet characterised.
|
|
305
|
+
- ROI target detection is substring-based (`GTV`/`CTV`/`PTV`); the TG-263 check is a light,
|
|
306
|
+
advisory nomenclature flag.
|
|
307
|
+
|
|
308
|
+
## Data & privacy
|
|
309
|
+
|
|
310
|
+
No real patient data is in this repository. The example cohort is fully synthetic
|
|
311
|
+
(fabricated UIDs, `SYNTHETIC^PHANTOM` as patient name, header-only, no pixel data) and is
|
|
312
|
+
regenerated on demand. The `.gitignore` blocks `*.dcm`, `*.csv`, `*.html` and output
|
|
313
|
+
folders so that real clinical data and generated reports are never committed.
|
|
314
|
+
|
|
315
|
+
## Layout
|
|
316
|
+
|
|
317
|
+
```
|
|
318
|
+
src/DICOM_discovery/
|
|
319
|
+
indexer.py # walk any tree -> canonical DICOM table (content-detected, traced keys)
|
|
320
|
+
rt_integrity.py # RT chain integrity (per study) + per-patient rollup
|
|
321
|
+
completeness.py # observed-vs-expected model (Protocol, timepoint from StudyDate)
|
|
322
|
+
report_map.py # self-contained completeness heatmap (Plotly embedded, no CDN)
|
|
323
|
+
report_cohort.py # unified RT-integrity + completeness cohort report (self-contained HTML)
|
|
324
|
+
cli.py / __main__.py # `dicom-discovery` commands: demo / index / rt-check / completeness / report / job / doctor
|
|
325
|
+
job.py # unattended scheduled run (run folders, latest/, status, lock, retention)
|
|
326
|
+
doctor.py # environment report + share/output probes (`doctor`)
|
|
327
|
+
gui.py # double-click launcher for the standalone binary (folder pickers)
|
|
328
|
+
fsutil.py # atomic writes (outputs and cache on network shares)
|
|
329
|
+
synthetic.py # synthetic DICOM-RT + longitudinal cohorts (+ ground truth)
|
|
330
|
+
protocol.brain_rt_followup.yaml # example expected-content protocol
|
|
331
|
+
Dockerfile # hardened NAS image (default command = `job`)
|
|
332
|
+
deploy/nas/ # docker-compose + .env, offline installer, systemd units
|
|
333
|
+
docs/NAS_DEPLOYMENT.md # NAS deployment guide (French)
|
|
334
|
+
deploy/standalone/ # PyInstaller spec + entry point for the frozen binary
|
|
335
|
+
docs/RELEASING.md # how a version is tagged, drafted and published
|
|
336
|
+
docs/STANDALONE.md # the no-Python, double-click path (French)
|
|
337
|
+
tests/ # pytest suite (209 tests), synthetic + real public data
|
|
338
|
+
```
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# DICOM_discovery
|
|
2
|
+
|
|
3
|
+
[](https://github.com/vivirtuose/DICOM_discovery/actions/workflows/ci.yml)
|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
**Cohort-level quality control for longitudinal radiotherapy (DICOM-RT) data.**
|
|
9
|
+
|
|
10
|
+
Before any radiomics or dose–outcome study can run, someone has to prove the cohort is
|
|
11
|
+
actually usable: that every patient has a complete, internally consistent RT chain
|
|
12
|
+
(`CT → RTSTRUCT → RTPLAN → RTDOSE`), sharing one frame of reference, with the target
|
|
13
|
+
volumes contoured. On a real clinical PACS this is rarely true — exports are duplicated,
|
|
14
|
+
references dangle, frames of reference drift. This tool finds those problems **at the
|
|
15
|
+
cohort level** and explains each one, so they are caught *before* they silently corrupt a
|
|
16
|
+
study (e.g. a patient analysed against the wrong structure set).
|
|
17
|
+
|
|
18
|
+
This is not a DICOM viewer and not a PACS. It is the unglamorous data-curation step that
|
|
19
|
+
precedes modelling — deliberately, because that step is where clinical cohorts break.
|
|
20
|
+
|
|
21
|
+
## Architecture — why it adapts to any server
|
|
22
|
+
|
|
23
|
+
A single **indexer** walks any file tree and reads DICOM *headers* into one canonical
|
|
24
|
+
table (`patient · study · series · modality · FrameOfReferenceUID · referenced UIDs ·
|
|
25
|
+
path`). Every analysis (RT integrity, completeness) consumes **only that table** and never
|
|
26
|
+
touches the filesystem. So the file path stops being *data* and becomes an opaque pointer:
|
|
27
|
+
patient/study/timepoint identity comes from DICOM tags, not folder positions, and the tool
|
|
28
|
+
works the same on a tidy export, a flat dump, or a different hospital's layout. DICOM files
|
|
29
|
+
are detected by **content** (the `DICM` preamble / a parseable SOPClassUID), not by a
|
|
30
|
+
`.dcm` extension — real exports are often extensionless.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Runs on **Python 3.9–3.14**, tested in CI on Linux, macOS and Windows. One command, straight from GitHub —
|
|
37
|
+
no clone needed (the package lives in the `DICOM_discovery/` subdirectory of the repo):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# From PyPI — available once the first release is published (see docs/RELEASING.md):
|
|
41
|
+
pip install dicom-discovery # or: pipx install dicom-discovery (isolated CLI)
|
|
42
|
+
|
|
43
|
+
# Until then (or to track master), straight from GitHub — no clone needed:
|
|
44
|
+
pip install "git+https://github.com/vivirtuose/DICOM_discovery.git#subdirectory=DICOM_discovery"
|
|
45
|
+
|
|
46
|
+
dicom-discovery --help
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
No Python on the target machine? Every release also ships a **standalone binary** for
|
|
50
|
+
Windows, macOS and Linux — unzip, double-click, pick the two folders
|
|
51
|
+
([docs/STANDALONE.md](https://github.com/vivirtuose/DICOM_discovery/blob/master/DICOM_discovery/docs/STANDALONE.md)).
|
|
52
|
+
No internet either? Every release ships `docker load`-able images and offline
|
|
53
|
+
pip wheelhouses for Linux, Windows and macOS — see the
|
|
54
|
+
[NAS deployment guide](https://github.com/vivirtuose/DICOM_discovery/blob/master/DICOM_discovery/docs/NAS_DEPLOYMENT.md).
|
|
55
|
+
|
|
56
|
+
The interactive Plotly report is a **core dependency**, so `dicom-discovery report` works out
|
|
57
|
+
of the box — there is no separate visualisation step to install. For development, clone the
|
|
58
|
+
repo and use an editable install with the test extras (`pip install -e ".[dev]"`).
|
|
59
|
+
|
|
60
|
+
## Run it on a hospital NAS (unattended)
|
|
61
|
+
|
|
62
|
+
`dicom-discovery job` is the scheduled, unattended run: it scans the share **read-only**,
|
|
63
|
+
writes a timestamped run folder, keeps `latest/` pointing at the last usable report, records
|
|
64
|
+
a PHI-free `last_run.json` for monitoring, refuses to overlap a running scan, prunes old runs
|
|
65
|
+
and re-uses an index cache so nightly re-scans only read new files. NAS recycle bins,
|
|
66
|
+
snapshots and thumbnail folders are skipped; directories it cannot list are reported
|
|
67
|
+
(`PARTIAL`, exit code 3), never silently ignored.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
dicom-discovery job --root /mnt/dicom --output-dir /srv/qc --dry-run # preflight, writes nothing
|
|
71
|
+
dicom-discovery job --root /mnt/dicom --output-dir /srv/qc # exit 0/1/2/3/75
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Two air-gapped deployment paths, both built and tested in CI (workflow **NAS bundle**) and
|
|
75
|
+
downloadable as artifacts:
|
|
76
|
+
|
|
77
|
+
- **Container on the NAS** (Synology Container Manager, QNAP, TrueNAS): a hardened image
|
|
78
|
+
(`amd64` + `arm64`) run via [`deploy/nas/docker-compose.yml`](https://github.com/vivirtuose/DICOM_discovery/blob/master/DICOM_discovery/deploy/nas/docker-compose.yml)
|
|
79
|
+
with no network, the DICOM share mounted read-only, a read-only root filesystem and a
|
|
80
|
+
non-root user.
|
|
81
|
+
- **Linux server mounting the share**: an offline pip wheelhouse per Python version +
|
|
82
|
+
[`install-offline.sh`](https://github.com/vivirtuose/DICOM_discovery/blob/master/DICOM_discovery/deploy/nas/install-offline.sh) and systemd timer units.
|
|
83
|
+
|
|
84
|
+
Step-by-step guide (French, for the hospital team): [`docs/NAS_DEPLOYMENT.md`](https://github.com/vivirtuose/DICOM_discovery/blob/master/DICOM_discovery/docs/NAS_DEPLOYMENT.md).
|
|
85
|
+
|
|
86
|
+
## On an unfamiliar machine
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
dicom-discovery --version
|
|
90
|
+
dicom-discovery doctor --root /mnt/dicom --output-dir /srv/qc
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`doctor` prints the tool, Python, platform and dependency versions, the stdout / filesystem /
|
|
94
|
+
locale encodings and — on Windows — whether long paths (>260 characters) are enabled, then
|
|
95
|
+
probes the share for readability and the output folder for writability. It exits 1 when a
|
|
96
|
+
check fails, so a scheduler can run it as a gate before a 40-minute scan that would write
|
|
97
|
+
nothing. CSV exports carry a UTF-8 BOM so accented verdict actions open correctly in Excel;
|
|
98
|
+
`verdicts.json` stays BOM-free for strict parsers.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Quickstart
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
pip install -e ".[dev]"
|
|
106
|
+
|
|
107
|
+
# One command: generate synthetic cohorts, run both engines, write the completeness map.
|
|
108
|
+
dicom-discovery demo # outputs land in ./examples/
|
|
109
|
+
xdg-open examples/completeness_map.html
|
|
110
|
+
|
|
111
|
+
# Point the tools at any DICOM tree (recurses; reads headers only):
|
|
112
|
+
dicom-discovery index --root /data/COHORT --dry-run # preflight: what's there?
|
|
113
|
+
dicom-discovery rt-check --root /data/COHORT --out-csv rt.csv # + rt_by_patient.csv
|
|
114
|
+
dicom-discovery completeness --root /data/COHORT --out map.html
|
|
115
|
+
|
|
116
|
+
# Unified RT-integrity + completeness report in one self-contained HTML (RUO; opens air-gapped):
|
|
117
|
+
dicom-discovery report --root /data/COHORT --out cohort_report.html
|
|
118
|
+
dicom-discovery report --root /data/COHORT --dry-run # preflight only, no output
|
|
119
|
+
|
|
120
|
+
# Emit the verdict as a versioned, schema-validated artifact (the HTML is one projection of it):
|
|
121
|
+
dicom-discovery report --root /data/COHORT --out cohort_report.html --json verdicts.json
|
|
122
|
+
dicom-discovery rt-check --root /data/COHORT --json verdicts.json
|
|
123
|
+
|
|
124
|
+
pytest # run the test suite
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### The verdict contract (`--json`)
|
|
128
|
+
|
|
129
|
+
`--json` writes a `verdicts.json` validated against a versioned JSON Schema
|
|
130
|
+
(`schema_version`). It carries the tool version, a UTC timestamp, the protocol, a **run
|
|
131
|
+
manifest with a content-hash provenance fingerprint**, and one entry per patient
|
|
132
|
+
(`verdict`, `reason`, recommended `action`, chain/ROI flags, TG-263 `n_roi_nonstandard`).
|
|
133
|
+
This is what makes the output a *reusable, re-runnable, diffable* artifact rather than a
|
|
134
|
+
report to read by eye — a downstream pipeline consumes the JSON; the HTML is one projection
|
|
135
|
+
of the same payload. **RUO — not for diagnostic use.**
|
|
136
|
+
|
|
137
|
+
ROI names are checked against a bounded subset of **AAPM TG-263** nomenclature (Mayo et al.,
|
|
138
|
+
2018) as an additive signal; it never changes the OK/WARN/INCOMPLETE verdict. Roadmap:
|
|
139
|
+
provenance down to the SOPInstanceUID, DICOM-SR (PS3.16) / FHIR export, IHE-RO alignment.
|
|
140
|
+
|
|
141
|
+
`--group-by dicom` (default) keys patients by the `PatientID` tag; `--group-by folder`
|
|
142
|
+
keys by the top folder (use it when `PatientID` is a hospital MRN rather than the study id).
|
|
143
|
+
`--dry-run` runs a preflight that reports what was found and **refuses to emit a report on
|
|
144
|
+
an empty scan** — so the tool never produces a falsely-confident result on a tree it did
|
|
145
|
+
not understand. `dicom-discovery` ≡ `python -m DICOM_discovery`.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## What it catches — a real example
|
|
150
|
+
|
|
151
|
+
The QC grades every patient `OK` / `WARN` / `INCOMPLETE` and attaches *explainable*
|
|
152
|
+
findings, each with a **severity** (`ERROR`/`WARNING`/`INFO`) and a **confidence**
|
|
153
|
+
(`HIGH` for facts derived from referenced UIDs, `HEURISTIC` for guesses). The headline
|
|
154
|
+
case — the kind of bug that would otherwise pass unnoticed into a study:
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
P003 WARN [ERROR/HIGH] PLAN_STRUCT_LINK_BROKEN:
|
|
158
|
+
RTPLAN references a RTSTRUCT SOPInstanceUID absent from this patient
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
That patient *looks* complete (it has a CT, an RTSTRUCT, an RTPLAN and an RTDOSE), but the
|
|
162
|
+
plan was contoured against a structure set that is no longer in the folder. A naive file
|
|
163
|
+
count says "fine"; the dosimetry is not.
|
|
164
|
+
|
|
165
|
+
## Validation: case → expected → detected
|
|
166
|
+
|
|
167
|
+
The synthetic cohort carries its own ground truth, so the QC is validated end-to-end
|
|
168
|
+
(this table is produced by `pytest` and the demo above):
|
|
169
|
+
|
|
170
|
+
| Patient | Scenario | Expected | Detected |
|
|
171
|
+
|---------|----------|----------|----------|
|
|
172
|
+
| P001 | Complete chain, consistent FoR, all targets | `OK` | `OK` ✅ |
|
|
173
|
+
| P002 | Two RTSTRUCT re-exports, plan links the 2nd | `OK` | `OK` ✅ |
|
|
174
|
+
| P003 | RTPLAN references an **absent** RTSTRUCT | `WARN` (`PLAN_STRUCT_LINK_BROKEN`) | ✅ |
|
|
175
|
+
| P004 | Orphan RTDOSE → absent RTPLAN | `WARN` (`DOSE_PLAN_LINK_BROKEN`) | ✅ |
|
|
176
|
+
| P005 | RTDOSE FrameOfReference ≠ RTSTRUCT/RTPLAN | `WARN` (`FOR_INCONSISTENT_RT`) | ✅ |
|
|
177
|
+
| P006 | No RTDOSE | `INCOMPLETE` (`MISSING_RTDOSE`) | ✅ |
|
|
178
|
+
| P007 | Complete chain but no PTV | `WARN` (`MISSING_TARGET_ROI`) | ✅ |
|
|
179
|
+
|
|
180
|
+
## On a real cohort (98 patients, ~1M files)
|
|
181
|
+
|
|
182
|
+
Run against a real brain-RT trial export, the tool surfaced things a naive scan hides:
|
|
183
|
+
|
|
184
|
+
- The tree holds **1,028,034 files** — only ~47k carry a `.dcm` extension; **~970k are
|
|
185
|
+
extensionless DICOM** (`image (0128)` …). The earlier extension-filtered scan was blind
|
|
186
|
+
to ~95 % of the imaging; content-detection recovers it.
|
|
187
|
+
- The DICOM `PatientID` tag is the **hospital MRN**, not the study pseudonym `IC NNN`
|
|
188
|
+
(the folder). The `--dry-run` preflight flags this *before* any report, so you switch to
|
|
189
|
+
`--group-by folder` rather than silently keying patients by MRN.
|
|
190
|
+
- Patients have **7.5 studies on average** (longitudinal MR follow-up). Only **102 of 731
|
|
191
|
+
studies actually contain an RT object**; the other 629 are follow-up MR and are reported
|
|
192
|
+
as `NOT_RT` (out of scope) instead of being wrongly flagged "incomplete RT chain".
|
|
193
|
+
- **Verdicts of that run (June 2026):** **96 OK / 3 WARN / 3 INCOMPLETE** over the **102 RT
|
|
194
|
+
studies**; per patient, **97 of the 98 carry RT objects** and 1 is imaging-only (`NO_RT`).
|
|
195
|
+
The per-patient rollup — the unit a PI cares about, and what the report leads with — was
|
|
196
|
+
not recorded here; re-run `rt-check` on the cohort to quote it.
|
|
197
|
+
- One patient's RT chain is *fragmented* across several studies — reported as a single honest
|
|
198
|
+
verdict ("`INCOMPLETE: missing RTDOSE; FRAGMENTED across N studies`"), not as confusing
|
|
199
|
+
per-study noise, by reconciling the chain through resolved referenced UIDs.
|
|
200
|
+
|
|
201
|
+
## Longitudinal completeness — observed vs. expected
|
|
202
|
+
|
|
203
|
+
"Complete" means nothing without a protocol to compare against. Given a `Protocol`
|
|
204
|
+
(timepoints + the modalities required at each — editable as YAML, see
|
|
205
|
+
`protocol.brain_rt_followup.yaml`), every `(patient, timepoint, modality)` cell is graded
|
|
206
|
+
and rendered as a self-contained heatmap whose colours make the one distinction a file
|
|
207
|
+
count cannot:
|
|
208
|
+
|
|
209
|
+
| Colour | State | Meaning |
|
|
210
|
+
|--------|-------|---------|
|
|
211
|
+
| 🟩 green | `PRESENT` | expected and found |
|
|
212
|
+
| 🟥 red | `MISSING` | **expected but absent** — the actionable cell |
|
|
213
|
+
| 🟦 blue | `EXTRA` | found but not in the protocol |
|
|
214
|
+
| ⬜ grey | `N/A` | not expected (an empty cell that is *fine*) |
|
|
215
|
+
|
|
216
|
+
An empty-but-expected cell (red) is visually distinct from a merely-irrelevant cell
|
|
217
|
+
(grey): that separation is the whole point. The map embeds Plotly inline (no CDN, opens on
|
|
218
|
+
an air-gapped clinical network) and is accompanied by a per-patient completeness table:
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
patient n_expected n_present n_missing pct_complete
|
|
222
|
+
L001 8 8 0 100.0
|
|
223
|
+
L002 8 7 1 87.5 # follow-up gap (no M6 MR)
|
|
224
|
+
L003 8 6 2 75.0 # no baseline RTDOSE; no M12 MR
|
|
225
|
+
L004 8 5 3 62.5 # baseline only, lost to follow-up
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## What changed vs the previous version (and why)
|
|
229
|
+
|
|
230
|
+
The earlier prototype inspected a *single* object per modality (`df.iloc[0]`), which
|
|
231
|
+
produced two documented false positives:
|
|
232
|
+
|
|
233
|
+
- **Multi-export RTSTRUCT** (P002): a patient with several structure-set re-exports was
|
|
234
|
+
wrongly flagged "RTPLAN does not reference the RTSTRUCT" whenever the plan pointed at a
|
|
235
|
+
different export. v2 resolves the link against the **set of all present**
|
|
236
|
+
SOPInstanceUIDs, so P002 is correctly `OK`. (`tests/test_rt_integrity.py::test_p002_no_multi_struct_false_positive`
|
|
237
|
+
is a regression test for exactly this.)
|
|
238
|
+
- **Planning-CT guess**: the CT/FoR check used `"RT" in filepath`, yielding spurious "FoR
|
|
239
|
+
RT ≠ FoR CT". v2 keeps this check but downgrades it to a `HEURISTIC`/`INFO` hint that
|
|
240
|
+
never, on its own, fails a patient — because *which* CT is the planning CT is genuinely
|
|
241
|
+
uncertain from headers alone.
|
|
242
|
+
|
|
243
|
+
The point: a QC that cries wolf is worse than no QC. Findings are now graded so a reader
|
|
244
|
+
can separate a hard chain break (`ERROR`/`HIGH`) from a low-confidence hint.
|
|
245
|
+
|
|
246
|
+
Later, running at full cohort scale surfaced two more: integrity is assessed **per study**
|
|
247
|
+
(so a re-plan with its own frame of reference is not a false inconsistency), studies with
|
|
248
|
+
no RT object are scoped out as `NOT_RT`, and a **per-patient rollup** reconciles the chain
|
|
249
|
+
across studies via resolved referenced UIDs — distinguishing a genuinely missing link from
|
|
250
|
+
a merely *fragmented* one, without the naive object-union that would produce a falsely
|
|
251
|
+
reassuring `OK`.
|
|
252
|
+
|
|
253
|
+
## Known limitations
|
|
254
|
+
|
|
255
|
+
This is a research data-curation tool (**Research Use Only**), not a medical device and
|
|
256
|
+
not a clinical safety check.
|
|
257
|
+
|
|
258
|
+
- The planning-CT ↔ RT frame-of-reference match is heuristic (`INFO` only).
|
|
259
|
+
- The per-patient rollup reconciles the chain through *resolved* referenced UIDs but does
|
|
260
|
+
not build a general connected-components graph; a deeply fragmented chain is flagged
|
|
261
|
+
`FRAGMENTED` for manual review rather than fully reconstructed. (Deliberate scope choice.)
|
|
262
|
+
- Scanning is header-only but the first scan reads every file once; on a ~1M-file tree over
|
|
263
|
+
NFS this takes a while. Later runs re-use the index cache (`--cache`, on by default in
|
|
264
|
+
`job`) and only read new or changed files.
|
|
265
|
+
- Validated on one centre's cohort and a synthetic set; other vendors' exports (private
|
|
266
|
+
tags, transfer syntaxes) are not yet characterised.
|
|
267
|
+
- ROI target detection is substring-based (`GTV`/`CTV`/`PTV`); the TG-263 check is a light,
|
|
268
|
+
advisory nomenclature flag.
|
|
269
|
+
|
|
270
|
+
## Data & privacy
|
|
271
|
+
|
|
272
|
+
No real patient data is in this repository. The example cohort is fully synthetic
|
|
273
|
+
(fabricated UIDs, `SYNTHETIC^PHANTOM` as patient name, header-only, no pixel data) and is
|
|
274
|
+
regenerated on demand. The `.gitignore` blocks `*.dcm`, `*.csv`, `*.html` and output
|
|
275
|
+
folders so that real clinical data and generated reports are never committed.
|
|
276
|
+
|
|
277
|
+
## Layout
|
|
278
|
+
|
|
279
|
+
```
|
|
280
|
+
src/DICOM_discovery/
|
|
281
|
+
indexer.py # walk any tree -> canonical DICOM table (content-detected, traced keys)
|
|
282
|
+
rt_integrity.py # RT chain integrity (per study) + per-patient rollup
|
|
283
|
+
completeness.py # observed-vs-expected model (Protocol, timepoint from StudyDate)
|
|
284
|
+
report_map.py # self-contained completeness heatmap (Plotly embedded, no CDN)
|
|
285
|
+
report_cohort.py # unified RT-integrity + completeness cohort report (self-contained HTML)
|
|
286
|
+
cli.py / __main__.py # `dicom-discovery` commands: demo / index / rt-check / completeness / report / job / doctor
|
|
287
|
+
job.py # unattended scheduled run (run folders, latest/, status, lock, retention)
|
|
288
|
+
doctor.py # environment report + share/output probes (`doctor`)
|
|
289
|
+
gui.py # double-click launcher for the standalone binary (folder pickers)
|
|
290
|
+
fsutil.py # atomic writes (outputs and cache on network shares)
|
|
291
|
+
synthetic.py # synthetic DICOM-RT + longitudinal cohorts (+ ground truth)
|
|
292
|
+
protocol.brain_rt_followup.yaml # example expected-content protocol
|
|
293
|
+
Dockerfile # hardened NAS image (default command = `job`)
|
|
294
|
+
deploy/nas/ # docker-compose + .env, offline installer, systemd units
|
|
295
|
+
docs/NAS_DEPLOYMENT.md # NAS deployment guide (French)
|
|
296
|
+
deploy/standalone/ # PyInstaller spec + entry point for the frozen binary
|
|
297
|
+
docs/RELEASING.md # how a version is tagged, drafted and published
|
|
298
|
+
docs/STANDALONE.md # the no-Python, double-click path (French)
|
|
299
|
+
tests/ # pytest suite (209 tests), synthetic + real public data
|
|
300
|
+
```
|