tk-rt-viewer 2.3.1__tar.gz → 2.3.3__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 (70) hide show
  1. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/.github/workflows/ci.yml +2 -2
  2. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/CHANGELOG.md +36 -0
  3. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/PKG-INFO +6 -1
  4. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/README.md +5 -0
  5. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/__init__.py +1 -1
  6. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/bbox3d_handler.py +7 -9
  7. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/brush_handler.py +5 -9
  8. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/crosshair_handler.py +1 -3
  9. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/viewer_events.py +3 -4
  10. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/geometry.py +21 -0
  11. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/io.py +16 -17
  12. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/reg_io.py +4 -1
  13. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/params.py +5 -7
  14. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/dvh.py +4 -7
  15. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/isodose.py +1 -1
  16. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/layout.py +14 -4
  17. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/view_axes.py +4 -2
  18. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/roi_operations.py +6 -25
  19. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rtstruct_io.py +47 -13
  20. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/phase_manager.py +1 -4
  21. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/secondary_manager.py +13 -3
  22. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/viewer_state.py +16 -0
  23. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/viewer.py +51 -78
  24. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_brush_handler.py +2 -2
  25. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_reg_io.py +23 -0
  26. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_rtstruct_io.py +117 -8
  27. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_viewer_state.py +35 -0
  28. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/.gitattributes +0 -0
  29. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/.gitignore +0 -0
  30. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/LICENSE +0 -0
  31. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/pyproject.toml +0 -0
  32. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/__init__.py +0 -0
  33. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/bbox_handler.py +0 -0
  34. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/rect_drag.py +0 -0
  35. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/events.py +0 -0
  36. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/isodose_levels.py +0 -0
  37. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/protocols.py +0 -0
  38. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/py.typed +0 -0
  39. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/__init__.py +0 -0
  40. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/deformable.py +0 -0
  41. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/errors.py +0 -0
  42. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/rigid.py +0 -0
  43. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/session.py +0 -0
  44. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/template.py +0 -0
  45. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/__init__.py +0 -0
  46. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/blit_compositor.py +0 -0
  47. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/contour_overlay.py +0 -0
  48. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/drawing_manager.py +0 -0
  49. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/image_layer.py +0 -0
  50. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/render.py +0 -0
  51. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/__init__.py +0 -0
  52. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/dose_manager.py +0 -0
  53. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/roi_editor.py +0 -0
  54. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/roi_manager.py +0 -0
  55. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/structure_set.py +0 -0
  56. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/viewer_cache.py +0 -0
  57. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/window_level.py +0 -0
  58. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_bbox3d.py +0 -0
  59. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_bbox3d_handler.py +0 -0
  60. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_event_controllers.py +0 -0
  61. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_geometry.py +0 -0
  62. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_io.py +0 -0
  63. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_isodose_levels.py +0 -0
  64. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_performance_opt.py +0 -0
  65. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_registration.py +0 -0
  66. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_render.py +0 -0
  67. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_rendering.py +0 -0
  68. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_roi_operations.py +0 -0
  69. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_state_extensions.py +0 -0
  70. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.3}/tests/test_state_managers.py +0 -0
@@ -12,9 +12,9 @@ jobs:
12
12
  # The suite is headless by design: no Tk display is available on CI.
13
13
  MPLBACKEND: Agg
14
14
  steps:
15
- - uses: actions/checkout@v4
15
+ - uses: actions/checkout@v5
16
16
 
17
- - uses: actions/setup-python@v5
17
+ - uses: actions/setup-python@v6
18
18
  with:
19
19
  python-version: "3.12"
20
20
  cache: pip
@@ -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.3.3]
8
+
9
+ ### Added
10
+
11
+ - `rtstruct_io.mask2rtstruct` and `rtstruct_io.save_structure_set` take a
12
+ keyword-only `add_roi_kwargs` mapping, forwarded to rt-utils'
13
+ `RTStruct.add_roi` for every ROI. Callers can now set
14
+ `approximate_contours`, `use_pin_hole`, `roi_generation_algorithm`,
15
+ `description`, etc. `mask`, `color` and `name` still come from the
16
+ structures and raise `ValueError` if given here. The default (`None`)
17
+ keeps the previous behaviour.
18
+
19
+ ## [2.3.2]
20
+
21
+ ### Fixed
22
+
23
+ - `reg_io.save_registration` wrote matrix values longer than the 16
24
+ characters the DS value representation allows (a rotation cosine prints as
25
+ 18-19), so strict readers rejected the file. Values are now formatted to
26
+ fit.
27
+ - A `set_secondary_image_data` call that raised (no primary image) left its
28
+ image behind as `secondary_source_image`.
29
+ - `mypy src/tk_rt_viewer` failed with Matplotlib 3.11 (two errors in
30
+ `rendering/view_axes.py` and `rendering/isodose.py`). Annotations only.
31
+
32
+ ### Changed
33
+
34
+ - `SliceViewerState.get_resampled_image` returns an image already on the
35
+ primary grid as-is when no transform is given. Switching between cached
36
+ 4DCT phases no longer resamples the volume again.
37
+ - `rtstruct_io.load_rt_struct` reads the RT-STRUCT file once instead of
38
+ twice, and skips a malformed `ROIContourSequence` item with a warning.
39
+ - Internal cleanup: `geometry.mask_bounds` replaces two copies of the mask
40
+ bounding-box code; duplicated series reading, scheduling and box-patch
41
+ code were merged.
42
+
7
43
  ## [2.3.1]
8
44
 
9
45
  A newly drawn 3-D box no longer spans the whole image along the axis that
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tk-rt-viewer
3
- Version: 2.3.1
3
+ Version: 2.3.3
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
@@ -374,6 +374,11 @@ written = save_structure_set(
374
374
  )
375
375
  ```
376
376
 
377
+ Pass `add_roi_kwargs` to forward options to rt-utils' `RTStruct.add_roi`
378
+ for every ROI, e.g. `add_roi_kwargs={"approximate_contours": False}` to
379
+ keep every contour vertex instead of the simplified outline. `mask2rtstruct`
380
+ accepts the same keyword.
381
+
377
382
  `rt_utils.RTStruct.save` silently appends `.dcm` to a path that does not
378
383
  already end with it, with no way to opt out; `mask2rtstruct` resolves
379
384
  against that internally so its `exists()` check, its log messages, and its
@@ -337,6 +337,11 @@ written = save_structure_set(
337
337
  )
338
338
  ```
339
339
 
340
+ Pass `add_roi_kwargs` to forward options to rt-utils' `RTStruct.add_roi`
341
+ for every ROI, e.g. `add_roi_kwargs={"approximate_contours": False}` to
342
+ keep every contour vertex instead of the simplified outline. `mask2rtstruct`
343
+ accepts the same keyword.
344
+
340
345
  `rt_utils.RTStruct.save` silently appends `.dcm` to a path that does not
341
346
  already end with it, with no way to opt out; `mask2rtstruct` resolves
342
347
  against that internally so its `exists()` check, its log messages, and its
@@ -104,7 +104,7 @@ __all__ = [
104
104
  "StructureSet",
105
105
  "to_gy_pairs",
106
106
  ]
107
- __version__ = "2.3.1"
107
+ __version__ = "2.3.3"
108
108
 
109
109
 
110
110
  def __getattr__(name: str) -> Any:
@@ -13,6 +13,8 @@ axis the user cannot see while drawing. Along an image narrower than
13
13
  :attr:`Bbox3dEventHandler.NARROW_EXTENT_MM` it is half the image instead.
14
14
  """
15
15
 
16
+ import SimpleITK as sitk
17
+
16
18
  from ..geometry import AXIS_TO_XYZ_DIM, Box3D, fit_box_length
17
19
  from .rect_drag import Rect, RectDragHandler
18
20
 
@@ -39,21 +41,17 @@ class Bbox3dEventHandler(RectDragHandler):
39
41
  self.state.set_bounding_box_3d(None)
40
42
 
41
43
  def _commit(self, axis: str, rect: Rect, mode: str) -> None:
42
- if mode == "create" and self.state.primary_image is not None:
43
- self.state.set_bounding_box_3d(self._new_box(axis, rect))
44
+ image = self.state.primary_image
45
+ if mode == "create" and image is not None:
46
+ self.state.set_bounding_box_3d(self._new_box(image, axis, rect))
44
47
  else:
45
48
  self.state.set_bbox_3d_from_view(axis, rect)
46
49
 
47
- def _new_box(self, axis: str, rect: Rect) -> Box3D:
50
+ def _new_box(self, image: sitk.Image, axis: str, rect: Rect) -> Box3D:
48
51
  """Return a box drawn as *rect* on *axis*, centred on the displayed slice."""
49
52
  dim = AXIS_TO_XYZ_DIM[axis]
50
53
  center = self.state.index_to_physical(axis, self.state.indices[axis])
51
- depth = fit_box_length(
52
- self.state.primary_image,
53
- dim,
54
- self.NEW_BOX_DEPTH_MM,
55
- self.NARROW_EXTENT_MM,
56
- )
54
+ depth = fit_box_length(image, dim, self.NEW_BOX_DEPTH_MM, self.NARROW_EXTENT_MM)
57
55
  base = Box3D(lower=(0.0, 0.0, 0.0), upper=(0.0, 0.0, 0.0))
58
56
  return base.with_range(
59
57
  dim, center - depth / 2.0, center + depth / 2.0
@@ -88,7 +88,7 @@ class BrushEventHandler:
88
88
  self.is_active = False
89
89
  self._cursor_ready = False
90
90
  self._abandon_stroke()
91
- self._remove_brush_cursor()
91
+ self.remove_cursor()
92
92
  self.viewer.refresh_canvas()
93
93
 
94
94
  def _abandon_stroke(self) -> None:
@@ -153,7 +153,7 @@ class BrushEventHandler:
153
153
  """Continue the stroke or update the brush cursor position."""
154
154
  if not self.is_active or not self._hover.current_axis:
155
155
  if self.brush_circle:
156
- self._remove_brush_cursor()
156
+ self.remove_cursor()
157
157
  return
158
158
 
159
159
  current = self._hover.current_axis
@@ -225,19 +225,19 @@ class BrushEventHandler:
225
225
  def _update_brush_cursor(self, event) -> None:
226
226
  """Create or reposition the circular brush cursor at the event location."""
227
227
  if self.viewer.toolbar_mode:
228
- self._remove_brush_cursor()
228
+ self.remove_cursor()
229
229
  return
230
230
 
231
231
  axis = self._hover.current_axis
232
232
  if not (axis and event.xdata is not None and event.ydata is not None):
233
- self._remove_brush_cursor()
233
+ self.remove_cursor()
234
234
  return
235
235
 
236
236
  if (
237
237
  not self.brush_circle
238
238
  or self.brush_circle.axes != self.viewer.axes_map[axis]
239
239
  ):
240
- self._remove_brush_cursor()
240
+ self.remove_cursor()
241
241
  roi_number = self.state.selected_roi_number
242
242
  color = (
243
243
  self.state.structure_set.get_color(roi_number)
@@ -259,10 +259,6 @@ class BrushEventHandler:
259
259
  self.viewer.request_redraw(axis)
260
260
 
261
261
  def remove_cursor(self) -> None:
262
- """Remove the brush cursor circle from the canvas."""
263
- self._remove_brush_cursor()
264
-
265
- def _remove_brush_cursor(self) -> None:
266
262
  """Remove the brush cursor circle from the canvas."""
267
263
  if not self.brush_circle:
268
264
  return
@@ -131,6 +131,4 @@ class CrosshairEventHandler:
131
131
  def handle_release(self, event) -> None:
132
132
  """End the crosshair drag on left-button release."""
133
133
  if event.button == 1:
134
- self._is_dragging = False
135
- self._active_axis = None
136
- self._drag_target = None
134
+ self.cancel()
@@ -48,8 +48,8 @@ ZOOM_STEP: float = 1.25
48
48
  #: immediate, long enough to coalesce a wheel flick.
49
49
  SCROLL_DEBOUNCE_MS: int = 30
50
50
 
51
- # Slice step for PageUp / PageDown (Up / Down move by 1)
52
- _PAGE_STEP: int = 10
51
+ # Slice steps per navigation key (PageUp / PageDown move by 10)
52
+ _KEY_STEPS: dict[str, int] = {"up": 1, "down": -1, "pageup": 10, "pagedown": -10}
53
53
 
54
54
  # Right mouse button drives the window/level drag
55
55
  _WL_BUTTON: int = 3
@@ -418,8 +418,7 @@ class ViewerEventHandler:
418
418
  axis = self._current_axis
419
419
  if not axis or self.state.primary_image is None:
420
420
  return
421
- deltas = {"up": 1, "down": -1, "pageup": _PAGE_STEP, "pagedown": -_PAGE_STEP}
422
- delta = deltas.get(event.key)
421
+ delta = _KEY_STEPS.get(event.key)
423
422
  if delta is None:
424
423
  return
425
424
  current = self.state.indices[axis]
@@ -216,6 +216,27 @@ def fit_box_length(
216
216
  return extent / 2.0 if extent < min_extent else length
217
217
 
218
218
 
219
+ def mask_bounds(
220
+ mask: np.ndarray, pad_voxels: tuple[int, int, int] = (0, 0, 0)
221
+ ) -> tuple[slice, slice, slice] | None:
222
+ """Return the bounding box of a ``(z, y, x)`` mask grown by *pad_voxels*.
223
+
224
+ Clamped to the array; ``None`` when the mask is empty. Lets whole-volume
225
+ work (a distance transform, a DVH) run on the usually small region the
226
+ mask occupies.
227
+ """
228
+ occupied = [
229
+ np.flatnonzero(mask.any(axis=axes)) for axes in ((1, 2), (0, 2), (0, 1))
230
+ ]
231
+ if any(indices.size == 0 for indices in occupied):
232
+ return None
233
+ z, y, x = (
234
+ slice(max(0, int(indices[0]) - pad), min(extent, int(indices[-1]) + pad + 1))
235
+ for indices, pad, extent in zip(occupied, pad_voxels, mask.shape, strict=True)
236
+ )
237
+ return z, y, x
238
+
239
+
219
240
  def view_dims(axis: str) -> tuple[int, int]:
220
241
  """Return the physical dimensions plotted on *axis*' x and y axes.
221
242
 
@@ -55,18 +55,18 @@ import re
55
55
  from collections.abc import Iterable, Iterator, Sequence
56
56
  from concurrent.futures import ThreadPoolExecutor
57
57
  from dataclasses import dataclass, field, replace
58
- from typing import TypedDict
58
+ from typing import TypedDict, cast
59
59
 
60
60
  import numpy as np
61
61
  import pydicom
62
62
  import SimpleITK as sitk
63
63
  from pydicom.errors import InvalidDicomError
64
64
 
65
+ from .reg_io import SPATIAL_REGISTRATION_SOP_CLASS_UID
65
66
  from .window_level import compute_auto_window_level
66
67
 
67
68
  logger = logging.getLogger(__name__)
68
69
 
69
- _SPATIAL_REGISTRATION_UID = "1.2.840.10008.5.1.4.1.1.66.1"
70
70
  _CT_IMAGE_STORAGE_UID = "1.2.840.10008.5.1.4.1.1.2"
71
71
  _PHASE_LABEL_PATTERN = re.compile(r"\d+%")
72
72
 
@@ -248,7 +248,10 @@ def _scan_dicom_tree(dcm_root_dir: str | pathlib.Path) -> _ScanResult:
248
248
  if series_uid is not None and modality:
249
249
  scan.modality_by_series[str(series_uid)] = modality
250
250
 
251
- if modality == "REG" and ds.get("SOPClassUID", "") == _SPATIAL_REGISTRATION_UID:
251
+ if (
252
+ modality == "REG"
253
+ and ds.get("SOPClassUID", "") == SPATIAL_REGISTRATION_SOP_CLASS_UID
254
+ ):
252
255
  _collect_reg_matrices(ds, file, scan.reg_matrices, reg_series_refs)
253
256
 
254
257
  # A REG object may name only one instance of the moving series; resolve
@@ -661,18 +664,17 @@ def select_phase_series(
661
664
  # ---------------------------------------------------------------------------
662
665
  # Internal helpers
663
666
  # ---------------------------------------------------------------------------
664
- def _read_series(
667
+ def _read_files(
665
668
  reader: sitk.ImageSeriesReader,
666
- dcm_dir: pathlib.Path,
669
+ file_names: Sequence[str],
667
670
  series_id: str,
668
- ) -> tuple[sitk.Image, tuple[str, ...]]:
669
- """Load *series_id* from *dcm_dir* and return ``(image, file_names)``."""
670
- file_names = reader.GetGDCMSeriesFileNames(str(dcm_dir), series_id)
671
+ ) -> sitk.Image:
672
+ """Read *file_names* as one series, keeping per-slice metadata on *reader*."""
671
673
  reader.SetFileNames(file_names)
672
674
  reader.MetaDataDictionaryArrayUpdateOn()
673
- image = reader.Execute()
675
+ image = cast(sitk.Image, reader.Execute())
674
676
  logger.info(f"Series '{series_id}' loaded with {len(file_names)} files.")
675
- return image, file_names
677
+ return image
676
678
 
677
679
 
678
680
  def _axis_aligned_grid(
@@ -887,7 +889,7 @@ def _read_reg_files(
887
889
  except Exception as exc:
888
890
  logger.warning(f"Skipping unreadable REG file '{file}': {exc}")
889
891
  continue
890
- if ds.get("SOPClassUID", "") == _SPATIAL_REGISTRATION_UID:
892
+ if ds.get("SOPClassUID", "") == SPATIAL_REGISTRATION_SOP_CLASS_UID:
891
893
  _collect_reg_matrices(ds, pathlib.Path(file), by_sop, by_series)
892
894
  return by_sop, by_series
893
895
 
@@ -944,7 +946,8 @@ def _load_all_series_impl(
944
946
  if modality.upper() in NON_IMAGE_MODALITIES:
945
947
  logger.info(f"Skipping {modality} series '{sid}' (not an image).")
946
948
  continue
947
- raw_image, file_names = _read_series(reader, dcm_dir, sid)
949
+ file_names = reader.GetGDCMSeriesFileNames(str(dcm_dir), sid)
950
+ raw_image = _read_files(reader, file_names, sid)
948
951
  description, info = _build_series_info(
949
952
  reader, raw_image, _find_reg_matrix(scan, file_names, sid), sid
950
953
  )
@@ -1133,12 +1136,8 @@ def _load_scanned_series(
1133
1136
  f"'{entry.series_dir}'."
1134
1137
  )
1135
1138
 
1136
- reader.SetFileNames(file_names)
1137
- reader.MetaDataDictionaryArrayUpdateOn()
1138
- raw_image = reader.Execute()
1139
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
-
1140
+ raw_image = _read_files(reader, file_names, series_id)
1142
1141
  reg_matrix = _match_reg_matrix(
1143
1142
  reader, len(file_names), entry.series_uid, reg_matrices
1144
1143
  )
@@ -23,6 +23,7 @@ import numpy as np
23
23
  import SimpleITK as sitk
24
24
  from pydicom.dataset import Dataset, FileDataset, FileMetaDataset
25
25
  from pydicom.uid import UID, ExplicitVRLittleEndian, generate_uid
26
+ from pydicom.valuerep import DSfloat
26
27
 
27
28
  logger = logging.getLogger(__name__)
28
29
 
@@ -260,8 +261,10 @@ def _registration_item(
260
261
  """Build one item of the Registration Sequence."""
261
262
  matrix_item = Dataset()
262
263
  matrix_item.FrameOfReferenceTransformationMatrixType = "RIGID"
264
+ # DS values are limited to 16 characters; a plain float such as a rotation
265
+ # cosine prints 18-19, which strict readers reject
263
266
  matrix_item.FrameOfReferenceTransformationMatrix = [
264
- float(value) for value in matrix.reshape(16)
267
+ DSfloat(float(value), auto_format=True) for value in matrix.reshape(16)
265
268
  ]
266
269
 
267
270
  matrix_registration = Dataset()
@@ -140,19 +140,17 @@ def params_from_resample_transform(
140
140
  center_array = np.array(center)
141
141
 
142
142
  # Re-express T(p) = R p + offset about the requested centre, i.e. solve
143
- # R (p - c) + c + t = R p + offset for t.
144
- recentred = sitk.Euler3DTransform()
145
- recentred.SetCenter(center)
146
- recentred.SetMatrix(matrix.ravel().tolist())
143
+ # R (p - c) + c + t = R p + offset for t. The angles depend on R alone,
144
+ # so they are read from the motion transform as it is
147
145
  translation = offset + matrix @ center_array - center_array
148
146
 
149
147
  return RigidParams(
150
148
  vert=float(translation[1]),
151
149
  lat=float(translation[0]),
152
150
  long=float(translation[2]),
153
- roll=math.degrees(recentred.GetAngleZ()),
154
- pitch=math.degrees(recentred.GetAngleX()),
155
- yaw=math.degrees(recentred.GetAngleY()),
151
+ roll=math.degrees(motion.GetAngleZ()),
152
+ pitch=math.degrees(motion.GetAngleX()),
153
+ yaw=math.degrees(motion.GetAngleY()),
156
154
  )
157
155
 
158
156
 
@@ -11,6 +11,8 @@ import numpy as np
11
11
  import SimpleITK as sitk
12
12
  from matplotlib.axes import Axes
13
13
 
14
+ from ..geometry import mask_bounds
15
+
14
16
  if TYPE_CHECKING:
15
17
  from ..state.viewer_state import SliceViewerState
16
18
 
@@ -39,14 +41,9 @@ class DvhPanel:
39
41
  Cropping to the mask's bounding box first avoids scanning the whole
40
42
  volume for a typically small ROI.
41
43
  """
42
- occupied = [
43
- np.flatnonzero(mask_arr.any(axis=axes)) for axes in ((1, 2), (0, 2), (0, 1))
44
- ]
45
- if any(indices.size == 0 for indices in occupied):
44
+ box = mask_bounds(mask_arr)
45
+ if box is None:
46
46
  return np.empty(0, dtype=dose_arr.dtype)
47
- box = tuple(
48
- slice(int(indices[0]), int(indices[-1]) + 1) for indices in occupied
49
- )
50
47
  mask_box = mask_arr[box]
51
48
  return np.asarray(dose_arr[box][mask_box != 0])
52
49
 
@@ -270,7 +270,7 @@ class IsoDoseOverlay:
270
270
  zorder=2,
271
271
  )
272
272
  # Per-instance override (documented Matplotlib pattern)
273
- fill.format_cursor_data = _format_fill_cursor_data # type: ignore[method-assign,assignment]
273
+ fill.format_cursor_data = _format_fill_cursor_data # type: ignore[method-assign,assignment,unused-ignore]
274
274
  self._fill[axis] = fill
275
275
  self._on_artists_changed(axis)
276
276
  return
@@ -1,6 +1,7 @@
1
1
  """layout.py — Builds the Matplotlib Axes for each layout mode."""
2
2
 
3
3
  from collections.abc import Callable
4
+ from typing import cast
4
5
 
5
6
  import matplotlib.gridspec as gridspec
6
7
  from matplotlib.axes import Axes
@@ -50,6 +51,7 @@ class LayoutManager:
50
51
  For ``"single"``, ``axs`` is ``{"axial": Axes}``. ``dvh_ax`` is
51
52
  ``None`` except for ``"mpr"``.
52
53
  """
54
+ axs: dict[str, Axes]
53
55
  if mode == "single":
54
56
  axs = {"axial": self._add_view(111)}
55
57
  dvh_ax = None
@@ -76,12 +78,20 @@ class LayoutManager:
76
78
  )
77
79
 
78
80
  for ax in axs.values():
79
- ax.set_facecolor("black")
80
- ax.tick_params(colors="white")
81
- ax.set_axis_off()
81
+ self.style_view(ax)
82
82
 
83
83
  return axs, dvh_ax
84
84
 
85
+ @staticmethod
86
+ def style_view(ax: Axes) -> None:
87
+ """Apply the image-view styling (also after ``Axes.clear()``)."""
88
+ ax.set_facecolor("black")
89
+ ax.tick_params(colors="white")
90
+ ax.set_axis_off()
91
+
85
92
  def _add_view(self, spec) -> ImageViewAxes:
86
93
  """Add an image view in the grid cell *spec*."""
87
- return self._fig.add_subplot(spec, axes_class=ImageViewAxes)
94
+ # add_subplot is typed as returning Axes, whatever axes_class is
95
+ return cast(
96
+ ImageViewAxes, self._fig.add_subplot(spec, axes_class=ImageViewAxes)
97
+ )
@@ -109,7 +109,9 @@ class ImageViewAxes(Axes):
109
109
  resized = (
110
110
  previous is not None and previous[:2] == limits and previous[2] != ratio
111
111
  )
112
- old_fit = self._fit_spans(previous[2]) if resized else None
112
+ old_fit = (
113
+ self._fit_spans(previous[2]) if resized and previous is not None else None
114
+ )
113
115
  new_fit = self._fit_spans(ratio)
114
116
  if old_fit is not None and new_fit is not None:
115
117
  zoom = old_fit[0] / span_x
@@ -140,7 +142,7 @@ class ImageViewAxes(Axes):
140
142
  width, height = self.bbox.width, self.bbox.height
141
143
  if width <= 0 or height <= 0:
142
144
  return None
143
- return height / width
145
+ return float(height / width)
144
146
 
145
147
  def _fit_spans(self, ratio: float | None) -> tuple[float, float] | None:
146
148
  """Return the spans showing the whole slice in a box of *ratio*."""
@@ -14,13 +14,14 @@ result keeps the input's origin / spacing / direction.
14
14
  import logging
15
15
  from dataclasses import dataclass
16
16
  from enum import Enum, auto
17
+ from itertools import pairwise
17
18
 
18
19
  import numpy as np
19
20
  import SimpleITK as sitk
20
21
  from scipy.ndimage import distance_transform_edt, gaussian_filter
21
22
  from scipy.ndimage import shift as ndshift
22
23
 
23
- from .geometry import resample_binary_mask
24
+ from .geometry import mask_bounds, resample_binary_mask
24
25
 
25
26
  logger = logging.getLogger(__name__)
26
27
 
@@ -209,7 +210,7 @@ def interpolate_contour(mask_image: sitk.Image) -> sitk.Image:
209
210
 
210
211
  result = binary.copy()
211
212
  n_filled = 0
212
- for prev_z, next_z in zip(nonempty, nonempty[1:], strict=False):
213
+ for prev_z, next_z in pairwise(nonempty):
213
214
  gap = next_z - prev_z
214
215
  if gap <= 1:
215
216
  continue
@@ -258,27 +259,6 @@ def _margin_sampling(
258
259
  return (scaled[2], scaled[1], scaled[0])
259
260
 
260
261
 
261
- def _margin_bounds(
262
- mask: np.ndarray, pad_voxels: tuple[int, int, int]
263
- ) -> tuple[slice, slice, slice] | None:
264
- """Return the mask's bounding box grown by *pad_voxels*, or ``None`` if empty.
265
-
266
- The distance transform only needs to run inside the region the margin
267
- can reach, which is usually a small fraction of the grid.
268
- """
269
- occupied = [
270
- np.flatnonzero(mask.any(axis=axes)) for axes in ((1, 2), (0, 2), (0, 1))
271
- ]
272
- if any(indices.size == 0 for indices in occupied):
273
- return None
274
- bounds = []
275
- for indices, pad, extent in zip(occupied, pad_voxels, mask.shape, strict=True):
276
- lo = max(0, int(indices[0]) - pad)
277
- hi = min(extent, int(indices[-1]) + pad + 1)
278
- bounds.append(slice(lo, hi))
279
- return (bounds[0], bounds[1], bounds[2])
280
-
281
-
282
262
  def apply_margin(mask_image: sitk.Image, config: MarginConfig) -> sitk.Image:
283
263
  """Apply a true Euclidean margin to a binary mask.
284
264
 
@@ -310,14 +290,15 @@ def apply_margin(mask_image: sitk.Image, config: MarginConfig) -> sitk.Image:
310
290
  mask = sitk.GetArrayViewFromImage(mask_image).astype(bool) # (z, y, x)
311
291
  result = np.zeros_like(mask)
312
292
 
313
- # Only a dilation can reach outside the mask's bounding box
293
+ # The distance transform only runs where the margin can reach; only a
294
+ # dilation reaches outside the mask's bounding box
314
295
  reach_mm = [radius + abs(off) for radius, off in zip(radii, offset, strict=True)]
315
296
  pad_voxels = (
316
297
  int(np.ceil(reach_mm[2] / spacing[2])) + 2 if expand else 2,
317
298
  int(np.ceil(reach_mm[1] / spacing[1])) + 2 if expand else 2,
318
299
  int(np.ceil(reach_mm[0] / spacing[0])) + 2 if expand else 2,
319
300
  )
320
- bounds = _margin_bounds(mask, pad_voxels)
301
+ bounds = mask_bounds(mask, pad_voxels)
321
302
  if bounds is None:
322
303
  logger.warning("Margin skipped: the mask is empty.")
323
304
  return mask_image