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.
Files changed (43) hide show
  1. dicom_discovery-0.11.0/LICENSE +26 -0
  2. dicom_discovery-0.11.0/PKG-INFO +338 -0
  3. dicom_discovery-0.11.0/README.md +300 -0
  4. dicom_discovery-0.11.0/pyproject.toml +81 -0
  5. dicom_discovery-0.11.0/setup.cfg +4 -0
  6. dicom_discovery-0.11.0/src/DICOM_discovery/__init__.py +41 -0
  7. dicom_discovery-0.11.0/src/DICOM_discovery/__main__.py +5 -0
  8. dicom_discovery-0.11.0/src/DICOM_discovery/cli.py +339 -0
  9. dicom_discovery-0.11.0/src/DICOM_discovery/completeness.py +202 -0
  10. dicom_discovery-0.11.0/src/DICOM_discovery/contract.py +115 -0
  11. dicom_discovery-0.11.0/src/DICOM_discovery/doctor.py +148 -0
  12. dicom_discovery-0.11.0/src/DICOM_discovery/fsutil.py +73 -0
  13. dicom_discovery-0.11.0/src/DICOM_discovery/gui.py +116 -0
  14. dicom_discovery-0.11.0/src/DICOM_discovery/indexer.py +526 -0
  15. dicom_discovery-0.11.0/src/DICOM_discovery/job.py +279 -0
  16. dicom_discovery-0.11.0/src/DICOM_discovery/report_cohort.py +1021 -0
  17. dicom_discovery-0.11.0/src/DICOM_discovery/report_map.py +142 -0
  18. dicom_discovery-0.11.0/src/DICOM_discovery/rt_integrity.py +424 -0
  19. dicom_discovery-0.11.0/src/DICOM_discovery/synthetic.py +290 -0
  20. dicom_discovery-0.11.0/src/DICOM_discovery/tg263.py +43 -0
  21. dicom_discovery-0.11.0/src/DICOM_discovery.egg-info/PKG-INFO +338 -0
  22. dicom_discovery-0.11.0/src/DICOM_discovery.egg-info/SOURCES.txt +41 -0
  23. dicom_discovery-0.11.0/src/DICOM_discovery.egg-info/dependency_links.txt +1 -0
  24. dicom_discovery-0.11.0/src/DICOM_discovery.egg-info/entry_points.txt +2 -0
  25. dicom_discovery-0.11.0/src/DICOM_discovery.egg-info/requires.txt +9 -0
  26. dicom_discovery-0.11.0/src/DICOM_discovery.egg-info/top_level.txt +1 -0
  27. dicom_discovery-0.11.0/tests/test_cli_encoding.py +56 -0
  28. dicom_discovery-0.11.0/tests/test_cli_report.py +132 -0
  29. dicom_discovery-0.11.0/tests/test_completeness.py +93 -0
  30. dicom_discovery-0.11.0/tests/test_contract.py +112 -0
  31. dicom_discovery-0.11.0/tests/test_deploy_config.py +81 -0
  32. dicom_discovery-0.11.0/tests/test_gui_launcher.py +98 -0
  33. dicom_discovery-0.11.0/tests/test_indexer.py +215 -0
  34. dicom_discovery-0.11.0/tests/test_job.py +230 -0
  35. dicom_discovery-0.11.0/tests/test_mr_planning.py +78 -0
  36. dicom_discovery-0.11.0/tests/test_nas_indexer.py +203 -0
  37. dicom_discovery-0.11.0/tests/test_packaging.py +98 -0
  38. dicom_discovery-0.11.0/tests/test_portability.py +123 -0
  39. dicom_discovery-0.11.0/tests/test_real_data.py +63 -0
  40. dicom_discovery-0.11.0/tests/test_report_cohort.py +531 -0
  41. dicom_discovery-0.11.0/tests/test_rollup_review.py +66 -0
  42. dicom_discovery-0.11.0/tests/test_rt_integrity.py +198 -0
  43. 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
+ [![CI](https://github.com/vivirtuose/DICOM_discovery/actions/workflows/ci.yml/badge.svg)](https://github.com/vivirtuose/DICOM_discovery/actions/workflows/ci.yml)
42
+ ![Python](https://img.shields.io/badge/python-3.9%E2%80%933.14-blue)
43
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green)
44
+ ![Use: research-only](https://img.shields.io/badge/use-research--only-orange)
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
+ [![CI](https://github.com/vivirtuose/DICOM_discovery/actions/workflows/ci.yml/badge.svg)](https://github.com/vivirtuose/DICOM_discovery/actions/workflows/ci.yml)
4
+ ![Python](https://img.shields.io/badge/python-3.9%E2%80%933.14-blue)
5
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green)
6
+ ![Use: research-only](https://img.shields.io/badge/use-research--only-orange)
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
+ ```