tk-rt-viewer 2.1.2__tar.gz → 2.2.1__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.
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/CHANGELOG.md +36 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/PKG-INFO +26 -5
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/README.md +25 -4
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/__init__.py +4 -3
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/io.py +296 -24
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/tests/test_io.py +251 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/.gitattributes +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/.github/workflows/ci.yml +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/.gitignore +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/LICENSE +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/pyproject.toml +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/event_controllers/__init__.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/event_controllers/bbox3d_handler.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/event_controllers/bbox_handler.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/event_controllers/brush_handler.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/event_controllers/crosshair_handler.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/event_controllers/rect_drag.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/event_controllers/viewer_events.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/events.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/geometry.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/isodose_levels.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/protocols.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/py.typed +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/reg_io.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/registration/__init__.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/registration/deformable.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/registration/errors.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/registration/params.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/registration/rigid.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/registration/session.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/registration/template.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/rendering/__init__.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/rendering/blit_compositor.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/rendering/contour_overlay.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/rendering/drawing_manager.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/rendering/dvh.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/rendering/image_layer.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/rendering/isodose.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/rendering/layout.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/rendering/render.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/roi_operations.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/rtstruct_io.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/state/__init__.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/state/dose_manager.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/state/phase_manager.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/state/roi_editor.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/state/roi_manager.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/state/secondary_manager.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/state/structure_set.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/state/viewer_cache.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/state/viewer_state.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/viewer.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/window_level.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/tests/test_bbox3d.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/tests/test_bbox3d_handler.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/tests/test_brush_handler.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/tests/test_event_controllers.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/tests/test_geometry.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/tests/test_isodose_levels.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/tests/test_performance_opt.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/tests/test_reg_io.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/tests/test_registration.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/tests/test_render.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/tests/test_rendering.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/tests/test_roi_operations.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/tests/test_rtstruct_io.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/tests/test_state_extensions.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/tests/test_state_managers.py +0 -0
- {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/tests/test_viewer_state.py +0 -0
|
@@ -4,6 +4,42 @@ All notable changes to this project are documented in this file.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [2.2.0]
|
|
8
|
+
|
|
9
|
+
Loading a series picked from a scan now reads only that series' files.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`load_scanned_series(entry, reg_files=())`** loads one series found by
|
|
14
|
+
`scan_dicom_series` from the files the scan recorded. `load_dcm_series`
|
|
15
|
+
walked the directory with pydicom and then twice more through GDCM before
|
|
16
|
+
reading, and refused any directory holding a second image series.
|
|
17
|
+
- **`load_phase_series(phases, reg_files=(), max_workers=None)`** loads the
|
|
18
|
+
phases of a 4DCT entry, several at a time. `load_all_series` +
|
|
19
|
+
`select_phase_series` read every image series under the root just to keep
|
|
20
|
+
the phases.
|
|
21
|
+
Both apply registrations from the REG files passed in (normally
|
|
22
|
+
`SeriesScan.reg_files`), matched by any slice's SOP Instance UID or by the
|
|
23
|
+
series, as `load_all_series` does.
|
|
24
|
+
- **`SeriesEntry.file_paths`** and **`PhaseEntry.file_paths`**: a series'
|
|
25
|
+
files in slice order (ImagePositionPatient along the slice normal, as GDCM
|
|
26
|
+
sorts). **`PhaseEntry.series_uid`** records the phase's SeriesInstanceUID.
|
|
27
|
+
All new fields have defaults, so existing constructor calls still work.
|
|
28
|
+
- **`scan_dicom_series(..., require_single_series_per_dir=False)`**: when
|
|
29
|
+
set, raises the new **`MixedSeriesDirectoryError`** (a `ValueError`) if a
|
|
30
|
+
directory holding a listed series also holds another image series.
|
|
31
|
+
Non-image objects and the phases of one 4DCT may share a directory. Off by
|
|
32
|
+
default; `MultiplePatientError` is still checked first.
|
|
33
|
+
- **`NON_IMAGE_MODALITIES`** (formerly the private `_NON_IMAGE_MODALITIES`):
|
|
34
|
+
the modalities never treated as an image series, so a host that sorts
|
|
35
|
+
files into per-series folders agrees with the scan on what an image
|
|
36
|
+
series is.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- `_build_series_info` takes the matched REG matrix instead of the tree scan
|
|
41
|
+
(internal).
|
|
42
|
+
|
|
7
43
|
## [2.1.2]
|
|
8
44
|
|
|
9
45
|
A review pass: behaviour fixes with no API break, a shared implementation for
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tk-rt-viewer
|
|
3
|
-
Version: 2.1
|
|
3
|
+
Version: 2.2.1
|
|
4
4
|
Summary: SimpleITK-based DICOM-RT MPR viewer widget for Tkinter (CT, RT-DOSE, RT-STRUCT, DVH)
|
|
5
5
|
Project-URL: Homepage, https://github.com/oki1002/DICOM_RT_Viewer
|
|
6
6
|
Project-URL: Repository, https://github.com/oki1002/DICOM_RT_Viewer
|
|
@@ -215,9 +215,29 @@ for entry in scan.series:
|
|
|
215
215
|
```
|
|
216
216
|
|
|
217
217
|
`scan_dicom_series` raises `MultiplePatientError` when the tree holds more
|
|
218
|
-
than one patient; pass `require_single_patient=False` to scan anyway.
|
|
219
|
-
|
|
220
|
-
|
|
218
|
+
than one patient; pass `require_single_patient=False` to scan anyway.
|
|
219
|
+
|
|
220
|
+
Each entry records its own files in slice order, so the series the user picks
|
|
221
|
+
is loaded without scanning its directory again, even when other series share
|
|
222
|
+
that directory. A 4DCT entry is loaded phase by phase, several at a time:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from tk_rt_viewer.io import load_phase_series, load_scanned_series
|
|
226
|
+
|
|
227
|
+
info = load_scanned_series(entry, scan.reg_files) # one series
|
|
228
|
+
phases = load_phase_series(four_d.phases, scan.reg_files) # {"0%": ..., ...}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Passing `scan.reg_files` attaches the transform of any registration naming
|
|
232
|
+
the series (see [Registration](#registration-fusion)). Load RT-DOSE entries with
|
|
233
|
+
`load_rt_dose(entry.file_path)`.
|
|
234
|
+
|
|
235
|
+
Code that addresses a series by its directory, such as RT-STRUCT import and
|
|
236
|
+
export (`rtstruct_io`, through rt-utils), needs one image series per
|
|
237
|
+
directory. Pass `require_single_series_per_dir=True` to have the scan raise
|
|
238
|
+
`MixedSeriesDirectoryError` (listing the directories) instead of letting
|
|
239
|
+
those slices mix. Non-image objects (RT-STRUCT, RT-PLAN, RT-DOSE, REG) may
|
|
240
|
+
share a directory with their series, and so may the phases of one 4DCT.
|
|
221
241
|
|
|
222
242
|
Load the primary image first. The secondary overlay, the 4DCT phases and
|
|
223
243
|
RT-DOSE are all displayed on the primary image's grid, so there is nothing
|
|
@@ -701,7 +721,8 @@ field and belongs to a different IOD, so `transform_to_matrix` raises
|
|
|
701
721
|
A registration written this way is read back by
|
|
702
722
|
`tk_rt_viewer.io.find_reg_matrices` (and therefore applied automatically by
|
|
703
723
|
`load_all_series` / `load_dcm_series`) when the file sits anywhere under the
|
|
704
|
-
scanned directory
|
|
724
|
+
scanned directory; `load_scanned_series` / `load_phase_series` apply it when
|
|
725
|
+
the file is among the `reg_files` passed in. Any referenced slice is enough: references are resolved
|
|
705
726
|
to their whole series. The reader also accepts the item-level
|
|
706
727
|
`ReferencedSeriesSequence` written by 2.1.x, and skips the identity item that
|
|
707
728
|
names the fixed image's own frame of reference, so the fixed series is not
|
|
@@ -178,9 +178,29 @@ for entry in scan.series:
|
|
|
178
178
|
```
|
|
179
179
|
|
|
180
180
|
`scan_dicom_series` raises `MultiplePatientError` when the tree holds more
|
|
181
|
-
than one patient; pass `require_single_patient=False` to scan anyway.
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
than one patient; pass `require_single_patient=False` to scan anyway.
|
|
182
|
+
|
|
183
|
+
Each entry records its own files in slice order, so the series the user picks
|
|
184
|
+
is loaded without scanning its directory again, even when other series share
|
|
185
|
+
that directory. A 4DCT entry is loaded phase by phase, several at a time:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from tk_rt_viewer.io import load_phase_series, load_scanned_series
|
|
189
|
+
|
|
190
|
+
info = load_scanned_series(entry, scan.reg_files) # one series
|
|
191
|
+
phases = load_phase_series(four_d.phases, scan.reg_files) # {"0%": ..., ...}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Passing `scan.reg_files` attaches the transform of any registration naming
|
|
195
|
+
the series (see [Registration](#registration-fusion)). Load RT-DOSE entries with
|
|
196
|
+
`load_rt_dose(entry.file_path)`.
|
|
197
|
+
|
|
198
|
+
Code that addresses a series by its directory, such as RT-STRUCT import and
|
|
199
|
+
export (`rtstruct_io`, through rt-utils), needs one image series per
|
|
200
|
+
directory. Pass `require_single_series_per_dir=True` to have the scan raise
|
|
201
|
+
`MixedSeriesDirectoryError` (listing the directories) instead of letting
|
|
202
|
+
those slices mix. Non-image objects (RT-STRUCT, RT-PLAN, RT-DOSE, REG) may
|
|
203
|
+
share a directory with their series, and so may the phases of one 4DCT.
|
|
184
204
|
|
|
185
205
|
Load the primary image first. The secondary overlay, the 4DCT phases and
|
|
186
206
|
RT-DOSE are all displayed on the primary image's grid, so there is nothing
|
|
@@ -664,7 +684,8 @@ field and belongs to a different IOD, so `transform_to_matrix` raises
|
|
|
664
684
|
A registration written this way is read back by
|
|
665
685
|
`tk_rt_viewer.io.find_reg_matrices` (and therefore applied automatically by
|
|
666
686
|
`load_all_series` / `load_dcm_series`) when the file sits anywhere under the
|
|
667
|
-
scanned directory
|
|
687
|
+
scanned directory; `load_scanned_series` / `load_phase_series` apply it when
|
|
688
|
+
the file is among the `reg_files` passed in. Any referenced slice is enough: references are resolved
|
|
668
689
|
to their whole series. The reader also accepts the item-level
|
|
669
690
|
`ReferencedSeriesSequence` written by 2.1.x, and skips the identity item that
|
|
670
691
|
names the fixed image's own frame of reference, so the fixed series is not
|
|
@@ -38,8 +38,9 @@ Submodule API (import from the submodule)
|
|
|
38
38
|
-----------------------------------------
|
|
39
39
|
``tk_rt_viewer.io``
|
|
40
40
|
validate_dicom_files, find_reg_matrices, scan_dicom_series,
|
|
41
|
-
|
|
42
|
-
normalize_phase_label
|
|
41
|
+
load_scanned_series, load_phase_series, select_phase_series,
|
|
42
|
+
load_all_series, load_dcm_series, normalize_phase_label,
|
|
43
|
+
MultiplePatientError, MixedSeriesDirectoryError, NON_IMAGE_MODALITIES
|
|
43
44
|
|
|
44
45
|
``tk_rt_viewer.rtstruct_io``
|
|
45
46
|
load_rt_struct, mask2rtstruct, save_structure_set,
|
|
@@ -103,7 +104,7 @@ __all__ = [
|
|
|
103
104
|
"StructureSet",
|
|
104
105
|
"to_gy_pairs",
|
|
105
106
|
]
|
|
106
|
-
__version__ = "2.1
|
|
107
|
+
__version__ = "2.2.1"
|
|
107
108
|
|
|
108
109
|
|
|
109
110
|
def __getattr__(name: str) -> Any:
|
|
@@ -13,6 +13,16 @@ scan_dicom_series(dcm_root_dir) -> SeriesScan
|
|
|
13
13
|
Enumerate the image series in a directory tree without reading pixel
|
|
14
14
|
data, for a series picker.
|
|
15
15
|
|
|
16
|
+
load_scanned_series(entry, reg_files=()) -> SeriesInfo
|
|
17
|
+
Load one series found by scan_dicom_series, reading only its own files.
|
|
18
|
+
|
|
19
|
+
load_phase_series(phases, reg_files=(), max_workers=None)
|
|
20
|
+
-> dict[str, SeriesInfo]
|
|
21
|
+
Load the phases of a scanned 4DCT, reading only their own files.
|
|
22
|
+
|
|
23
|
+
NON_IMAGE_MODALITIES
|
|
24
|
+
Modalities never treated as an image series (RT objects, REG, SR, ...).
|
|
25
|
+
|
|
16
26
|
select_phase_series(all_series, phases) -> dict[str, SeriesInfo]
|
|
17
27
|
Pick the 4DCT phases named by a scan result out of a load_all_series map.
|
|
18
28
|
|
|
@@ -42,8 +52,9 @@ import logging
|
|
|
42
52
|
import math
|
|
43
53
|
import pathlib
|
|
44
54
|
import re
|
|
45
|
-
from collections.abc import Iterator, Sequence
|
|
46
|
-
from
|
|
55
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
56
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
57
|
+
from dataclasses import dataclass, field, replace
|
|
47
58
|
from typing import TypedDict
|
|
48
59
|
|
|
49
60
|
import numpy as np
|
|
@@ -62,8 +73,9 @@ _PHASE_LABEL_PATTERN = re.compile(r"\d+%")
|
|
|
62
73
|
#: Modalities never loaded as a displayable image series. Without this,
|
|
63
74
|
#: GDCM enumerates RT objects alongside the images and they are read through
|
|
64
75
|
#: the CT path (RT-DOSE without its DoseGridScaling). RT-DOSE has its own
|
|
65
|
-
#: loader, :func:`load_rt_dose`.
|
|
66
|
-
|
|
76
|
+
#: loader, :func:`load_rt_dose`. Public so hosts that reorganise files agree
|
|
77
|
+
#: with :func:`scan_dicom_series` on what counts as an image series.
|
|
78
|
+
NON_IMAGE_MODALITIES: frozenset[str] = frozenset(
|
|
67
79
|
{"RTSTRUCT", "RTPLAN", "RTRECORD", "RTDOSE", "REG", "SR", "PR", "KO", "SEG"}
|
|
68
80
|
)
|
|
69
81
|
|
|
@@ -81,10 +93,19 @@ _NON_CT_WINDOW_PERCENTILES: tuple[float, float] = (0.5, 99.5)
|
|
|
81
93
|
#: Width used for an image with a flat intensity range (no meaningful window).
|
|
82
94
|
_FLAT_IMAGE_WINDOW_WIDTH: float = 1.0
|
|
83
95
|
|
|
96
|
+
#: Upper bound on the worker threads :func:`load_phase_series` uses by default.
|
|
97
|
+
#: Phases are read concurrently because reading is I/O bound and SimpleITK
|
|
98
|
+
#: releases the GIL; more workers than this mostly contend for the disk.
|
|
99
|
+
_DEFAULT_PHASE_LOAD_WORKERS: int = 4
|
|
100
|
+
|
|
84
101
|
#: Modalities :func:`scan_dicom_series` lists by default.
|
|
85
102
|
DEFAULT_SCAN_MODALITIES: frozenset[str] = frozenset({"CT", "MR", "PT", "RTDOSE"})
|
|
86
103
|
|
|
87
104
|
|
|
105
|
+
#: ``(by_sop_instance_uid, by_series_instance_uid)`` registration matrices.
|
|
106
|
+
type _RegMatrices = tuple[dict[str, np.ndarray], dict[str, np.ndarray]]
|
|
107
|
+
|
|
108
|
+
|
|
88
109
|
class SeriesInfo(TypedDict):
|
|
89
110
|
"""Dict shape returned by :func:`load_all_series` and :func:`load_dcm_series`."""
|
|
90
111
|
|
|
@@ -327,6 +348,24 @@ class MultiplePatientError(ValueError):
|
|
|
327
348
|
"""
|
|
328
349
|
|
|
329
350
|
|
|
351
|
+
class MixedSeriesDirectoryError(ValueError):
|
|
352
|
+
"""A directory being scanned holds more than one image series.
|
|
353
|
+
|
|
354
|
+
Raised by :func:`scan_dicom_series` when *require_single_series_per_dir*
|
|
355
|
+
is set. Code that addresses a series by its directory (RT-STRUCT export
|
|
356
|
+
and import through rt-utils, for one) would silently mix the slices of
|
|
357
|
+
every image series in that directory.
|
|
358
|
+
|
|
359
|
+
Attributes:
|
|
360
|
+
directories: The offending directories, sorted.
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
def __init__(self, directories: Sequence[pathlib.Path]) -> None:
|
|
364
|
+
self.directories: tuple[pathlib.Path, ...] = tuple(sorted(directories))
|
|
365
|
+
listing = ", ".join(f"'{d}'" for d in self.directories)
|
|
366
|
+
super().__init__(f"Found more than one image series in: {listing}.")
|
|
367
|
+
|
|
368
|
+
|
|
330
369
|
@dataclass(frozen=True)
|
|
331
370
|
class PhaseEntry:
|
|
332
371
|
"""One respiratory phase of a 4DCT series.
|
|
@@ -335,11 +374,16 @@ class PhaseEntry:
|
|
|
335
374
|
label: Normalised phase label, e.g. ``"10%"``.
|
|
336
375
|
description: The phase's own SeriesDescription.
|
|
337
376
|
series_dir: Directory holding the phase's files.
|
|
377
|
+
series_uid: The phase's SeriesInstanceUID.
|
|
378
|
+
file_paths: The phase's files in slice order (see
|
|
379
|
+
:attr:`SeriesEntry.file_paths`).
|
|
338
380
|
"""
|
|
339
381
|
|
|
340
382
|
label: str
|
|
341
383
|
description: str
|
|
342
384
|
series_dir: pathlib.Path
|
|
385
|
+
series_uid: str = ""
|
|
386
|
+
file_paths: tuple[pathlib.Path, ...] = ()
|
|
343
387
|
|
|
344
388
|
|
|
345
389
|
@dataclass(frozen=True)
|
|
@@ -355,6 +399,9 @@ class SeriesEntry:
|
|
|
355
399
|
directly, since a directory may hold several dose objects.
|
|
356
400
|
phases: The phases of a 4DCT series, lowest percentage first;
|
|
357
401
|
empty for an ordinary series.
|
|
402
|
+
file_paths: Every file of the series, in slice order, so
|
|
403
|
+
:func:`load_scanned_series` reads them without scanning the
|
|
404
|
+
directory again. Empty for a grouped 4DCT entry (see *phases*).
|
|
358
405
|
"""
|
|
359
406
|
|
|
360
407
|
modality: str
|
|
@@ -363,6 +410,7 @@ class SeriesEntry:
|
|
|
363
410
|
series_uid: str
|
|
364
411
|
file_path: pathlib.Path
|
|
365
412
|
phases: tuple[PhaseEntry, ...] = ()
|
|
413
|
+
file_paths: tuple[pathlib.Path, ...] = ()
|
|
366
414
|
|
|
367
415
|
@property
|
|
368
416
|
def is_4dct(self) -> bool:
|
|
@@ -392,13 +440,16 @@ def scan_dicom_series(
|
|
|
392
440
|
modalities: frozenset[str] = DEFAULT_SCAN_MODALITIES,
|
|
393
441
|
group_4dct: bool = True,
|
|
394
442
|
require_single_patient: bool = True,
|
|
443
|
+
require_single_series_per_dir: bool = False,
|
|
395
444
|
) -> SeriesScan:
|
|
396
445
|
"""List the series under *dcm_root_dir* without reading any pixel data.
|
|
397
446
|
|
|
398
447
|
Headers only, so a large folder is enumerated quickly and the host loads
|
|
399
|
-
just what the user selects (:func:`
|
|
400
|
-
:func:`load_rt_dose`).
|
|
401
|
-
|
|
448
|
+
just what the user selects (:func:`load_scanned_series`,
|
|
449
|
+
:func:`load_phase_series`, :func:`load_rt_dose`). Each entry records its
|
|
450
|
+
files in slice order, so loading it never scans the directory again.
|
|
451
|
+
Series come back images first, then RT-DOSE, each group ordered by
|
|
452
|
+
modality and description.
|
|
402
453
|
|
|
403
454
|
Args:
|
|
404
455
|
dcm_root_dir: Root directory to scan, recursively.
|
|
@@ -408,13 +459,23 @@ def scan_dicom_series(
|
|
|
408
459
|
(``"0%"``, ``"10%"``, ...) into one entry holding them as phases.
|
|
409
460
|
require_single_patient: Raise when the tree holds more than one
|
|
410
461
|
patient.
|
|
462
|
+
require_single_series_per_dir: Raise when a directory holding a
|
|
463
|
+
listed series also holds another image series. Non-image objects
|
|
464
|
+
(RT-STRUCT, RT-PLAN, RT-DOSE, REG, ...) may share the directory,
|
|
465
|
+
and so may the phases of one grouped 4DCT. Set this when the
|
|
466
|
+
host addresses a series by its directory, e.g. for RT-STRUCT
|
|
467
|
+
import and export.
|
|
411
468
|
|
|
412
469
|
Raises:
|
|
413
470
|
MultiplePatientError: If *require_single_patient* and more than one
|
|
414
|
-
PatientID or PatientName was found.
|
|
471
|
+
PatientID or PatientName was found. Checked first.
|
|
472
|
+
MixedSeriesDirectoryError: If *require_single_series_per_dir* and a
|
|
473
|
+
directory mixes image series.
|
|
415
474
|
"""
|
|
416
475
|
root = pathlib.Path(dcm_root_dir)
|
|
417
476
|
series_by_uid: dict[str, SeriesEntry] = {}
|
|
477
|
+
files_by_uid: dict[str, list[tuple[tuple[float, float], pathlib.Path]]] = {}
|
|
478
|
+
image_series_by_dir: dict[pathlib.Path, set[str]] = {}
|
|
418
479
|
reg_files: list[pathlib.Path] = []
|
|
419
480
|
patient_ids: set[str] = set()
|
|
420
481
|
patient_names: set[str] = set()
|
|
@@ -431,19 +492,24 @@ def scan_dicom_series(
|
|
|
431
492
|
if modality == "REG":
|
|
432
493
|
reg_files.append(file)
|
|
433
494
|
continue
|
|
434
|
-
if modality not in modalities:
|
|
435
|
-
continue
|
|
436
495
|
|
|
437
496
|
series_uid = str(ds.get("SeriesInstanceUID", ""))
|
|
438
|
-
if not series_uid
|
|
497
|
+
if not series_uid:
|
|
498
|
+
continue
|
|
499
|
+
if modality not in NON_IMAGE_MODALITIES:
|
|
500
|
+
image_series_by_dir.setdefault(file.parent, set()).add(series_uid)
|
|
501
|
+
if modality not in modalities:
|
|
439
502
|
continue
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
503
|
+
|
|
504
|
+
files_by_uid.setdefault(series_uid, []).append((_slice_sort_key(ds), file))
|
|
505
|
+
if series_uid not in series_by_uid:
|
|
506
|
+
series_by_uid[series_uid] = SeriesEntry(
|
|
507
|
+
modality=modality,
|
|
508
|
+
description=str(ds.get("SeriesDescription", "")).strip(),
|
|
509
|
+
series_dir=file.parent,
|
|
510
|
+
series_uid=series_uid,
|
|
511
|
+
file_path=file,
|
|
512
|
+
)
|
|
447
513
|
|
|
448
514
|
if require_single_patient and (len(patient_ids) > 1 or len(patient_names) > 1):
|
|
449
515
|
raise MultiplePatientError(
|
|
@@ -451,7 +517,19 @@ def scan_dicom_series(
|
|
|
451
517
|
f"names={sorted(patient_names)}."
|
|
452
518
|
)
|
|
453
519
|
|
|
454
|
-
|
|
520
|
+
entries = [
|
|
521
|
+
replace(entry, file_paths=_sorted_files(files_by_uid[uid]))
|
|
522
|
+
for uid, entry in series_by_uid.items()
|
|
523
|
+
]
|
|
524
|
+
series = _order_series(entries, group_4dct)
|
|
525
|
+
|
|
526
|
+
if require_single_series_per_dir:
|
|
527
|
+
mixed_dirs = _find_mixed_series_dirs(
|
|
528
|
+
image_series_by_dir, frozenset(series_by_uid), series
|
|
529
|
+
)
|
|
530
|
+
if mixed_dirs:
|
|
531
|
+
raise MixedSeriesDirectoryError(mixed_dirs)
|
|
532
|
+
|
|
455
533
|
logger.info(
|
|
456
534
|
f"Scanned '{root}': {len(series)} series "
|
|
457
535
|
f"({sum(len(entry.phases) for entry in series)} 4DCT phases, "
|
|
@@ -465,6 +543,58 @@ def scan_dicom_series(
|
|
|
465
543
|
)
|
|
466
544
|
|
|
467
545
|
|
|
546
|
+
def _slice_sort_key(ds: pydicom.Dataset) -> tuple[float, float]:
|
|
547
|
+
"""Return ``(position along the slice normal, InstanceNumber)`` for a slice.
|
|
548
|
+
|
|
549
|
+
The position is the ImagePositionPatient projected onto the normal of
|
|
550
|
+
ImageOrientationPatient, the order GDCM itself sorts a series into. A
|
|
551
|
+
slice without usable geometry falls back to its InstanceNumber.
|
|
552
|
+
"""
|
|
553
|
+
try:
|
|
554
|
+
instance = float(ds.get("InstanceNumber"))
|
|
555
|
+
except (TypeError, ValueError):
|
|
556
|
+
instance = math.inf
|
|
557
|
+
|
|
558
|
+
position = ds.get("ImagePositionPatient")
|
|
559
|
+
orientation = ds.get("ImageOrientationPatient")
|
|
560
|
+
if position is None or orientation is None:
|
|
561
|
+
return instance, instance
|
|
562
|
+
try:
|
|
563
|
+
cosines = np.asarray(orientation, dtype=float)
|
|
564
|
+
normal = np.cross(cosines[:3], cosines[3:6])
|
|
565
|
+
distance = float(np.dot(normal, np.asarray(position, dtype=float)))
|
|
566
|
+
except (TypeError, ValueError):
|
|
567
|
+
return instance, instance
|
|
568
|
+
return distance, instance
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _sorted_files(
|
|
572
|
+
keyed_files: Iterable[tuple[tuple[float, float], pathlib.Path]],
|
|
573
|
+
) -> tuple[pathlib.Path, ...]:
|
|
574
|
+
"""Order a series' files by their slice keys, the path breaking ties."""
|
|
575
|
+
return tuple(
|
|
576
|
+
file for _, file in sorted(keyed_files, key=lambda kf: (kf[0], str(kf[1])))
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _find_mixed_series_dirs(
|
|
581
|
+
image_series_by_dir: dict[pathlib.Path, set[str]],
|
|
582
|
+
listed_uids: frozenset[str],
|
|
583
|
+
series: Sequence[SeriesEntry],
|
|
584
|
+
) -> list[pathlib.Path]:
|
|
585
|
+
"""Return the directories where a listed series shares with another image series.
|
|
586
|
+
|
|
587
|
+
The phases of a grouped 4DCT count as one acquisition, so a directory
|
|
588
|
+
holding only those phases is not mixed.
|
|
589
|
+
"""
|
|
590
|
+
phase_uids = {phase.series_uid for entry in series for phase in entry.phases}
|
|
591
|
+
return [
|
|
592
|
+
directory
|
|
593
|
+
for directory, uids in image_series_by_dir.items()
|
|
594
|
+
if len(uids) > 1 and uids & listed_uids and not uids <= phase_uids
|
|
595
|
+
]
|
|
596
|
+
|
|
597
|
+
|
|
468
598
|
def _order_series(entries: list[SeriesEntry], group_4dct: bool) -> list[SeriesEntry]:
|
|
469
599
|
"""Group 4DCT phases and sort the result into display order."""
|
|
470
600
|
phases: list[SeriesEntry] = []
|
|
@@ -495,6 +625,8 @@ def _order_series(entries: list[SeriesEntry], group_4dct: bool) -> list[SeriesEn
|
|
|
495
625
|
label=str(normalize_phase_label(entry.description)),
|
|
496
626
|
description=entry.description,
|
|
497
627
|
series_dir=entry.series_dir,
|
|
628
|
+
series_uid=entry.series_uid,
|
|
629
|
+
file_paths=entry.file_paths,
|
|
498
630
|
)
|
|
499
631
|
for entry in phases
|
|
500
632
|
),
|
|
@@ -714,8 +846,7 @@ def _find_reg_matrix(
|
|
|
714
846
|
def _build_series_info(
|
|
715
847
|
reader: sitk.ImageSeriesReader,
|
|
716
848
|
raw_image: sitk.Image,
|
|
717
|
-
|
|
718
|
-
scan: _ScanResult,
|
|
849
|
+
reg_matrix: np.ndarray | None,
|
|
719
850
|
series_id: str,
|
|
720
851
|
) -> tuple[str, SeriesInfo]:
|
|
721
852
|
"""Build the ``(description, SeriesInfo)`` pair for one loaded series."""
|
|
@@ -723,7 +854,6 @@ def _build_series_info(
|
|
|
723
854
|
modality = _get_modality(reader)
|
|
724
855
|
window_level = _get_window_level(reader, image_lps, modality)
|
|
725
856
|
|
|
726
|
-
reg_matrix = _find_reg_matrix(scan, file_names, series_id)
|
|
727
857
|
if reg_matrix is not None:
|
|
728
858
|
logger.info(f"Applying REG matrix to series '{series_id}'.")
|
|
729
859
|
transform: sitk.AffineTransform | None = _build_transform(reg_matrix)
|
|
@@ -741,6 +871,49 @@ def _build_series_info(
|
|
|
741
871
|
)
|
|
742
872
|
|
|
743
873
|
|
|
874
|
+
def _read_reg_files(
|
|
875
|
+
reg_files: Iterable[pathlib.Path],
|
|
876
|
+
) -> _RegMatrices:
|
|
877
|
+
"""Return ``(by_sop_instance_uid, by_series_instance_uid)`` from REG files.
|
|
878
|
+
|
|
879
|
+
Reads only the files given (typically :attr:`SeriesScan.reg_files`), so
|
|
880
|
+
no directory tree is walked. Unreadable files are logged and skipped.
|
|
881
|
+
"""
|
|
882
|
+
by_sop: dict[str, np.ndarray] = {}
|
|
883
|
+
by_series: dict[str, np.ndarray] = {}
|
|
884
|
+
for file in reg_files:
|
|
885
|
+
try:
|
|
886
|
+
ds = pydicom.dcmread(str(file))
|
|
887
|
+
except Exception as exc:
|
|
888
|
+
logger.warning(f"Skipping unreadable REG file '{file}': {exc}")
|
|
889
|
+
continue
|
|
890
|
+
if ds.get("SOPClassUID", "") == _SPATIAL_REGISTRATION_UID:
|
|
891
|
+
_collect_reg_matrices(ds, pathlib.Path(file), by_sop, by_series)
|
|
892
|
+
return by_sop, by_series
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _match_reg_matrix(
|
|
896
|
+
reader: sitk.ImageSeriesReader,
|
|
897
|
+
file_count: int,
|
|
898
|
+
series_uid: str,
|
|
899
|
+
reg_matrices: _RegMatrices,
|
|
900
|
+
) -> np.ndarray | None:
|
|
901
|
+
"""Return the REG matrix for a series read by *reader*, if any.
|
|
902
|
+
|
|
903
|
+
Matched by the SOP Instance UID of any slice (a REG object may name only
|
|
904
|
+
one), in slice order, then by *series_uid*.
|
|
905
|
+
"""
|
|
906
|
+
by_sop, by_series = reg_matrices
|
|
907
|
+
if by_sop:
|
|
908
|
+
for index in range(file_count):
|
|
909
|
+
if not reader.HasMetaDataKey(index, "0008|0018"):
|
|
910
|
+
continue
|
|
911
|
+
sop_uid = reader.GetMetaData(index, "0008|0018").strip().rstrip("\0")
|
|
912
|
+
if sop_uid in by_sop:
|
|
913
|
+
return by_sop[sop_uid]
|
|
914
|
+
return by_series.get(series_uid)
|
|
915
|
+
|
|
916
|
+
|
|
744
917
|
# ---------------------------------------------------------------------------
|
|
745
918
|
# Public API
|
|
746
919
|
# ---------------------------------------------------------------------------
|
|
@@ -768,12 +941,12 @@ def _load_all_series_impl(
|
|
|
768
941
|
for dcm_dir in sorted(scan.dirs_with_dicom):
|
|
769
942
|
for sid in reader.GetGDCMSeriesIDs(str(dcm_dir)):
|
|
770
943
|
modality = scan.modality_by_series.get(sid, "")
|
|
771
|
-
if modality.upper() in
|
|
944
|
+
if modality.upper() in NON_IMAGE_MODALITIES:
|
|
772
945
|
logger.info(f"Skipping {modality} series '{sid}' (not an image).")
|
|
773
946
|
continue
|
|
774
947
|
raw_image, file_names = _read_series(reader, dcm_dir, sid)
|
|
775
948
|
description, info = _build_series_info(
|
|
776
|
-
reader, raw_image, file_names,
|
|
949
|
+
reader, raw_image, _find_reg_matrix(scan, file_names, sid), sid
|
|
777
950
|
)
|
|
778
951
|
loaded_count += 1
|
|
779
952
|
if description in series_dict:
|
|
@@ -872,3 +1045,102 @@ def load_dcm_series(dcm_dir: str | pathlib.Path) -> SeriesInfo:
|
|
|
872
1045
|
f"but found {loaded_count}."
|
|
873
1046
|
)
|
|
874
1047
|
return next(iter(series_dict.values()))
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def load_scanned_series(
|
|
1051
|
+
entry: SeriesEntry | PhaseEntry,
|
|
1052
|
+
reg_files: Sequence[pathlib.Path] = (),
|
|
1053
|
+
) -> SeriesInfo:
|
|
1054
|
+
"""Load one series found by :func:`scan_dicom_series`.
|
|
1055
|
+
|
|
1056
|
+
Reads exactly the entry's own files, in the slice order the scan
|
|
1057
|
+
recorded, so it neither walks the directory again nor trips over other
|
|
1058
|
+
series stored in the same directory. An entry built without
|
|
1059
|
+
``file_paths`` falls back to asking GDCM for the files of its
|
|
1060
|
+
``series_uid`` in ``series_dir``.
|
|
1061
|
+
|
|
1062
|
+
Args:
|
|
1063
|
+
entry: An image series or a single 4DCT phase from a scan.
|
|
1064
|
+
reg_files: REG files to look the series up in, typically
|
|
1065
|
+
:attr:`SeriesScan.reg_files`. The matching transform is attached
|
|
1066
|
+
as ``SeriesInfo["transform"]``; with none given it is ``None``.
|
|
1067
|
+
|
|
1068
|
+
Raises:
|
|
1069
|
+
ValueError: If *entry* is a grouped 4DCT (use
|
|
1070
|
+
:func:`load_phase_series`) or an RT-DOSE (use
|
|
1071
|
+
:func:`load_rt_dose`).
|
|
1072
|
+
FileNotFoundError: If the entry has no files to read.
|
|
1073
|
+
"""
|
|
1074
|
+
return _load_scanned_series(entry, _read_reg_files(reg_files))
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def load_phase_series(
|
|
1078
|
+
phases: Sequence[PhaseEntry],
|
|
1079
|
+
reg_files: Sequence[pathlib.Path] = (),
|
|
1080
|
+
max_workers: int | None = None,
|
|
1081
|
+
) -> dict[str, SeriesInfo]:
|
|
1082
|
+
"""Load the phases of a scanned 4DCT, keyed by phase label in *phases* order.
|
|
1083
|
+
|
|
1084
|
+
Only the phases' own files are read, several phases at a time.
|
|
1085
|
+
|
|
1086
|
+
Args:
|
|
1087
|
+
phases: :attr:`SeriesEntry.phases` of a grouped 4DCT entry.
|
|
1088
|
+
reg_files: REG files to look each phase up in (see
|
|
1089
|
+
:func:`load_scanned_series`).
|
|
1090
|
+
max_workers: Phases read concurrently. Defaults to
|
|
1091
|
+
``min(len(phases), 4)``.
|
|
1092
|
+
|
|
1093
|
+
Raises:
|
|
1094
|
+
FileNotFoundError: If a phase has no files to read. The first
|
|
1095
|
+
failure of any phase is propagated.
|
|
1096
|
+
"""
|
|
1097
|
+
if not phases:
|
|
1098
|
+
return {}
|
|
1099
|
+
reg_matrices = _read_reg_files(reg_files)
|
|
1100
|
+
workers = max_workers or min(len(phases), _DEFAULT_PHASE_LOAD_WORKERS)
|
|
1101
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
1102
|
+
infos = list(
|
|
1103
|
+
executor.map(
|
|
1104
|
+
lambda phase: _load_scanned_series(phase, reg_matrices), phases
|
|
1105
|
+
)
|
|
1106
|
+
)
|
|
1107
|
+
return {phase.label: info for phase, info in zip(phases, infos, strict=True)}
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _load_scanned_series(
|
|
1111
|
+
entry: SeriesEntry | PhaseEntry,
|
|
1112
|
+
reg_matrices: _RegMatrices,
|
|
1113
|
+
) -> SeriesInfo:
|
|
1114
|
+
"""Load one scanned series with REG matrices already read.
|
|
1115
|
+
|
|
1116
|
+
Shared by :func:`load_scanned_series` and :func:`load_phase_series`.
|
|
1117
|
+
"""
|
|
1118
|
+
if isinstance(entry, SeriesEntry):
|
|
1119
|
+
if entry.is_4dct:
|
|
1120
|
+
raise ValueError("A grouped 4DCT entry must be loaded by its phases.")
|
|
1121
|
+
if entry.modality == "RTDOSE":
|
|
1122
|
+
raise ValueError("RT-DOSE must be loaded with load_rt_dose.")
|
|
1123
|
+
|
|
1124
|
+
reader = sitk.ImageSeriesReader()
|
|
1125
|
+
file_names = [str(file) for file in entry.file_paths]
|
|
1126
|
+
if not file_names and entry.series_uid:
|
|
1127
|
+
file_names = list(
|
|
1128
|
+
reader.GetGDCMSeriesFileNames(str(entry.series_dir), entry.series_uid)
|
|
1129
|
+
)
|
|
1130
|
+
if not file_names:
|
|
1131
|
+
raise FileNotFoundError(
|
|
1132
|
+
f"No files recorded for series '{entry.series_uid}' in "
|
|
1133
|
+
f"'{entry.series_dir}'."
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
reader.SetFileNames(file_names)
|
|
1137
|
+
reader.MetaDataDictionaryArrayUpdateOn()
|
|
1138
|
+
raw_image = reader.Execute()
|
|
1139
|
+
series_id = entry.series_uid or str(entry.series_dir)
|
|
1140
|
+
logger.info(f"Series '{series_id}' loaded with {len(file_names)} files.")
|
|
1141
|
+
|
|
1142
|
+
reg_matrix = _match_reg_matrix(
|
|
1143
|
+
reader, len(file_names), entry.series_uid, reg_matrices
|
|
1144
|
+
)
|
|
1145
|
+
_, info = _build_series_info(reader, raw_image, reg_matrix, series_id)
|
|
1146
|
+
return info
|
|
@@ -8,11 +8,14 @@ from pydicom.dataset import Dataset, FileDataset, FileMetaDataset
|
|
|
8
8
|
from pydicom.uid import CTImageStorage, ExplicitVRLittleEndian, generate_uid
|
|
9
9
|
|
|
10
10
|
from tk_rt_viewer.io import (
|
|
11
|
+
MixedSeriesDirectoryError,
|
|
11
12
|
MultiplePatientError,
|
|
12
13
|
PhaseEntry,
|
|
13
14
|
_collect_reg_matrices,
|
|
14
15
|
_first_float,
|
|
15
16
|
load_dcm_series,
|
|
17
|
+
load_phase_series,
|
|
18
|
+
load_scanned_series,
|
|
16
19
|
normalize_phase_label,
|
|
17
20
|
scan_dicom_series,
|
|
18
21
|
select_phase_series,
|
|
@@ -349,3 +352,251 @@ class TestNonCtWindowFallback:
|
|
|
349
352
|
width, level = _get_window_level(sitk.ImageSeriesReader(), image, "MR")
|
|
350
353
|
assert width > 0
|
|
351
354
|
assert level == pytest.approx(7.0)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _write_ct_slices(
|
|
358
|
+
directory: pathlib.Path,
|
|
359
|
+
series_description: str,
|
|
360
|
+
n_slices: int = 3,
|
|
361
|
+
prefix: str = "",
|
|
362
|
+
patient: str = "Test",
|
|
363
|
+
positions: list[float] | None = None,
|
|
364
|
+
value: int = 0,
|
|
365
|
+
) -> tuple[str, list[pathlib.Path]]:
|
|
366
|
+
"""Write a CT series whose file names do not follow the slice order.
|
|
367
|
+
|
|
368
|
+
Files are named in *reverse* slice order, and several series can share
|
|
369
|
+
*directory* through distinct *prefix* values. Every voxel of slice ``i``
|
|
370
|
+
holds ``value + i`` so the slice order of a loaded volume can be read
|
|
371
|
+
back. Returns ``(series_uid, files in slice order)``.
|
|
372
|
+
"""
|
|
373
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
374
|
+
series_uid, study, for_ref = generate_uid(), generate_uid(), generate_uid()
|
|
375
|
+
z_values = (
|
|
376
|
+
positions if positions is not None else [float(i) for i in range(n_slices)]
|
|
377
|
+
)
|
|
378
|
+
files: list[pathlib.Path] = []
|
|
379
|
+
for i, z in enumerate(z_values):
|
|
380
|
+
file_meta = FileMetaDataset()
|
|
381
|
+
file_meta.MediaStorageSOPClassUID = CTImageStorage
|
|
382
|
+
sop_uid = generate_uid()
|
|
383
|
+
file_meta.MediaStorageSOPInstanceUID = sop_uid
|
|
384
|
+
file_meta.TransferSyntaxUID = ExplicitVRLittleEndian
|
|
385
|
+
|
|
386
|
+
path = directory / f"{prefix}{len(z_values) - i:03d}.dcm"
|
|
387
|
+
ds = FileDataset(str(path), {}, file_meta=file_meta, preamble=b"\0" * 128)
|
|
388
|
+
ds.SOPClassUID = CTImageStorage
|
|
389
|
+
ds.SOPInstanceUID = sop_uid
|
|
390
|
+
ds.StudyInstanceUID = study
|
|
391
|
+
ds.SeriesInstanceUID = series_uid
|
|
392
|
+
ds.FrameOfReferenceUID = for_ref
|
|
393
|
+
ds.PatientName = patient
|
|
394
|
+
ds.PatientID = patient
|
|
395
|
+
ds.Modality = "CT"
|
|
396
|
+
ds.SeriesDescription = series_description
|
|
397
|
+
ds.Rows = 4
|
|
398
|
+
ds.Columns = 4
|
|
399
|
+
ds.BitsAllocated = 16
|
|
400
|
+
ds.BitsStored = 16
|
|
401
|
+
ds.HighBit = 15
|
|
402
|
+
ds.PixelRepresentation = 1
|
|
403
|
+
ds.SamplesPerPixel = 1
|
|
404
|
+
ds.PhotometricInterpretation = "MONOCHROME2"
|
|
405
|
+
ds.PixelSpacing = [1.0, 1.0]
|
|
406
|
+
ds.SliceThickness = 1.0
|
|
407
|
+
ds.ImagePositionPatient = [0.0, 0.0, z]
|
|
408
|
+
ds.ImageOrientationPatient = [1, 0, 0, 0, 1, 0]
|
|
409
|
+
ds.InstanceNumber = i + 1
|
|
410
|
+
ds.RescaleIntercept = 0
|
|
411
|
+
ds.RescaleSlope = 1
|
|
412
|
+
ds.PixelData = np.full((4, 4), value + i, dtype=np.int16).tobytes()
|
|
413
|
+
ds.save_as(str(path), enforce_file_format=True)
|
|
414
|
+
files.append(path)
|
|
415
|
+
return series_uid, files
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _write_rtdose(path: pathlib.Path, patient: str = "Test") -> None:
|
|
419
|
+
"""Write a header-only RT-DOSE object (enough for the scan)."""
|
|
420
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
421
|
+
meta = FileMetaDataset()
|
|
422
|
+
meta.MediaStorageSOPClassUID = "1.2.840.10008.5.1.4.1.1.481.2"
|
|
423
|
+
meta.MediaStorageSOPInstanceUID = generate_uid()
|
|
424
|
+
meta.TransferSyntaxUID = ExplicitVRLittleEndian
|
|
425
|
+
ds = FileDataset(str(path), {}, file_meta=meta, preamble=b"\0" * 128)
|
|
426
|
+
ds.SOPClassUID = meta.MediaStorageSOPClassUID
|
|
427
|
+
ds.SOPInstanceUID = meta.MediaStorageSOPInstanceUID
|
|
428
|
+
ds.SeriesInstanceUID = generate_uid()
|
|
429
|
+
ds.Modality = "RTDOSE"
|
|
430
|
+
ds.PatientID = patient
|
|
431
|
+
ds.PatientName = patient
|
|
432
|
+
ds.save_as(str(path), enforce_file_format=True)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _slice_values(image) -> list[int]:
|
|
436
|
+
import SimpleITK as sitk
|
|
437
|
+
|
|
438
|
+
array = sitk.GetArrayFromImage(image)
|
|
439
|
+
return [int(array[k, 0, 0]) for k in range(array.shape[0])]
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class TestScanRecordsSeriesFiles:
|
|
443
|
+
def test_files_are_recorded_in_slice_order(self, tmp_path) -> None:
|
|
444
|
+
# Positions deliberately out of step with both names and numbers
|
|
445
|
+
_, files = _write_ct_slices(tmp_path, "CT", positions=[2.0, 0.0, 1.0])
|
|
446
|
+
(entry,) = scan_dicom_series(tmp_path).series
|
|
447
|
+
assert entry.file_paths == (files[1], files[2], files[0])
|
|
448
|
+
|
|
449
|
+
def test_order_matches_gdcm(self, tmp_path) -> None:
|
|
450
|
+
import SimpleITK as sitk
|
|
451
|
+
|
|
452
|
+
uid, _ = _write_ct_slices(tmp_path, "CT", positions=[5.0, -1.0, 3.0, 0.5])
|
|
453
|
+
(entry,) = scan_dicom_series(tmp_path).series
|
|
454
|
+
gdcm = sitk.ImageSeriesReader.GetGDCMSeriesFileNames(str(tmp_path), uid)
|
|
455
|
+
assert [str(p) for p in entry.file_paths] == list(gdcm)
|
|
456
|
+
|
|
457
|
+
def test_phases_carry_their_uid_and_files(self, tmp_path) -> None:
|
|
458
|
+
uid, files = _write_ct_slices(tmp_path / "p0", "4D 0%")
|
|
459
|
+
_write_ct_slices(tmp_path / "p50", "4D 50%")
|
|
460
|
+
(entry,) = scan_dicom_series(tmp_path).series
|
|
461
|
+
phase = entry.phases[0]
|
|
462
|
+
assert phase.series_uid == uid
|
|
463
|
+
assert phase.file_paths == tuple(files)
|
|
464
|
+
# The grouped entry itself holds no files of its own
|
|
465
|
+
assert entry.file_paths == ()
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
class TestRequireSingleSeriesPerDir:
|
|
469
|
+
def test_two_image_series_in_one_directory_raise(self, tmp_path) -> None:
|
|
470
|
+
_write_ct_slices(tmp_path / "mixed", "Plan CT", prefix="a")
|
|
471
|
+
_write_ct_slices(tmp_path / "mixed", "Other CT", prefix="b")
|
|
472
|
+
with pytest.raises(MixedSeriesDirectoryError) as info:
|
|
473
|
+
scan_dicom_series(tmp_path, require_single_series_per_dir=True)
|
|
474
|
+
assert info.value.directories == (tmp_path / "mixed",)
|
|
475
|
+
|
|
476
|
+
def test_off_by_default(self, tmp_path) -> None:
|
|
477
|
+
_write_ct_slices(tmp_path, "Plan CT", prefix="a")
|
|
478
|
+
_write_ct_slices(tmp_path, "Other CT", prefix="b")
|
|
479
|
+
assert len(scan_dicom_series(tmp_path).series) == 2
|
|
480
|
+
|
|
481
|
+
def test_non_image_objects_may_share_the_directory(self, tmp_path) -> None:
|
|
482
|
+
_write_ct_slices(tmp_path, "Plan CT")
|
|
483
|
+
_write_rtdose(tmp_path / "dose.dcm")
|
|
484
|
+
scan = scan_dicom_series(tmp_path, require_single_series_per_dir=True)
|
|
485
|
+
assert [e.modality for e in scan.series] == ["CT", "RTDOSE"]
|
|
486
|
+
|
|
487
|
+
def test_series_in_separate_directories_pass(self, tmp_path) -> None:
|
|
488
|
+
_write_ct_slices(tmp_path / "a", "Plan CT")
|
|
489
|
+
_write_ct_slices(tmp_path / "b", "Other CT")
|
|
490
|
+
scan = scan_dicom_series(tmp_path, require_single_series_per_dir=True)
|
|
491
|
+
assert len(scan.series) == 2
|
|
492
|
+
|
|
493
|
+
def test_4dct_phases_may_share_a_directory(self, tmp_path) -> None:
|
|
494
|
+
for percent in (0, 50):
|
|
495
|
+
_write_ct_slices(tmp_path / "4d", f"4D {percent}%", prefix=f"p{percent}_")
|
|
496
|
+
scan = scan_dicom_series(tmp_path, require_single_series_per_dir=True)
|
|
497
|
+
assert len(scan.series[0].phases) == 2
|
|
498
|
+
|
|
499
|
+
def test_4dct_phases_mixed_with_another_series_raise(self, tmp_path) -> None:
|
|
500
|
+
for percent in (0, 50):
|
|
501
|
+
_write_ct_slices(tmp_path / "4d", f"4D {percent}%", prefix=f"p{percent}_")
|
|
502
|
+
_write_ct_slices(tmp_path / "4d", "AIP", prefix="aip_")
|
|
503
|
+
with pytest.raises(MixedSeriesDirectoryError):
|
|
504
|
+
scan_dicom_series(tmp_path, require_single_series_per_dir=True)
|
|
505
|
+
|
|
506
|
+
def test_multiple_patients_are_reported_first(self, tmp_path) -> None:
|
|
507
|
+
_write_ct_slices(tmp_path, "Plan CT", prefix="a", patient="P1")
|
|
508
|
+
_write_ct_slices(tmp_path, "Other CT", prefix="b", patient="P2")
|
|
509
|
+
with pytest.raises(MultiplePatientError):
|
|
510
|
+
scan_dicom_series(tmp_path, require_single_series_per_dir=True)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
class TestLoadScannedSeries:
|
|
514
|
+
def test_loads_only_the_selected_series_from_a_shared_directory(
|
|
515
|
+
self, tmp_path
|
|
516
|
+
) -> None:
|
|
517
|
+
_write_ct_slices(tmp_path, "A", prefix="a", value=100)
|
|
518
|
+
_write_ct_slices(tmp_path, "B", prefix="b", n_slices=5, value=200)
|
|
519
|
+
scan = scan_dicom_series(tmp_path)
|
|
520
|
+
entry_b = next(e for e in scan.series if e.description == "B")
|
|
521
|
+
|
|
522
|
+
info = load_scanned_series(entry_b)
|
|
523
|
+
|
|
524
|
+
assert info["sitk_image"].GetSize() == (4, 4, 5)
|
|
525
|
+
assert _slice_values(info["sitk_image"]) == [200, 201, 202, 203, 204]
|
|
526
|
+
assert info["transform"] is None
|
|
527
|
+
|
|
528
|
+
def test_matches_load_dcm_series(self, tmp_path) -> None:
|
|
529
|
+
_write_ct_slices(tmp_path, "A", positions=[3.0, 1.0, 2.0, 0.0], value=10)
|
|
530
|
+
(entry,) = scan_dicom_series(tmp_path).series
|
|
531
|
+
expected = load_dcm_series(tmp_path)
|
|
532
|
+
actual = load_scanned_series(entry)
|
|
533
|
+
import SimpleITK as sitk
|
|
534
|
+
|
|
535
|
+
for key in ("sitk_image", "original_sitk_image"):
|
|
536
|
+
assert actual[key].GetOrigin() == expected[key].GetOrigin()
|
|
537
|
+
assert actual[key].GetDirection() == expected[key].GetDirection()
|
|
538
|
+
assert np.array_equal(
|
|
539
|
+
sitk.GetArrayFromImage(actual[key]),
|
|
540
|
+
sitk.GetArrayFromImage(expected[key]),
|
|
541
|
+
)
|
|
542
|
+
assert actual["window_level"] == expected["window_level"]
|
|
543
|
+
assert actual["modality"] == expected["modality"]
|
|
544
|
+
|
|
545
|
+
def test_entry_without_files_falls_back_to_gdcm(self, tmp_path) -> None:
|
|
546
|
+
import dataclasses
|
|
547
|
+
|
|
548
|
+
_write_ct_slices(tmp_path, "A", prefix="a", value=1)
|
|
549
|
+
_write_ct_slices(tmp_path, "B", prefix="b", value=50)
|
|
550
|
+
entry = next(
|
|
551
|
+
e for e in scan_dicom_series(tmp_path).series if e.description == "B"
|
|
552
|
+
)
|
|
553
|
+
info = load_scanned_series(dataclasses.replace(entry, file_paths=()))
|
|
554
|
+
assert _slice_values(info["sitk_image"]) == [50, 51, 52]
|
|
555
|
+
|
|
556
|
+
def test_rejects_grouped_4dct_and_dose(self, tmp_path) -> None:
|
|
557
|
+
_write_ct_slices(tmp_path / "p0", "4D 0%")
|
|
558
|
+
_write_rtdose(tmp_path / "dose" / "dose.dcm")
|
|
559
|
+
scan = scan_dicom_series(tmp_path)
|
|
560
|
+
for entry in scan.series:
|
|
561
|
+
with pytest.raises(ValueError):
|
|
562
|
+
load_scanned_series(entry)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
class TestLoadPhaseSeries:
|
|
566
|
+
def test_loads_phases_in_order_with_their_registration(self, tmp_path) -> None:
|
|
567
|
+
import pydicom
|
|
568
|
+
|
|
569
|
+
from tk_rt_viewer.reg_io import save_registration
|
|
570
|
+
|
|
571
|
+
_write_ct_slices(tmp_path / "plan", "Plan CT")
|
|
572
|
+
for percent in (50, 0, 10):
|
|
573
|
+
_write_ct_slices(
|
|
574
|
+
tmp_path / "4d", f"4D {percent}%", prefix=f"p{percent}_", value=percent
|
|
575
|
+
)
|
|
576
|
+
# An unrelated series in the tree must not be read at all
|
|
577
|
+
_write_ct_slices(tmp_path / "other", "Other CT")
|
|
578
|
+
|
|
579
|
+
scan = scan_dicom_series(tmp_path / "plan") # only for the reference
|
|
580
|
+
fixed_ref = pydicom.dcmread(scan.series[0].file_path, stop_before_pixels=True)
|
|
581
|
+
moving_ref = pydicom.dcmread(
|
|
582
|
+
tmp_path / "4d" / "p10_001.dcm", stop_before_pixels=True
|
|
583
|
+
)
|
|
584
|
+
matrix = np.eye(4)
|
|
585
|
+
matrix[:3, 3] = (5.0, -3.0, 2.0)
|
|
586
|
+
save_registration(tmp_path / "reg" / "reg.dcm", matrix, fixed_ref, moving_ref)
|
|
587
|
+
|
|
588
|
+
scan = scan_dicom_series(tmp_path)
|
|
589
|
+
four_d = scan.series[0]
|
|
590
|
+
phases = load_phase_series(four_d.phases, scan.reg_files)
|
|
591
|
+
|
|
592
|
+
assert list(phases) == ["0%", "10%", "50%"]
|
|
593
|
+
assert _slice_values(phases["50%"]["sitk_image"]) == [50, 51, 52]
|
|
594
|
+
assert phases["0%"]["transform"] is None
|
|
595
|
+
transform = phases["10%"]["transform"]
|
|
596
|
+
assert transform is not None
|
|
597
|
+
assert transform.TransformPoint((0.0, 0.0, 0.0)) == pytest.approx(
|
|
598
|
+
(-5.0, 3.0, -2.0)
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
def test_no_phases(self) -> None:
|
|
602
|
+
assert load_phase_series(()) == {}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/event_controllers/bbox3d_handler.py
RENAMED
|
File without changes
|
{tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/event_controllers/bbox_handler.py
RENAMED
|
File without changes
|
{tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/event_controllers/brush_handler.py
RENAMED
|
File without changes
|
{tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/event_controllers/crosshair_handler.py
RENAMED
|
File without changes
|
|
File without changes
|
{tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.1}/src/tk_rt_viewer/event_controllers/viewer_events.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|