tk-rt-viewer 2.3.2__tar.gz → 2.3.3__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 (70) hide show
  1. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/CHANGELOG.md +12 -0
  2. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/PKG-INFO +6 -1
  3. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/README.md +5 -0
  4. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/__init__.py +1 -1
  5. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/io.py +2 -2
  6. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rtstruct_io.py +36 -8
  7. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/tests/test_rtstruct_io.py +105 -2
  8. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/.gitattributes +0 -0
  9. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/.github/workflows/ci.yml +0 -0
  10. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/.gitignore +0 -0
  11. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/LICENSE +0 -0
  12. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/pyproject.toml +0 -0
  13. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/__init__.py +0 -0
  14. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/bbox3d_handler.py +0 -0
  15. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/bbox_handler.py +0 -0
  16. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/brush_handler.py +0 -0
  17. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/crosshair_handler.py +0 -0
  18. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/rect_drag.py +0 -0
  19. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/event_controllers/viewer_events.py +0 -0
  20. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/events.py +0 -0
  21. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/geometry.py +0 -0
  22. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/isodose_levels.py +0 -0
  23. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/protocols.py +0 -0
  24. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/py.typed +0 -0
  25. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/reg_io.py +0 -0
  26. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/__init__.py +0 -0
  27. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/deformable.py +0 -0
  28. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/errors.py +0 -0
  29. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/params.py +0 -0
  30. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/rigid.py +0 -0
  31. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/session.py +0 -0
  32. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/registration/template.py +0 -0
  33. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/__init__.py +0 -0
  34. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/blit_compositor.py +0 -0
  35. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/contour_overlay.py +0 -0
  36. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/drawing_manager.py +0 -0
  37. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/dvh.py +0 -0
  38. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/image_layer.py +0 -0
  39. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/isodose.py +0 -0
  40. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/layout.py +0 -0
  41. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/render.py +0 -0
  42. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/rendering/view_axes.py +0 -0
  43. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/roi_operations.py +0 -0
  44. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/__init__.py +0 -0
  45. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/dose_manager.py +0 -0
  46. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/phase_manager.py +0 -0
  47. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/roi_editor.py +0 -0
  48. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/roi_manager.py +0 -0
  49. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/secondary_manager.py +0 -0
  50. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/structure_set.py +0 -0
  51. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/viewer_cache.py +0 -0
  52. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/state/viewer_state.py +0 -0
  53. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/viewer.py +0 -0
  54. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/src/tk_rt_viewer/window_level.py +0 -0
  55. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/tests/test_bbox3d.py +0 -0
  56. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/tests/test_bbox3d_handler.py +0 -0
  57. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/tests/test_brush_handler.py +0 -0
  58. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/tests/test_event_controllers.py +0 -0
  59. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/tests/test_geometry.py +0 -0
  60. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/tests/test_io.py +0 -0
  61. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/tests/test_isodose_levels.py +0 -0
  62. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/tests/test_performance_opt.py +0 -0
  63. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/tests/test_reg_io.py +0 -0
  64. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/tests/test_registration.py +0 -0
  65. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/tests/test_render.py +0 -0
  66. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/tests/test_rendering.py +0 -0
  67. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/tests/test_roi_operations.py +0 -0
  68. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/tests/test_state_extensions.py +0 -0
  69. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/tests/test_state_managers.py +0 -0
  70. {tk_rt_viewer-2.3.2 → tk_rt_viewer-2.3.3}/tests/test_viewer_state.py +0 -0
@@ -4,6 +4,18 @@ 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.3]
8
+
9
+ ### Added
10
+
11
+ - `rtstruct_io.mask2rtstruct` and `rtstruct_io.save_structure_set` take a
12
+ keyword-only `add_roi_kwargs` mapping, forwarded to rt-utils'
13
+ `RTStruct.add_roi` for every ROI. Callers can now set
14
+ `approximate_contours`, `use_pin_hole`, `roi_generation_algorithm`,
15
+ `description`, etc. `mask`, `color` and `name` still come from the
16
+ structures and raise `ValueError` if given here. The default (`None`)
17
+ keeps the previous behaviour.
18
+
7
19
  ## [2.3.2]
8
20
 
9
21
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tk-rt-viewer
3
- Version: 2.3.2
3
+ Version: 2.3.3
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
@@ -374,6 +374,11 @@ written = save_structure_set(
374
374
  )
375
375
  ```
376
376
 
377
+ Pass `add_roi_kwargs` to forward options to rt-utils' `RTStruct.add_roi`
378
+ for every ROI, e.g. `add_roi_kwargs={"approximate_contours": False}` to
379
+ keep every contour vertex instead of the simplified outline. `mask2rtstruct`
380
+ accepts the same keyword.
381
+
377
382
  `rt_utils.RTStruct.save` silently appends `.dcm` to a path that does not
378
383
  already end with it, with no way to opt out; `mask2rtstruct` resolves
379
384
  against that internally so its `exists()` check, its log messages, and its
@@ -337,6 +337,11 @@ written = save_structure_set(
337
337
  )
338
338
  ```
339
339
 
340
+ Pass `add_roi_kwargs` to forward options to rt-utils' `RTStruct.add_roi`
341
+ for every ROI, e.g. `add_roi_kwargs={"approximate_contours": False}` to
342
+ keep every contour vertex instead of the simplified outline. `mask2rtstruct`
343
+ accepts the same keyword.
344
+
340
345
  `rt_utils.RTStruct.save` silently appends `.dcm` to a path that does not
341
346
  already end with it, with no way to opt out; `mask2rtstruct` resolves
342
347
  against that internally so its `exists()` check, its log messages, and its
@@ -104,7 +104,7 @@ __all__ = [
104
104
  "StructureSet",
105
105
  "to_gy_pairs",
106
106
  ]
107
- __version__ = "2.3.2"
107
+ __version__ = "2.3.3"
108
108
 
109
109
 
110
110
  def __getattr__(name: str) -> Any:
@@ -55,7 +55,7 @@ import re
55
55
  from collections.abc import Iterable, Iterator, Sequence
56
56
  from concurrent.futures import ThreadPoolExecutor
57
57
  from dataclasses import dataclass, field, replace
58
- from typing import TypedDict
58
+ from typing import TypedDict, cast
59
59
 
60
60
  import numpy as np
61
61
  import pydicom
@@ -672,7 +672,7 @@ def _read_files(
672
672
  """Read *file_names* as one series, keeping per-slice metadata on *reader*."""
673
673
  reader.SetFileNames(file_names)
674
674
  reader.MetaDataDictionaryArrayUpdateOn()
675
- image = reader.Execute()
675
+ image = cast(sitk.Image, reader.Execute())
676
676
  logger.info(f"Series '{series_id}' loaded with {len(file_names)} files.")
677
677
  return image
678
678
 
@@ -8,13 +8,15 @@ load_rt_struct(ct_dir, rtstruct_path, progress_callback=None, max_workers=1)
8
8
  and display metadata. Raises RtStructLoadError if the file itself
9
9
  cannot be parsed.
10
10
 
11
- mask2rtstruct(ct_dir, rtss_path, structures) -> pathlib.Path
11
+ mask2rtstruct(ct_dir, rtss_path, structures, *, replace_existing=True,
12
+ add_roi_kwargs=None) -> pathlib.Path
12
13
  Convert NumPy mask arrays to an RT-STRUCT DICOM file, creating or
13
14
  updating as appropriate. Returns the path actually written to (rt-utils
14
- appends ".dcm" to a path that lacks it).
15
+ appends ".dcm" to a path that lacks it). add_roi_kwargs is forwarded to
16
+ rt-utils' RTStruct.add_roi (e.g. approximate_contours).
15
17
 
16
18
  save_structure_set(structure_set, ct_dir, rtss_path, lps_image,
17
- original_image=None) -> int
19
+ original_image=None, *, add_roi_kwargs=None) -> int
18
20
  Write every ROI of a StructureSet to an RT-STRUCT file, resampling
19
21
  each mask back to the original DICOM geometry first.
20
22
 
@@ -29,7 +31,7 @@ random_hex_color() -> str
29
31
  import logging
30
32
  import pathlib
31
33
  from collections import Counter
32
- from collections.abc import Callable
34
+ from collections.abc import Callable, Mapping
33
35
  from concurrent.futures import ThreadPoolExecutor, as_completed
34
36
  from typing import TYPE_CHECKING, Any, TypedDict
35
37
 
@@ -63,6 +65,9 @@ _COLOR_RNG: np.random.Generator = np.random.default_rng()
63
65
  #: Suffix rt-utils' ``RTStruct.save()`` silently appends to any path lacking it.
64
66
  _RTSTRUCT_SUFFIX = ".dcm"
65
67
 
68
+ #: add_roi keywords mask2rtstruct fills in itself from *structures*
69
+ _RESERVED_ADD_ROI_KEYS = frozenset({"mask", "color", "name"})
70
+
66
71
 
67
72
  def _resolved_rtss_path(rtss_path: pathlib.Path) -> pathlib.Path:
68
73
  """Return the path rt-utils will actually write *rtss_path* to.
@@ -97,6 +102,8 @@ def save_structure_set(
97
102
  rtss_path: str | pathlib.Path,
98
103
  lps_image: sitk.Image,
99
104
  original_image: sitk.Image | None = None,
105
+ *,
106
+ add_roi_kwargs: Mapping[str, Any] | None = None,
100
107
  ) -> int:
101
108
  """Write every ROI of *structure_set* to an RT-STRUCT file.
102
109
 
@@ -115,13 +122,15 @@ def save_structure_set(
115
122
  original_image: The CT before LPS alignment
116
123
  (``SeriesInfo["original_sitk_image"]``). ``None`` when the series
117
124
  needed no reorientation; the masks are then written as they are.
125
+ add_roi_kwargs: Forwarded to :func:`mask2rtstruct`.
118
126
 
119
127
  Returns:
120
128
  The number of ROIs written.
121
129
 
122
130
  Raises:
123
131
  ValueError: If *structure_set* contains no ROI with a usable mask,
124
- since that would write an RT-STRUCT with no structures at all.
132
+ since that would write an RT-STRUCT with no structures at all,
133
+ or if *add_roi_kwargs* contains a reserved key.
125
134
  RuntimeError: If rt-utils rejects an ROI (propagated from
126
135
  :func:`mask2rtstruct`).
127
136
  """
@@ -146,7 +155,9 @@ def save_structure_set(
146
155
  if not structures:
147
156
  raise ValueError("Structure set contains no ROI with a mask to save.")
148
157
 
149
- written_path = mask2rtstruct(ct_dir, rtss_path, structures)
158
+ written_path = mask2rtstruct(
159
+ ct_dir, rtss_path, structures, add_roi_kwargs=add_roi_kwargs
160
+ )
150
161
  logger.info(f"Structure set saved to '{written_path}' ({len(structures)} ROIs).")
151
162
  return len(structures)
152
163
 
@@ -333,6 +344,7 @@ def mask2rtstruct(
333
344
  structures: dict[int, dict[str, Any]],
334
345
  *,
335
346
  replace_existing: bool = True,
347
+ add_roi_kwargs: Mapping[str, Any] | None = None,
336
348
  ) -> pathlib.Path:
337
349
  """Write ``(D, H, W)`` mask arrays to an RT-STRUCT DICOM file.
338
350
 
@@ -346,17 +358,32 @@ def mask2rtstruct(
346
358
  rebuilt so it holds exactly *structures*. ``False`` appends to the
347
359
  existing file instead; rt-utils cannot remove ROIs, so saving the
348
360
  same ROIs twice that way duplicates them.
361
+ add_roi_kwargs: Extra keyword arguments forwarded to rt-utils'
362
+ ``RTStruct.add_roi`` for every ROI, e.g.
363
+ ``{"approximate_contours": False, "use_pin_hole": True}``.
364
+ ``mask``, ``color`` and ``name`` are taken from *structures*
365
+ and must not be given here.
349
366
 
350
367
  Returns:
351
368
  The path actually written.
352
369
 
353
370
  Raises:
354
- ValueError: If *rtss_path* is ``None``.
355
- RuntimeError: If any ROI cannot be added to the RT-STRUCT builder.
371
+ ValueError: If *rtss_path* is ``None`` or *add_roi_kwargs* contains
372
+ a reserved key.
373
+ RuntimeError: If any ROI cannot be added to the RT-STRUCT builder
374
+ (including unknown keys in *add_roi_kwargs*).
356
375
  """
357
376
  if rtss_path is None:
358
377
  raise ValueError("rtss_path must not be None; provide a concrete output path.")
359
378
 
379
+ roi_options = dict(add_roi_kwargs or {})
380
+ reserved = _RESERVED_ADD_ROI_KEYS & roi_options.keys()
381
+ if reserved:
382
+ raise ValueError(
383
+ f"add_roi_kwargs must not contain {sorted(reserved)}; "
384
+ "they are taken from structures."
385
+ )
386
+
360
387
  ct_dir = pathlib.Path(ct_dir)
361
388
  rtss_path = _resolved_rtss_path(pathlib.Path(rtss_path))
362
389
 
@@ -382,6 +409,7 @@ def mask2rtstruct(
382
409
  mask=np.transpose(roi_data["mask"], (1, 2, 0)).astype(bool, copy=False),
383
410
  color=roi_data["color"],
384
411
  name=roi_name,
412
+ **roi_options,
385
413
  )
386
414
  except Exception as exc:
387
415
  logger.error(f"Failed to add ROI '{roi_name}': {exc}")
@@ -22,16 +22,17 @@ from tk_rt_viewer.state.viewer_state import StructureSet
22
22
 
23
23
  @pytest.fixture()
24
24
  def captured_structures(monkeypatch) -> dict:
25
- """Capture the structures dict (and replace_existing) passed to mask2rtstruct."""
25
+ """Capture the arguments passed to mask2rtstruct."""
26
26
  captured: dict = {}
27
27
 
28
28
  def fake_mask2rtstruct(
29
- ct_dir, rtss_path, structures, *, replace_existing=True
29
+ ct_dir, rtss_path, structures, *, replace_existing=True, add_roi_kwargs=None
30
30
  ) -> pathlib.Path:
31
31
  captured["ct_dir"] = ct_dir
32
32
  captured["rtss_path"] = rtss_path
33
33
  captured["structures"] = structures
34
34
  captured["replace_existing"] = replace_existing
35
+ captured["add_roi_kwargs"] = add_roi_kwargs
35
36
  return pathlib.Path(rtss_path)
36
37
 
37
38
  monkeypatch.setattr(rtstruct_io, "mask2rtstruct", fake_mask2rtstruct)
@@ -142,6 +143,22 @@ class TestSaveStructureSet:
142
143
 
143
144
  assert captured_structures["replace_existing"] is True
144
145
 
146
+ def test_add_roi_kwargs_is_forwarded(self, captured_structures) -> None:
147
+ lps = _image()
148
+ structure_set = StructureSet()
149
+ structure_set.add("PTV", _mask(lps), "#ff0000")
150
+ options = {"approximate_contours": False}
151
+
152
+ rtstruct_io.save_structure_set(
153
+ structure_set,
154
+ "/ct",
155
+ "/out/rs.dcm",
156
+ lps_image=lps,
157
+ add_roi_kwargs=options,
158
+ )
159
+
160
+ assert captured_structures["add_roi_kwargs"] == options
161
+
145
162
 
146
163
  class TestSaveStructureSetResampling:
147
164
  """Resampling must happen only when there is a different geometry to reach."""
@@ -344,6 +361,92 @@ class TestMask2RtStructImplicitDcmSuffix:
344
361
  assert written == out
345
362
 
346
363
 
364
+ class TestMask2RtStructAddRoiKwargs:
365
+ """``add_roi_kwargs`` reaches ``RTStruct.add_roi`` for every ROI."""
366
+
367
+ @staticmethod
368
+ def _structures() -> dict:
369
+ mask = np.zeros((4, 8, 8), dtype=bool)
370
+ mask[1:3, 2:6, 2:6] = True
371
+ return {
372
+ 1: {"name": "PTV", "mask": mask, "color": "#ff0000"},
373
+ 2: {"name": "CTV", "mask": mask.copy(), "color": "#00ff00"},
374
+ }
375
+
376
+ @staticmethod
377
+ def _ct_dir(tmp_path) -> pathlib.Path:
378
+ ct_dir = tmp_path / "ct"
379
+ ct_dir.mkdir()
380
+ _write_minimal_ct_series(ct_dir)
381
+ return ct_dir
382
+
383
+ def test_options_are_passed_to_every_add_roi_call(
384
+ self, tmp_path, monkeypatch
385
+ ) -> None:
386
+ from rt_utils import RTStruct
387
+
388
+ calls: list[dict] = []
389
+ real_add_roi = RTStruct.add_roi
390
+
391
+ def spy(self, **kwargs):
392
+ calls.append(kwargs)
393
+ return real_add_roi(self, **kwargs)
394
+
395
+ monkeypatch.setattr(RTStruct, "add_roi", spy)
396
+
397
+ rtstruct_io.mask2rtstruct(
398
+ self._ct_dir(tmp_path),
399
+ tmp_path / "rs.dcm",
400
+ self._structures(),
401
+ add_roi_kwargs={"approximate_contours": False, "description": "auto"},
402
+ )
403
+
404
+ assert len(calls) == 2
405
+ assert all(c["approximate_contours"] is False for c in calls)
406
+ assert all(c["description"] == "auto" for c in calls)
407
+
408
+ def test_none_keeps_the_rt_utils_defaults(self, tmp_path, monkeypatch) -> None:
409
+ from rt_utils import RTStruct
410
+
411
+ calls: list[dict] = []
412
+ real_add_roi = RTStruct.add_roi
413
+
414
+ def spy(self, **kwargs):
415
+ calls.append(kwargs)
416
+ return real_add_roi(self, **kwargs)
417
+
418
+ monkeypatch.setattr(RTStruct, "add_roi", spy)
419
+
420
+ rtstruct_io.mask2rtstruct(
421
+ self._ct_dir(tmp_path), tmp_path / "rs.dcm", self._structures()
422
+ )
423
+
424
+ assert all(set(c) == {"mask", "color", "name"} for c in calls)
425
+
426
+ @pytest.mark.parametrize("key", ["mask", "color", "name"])
427
+ def test_reserved_keys_are_rejected_before_writing(self, tmp_path, key) -> None:
428
+ out = tmp_path / "rs.dcm"
429
+
430
+ with pytest.raises(ValueError, match=key):
431
+ rtstruct_io.mask2rtstruct(
432
+ tmp_path / "missing_ct",
433
+ out,
434
+ self._structures(),
435
+ add_roi_kwargs={key: None},
436
+ )
437
+
438
+ assert not out.exists()
439
+
440
+ def test_an_unknown_option_raises_runtime_error(self, tmp_path) -> None:
441
+ with pytest.raises(RuntimeError, match="PTV"):
442
+ rtstruct_io.mask2rtstruct(
443
+ self._ct_dir(tmp_path),
444
+ tmp_path / "rs.dcm",
445
+ self._structures(),
446
+ add_roi_kwargs={"no_such_option": True},
447
+ )
448
+
449
+
347
450
  class TestLoadRtStructDuplicateNames:
348
451
  """Pins a 2.0.1 fix: two ROIs sharing a name must not collapse onto one mask.
349
452
 
File without changes
File without changes