amithjkamath commited on
Commit
ec11fc4
·
unverified ·
2 Parent(s): d3970a7faad21c

Merge branch 'main' into deploy

Browse files
Files changed (8) hide show
  1. .gitignore +10 -0
  2. Makefile +46 -5
  3. README.md +48 -2
  4. app.py +1498 -68
  5. pyproject.toml +2 -0
  6. requirements.txt +2 -0
  7. src/rtssdiffviewer/diff_core.py +175 -0
  8. tests/test_app_features.py +298 -0
.gitignore CHANGED
@@ -1,6 +1,16 @@
1
  __pycache__/
2
  *.pyc
 
3
  .venv/
4
  .env
5
  .DS_Store
6
  .streamlit/
 
 
 
 
 
 
 
 
 
 
1
  __pycache__/
2
  *.pyc
3
+ *.pyo
4
  .venv/
5
  .env
6
  .DS_Store
7
  .streamlit/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .ipynb_checkpoints/
14
+ *.egg-info/
15
+ build/
16
+ dist/
Makefile CHANGED
@@ -1,22 +1,48 @@
1
  PYTHON ?= python3
 
 
2
  VENV ?= .venv
3
  PIP := $(VENV)/bin/pip
 
4
  STREAMLIT := $(VENV)/bin/streamlit
5
 
6
  SPACE ?= amithjkamath/rtssdiffviewer
7
  HF_REMOTE ?= hf
8
  DEPLOY_BRANCH ?= deploy
9
 
10
- .PHONY: install run fmt lint clean deploy-init deploy status
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  install:
13
- $(PYTHON) -m venv $(VENV)
14
- $(PIP) install --upgrade pip
15
- $(PIP) install -r requirements.txt
16
 
17
  run:
18
  $(STREAMLIT) run app.py
19
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  fmt:
21
  @echo "No formatter configured."
22
 
@@ -28,7 +54,16 @@ lint:
28
  fi
29
 
30
  clean:
31
- rm -rf $(VENV) __pycache__ src/rtssdiffviewer/__pycache__
 
 
 
 
 
 
 
 
 
32
 
33
  deploy-init:
34
  @if git remote | grep -q '^$(HF_REMOTE)$$'; then \
@@ -47,5 +82,11 @@ deploy-init:
47
  deploy:
48
  bash deploy.sh $(SPACE)
49
 
 
 
 
 
 
 
50
  status:
51
  @echo "Space URL: https://huggingface.co/spaces/$(SPACE)"
 
1
  PYTHON ?= python3
2
+ UV ?= uv
3
+ PYTHON_VERSION ?= 3.11
4
  VENV ?= .venv
5
  PIP := $(VENV)/bin/pip
6
+ PYTEST := $(VENV)/bin/pytest
7
  STREAMLIT := $(VENV)/bin/streamlit
8
 
9
  SPACE ?= amithjkamath/rtssdiffviewer
10
  HF_REMOTE ?= hf
11
  DEPLOY_BRANCH ?= deploy
12
 
13
+ .PHONY: check-uv venv install run test test-all fmt lint clean clean-venv clean-py deploy-init deploy deploy-now status
14
+
15
+ check-uv:
16
+ @command -v $(UV) >/dev/null 2>&1 || { \
17
+ echo "uv is required. Install from https://docs.astral.sh/uv/getting-started/installation/"; \
18
+ exit 1; \
19
+ }
20
+
21
+ venv:
22
+ $(MAKE) check-uv
23
+ rm -rf $(VENV)
24
+ $(UV) venv --python $(PYTHON_VERSION) $(VENV)
25
 
26
  install:
27
+ $(MAKE) venv
28
+ $(UV) pip install --python $(VENV)/bin/python -r requirements.txt
29
+ $(UV) pip install --python $(VENV)/bin/python -e .
30
 
31
  run:
32
  $(STREAMLIT) run app.py
33
 
34
+ test:
35
+ @if [ -x "$(PYTEST)" ]; then \
36
+ $(PYTEST) -q; \
37
+ else \
38
+ echo "pytest is not installed in $(VENV). Run 'make install' first."; \
39
+ exit 1; \
40
+ fi
41
+
42
+ test-all:
43
+ $(MAKE) install
44
+ $(MAKE) test
45
+
46
  fmt:
47
  @echo "No formatter configured."
48
 
 
54
  fi
55
 
56
  clean:
57
+ $(MAKE) clean-venv
58
+ $(MAKE) clean-py
59
+
60
+ clean-venv:
61
+ rm -rf $(VENV)
62
+
63
+ clean-py:
64
+ find . -type d -name "__pycache__" -prune -exec rm -rf {} +
65
+ find . -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete
66
+ rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov .ipynb_checkpoints
67
 
68
  deploy-init:
69
  @if git remote | grep -q '^$(HF_REMOTE)$$'; then \
 
82
  deploy:
83
  bash deploy.sh $(SPACE)
84
 
85
+ deploy-now:
86
+ $(MAKE) deploy-init
87
+ @git add -A && git commit -m "Deploy: $(shell date '+%Y-%m-%d %H:%M:%S')" || true
88
+ @git push $(HF_REMOTE) $(DEPLOY_BRANCH):main
89
+ @echo "Deployment complete. Space: https://huggingface.co/spaces/$(SPACE)"
90
+
91
  status:
92
  @echo "Space URL: https://huggingface.co/spaces/$(SPACE)"
README.md CHANGED
@@ -15,16 +15,62 @@ A standalone Streamlit app for:
15
 
16
  - converting RTSS `.dcm` files to JSON
17
  - downloading normalized JSON outputs
18
- - visualizing git-like diffs between two RTSS versions
19
  - batch-uploading multiple variants and switching any two versions for comparison
 
20
 
21
- ## Local Setup
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  ```bash
24
  make install
25
  make run
26
  ```
27
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  ## Hugging Face Spaces Deployment
29
 
30
  Use the deploy workflow in this repository:
 
15
 
16
  - converting RTSS `.dcm` files to JSON
17
  - downloading normalized JSON outputs
18
+ - visualizing intelligent diffs between two RTSS versions (slice-by-slice for contours)
19
  - batch-uploading multiple variants and switching any two versions for comparison
20
+ - efficiently handling large RTSS files with many contour points
21
 
22
+ ## Component Comparison Modes
23
+
24
+ The app supports comparing four different components:
25
+
26
+ - **metadata**: DICOM package-level tags using standard unified text diff (fast)
27
+ - **structures**: ROI structure definitions using text diff
28
+ - **references**: Reference frame sequences using text diff
29
+ - **contours**: Point-cloud comparison grouped by slice plane (z-coordinate)
30
+ - Optimized for large files with many contour points (>50)
31
+ - Two-panel left-right layout for easy visual comparison
32
+ - Shows points organized by slice rather than full text diff
33
+ - Clearly marks which ROIs/slices are identical, different, or missing
34
+ - Much faster and more actionable for clinical workflows
35
+
36
+ ## Contour Comparison Features
37
+
38
+ When comparing contour data:
39
+ - **Slice-by-slice organization**: Each z-coordinate (slice plane) shown in a collapsible expander
40
+ - **Two-panel layout**: Left file contents on the left, right file contents on the right
41
+ - **Visual indicators**:
42
+ - ✓ Identical ROI and points
43
+ - ⚠️ Different points in ROI
44
+ - ⊘ ROI only exists in one file
45
+ - ✅ Entire slice is identical
46
+ - ❌ No contour data on this slice
47
+ - **Component equality**: When metadata, structures, or references are identical, a clear message indicates no differences
48
+
49
+ ## User Workflows
50
+
51
+ - `Instructions` mode: in-app overview of Pair Mode and Batch Compare usage.
52
+ - `Pair Mode`: upload two RTSS files, inspect structured diff by component, and view contour points in 3D.
53
+ - `Batch Compare`: upload multiple RTSS files and compare any two using your choice of comparison component.
54
+
55
+ ## Developer Setup
56
 
57
  ```bash
58
  make install
59
  make run
60
  ```
61
 
62
+ ## Developer Testing
63
+
64
+ ```bash
65
+ make test
66
+ ```
67
+
68
+ To install dependencies and run tests in one step:
69
+
70
+ ```bash
71
+ make test-all
72
+ ```
73
+
74
  ## Hugging Face Spaces Deployment
75
 
76
  Use the deploy workflow in this repository:
app.py CHANGED
@@ -5,9 +5,11 @@ from __future__ import annotations
5
 
6
  import sys
7
  import tempfile
 
8
  from pathlib import Path
9
  from typing import Any
10
 
 
11
  import streamlit as st
12
 
13
  ROOT = Path(__file__).resolve().parent
@@ -19,6 +21,8 @@ from rtssdiffviewer.dcm_to_json import dcm_to_json # noqa: E402
19
  from rtssdiffviewer.diff_core import ( # noqa: E402
20
  COMPONENT_KEYS,
21
  DEFAULT_VOLATILE_TAG_PREFIXES,
 
 
22
  normalize_value,
23
  pretty_json_text,
24
  select_component,
@@ -47,6 +51,539 @@ def ensure_state() -> None:
47
  st.session_state.setdefault("left_json_raw", None)
48
  st.session_state.setdefault("right_json_raw", None)
49
  st.session_state.setdefault("batch_variants", {})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
 
52
  def render_diff_panel(
@@ -58,15 +595,315 @@ def render_diff_panel(
58
  component: str,
59
  precision: int,
60
  keep_volatile: bool,
 
 
 
61
  key_prefix: str,
62
  ) -> None:
63
  ignore_prefixes: set[str] = set()
64
  if not keep_volatile:
65
  ignore_prefixes.update(DEFAULT_VOLATILE_TAG_PREFIXES)
66
 
 
67
  left_selected = select_component(left_raw, component)
68
  right_selected = select_component(right_raw, component)
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  left_norm = normalize_value(left_selected, precision, ignore_prefixes)
71
  right_norm = normalize_value(right_selected, precision, ignore_prefixes)
72
 
@@ -101,8 +938,23 @@ def render_diff_panel(
101
  key=f"{key_prefix}_dl_diff",
102
  )
103
 
 
 
 
 
 
 
 
 
104
  st.markdown("### Diff View")
105
- if diff_viewer is not None:
 
 
 
 
 
 
 
106
  diff_viewer(left_text, right_text, split_view=True)
107
  else:
108
  if diff_text.strip():
@@ -111,60 +963,626 @@ def render_diff_panel(
111
  st.success("No differences after normalization and filtering.")
112
 
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  def main() -> None:
115
  st.set_page_config(page_title="RTSS Diff Viewer", layout="wide")
116
  ensure_state()
117
 
118
  st.title("RTSS Diff Viewer")
119
- st.caption("Upload RTSS .dcm files, convert to JSON, and compare with git-like diffs.")
120
-
121
- with st.sidebar:
122
- st.header("Diff Controls")
123
- component_options = ["all", "metadata", *COMPONENT_KEYS.keys()]
124
- component = st.selectbox("Component", options=component_options, index=0)
125
- precision = st.slider("Float precision", min_value=2, max_value=10, value=6)
126
- keep_volatile = st.checkbox("Keep volatile UID/time tags", value=False)
127
 
128
- tab_upload, tab_diff, tab_batch = st.tabs(
129
- ["1) Upload Pair", "2) Pair Diff", "3) Batch Compare"]
 
 
 
130
  )
131
 
132
- with tab_upload:
133
- left_col, right_col = st.columns(2)
134
- with left_col:
135
- left_file = st.file_uploader("Left RTSS (.dcm)", type=["dcm"], key="left_pair")
136
- with right_col:
137
- right_file = st.file_uploader("Right RTSS (.dcm)", type=["dcm"], key="right_pair")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
- if st.button("Convert pair", type="primary"):
140
- if left_file is None or right_file is None:
141
- st.warning("Upload both files first.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  else:
143
- with st.spinner("Converting files..."):
144
- st.session_state.left_json_raw = dcm_bytes_to_json_dict(left_file.getvalue())
145
- st.session_state.right_json_raw = dcm_bytes_to_json_dict(right_file.getvalue())
146
- st.session_state.left_name = left_file.name
147
- st.session_state.right_name = right_file.name
148
- st.success("Pair conversion complete.")
149
-
150
- with tab_diff:
151
- left_raw = st.session_state.left_json_raw
152
- right_raw = st.session_state.right_json_raw
153
- if left_raw is None or right_raw is None:
154
- st.info("Convert a pair in tab 1 first.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  else:
156
- render_diff_panel(
157
- left_name=st.session_state.left_name,
158
- right_name=st.session_state.right_name,
159
- left_raw=left_raw,
160
- right_raw=right_raw,
161
- component=component,
162
- precision=precision,
163
- keep_volatile=keep_volatile,
164
- key_prefix="pair",
165
- )
166
-
167
- with tab_batch:
 
 
 
 
 
 
 
 
 
168
  files = st.file_uploader(
169
  "Upload RTSS variant set (.dcm)",
170
  type=["dcm"],
@@ -189,32 +1607,44 @@ def main() -> None:
189
  variants = st.session_state.batch_variants
190
  if not variants:
191
  st.info("No batch set loaded yet.")
192
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
 
194
- names = sorted(variants.keys())
195
- if len(names) < 2:
196
- st.warning("Need at least two variants.")
197
- return
 
198
 
199
- left_col, right_col = st.columns(2)
200
- with left_col:
201
- left_name = st.selectbox("Left variant", names, index=0, key="batch_left")
202
- with right_col:
203
- right_name = st.selectbox("Right variant", names, index=1, key="batch_right")
204
-
205
- if left_name == right_name:
206
- st.warning("Select two different variants.")
207
- else:
208
- render_diff_panel(
209
- left_name=left_name,
210
- right_name=right_name,
211
- left_raw=variants[left_name],
212
- right_raw=variants[right_name],
213
- component=component,
214
- precision=precision,
215
- keep_volatile=keep_volatile,
216
- key_prefix="batch",
217
- )
218
 
219
 
220
  if __name__ == "__main__":
 
5
 
6
  import sys
7
  import tempfile
8
+ from math import sqrt
9
  from pathlib import Path
10
  from typing import Any
11
 
12
+ import plotly.graph_objects as go
13
  import streamlit as st
14
 
15
  ROOT = Path(__file__).resolve().parent
 
21
  from rtssdiffviewer.diff_core import ( # noqa: E402
22
  COMPONENT_KEYS,
23
  DEFAULT_VOLATILE_TAG_PREFIXES,
24
+ contour_diff_text,
25
+ get_contour_slices_structured,
26
  normalize_value,
27
  pretty_json_text,
28
  select_component,
 
51
  st.session_state.setdefault("left_json_raw", None)
52
  st.session_state.setdefault("right_json_raw", None)
53
  st.session_state.setdefault("batch_variants", {})
54
+ st.session_state.setdefault("pair_step", "Upload Pair")
55
+ st.session_state.setdefault("pair_diff_requested", False)
56
+ st.session_state.setdefault("pair_diff_sig", None)
57
+ st.session_state.setdefault("pair_focus_slice_z", None)
58
+ st.session_state.setdefault("axial_target_slice_z", None)
59
+ st.session_state.setdefault("contour_detail_target_slice_z", None)
60
+
61
+
62
+ def _point_distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
63
+ return sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2)
64
+
65
+
66
+ def _greedy_point_matches(
67
+ left_points: list[tuple[float, float, float]],
68
+ right_points: list[tuple[float, float, float]],
69
+ ) -> list[tuple[int, int, float]]:
70
+ if not left_points or not right_points:
71
+ return []
72
+
73
+ candidates: list[tuple[float, int, int]] = []
74
+ for li, lp in enumerate(left_points):
75
+ for ri, rp in enumerate(right_points):
76
+ candidates.append((_point_distance(lp, rp), li, ri))
77
+ candidates.sort(key=lambda x: x[0])
78
+
79
+ used_left: set[int] = set()
80
+ used_right: set[int] = set()
81
+ matches: list[tuple[int, int, float]] = []
82
+ for dist, li, ri in candidates:
83
+ if li in used_left or ri in used_right:
84
+ continue
85
+ used_left.add(li)
86
+ used_right.add(ri)
87
+ matches.append((li, ri, dist))
88
+ return matches
89
+
90
+
91
+ def _slice_point_counts(
92
+ left_rois: dict[str, list[tuple[float, float, float]]],
93
+ right_rois: dict[str, list[tuple[float, float, float]]],
94
+ ) -> tuple[int, int, list[tuple[float, float, float]], list[tuple[float, float, float]]]:
95
+ left_points = [p for pts in left_rois.values() for p in pts]
96
+ right_points = [p for pts in right_rois.values() for p in pts]
97
+ return len(left_points), len(right_points), left_points, right_points
98
+
99
+
100
+ def _slice_match_metrics(
101
+ left_rois: dict[str, list[tuple[float, float, float]]],
102
+ right_rois: dict[str, list[tuple[float, float, float]]],
103
+ tolerance_mm: float,
104
+ ) -> dict[str, Any]:
105
+ left_count, right_count, left_points, right_points = _slice_point_counts(left_rois, right_rois)
106
+ matches = _greedy_point_matches(left_points, right_points)
107
+ matched_in_tol = sum(1 for _, _, dist in matches if dist <= tolerance_mm)
108
+ dice = (2.0 * matched_in_tol) / (left_count + right_count) if (left_count + right_count) > 0 else 1.0
109
+ mismatch_count = left_count + right_count - (2 * matched_in_tol)
110
+ count_delta = abs(left_count - right_count)
111
+
112
+ left_roi_names = set(left_rois.keys())
113
+ right_roi_names = set(right_rois.keys())
114
+ all_roi_names = sorted(left_roi_names | right_roi_names)
115
+ identical_slice = left_roi_names == right_roi_names and all(
116
+ sorted(left_rois.get(roi, [])) == sorted(right_rois.get(roi, []))
117
+ for roi in all_roi_names
118
+ )
119
+
120
+ return {
121
+ "left_count": left_count,
122
+ "right_count": right_count,
123
+ "dice": dice,
124
+ "mismatch_count": mismatch_count,
125
+ "count_delta": count_delta,
126
+ "left_roi_names": left_roi_names,
127
+ "right_roi_names": right_roi_names,
128
+ "all_roi_names": all_roi_names,
129
+ "identical_slice": identical_slice,
130
+ }
131
+
132
+
133
+ def _safe_key_fragment(value: str) -> str:
134
+ out = []
135
+ for ch in value:
136
+ out.append(ch if ch.isalnum() else "_")
137
+ return "".join(out)
138
+
139
+
140
+ def _nearest_slice_value(slices: list[float], target: float | None) -> float | None:
141
+ if not slices:
142
+ return None
143
+ if target is None:
144
+ return slices[0]
145
+ return min(slices, key=lambda z: (abs(z - target), z))
146
+
147
+
148
+ def _step_slice_value(slices: list[float], current: float, step: int) -> float:
149
+ if not slices:
150
+ return current
151
+ try:
152
+ idx = slices.index(current)
153
+ except ValueError:
154
+ idx = 0
155
+ next_idx = max(0, min(len(slices) - 1, idx + step))
156
+ return slices[next_idx]
157
+
158
+
159
+ def _format_slice_rois_text(rois: dict[str, list[tuple[float, float, float]]], precision: int) -> str:
160
+ if not rois:
161
+ return "(no contours on this slice)"
162
+
163
+ lines: list[str] = []
164
+ for roi_name in sorted(rois.keys()):
165
+ points = rois[roi_name]
166
+ lines.append(f"{roi_name}: {len(points)} points")
167
+ for i, (x, y, z) in enumerate(points, start=1):
168
+ lines.append(f" {i:03d}: ({x:.{precision}f}, {y:.{precision}f}, {z:.{precision}f})")
169
+ lines.append("")
170
+ return "\n".join(lines).strip()
171
+
172
+
173
+ def _extract_ordered_contours_by_slice(
174
+ rtss_json: dict[str, Any],
175
+ precision: int = 4,
176
+ ) -> dict[float, list[dict[str, Any]]]:
177
+ """Extract ordered contour polylines grouped by axial slice (z)."""
178
+ slices: dict[float, list[dict[str, Any]]] = {}
179
+ roi_contour_seq = rtss_json.get("(3006,0039) ROIContourSequence", [])
180
+ if not isinstance(roi_contour_seq, list):
181
+ return slices
182
+
183
+ for roi_idx, roi_item in enumerate(roi_contour_seq):
184
+ if not isinstance(roi_item, dict):
185
+ continue
186
+
187
+ roi_number = roi_item.get("(3006,0084) ReferencedROINumber")
188
+ if roi_number is None:
189
+ roi_number = roi_idx
190
+ roi_name = f"ROI {roi_number}"
191
+
192
+ contour_seq = roi_item.get("(3006,0040) ContourSequence", [])
193
+ if not isinstance(contour_seq, list):
194
+ continue
195
+
196
+ for contour_idx, contour_item in enumerate(contour_seq, start=1):
197
+ if not isinstance(contour_item, dict):
198
+ continue
199
+
200
+ points = _extract_xyz_points(contour_item.get("(3006,0050) ContourData", []))
201
+ if not points:
202
+ continue
203
+
204
+ z_mean = sum(p[2] for p in points) / len(points)
205
+ z_key = round(z_mean, precision)
206
+ contour_number = contour_item.get("(3006,0048) ContourNumber", contour_idx)
207
+
208
+ slices.setdefault(z_key, []).append(
209
+ {
210
+ "roi_name": roi_name,
211
+ "contour_label": f"{roi_name} | Contour {contour_number}",
212
+ "points": points,
213
+ }
214
+ )
215
+
216
+ return slices
217
+
218
+
219
+ def _add_direction_annotation(
220
+ fig: go.Figure,
221
+ points: list[tuple[float, float, float]],
222
+ color: str,
223
+ text: str,
224
+ ) -> None:
225
+ if len(points) < 2:
226
+ return
227
+ x0, y0, _ = points[0]
228
+ x1, y1, _ = points[1]
229
+ fig.add_annotation(
230
+ x=x1,
231
+ y=y1,
232
+ ax=x0,
233
+ ay=y0,
234
+ xref="x",
235
+ yref="y",
236
+ axref="x",
237
+ ayref="y",
238
+ showarrow=True,
239
+ arrowhead=3,
240
+ arrowsize=1,
241
+ arrowwidth=1.6,
242
+ arrowcolor=color,
243
+ text=text,
244
+ font={"size": 11, "color": color},
245
+ align="left",
246
+ xanchor="left",
247
+ )
248
+
249
+
250
+ def _add_contour_traces_2d(
251
+ fig: go.Figure,
252
+ contours: list[dict[str, Any]],
253
+ side_name: str,
254
+ line_color: str,
255
+ line_dash: str,
256
+ ) -> None:
257
+ for idx, contour in enumerate(contours, start=1):
258
+ points = contour["points"]
259
+ if not points:
260
+ continue
261
+
262
+ xs = [p[0] for p in points]
263
+ ys = [p[1] for p in points]
264
+ point_ids = list(range(1, len(points) + 1))
265
+ contour_name = contour["contour_label"]
266
+
267
+ fig.add_trace(
268
+ go.Scatter(
269
+ x=xs,
270
+ y=ys,
271
+ mode="lines+markers",
272
+ line={"width": 2, "color": line_color, "dash": line_dash},
273
+ marker={"size": 5, "color": line_color},
274
+ name=f"{side_name}: {contour_name}",
275
+ legendgroup=f"{side_name}_{idx}",
276
+ hovertemplate=(
277
+ f"{side_name}<br>{contour_name}<br>Point %{{customdata}}"
278
+ "<br>x=%{x:.3f}<br>y=%{y:.3f}<extra></extra>"
279
+ ),
280
+ customdata=point_ids,
281
+ )
282
+ )
283
+
284
+ first = points[0]
285
+ last = points[-1]
286
+ fig.add_trace(
287
+ go.Scatter(
288
+ x=[first[0]],
289
+ y=[first[1]],
290
+ mode="markers+text",
291
+ marker={"size": 11, "color": line_color, "symbol": "star"},
292
+ text=["Start"],
293
+ textposition="top center",
294
+ name=f"{side_name} start",
295
+ legendgroup=f"{side_name}_{idx}",
296
+ showlegend=False,
297
+ hovertemplate=(
298
+ f"{side_name}<br>{contour_name}<br>Start point (index 1)"
299
+ "<br>x=%{x:.3f}<br>y=%{y:.3f}<extra></extra>"
300
+ ),
301
+ )
302
+ )
303
+ fig.add_trace(
304
+ go.Scatter(
305
+ x=[last[0]],
306
+ y=[last[1]],
307
+ mode="markers+text",
308
+ marker={"size": 10, "color": line_color, "symbol": "x"},
309
+ text=[f"End ({len(points)})"],
310
+ textposition="bottom center",
311
+ name=f"{side_name} end",
312
+ legendgroup=f"{side_name}_{idx}",
313
+ showlegend=False,
314
+ hovertemplate=(
315
+ f"{side_name}<br>{contour_name}<br>End point (index {len(points)})"
316
+ "<br>x=%{x:.3f}<br>y=%{y:.3f}<extra></extra>"
317
+ ),
318
+ )
319
+ )
320
+
321
+ _add_direction_annotation(fig, points, line_color, f"{side_name} direction")
322
+
323
+
324
+ def render_axial_contour_view(
325
+ *,
326
+ left_name: str,
327
+ right_name: str,
328
+ left_raw: dict[str, Any],
329
+ right_raw: dict[str, Any],
330
+ precision: int = 4,
331
+ ) -> None:
332
+ left_by_slice = _extract_ordered_contours_by_slice(left_raw, precision=precision)
333
+ right_by_slice = _extract_ordered_contours_by_slice(right_raw, precision=precision)
334
+ all_slices = sorted(set(left_by_slice.keys()) | set(right_by_slice.keys()))
335
+
336
+ if not all_slices:
337
+ st.warning("No contour data found in either RTSS file.")
338
+ return
339
+
340
+ summary_left = sum(len(v) for v in left_by_slice.values())
341
+ summary_right = sum(len(v) for v in right_by_slice.values())
342
+ only_left = [z for z in all_slices if z in left_by_slice and z not in right_by_slice]
343
+ only_right = [z for z in all_slices if z in right_by_slice and z not in left_by_slice]
344
+
345
+ m1, m2, m3, m4 = st.columns(4)
346
+ with m1:
347
+ st.metric(f"{left_name} slices", f"{len(left_by_slice):,}")
348
+ with m2:
349
+ st.metric(f"{right_name} slices", f"{len(right_by_slice):,}")
350
+ with m3:
351
+ st.metric(f"{left_name} contours", f"{summary_left:,}")
352
+ with m4:
353
+ st.metric(f"{right_name} contours", f"{summary_right:,}")
354
+
355
+ if only_left or only_right:
356
+ notes: list[str] = []
357
+ if only_left:
358
+ notes.append(f"Slices only in {left_name}: {len(only_left)}")
359
+ if only_right:
360
+ notes.append(f"Slices only in {right_name}: {len(only_right)}")
361
+ st.warning(" | ".join(notes))
362
+ else:
363
+ st.success("Both files have contours on the same set of axial slice positions.")
364
+
365
+ target_z = st.session_state.axial_target_slice_z
366
+ if target_z is not None:
367
+ aligned_target = _nearest_slice_value(all_slices, target_z)
368
+ if aligned_target is not None:
369
+ st.session_state.axial_slice_select = aligned_target
370
+ st.session_state.axial_target_slice_z = None
371
+ elif st.session_state.get("axial_slice_select") not in all_slices:
372
+ st.session_state.axial_slice_select = all_slices[0]
373
+
374
+ nav_col_1, nav_col_2, nav_col_3 = st.columns(3)
375
+ with nav_col_1:
376
+ if st.button("Previous Slice", key="axial_prev_slice"):
377
+ st.session_state.axial_slice_select = _step_slice_value(
378
+ all_slices,
379
+ st.session_state.get("axial_slice_select", all_slices[0]),
380
+ -1,
381
+ )
382
+ st.rerun()
383
+ with nav_col_2:
384
+ if st.button("Next Slice", key="axial_next_slice"):
385
+ st.session_state.axial_slice_select = _step_slice_value(
386
+ all_slices,
387
+ st.session_state.get("axial_slice_select", all_slices[0]),
388
+ 1,
389
+ )
390
+ st.rerun()
391
+ with nav_col_3:
392
+ if st.button("Open Contour Detail View", key="axial_open_contour_detail"):
393
+ current_z = st.session_state.get("axial_slice_select", all_slices[0])
394
+ st.session_state.contour_detail_target_slice_z = current_z
395
+ st.session_state.pair_focus_slice_z = current_z
396
+ st.session_state.pair_step = "Contour Detail View"
397
+ st.rerun()
398
+
399
+ selected_z = st.select_slider(
400
+ "Axial slice z (mm)",
401
+ options=all_slices,
402
+ format_func=lambda z: f"{z:.{precision}f}",
403
+ value=st.session_state.get("axial_slice_select", all_slices[0]),
404
+ key="axial_slice_select",
405
+ )
406
+ st.session_state.pair_focus_slice_z = selected_z
407
+
408
+ left_contours = left_by_slice.get(selected_z, [])
409
+ right_contours = right_by_slice.get(selected_z, [])
410
+
411
+ left_labels = {c["contour_label"] for c in left_contours}
412
+ right_labels = {c["contour_label"] for c in right_contours}
413
+ only_left_contours = sorted(left_labels - right_labels)
414
+ only_right_contours = sorted(right_labels - left_labels)
415
+
416
+ if left_contours and not right_contours:
417
+ st.error(f"Slice z={selected_z:.{precision}f} is present only in {left_name}.")
418
+ elif right_contours and not left_contours:
419
+ st.error(f"Slice z={selected_z:.{precision}f} is present only in {right_name}.")
420
+ else:
421
+ st.info(
422
+ f"Slice z={selected_z:.{precision}f} has contours in both files: "
423
+ f"{len(left_contours)} vs {len(right_contours)}"
424
+ )
425
+
426
+ if only_left_contours or only_right_contours:
427
+ mismatch_notes: list[str] = []
428
+ if only_left_contours:
429
+ mismatch_notes.append(f"Contours only in {left_name}: {len(only_left_contours)}")
430
+ if only_right_contours:
431
+ mismatch_notes.append(f"Contours only in {right_name}: {len(only_right_contours)}")
432
+ st.warning(" | ".join(mismatch_notes))
433
+
434
+ fig = go.Figure()
435
+ if left_contours:
436
+ _add_contour_traces_2d(
437
+ fig,
438
+ contours=left_contours,
439
+ side_name=left_name,
440
+ line_color="#d62728",
441
+ line_dash="solid",
442
+ )
443
+ if right_contours:
444
+ _add_contour_traces_2d(
445
+ fig,
446
+ contours=right_contours,
447
+ side_name=right_name,
448
+ line_color="#2ca02c",
449
+ line_dash="dot",
450
+ )
451
+
452
+ if not fig.data:
453
+ st.info("No contours to display for this slice.")
454
+ return
455
+
456
+ fig.update_layout(
457
+ title=f"Axial Contour View at z={selected_z:.{precision}f} mm",
458
+ xaxis_title="X (mm)",
459
+ yaxis_title="Y (mm)",
460
+ xaxis={"fixedrange": False},
461
+ yaxis={"scaleanchor": "x", "scaleratio": 1, "fixedrange": False},
462
+ dragmode="zoom",
463
+ margin={"l": 10, "r": 10, "t": 48, "b": 10},
464
+ legend={"orientation": "h", "y": 1.02, "x": 0},
465
+ )
466
+ st.plotly_chart(
467
+ fig,
468
+ use_container_width=True,
469
+ config={
470
+ "scrollZoom": True,
471
+ "displaylogo": False,
472
+ "modeBarButtonsToAdd": ["zoom2d", "pan2d", "resetScale2d"],
473
+ },
474
+ )
475
+
476
+ with st.expander("Slice contour details", expanded=False):
477
+ left_col, right_col = st.columns(2)
478
+ with left_col:
479
+ st.markdown(f"**{left_name}**")
480
+ if not left_contours:
481
+ st.caption("No contours on this slice.")
482
+ else:
483
+ for c in left_contours:
484
+ pts = c["points"]
485
+ st.write(
486
+ f"{c['contour_label']}: {len(pts)} points | "
487
+ f"start=({pts[0][0]:.{precision}f}, {pts[0][1]:.{precision}f}) | "
488
+ f"end=({pts[-1][0]:.{precision}f}, {pts[-1][1]:.{precision}f})"
489
+ )
490
+
491
+
492
+ def render_contour_detail_text_view(
493
+ *,
494
+ left_name: str,
495
+ right_name: str,
496
+ left_raw: dict[str, Any],
497
+ right_raw: dict[str, Any],
498
+ precision: int = 4,
499
+ ) -> None:
500
+ left_contours = select_component(left_raw, "contours")
501
+ right_contours = select_component(right_raw, "contours")
502
+ left_slices, right_slices = get_contour_slices_structured(left_contours, right_contours, precision=precision)
503
+ all_slices = sorted(set(left_slices.keys()) | set(right_slices.keys()))
504
+
505
+ if not all_slices:
506
+ st.warning("No contour data found in either file.")
507
+ return
508
+
509
+ target_z = st.session_state.contour_detail_target_slice_z
510
+ if target_z is not None:
511
+ aligned_target = _nearest_slice_value(all_slices, target_z)
512
+ if aligned_target is not None:
513
+ st.session_state.contour_detail_slice_select = aligned_target
514
+ st.session_state.contour_detail_target_slice_z = None
515
+ elif st.session_state.get("contour_detail_slice_select") not in all_slices:
516
+ st.session_state.contour_detail_slice_select = all_slices[0]
517
+
518
+ nav_col_1, nav_col_2, nav_col_3 = st.columns(3)
519
+ with nav_col_1:
520
+ if st.button("Previous Slice", key="detail_prev_slice"):
521
+ st.session_state.contour_detail_slice_select = _step_slice_value(
522
+ all_slices,
523
+ st.session_state.get("contour_detail_slice_select", all_slices[0]),
524
+ -1,
525
+ )
526
+ st.rerun()
527
+ with nav_col_2:
528
+ if st.button("Next Slice", key="detail_next_slice"):
529
+ st.session_state.contour_detail_slice_select = _step_slice_value(
530
+ all_slices,
531
+ st.session_state.get("contour_detail_slice_select", all_slices[0]),
532
+ 1,
533
+ )
534
+ st.rerun()
535
+ with nav_col_3:
536
+ if st.button("Back To Visual Comparison", key="detail_back_to_visual"):
537
+ current_z = st.session_state.get("contour_detail_slice_select", all_slices[0])
538
+ st.session_state.axial_target_slice_z = current_z
539
+ st.session_state.pair_focus_slice_z = current_z
540
+ st.session_state.pair_step = "Pair Diff"
541
+ st.session_state.pair_component = "contours"
542
+ st.session_state.pair_diff_requested = True
543
+ st.session_state.pair_diff_sig = "force"
544
+ st.rerun()
545
+
546
+ selected_z = st.select_slider(
547
+ "Contour slice z (mm)",
548
+ options=all_slices,
549
+ format_func=lambda z: f"{z:.{precision}f}",
550
+ value=st.session_state.get("contour_detail_slice_select", all_slices[0]),
551
+ key="contour_detail_slice_select",
552
+ )
553
+ st.session_state.pair_focus_slice_z = selected_z
554
+
555
+ left_rois = left_slices.get(selected_z, {})
556
+ right_rois = right_slices.get(selected_z, {})
557
+
558
+ st.markdown(f"### Contour Text Comparison At z={selected_z:.{precision}f}")
559
+ col_1, col_2 = st.columns(2)
560
+ with col_1:
561
+ st.markdown(f"**{left_name}**")
562
+ st.code(_format_slice_rois_text(left_rois, precision), language="text")
563
+ with col_2:
564
+ st.markdown(f"**{right_name}**")
565
+ st.code(_format_slice_rois_text(right_rois, precision), language="text")
566
+
567
+ left_text = _format_slice_rois_text(left_rois, precision)
568
+ right_text = _format_slice_rois_text(right_rois, precision)
569
+ diff_text = unified_diff_text(left_text, right_text, left_name, right_name)
570
+ st.markdown("#### Slice Unified Diff")
571
+ if diff_text.strip():
572
+ st.code(diff_text, language="diff")
573
+ else:
574
+ st.success("No textual differences on this slice.")
575
+ with right_col:
576
+ st.markdown(f"**{right_name}**")
577
+ if not right_contours:
578
+ st.caption("No contours on this slice.")
579
+ else:
580
+ for c in right_contours:
581
+ pts = c["points"]
582
+ st.write(
583
+ f"{c['contour_label']}: {len(pts)} points | "
584
+ f"start=({pts[0][0]:.{precision}f}, {pts[0][1]:.{precision}f}) | "
585
+ f"end=({pts[-1][0]:.{precision}f}, {pts[-1][1]:.{precision}f})"
586
+ )
587
 
588
 
589
  def render_diff_panel(
 
595
  component: str,
596
  precision: int,
597
  keep_volatile: bool,
598
+ allow_rich_view: bool,
599
+ max_rich_chars: int,
600
+ max_rich_lines: int,
601
  key_prefix: str,
602
  ) -> None:
603
  ignore_prefixes: set[str] = set()
604
  if not keep_volatile:
605
  ignore_prefixes.update(DEFAULT_VOLATILE_TAG_PREFIXES)
606
 
607
+ # Get selected components
608
  left_selected = select_component(left_raw, component)
609
  right_selected = select_component(right_raw, component)
610
 
611
+ # Check if components are identical
612
+ if left_selected == right_selected:
613
+ st.info(f"✅ **{component.capitalize()} components are identical** — no differences to compare.")
614
+ col1, col2 = st.columns(2)
615
+ with col1:
616
+ left_json_text = pretty_json_text(left_selected)
617
+ st.download_button(
618
+ "Download left JSON",
619
+ data=left_json_text,
620
+ file_name=f"{Path(left_name).stem}.{component}.json",
621
+ mime="application/json",
622
+ key=f"{key_prefix}_dl_left",
623
+ )
624
+ with col2:
625
+ right_json_text = pretty_json_text(right_selected)
626
+ st.download_button(
627
+ "Download right JSON",
628
+ data=right_json_text,
629
+ file_name=f"{Path(right_name).stem}.{component}.json",
630
+ mime="application/json",
631
+ key=f"{key_prefix}_dl_right",
632
+ )
633
+ return
634
+
635
+ # Special handling for contour diffing with two-panel layout
636
+ if component == "contours":
637
+ left_slices, right_slices = get_contour_slices_structured(left_selected, right_selected, precision=precision)
638
+
639
+ col1, col2, col3 = st.columns(3)
640
+ with col1:
641
+ left_json_text = pretty_json_text(left_selected)
642
+ st.download_button(
643
+ "Download left JSON",
644
+ data=left_json_text,
645
+ file_name=f"{Path(left_name).stem}.{component}.json",
646
+ mime="application/json",
647
+ key=f"{key_prefix}_dl_left",
648
+ )
649
+ with col2:
650
+ right_json_text = pretty_json_text(right_selected)
651
+ st.download_button(
652
+ "Download right JSON",
653
+ data=right_json_text,
654
+ file_name=f"{Path(right_name).stem}.{component}.json",
655
+ mime="application/json",
656
+ key=f"{key_prefix}_dl_right",
657
+ )
658
+ with col3:
659
+ diff_text = contour_diff_text(left_selected, right_selected, left_name, right_name, precision=precision)
660
+ st.download_button(
661
+ "Download contour diff",
662
+ data=diff_text,
663
+ file_name=f"{Path(left_name).stem}__{Path(right_name).stem}.{component}.diff",
664
+ mime="text/plain",
665
+ key=f"{key_prefix}_dl_diff",
666
+ )
667
+
668
+ st.markdown("### Contour Diff View (by Slice Plane)")
669
+ st.caption(
670
+ "Contour points are compared by slice plane (z-coordinate). "
671
+ "Left panel shows the first file, right panel shows the second file."
672
+ )
673
+
674
+ if not left_slices and not right_slices:
675
+ st.warning("No contour data found in either file.")
676
+ return
677
+
678
+ global_tolerance = st.slider(
679
+ "Correspondence tolerance (mm)",
680
+ min_value=0.1,
681
+ max_value=10.0,
682
+ value=1.0,
683
+ step=0.1,
684
+ key=f"{key_prefix}_corr_tol",
685
+ )
686
+
687
+ filter_col_1, filter_col_2, filter_col_3, filter_col_4 = st.columns(4)
688
+ with filter_col_1:
689
+ hide_identical_slices = st.checkbox(
690
+ "Hide identical slices",
691
+ value=False,
692
+ key=f"{key_prefix}_hide_identical_slices",
693
+ )
694
+ with filter_col_2:
695
+ max_dice_filter = st.slider(
696
+ "Max dice to include",
697
+ min_value=0.0,
698
+ max_value=1.0,
699
+ value=1.0,
700
+ step=0.01,
701
+ key=f"{key_prefix}_max_dice",
702
+ )
703
+ with filter_col_3:
704
+ min_mismatch_filter = st.number_input(
705
+ "Min mismatch",
706
+ min_value=0,
707
+ value=0,
708
+ step=1,
709
+ key=f"{key_prefix}_min_mismatch",
710
+ )
711
+ with filter_col_4:
712
+ sort_mode = st.selectbox(
713
+ "Sort slices",
714
+ options=[
715
+ "Worst dice first",
716
+ "Best dice first",
717
+ "Highest mismatch first",
718
+ "Slice z ascending",
719
+ "Slice z descending",
720
+ ],
721
+ index=0,
722
+ key=f"{key_prefix}_slice_sort",
723
+ )
724
+
725
+ # Build slice metrics first, then filter/sort.
726
+ all_z_coords = sorted(set(left_slices.keys()) | set(right_slices.keys()))
727
+ slice_rows: list[dict[str, Any]] = []
728
+ for z in all_z_coords:
729
+ left_rois = left_slices.get(z, {})
730
+ right_rois = right_slices.get(z, {})
731
+ metrics = _slice_match_metrics(left_rois, right_rois, global_tolerance)
732
+ slice_rows.append(
733
+ {
734
+ "z": z,
735
+ "left_rois": left_rois,
736
+ "right_rois": right_rois,
737
+ "left_count": metrics["left_count"],
738
+ "right_count": metrics["right_count"],
739
+ "dice": metrics["dice"],
740
+ "mismatch_count": metrics["mismatch_count"],
741
+ "count_delta": metrics["count_delta"],
742
+ "identical_slice": metrics["identical_slice"],
743
+ "left_roi_names": metrics["left_roi_names"],
744
+ "right_roi_names": metrics["right_roi_names"],
745
+ "all_roi_names": metrics["all_roi_names"],
746
+ }
747
+ )
748
+
749
+ filtered_rows = [
750
+ row
751
+ for row in slice_rows
752
+ if row["dice"] <= max_dice_filter
753
+ and row["mismatch_count"] >= min_mismatch_filter
754
+ and (not hide_identical_slices or not row["identical_slice"])
755
+ ]
756
+
757
+ if sort_mode == "Worst dice first":
758
+ filtered_rows.sort(key=lambda r: (r["dice"], -r["mismatch_count"], r["z"]))
759
+ elif sort_mode == "Best dice first":
760
+ filtered_rows.sort(key=lambda r: (-r["dice"], -r["mismatch_count"], r["z"]))
761
+ elif sort_mode == "Highest mismatch first":
762
+ filtered_rows.sort(key=lambda r: (-r["mismatch_count"], r["dice"], r["z"]))
763
+ elif sort_mode == "Slice z descending":
764
+ filtered_rows.sort(key=lambda r: r["z"], reverse=True)
765
+ else:
766
+ filtered_rows.sort(key=lambda r: r["z"])
767
+
768
+ st.caption(
769
+ f"Showing {len(filtered_rows)} / {len(slice_rows)} slices after filters "
770
+ f"(tolerance={global_tolerance:.1f} mm)."
771
+ )
772
+
773
+ if not filtered_rows:
774
+ st.info("No slices match the current filters.")
775
+ return
776
+
777
+ focus_slice = _nearest_slice_value(
778
+ [float(row["z"]) for row in filtered_rows],
779
+ st.session_state.pair_focus_slice_z,
780
+ )
781
+ if focus_slice is not None:
782
+ st.caption(f"Focused slice: z={focus_slice:.{precision}f}")
783
+
784
+ # Render two-panel layout for each filtered slice
785
+ for row in filtered_rows:
786
+ z = row["z"]
787
+ left_rois = row["left_rois"]
788
+ right_rois = row["right_rois"]
789
+ left_count = row["left_count"]
790
+ right_count = row["right_count"]
791
+ mismatch_count = row["mismatch_count"]
792
+ count_delta = row["count_delta"]
793
+ dice = row["dice"]
794
+ left_roi_names = row["left_roi_names"]
795
+ right_roi_names = row["right_roi_names"]
796
+ all_roi_names = row["all_roi_names"]
797
+ identical_slice = row["identical_slice"]
798
+
799
+ header = (
800
+ f"Slice z={z:.{precision}f} | L={left_count} R={right_count} "
801
+ f"| delta={count_delta} | mismatch={mismatch_count} | dice={dice:.3f}"
802
+ )
803
+
804
+ expanded = focus_slice is not None and z == focus_slice
805
+ with st.expander(header, expanded=expanded):
806
+ if st.button(
807
+ "Open This Slice In 2D Axial View",
808
+ key=f"{key_prefix}_open_axial_{_safe_key_fragment(f'{z:.{precision}f}')}",
809
+ ):
810
+ st.session_state.axial_target_slice_z = z
811
+ st.session_state.pair_focus_slice_z = z
812
+ st.session_state.pair_step = "2D Axial Contour View"
813
+ st.rerun()
814
+
815
+ if identical_slice:
816
+ st.caption("✅ This slice is identical in both files")
817
+
818
+ # Two-column layout for this slice
819
+ left_col, right_col = st.columns(2)
820
+
821
+ # LEFT PANEL
822
+ with left_col:
823
+ st.markdown(f"**{left_name}**")
824
+ if z not in left_slices:
825
+ st.caption("❌ No contour data on this slice")
826
+ else:
827
+ for roi_name in sorted(left_rois.keys()):
828
+ points = sorted(left_rois[roi_name])
829
+ is_different = roi_name not in right_rois or sorted(right_rois[roi_name]) != points
830
+ marker = "⚠️" if is_different else "✓"
831
+ st.markdown(f"{marker} *{roi_name}* ({len(points)} points)")
832
+ points_text = "\n".join(
833
+ [f"({x:.{precision}f}, {y:.{precision}f}, {z:.{precision}f})" for x, y, z in points]
834
+ )
835
+ st.code(points_text, language="")
836
+
837
+ # Show ROIs that only exist in right
838
+ for roi_name in sorted(right_roi_names - left_roi_names):
839
+ st.markdown(f"⊘ *{roi_name}* (only in {right_name})")
840
+
841
+ # RIGHT PANEL
842
+ with right_col:
843
+ st.markdown(f"**{right_name}**")
844
+ if z not in right_slices:
845
+ st.caption("❌ No contour data on this slice")
846
+ else:
847
+ for roi_name in sorted(right_rois.keys()):
848
+ points = sorted(right_rois[roi_name])
849
+ is_different = roi_name not in left_rois or sorted(left_rois[roi_name]) != points
850
+ marker = "⚠️" if is_different else "✓"
851
+ st.markdown(f"{marker} *{roi_name}* ({len(points)} points)")
852
+ points_text = "\n".join(
853
+ [f"({x:.{precision}f}, {y:.{precision}f}, {z:.{precision}f})" for x, y, z in points]
854
+ )
855
+ st.code(points_text, language="")
856
+
857
+ # Show ROIs that only exist in left
858
+ for roi_name in sorted(left_roi_names - right_roi_names):
859
+ st.markdown(f"⊘ *{roi_name}* (only in {left_name})")
860
+
861
+ st.markdown("#### Correspondence Explorer")
862
+ common_rois = sorted(left_roi_names & right_roi_names)
863
+ if not common_rois:
864
+ st.caption("No common ROIs on this slice to match.")
865
+ else:
866
+ z_key = _safe_key_fragment(f"{z:.{precision}f}")
867
+ roi_choice = st.selectbox(
868
+ "ROI",
869
+ options=common_rois,
870
+ key=f"{key_prefix}_roi_{z_key}",
871
+ )
872
+ left_roi_points = sorted(left_rois.get(roi_choice, []))
873
+ right_roi_points = sorted(right_rois.get(roi_choice, []))
874
+ roi_matches = _greedy_point_matches(left_roi_points, right_roi_points)
875
+
876
+ if not roi_matches:
877
+ st.caption("No points available for correspondence on this ROI.")
878
+ else:
879
+ option_labels: list[str] = []
880
+ default_labels: list[str] = []
881
+ for li, ri, dist in roi_matches:
882
+ label = (
883
+ f"L{li + 1} {left_roi_points[li]} <-> "
884
+ f"R{ri + 1} {right_roi_points[ri]} | d={dist:.3f}"
885
+ )
886
+ option_labels.append(label)
887
+ if dist <= global_tolerance:
888
+ default_labels.append(label)
889
+
890
+ selected_pairs = st.multiselect(
891
+ "Select correspondences",
892
+ options=option_labels,
893
+ default=default_labels,
894
+ key=f"{key_prefix}_corr_{z_key}_{_safe_key_fragment(roi_choice)}",
895
+ )
896
+ st.caption(
897
+ f"Auto-suggested correspondences within tolerance: {len(default_labels)} / {len(roi_matches)}"
898
+ )
899
+ if selected_pairs:
900
+ st.code("\n".join(selected_pairs), language="text")
901
+ else:
902
+ st.caption("No correspondences selected.")
903
+
904
+ return
905
+
906
+ # Standard text-based diffing for other components
907
  left_norm = normalize_value(left_selected, precision, ignore_prefixes)
908
  right_norm = normalize_value(right_selected, precision, ignore_prefixes)
909
 
 
938
  key=f"{key_prefix}_dl_diff",
939
  )
940
 
941
+ use_unified_only, reason = should_use_unified_only(
942
+ left_text,
943
+ right_text,
944
+ allow_rich_view=allow_rich_view,
945
+ max_rich_chars=max_rich_chars,
946
+ max_rich_lines=max_rich_lines,
947
+ )
948
+
949
  st.markdown("### Diff View")
950
+ if use_unified_only:
951
+ if reason:
952
+ st.caption(reason)
953
+ if diff_text.strip():
954
+ st.code(diff_text, language="diff")
955
+ else:
956
+ st.success("No differences after normalization and filtering.")
957
+ elif diff_viewer is not None:
958
  diff_viewer(left_text, right_text, split_view=True)
959
  else:
960
  if diff_text.strip():
 
963
  st.success("No differences after normalization and filtering.")
964
 
965
 
966
+ def should_use_unified_only(
967
+ left_text: str,
968
+ right_text: str,
969
+ *,
970
+ allow_rich_view: bool,
971
+ max_rich_chars: int,
972
+ max_rich_lines: int,
973
+ ) -> tuple[bool, str]:
974
+ if not allow_rich_view:
975
+ return True, "Showing unified diff text only for this mode."
976
+
977
+ total_chars = len(left_text) + len(right_text)
978
+ total_lines = left_text.count("\n") + right_text.count("\n") + 2
979
+ if total_chars > max_rich_chars or total_lines > max_rich_lines:
980
+ return (
981
+ True,
982
+ (
983
+ "Large comparison detected. Showing unified diff text for faster loading. "
984
+ f"(chars={total_chars:,}, lines={total_lines:,})"
985
+ ),
986
+ )
987
+
988
+ return False, ""
989
+
990
+
991
+ def _extract_xyz_points(value: Any) -> list[tuple[float, float, float]]:
992
+ points: list[tuple[float, float, float]] = []
993
+
994
+ if isinstance(value, list):
995
+ if len(value) == 3 and all(isinstance(v, (int, float)) for v in value):
996
+ points.append((float(value[0]), float(value[1]), float(value[2])))
997
+ else:
998
+ for item in value:
999
+ points.extend(_extract_xyz_points(item))
1000
+
1001
+ return points
1002
+
1003
+
1004
+ def extract_contour_points(rtss_json: dict[str, Any]) -> list[tuple[float, float, float]]:
1005
+ points: list[tuple[float, float, float]] = []
1006
+
1007
+ def walk(node: Any) -> None:
1008
+ if isinstance(node, dict):
1009
+ for key, value in node.items():
1010
+ if "ContourData" in key:
1011
+ points.extend(_extract_xyz_points(value))
1012
+ else:
1013
+ walk(value)
1014
+ return
1015
+
1016
+ if isinstance(node, list):
1017
+ for item in node:
1018
+ walk(item)
1019
+
1020
+ walk(rtss_json)
1021
+ return points
1022
+
1023
+
1024
+ def _as_float_list(value: Any, expected_len: int | None = None) -> list[float] | None:
1025
+ if not isinstance(value, list):
1026
+ return None
1027
+ try:
1028
+ out = [float(v) for v in value]
1029
+ except (TypeError, ValueError):
1030
+ return None
1031
+ if expected_len is not None and len(out) != expected_len:
1032
+ return None
1033
+ return out
1034
+
1035
+
1036
+ def _as_int(value: Any) -> int | None:
1037
+ if isinstance(value, int):
1038
+ return value
1039
+ try:
1040
+ return int(str(value))
1041
+ except (TypeError, ValueError):
1042
+ return None
1043
+
1044
+
1045
+ def _find_first_keyword_value(node: Any, keyword: str) -> Any | None:
1046
+ if isinstance(node, dict):
1047
+ for key, value in node.items():
1048
+ if keyword in key:
1049
+ return value
1050
+ found = _find_first_keyword_value(value, keyword)
1051
+ if found is not None:
1052
+ return found
1053
+ elif isinstance(node, list):
1054
+ for item in node:
1055
+ found = _find_first_keyword_value(item, keyword)
1056
+ if found is not None:
1057
+ return found
1058
+ return None
1059
+
1060
+
1061
+ def _vadd(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
1062
+ return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
1063
+
1064
+
1065
+ def _vscale(v: tuple[float, float, float], s: float) -> tuple[float, float, float]:
1066
+ return (v[0] * s, v[1] * s, v[2] * s)
1067
+
1068
+
1069
+ def _vnorm(v: tuple[float, float, float]) -> float:
1070
+ return sqrt(v[0] ** 2 + v[1] ** 2 + v[2] ** 2)
1071
+
1072
+
1073
+ def _vunit(v: tuple[float, float, float]) -> tuple[float, float, float] | None:
1074
+ n = _vnorm(v)
1075
+ if n == 0:
1076
+ return None
1077
+ return (v[0] / n, v[1] / n, v[2] / n)
1078
+
1079
+
1080
+ def _cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
1081
+ return (
1082
+ a[1] * b[2] - a[2] * b[1],
1083
+ a[2] * b[0] - a[0] * b[2],
1084
+ a[0] * b[1] - a[1] * b[0],
1085
+ )
1086
+
1087
+
1088
+ def _bounds_from_points(points: list[tuple[float, float, float]]) -> dict[str, float] | None:
1089
+ if not points:
1090
+ return None
1091
+ xs = [p[0] for p in points]
1092
+ ys = [p[1] for p in points]
1093
+ zs = [p[2] for p in points]
1094
+ return {
1095
+ "x_min": min(xs),
1096
+ "x_max": max(xs),
1097
+ "y_min": min(ys),
1098
+ "y_max": max(ys),
1099
+ "z_min": min(zs),
1100
+ "z_max": max(zs),
1101
+ }
1102
+
1103
+
1104
+ def _extract_volume_bounds_from_rtss(rtss_json: dict[str, Any]) -> tuple[dict[str, float] | None, str]:
1105
+ origin_raw = _find_first_keyword_value(rtss_json, "ImagePositionPatient")
1106
+ orient_raw = _find_first_keyword_value(rtss_json, "ImageOrientationPatient")
1107
+ pixel_spacing_raw = _find_first_keyword_value(rtss_json, "PixelSpacing")
1108
+ rows_raw = _find_first_keyword_value(rtss_json, "Rows")
1109
+ cols_raw = _find_first_keyword_value(rtss_json, "Columns")
1110
+ frames_raw = _find_first_keyword_value(rtss_json, "NumberOfFrames")
1111
+ spacing_between_raw = _find_first_keyword_value(rtss_json, "SpacingBetweenSlices")
1112
+ slice_thickness_raw = _find_first_keyword_value(rtss_json, "SliceThickness")
1113
+
1114
+ origin = _as_float_list(origin_raw, expected_len=3)
1115
+ orient = _as_float_list(orient_raw, expected_len=6)
1116
+ pixel_spacing = _as_float_list(pixel_spacing_raw, expected_len=2)
1117
+ rows = _as_int(rows_raw)
1118
+ cols = _as_int(cols_raw)
1119
+ frames = _as_int(frames_raw)
1120
+
1121
+ if frames is None:
1122
+ frames = 1
1123
+
1124
+ slice_spacing = None
1125
+ if spacing_between_raw is not None:
1126
+ try:
1127
+ slice_spacing = float(spacing_between_raw)
1128
+ except (TypeError, ValueError):
1129
+ slice_spacing = None
1130
+ if slice_spacing is None and slice_thickness_raw is not None:
1131
+ try:
1132
+ slice_spacing = float(slice_thickness_raw)
1133
+ except (TypeError, ValueError):
1134
+ slice_spacing = None
1135
+ if slice_spacing is None:
1136
+ slice_spacing = 1.0
1137
+
1138
+ if origin is None or orient is None or pixel_spacing is None or rows is None or cols is None:
1139
+ return (
1140
+ None,
1141
+ "Volume geometry metadata is incomplete in RTSS. Falling back to contour-point bounds.",
1142
+ )
1143
+
1144
+ row_dir = _vunit((orient[0], orient[1], orient[2]))
1145
+ col_dir = _vunit((orient[3], orient[4], orient[5]))
1146
+ if row_dir is None or col_dir is None:
1147
+ return (
1148
+ None,
1149
+ "Image orientation metadata is invalid. Falling back to contour-point bounds.",
1150
+ )
1151
+
1152
+ normal = _vunit(_cross(row_dir, col_dir))
1153
+ if normal is None:
1154
+ return (
1155
+ None,
1156
+ "Unable to derive slice-normal direction from orientation. Falling back to contour-point bounds.",
1157
+ )
1158
+
1159
+ row_spacing, col_spacing = pixel_spacing[0], pixel_spacing[1]
1160
+ row_extent = max(rows - 1, 0) * row_spacing
1161
+ col_extent = max(cols - 1, 0) * col_spacing
1162
+ depth_extent = max(frames - 1, 0) * slice_spacing
1163
+ origin_xyz = (origin[0], origin[1], origin[2])
1164
+
1165
+ corners: list[tuple[float, float, float]] = []
1166
+ for r in (0.0, row_extent):
1167
+ for c in (0.0, col_extent):
1168
+ for d in (0.0, depth_extent):
1169
+ corner = origin_xyz
1170
+ corner = _vadd(corner, _vscale(row_dir, r))
1171
+ corner = _vadd(corner, _vscale(col_dir, c))
1172
+ corner = _vadd(corner, _vscale(normal, d))
1173
+ corners.append(corner)
1174
+
1175
+ bounds = _bounds_from_points(corners)
1176
+ if bounds is None:
1177
+ return (
1178
+ None,
1179
+ "Unable to derive volume bounds from RTSS metadata. Falling back to contour-point bounds.",
1180
+ )
1181
+
1182
+ return bounds, "Volume extents derived from RTSS geometry metadata."
1183
+
1184
+
1185
+ def _merge_bounds(a: dict[str, float] | None, b: dict[str, float] | None) -> dict[str, float] | None:
1186
+ if a is None:
1187
+ return b
1188
+ if b is None:
1189
+ return a
1190
+ return {
1191
+ "x_min": min(a["x_min"], b["x_min"]),
1192
+ "x_max": max(a["x_max"], b["x_max"]),
1193
+ "y_min": min(a["y_min"], b["y_min"]),
1194
+ "y_max": max(a["y_max"], b["y_max"]),
1195
+ "z_min": min(a["z_min"], b["z_min"]),
1196
+ "z_max": max(a["z_max"], b["z_max"]),
1197
+ }
1198
+
1199
+
1200
+ def _add_bounds_box(fig: go.Figure, bounds: dict[str, float], color: str, name: str) -> None:
1201
+ x0, x1 = bounds["x_min"], bounds["x_max"]
1202
+ y0, y1 = bounds["y_min"], bounds["y_max"]
1203
+ z0, z1 = bounds["z_min"], bounds["z_max"]
1204
+
1205
+ corners = [
1206
+ (x0, y0, z0),
1207
+ (x1, y0, z0),
1208
+ (x1, y1, z0),
1209
+ (x0, y1, z0),
1210
+ (x0, y0, z1),
1211
+ (x1, y0, z1),
1212
+ (x1, y1, z1),
1213
+ (x0, y1, z1),
1214
+ ]
1215
+ edges = [
1216
+ (0, 1),
1217
+ (1, 2),
1218
+ (2, 3),
1219
+ (3, 0),
1220
+ (4, 5),
1221
+ (5, 6),
1222
+ (6, 7),
1223
+ (7, 4),
1224
+ (0, 4),
1225
+ (1, 5),
1226
+ (2, 6),
1227
+ (3, 7),
1228
+ ]
1229
+
1230
+ for idx, (a, b) in enumerate(edges):
1231
+ xa, ya, za = corners[a]
1232
+ xb, yb, zb = corners[b]
1233
+ fig.add_trace(
1234
+ go.Scatter3d(
1235
+ x=[xa, xb],
1236
+ y=[ya, yb],
1237
+ z=[za, zb],
1238
+ mode="lines",
1239
+ line={"width": 2, "color": color},
1240
+ name=name if idx == 0 else name,
1241
+ legendgroup=name,
1242
+ showlegend=(idx == 0),
1243
+ opacity=0.35,
1244
+ )
1245
+ )
1246
+
1247
+
1248
+ def _add_axes_markers(fig: go.Figure, bounds: dict[str, float]) -> None:
1249
+ origin = (bounds["x_min"], bounds["y_min"], bounds["z_min"])
1250
+ x_range = max(bounds["x_max"] - bounds["x_min"], 1.0)
1251
+ y_range = max(bounds["y_max"] - bounds["y_min"], 1.0)
1252
+ z_range = max(bounds["z_max"] - bounds["z_min"], 1.0)
1253
+ axis_len = max(x_range, y_range, z_range) * 0.15
1254
+
1255
+ x_end = (origin[0] + axis_len, origin[1], origin[2])
1256
+ y_end = (origin[0], origin[1] + axis_len, origin[2])
1257
+ z_end = (origin[0], origin[1], origin[2] + axis_len)
1258
+
1259
+ fig.add_trace(
1260
+ go.Scatter3d(
1261
+ x=[origin[0], x_end[0]],
1262
+ y=[origin[1], x_end[1]],
1263
+ z=[origin[2], x_end[2]],
1264
+ mode="lines+markers+text",
1265
+ line={"width": 5, "color": "#1f77b4"},
1266
+ marker={"size": [3, 5], "color": "#1f77b4"},
1267
+ text=["", "X+"],
1268
+ textposition="top center",
1269
+ name="X axis",
1270
+ )
1271
+ )
1272
+ fig.add_trace(
1273
+ go.Scatter3d(
1274
+ x=[origin[0], y_end[0]],
1275
+ y=[origin[1], y_end[1]],
1276
+ z=[origin[2], y_end[2]],
1277
+ mode="lines+markers+text",
1278
+ line={"width": 5, "color": "#ff7f0e"},
1279
+ marker={"size": [3, 5], "color": "#ff7f0e"},
1280
+ text=["", "Y+"],
1281
+ textposition="top center",
1282
+ name="Y axis",
1283
+ )
1284
+ )
1285
+ fig.add_trace(
1286
+ go.Scatter3d(
1287
+ x=[origin[0], z_end[0]],
1288
+ y=[origin[1], z_end[1]],
1289
+ z=[origin[2], z_end[2]],
1290
+ mode="lines+markers+text",
1291
+ line={"width": 5, "color": "#2ca02c"},
1292
+ marker={"size": [3, 5], "color": "#2ca02c"},
1293
+ text=["", "Z+"],
1294
+ textposition="top center",
1295
+ name="Z axis",
1296
+ )
1297
+ )
1298
+
1299
+
1300
+ def render_contour_point_cloud(
1301
+ *,
1302
+ left_name: str,
1303
+ right_name: str,
1304
+ left_raw: dict[str, Any],
1305
+ right_raw: dict[str, Any],
1306
+ ) -> None:
1307
+ left_points = extract_contour_points(left_raw)
1308
+ right_points = extract_contour_points(right_raw)
1309
+ left_volume_bounds, left_volume_msg = _extract_volume_bounds_from_rtss(left_raw)
1310
+ right_volume_bounds, right_volume_msg = _extract_volume_bounds_from_rtss(right_raw)
1311
+
1312
+ c1, c2 = st.columns(2)
1313
+ with c1:
1314
+ st.metric(f"{left_name} points", f"{len(left_points):,}")
1315
+ with c2:
1316
+ st.metric(f"{right_name} points", f"{len(right_points):,}")
1317
+
1318
+ if not left_points and not right_points:
1319
+ st.warning("No contour points found in the selected RTSS files.")
1320
+ return
1321
+
1322
+ contour_bounds = _bounds_from_points([*left_points, *right_points])
1323
+ volume_bounds = _merge_bounds(left_volume_bounds, right_volume_bounds)
1324
+ display_bounds = volume_bounds or contour_bounds
1325
+ if display_bounds is None:
1326
+ st.warning("Unable to determine display bounds for the contour plot.")
1327
+ return
1328
+
1329
+ if volume_bounds is not None:
1330
+ st.caption(
1331
+ "Plot extents are aligned to RTSS-derived imaging volume bounds. "
1332
+ f"Left: {left_volume_msg} Right: {right_volume_msg}"
1333
+ )
1334
+ else:
1335
+ st.caption(
1336
+ "Imaging volume bounds were not available in RTSS metadata. "
1337
+ "Using contour-point bounds instead."
1338
+ )
1339
+
1340
+ control_1, control_2, control_3 = st.columns(3)
1341
+ with control_1:
1342
+ show_left = st.checkbox(f"Show {left_name}", value=True, key="pc_show_left")
1343
+ with control_2:
1344
+ show_right = st.checkbox(f"Show {right_name}", value=True, key="pc_show_right")
1345
+ with control_3:
1346
+ point_size = st.slider("Point size", min_value=1, max_value=8, value=3, key="pc_point_size")
1347
+
1348
+ fig = go.Figure()
1349
+
1350
+ if show_left and left_points:
1351
+ lx, ly, lz = zip(*left_points)
1352
+ fig.add_trace(
1353
+ go.Scatter3d(
1354
+ x=lx,
1355
+ y=ly,
1356
+ z=lz,
1357
+ mode="markers",
1358
+ marker={"size": point_size, "color": "red", "opacity": 0.7},
1359
+ name=f"First RTSS: {left_name}",
1360
+ )
1361
+ )
1362
+
1363
+ if show_right and right_points:
1364
+ rx, ry, rz = zip(*right_points)
1365
+ fig.add_trace(
1366
+ go.Scatter3d(
1367
+ x=rx,
1368
+ y=ry,
1369
+ z=rz,
1370
+ mode="markers",
1371
+ marker={"size": point_size, "color": "green", "opacity": 0.7},
1372
+ name=f"Second RTSS: {right_name}",
1373
+ )
1374
+ )
1375
+
1376
+ if not fig.data:
1377
+ st.info("Both point groups are hidden. Turn at least one group back on.")
1378
+ return
1379
+
1380
+ _add_bounds_box(fig, display_bounds, color="#555555", name="Display bounds")
1381
+ _add_axes_markers(fig, display_bounds)
1382
+
1383
+ x_span = max(display_bounds["x_max"] - display_bounds["x_min"], 1.0)
1384
+ y_span = max(display_bounds["y_max"] - display_bounds["y_min"], 1.0)
1385
+ z_span = max(display_bounds["z_max"] - display_bounds["z_min"], 1.0)
1386
+ pad_ratio = 0.03
1387
+ x_pad = x_span * pad_ratio
1388
+ y_pad = y_span * pad_ratio
1389
+ z_pad = z_span * pad_ratio
1390
+
1391
+ fig.update_layout(
1392
+ title="RTSS Contour Point Cloud Diff",
1393
+ scene={
1394
+ "xaxis_title": "X (mm, patient Left +)",
1395
+ "yaxis_title": "Y (mm, patient Posterior +)",
1396
+ "zaxis_title": "Z (mm, patient Superior +)",
1397
+ "xaxis": {"range": [display_bounds["x_min"] - x_pad, display_bounds["x_max"] + x_pad]},
1398
+ "yaxis": {"range": [display_bounds["y_min"] - y_pad, display_bounds["y_max"] + y_pad]},
1399
+ "zaxis": {"range": [display_bounds["z_min"] - z_pad, display_bounds["z_max"] + z_pad]},
1400
+ },
1401
+ margin={"l": 0, "r": 0, "t": 48, "b": 0},
1402
+ legend={"orientation": "h", "y": 1.02, "x": 0},
1403
+ )
1404
+ st.plotly_chart(fig, use_container_width=True)
1405
+ st.caption(
1406
+ "Coordinate system uses DICOM patient coordinates (LPS): X increases toward patient Left, "
1407
+ "Y increases toward Posterior, and Z increases toward Superior. "
1408
+ "Use drag to rotate, scroll to zoom, and legend clicks to show or hide each group."
1409
+ )
1410
+
1411
+
1412
  def main() -> None:
1413
  st.set_page_config(page_title="RTSS Diff Viewer", layout="wide")
1414
  ensure_state()
1415
 
1416
  st.title("RTSS Diff Viewer")
1417
+ st.caption("Compare RTSS DICOM files using textual diffs and 2D axial contour overlays.")
 
 
 
 
 
 
 
1418
 
1419
+ app_mode = st.radio(
1420
+ "Mode",
1421
+ options=["Instructions", "Pair Mode", "Batch Compare"],
1422
+ index=0,
1423
+ horizontal=True,
1424
  )
1425
 
1426
+ if app_mode == "Instructions":
1427
+ st.markdown("### What This App Does")
1428
+ st.write("This app compares RTSS DICOM files using intelligent text diffs and 2D axial contour overlays.")
1429
+
1430
+ st.markdown("### Pair Mode")
1431
+ st.write("Use Pair Mode to compare exactly two RTSS files in detail.")
1432
+ st.write("1. Open Pair Mode.")
1433
+ st.write("2. In Upload Pair, upload the first and second RTSS .dcm files.")
1434
+ st.write("3. Click Convert pair.")
1435
+ st.write("4. Open Pair Diff to review JSON differences by component:")
1436
+ st.write(" - **metadata**: DICOM package-level tags (fast text diff)")
1437
+ st.write(" - **structures**: ROI structure definitions (text diff)")
1438
+ st.write(" - **references**: Reference frame sequences (text diff)")
1439
+ st.write(" - **contours**: 2D axial visual comparison directly in Pair Diff (fast, slice-by-slice)")
1440
+ st.write("5. Open Contour Detail View to inspect textual point lists and per-slice unified diffs.")
1441
+ st.write("6. Navigation buttons keep the selected slice synchronized between visual and detail views.")
1442
+
1443
+ st.markdown("### Batch Compare")
1444
+ st.write("Use Batch Compare to compare any two files from a larger uploaded set.")
1445
+ st.write("1. Open Batch Compare.")
1446
+ st.write("2. Upload multiple RTSS .dcm files.")
1447
+ st.write("3. Click Convert uploaded set.")
1448
+ st.write("4. Pick any two variants from the dropdowns.")
1449
+ st.write("5. Select the component to compare (see above for component descriptions).")
1450
+ st.write("6. Review the diff and download outputs if needed.")
1451
+
1452
+ st.markdown("### Component Comparison Details")
1453
+ st.write("**Metadata, Structures, References**: Uses standard unified text diff (fast and simple).")
1454
+ st.write("**Contours**: Uses intelligent slice-plane comparison:")
1455
+ st.write("- Contour points are grouped by slice (z-coordinate)")
1456
+ st.write("- For each slice, differences between ROIs are clearly highlighted")
1457
+ st.write("- Slices-only in one file are clearly marked")
1458
+ st.write("- This approach is much faster and more useful for large RTSS files with hundreds of points")
1459
+
1460
+ st.markdown("### Tips")
1461
+ st.write("- For RTSS files with many contour points (>50), use the **contours** component in Pair Diff for fast visual review.")
1462
+ st.write("- For metadata changes, use **metadata** to quickly identify DICOM tag differences.")
1463
+ st.write("- In contour visual view, use slider and Previous/Next buttons to move quickly between slices.")
1464
+ st.write("- Large comparisons automatically switch to unified text diff for faster loading (when not using contour mode).")
1465
+
1466
+ elif app_mode == "Pair Mode":
1467
+ st.markdown("### Pair Mode")
1468
+ step_options = ["Upload Pair", "Pair Diff", "Contour Detail View"]
1469
+ step = st.radio(
1470
+ "Step",
1471
+ options=step_options,
1472
+ index=step_options.index(st.session_state.pair_step),
1473
+ horizontal=True,
1474
+ )
1475
+ st.session_state.pair_step = step
1476
+
1477
+ if step == "Upload Pair":
1478
+ st.info("Step 1 of 3: Upload two RTSS files and click Convert pair.")
1479
+ left_col, right_col = st.columns(2)
1480
+ with left_col:
1481
+ left_file = st.file_uploader("Left RTSS (.dcm)", type=["dcm"], key="left_pair")
1482
+ with right_col:
1483
+ right_file = st.file_uploader("Right RTSS (.dcm)", type=["dcm"], key="right_pair")
1484
+
1485
+ if st.button("Convert pair", type="primary"):
1486
+ if left_file is None or right_file is None:
1487
+ st.warning("Upload both files first.")
1488
+ else:
1489
+ with st.spinner("Converting files..."):
1490
+ st.session_state.left_json_raw = dcm_bytes_to_json_dict(left_file.getvalue())
1491
+ st.session_state.right_json_raw = dcm_bytes_to_json_dict(right_file.getvalue())
1492
+ st.session_state.left_name = left_file.name
1493
+ st.session_state.right_name = right_file.name
1494
+ st.session_state.pair_diff_requested = False
1495
+ st.session_state.pair_diff_sig = None
1496
+ st.session_state.pair_step = "Pair Diff"
1497
+ st.rerun()
1498
 
1499
+ elif step == "Pair Diff":
1500
+ st.info("Step 2 of 3: Choose component and compute diff on demand.")
1501
+ st.markdown("**Select what to compare:**")
1502
+ st.caption("**metadata** compares package-level DICOM tags. **structures** and **references** use text diff. **contours** shows an intelligent slice-by-slice point comparison (recommended for large files).")
1503
+ component_options = ["metadata", "structures", "references", "contours"]
1504
+ control_col_1, control_col_2, control_col_3 = st.columns(3)
1505
+ with control_col_1:
1506
+ component = st.selectbox("Component", options=component_options, index=0, key="pair_component")
1507
+ with control_col_2:
1508
+ precision = st.slider("Float precision", min_value=2, max_value=10, value=6, key="pair_precision")
1509
+ with control_col_3:
1510
+ keep_volatile = st.checkbox("Keep volatile UID/time tags", value=False, key="pair_keep_volatile")
1511
+
1512
+ left_raw = st.session_state.left_json_raw
1513
+ right_raw = st.session_state.right_json_raw
1514
+ if left_raw is None or right_raw is None:
1515
+ st.info("Convert a pair in Upload Pair first.")
1516
  else:
1517
+ current_sig = (
1518
+ component,
1519
+ precision,
1520
+ keep_volatile,
1521
+ st.session_state.left_name,
1522
+ st.session_state.right_name,
1523
+ )
1524
+ compute_clicked = st.button("Compute Pair Diff", type="primary", key="pair_compute_diff")
1525
+ if compute_clicked:
1526
+ st.session_state.pair_diff_requested = True
1527
+ st.session_state.pair_diff_sig = current_sig
1528
+
1529
+ pair_diff_sig = st.session_state.pair_diff_sig
1530
+ should_render_diff = (
1531
+ st.session_state.pair_diff_requested
1532
+ and (pair_diff_sig == current_sig or pair_diff_sig == "force")
1533
+ )
1534
+
1535
+ if should_render_diff:
1536
+ if component == "contours":
1537
+ st.caption("Visual contour comparison is shown directly for faster slice-by-slice review.")
1538
+ render_axial_contour_view(
1539
+ left_name=st.session_state.left_name,
1540
+ right_name=st.session_state.right_name,
1541
+ left_raw=left_raw,
1542
+ right_raw=right_raw,
1543
+ precision=precision,
1544
+ )
1545
+ else:
1546
+ with st.spinner("Computing diff..."):
1547
+ render_diff_panel(
1548
+ left_name=st.session_state.left_name,
1549
+ right_name=st.session_state.right_name,
1550
+ left_raw=left_raw,
1551
+ right_raw=right_raw,
1552
+ component=component,
1553
+ precision=precision,
1554
+ keep_volatile=keep_volatile,
1555
+ allow_rich_view=True,
1556
+ max_rich_chars=400_000,
1557
+ max_rich_lines=5_000,
1558
+ key_prefix="pair",
1559
+ )
1560
+ st.session_state.pair_diff_sig = current_sig
1561
+ else:
1562
+ st.caption("Diff is computed on demand. Click 'Compute Pair Diff' to run comparison.")
1563
+
1564
  else:
1565
+ st.markdown("### Contour Detail View")
1566
+ st.info(
1567
+ "Step 3 of 3: Inspect textual contour point descriptions on each slice. "
1568
+ "Use this for detailed text-level slice comparison."
1569
+ )
1570
+ left_raw = st.session_state.left_json_raw
1571
+ right_raw = st.session_state.right_json_raw
1572
+ if left_raw is None or right_raw is None:
1573
+ st.info("Convert a pair in Upload Pair first.")
1574
+ else:
1575
+ with st.spinner("Preparing contour slice text comparison..."):
1576
+ render_contour_detail_text_view(
1577
+ left_name=st.session_state.left_name,
1578
+ right_name=st.session_state.right_name,
1579
+ left_raw=left_raw,
1580
+ right_raw=right_raw,
1581
+ precision=4,
1582
+ )
1583
+
1584
+ else:
1585
+ st.markdown("### Batch Compare")
1586
  files = st.file_uploader(
1587
  "Upload RTSS variant set (.dcm)",
1588
  type=["dcm"],
 
1607
  variants = st.session_state.batch_variants
1608
  if not variants:
1609
  st.info("No batch set loaded yet.")
1610
+ else:
1611
+ names = sorted(variants.keys())
1612
+ if len(names) < 2:
1613
+ st.warning("Need at least two variants.")
1614
+ else:
1615
+ st.markdown("**Select what to compare:**")
1616
+ st.caption("**metadata** compares package-level DICOM tags. **structures** and **references** use text diff. **contours** shows an intelligent slice-by-slice point comparison (recommended for large files).")
1617
+ component_options = ["metadata", "structures", "references", "contours"]
1618
+ control_col_1, control_col_2, control_col_3 = st.columns(3)
1619
+ with control_col_1:
1620
+ component = st.selectbox("Component", options=component_options, index=0, key="batch_component")
1621
+ with control_col_2:
1622
+ precision = st.slider("Float precision", min_value=2, max_value=10, value=6, key="batch_precision")
1623
+ with control_col_3:
1624
+ keep_volatile = st.checkbox("Keep volatile UID/time tags", value=False, key="batch_keep_volatile")
1625
 
1626
+ left_col, right_col = st.columns(2)
1627
+ with left_col:
1628
+ left_name = st.selectbox("Left variant", names, index=0, key="batch_left")
1629
+ with right_col:
1630
+ right_name = st.selectbox("Right variant", names, index=1, key="batch_right")
1631
 
1632
+ if left_name == right_name:
1633
+ st.warning("Select two different variants.")
1634
+ else:
1635
+ render_diff_panel(
1636
+ left_name=left_name,
1637
+ right_name=right_name,
1638
+ left_raw=variants[left_name],
1639
+ right_raw=variants[right_name],
1640
+ component=component,
1641
+ precision=precision,
1642
+ keep_volatile=keep_volatile,
1643
+ allow_rich_view=False,
1644
+ max_rich_chars=0,
1645
+ max_rich_lines=0,
1646
+ key_prefix="batch",
1647
+ )
 
 
 
1648
 
1649
 
1650
  if __name__ == "__main__":
pyproject.toml CHANGED
@@ -7,6 +7,8 @@ dependencies = [
7
  "streamlit>=1.43",
8
  "st-diff-viewer>=1.0.7",
9
  "pydicom>=3.0.1",
 
 
10
  ]
11
 
12
  [build-system]
 
7
  "streamlit>=1.43",
8
  "st-diff-viewer>=1.0.7",
9
  "pydicom>=3.0.1",
10
+ "plotly>=5.24",
11
+ "pytest>=8.3",
12
  ]
13
 
14
  [build-system]
requirements.txt CHANGED
@@ -1,3 +1,5 @@
1
  streamlit>=1.43
2
  st-diff-viewer>=1.0.7
3
  pydicom>=3.0.1
 
 
 
1
  streamlit>=1.43
2
  st-diff-viewer>=1.0.7
3
  pydicom>=3.0.1
4
+ plotly>=5.24
5
+ pytest>=8.3
src/rtssdiffviewer/diff_core.py CHANGED
@@ -81,3 +81,178 @@ def unified_diff_text(left_text: str, right_text: str, left_name: str, right_nam
81
  lineterm="",
82
  )
83
  return "\n".join(diff)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  lineterm="",
82
  )
83
  return "\n".join(diff)
84
+
85
+
86
+ def _extract_xyz_points(value: Any) -> list[tuple[float, float, float]]:
87
+ """Recursively extract (x, y, z) coordinate tuples from nested structures."""
88
+ points: list[tuple[float, float, float]] = []
89
+
90
+ if isinstance(value, list):
91
+ if len(value) == 3 and all(isinstance(v, (int, float)) for v in value):
92
+ points.append((float(value[0]), float(value[1]), float(value[2])))
93
+ else:
94
+ for item in value:
95
+ points.extend(_extract_xyz_points(item))
96
+
97
+ return points
98
+
99
+
100
+ def extract_contours_by_slice(
101
+ contour_data: dict[str, Any], precision: int
102
+ ) -> dict[float, dict[str, list[tuple[float, float, float]]]]:
103
+ """
104
+ Extract contours grouped by slice (z-coordinate, rounded to precision).
105
+
106
+ Returns a dict mapping z-coordinate -> {roi_name -> list of (x, y, z) points}
107
+ """
108
+ slices: dict[float, dict[str, list[tuple[float, float, float]]]] = {}
109
+
110
+ roi_contour_seq = contour_data.get("(3006,0039) ROIContourSequence", [])
111
+ if not isinstance(roi_contour_seq, list):
112
+ return slices
113
+
114
+ for roi_idx, roi_item in enumerate(roi_contour_seq):
115
+ if not isinstance(roi_item, dict):
116
+ continue
117
+
118
+ # Try to get ROI identifier - use ReferencedROINumber if available, otherwise use index
119
+ roi_number = roi_item.get("(3006,0084) ReferencedROINumber")
120
+ if roi_number is None:
121
+ roi_number = roi_idx
122
+ roi_name = f"ROI {roi_number}"
123
+
124
+ # Extract contour sequences
125
+ contour_seq = roi_item.get("(3006,0040) ContourSequence", [])
126
+ if not isinstance(contour_seq, list):
127
+ continue
128
+
129
+ for contour_item in contour_seq:
130
+ if not isinstance(contour_item, dict):
131
+ continue
132
+
133
+ points = _extract_xyz_points(contour_item.get("(3006,0050) ContourData", []))
134
+ if not points:
135
+ continue
136
+
137
+ # Group by z-coordinate (rounded to precision)
138
+ for x, y, z in points:
139
+ z_rounded = round(z, precision)
140
+ if z_rounded not in slices:
141
+ slices[z_rounded] = {}
142
+ if roi_name not in slices[z_rounded]:
143
+ slices[z_rounded][roi_name] = []
144
+ slices[z_rounded][roi_name].append((x, y, z))
145
+
146
+ return slices
147
+
148
+
149
+ def _format_points_for_display(points: list[tuple[float, float, float]], precision: int = 4) -> str:
150
+ """Format a list of points as a readable string."""
151
+ if not points:
152
+ return " (no points)"
153
+ lines = []
154
+ for i, (x, y, z) in enumerate(sorted(points), 1):
155
+ lines.append(f"Point {i}: x={x:.{precision}f}, y={y:.{precision}f}, z={z:.{precision}f}")
156
+ return "\n".join(lines)
157
+
158
+
159
+ def get_contour_slices_structured(
160
+ left_data: dict[str, Any],
161
+ right_data: dict[str, Any],
162
+ precision: int = 4,
163
+ ) -> tuple[dict[float, dict[str, list[tuple[float, float, float]]]], dict[float, dict[str, list[tuple[float, float, float]]]]]:
164
+ """
165
+ Extract and return structured slice data for both left and right files.
166
+
167
+ Returns: (left_slices, right_slices) where each is a dict mapping
168
+ z-coordinate -> {roi_name -> list of (x, y, z) points}
169
+ """
170
+ left_slices = extract_contours_by_slice(left_data, precision)
171
+ right_slices = extract_contours_by_slice(right_data, precision)
172
+ return left_slices, right_slices
173
+
174
+
175
+ def contour_diff_text(
176
+ left_data: dict[str, Any],
177
+ right_data: dict[str, Any],
178
+ left_name: str,
179
+ right_name: str,
180
+ precision: int = 4,
181
+ ) -> str:
182
+ """
183
+ Generate an intelligent diff for contour data grouped by slice plane.
184
+
185
+ Compares contours by slice (z-coordinate) and shows:
186
+ - Slices only in left file
187
+ - Slices only in right file
188
+ - Slices in both with point differences
189
+ """
190
+ left_slices = extract_contours_by_slice(left_data, precision)
191
+ right_slices = extract_contours_by_slice(right_data, precision)
192
+
193
+ if not left_slices and not right_slices:
194
+ return "No contour data found in either file."
195
+
196
+ lines: list[str] = []
197
+ lines.append(f"=== Contour Diff by Slice Plane ===")
198
+ lines.append(f"Left: {left_name}")
199
+ lines.append(f"Right: {right_name}")
200
+ lines.append("")
201
+
202
+ all_z_coords = sorted(set(left_slices.keys()) | set(right_slices.keys()))
203
+
204
+ for z in all_z_coords:
205
+ lines.append(f"--- Slice z={z:.{precision}f} ---")
206
+
207
+ left_rois = left_slices.get(z, {})
208
+ right_rois = right_slices.get(z, {})
209
+
210
+ if z not in left_slices:
211
+ lines.append(f"Only in {right_name}:")
212
+ for roi_name, points in right_rois.items():
213
+ lines.append(f" ROI: {roi_name} ({len(points)} points)")
214
+ lines.append(_format_points_for_display(points, precision))
215
+ lines.append("")
216
+ continue
217
+
218
+ if z not in right_slices:
219
+ lines.append(f"Only in {left_name}:")
220
+ for roi_name, points in left_rois.items():
221
+ lines.append(f" ROI: {roi_name} ({len(points)} points)")
222
+ lines.append(_format_points_for_display(points, precision))
223
+ lines.append("")
224
+ continue
225
+
226
+ # Both have this slice - compare ROIs
227
+ all_roi_names = sorted(set(left_rois.keys()) | set(right_rois.keys()))
228
+ has_differences = False
229
+
230
+ for roi_name in all_roi_names:
231
+ left_points = sorted(left_rois.get(roi_name, []))
232
+ right_points = sorted(right_rois.get(roi_name, []))
233
+
234
+ if left_points == right_points:
235
+ continue
236
+
237
+ has_differences = True
238
+ lines.append(f" ROI: {roi_name}")
239
+
240
+ if roi_name not in left_rois:
241
+ lines.append(f" Only in {right_name}: {len(right_points)} points")
242
+ lines.append(_format_points_for_display(right_points, precision))
243
+ elif roi_name not in right_rois:
244
+ lines.append(f" Only in {left_name}: {len(left_points)} points")
245
+ lines.append(_format_points_for_display(left_points, precision))
246
+ else:
247
+ lines.append(f" {left_name}: {len(left_points)} points")
248
+ lines.append(_format_points_for_display(left_points, precision))
249
+ lines.append(f" {right_name}: {len(right_points)} points")
250
+ lines.append(_format_points_for_display(right_points, precision))
251
+
252
+ if has_differences:
253
+ lines.append("")
254
+ else:
255
+ lines.append(f" All ROIs identical on this slice")
256
+ lines.append("")
257
+
258
+ return "\n".join(lines)
tests/test_app_features.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ ROOT = Path(__file__).resolve().parents[1]
7
+ if str(ROOT) not in sys.path:
8
+ sys.path.insert(0, str(ROOT))
9
+
10
+ from app import (
11
+ _format_slice_rois_text,
12
+ _greedy_point_matches,
13
+ _extract_ordered_contours_by_slice,
14
+ _extract_volume_bounds_from_rtss,
15
+ _merge_bounds,
16
+ _nearest_slice_value,
17
+ _safe_key_fragment,
18
+ _slice_match_metrics,
19
+ _step_slice_value,
20
+ extract_contour_points,
21
+ should_use_unified_only,
22
+ )
23
+
24
+
25
+ def test_extract_contour_points_collects_all_triplets() -> None:
26
+ sample = {
27
+ "(3006,0039) ROIContourSequence": [
28
+ {
29
+ "(3006,0040) ContourSequence": [
30
+ {
31
+ "(3006,0050) ContourData": [
32
+ [1.0, 2.0, 3.0],
33
+ [4, 5, 6],
34
+ ]
35
+ },
36
+ {
37
+ "(3006,0050) ContourData": [
38
+ [7.5, 8.5, 9.5],
39
+ ]
40
+ },
41
+ ]
42
+ }
43
+ ]
44
+ }
45
+
46
+ points = extract_contour_points(sample)
47
+
48
+ assert points == [
49
+ (1.0, 2.0, 3.0),
50
+ (4.0, 5.0, 6.0),
51
+ (7.5, 8.5, 9.5),
52
+ ]
53
+
54
+
55
+ def test_extract_contour_points_ignores_non_triplet_data() -> None:
56
+ sample = {
57
+ "(3006,0050) ContourData": [
58
+ [1.0, 2.0],
59
+ [3.0, 4.0, 5.0, 6.0],
60
+ "not-a-point",
61
+ {"unexpected": "shape"},
62
+ ]
63
+ }
64
+
65
+ points = extract_contour_points(sample)
66
+
67
+ assert points == []
68
+
69
+
70
+ def test_should_use_unified_only_for_batch_text_mode() -> None:
71
+ use_unified_only, reason = should_use_unified_only(
72
+ "left",
73
+ "right",
74
+ allow_rich_view=False,
75
+ max_rich_chars=400_000,
76
+ max_rich_lines=5_000,
77
+ )
78
+
79
+ assert use_unified_only is True
80
+ assert "unified diff text only" in reason
81
+
82
+
83
+ def test_should_use_unified_only_for_large_input() -> None:
84
+ left = "a" * 250_000
85
+ right = "b" * 250_000
86
+
87
+ use_unified_only, reason = should_use_unified_only(
88
+ left,
89
+ right,
90
+ allow_rich_view=True,
91
+ max_rich_chars=400_000,
92
+ max_rich_lines=5_000,
93
+ )
94
+
95
+ assert use_unified_only is True
96
+ assert "Large comparison detected" in reason
97
+
98
+
99
+ def test_should_use_rich_view_for_small_input() -> None:
100
+ use_unified_only, reason = should_use_unified_only(
101
+ "small-left\n",
102
+ "small-right\n",
103
+ allow_rich_view=True,
104
+ max_rich_chars=400_000,
105
+ max_rich_lines=5_000,
106
+ )
107
+
108
+ assert use_unified_only is False
109
+ assert reason == ""
110
+
111
+
112
+ def test_extract_volume_bounds_from_rtss_metadata() -> None:
113
+ sample = {
114
+ "(3006,0039) ROIContourSequence": [
115
+ {
116
+ "(3006,004A) SourcePixelPlanesCharacteristicsSequence": [
117
+ {
118
+ "(0020,0032) ImagePositionPatient": [10.0, 20.0, 30.0],
119
+ "(0020,0037) ImageOrientationPatient": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
120
+ "(0028,0030) PixelSpacing": [2.0, 1.0],
121
+ "(0028,0010) Rows": 2,
122
+ "(0028,0011) Columns": 3,
123
+ "(0028,0008) NumberOfFrames": 4,
124
+ "(0018,0088) SpacingBetweenSlices": 2.0,
125
+ }
126
+ ]
127
+ }
128
+ ]
129
+ }
130
+
131
+ bounds, msg = _extract_volume_bounds_from_rtss(sample)
132
+
133
+ assert bounds is not None
134
+ assert bounds["x_min"] == 10.0
135
+ assert bounds["x_max"] == 12.0
136
+ assert bounds["y_min"] == 20.0
137
+ assert bounds["y_max"] == 22.0
138
+ assert bounds["z_min"] == 30.0
139
+ assert bounds["z_max"] == 36.0
140
+ assert "derived" in msg
141
+
142
+
143
+ def test_extract_volume_bounds_from_rtss_missing_metadata() -> None:
144
+ sample = {
145
+ "(3006,0039) ROIContourSequence": [
146
+ {"(3006,0040) ContourSequence": [{"(3006,0050) ContourData": [[1.0, 2.0, 3.0]]}]}
147
+ ]
148
+ }
149
+
150
+ bounds, msg = _extract_volume_bounds_from_rtss(sample)
151
+
152
+ assert bounds is None
153
+ assert "incomplete" in msg
154
+
155
+
156
+ def test_merge_bounds() -> None:
157
+ a = {"x_min": 0.0, "x_max": 1.0, "y_min": 2.0, "y_max": 3.0, "z_min": 4.0, "z_max": 5.0}
158
+ b = {"x_min": -1.0, "x_max": 2.0, "y_min": 1.5, "y_max": 3.5, "z_min": 3.0, "z_max": 6.0}
159
+
160
+ merged = _merge_bounds(a, b)
161
+
162
+ assert merged == {
163
+ "x_min": -1.0,
164
+ "x_max": 2.0,
165
+ "y_min": 1.5,
166
+ "y_max": 3.5,
167
+ "z_min": 3.0,
168
+ "z_max": 6.0,
169
+ }
170
+
171
+
172
+ def test_greedy_point_matches_unique_pairs() -> None:
173
+ left = [(0.0, 0.0, 0.0), (10.0, 0.0, 0.0)]
174
+ right = [(0.2, 0.0, 0.0), (9.8, 0.0, 0.0)]
175
+
176
+ matches = _greedy_point_matches(left, right)
177
+
178
+ assert len(matches) == 2
179
+ assert {m[0] for m in matches} == {0, 1}
180
+ assert {m[1] for m in matches} == {0, 1}
181
+
182
+
183
+ def test_slice_match_metrics_dice_and_mismatch() -> None:
184
+ left_rois = {
185
+ "ROI 1": [
186
+ (0.0, 0.0, 1.0),
187
+ (10.0, 0.0, 1.0),
188
+ ]
189
+ }
190
+ right_rois = {
191
+ "ROI 1": [
192
+ (0.2, 0.0, 1.0),
193
+ (11.5, 0.0, 1.0),
194
+ ]
195
+ }
196
+
197
+ metrics = _slice_match_metrics(left_rois, right_rois, tolerance_mm=1.0)
198
+
199
+ assert metrics["left_count"] == 2
200
+ assert metrics["right_count"] == 2
201
+ assert metrics["mismatch_count"] == 2
202
+ assert metrics["count_delta"] == 0
203
+ assert metrics["dice"] == 0.5
204
+ assert metrics["identical_slice"] is False
205
+
206
+
207
+ def test_slice_match_metrics_identical_slice() -> None:
208
+ rois = {
209
+ "ROI 1": [
210
+ (1.0, 2.0, 3.0),
211
+ (4.0, 5.0, 3.0),
212
+ ]
213
+ }
214
+
215
+ metrics = _slice_match_metrics(rois, rois, tolerance_mm=0.1)
216
+
217
+ assert metrics["identical_slice"] is True
218
+ assert metrics["mismatch_count"] == 0
219
+ assert metrics["dice"] == 1.0
220
+
221
+
222
+ def test_safe_key_fragment_replaces_non_alnum() -> None:
223
+ assert _safe_key_fragment("z=12.5/ROI 1") == "z_12_5_ROI_1"
224
+
225
+
226
+ def test_nearest_slice_value_selects_closest() -> None:
227
+ slices = [1.0, 2.5, 5.0]
228
+
229
+ assert _nearest_slice_value(slices, 2.7) == 2.5
230
+ assert _nearest_slice_value(slices, None) == 1.0
231
+
232
+
233
+ def test_nearest_slice_value_returns_none_for_empty() -> None:
234
+ assert _nearest_slice_value([], 10.0) is None
235
+
236
+
237
+ def test_extract_ordered_contours_by_slice_preserves_point_order() -> None:
238
+ sample = {
239
+ "(3006,0039) ROIContourSequence": [
240
+ {
241
+ "(3006,0084) ReferencedROINumber": 7,
242
+ "(3006,0040) ContourSequence": [
243
+ {
244
+ "(3006,0048) ContourNumber": 3,
245
+ "(3006,0050) ContourData": [
246
+ [10.0, 5.0, 1.0001],
247
+ [11.0, 6.0, 1.0001],
248
+ [12.0, 7.0, 1.0001],
249
+ ],
250
+ }
251
+ ],
252
+ }
253
+ ]
254
+ }
255
+
256
+ slices = _extract_ordered_contours_by_slice(sample, precision=3)
257
+
258
+ assert list(slices.keys()) == [1.0]
259
+ assert len(slices[1.0]) == 1
260
+ contour = slices[1.0][0]
261
+ assert contour["contour_label"] == "ROI 7 | Contour 3"
262
+ assert contour["points"] == [
263
+ (10.0, 5.0, 1.0001),
264
+ (11.0, 6.0, 1.0001),
265
+ (12.0, 7.0, 1.0001),
266
+ ]
267
+
268
+
269
+ def test_step_slice_value_bounds_and_steps() -> None:
270
+ slices = [1.0, 2.0, 3.0]
271
+
272
+ assert _step_slice_value(slices, 2.0, -1) == 1.0
273
+ assert _step_slice_value(slices, 2.0, 1) == 3.0
274
+ assert _step_slice_value(slices, 1.0, -1) == 1.0
275
+ assert _step_slice_value(slices, 3.0, 1) == 3.0
276
+
277
+
278
+ def test_step_slice_value_handles_missing_current() -> None:
279
+ slices = [1.0, 2.0, 3.0]
280
+ assert _step_slice_value(slices, 99.0, 1) == 2.0
281
+
282
+
283
+ def test_format_slice_rois_text_includes_index_and_order() -> None:
284
+ rois = {
285
+ "ROI 2": [(2.0, 2.0, 5.0)],
286
+ "ROI 1": [(1.0, 1.0, 5.0), (3.0, 3.0, 5.0)],
287
+ }
288
+
289
+ text = _format_slice_rois_text(rois, precision=2)
290
+
291
+ assert "ROI 1: 2 points" in text
292
+ assert "001: (1.00, 1.00, 5.00)" in text
293
+ assert "002: (3.00, 3.00, 5.00)" in text
294
+ assert "ROI 2: 1 points" in text
295
+
296
+
297
+ def test_format_slice_rois_text_empty() -> None:
298
+ assert _format_slice_rois_text({}, precision=4) == "(no contours on this slice)"