tk-rt-viewer 2.3.1__tar.gz → 2.3.2__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.2}/.github/workflows/ci.yml +2 -2
  2. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/CHANGELOG.md +24 -0
  3. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/PKG-INFO +1 -1
  4. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/__init__.py +1 -1
  5. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/bbox3d_handler.py +7 -9
  6. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/brush_handler.py +5 -9
  7. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/crosshair_handler.py +1 -3
  8. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/viewer_events.py +3 -4
  9. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/geometry.py +21 -0
  10. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/io.py +14 -15
  11. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/reg_io.py +4 -1
  12. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/params.py +5 -7
  13. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/dvh.py +4 -7
  14. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/isodose.py +1 -1
  15. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/layout.py +14 -4
  16. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/view_axes.py +4 -2
  17. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/roi_operations.py +6 -25
  18. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rtstruct_io.py +11 -5
  19. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/phase_manager.py +1 -4
  20. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/secondary_manager.py +13 -3
  21. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/viewer_state.py +16 -0
  22. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/viewer.py +51 -78
  23. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_brush_handler.py +2 -2
  24. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_reg_io.py +23 -0
  25. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_rtstruct_io.py +12 -6
  26. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_viewer_state.py +35 -0
  27. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/.gitattributes +0 -0
  28. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/.gitignore +0 -0
  29. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/LICENSE +0 -0
  30. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/README.md +0 -0
  31. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/pyproject.toml +0 -0
  32. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/__init__.py +0 -0
  33. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/bbox_handler.py +0 -0
  34. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/rect_drag.py +0 -0
  35. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/events.py +0 -0
  36. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/isodose_levels.py +0 -0
  37. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/protocols.py +0 -0
  38. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/py.typed +0 -0
  39. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/__init__.py +0 -0
  40. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/deformable.py +0 -0
  41. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/errors.py +0 -0
  42. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/rigid.py +0 -0
  43. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/session.py +0 -0
  44. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/template.py +0 -0
  45. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/__init__.py +0 -0
  46. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/blit_compositor.py +0 -0
  47. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/contour_overlay.py +0 -0
  48. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/drawing_manager.py +0 -0
  49. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/image_layer.py +0 -0
  50. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/render.py +0 -0
  51. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/__init__.py +0 -0
  52. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/dose_manager.py +0 -0
  53. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/roi_editor.py +0 -0
  54. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/roi_manager.py +0 -0
  55. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/structure_set.py +0 -0
  56. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/viewer_cache.py +0 -0
  57. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/window_level.py +0 -0
  58. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_bbox3d.py +0 -0
  59. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_bbox3d_handler.py +0 -0
  60. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_event_controllers.py +0 -0
  61. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_geometry.py +0 -0
  62. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_io.py +0 -0
  63. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_isodose_levels.py +0 -0
  64. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_performance_opt.py +0 -0
  65. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_registration.py +0 -0
  66. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_render.py +0 -0
  67. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_rendering.py +0 -0
  68. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_roi_operations.py +0 -0
  69. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/tests/test_state_extensions.py +0 -0
  70. {tk_rt_viewer-2.3.1 → tk_rt_viewer-2.3.2}/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,30 @@ 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.2]
8
+
9
+ ### Fixed
10
+
11
+ - `reg_io.save_registration` wrote matrix values longer than the 16
12
+ characters the DS value representation allows (a rotation cosine prints as
13
+ 18-19), so strict readers rejected the file. Values are now formatted to
14
+ fit.
15
+ - A `set_secondary_image_data` call that raised (no primary image) left its
16
+ image behind as `secondary_source_image`.
17
+ - `mypy src/tk_rt_viewer` failed with Matplotlib 3.11 (two errors in
18
+ `rendering/view_axes.py` and `rendering/isodose.py`). Annotations only.
19
+
20
+ ### Changed
21
+
22
+ - `SliceViewerState.get_resampled_image` returns an image already on the
23
+ primary grid as-is when no transform is given. Switching between cached
24
+ 4DCT phases no longer resamples the volume again.
25
+ - `rtstruct_io.load_rt_struct` reads the RT-STRUCT file once instead of
26
+ twice, and skips a malformed `ROIContourSequence` item with a warning.
27
+ - Internal cleanup: `geometry.mask_bounds` replaces two copies of the mask
28
+ bounding-box code; duplicated series reading, scheduling and box-patch
29
+ code were merged.
30
+
7
31
  ## [2.3.1]
8
32
 
9
33
  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.2
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
@@ -104,7 +104,7 @@ __all__ = [
104
104
  "StructureSet",
105
105
  "to_gy_pairs",
106
106
  ]
107
- __version__ = "2.3.1"
107
+ __version__ = "2.3.2"
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
 
@@ -62,11 +62,11 @@ 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
675
  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
@@ -34,7 +34,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
34
34
  from typing import TYPE_CHECKING, Any, TypedDict
35
35
 
36
36
  import numpy as np
37
- import pydicom
38
37
  import SimpleITK as sitk
39
38
  from rt_utils import RTStructBuilder
40
39
 
@@ -213,7 +212,9 @@ def load_rt_struct(
213
212
  dicom_series_path=str(ct_dir),
214
213
  rt_struct_path=str(rtstruct_path),
215
214
  )
216
- ds = pydicom.dcmread(str(rtstruct_path))
215
+ # rtstruct.ds is the file already parsed by rt-utils; reading it again
216
+ # would double the I/O for a large structure set
217
+ ds = rtstruct.ds
217
218
  # ROIContourSequence is conditional: a structure set without contours
218
219
  # may omit it, which must yield {} rather than AttributeError
219
220
  roi_name_map: dict[int, str] = {
@@ -228,9 +229,14 @@ def load_rt_struct(
228
229
 
229
230
  roi_tasks: list[tuple[int, str, str]] = []
230
231
  for roi_contour in roi_contours:
231
- roi_number = int(roi_contour.ReferencedROINumber)
232
+ # One malformed item is skipped, like an ROI whose mask cannot be built
233
+ try:
234
+ roi_number = int(roi_contour.ReferencedROINumber)
235
+ color_hex = _extract_roi_color(roi_contour)
236
+ except (AttributeError, TypeError, ValueError) as exc:
237
+ logger.warning(f"Skipping a malformed ROIContourSequence item: {exc}")
238
+ continue
232
239
  roi_name = roi_name_map.get(roi_number, f"ROI_{roi_number}")
233
- color_hex = _extract_roi_color(roi_contour)
234
240
  roi_tasks.append((roi_number, roi_name, color_hex))
235
241
 
236
242
  if not roi_tasks:
@@ -254,7 +260,7 @@ def load_rt_struct(
254
260
  f"{sorted(duplicate_names)}; resolving each by ROINumber instead "
255
261
  "of name to avoid every same-named ROI receiving the same mask."
256
262
  )
257
- for roi in rtstruct.ds.StructureSetROISequence:
263
+ for roi in ds.StructureSetROISequence:
258
264
  if roi.ROIName in duplicate_names:
259
265
  unique_name = f"__tk_rt_viewer_load_tmp_{roi.ROINumber}__"
260
266
  lookup_name_by_number[int(roi.ROINumber)] = unique_name
@@ -83,10 +83,7 @@ class PhaseManager:
83
83
 
84
84
  def clear(self) -> None:
85
85
  """Drop all phases, the resampled cache, and the current-phase marker."""
86
- self._phases = {}
87
- self._rebuild_view()
88
- self._resampled.clear()
89
- self._current_phase = None
86
+ self.set_all({})
90
87
 
91
88
  def _rebuild_view(self) -> None:
92
89
  """Refresh the read-only view after :attr:`_phases` has been replaced."""
@@ -73,12 +73,22 @@ class SecondaryManager:
73
73
 
74
74
  Returns:
75
75
  The resampled image, or ``None`` when *image* is ``None``.
76
+
77
+ Raises:
78
+ Whatever the resample callable raises (e.g. ``RuntimeError``
79
+ without a primary image); the stored source is then unchanged.
76
80
  """
81
+ fill_value = float(fill_value)
82
+ # Resampled before anything is stored, so a failure leaves no source
83
+ # behind that the displayed image does not come from
84
+ resampled = (
85
+ None if image is None else self._resample(image, transform, fill_value)
86
+ )
77
87
  self._source = image
78
88
  self._transform = transform
79
- self._fill_value = float(fill_value)
80
- self._resampled = None if image is None else self.resample_with(transform)
81
- return self._resampled
89
+ self._fill_value = fill_value
90
+ self._resampled = resampled
91
+ return resampled
82
92
 
83
93
  def set_transform(
84
94
  self, transform: sitk.Transform | None, resampled: sitk.Image | None = None
@@ -97,6 +97,16 @@ WINDOW_LEVEL_TARGETS: tuple[str, ...] = ("primary", "secondary")
97
97
  MIN_BRUSH_SIZE_MM: float = 0.1
98
98
 
99
99
 
100
+ def _same_grid(image: sitk.Image, reference: sitk.Image) -> bool:
101
+ """Return whether *image* has exactly *reference*'s voxel grid."""
102
+ return bool(
103
+ image.GetSize() == reference.GetSize()
104
+ and image.GetOrigin() == reference.GetOrigin()
105
+ and image.GetSpacing() == reference.GetSpacing()
106
+ and image.GetDirection() == reference.GetDirection()
107
+ )
108
+
109
+
100
110
  @dataclass(eq=False)
101
111
  class SliceViewerState:
102
112
  """State container for the 3-plane DICOM viewer.
@@ -561,6 +571,10 @@ class SliceViewerState:
561
571
  ) -> sitk.Image:
562
572
  """Resample *image* onto the primary image grid (linear interpolation).
563
573
 
574
+ An *image* already on the primary grid is returned as-is when no
575
+ *transform* is given: an identity resample would only copy it. That
576
+ is the case for every 4DCT phase leaving the LRU cache.
577
+
564
578
  Args:
565
579
  image: The source image.
566
580
  transform: Maps primary-grid points into *image* (a registration
@@ -576,6 +590,8 @@ class SliceViewerState:
576
590
  "Cannot resample: no primary image is loaded, so there is no "
577
591
  "reference grid to resample onto."
578
592
  )
593
+ if transform is None and _same_grid(image, self.primary_image):
594
+ return image
579
595
  resample = sitk.ResampleImageFilter()
580
596
  resample.SetReferenceImage(self.primary_image)
581
597
  resample.SetInterpolator(sitk.sitkLinear)
@@ -233,7 +233,7 @@ class DicomViewer(ttk.Frame):
233
233
  self.viewer_state, on_artists_changed=self._compositor.invalidate
234
234
  )
235
235
  self._contour_redraws = ContourRedrawCoalescer(
236
- schedule=self._schedule_from_worker,
236
+ schedule=self.schedule,
237
237
  cancel=self._safe_after_cancel,
238
238
  redraw=self._update_all_contours,
239
239
  active_rois=lambda: self.viewer_state.active_contours,
@@ -331,8 +331,11 @@ class DicomViewer(ttk.Frame):
331
331
  def schedule(self, delay_ms: int, callback: Callable[[], None]) -> str | None:
332
332
  """Run *callback* after *delay_ms*, returning a cancellation handle.
333
333
 
334
- Returns ``None`` when no Tk event loop is available, so a caller can
335
- fall back to acting immediately instead of losing the work.
334
+ Returns ``None`` when no Tk event loop is available (or the widget is
335
+ gone), so a caller can fall back to acting immediately instead of
336
+ losing the work. Also called from the contour-build workers: ``after``
337
+ is thread-safe with a threaded Tcl (CPython's default; see the
338
+ README's "Threading model").
336
339
  """
337
340
  try:
338
341
  return self.after(delay_ms, callback)
@@ -526,9 +529,7 @@ class DicomViewer(ttk.Frame):
526
529
  """
527
530
  for ax in self.axs.values():
528
531
  ax.clear()
529
- ax.set_facecolor("black")
530
- ax.tick_params(colors="white")
531
- ax.set_axis_off()
532
+ LayoutManager.style_view(ax)
532
533
  self.image_layer.reset()
533
534
  self.isodose.reset()
534
535
  self.contours.reset()
@@ -628,35 +629,9 @@ class DicomViewer(ttk.Frame):
628
629
  return
629
630
  patch = self.bbox_patches[axis]
630
631
  if patch is None:
631
- patch = Rectangle(
632
- (0, 0),
633
- 0,
634
- 0,
635
- linewidth=1.0,
636
- edgecolor="red",
637
- facecolor="none",
638
- visible=False,
639
- )
640
- ax.add_patch(patch)
641
- self.bbox_patches[axis] = patch
642
- self._compositor.invalidate(axis)
643
-
644
- if bbox is None or not self.viewer_state.bbox_visible:
645
- if patch.get_visible():
646
- patch.set_visible(False)
647
- self._compositor.invalidate(axis)
648
- patch.set_xy((0, 0))
649
- patch.set_width(0)
650
- patch.set_height(0)
651
- else:
652
- x, y, w, h = bbox
653
- patch.set_xy((x, y))
654
- patch.set_width(w)
655
- patch.set_height(h)
656
- if not patch.get_visible():
657
- patch.set_visible(True)
658
- self._compositor.invalidate(axis)
659
- self.drawing_manager.add_request(axis)
632
+ patch = self.bbox_patches[axis] = self._add_box_patch(axis, ax)
633
+ rect = bbox if self.viewer_state.bbox_visible else None
634
+ self._place_box_patch(axis, patch, rect)
660
635
 
661
636
  def _on_bounding_box_3d_changed(self, box: Box3D | None) -> None:
662
637
  """Redraw every view's projection of the volumetric bounding box."""
@@ -681,35 +656,48 @@ class DicomViewer(ttk.Frame):
681
656
 
682
657
  patch = self.bbox_3d_patches[axis]
683
658
  if patch is None:
684
- patch = Rectangle(
685
- (0, 0),
686
- 0,
687
- 0,
688
- linewidth=1.0,
689
- edgecolor="red",
690
- facecolor="none",
691
- visible=False,
692
- )
693
- ax.add_patch(patch)
694
- self.bbox_3d_patches[axis] = patch
695
- self._compositor.invalidate(axis)
696
-
659
+ patch = self.bbox_3d_patches[axis] = self._add_box_patch(axis, ax)
697
660
  if box is None or not self.viewer_state.bbox_3d_visible:
698
- if patch.get_visible():
699
- patch.set_visible(False)
700
- self._compositor.invalidate(axis)
701
- patch.set_bounds(0, 0, 0, 0)
702
- else:
703
- patch.set_bounds(*box.project(axis))
704
- slice_coord = self.viewer_state.index_to_physical(
705
- axis, self.viewer_state.indices[axis]
706
- )
707
- patch.set_linestyle(
708
- "solid" if box.contains_coordinate(axis, slice_coord) else "dashed"
709
- )
710
- if not patch.get_visible():
711
- patch.set_visible(True)
712
- self._compositor.invalidate(axis)
661
+ self._place_box_patch(axis, patch, None)
662
+ return
663
+ slice_coord = self.viewer_state.index_to_physical(
664
+ axis, self.viewer_state.indices[axis]
665
+ )
666
+ patch.set_linestyle(
667
+ "solid" if box.contains_coordinate(axis, slice_coord) else "dashed"
668
+ )
669
+ self._place_box_patch(axis, patch, box.project(axis))
670
+
671
+ def _add_box_patch(self, axis: str, ax: Axes) -> Rectangle:
672
+ """Add a hidden box outline to *ax* and invalidate its artist list."""
673
+ patch = Rectangle(
674
+ (0, 0),
675
+ 0,
676
+ 0,
677
+ linewidth=1.0,
678
+ edgecolor="red",
679
+ facecolor="none",
680
+ visible=False,
681
+ )
682
+ ax.add_patch(patch)
683
+ self._compositor.invalidate(axis)
684
+ return patch
685
+
686
+ def _place_box_patch(
687
+ self,
688
+ axis: str,
689
+ patch: Rectangle,
690
+ rect: tuple[float, float, float, float] | None,
691
+ ) -> None:
692
+ """Move *patch* onto *rect* ``(x, y, width, height)``, or hide it for ``None``.
693
+
694
+ The artist list is invalidated only when the visibility changes.
695
+ """
696
+ visible = rect is not None
697
+ patch.set_bounds(*(rect if rect is not None else (0, 0, 0, 0)))
698
+ if patch.get_visible() != visible:
699
+ patch.set_visible(visible)
700
+ self._compositor.invalidate(axis)
713
701
  self.drawing_manager.add_request(axis)
714
702
 
715
703
  def _on_all_contours_changed(self, structure_set) -> None:
@@ -730,21 +718,6 @@ class DicomViewer(ttk.Frame):
730
718
  """
731
719
  self._contour_redraws.notify_built(roi_number)
732
720
 
733
- def _schedule_from_worker(
734
- self, delay_ms: int, callback: Callable[[], None]
735
- ) -> str | None:
736
- """Schedule *callback* on the Tk main loop from a worker thread.
737
-
738
- ``after`` is thread-safe with a threaded Tcl (CPython's default; see
739
- the README's "Threading model").
740
- """
741
- try:
742
- return self.after(delay_ms, callback)
743
- except (RuntimeError, tk.TclError):
744
- # The main loop has exited or the widget is gone: nothing to redraw
745
- logger.debug("Contour cache built after teardown; redraw skipped.")
746
- return None
747
-
748
721
  def _on_rt_dose_changed(self, image) -> None:
749
722
  """Update the dose overlay and DVH panel when the RT-DOSE changes."""
750
723
  self._update_blend_slider_visibility()
@@ -239,7 +239,7 @@ class TestResetAfterAxesCleared:
239
239
  ax.clear() # Mirrors DicomViewer._reset_artists()
240
240
 
241
241
  try:
242
- handler._remove_brush_cursor()
242
+ handler.remove_cursor()
243
243
  except NotImplementedError:
244
244
  pass
245
245
  else:
@@ -270,7 +270,7 @@ class TestResetAfterAxesCleared:
270
270
  assert handler.brush_circle is None
271
271
 
272
272
  # Must not raise: no stale artist reference remains.
273
- handler._remove_brush_cursor()
273
+ handler.remove_cursor()
274
274
  handler.handle_motion(_Event(xdata=1.0, ydata=1.0))
275
275
  handler.remove_cursor()
276
276
  finally:
@@ -116,6 +116,29 @@ class TestSaveRegistration:
116
116
  moving.SeriesInstanceUID,
117
117
  ]
118
118
 
119
+ def test_matrix_values_fit_the_ds_length_limit(self, tmp_path) -> None:
120
+ """A rotation's cosines print as 18-19 characters; DS allows 16."""
121
+ matrix = transform_to_matrix(
122
+ motion_transform(
123
+ RigidParams(lat=1.23456789, roll=7.3, pitch=-2.9, yaw=11.1),
124
+ center=(0.0, 0.0, 0.0),
125
+ )
126
+ )
127
+ path = tmp_path / "reg.dcm"
128
+ save_registration(path, matrix, make_reference(), make_reference("1.2.9"))
129
+
130
+ ds = pydicom.dcmread(path)
131
+ values = (
132
+ ds.RegistrationSequence[1]
133
+ .MatrixRegistrationSequence[0]
134
+ .MatrixSequence[0]
135
+ .FrameOfReferenceTransformationMatrix
136
+ )
137
+ assert all(len(str(value)) <= 16 for value in values)
138
+ assert np.array(values, dtype=float).reshape(4, 4) == pytest.approx(
139
+ matrix, abs=1e-12
140
+ )
141
+
119
142
  def test_rejects_a_reflection(self, tmp_path) -> None:
120
143
  matrix = np.eye(4)
121
144
  matrix[0, 0] = -1.0 # orthonormal, but det == -1
@@ -350,9 +350,9 @@ class TestLoadRtStructDuplicateNames:
350
350
  ``rt_utils.RTStruct.get_roi_mask_by_name`` matches the *first*
351
351
  ``StructureSetROISequence`` entry with a given name, which is a real risk
352
352
  for TPS exports (duplicate ROI names are not invalid DICOM). These tests
353
- stub out ``RTStructBuilder.create_from`` and ``pydicom.dcmread`` with a
354
- minimal fake object exposing only what ``load_rt_struct`` reads, so no
355
- on-disk DICOM series is needed.
353
+ stub out ``RTStructBuilder.create_from`` with a minimal fake object
354
+ exposing only what ``load_rt_struct`` reads, so no on-disk DICOM series
355
+ is needed.
356
356
  """
357
357
 
358
358
  class _FakeRoi:
@@ -403,7 +403,6 @@ class TestLoadRtStructDuplicateNames:
403
403
  monkeypatch.setattr(
404
404
  rtstruct_io.RTStructBuilder, "create_from", lambda **kw: rtstruct
405
405
  )
406
- monkeypatch.setattr(rtstruct_io.pydicom, "dcmread", lambda *a, **kw: ds)
407
406
 
408
407
  result = rtstruct_io.load_rt_struct(ct_dir="/ct", rtstruct_path="/rs.dcm")
409
408
 
@@ -441,7 +440,6 @@ class TestLoadRtStructDuplicateNames:
441
440
  monkeypatch.setattr(
442
441
  rtstruct_io.RTStructBuilder, "create_from", lambda **kw: rtstruct
443
442
  )
444
- monkeypatch.setattr(rtstruct_io.pydicom, "dcmread", lambda *a, **kw: ds)
445
443
 
446
444
  rtstruct_io.load_rt_struct(ct_dir="/ct", rtstruct_path="/rs.dcm")
447
445
 
@@ -474,13 +472,21 @@ class TestLoadRtStructMissingSequences:
474
472
  "create_from",
475
473
  lambda **kw: self._FakeRTStruct(ds),
476
474
  )
477
- monkeypatch.setattr(rtstruct_io.pydicom, "dcmread", lambda *a, **kw: ds)
478
475
 
479
476
  def test_missing_roi_contour_sequence_returns_empty(self, monkeypatch) -> None:
480
477
  self._patch(monkeypatch, self._EmptyDs())
481
478
  result = rtstruct_io.load_rt_struct(ct_dir="/ct", rtstruct_path="/rs.dcm")
482
479
  assert result == {}
483
480
 
481
+ def test_a_malformed_contour_item_is_skipped(self, monkeypatch) -> None:
482
+ class _Ds:
483
+ StructureSetROISequence: list = []
484
+ ROIContourSequence = [object()] # no ReferencedROINumber
485
+
486
+ self._patch(monkeypatch, _Ds())
487
+ result = rtstruct_io.load_rt_struct(ct_dir="/ct", rtstruct_path="/rs.dcm")
488
+ assert result == {}
489
+
484
490
  def test_an_unreadable_file_still_raises_rtstruct_load_error(
485
491
  self, monkeypatch
486
492
  ) -> None:
@@ -720,6 +720,13 @@ class TestResampleRequiresAPrimaryImage:
720
720
  with pytest.raises(RuntimeError, match="no primary image"):
721
721
  state.set_secondary_image_data(self._small_image())
722
722
 
723
+ def test_a_failed_secondary_leaves_no_source_behind(self) -> None:
724
+ state = SliceViewerState()
725
+ with pytest.raises(RuntimeError):
726
+ state.set_secondary_image_data(self._small_image())
727
+ assert state.secondary_source_image is None
728
+ assert state.secondary_image is None
729
+
723
730
  def test_clearing_the_secondary_without_a_primary_is_allowed(self) -> None:
724
731
  state = SliceViewerState()
725
732
  state.set_secondary_image_data(None) # must not raise
@@ -756,3 +763,31 @@ class TestDeletingTheSelectedRoi:
756
763
  state.delete_contour(other)
757
764
 
758
765
  assert state.selected_roi_number == keep
766
+
767
+
768
+ class TestResampleOntoTheSameGrid:
769
+ """An image already on the primary grid is not resampled again.
770
+
771
+ A 4DCT phase is resampled once into the LRU cache and then handed to
772
+ ``set_secondary_image_data``, which used to resample it a second time
773
+ on every phase switch.
774
+ """
775
+
776
+ def test_identity_on_the_primary_grid_returns_the_image(self) -> None:
777
+ state = make_state_with_image()
778
+ image = sitk.Image(state.primary_image)
779
+ assert state.get_resampled_image(image) is image
780
+
781
+ def test_a_transform_still_resamples(self) -> None:
782
+ state = make_state_with_image()
783
+ image = sitk.Image(state.primary_image)
784
+ moved = state.get_resampled_image(
785
+ image, transform=sitk.TranslationTransform(3, (1.0, 0.0, 0.0))
786
+ )
787
+ assert moved is not image
788
+
789
+ def test_another_grid_still_resamples(self) -> None:
790
+ state = make_state_with_image()
791
+ image = sitk.Image(state.primary_image)
792
+ image.SetOrigin(tuple(v + 0.5 for v in image.GetOrigin()))
793
+ assert state.get_resampled_image(image) is not image
File without changes
File without changes
File without changes