tk-rt-viewer 2.1.2__tar.gz → 2.2.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 (69) hide show
  1. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/CHANGELOG.md +32 -0
  2. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/PKG-INFO +26 -5
  3. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/README.md +25 -4
  4. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/__init__.py +4 -3
  5. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/io.py +289 -21
  6. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/tests/test_io.py +251 -0
  7. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/.gitattributes +0 -0
  8. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/.github/workflows/ci.yml +0 -0
  9. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/.gitignore +0 -0
  10. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/LICENSE +0 -0
  11. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/pyproject.toml +0 -0
  12. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/event_controllers/__init__.py +0 -0
  13. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/event_controllers/bbox3d_handler.py +0 -0
  14. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/event_controllers/bbox_handler.py +0 -0
  15. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/event_controllers/brush_handler.py +0 -0
  16. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/event_controllers/crosshair_handler.py +0 -0
  17. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/event_controllers/rect_drag.py +0 -0
  18. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/event_controllers/viewer_events.py +0 -0
  19. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/events.py +0 -0
  20. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/geometry.py +0 -0
  21. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/isodose_levels.py +0 -0
  22. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/protocols.py +0 -0
  23. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/py.typed +0 -0
  24. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/reg_io.py +0 -0
  25. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/registration/__init__.py +0 -0
  26. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/registration/deformable.py +0 -0
  27. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/registration/errors.py +0 -0
  28. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/registration/params.py +0 -0
  29. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/registration/rigid.py +0 -0
  30. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/registration/session.py +0 -0
  31. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/registration/template.py +0 -0
  32. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/rendering/__init__.py +0 -0
  33. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/rendering/blit_compositor.py +0 -0
  34. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/rendering/contour_overlay.py +0 -0
  35. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/rendering/drawing_manager.py +0 -0
  36. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/rendering/dvh.py +0 -0
  37. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/rendering/image_layer.py +0 -0
  38. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/rendering/isodose.py +0 -0
  39. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/rendering/layout.py +0 -0
  40. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/rendering/render.py +0 -0
  41. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/roi_operations.py +0 -0
  42. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/rtstruct_io.py +0 -0
  43. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/state/__init__.py +0 -0
  44. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/state/dose_manager.py +0 -0
  45. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/state/phase_manager.py +0 -0
  46. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/state/roi_editor.py +0 -0
  47. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/state/roi_manager.py +0 -0
  48. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/state/secondary_manager.py +0 -0
  49. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/state/structure_set.py +0 -0
  50. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/state/viewer_cache.py +0 -0
  51. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/state/viewer_state.py +0 -0
  52. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/viewer.py +0 -0
  53. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/src/tk_rt_viewer/window_level.py +0 -0
  54. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/tests/test_bbox3d.py +0 -0
  55. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/tests/test_bbox3d_handler.py +0 -0
  56. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/tests/test_brush_handler.py +0 -0
  57. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/tests/test_event_controllers.py +0 -0
  58. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/tests/test_geometry.py +0 -0
  59. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/tests/test_isodose_levels.py +0 -0
  60. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/tests/test_performance_opt.py +0 -0
  61. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/tests/test_reg_io.py +0 -0
  62. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/tests/test_registration.py +0 -0
  63. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/tests/test_render.py +0 -0
  64. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/tests/test_rendering.py +0 -0
  65. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/tests/test_roi_operations.py +0 -0
  66. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/tests/test_rtstruct_io.py +0 -0
  67. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/tests/test_state_extensions.py +0 -0
  68. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/tests/test_state_managers.py +0 -0
  69. {tk_rt_viewer-2.1.2 → tk_rt_viewer-2.2.0}/tests/test_viewer_state.py +0 -0
@@ -4,6 +4,38 @@ 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
+
34
+ ### Changed
35
+
36
+ - `_build_series_info` takes the matched REG matrix instead of the tree scan
37
+ (internal).
38
+
7
39
  ## [2.1.2]
8
40
 
9
41
  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.2
3
+ Version: 2.2.0
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. To load
219
- the phases of a 4DCT entry afterwards, narrow a `load_all_series` result with
220
- `select_phase_series(all_series, entry.phases)`.
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. Any referenced slice is enough: references are resolved
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. To load
182
- the phases of a 4DCT entry afterwards, narrow a `load_all_series` result with
183
- `select_phase_series(all_series, entry.phases)`.
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. Any referenced slice is enough: references are resolved
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
- select_phase_series, load_all_series, load_dcm_series,
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
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.2"
107
+ __version__ = "2.2.0"
107
108
 
108
109
 
109
110
  def __getattr__(name: str) -> Any:
@@ -13,6 +13,13 @@ 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
+
16
23
  select_phase_series(all_series, phases) -> dict[str, SeriesInfo]
17
24
  Pick the 4DCT phases named by a scan result out of a load_all_series map.
18
25
 
@@ -42,8 +49,9 @@ import logging
42
49
  import math
43
50
  import pathlib
44
51
  import re
45
- from collections.abc import Iterator, Sequence
46
- from dataclasses import dataclass, field
52
+ from collections.abc import Iterable, Iterator, Sequence
53
+ from concurrent.futures import ThreadPoolExecutor
54
+ from dataclasses import dataclass, field, replace
47
55
  from typing import TypedDict
48
56
 
49
57
  import numpy as np
@@ -81,10 +89,19 @@ _NON_CT_WINDOW_PERCENTILES: tuple[float, float] = (0.5, 99.5)
81
89
  #: Width used for an image with a flat intensity range (no meaningful window).
82
90
  _FLAT_IMAGE_WINDOW_WIDTH: float = 1.0
83
91
 
92
+ #: Upper bound on the worker threads :func:`load_phase_series` uses by default.
93
+ #: Phases are read concurrently because reading is I/O bound and SimpleITK
94
+ #: releases the GIL; more workers than this mostly contend for the disk.
95
+ _DEFAULT_PHASE_LOAD_WORKERS: int = 4
96
+
84
97
  #: Modalities :func:`scan_dicom_series` lists by default.
85
98
  DEFAULT_SCAN_MODALITIES: frozenset[str] = frozenset({"CT", "MR", "PT", "RTDOSE"})
86
99
 
87
100
 
101
+ #: ``(by_sop_instance_uid, by_series_instance_uid)`` registration matrices.
102
+ type _RegMatrices = tuple[dict[str, np.ndarray], dict[str, np.ndarray]]
103
+
104
+
88
105
  class SeriesInfo(TypedDict):
89
106
  """Dict shape returned by :func:`load_all_series` and :func:`load_dcm_series`."""
90
107
 
@@ -327,6 +344,24 @@ class MultiplePatientError(ValueError):
327
344
  """
328
345
 
329
346
 
347
+ class MixedSeriesDirectoryError(ValueError):
348
+ """A directory being scanned holds more than one image series.
349
+
350
+ Raised by :func:`scan_dicom_series` when *require_single_series_per_dir*
351
+ is set. Code that addresses a series by its directory (RT-STRUCT export
352
+ and import through rt-utils, for one) would silently mix the slices of
353
+ every image series in that directory.
354
+
355
+ Attributes:
356
+ directories: The offending directories, sorted.
357
+ """
358
+
359
+ def __init__(self, directories: Sequence[pathlib.Path]) -> None:
360
+ self.directories: tuple[pathlib.Path, ...] = tuple(sorted(directories))
361
+ listing = ", ".join(f"'{d}'" for d in self.directories)
362
+ super().__init__(f"Found more than one image series in: {listing}.")
363
+
364
+
330
365
  @dataclass(frozen=True)
331
366
  class PhaseEntry:
332
367
  """One respiratory phase of a 4DCT series.
@@ -335,11 +370,16 @@ class PhaseEntry:
335
370
  label: Normalised phase label, e.g. ``"10%"``.
336
371
  description: The phase's own SeriesDescription.
337
372
  series_dir: Directory holding the phase's files.
373
+ series_uid: The phase's SeriesInstanceUID.
374
+ file_paths: The phase's files in slice order (see
375
+ :attr:`SeriesEntry.file_paths`).
338
376
  """
339
377
 
340
378
  label: str
341
379
  description: str
342
380
  series_dir: pathlib.Path
381
+ series_uid: str = ""
382
+ file_paths: tuple[pathlib.Path, ...] = ()
343
383
 
344
384
 
345
385
  @dataclass(frozen=True)
@@ -355,6 +395,9 @@ class SeriesEntry:
355
395
  directly, since a directory may hold several dose objects.
356
396
  phases: The phases of a 4DCT series, lowest percentage first;
357
397
  empty for an ordinary series.
398
+ file_paths: Every file of the series, in slice order, so
399
+ :func:`load_scanned_series` reads them without scanning the
400
+ directory again. Empty for a grouped 4DCT entry (see *phases*).
358
401
  """
359
402
 
360
403
  modality: str
@@ -363,6 +406,7 @@ class SeriesEntry:
363
406
  series_uid: str
364
407
  file_path: pathlib.Path
365
408
  phases: tuple[PhaseEntry, ...] = ()
409
+ file_paths: tuple[pathlib.Path, ...] = ()
366
410
 
367
411
  @property
368
412
  def is_4dct(self) -> bool:
@@ -392,13 +436,16 @@ def scan_dicom_series(
392
436
  modalities: frozenset[str] = DEFAULT_SCAN_MODALITIES,
393
437
  group_4dct: bool = True,
394
438
  require_single_patient: bool = True,
439
+ require_single_series_per_dir: bool = False,
395
440
  ) -> SeriesScan:
396
441
  """List the series under *dcm_root_dir* without reading any pixel data.
397
442
 
398
443
  Headers only, so a large folder is enumerated quickly and the host loads
399
- just what the user selects (:func:`load_dcm_series`,
400
- :func:`load_rt_dose`). Series come back images first, then RT-DOSE, each
401
- group ordered by modality and description.
444
+ just what the user selects (:func:`load_scanned_series`,
445
+ :func:`load_phase_series`, :func:`load_rt_dose`). Each entry records its
446
+ files in slice order, so loading it never scans the directory again.
447
+ Series come back images first, then RT-DOSE, each group ordered by
448
+ modality and description.
402
449
 
403
450
  Args:
404
451
  dcm_root_dir: Root directory to scan, recursively.
@@ -408,13 +455,23 @@ def scan_dicom_series(
408
455
  (``"0%"``, ``"10%"``, ...) into one entry holding them as phases.
409
456
  require_single_patient: Raise when the tree holds more than one
410
457
  patient.
458
+ require_single_series_per_dir: Raise when a directory holding a
459
+ listed series also holds another image series. Non-image objects
460
+ (RT-STRUCT, RT-PLAN, RT-DOSE, REG, ...) may share the directory,
461
+ and so may the phases of one grouped 4DCT. Set this when the
462
+ host addresses a series by its directory, e.g. for RT-STRUCT
463
+ import and export.
411
464
 
412
465
  Raises:
413
466
  MultiplePatientError: If *require_single_patient* and more than one
414
- PatientID or PatientName was found.
467
+ PatientID or PatientName was found. Checked first.
468
+ MixedSeriesDirectoryError: If *require_single_series_per_dir* and a
469
+ directory mixes image series.
415
470
  """
416
471
  root = pathlib.Path(dcm_root_dir)
417
472
  series_by_uid: dict[str, SeriesEntry] = {}
473
+ files_by_uid: dict[str, list[tuple[tuple[float, float], pathlib.Path]]] = {}
474
+ image_series_by_dir: dict[pathlib.Path, set[str]] = {}
418
475
  reg_files: list[pathlib.Path] = []
419
476
  patient_ids: set[str] = set()
420
477
  patient_names: set[str] = set()
@@ -431,19 +488,24 @@ def scan_dicom_series(
431
488
  if modality == "REG":
432
489
  reg_files.append(file)
433
490
  continue
434
- if modality not in modalities:
435
- continue
436
491
 
437
492
  series_uid = str(ds.get("SeriesInstanceUID", ""))
438
- if not series_uid or series_uid in series_by_uid:
493
+ if not series_uid:
439
494
  continue
440
- series_by_uid[series_uid] = SeriesEntry(
441
- modality=modality,
442
- description=str(ds.get("SeriesDescription", "")).strip(),
443
- series_dir=file.parent,
444
- series_uid=series_uid,
445
- file_path=file,
446
- )
495
+ if modality not in _NON_IMAGE_MODALITIES:
496
+ image_series_by_dir.setdefault(file.parent, set()).add(series_uid)
497
+ if modality not in modalities:
498
+ continue
499
+
500
+ files_by_uid.setdefault(series_uid, []).append((_slice_sort_key(ds), file))
501
+ if series_uid not in series_by_uid:
502
+ series_by_uid[series_uid] = SeriesEntry(
503
+ modality=modality,
504
+ description=str(ds.get("SeriesDescription", "")).strip(),
505
+ series_dir=file.parent,
506
+ series_uid=series_uid,
507
+ file_path=file,
508
+ )
447
509
 
448
510
  if require_single_patient and (len(patient_ids) > 1 or len(patient_names) > 1):
449
511
  raise MultiplePatientError(
@@ -451,7 +513,19 @@ def scan_dicom_series(
451
513
  f"names={sorted(patient_names)}."
452
514
  )
453
515
 
454
- series = _order_series(list(series_by_uid.values()), group_4dct)
516
+ entries = [
517
+ replace(entry, file_paths=_sorted_files(files_by_uid[uid]))
518
+ for uid, entry in series_by_uid.items()
519
+ ]
520
+ series = _order_series(entries, group_4dct)
521
+
522
+ if require_single_series_per_dir:
523
+ mixed_dirs = _find_mixed_series_dirs(
524
+ image_series_by_dir, frozenset(series_by_uid), series
525
+ )
526
+ if mixed_dirs:
527
+ raise MixedSeriesDirectoryError(mixed_dirs)
528
+
455
529
  logger.info(
456
530
  f"Scanned '{root}': {len(series)} series "
457
531
  f"({sum(len(entry.phases) for entry in series)} 4DCT phases, "
@@ -465,6 +539,58 @@ def scan_dicom_series(
465
539
  )
466
540
 
467
541
 
542
+ def _slice_sort_key(ds: pydicom.Dataset) -> tuple[float, float]:
543
+ """Return ``(position along the slice normal, InstanceNumber)`` for a slice.
544
+
545
+ The position is the ImagePositionPatient projected onto the normal of
546
+ ImageOrientationPatient, the order GDCM itself sorts a series into. A
547
+ slice without usable geometry falls back to its InstanceNumber.
548
+ """
549
+ try:
550
+ instance = float(ds.get("InstanceNumber"))
551
+ except (TypeError, ValueError):
552
+ instance = math.inf
553
+
554
+ position = ds.get("ImagePositionPatient")
555
+ orientation = ds.get("ImageOrientationPatient")
556
+ if position is None or orientation is None:
557
+ return instance, instance
558
+ try:
559
+ cosines = np.asarray(orientation, dtype=float)
560
+ normal = np.cross(cosines[:3], cosines[3:6])
561
+ distance = float(np.dot(normal, np.asarray(position, dtype=float)))
562
+ except (TypeError, ValueError):
563
+ return instance, instance
564
+ return distance, instance
565
+
566
+
567
+ def _sorted_files(
568
+ keyed_files: Iterable[tuple[tuple[float, float], pathlib.Path]],
569
+ ) -> tuple[pathlib.Path, ...]:
570
+ """Order a series' files by their slice keys, the path breaking ties."""
571
+ return tuple(
572
+ file for _, file in sorted(keyed_files, key=lambda kf: (kf[0], str(kf[1])))
573
+ )
574
+
575
+
576
+ def _find_mixed_series_dirs(
577
+ image_series_by_dir: dict[pathlib.Path, set[str]],
578
+ listed_uids: frozenset[str],
579
+ series: Sequence[SeriesEntry],
580
+ ) -> list[pathlib.Path]:
581
+ """Return the directories where a listed series shares with another image series.
582
+
583
+ The phases of a grouped 4DCT count as one acquisition, so a directory
584
+ holding only those phases is not mixed.
585
+ """
586
+ phase_uids = {phase.series_uid for entry in series for phase in entry.phases}
587
+ return [
588
+ directory
589
+ for directory, uids in image_series_by_dir.items()
590
+ if len(uids) > 1 and uids & listed_uids and not uids <= phase_uids
591
+ ]
592
+
593
+
468
594
  def _order_series(entries: list[SeriesEntry], group_4dct: bool) -> list[SeriesEntry]:
469
595
  """Group 4DCT phases and sort the result into display order."""
470
596
  phases: list[SeriesEntry] = []
@@ -495,6 +621,8 @@ def _order_series(entries: list[SeriesEntry], group_4dct: bool) -> list[SeriesEn
495
621
  label=str(normalize_phase_label(entry.description)),
496
622
  description=entry.description,
497
623
  series_dir=entry.series_dir,
624
+ series_uid=entry.series_uid,
625
+ file_paths=entry.file_paths,
498
626
  )
499
627
  for entry in phases
500
628
  ),
@@ -714,8 +842,7 @@ def _find_reg_matrix(
714
842
  def _build_series_info(
715
843
  reader: sitk.ImageSeriesReader,
716
844
  raw_image: sitk.Image,
717
- file_names: tuple[str, ...],
718
- scan: _ScanResult,
845
+ reg_matrix: np.ndarray | None,
719
846
  series_id: str,
720
847
  ) -> tuple[str, SeriesInfo]:
721
848
  """Build the ``(description, SeriesInfo)`` pair for one loaded series."""
@@ -723,7 +850,6 @@ def _build_series_info(
723
850
  modality = _get_modality(reader)
724
851
  window_level = _get_window_level(reader, image_lps, modality)
725
852
 
726
- reg_matrix = _find_reg_matrix(scan, file_names, series_id)
727
853
  if reg_matrix is not None:
728
854
  logger.info(f"Applying REG matrix to series '{series_id}'.")
729
855
  transform: sitk.AffineTransform | None = _build_transform(reg_matrix)
@@ -741,6 +867,49 @@ def _build_series_info(
741
867
  )
742
868
 
743
869
 
870
+ def _read_reg_files(
871
+ reg_files: Iterable[pathlib.Path],
872
+ ) -> _RegMatrices:
873
+ """Return ``(by_sop_instance_uid, by_series_instance_uid)`` from REG files.
874
+
875
+ Reads only the files given (typically :attr:`SeriesScan.reg_files`), so
876
+ no directory tree is walked. Unreadable files are logged and skipped.
877
+ """
878
+ by_sop: dict[str, np.ndarray] = {}
879
+ by_series: dict[str, np.ndarray] = {}
880
+ for file in reg_files:
881
+ try:
882
+ ds = pydicom.dcmread(str(file))
883
+ except Exception as exc:
884
+ logger.warning(f"Skipping unreadable REG file '{file}': {exc}")
885
+ continue
886
+ if ds.get("SOPClassUID", "") == _SPATIAL_REGISTRATION_UID:
887
+ _collect_reg_matrices(ds, pathlib.Path(file), by_sop, by_series)
888
+ return by_sop, by_series
889
+
890
+
891
+ def _match_reg_matrix(
892
+ reader: sitk.ImageSeriesReader,
893
+ file_count: int,
894
+ series_uid: str,
895
+ reg_matrices: _RegMatrices,
896
+ ) -> np.ndarray | None:
897
+ """Return the REG matrix for a series read by *reader*, if any.
898
+
899
+ Matched by the SOP Instance UID of any slice (a REG object may name only
900
+ one), in slice order, then by *series_uid*.
901
+ """
902
+ by_sop, by_series = reg_matrices
903
+ if by_sop:
904
+ for index in range(file_count):
905
+ if not reader.HasMetaDataKey(index, "0008|0018"):
906
+ continue
907
+ sop_uid = reader.GetMetaData(index, "0008|0018").strip().rstrip("\0")
908
+ if sop_uid in by_sop:
909
+ return by_sop[sop_uid]
910
+ return by_series.get(series_uid)
911
+
912
+
744
913
  # ---------------------------------------------------------------------------
745
914
  # Public API
746
915
  # ---------------------------------------------------------------------------
@@ -773,7 +942,7 @@ def _load_all_series_impl(
773
942
  continue
774
943
  raw_image, file_names = _read_series(reader, dcm_dir, sid)
775
944
  description, info = _build_series_info(
776
- reader, raw_image, file_names, scan, sid
945
+ reader, raw_image, _find_reg_matrix(scan, file_names, sid), sid
777
946
  )
778
947
  loaded_count += 1
779
948
  if description in series_dict:
@@ -872,3 +1041,102 @@ def load_dcm_series(dcm_dir: str | pathlib.Path) -> SeriesInfo:
872
1041
  f"but found {loaded_count}."
873
1042
  )
874
1043
  return next(iter(series_dict.values()))
1044
+
1045
+
1046
+ def load_scanned_series(
1047
+ entry: SeriesEntry | PhaseEntry,
1048
+ reg_files: Sequence[pathlib.Path] = (),
1049
+ ) -> SeriesInfo:
1050
+ """Load one series found by :func:`scan_dicom_series`.
1051
+
1052
+ Reads exactly the entry's own files, in the slice order the scan
1053
+ recorded, so it neither walks the directory again nor trips over other
1054
+ series stored in the same directory. An entry built without
1055
+ ``file_paths`` falls back to asking GDCM for the files of its
1056
+ ``series_uid`` in ``series_dir``.
1057
+
1058
+ Args:
1059
+ entry: An image series or a single 4DCT phase from a scan.
1060
+ reg_files: REG files to look the series up in, typically
1061
+ :attr:`SeriesScan.reg_files`. The matching transform is attached
1062
+ as ``SeriesInfo["transform"]``; with none given it is ``None``.
1063
+
1064
+ Raises:
1065
+ ValueError: If *entry* is a grouped 4DCT (use
1066
+ :func:`load_phase_series`) or an RT-DOSE (use
1067
+ :func:`load_rt_dose`).
1068
+ FileNotFoundError: If the entry has no files to read.
1069
+ """
1070
+ return _load_scanned_series(entry, _read_reg_files(reg_files))
1071
+
1072
+
1073
+ def load_phase_series(
1074
+ phases: Sequence[PhaseEntry],
1075
+ reg_files: Sequence[pathlib.Path] = (),
1076
+ max_workers: int | None = None,
1077
+ ) -> dict[str, SeriesInfo]:
1078
+ """Load the phases of a scanned 4DCT, keyed by phase label in *phases* order.
1079
+
1080
+ Only the phases' own files are read, several phases at a time.
1081
+
1082
+ Args:
1083
+ phases: :attr:`SeriesEntry.phases` of a grouped 4DCT entry.
1084
+ reg_files: REG files to look each phase up in (see
1085
+ :func:`load_scanned_series`).
1086
+ max_workers: Phases read concurrently. Defaults to
1087
+ ``min(len(phases), 4)``.
1088
+
1089
+ Raises:
1090
+ FileNotFoundError: If a phase has no files to read. The first
1091
+ failure of any phase is propagated.
1092
+ """
1093
+ if not phases:
1094
+ return {}
1095
+ reg_matrices = _read_reg_files(reg_files)
1096
+ workers = max_workers or min(len(phases), _DEFAULT_PHASE_LOAD_WORKERS)
1097
+ with ThreadPoolExecutor(max_workers=workers) as executor:
1098
+ infos = list(
1099
+ executor.map(
1100
+ lambda phase: _load_scanned_series(phase, reg_matrices), phases
1101
+ )
1102
+ )
1103
+ return {phase.label: info for phase, info in zip(phases, infos, strict=True)}
1104
+
1105
+
1106
+ def _load_scanned_series(
1107
+ entry: SeriesEntry | PhaseEntry,
1108
+ reg_matrices: _RegMatrices,
1109
+ ) -> SeriesInfo:
1110
+ """Load one scanned series with REG matrices already read.
1111
+
1112
+ Shared by :func:`load_scanned_series` and :func:`load_phase_series`.
1113
+ """
1114
+ if isinstance(entry, SeriesEntry):
1115
+ if entry.is_4dct:
1116
+ raise ValueError("A grouped 4DCT entry must be loaded by its phases.")
1117
+ if entry.modality == "RTDOSE":
1118
+ raise ValueError("RT-DOSE must be loaded with load_rt_dose.")
1119
+
1120
+ reader = sitk.ImageSeriesReader()
1121
+ file_names = [str(file) for file in entry.file_paths]
1122
+ if not file_names and entry.series_uid:
1123
+ file_names = list(
1124
+ reader.GetGDCMSeriesFileNames(str(entry.series_dir), entry.series_uid)
1125
+ )
1126
+ if not file_names:
1127
+ raise FileNotFoundError(
1128
+ f"No files recorded for series '{entry.series_uid}' in "
1129
+ f"'{entry.series_dir}'."
1130
+ )
1131
+
1132
+ reader.SetFileNames(file_names)
1133
+ reader.MetaDataDictionaryArrayUpdateOn()
1134
+ raw_image = reader.Execute()
1135
+ series_id = entry.series_uid or str(entry.series_dir)
1136
+ logger.info(f"Series '{series_id}' loaded with {len(file_names)} files.")
1137
+
1138
+ reg_matrix = _match_reg_matrix(
1139
+ reader, len(file_names), entry.series_uid, reg_matrices
1140
+ )
1141
+ _, info = _build_series_info(reader, raw_image, reg_matrix, series_id)
1142
+ 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