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.
- tk_rt_viewer-1.1.0/.gitattributes +2 -0
- tk_rt_viewer-1.1.0/.gitignore +61 -0
- tk_rt_viewer-1.1.0/CHANGELOG.md +571 -0
- tk_rt_viewer-1.1.0/LICENSE +21 -0
- tk_rt_viewer-1.1.0/PKG-INFO +547 -0
- tk_rt_viewer-1.1.0/README.md +509 -0
- tk_rt_viewer-1.1.0/pyproject.toml +107 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/__init__.py +101 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/event_controllers/bbox_handler.py +270 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/event_controllers/brush_handler.py +526 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/event_controllers/crosshair_handler.py +124 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/event_controllers/viewer_events.py +313 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/events.py +67 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/geometry.py +174 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/io.py +535 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/isodose_levels.py +113 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/rendering/contour_overlay.py +191 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/rendering/drawing_manager.py +75 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/rendering/dvh.py +138 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/rendering/isodose.py +399 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/rendering/layout.py +92 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/rendering/render.py +97 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/roi_operations.py +379 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/rtstruct_io.py +382 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/state/phase_manager.py +199 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/state/structure_set.py +170 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/state/viewer_cache.py +556 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/state/viewer_state.py +1406 -0
- tk_rt_viewer-1.1.0/src/tk_rt_viewer/viewer.py +1259 -0
- tk_rt_viewer-1.1.0/tests/test_brush_handler.py +167 -0
- tk_rt_viewer-1.1.0/tests/test_geometry.py +125 -0
- tk_rt_viewer-1.1.0/tests/test_io.py +31 -0
- tk_rt_viewer-1.1.0/tests/test_isodose_levels.py +77 -0
- tk_rt_viewer-1.1.0/tests/test_performance_opt.py +208 -0
- tk_rt_viewer-1.1.0/tests/test_render.py +62 -0
- tk_rt_viewer-1.1.0/tests/test_roi_operations.py +216 -0
- tk_rt_viewer-1.1.0/tests/test_rtstruct_io.py +165 -0
- tk_rt_viewer-1.1.0/tests/test_viewer_state.py +463 -0
|
@@ -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.
|