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.
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/.github/workflows/ci.yml +2 -2
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/CHANGELOG.md +47 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/PKG-INFO +6 -3
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/README.md +5 -2
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/__init__.py +1 -1
- tk_rt_viewer-2.3.2/src/tk_rt_viewer/event_controllers/bbox3d_handler.py +58 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/brush_handler.py +5 -9
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/crosshair_handler.py +1 -3
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/viewer_events.py +3 -4
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/geometry.py +40 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/io.py +14 -15
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/reg_io.py +4 -1
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/params.py +5 -7
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/dvh.py +4 -7
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/isodose.py +1 -1
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/layout.py +14 -4
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/view_axes.py +4 -2
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/roi_operations.py +6 -25
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rtstruct_io.py +11 -5
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/phase_manager.py +1 -4
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/secondary_manager.py +13 -3
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/viewer_state.py +16 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/viewer.py +51 -78
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_bbox3d_handler.py +56 -9
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_brush_handler.py +2 -2
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_geometry.py +17 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_reg_io.py +23 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_rtstruct_io.py +12 -6
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_viewer_state.py +35 -0
- tk_rt_viewer-2.3.0/src/tk_rt_viewer/event_controllers/bbox3d_handler.py +0 -59
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/.gitattributes +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/.gitignore +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/LICENSE +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/pyproject.toml +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/__init__.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/bbox_handler.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/rect_drag.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/events.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/isodose_levels.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/protocols.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/py.typed +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/__init__.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/deformable.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/errors.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/rigid.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/session.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/registration/template.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/__init__.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/blit_compositor.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/contour_overlay.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/drawing_manager.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/image_layer.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/rendering/render.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/__init__.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/dose_manager.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/roi_editor.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/roi_manager.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/structure_set.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/state/viewer_cache.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/window_level.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_bbox3d.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_event_controllers.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_io.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_isodose_levels.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_performance_opt.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_registration.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_render.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_rendering.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_roi_operations.py +0 -0
- {tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/tests/test_state_extensions.py +0 -0
- {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@
|
|
15
|
+
- uses: actions/checkout@v5
|
|
16
16
|
|
|
17
|
-
- uses: actions/setup-python@
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
@@ -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)
|
{tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/brush_handler.py
RENAMED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
{tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/crosshair_handler.py
RENAMED
|
@@ -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.
|
|
135
|
-
self._active_axis = None
|
|
136
|
-
self._drag_target = None
|
|
134
|
+
self.cancel()
|
{tk_rt_viewer-2.3.0 → tk_rt_viewer-2.3.2}/src/tk_rt_viewer/event_controllers/viewer_events.py
RENAMED
|
@@ -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
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
667
|
+
def _read_files(
|
|
665
668
|
reader: sitk.ImageSeriesReader,
|
|
666
|
-
|
|
669
|
+
file_names: Sequence[str],
|
|
667
670
|
series_id: str,
|
|
668
|
-
) ->
|
|
669
|
-
"""
|
|
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
|
|
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", "") ==
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
154
|
-
pitch=math.degrees(
|
|
155
|
-
yaw=math.degrees(
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
#
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|