tk-rt-viewer 2.3.0__tar.gz → 2.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/CHANGELOG.md +23 -0
  2. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/PKG-INFO +6 -3
  3. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/README.md +5 -2
  4. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/__init__.py +1 -1
  5. tk_rt_viewer-2.3.1/src/tk_rt_viewer/event_controllers/bbox3d_handler.py +60 -0
  6. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/geometry.py +19 -0
  7. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/tests/test_bbox3d_handler.py +56 -9
  8. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/tests/test_geometry.py +17 -0
  9. tk_rt_viewer-2.3.0/src/tk_rt_viewer/event_controllers/bbox3d_handler.py +0 -59
  10. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/.gitattributes +0 -0
  11. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/.github/workflows/ci.yml +0 -0
  12. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/.gitignore +0 -0
  13. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/LICENSE +0 -0
  14. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/pyproject.toml +0 -0
  15. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/event_controllers/__init__.py +0 -0
  16. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/event_controllers/bbox_handler.py +0 -0
  17. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/event_controllers/brush_handler.py +0 -0
  18. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/event_controllers/crosshair_handler.py +0 -0
  19. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/event_controllers/rect_drag.py +0 -0
  20. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/event_controllers/viewer_events.py +0 -0
  21. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/events.py +0 -0
  22. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/io.py +0 -0
  23. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/isodose_levels.py +0 -0
  24. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/protocols.py +0 -0
  25. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/py.typed +0 -0
  26. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/reg_io.py +0 -0
  27. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/registration/__init__.py +0 -0
  28. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/registration/deformable.py +0 -0
  29. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/registration/errors.py +0 -0
  30. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/registration/params.py +0 -0
  31. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/registration/rigid.py +0 -0
  32. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/registration/session.py +0 -0
  33. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/registration/template.py +0 -0
  34. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/rendering/__init__.py +0 -0
  35. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/rendering/blit_compositor.py +0 -0
  36. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/rendering/contour_overlay.py +0 -0
  37. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/rendering/drawing_manager.py +0 -0
  38. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/rendering/dvh.py +0 -0
  39. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/rendering/image_layer.py +0 -0
  40. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/rendering/isodose.py +0 -0
  41. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/rendering/layout.py +0 -0
  42. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/rendering/render.py +0 -0
  43. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/rendering/view_axes.py +0 -0
  44. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/roi_operations.py +0 -0
  45. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/rtstruct_io.py +0 -0
  46. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/state/__init__.py +0 -0
  47. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/state/dose_manager.py +0 -0
  48. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/state/phase_manager.py +0 -0
  49. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/state/roi_editor.py +0 -0
  50. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/state/roi_manager.py +0 -0
  51. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/state/secondary_manager.py +0 -0
  52. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/state/structure_set.py +0 -0
  53. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/state/viewer_cache.py +0 -0
  54. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/state/viewer_state.py +0 -0
  55. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/viewer.py +0 -0
  56. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/src/tk_rt_viewer/window_level.py +0 -0
  57. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/tests/test_bbox3d.py +0 -0
  58. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/tests/test_brush_handler.py +0 -0
  59. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/tests/test_event_controllers.py +0 -0
  60. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/tests/test_io.py +0 -0
  61. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/tests/test_isodose_levels.py +0 -0
  62. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/tests/test_performance_opt.py +0 -0
  63. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/tests/test_reg_io.py +0 -0
  64. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/tests/test_registration.py +0 -0
  65. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/tests/test_render.py +0 -0
  66. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/tests/test_rendering.py +0 -0
  67. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/tests/test_roi_operations.py +0 -0
  68. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/tests/test_rtstruct_io.py +0 -0
  69. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/tests/test_state_extensions.py +0 -0
  70. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/tests/test_state_managers.py +0 -0
  71. {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.1}/tests/test_viewer_state.py +0 -0
@@ -4,6 +4,29 @@ 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.1]
8
+
9
+ A newly drawn 3-D box no longer spans the whole image along the axis that
10
+ cannot be seen while drawing.
11
+
12
+ ### Added
13
+
14
+ - **`geometry.fit_box_length(image, dim, length, min_extent)`**: returns
15
+ *length*, or half of the image's extent along *dim* when that extent is
16
+ shorter than *min_extent*.
17
+
18
+ ### Changed
19
+
20
+ - A 3-D box newly drawn on a view is now
21
+ **`Bbox3dEventHandler.NEW_BOX_DEPTH_MM`** (50 mm) deep along that view's
22
+ normal, centred on the displayed slice. It used to keep the depth of the box
23
+ it replaced, or span the whole image when none was left (e.g. after a
24
+ clearing click), which hid how deep the box was while drawing.
25
+ - Where the image along that normal is shorter than
26
+ **`Bbox3dEventHandler.NARROW_EXTENT_MM`** (60 mm), e.g. a short
27
+ craniocaudal scan range, the new box is half the image deep instead, so it
28
+ does not fill the image end to end.
29
+
7
30
  ## [2.3.0]
8
31
 
9
32
  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.1
4
4
  Summary: SimpleITK-based DICOM-RT MPR viewer widget for Tkinter (CT, RT-DOSE, RT-STRUCT, DVH)
5
5
  Project-URL: Homepage, https://github.com/oki1002/DICOM_RT_Viewer
6
6
  Project-URL: Repository, https://github.com/oki1002/DICOM_RT_Viewer
@@ -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.1"
108
108
 
109
109
 
110
110
  def __getattr__(name: str) -> Any:
@@ -0,0 +1,60 @@
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
+ from ..geometry import AXIS_TO_XYZ_DIM, Box3D, fit_box_length
17
+ from .rect_drag import Rect, RectDragHandler
18
+
19
+
20
+ class Bbox3dEventHandler(RectDragHandler):
21
+ """Create / move / resize the 3-D bounding box from any view."""
22
+
23
+ #: Depth (mm) of a newly drawn box along the normal of the view it is
24
+ #: drawn on, centred on the displayed slice. Hosts may override it.
25
+ NEW_BOX_DEPTH_MM: float = 50.0
26
+
27
+ #: Image extent (mm) along that normal below which the new box is half the
28
+ #: extent instead, so it does not fill a narrow scan range.
29
+ NARROW_EXTENT_MM: float = 60.0
30
+
31
+ def _is_enabled(self) -> bool:
32
+ return self.state.bbox_3d_visible and self.state.primary_image is not None
33
+
34
+ def _current_rect(self, axis: str) -> Rect | None:
35
+ box = self.state.bounding_box_3d
36
+ return None if box is None else box.project(axis)
37
+
38
+ def _clear(self, axis: str) -> None:
39
+ self.state.set_bounding_box_3d(None)
40
+
41
+ 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
+ else:
45
+ self.state.set_bbox_3d_from_view(axis, rect)
46
+
47
+ def _new_box(self, axis: str, rect: Rect) -> Box3D:
48
+ """Return a box drawn as *rect* on *axis*, centred on the displayed slice."""
49
+ dim = AXIS_TO_XYZ_DIM[axis]
50
+ 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
+ )
57
+ base = Box3D(lower=(0.0, 0.0, 0.0), upper=(0.0, 0.0, 0.0))
58
+ return base.with_range(
59
+ dim, center - depth / 2.0, center + depth / 2.0
60
+ ).with_view_rect(axis, rect)
@@ -197,6 +197,25 @@ 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
+
200
219
  def view_dims(axis: str) -> tuple[int, int]:
201
220
  """Return the physical dimensions plotted on *axis*' x and y axes.
202
221
 
@@ -19,7 +19,7 @@ from matplotlib.figure import Figure
19
19
 
20
20
  from tk_rt_viewer.event_controllers import rect_drag
21
21
  from tk_rt_viewer.event_controllers.bbox3d_handler import Bbox3dEventHandler
22
- from tk_rt_viewer.geometry import AXES, Box3D
22
+ from tk_rt_viewer.geometry import AXES
23
23
  from tk_rt_viewer.state.viewer_state import SliceViewerState
24
24
 
25
25
 
@@ -134,33 +134,80 @@ class TestRectDrag:
134
134
 
135
135
 
136
136
  class TestBbox3dHandler:
137
- def test_drag_creates_a_full_depth_box(self, setup) -> None:
137
+ @staticmethod
138
+ def _slice_coordinate(state: SliceViewerState, axis: str) -> float:
139
+ return state.index_to_physical(axis, state.indices[axis])
140
+
141
+ def test_drag_creates_a_fixed_depth_box_on_the_displayed_slice(self, setup) -> None:
138
142
  state, handler, _ = setup
139
143
  drag(handler, (-10.0, -10.0), (10.0, 20.0))
140
144
 
141
145
  box = state.bounding_box_3d
142
146
  assert box is not None
143
147
  assert box.project("axial") == pytest.approx((-10.0, -10.0, 20.0, 30.0))
144
- extent = Box3D.from_image_extent(state.primary_image)
145
- assert (box.lower[2], box.upper[2]) == (extent.lower[2], extent.upper[2])
148
+ center = self._slice_coordinate(state, "axial")
149
+ half = Bbox3dEventHandler.NEW_BOX_DEPTH_MM / 2.0
150
+ assert (box.lower[2], box.upper[2]) == pytest.approx(
151
+ (center - half, center + half)
152
+ )
153
+
154
+ def test_new_box_on_a_narrow_image_is_half_the_image_deep(self) -> None:
155
+ state = SliceViewerState()
156
+ # 10 axial slices x 3 mm = 30 mm craniocaudally (narrower than 60 mm)
157
+ image = sitk.GetImageFromArray(np.zeros((10, 40, 40), dtype=np.int16))
158
+ image.SetSpacing((2.0, 2.0, 3.0))
159
+ state.set_primary_image_data(image)
160
+ state.set_bbox_3d_visible(True)
161
+ handler = Bbox3dEventHandler(state, FakeViewer(), FakeHover())
162
+
163
+ drag(handler, (10.0, 10.0), (30.0, 30.0))
146
164
 
147
- def test_drag_on_another_view_only_changes_that_view_dimensions(
165
+ assert state.bounding_box_3d.size[2] == pytest.approx(15.0)
166
+
167
+ def test_new_box_after_a_clearing_click_keeps_the_fixed_depth(self, setup) -> None:
168
+ state, handler, _ = setup
169
+ drag(handler, (-10.0, -10.0), (10.0, 20.0))
170
+ drag(handler, (-30.0, -30.0), (-30.0, -30.0))
171
+ assert state.bounding_box_3d is None
172
+
173
+ drag(handler, (-20.0, -20.0), (0.0, 0.0))
174
+
175
+ box = state.bounding_box_3d
176
+ assert box.size[2] == pytest.approx(Bbox3dEventHandler.NEW_BOX_DEPTH_MM)
177
+
178
+ def test_new_box_on_another_view_uses_that_view_slice_for_depth(
148
179
  self, setup
149
180
  ) -> None:
150
181
  state, handler, hover = setup
151
182
  drag(handler, (-10.0, -10.0), (10.0, 20.0))
152
- y_range = (state.bounding_box_3d.lower[1], state.bounding_box_3d.upper[1])
153
183
 
154
- # Start outside the current projection: the box is redrawn in this
155
- # plane, keeping the depth (y) set from the axial view.
184
+ # Start outside the current projection: a new box is drawn in this
185
+ # plane, its depth (y) centred on the displayed coronal slice
156
186
  hover.current_axis = "coronal" # shows x and z
157
187
  drag(handler, (-30.0, -6.0), (-20.0, 6.0))
158
188
 
159
189
  box = state.bounding_box_3d
160
- assert (box.lower[1], box.upper[1]) == y_range
190
+ center = self._slice_coordinate(state, "coronal")
191
+ half = Bbox3dEventHandler.NEW_BOX_DEPTH_MM / 2.0
192
+ assert (box.lower[1], box.upper[1]) == pytest.approx(
193
+ (center - half, center + half)
194
+ )
161
195
  assert (box.lower[2], box.upper[2]) == pytest.approx((-6.0, 6.0))
162
196
  assert (box.lower[0], box.upper[0]) == pytest.approx((-30.0, -20.0))
163
197
 
198
+ def test_resize_on_another_view_keeps_the_depth(self, setup) -> None:
199
+ state, handler, hover = setup
200
+ drag(handler, (-10.0, -10.0), (10.0, 20.0))
201
+ y_range = (state.bounding_box_3d.lower[1], state.bounding_box_3d.upper[1])
202
+
203
+ hover.current_axis = "coronal"
204
+ z_low = state.bounding_box_3d.lower[2]
205
+ drag(handler, (0.0, z_low), (0.0, z_low - 5.0)) # bottom edge
206
+
207
+ box = state.bounding_box_3d
208
+ assert (box.lower[1], box.upper[1]) == y_range
209
+ assert box.lower[2] == pytest.approx(z_low - 5.0)
210
+
164
211
  def test_click_outside_clears_the_box(self, setup) -> None:
165
212
  state, handler, _ = setup
166
213
  drag(handler, (-10.0, -10.0), (10.0, 20.0))
@@ -14,6 +14,7 @@ import SimpleITK as sitk
14
14
  from tk_rt_viewer.geometry import (
15
15
  AXES,
16
16
  compute_extent,
17
+ fit_box_length,
17
18
  mask_slice_to_paths,
18
19
  slice_along_axis,
19
20
  )
@@ -123,3 +124,19 @@ class TestSliceAlongAxis:
123
124
  np.testing.assert_array_equal(
124
125
  slice_along_axis(arr, "sagittal", 3), arr[:, :, 3]
125
126
  )
127
+
128
+
129
+ class TestFitBoxLength:
130
+ @staticmethod
131
+ def _image(slices: int) -> sitk.Image:
132
+ image = sitk.GetImageFromArray(np.zeros((slices, 4, 4), dtype=np.int16))
133
+ image.SetSpacing((1.0, 1.0, 2.0))
134
+ return image
135
+
136
+ def test_keeps_the_length_on_a_wide_image(self) -> None:
137
+ # 30 slices x 2 mm = 60 mm: not narrower than the threshold
138
+ assert fit_box_length(self._image(30), 2, 50.0, 60.0) == 50.0
139
+
140
+ def test_uses_half_the_extent_on_a_narrow_image(self) -> None:
141
+ # 20 slices x 2 mm = 40 mm
142
+ assert fit_box_length(self._image(20), 2, 50.0, 60.0) == 40.0 / 2
@@ -1,59 +0,0 @@
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 keeps the depth of the box it replaces, or spans the whole
11
- image when there was none.
12
- """
13
-
14
- from typing import TYPE_CHECKING
15
-
16
- from ..geometry import Box3D
17
- from ..protocols import ViewerHost
18
- from .rect_drag import Rect, RectDragHandler
19
-
20
- if TYPE_CHECKING:
21
- from ..state.viewer_state import SliceViewerState
22
- from .viewer_events import ViewerEventHandler
23
-
24
-
25
- class Bbox3dEventHandler(RectDragHandler):
26
- """Create / move / resize the 3-D bounding box from any view."""
27
-
28
- def __init__(
29
- self,
30
- state: "SliceViewerState",
31
- viewer: ViewerHost,
32
- hover: "ViewerEventHandler",
33
- ) -> None:
34
- super().__init__(state, viewer, hover)
35
- # The box cleared by a create press; its depth is reused for the new one
36
- self._previous_box: Box3D | None = None
37
-
38
- def cancel(self) -> None:
39
- super().cancel()
40
- self._previous_box = None
41
-
42
- def _is_enabled(self) -> bool:
43
- return self.state.bbox_3d_visible and self.state.primary_image is not None
44
-
45
- def _current_rect(self, axis: str) -> Rect | None:
46
- box = self.state.bounding_box_3d
47
- return None if box is None else box.project(axis)
48
-
49
- def _clear(self, axis: str) -> None:
50
- self._previous_box = self.state.bounding_box_3d
51
- self.state.set_bounding_box_3d(None)
52
-
53
- def _commit(self, axis: str, rect: Rect, mode: str) -> None:
54
- image = self.state.primary_image
55
- if mode == "create" and image is not None:
56
- base = self._previous_box or Box3D.from_image_extent(image)
57
- self.state.set_bounding_box_3d(base.with_view_rect(axis, rect))
58
- else:
59
- self.state.set_bbox_3d_from_view(axis, rect)
File without changes
File without changes