tk-rt-viewer 1.1.0__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 (38) hide show
  1. tk_rt_viewer-1.1.0/.gitattributes +2 -0
  2. tk_rt_viewer-1.1.0/.gitignore +61 -0
  3. tk_rt_viewer-1.1.0/CHANGELOG.md +571 -0
  4. tk_rt_viewer-1.1.0/LICENSE +21 -0
  5. tk_rt_viewer-1.1.0/PKG-INFO +547 -0
  6. tk_rt_viewer-1.1.0/README.md +509 -0
  7. tk_rt_viewer-1.1.0/pyproject.toml +107 -0
  8. tk_rt_viewer-1.1.0/src/tk_rt_viewer/__init__.py +101 -0
  9. tk_rt_viewer-1.1.0/src/tk_rt_viewer/event_controllers/bbox_handler.py +270 -0
  10. tk_rt_viewer-1.1.0/src/tk_rt_viewer/event_controllers/brush_handler.py +526 -0
  11. tk_rt_viewer-1.1.0/src/tk_rt_viewer/event_controllers/crosshair_handler.py +124 -0
  12. tk_rt_viewer-1.1.0/src/tk_rt_viewer/event_controllers/viewer_events.py +313 -0
  13. tk_rt_viewer-1.1.0/src/tk_rt_viewer/events.py +67 -0
  14. tk_rt_viewer-1.1.0/src/tk_rt_viewer/geometry.py +174 -0
  15. tk_rt_viewer-1.1.0/src/tk_rt_viewer/io.py +535 -0
  16. tk_rt_viewer-1.1.0/src/tk_rt_viewer/isodose_levels.py +113 -0
  17. tk_rt_viewer-1.1.0/src/tk_rt_viewer/rendering/contour_overlay.py +191 -0
  18. tk_rt_viewer-1.1.0/src/tk_rt_viewer/rendering/drawing_manager.py +75 -0
  19. tk_rt_viewer-1.1.0/src/tk_rt_viewer/rendering/dvh.py +138 -0
  20. tk_rt_viewer-1.1.0/src/tk_rt_viewer/rendering/isodose.py +399 -0
  21. tk_rt_viewer-1.1.0/src/tk_rt_viewer/rendering/layout.py +92 -0
  22. tk_rt_viewer-1.1.0/src/tk_rt_viewer/rendering/render.py +97 -0
  23. tk_rt_viewer-1.1.0/src/tk_rt_viewer/roi_operations.py +379 -0
  24. tk_rt_viewer-1.1.0/src/tk_rt_viewer/rtstruct_io.py +382 -0
  25. tk_rt_viewer-1.1.0/src/tk_rt_viewer/state/phase_manager.py +199 -0
  26. tk_rt_viewer-1.1.0/src/tk_rt_viewer/state/structure_set.py +170 -0
  27. tk_rt_viewer-1.1.0/src/tk_rt_viewer/state/viewer_cache.py +556 -0
  28. tk_rt_viewer-1.1.0/src/tk_rt_viewer/state/viewer_state.py +1406 -0
  29. tk_rt_viewer-1.1.0/src/tk_rt_viewer/viewer.py +1259 -0
  30. tk_rt_viewer-1.1.0/tests/test_brush_handler.py +167 -0
  31. tk_rt_viewer-1.1.0/tests/test_geometry.py +125 -0
  32. tk_rt_viewer-1.1.0/tests/test_io.py +31 -0
  33. tk_rt_viewer-1.1.0/tests/test_isodose_levels.py +77 -0
  34. tk_rt_viewer-1.1.0/tests/test_performance_opt.py +208 -0
  35. tk_rt_viewer-1.1.0/tests/test_render.py +62 -0
  36. tk_rt_viewer-1.1.0/tests/test_roi_operations.py +216 -0
  37. tk_rt_viewer-1.1.0/tests/test_rtstruct_io.py +165 -0
  38. tk_rt_viewer-1.1.0/tests/test_viewer_state.py +463 -0
@@ -0,0 +1,2 @@
1
+ * text=auto eol=lf
2
+ *.py text eol=lf
@@ -0,0 +1,61 @@
1
+ # --- Python build / distribution ---
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ build/
7
+ develop-eggs/
8
+ dist/
9
+ downloads/
10
+ eggs/
11
+ .eggs/
12
+ lib/
13
+ lib64/
14
+ parts/
15
+ sdist/
16
+ var/
17
+ wheels/
18
+ share/python-wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+ MANIFEST
23
+
24
+ # --- Virtual Environments ---
25
+ .env
26
+ .venv
27
+ env/
28
+ venv/
29
+ ENV/
30
+ env.bak/
31
+ venv.bak/
32
+
33
+ # --- Testing / Coverage ---
34
+ .pytest_cache/
35
+ .mypy_cache/
36
+ .ruff_cache/
37
+ .coverage
38
+ htmlcov/
39
+
40
+ # --- IDEs / Editors ---
41
+ .vscode/
42
+ .idea/
43
+ *.swp
44
+ *.swo
45
+ *~
46
+ .DS_Store
47
+
48
+ # --- DICOM / Medical Data (Safety first) ---
49
+ *.dcm
50
+ *.DCM
51
+ /data/
52
+ /samples/
53
+ /tests/data/
54
+ !/tests/data/.gitkeep
55
+
56
+ # --- SimpleITK / Visualization ---
57
+ *.nii
58
+ *.nii.gz
59
+ *.mhd
60
+ *.zraw
61
+ *.raw
@@ -0,0 +1,571 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [1.1.0] — 2026
8
+
9
+ Both the distribution and the import package are renamed. This is the same
10
+ shape of change already made once for `dicom_viewer` → `dicom_rt_viewer` in
11
+ 0.6.0/0.7.0, applied again for the same underlying reason: the name did not
12
+ say enough about what makes this library reusable.
13
+
14
+ ### Changed (breaking)
15
+
16
+ - **Distribution renamed to `tk-rt-viewer`.** `dicom-rt-viewer` correctly
17
+ signalled the file formats this library reads (DICOM, RT-STRUCT, RT-DOSE)
18
+ but said nothing about the one fact that most affects whether a given
19
+ project can use it as-is: it is a Tkinter widget, not a standalone
20
+ application, a web viewer, or another GUI framework's plugin. The name was
21
+ also close enough to several unrelated DICOM-tooling projects on PyPI to
22
+ be mistaken for one of them in a search result. `pip install dicom-rt-viewer`
23
+ is replaced by `pip install tk-rt-viewer`.
24
+ - **Import package renamed from `dicom_rt_viewer` to `tk_rt_viewer`**,
25
+ matching the distribution name as before (hyphens are not valid in Python
26
+ identifiers, so the import name uses underscores in their place). Update
27
+ `from dicom_rt_viewer import ...` to `from tk_rt_viewer import ...`; every
28
+ submodule path moves the same way (`dicom_rt_viewer.io` →
29
+ `tk_rt_viewer.io`, and likewise for `.rtstruct_io`, `.roi_operations`,
30
+ `.events`, `.isodose_levels`, and `.state.*`). Nothing else about the
31
+ public surface changes — every class, function, and argument keeps its
32
+ 1.0.0 name and behaviour.
33
+
34
+ `dicom-rt-viewer` will not receive further releases on PyPI beyond 1.0.0.
35
+ Pin `dicom-rt-viewer==1.0.0` if you are not ready to migrate; there is no
36
+ functional reason to, since 2.0.0 is import-path-identical to 1.0.0 aside
37
+ from the package name itself.
38
+
39
+ ## [1.0.0] — 2026
40
+
41
+ First release with a stable public surface. The two changes below were
42
+ deferred from 0.9.1 because both alter published API; they are grouped here
43
+ rather than shipped piecemeal.
44
+
45
+ ### Changed (breaking)
46
+
47
+ - **`indices`, `crosshair_pos` and `bounding_boxes` are now read-only
48
+ mappings.** Each is derived or validated: `set_index` clamps to the image
49
+ bounds, `set_bounding_box` clears the box on every other axis, and
50
+ `crosshair_pos` is recomputed from the indices. Publishing the live
51
+ dictionaries let a caller assign into them and skip all of that, leaving
52
+ the viewer drawing one slice while the state reported another with no
53
+ event to reconcile them — the same class of aliasing 0.8.1 and 0.9.1
54
+ fixed for `active_contours` and `all_phases_data`. They are now backed by
55
+ private storage and exposed as views.
56
+
57
+ Reading is unaffected: indexing, `in`, `len()`, `.get()`, `.items()` and
58
+ `dict(...)` all behave as before, so code that only reads them needs no
59
+ change. Assigning into them now raises `TypeError`, and mutating methods
60
+ (`pop`, `clear`, `update`, `setdefault`) are gone. Use `set_index`,
61
+ `set_bounding_box` / `set_bbox_from_pixel_coords`, and
62
+ `update_crosshair_by_index` / `refresh_crosshair` instead.
63
+
64
+ They are also no longer accepted as `SliceViewerState(...)` keyword
65
+ arguments. Passing them at construction never had any effect — the
66
+ setters are the only supported entry points — so this affects no working
67
+ code.
68
+
69
+ ### Changed
70
+
71
+ - **`StructureSet` and `RoiEntry` moved to
72
+ `dicom_rt_viewer.state.structure_set`.** The ROI container has no
73
+ dependency on `SliceViewerState`: it holds no image, emits no events, and
74
+ knows nothing about slices or caches, which is what lets it be built and
75
+ inspected outside a viewer. It is re-exported from both
76
+ `dicom_rt_viewer` and `dicom_rt_viewer.state.viewer_state`, so existing
77
+ imports from either path keep working.
78
+
79
+ ## [0.9.1] — 2026
80
+
81
+ Follow-up to 0.9.0, from a review of the changes it introduced.
82
+
83
+ ### Fixed
84
+
85
+ - **`all_phases_data` handed out the state's own dictionaries.** The same
86
+ aliasing problem 0.8.1 fixed for `active_contours` remained on the 4DCT
87
+ path, and 0.9.0 moved that code into `PhaseManager` without addressing
88
+ it: the property returned the stored mapping itself, and
89
+ `phases_data_loaded` listeners were passed the same object. Dropping a
90
+ phase or replacing its `"sitk_image"` through either route left the
91
+ resampled-volume LRU cache holding a volume that no longer matched the
92
+ phase it was keyed by. Both now expose a read-only view of the outer
93
+ mapping *and* of each phase entry. Every read a caller legitimately
94
+ performs (indexing, `in`, `len`, `items()`, `dict(...)`) is unaffected;
95
+ only mutation now raises.
96
+ - **`add_rt_struct_rois` documented graceful skipping it did not do.** Its
97
+ docstring said ROIs whose mask "could not be wrapped" were skipped, but
98
+ the only branch that could skip was unreachable, and the realistic
99
+ failure — a mask whose shape does not match the primary image, i.e. an
100
+ RT-STRUCT belonging to a different series — surfaced as a `RuntimeError`
101
+ from deep inside SimpleITK. Mask shapes are now validated up front and
102
+ a mismatch raises `ValueError` naming the ROI, both shapes and the likely
103
+ cause. Validation completes before any ROI is added, so the structure set
104
+ is left untouched rather than half-populated.
105
+ - **`save_structure_set` resampled every mask even when there was nothing
106
+ to resample to.** With `original_image` omitted the target geometry was
107
+ `lps_image` itself, so each ROI paid a full nearest-neighbour resample to
108
+ reach the grid it was already on. The resample is now skipped in that
109
+ case.
110
+
111
+ ### Changed
112
+
113
+ - `active_contours_changed` listeners now receive a `frozenset` rather than
114
+ a `set` copy — an immutable snapshot needs no defensive copying by either
115
+ side. Code that only reads the argument is unaffected.
116
+ - `IsoDoseOverlay._resolve_levels` was restructured in 0.9.0 so that its
117
+ `else` branch returned while the final statement was reachable only from
118
+ the `if`, making it read as though both paths fell through. Rewritten as
119
+ a plain early return.
120
+ - `Iterable` is imported from `collections.abc` rather than the deprecated
121
+ `typing` aliases.
122
+ - The 4DCT cache limit is clamped with a warning when read at runtime, not
123
+ silently, matching what `SliceViewerState` already does when the same
124
+ value is out of range at construction time.
125
+
126
+ ### Documentation
127
+
128
+ - 0.9.0 changed the colour written for an ROI with no colour set: the
129
+ previous host-side code substituted white, whereas `save_structure_set`
130
+ passes `None` through and lets rt-utils assign from its palette. This was
131
+ an undocumented behaviour change in a release billed as additive; it is
132
+ recorded here.
133
+
134
+ ## [0.9.0] — 2026
135
+
136
+ Additive release: three pieces of glue that every consumer of this library
137
+ had to write for itself are now provided here. Nothing is removed or
138
+ renamed, so 0.8.1 code keeps working.
139
+
140
+ ### Added
141
+
142
+ - **`dicom_rt_viewer.isodose_levels`** — `IsoDoseLevel`,
143
+ `DEFAULT_ISODOSE_LEVELS` and `to_gy_pairs`, also re-exported from the
144
+ package root. Iso-dose levels are chosen clinically as percentages of a
145
+ reference dose, but `DicomViewer.set_isodose_lines` takes absolute Gy, so
146
+ every application that offers a level-settings UI held its own percentage
147
+ ladder and its own percent-to-Gy conversion. `IsoDoseOverlay` previously
148
+ kept the default ladder in a private `_DEFAULT_LEVELS_PCT`, which meant
149
+ the same seven values and colours existed in two places with nothing
150
+ keeping them in step; it now uses `DEFAULT_ISODOSE_LEVELS`. `to_gy_pairs`
151
+ produces exactly what `set_isodose_lines` expects — hidden levels
152
+ dropped, non-positive doses dropped, sorted ascending — so the rule that
153
+ a level at or below zero swallows the lowest colour band is enforced in
154
+ one place rather than in each caller. The module imports nothing beyond
155
+ the standard library.
156
+ - **`rtstruct_io.save_structure_set(...)`** — writes every ROI of a
157
+ `StructureSet` to an RT-STRUCT file. Saving previously required the
158
+ caller to resample each mask from the LPS-aligned space the viewer works
159
+ in back to the geometry the RT-STRUCT references, convert each
160
+ `sitk.Image` to a `(D, H, W)` boolean array, and — because
161
+ `StructureSet` stores colours as `"#rrggbb"` while the examples reached
162
+ for `[R, G, B]` — convert the colour too. The colour conversion turned
163
+ out to be unnecessary: rt-utils accepts a hex string directly, so it is
164
+ passed straight through. ROIs whose mask is missing are skipped with a
165
+ warning instead of aborting the save; a structure set with no usable mask
166
+ at all raises `ValueError` rather than writing an empty RT-STRUCT.
167
+ - **`SliceViewerState.add_rt_struct_rois(...)`** — adds the
168
+ `dict[int, RoiInfo]` returned by `load_rt_struct` in one batch. Bridging
169
+ `load_rt_struct` (NumPy masks, ROI numbers from the file) to
170
+ `add_contours` (`sitk.Image` masks, ROI numbers assigned by the state)
171
+ meant wrapping each array, resolving names that collide with ROIs already
172
+ loaded, and activating the result. Doing it per ROI also fired
173
+ `all_contours_changed` — and therefore a full contour redraw — once per
174
+ ROI, so a 30-ROI structure set triggered dozens of redraws; each event now
175
+ fires once. `activate` and `resolve_name_collisions` are keyword-only
176
+ options.
177
+ - **`StructureSet.generate_unique_name(base_name, *, reserved=())`** — the
178
+ new `reserved` argument lets a caller naming several ROIs before adding
179
+ any of them keep them distinct from each other. Without it, two incoming
180
+ ROIs sharing a name both resolved to the same free name, because neither
181
+ was in the container yet for the other to collide with. Used by
182
+ `add_rt_struct_rois`; the existing single-argument behaviour is unchanged.
183
+
184
+ ## [0.8.1] — 2026
185
+
186
+ ### Fixed
187
+
188
+ - **The brush tool erased the mask on any mouse button other than
189
+ left-click.** `_apply_stroke_to_mask_cached` selected between paint and
190
+ erase with `if button == 1: ... else: ...`, and neither
191
+ `ViewerEventHandler.on_press` nor `BrushEventHandler.handle_press`
192
+ filtered the button beforehand. A middle-click — easy to trigger
193
+ accidentally with a scroll-wheel press while the brush was active —
194
+ therefore took the erase branch and silently subtracted a brush-sized
195
+ region from the selected ROI, contrary to the documented "left-click
196
+ paints, right-click erases" behaviour. `handle_press` now ignores any
197
+ button other than those two, and both branches are matched explicitly so
198
+ an unexpected value leaves the mask untouched.
199
+ - **`active_contours` handed its internal set to listeners, which then
200
+ changed underneath them.** `set_active_contours` copied the caller's set
201
+ before storing it (fixed in 0.8.0) but passed that same stored object to
202
+ `active_contours_changed` listeners, and `delete_contour` discarded from
203
+ it in place. A listener that retained the set it was given would see its
204
+ contents change with no further notification — the mirror image of the
205
+ aliasing bug 0.8.0 fixed. Listeners now receive a copy, and
206
+ `delete_contour` deactivates through `set_active_contours`.
207
+ - **`delete_contour` fired `active_contours_changed` even when the deleted
208
+ ROI was not active.** Routing deactivation through `set_active_contours`
209
+ means the event is now emitted only when the active set actually changes.
210
+ - **Assigning a malformed value to `state.window_level` raised `IndexError`
211
+ from inside `__setattr__`.** The observable-field redirect unpacked the
212
+ assigned value positionally, so `state.window_level = (300,)` failed with
213
+ a traceback pointing into the state machinery rather than at the
214
+ assignment. Such assignments now raise `ValueError` naming the field and
215
+ the expected shape.
216
+
217
+ ### Changed
218
+
219
+ - **`DicomViewer` is now imported lazily.** `dicom_rt_viewer/__init__.py`
220
+ imported `viewer` — and therefore Tkinter and a Matplotlib GUI backend —
221
+ at package import time, so `from dicom_rt_viewer import events` or using
222
+ the pure-SimpleITK helpers in `io`, `rtstruct_io` and `roi_operations`
223
+ required a working Tkinter build. Those modules can now be imported and
224
+ used from a headless process. `dicom_rt_viewer.DicomViewer` continues to
225
+ work unchanged; it is resolved on first attribute access via a module
226
+ `__getattr__`.
227
+ - **4DCT phase storage and lazy resampling moved into a `PhaseManager`
228
+ collaborator** (`dicom_rt_viewer.state.phase_manager`), following the same
229
+ split already applied to the performance caches in `ViewerCacheManager`.
230
+ `SliceViewerState` keeps its phase API (`set_all_phases`,
231
+ `set_active_phase_as_secondary`, `all_phases_data`, `current_phase`,
232
+ `max_cached_phases`) and remains the only thing that emits
233
+ `phases_data_loaded` / `phase_changed`; behaviour is unchanged.
234
+ `all_phases_data` and `current_phase` are now read-only properties rather
235
+ than dataclass fields, so they are no longer accepted as constructor
236
+ keyword arguments — passing them at construction never had any effect,
237
+ since `set_all_phases` is the only supported way to load phases.
238
+ - **`load_rt_dose` scales the dose with SimpleITK instead of NumPy.** The
239
+ previous `GetArrayFromImage` → multiply → `GetImageFromArray` round-trip
240
+ allocated two extra full-size copies of the dose volume and then had to
241
+ restore the geometry with `CopyInformation`; it is now a `Cast` followed
242
+ by a `Multiply`.
243
+ - **Minimum NumPy raised from 1.24 to 1.26.** 1.26 is the first release
244
+ supporting Python 3.12, which this package already requires, so the old
245
+ lower bound described a combination that could never be installed.
246
+
247
+ ### Documentation
248
+
249
+ - The Quick start example injected a `SliceViewerState` without ever
250
+ closing it. Since `DicomViewer.destroy()` deliberately does not close an
251
+ injected state, the example leaked the contour-build thread pool — whose
252
+ workers are non-daemon and can delay interpreter shutdown. It now closes
253
+ the state from a window-close handler, and `DicomViewer.destroy()`
254
+ documents the host's responsibility (and logs a debug message when a
255
+ viewer is destroyed with an injected state).
256
+ - Documented that a `sitk.Image` passed to `add_contour` /
257
+ `update_contour_properties` must be treated as immutable afterwards,
258
+ since the slice caches keep zero-copy views over its buffer.
259
+ - Noted in the brush-tool section that buttons other than left and right
260
+ are ignored.
261
+
262
+ ## [0.8.0] — 2026
263
+
264
+ ### Changed
265
+
266
+ - **BREAKING:** `DicomViewer.state` has been renamed to
267
+ `DicomViewer.viewer_state`. As an instance attribute, `state` shadowed
268
+ the inherited `ttk.Frame.state()` method (used to query/set Tk widget
269
+ states such as `"disabled"`); any host application code that called
270
+ `viewer.state()` expecting the Tk behaviour would instead hit the
271
+ `SliceViewerState` object and raise `TypeError`. Update call sites from
272
+ `viewer.state.xxx` to `viewer.viewer_state.xxx`; the `state=` constructor
273
+ keyword argument to `DicomViewer(...)` is unaffected.
274
+
275
+ ### Fixed
276
+
277
+ - **`SliceViewerState.set_active_contours` could silently skip its change
278
+ notification.** The set passed in was stored by reference. If a caller
279
+ kept its own reference to that set and later mutated it in place (e.g.
280
+ via `add`/`discard`) instead of calling `set_active_contours` again, the
281
+ next real call would compare the stored set against that
282
+ already-mutated same object, find them equal, and skip the
283
+ notification — desynchronising listeners from the actual active-ROI
284
+ set. `set_active_contours` now stores a defensive copy
285
+ (`set(active_roi_numbers)`) instead of the caller's set.
286
+ - **`BrushEventHandler.handle_release` could raise if the primary image
287
+ was cleared mid-stroke.** If a host application called
288
+ `state.set_primary_image_data(None)` (e.g. from an unrelated event)
289
+ while a brush stroke was still in progress, `handle_release` would call
290
+ `new_mask.CopyInformation(self.state.primary_image)` with a `None`
291
+ reference and raise `AttributeError` instead of finishing cleanly. It
292
+ now discards the in-progress stroke when the primary image has gone
293
+ missing, matching the existing empty-mask-volume guard just above it.
294
+ - **Duplicated nearest-neighbour mask-resampling code in `rtstruct_io.py`
295
+ and `roi_operations.py` could drift apart.**
296
+ `resample_mask_to_original_space` and `boolean_operation` each built an
297
+ identical `sitk.ResampleImageFilter` (reference image, nearest-neighbour
298
+ interpolator, zero default pixel value, identity transform) inline.
299
+ Both now call a single shared `geometry.resample_binary_mask(mask,
300
+ reference)` helper.
301
+
302
+ ### Performance
303
+
304
+ - **`BrushEventHandler` no longer converts the same cursor position from
305
+ physical to pixel coordinates twice per motion event.** `handle_motion`
306
+ already computes the pixel position to decide whether the cursor moved
307
+ enough to paint; previously `_paint_at` recomputed the identical
308
+ conversion instead of reusing that result. `_paint_at` now accepts an
309
+ optional pre-computed `center_px` and `handle_motion` passes its own
310
+ result through, halving the conversions per motion event during a
311
+ stroke.
312
+ - **`BrushEventHandler._physical_to_slice_pixel` no longer re-slices the
313
+ mask volume on every motion event during an active stroke.** The
314
+ in-plane slice shape it needs is fixed for the duration of a stroke —
315
+ the same property `_stroke_radii_px` already relied on — so it is now
316
+ cached once in `handle_press` (`_stroke_slice_shape`) and reused for
317
+ every motion event of that stroke, instead of calling
318
+ `state.get_slice_data` again on each one. Lookups outside an active
319
+ stroke (e.g. cursor-preview positioning before the first press) still
320
+ read the shape fresh, since no stroke-scoped cache is valid then.
321
+
322
+ ## [0.7.1] — 2026
323
+
324
+ ### Fixed
325
+
326
+ - **Brush tool could crash when a stroke started outside any view.**
327
+ `BrushEventHandler.handle_press` now guards against an empty
328
+ `current_axis` / missing `event.xdata`/`event.ydata` (e.g. a click that
329
+ lands on the figure margin between the MPR panels) instead of falling
330
+ through to `state.indices[""]`, which raised `KeyError`.
331
+ - **Brush strokes could commit to the wrong ROI if the selected ROI
332
+ changed mid-drag.** `BrushEventHandler.handle_release` now commits the
333
+ stroke to the ROI that was selected when the stroke started
334
+ (`self._cached_roi_number`, captured in `handle_press`) instead of
335
+ re-reading `state.selected_roi_number` at release time. Previously, if
336
+ a host application switched the selected ROI from another widget while
337
+ the mouse button was still held down, the stroke's mask volume — built
338
+ for the *original* ROI — was written into the *new* ROI's entry,
339
+ silently overwriting its mask.
340
+ - **`SliceViewerState._notify`'s docstring cross-reference was stale**
341
+ (`_KNOWN_EVENTS`, a name that no longer exists) in `events.py`; it now
342
+ points at `ALL_EVENTS`.
343
+ - **`set_bbox_visible` bypassed the event-name constant**, notifying with
344
+ the string literal `"bounding_boxes_changed"` instead of
345
+ `events.BOUNDING_BOXES_CHANGED`, defeating the typo-detection this
346
+ project's event constants exist for. It now uses the constant like
347
+ every other `set_*` method.
348
+ - **`window_level_changed`'s documented callback signature said
349
+ `(window: int, level: int)`** in both `SliceViewerState`'s event table
350
+ and `DicomViewer._on_window_level_changed`'s annotation, while the
351
+ values have been floats (for MR percentile windows and dose-in-Gy
352
+ windowing) since window/level was changed to float storage. Both are
353
+ now annotated `(window: float, level: float)`.
354
+ - **`DicomViewer._update_slice_display`'s empty-primary-data branch never
355
+ requested a redraw.** Clearing the display when the primary slice is
356
+ empty (e.g. after the image is unloaded) now calls
357
+ `drawing_manager.add_request(axis)` like every other branch of this
358
+ method, so the cleared view reaches the screen immediately instead of
359
+ waiting for an unrelated redraw to happen to touch the same axis.
360
+
361
+ ### Changed
362
+
363
+ - **`SliceViewerState.__setattr__` no longer inspects the caller's stack
364
+ frame.** The observable-field write guard (redirecting e.g.
365
+ `state.blend_alpha = 0.5` through `set_blend_alpha` so the change
366
+ notification isn't silently skipped) previously walked
367
+ `inspect.currentframe()` and compared the caller's `__name__` on
368
+ *every* attribute write, including hot paths such as
369
+ `crosshair_pos` updates during a drag. It now uses a cheap `name in
370
+ self.__dict__` check instead: the very first write to an observable
371
+ field is always the dataclass-generated `__init__` populating its
372
+ default, which is let through directly since no listener could be
373
+ registered yet; every later write is an update and is redirected. Each
374
+ `set_*` method writes its own field with `object.__setattr__` so it
375
+ never re-enters itself, and the coordinated multi-field reset in
376
+ `set_primary_image_data` does the same for the fields it intentionally
377
+ resets without a per-field notification. Behaviour is unchanged (see
378
+ `TestSetattrGuard` / `TestObserverPattern` in
379
+ `tests/test_viewer_state.py`, which still pass unmodified); this is a
380
+ cost and robustness fix, not an API change.
381
+ - **`SliceViewerState.set_blend_alpha` now clamps its input to
382
+ `[0.0, 1.0]`** instead of accepting and storing an out-of-range value
383
+ verbatim, matching the range every consumer of `blend_alpha` (the
384
+ secondary-image LUT, the isodose fill alpha) already assumes.
385
+ - **`ViewerCacheManager`'s background contour-build thread pool size is
386
+ now configurable** via a `max_workers` constructor argument (default
387
+ unchanged at 8, now named `ViewerCacheManager._DEFAULT_CONTOUR_WORKERS`)
388
+ instead of a value hard-coded at the `ThreadPoolExecutor` call site.
389
+ - **`BrushEventHandler` exposes a public `remove_cursor()`** so callers
390
+ outside the class (`ViewerEventHandler.on_leave_axes`) no longer reach
391
+ into the private `_remove_brush_cursor()`.
392
+ - **`io._scan_dicom_tree` now also collects each file's SOPInstanceUID**
393
+ in its single existing pass over the DICOM tree. `_build_series_info`
394
+ uses that map to resolve a series' first file's UID (needed for
395
+ REG-matrix matching) instead of a second `pydicom.dcmread` of that file
396
+ — one fewer file read per loaded series, on top of the read-sharing
397
+ `_scan_dicom_tree` already did for REG-file discovery.
398
+ - **`roi_operations._shift_accumulate` no longer copies the input array**
399
+ when the requested shift is 0 voxels (a margin of `0.0` mm in a given
400
+ direction). `apply_margin` calls it once per anatomical direction (up
401
+ to 6 times), and a zero-margin direction previously still paid for a
402
+ full-volume copy that was immediately discarded.
403
+
404
+ ### Documentation
405
+
406
+ - `DicomViewer._update_dose_display`'s docstring incorrectly called it a
407
+ "public entry point kept for backward compatibility"; it is a private
408
+ method and is now documented as the thin per-axis wrapper around
409
+ `IsoDoseOverlay.update` that it actually is.
410
+ - `io.load_rt_dose` now notes that z-spacing for a multi-frame RT-DOSE
411
+ file is derived from `GridFrameOffsetVector` under an assumption of
412
+ uniform frame spacing, and recommends verifying against a known dose
413
+ file when integrating a new treatment-planning system's export.
414
+
415
+ ## [0.7.0] — 2026
416
+
417
+ ### Changed
418
+
419
+ - Completed the package-rename migration to `dicom_rt_viewer` started in
420
+ 0.6.0 (see the 0.6.0 entry below for the `dicom_viewer` →
421
+ `dicom_rt_viewer` import-name change and the `dicom-rt-viewer`
422
+ distribution rename): remaining internal references, packaging
423
+ metadata, and documentation were brought in line with the new name.
424
+
425
+ ## [0.6.0] — 2026
426
+
427
+ ### Fixed
428
+
429
+ - **Coordinate convention unified to pixel centers.** `compute_extent` now
430
+ returns edges half a voxel outside the first/last pixel centers, so
431
+ `imshow(extent=...)`, `TransformIndexToPhysicalPoint`, contour paths
432
+ (`mask_slice_to_paths`), the isodose grid, and the brush tool's
433
+ physical-to-pixel mapping all agree on a single physical grid. Previously
434
+ the displayed image, contours, and crosshair could disagree by up to one
435
+ voxel across the field of view. Pinned by regression tests.
436
+ - **Negative directional margins shaved the wrong face.** `apply_margin`
437
+ with a negative value (e.g. `MarginConfig(superior=-2)`) contracted the
438
+ *opposite* face of the structure. Erosion now removes the outermost layer
439
+ of the named face; dilation behaviour is unchanged.
440
+ - **`layout_mode` on an injected state was ignored.** Constructing
441
+ `DicomViewer` with `SliceViewerState(layout_mode="single")` built the
442
+ default `mpr_wide` layout with no way to switch. The viewer now builds
443
+ the layout named by the injected state.
444
+ - **Brush strokes could corrupt masks when the pointer crossed into another
445
+ view mid-drag.** A stroke is now confined to the axis it started on.
446
+ - **A destroyed viewer stayed subscribed to an injected state.**
447
+ `DicomViewer.destroy()` now unregisters every state listener it added
448
+ (including the event handler's), so a shared `SliceViewerState` no
449
+ longer keeps notifying dead Tk widgets or pinning the viewer in memory.
450
+ - **A single malformed REG file aborted `load_all_series`.** Malformed
451
+ registration entries are now logged and skipped per file.
452
+ - **Multi-valued Window Width/Center tags fell back to defaults.**
453
+ Backslash-separated DS values (common on GE consoles) now use the first
454
+ preset.
455
+ - `LayoutManager.build` and `SliceViewerState.set_layout_mode` now raise
456
+ `ValueError` for unknown layout modes instead of silently falling back
457
+ to `"mpr"`.
458
+
459
+ ### Changed
460
+
461
+ - **Breaking: import package renamed from `dicom_viewer` to
462
+ `dicom_rt_viewer`**, matching the distribution name (hyphens are not
463
+ valid in Python identifiers, so the import name uses underscores in
464
+ their place). Update `from dicom_viewer import ...` to
465
+ `from dicom_rt_viewer import ...`.
466
+ - **Distribution renamed to `dicom-rt-viewer`.** The import package was
467
+ initially left as `dicom_viewer`; see the entry above for its rename to
468
+ `dicom_rt_viewer`.
469
+ - **`load_rt_struct` raises `RtStructLoadError`** when the file cannot be
470
+ parsed, instead of returning an empty dict indistinguishable from an
471
+ empty structure set. ROI mask decoding is now sequential by default;
472
+ parallel decoding is opt-in via the new `max_workers` parameter.
473
+ - **`StructureSet` entries are typed.** `get_all()` returns
474
+ `dict[int, RoiEntry]` (a dataclass with `name` / `mask` / `color`)
475
+ instead of `dict[int, dict[str, Any]]`; `StructureSet.update` rejects
476
+ unknown property keys with `ValueError`.
477
+ - **Event names are constants.** All `SliceViewerState` event names are
478
+ declared in the new `dicom_rt_viewer.events` module; `_notify` validates
479
+ event names at dispatch time.
480
+ - **Direct writes to observable state fields are redirected through their
481
+ setters** (e.g. `state.blend_alpha = 0.5` now notifies listeners), so
482
+ bypassing a setter can no longer silently desynchronise the display.
483
+ - **`window_level` is now `tuple[float, float]`** (was `tuple[int, int]`)
484
+ to preserve precision for percentile-derived MR windows and dose
485
+ displays.
486
+ - `DicomViewer.destroy()` closes the state's thread pool only when the
487
+ viewer created the state itself; injected states are owned by their
488
+ creator.
489
+ - `DicomViewer.metadata` always returns the keys `spacing` / `origin` /
490
+ `size` (each `None` when no image is loaded).
491
+ - PageUp / PageDown now step ±10 slices (Up / Down remain ±1).
492
+ - mypy configuration changed from `strict = true` (which the codebase did
493
+ not satisfy) to an enforced realistic baseline (`check_untyped_defs`,
494
+ `warn_return_any`, etc.); the package now ships a `py.typed` marker.
495
+ Restoring full strict mode is future work.
496
+
497
+ ### Added
498
+
499
+ - Test suite (`tests/`) covering the coordinate convention, margin
500
+ directions, boolean operations, LUT/RGBA rendering, the observer
501
+ pattern, the setter guard, `StructureSet`, and the memory /
502
+ performance optimisations below.
503
+
504
+ ### Performance & memory
505
+
506
+ - **Image / mask / dose caches are now zero-copy views.** The primary and
507
+ secondary image caches, per-ROI mask volumes, and the resampled dose
508
+ volume are kept as `GetArrayViewFromImage` views instead of separate
509
+ copies. Per-slice float promotion happens in `slice_to_rgba` at render
510
+ time (<0.1 ms per 512x512 slice). This removes the standing float32 copy
511
+ of the CT (~200 MB for 512x512x200) and the duplicate uint8 copy of every
512
+ ROI mask (~50 MB each, ~1 GB across 20 ROIs). Each cache keeps a strong
513
+ reference to the backing `sitk.Image`, so a cached view can never dangle.
514
+ - **Resampled dose stored as float32** (down from float64), halving the
515
+ resampled dose volume's footprint.
516
+ - **4DCT phases are resampled lazily with an LRU cache.** `set_all_phases`
517
+ no longer resamples every phase up front; each phase is resampled to the
518
+ primary grid on first activation and the most-recent
519
+ `max_cached_phases` (default 3) results are cached. Peak memory now
520
+ scales with the number of *recently viewed* phases rather than the total
521
+ phase count.
522
+ - **RGBA render buffers are reused across frames.** `slice_to_rgba` accepts
523
+ an optional `out` buffer; the viewer keeps one per axis per layer, cutting
524
+ the per-frame RGBA conversion cost roughly 4x (measured 3.4 ms -> 0.8 ms
525
+ for a 512x512 slice), which is paid on every scroll / window-level /
526
+ crosshair-drag frame.
527
+ - **Breaking: `all_phases_data["..."]["sitk_image"]` is no longer
528
+ pre-resampled to the primary grid.** `set_all_phases` now stores each
529
+ phase's raw image and defers resampling to first activation (see below),
530
+ so listeners of `"phases_data_loaded"` that read geometry directly from
531
+ `all_phases_data` must resample themselves via `get_resampled_image`, or
532
+ read the resampled volume through `set_active_phase_as_secondary` /
533
+ the secondary-image cache instead.
534
+ - **Background contour build skips empty slices.** The mask is projected
535
+ onto each axis once (a cheap `any()` reduction) so `find_contours` runs
536
+ only on occupied slices, which are a small fraction of the volume for a
537
+ typical ROI. Measured ~3x faster build with byte-identical output.
538
+ - GitHub Actions CI: Black, isort, mypy, and pytest on every push / PR.
539
+ - `pyproject.toml` metadata: authors, URLs, classifiers, keywords, and
540
+ Black / isort / pytest tool configuration.
541
+ - README: PyPI installation, medical-device disclaimer, threading-model
542
+ documentation, state-event documentation, and development instructions.
543
+
544
+ ### Removed
545
+
546
+ - Unused backward-compatibility shims from the pre-release internal API:
547
+ `DicomViewer.axis_vars` (and the `_IndexVarProxy` / `_SingleVar`
548
+ adapters) and the `_axis_to_xyz_index` / `_axis_to_numpy_index` /
549
+ `_update_crosshair_by_index` aliases.
550
+
551
+ ## [0.5.1] — 2026
552
+
553
+ - Fix partial-blit bounding-box mismatch under `constrained_layout=True`
554
+ (visual ghosting in embedded hosts).
555
+ - Add the `add_overlay_artist` / `remove_overlay_artist` API so host
556
+ applications' custom Matplotlib artists survive blit restores.
557
+
558
+ ## [0.5.0] — 2026
559
+
560
+ - Add the `"single"` layout mode (one full-figure Axes keyed as
561
+ `"axial"`).
562
+
563
+ ## [0.4.x] — 2025–2026
564
+
565
+ - Split the package into `state/`, `rendering/`, and `event_controllers/`
566
+ sub-packages with dependency-injected collaborators.
567
+ - Blit-based idle-driven rendering (`DrawingManager`), per-slice contour
568
+ path caching, and background contour builds.
569
+ - RT-DOSE loading, isodose fill/line overlay, and the DVH panel.
570
+ - RT-STRUCT read/write, ROI operations (interpolation, margins, smoothing,
571
+ boolean operations, slice thinning), brush tool, and bounding-box tool.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 oki1002
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.