tk-rt-viewer 2.3.0__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 (71) hide show
  1. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/.github/workflows/ci.yml +2 -2
  2. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/CHANGELOG.md +47 -0
  3. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/PKG-INFO +6 -3
  4. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/README.md +5 -2
  5. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/__init__.py +1 -1
  6. tk_rt_viewer-2.3.2/src/tk_rt_viewer/event_controllers/bbox3d_handler.py +58 -0
  7. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/brush_handler.py +5 -9
  8. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/crosshair_handler.py +1 -3
  9. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/viewer_events.py +3 -4
  10. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/geometry.py +40 -0
  11. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/io.py +14 -15
  12. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/reg_io.py +4 -1
  13. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/params.py +5 -7
  14. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/dvh.py +4 -7
  15. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/isodose.py +1 -1
  16. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/layout.py +14 -4
  17. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/view_axes.py +4 -2
  18. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/roi_operations.py +6 -25
  19. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rtstruct_io.py +11 -5
  20. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/phase_manager.py +1 -4
  21. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/secondary_manager.py +13 -3
  22. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/viewer_state.py +16 -0
  23. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/viewer.py +51 -78
  24. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_bbox3d_handler.py +56 -9
  25. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_brush_handler.py +2 -2
  26. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_geometry.py +17 -0
  27. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_reg_io.py +23 -0
  28. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_rtstruct_io.py +12 -6
  29. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_viewer_state.py +35 -0
  30. tk_rt_viewer-2.3.0/src/tk_rt_viewer/event_controllers/bbox3d_handler.py +0 -59
  31. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/.gitattributes +0 -0
  32. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/.gitignore +0 -0
  33. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/LICENSE +0 -0
  34. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/pyproject.toml +0 -0
  35. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/__init__.py +0 -0
  36. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/bbox_handler.py +0 -0
  37. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/rect_drag.py +0 -0
  38. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/events.py +0 -0
  39. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/isodose_levels.py +0 -0
  40. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/protocols.py +0 -0
  41. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/py.typed +0 -0
  42. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/__init__.py +0 -0
  43. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/deformable.py +0 -0
  44. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/errors.py +0 -0
  45. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/rigid.py +0 -0
  46. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/session.py +0 -0
  47. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/template.py +0 -0
  48. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/__init__.py +0 -0
  49. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/blit_compositor.py +0 -0
  50. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/contour_overlay.py +0 -0
  51. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/drawing_manager.py +0 -0
  52. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/image_layer.py +0 -0
  53. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/render.py +0 -0
  54. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/__init__.py +0 -0
  55. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/dose_manager.py +0 -0
  56. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/roi_editor.py +0 -0
  57. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/roi_manager.py +0 -0
  58. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/structure_set.py +0 -0
  59. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/viewer_cache.py +0 -0
  60. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/window_level.py +0 -0
  61. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_bbox3d.py +0 -0
  62. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_event_controllers.py +0 -0
  63. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_io.py +0 -0
  64. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_isodose_levels.py +0 -0
  65. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_performance_opt.py +0 -0
  66. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_registration.py +0 -0
  67. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_render.py +0 -0
  68. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_rendering.py +0 -0
  69. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_roi_operations.py +0 -0
  70. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_state_extensions.py +0 -0
  71. {tk_rt_viewer-2.3.0 → 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,53 @@ 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
+
31
+ ## [2.3.1]
32
+
33
+ A newly drawn 3-D box no longer spans the whole image along the axis that
34
+ cannot be seen while drawing.
35
+
36
+ ### Added
37
+
38
+ - **`geometry.fit_box_length(image, dim, length, min_extent)`**: returns
39
+ *length*, or half of the image's extent along *dim* when that extent is
40
+ shorter than *min_extent*.
41
+
42
+ ### Changed
43
+
44
+ - A 3-D box newly drawn on a view is now
45
+ **`Bbox3dEventHandler.NEW_BOX_DEPTH_MM`** (50 mm) deep along that view's
46
+ normal, centred on the displayed slice. It used to keep the depth of the box
47
+ it replaced, or span the whole image when none was left (e.g. after a
48
+ clearing click), which hid how deep the box was while drawing.
49
+ - Where the image along that normal is shorter than
50
+ **`Bbox3dEventHandler.NARROW_EXTENT_MM`** (60 mm), e.g. a short
51
+ craniocaudal scan range, the new box is half the image deep instead, so it
52
+ does not fill the image end to end.
53
+
7
54
  ## [2.3.0]
8
55
 
9
56
  Zoom per view, a reversible wheel, and views that fill the figure.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tk-rt-viewer
3
- Version: 2.3.0
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
@@ -544,7 +544,7 @@ visible the 3-D box takes the mouse.
544
544
  ```python
545
545
  from tk_rt_viewer import Box3D
546
546
 
547
- # Turn the tool on; the user draws on any view. Drawing on a second view
547
+ # Turn the tool on; the user draws on any view. Resizing on a second view
548
548
  # trims the depth of the box drawn on the first.
549
549
  state.set_bbox_3d_visible(True)
550
550
 
@@ -560,7 +560,10 @@ state.set_bounding_box_3d(None)
560
560
  ```
561
561
 
562
562
  Clicking outside the box clears it, as with the per-view box; a drag that
563
- follows redraws it in that plane while keeping the depth set on another view.
563
+ follows draws a new box in that plane, 50 mm deep along the view's normal and
564
+ centred on the displayed slice (`Bbox3dEventHandler.NEW_BOX_DEPTH_MM`), or half
565
+ the image deep where the image is narrower than 60 mm along that normal
566
+ (`NARROW_EXTENT_MM`). Resize or move it on another view to change its depth.
564
567
 
565
568
  Each view draws the box solid while the displayed slice cuts through it and
566
569
  dashed while it does not, so its depth is visible without leaving the slice.
@@ -507,7 +507,7 @@ visible the 3-D box takes the mouse.
507
507
  ```python
508
508
  from tk_rt_viewer import Box3D
509
509
 
510
- # Turn the tool on; the user draws on any view. Drawing on a second view
510
+ # Turn the tool on; the user draws on any view. Resizing on a second view
511
511
  # trims the depth of the box drawn on the first.
512
512
  state.set_bbox_3d_visible(True)
513
513
 
@@ -523,7 +523,10 @@ state.set_bounding_box_3d(None)
523
523
  ```
524
524
 
525
525
  Clicking outside the box clears it, as with the per-view box; a drag that
526
- follows redraws it in that plane while keeping the depth set on another view.
526
+ follows draws a new box in that plane, 50 mm deep along the view's normal and
527
+ centred on the displayed slice (`Bbox3dEventHandler.NEW_BOX_DEPTH_MM`), or half
528
+ the image deep where the image is narrower than 60 mm along that normal
529
+ (`NARROW_EXTENT_MM`). Resize or move it on another view to change its depth.
527
530
 
528
531
  Each view draws the box solid while the displayed slice cuts through it and
529
532
  dashed while it does not, so its depth is visible without leaving the slice.
@@ -104,7 +104,7 @@ __all__ = [
104
104
  "StructureSet",
105
105
  "to_gy_pairs",
106
106
  ]
107
- __version__ = "2.3.0"
107
+ __version__ = "2.3.2"
108
108
 
109
109
 
110
110
  def __getattr__(name: str) -> Any:
@@ -0,0 +1,58 @@
1
+ """bbox3d_handler.py — Volumetric (3-D) bounding box drag handler.
2
+
3
+ The box is a single :class:`~tk_rt_viewer.geometry.Box3D` in
4
+ :class:`~tk_rt_viewer.state.viewer_state.SliceViewerState`; every view shows
5
+ its projection and the viewer renders it through the
6
+ ``"bounding_box_3d_changed"`` listener.
7
+
8
+ A gesture on any view edits that one box and only the two dimensions the view
9
+ displays; the perpendicular dimension is set from a view that shows it. A new
10
+ box drawn on a view is :attr:`Bbox3dEventHandler.NEW_BOX_DEPTH_MM` deep,
11
+ centred on the displayed slice, so it never spans the whole image along the
12
+ axis the user cannot see while drawing. Along an image narrower than
13
+ :attr:`Bbox3dEventHandler.NARROW_EXTENT_MM` it is half the image instead.
14
+ """
15
+
16
+ import SimpleITK as sitk
17
+
18
+ from ..geometry import AXIS_TO_XYZ_DIM, Box3D, fit_box_length
19
+ from .rect_drag import Rect, RectDragHandler
20
+
21
+
22
+ class Bbox3dEventHandler(RectDragHandler):
23
+ """Create / move / resize the 3-D bounding box from any view."""
24
+
25
+ #: Depth (mm) of a newly drawn box along the normal of the view it is
26
+ #: drawn on, centred on the displayed slice. Hosts may override it.
27
+ NEW_BOX_DEPTH_MM: float = 50.0
28
+
29
+ #: Image extent (mm) along that normal below which the new box is half the
30
+ #: extent instead, so it does not fill a narrow scan range.
31
+ NARROW_EXTENT_MM: float = 60.0
32
+
33
+ def _is_enabled(self) -> bool:
34
+ return self.state.bbox_3d_visible and self.state.primary_image is not None
35
+
36
+ def _current_rect(self, axis: str) -> Rect | None:
37
+ box = self.state.bounding_box_3d
38
+ return None if box is None else box.project(axis)
39
+
40
+ def _clear(self, axis: str) -> None:
41
+ self.state.set_bounding_box_3d(None)
42
+
43
+ def _commit(self, axis: str, rect: Rect, mode: str) -> None:
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))
47
+ else:
48
+ self.state.set_bbox_3d_from_view(axis, rect)
49
+
50
+ def _new_box(self, image: sitk.Image, axis: str, rect: Rect) -> Box3D:
51
+ """Return a box drawn as *rect* on *axis*, centred on the displayed slice."""
52
+ dim = AXIS_TO_XYZ_DIM[axis]
53
+ center = self.state.index_to_physical(axis, self.state.indices[axis])
54
+ depth = fit_box_length(image, dim, self.NEW_BOX_DEPTH_MM, self.NARROW_EXTENT_MM)
55
+ base = Box3D(lower=(0.0, 0.0, 0.0), upper=(0.0, 0.0, 0.0))
56
+ return base.with_range(
57
+ dim, center - depth / 2.0, center + depth / 2.0
58
+ ).with_view_rect(axis, rect)
@@ -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]
@@ -197,6 +197,46 @@ def as_point(values) -> tuple[float, float, float]:
197
197
  return (float(values[0]), float(values[1]), float(values[2]))
198
198
 
199
199
 
200
+ def fit_box_length(
201
+ image: sitk.Image, dim: int, length: float, min_extent: float
202
+ ) -> float:
203
+ """Return *length*, or half of *image*'s extent along *dim* if it is narrow.
204
+
205
+ A default-sized box would cover a narrow image (e.g. a short craniocaudal
206
+ scan range) end to end, so when the extent along physical dimension *dim*
207
+ (0=x, 1=y, 2=z) is shorter than *min_extent* the box takes half of it.
208
+
209
+ Args:
210
+ image: The image the box is drawn on.
211
+ dim: Physical dimension (0=x, 1=y, 2=z).
212
+ length: Preferred box length in mm.
213
+ min_extent: Extent (mm) below which half the extent is used instead.
214
+ """
215
+ extent = Box3D.from_image_extent(image).size[dim]
216
+ return extent / 2.0 if extent < min_extent else length
217
+
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
+
200
240
  def view_dims(axis: str) -> tuple[int, int]:
201
241
  """Return the physical dimensions plotted on *axis*' x and y axes.
202
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