amithjkamath commited on
Commit
0544b07
·
unverified ·
1 Parent(s): 358087c

updated interface for app

Browse files
Files changed (6) hide show
  1. Makefile +40 -5
  2. README.md +19 -1
  3. app.py +607 -69
  4. pyproject.toml +2 -0
  5. requirements.txt +2 -0
  6. tests/test_app_features.py +162 -0
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 \
 
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 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 \
README.md CHANGED
@@ -18,13 +18,31 @@ A standalone Streamlit app for:
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:
 
18
  - visualizing git-like diffs between two RTSS versions
19
  - batch-uploading multiple variants and switching any two versions for comparison
20
 
21
+ ## User Workflows
22
+
23
+ - `Instructions` mode: in-app overview of Pair Mode and Batch Compare usage.
24
+ - `Pair Mode`: upload two RTSS files, inspect structured diff, and view contour points in 3D.
25
+ - `Batch Compare`: upload multiple RTSS files and compare any two using unified text diff.
26
+
27
+ ## Developer Setup
28
 
29
  ```bash
30
  make install
31
  make run
32
  ```
33
 
34
+ ## Developer Testing
35
+
36
+ ```bash
37
+ make test
38
+ ```
39
+
40
+ To install dependencies and run tests in one step:
41
+
42
+ ```bash
43
+ make test-all
44
+ ```
45
+
46
  ## Hugging Face Spaces Deployment
47
 
48
  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
@@ -58,6 +60,9 @@ 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()
@@ -101,8 +106,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 +131,568 @@ 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 +717,42 @@ 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
 
60
  component: str,
61
  precision: int,
62
  keep_volatile: bool,
63
+ allow_rich_view: bool,
64
+ max_rich_chars: int,
65
+ max_rich_lines: int,
66
  key_prefix: str,
67
  ) -> None:
68
  ignore_prefixes: set[str] = set()
 
106
  key=f"{key_prefix}_dl_diff",
107
  )
108
 
109
+ use_unified_only, reason = should_use_unified_only(
110
+ left_text,
111
+ right_text,
112
+ allow_rich_view=allow_rich_view,
113
+ max_rich_chars=max_rich_chars,
114
+ max_rich_lines=max_rich_lines,
115
+ )
116
+
117
  st.markdown("### Diff View")
118
+ if use_unified_only:
119
+ if reason:
120
+ st.caption(reason)
121
+ if diff_text.strip():
122
+ st.code(diff_text, language="diff")
123
+ else:
124
+ st.success("No differences after normalization and filtering.")
125
+ elif diff_viewer is not None:
126
  diff_viewer(left_text, right_text, split_view=True)
127
  else:
128
  if diff_text.strip():
 
131
  st.success("No differences after normalization and filtering.")
132
 
133
 
134
+ def should_use_unified_only(
135
+ left_text: str,
136
+ right_text: str,
137
+ *,
138
+ allow_rich_view: bool,
139
+ max_rich_chars: int,
140
+ max_rich_lines: int,
141
+ ) -> tuple[bool, str]:
142
+ if not allow_rich_view:
143
+ return True, "Showing unified diff text only for this mode."
144
+
145
+ total_chars = len(left_text) + len(right_text)
146
+ total_lines = left_text.count("\n") + right_text.count("\n") + 2
147
+ if total_chars > max_rich_chars or total_lines > max_rich_lines:
148
+ return (
149
+ True,
150
+ (
151
+ "Large comparison detected. Showing unified diff text for faster loading. "
152
+ f"(chars={total_chars:,}, lines={total_lines:,})"
153
+ ),
154
+ )
155
+
156
+ return False, ""
157
+
158
+
159
+ def _extract_xyz_points(value: Any) -> list[tuple[float, float, float]]:
160
+ points: list[tuple[float, float, float]] = []
161
+
162
+ if isinstance(value, list):
163
+ if len(value) == 3 and all(isinstance(v, (int, float)) for v in value):
164
+ points.append((float(value[0]), float(value[1]), float(value[2])))
165
+ else:
166
+ for item in value:
167
+ points.extend(_extract_xyz_points(item))
168
+
169
+ return points
170
+
171
+
172
+ def extract_contour_points(rtss_json: dict[str, Any]) -> list[tuple[float, float, float]]:
173
+ points: list[tuple[float, float, float]] = []
174
+
175
+ def walk(node: Any) -> None:
176
+ if isinstance(node, dict):
177
+ for key, value in node.items():
178
+ if "ContourData" in key:
179
+ points.extend(_extract_xyz_points(value))
180
+ else:
181
+ walk(value)
182
+ return
183
+
184
+ if isinstance(node, list):
185
+ for item in node:
186
+ walk(item)
187
+
188
+ walk(rtss_json)
189
+ return points
190
+
191
+
192
+ def _as_float_list(value: Any, expected_len: int | None = None) -> list[float] | None:
193
+ if not isinstance(value, list):
194
+ return None
195
+ try:
196
+ out = [float(v) for v in value]
197
+ except (TypeError, ValueError):
198
+ return None
199
+ if expected_len is not None and len(out) != expected_len:
200
+ return None
201
+ return out
202
+
203
+
204
+ def _as_int(value: Any) -> int | None:
205
+ if isinstance(value, int):
206
+ return value
207
+ try:
208
+ return int(str(value))
209
+ except (TypeError, ValueError):
210
+ return None
211
+
212
+
213
+ def _find_first_keyword_value(node: Any, keyword: str) -> Any | None:
214
+ if isinstance(node, dict):
215
+ for key, value in node.items():
216
+ if keyword in key:
217
+ return value
218
+ found = _find_first_keyword_value(value, keyword)
219
+ if found is not None:
220
+ return found
221
+ elif isinstance(node, list):
222
+ for item in node:
223
+ found = _find_first_keyword_value(item, keyword)
224
+ if found is not None:
225
+ return found
226
+ return None
227
+
228
+
229
+ def _vadd(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
230
+ return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
231
+
232
+
233
+ def _vscale(v: tuple[float, float, float], s: float) -> tuple[float, float, float]:
234
+ return (v[0] * s, v[1] * s, v[2] * s)
235
+
236
+
237
+ def _vnorm(v: tuple[float, float, float]) -> float:
238
+ return sqrt(v[0] ** 2 + v[1] ** 2 + v[2] ** 2)
239
+
240
+
241
+ def _vunit(v: tuple[float, float, float]) -> tuple[float, float, float] | None:
242
+ n = _vnorm(v)
243
+ if n == 0:
244
+ return None
245
+ return (v[0] / n, v[1] / n, v[2] / n)
246
+
247
+
248
+ def _cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
249
+ return (
250
+ a[1] * b[2] - a[2] * b[1],
251
+ a[2] * b[0] - a[0] * b[2],
252
+ a[0] * b[1] - a[1] * b[0],
253
+ )
254
+
255
+
256
+ def _bounds_from_points(points: list[tuple[float, float, float]]) -> dict[str, float] | None:
257
+ if not points:
258
+ return None
259
+ xs = [p[0] for p in points]
260
+ ys = [p[1] for p in points]
261
+ zs = [p[2] for p in points]
262
+ return {
263
+ "x_min": min(xs),
264
+ "x_max": max(xs),
265
+ "y_min": min(ys),
266
+ "y_max": max(ys),
267
+ "z_min": min(zs),
268
+ "z_max": max(zs),
269
+ }
270
+
271
+
272
+ def _extract_volume_bounds_from_rtss(rtss_json: dict[str, Any]) -> tuple[dict[str, float] | None, str]:
273
+ origin_raw = _find_first_keyword_value(rtss_json, "ImagePositionPatient")
274
+ orient_raw = _find_first_keyword_value(rtss_json, "ImageOrientationPatient")
275
+ pixel_spacing_raw = _find_first_keyword_value(rtss_json, "PixelSpacing")
276
+ rows_raw = _find_first_keyword_value(rtss_json, "Rows")
277
+ cols_raw = _find_first_keyword_value(rtss_json, "Columns")
278
+ frames_raw = _find_first_keyword_value(rtss_json, "NumberOfFrames")
279
+ spacing_between_raw = _find_first_keyword_value(rtss_json, "SpacingBetweenSlices")
280
+ slice_thickness_raw = _find_first_keyword_value(rtss_json, "SliceThickness")
281
+
282
+ origin = _as_float_list(origin_raw, expected_len=3)
283
+ orient = _as_float_list(orient_raw, expected_len=6)
284
+ pixel_spacing = _as_float_list(pixel_spacing_raw, expected_len=2)
285
+ rows = _as_int(rows_raw)
286
+ cols = _as_int(cols_raw)
287
+ frames = _as_int(frames_raw)
288
+
289
+ if frames is None:
290
+ frames = 1
291
+
292
+ slice_spacing = None
293
+ if spacing_between_raw is not None:
294
+ try:
295
+ slice_spacing = float(spacing_between_raw)
296
+ except (TypeError, ValueError):
297
+ slice_spacing = None
298
+ if slice_spacing is None and slice_thickness_raw is not None:
299
+ try:
300
+ slice_spacing = float(slice_thickness_raw)
301
+ except (TypeError, ValueError):
302
+ slice_spacing = None
303
+ if slice_spacing is None:
304
+ slice_spacing = 1.0
305
+
306
+ if origin is None or orient is None or pixel_spacing is None or rows is None or cols is None:
307
+ return (
308
+ None,
309
+ "Volume geometry metadata is incomplete in RTSS. Falling back to contour-point bounds.",
310
+ )
311
+
312
+ row_dir = _vunit((orient[0], orient[1], orient[2]))
313
+ col_dir = _vunit((orient[3], orient[4], orient[5]))
314
+ if row_dir is None or col_dir is None:
315
+ return (
316
+ None,
317
+ "Image orientation metadata is invalid. Falling back to contour-point bounds.",
318
+ )
319
+
320
+ normal = _vunit(_cross(row_dir, col_dir))
321
+ if normal is None:
322
+ return (
323
+ None,
324
+ "Unable to derive slice-normal direction from orientation. Falling back to contour-point bounds.",
325
+ )
326
+
327
+ row_spacing, col_spacing = pixel_spacing[0], pixel_spacing[1]
328
+ row_extent = max(rows - 1, 0) * row_spacing
329
+ col_extent = max(cols - 1, 0) * col_spacing
330
+ depth_extent = max(frames - 1, 0) * slice_spacing
331
+ origin_xyz = (origin[0], origin[1], origin[2])
332
+
333
+ corners: list[tuple[float, float, float]] = []
334
+ for r in (0.0, row_extent):
335
+ for c in (0.0, col_extent):
336
+ for d in (0.0, depth_extent):
337
+ corner = origin_xyz
338
+ corner = _vadd(corner, _vscale(row_dir, r))
339
+ corner = _vadd(corner, _vscale(col_dir, c))
340
+ corner = _vadd(corner, _vscale(normal, d))
341
+ corners.append(corner)
342
+
343
+ bounds = _bounds_from_points(corners)
344
+ if bounds is None:
345
+ return (
346
+ None,
347
+ "Unable to derive volume bounds from RTSS metadata. Falling back to contour-point bounds.",
348
+ )
349
+
350
+ return bounds, "Volume extents derived from RTSS geometry metadata."
351
+
352
+
353
+ def _merge_bounds(a: dict[str, float] | None, b: dict[str, float] | None) -> dict[str, float] | None:
354
+ if a is None:
355
+ return b
356
+ if b is None:
357
+ return a
358
+ return {
359
+ "x_min": min(a["x_min"], b["x_min"]),
360
+ "x_max": max(a["x_max"], b["x_max"]),
361
+ "y_min": min(a["y_min"], b["y_min"]),
362
+ "y_max": max(a["y_max"], b["y_max"]),
363
+ "z_min": min(a["z_min"], b["z_min"]),
364
+ "z_max": max(a["z_max"], b["z_max"]),
365
+ }
366
+
367
+
368
+ def _add_bounds_box(fig: go.Figure, bounds: dict[str, float], color: str, name: str) -> None:
369
+ x0, x1 = bounds["x_min"], bounds["x_max"]
370
+ y0, y1 = bounds["y_min"], bounds["y_max"]
371
+ z0, z1 = bounds["z_min"], bounds["z_max"]
372
+
373
+ corners = [
374
+ (x0, y0, z0),
375
+ (x1, y0, z0),
376
+ (x1, y1, z0),
377
+ (x0, y1, z0),
378
+ (x0, y0, z1),
379
+ (x1, y0, z1),
380
+ (x1, y1, z1),
381
+ (x0, y1, z1),
382
+ ]
383
+ edges = [
384
+ (0, 1),
385
+ (1, 2),
386
+ (2, 3),
387
+ (3, 0),
388
+ (4, 5),
389
+ (5, 6),
390
+ (6, 7),
391
+ (7, 4),
392
+ (0, 4),
393
+ (1, 5),
394
+ (2, 6),
395
+ (3, 7),
396
+ ]
397
+
398
+ for idx, (a, b) in enumerate(edges):
399
+ xa, ya, za = corners[a]
400
+ xb, yb, zb = corners[b]
401
+ fig.add_trace(
402
+ go.Scatter3d(
403
+ x=[xa, xb],
404
+ y=[ya, yb],
405
+ z=[za, zb],
406
+ mode="lines",
407
+ line={"width": 2, "color": color},
408
+ name=name if idx == 0 else name,
409
+ legendgroup=name,
410
+ showlegend=(idx == 0),
411
+ opacity=0.35,
412
+ )
413
+ )
414
+
415
+
416
+ def _add_axes_markers(fig: go.Figure, bounds: dict[str, float]) -> None:
417
+ origin = (bounds["x_min"], bounds["y_min"], bounds["z_min"])
418
+ x_range = max(bounds["x_max"] - bounds["x_min"], 1.0)
419
+ y_range = max(bounds["y_max"] - bounds["y_min"], 1.0)
420
+ z_range = max(bounds["z_max"] - bounds["z_min"], 1.0)
421
+ axis_len = max(x_range, y_range, z_range) * 0.15
422
+
423
+ x_end = (origin[0] + axis_len, origin[1], origin[2])
424
+ y_end = (origin[0], origin[1] + axis_len, origin[2])
425
+ z_end = (origin[0], origin[1], origin[2] + axis_len)
426
+
427
+ fig.add_trace(
428
+ go.Scatter3d(
429
+ x=[origin[0], x_end[0]],
430
+ y=[origin[1], x_end[1]],
431
+ z=[origin[2], x_end[2]],
432
+ mode="lines+markers+text",
433
+ line={"width": 5, "color": "#1f77b4"},
434
+ marker={"size": [3, 5], "color": "#1f77b4"},
435
+ text=["", "X+"],
436
+ textposition="top center",
437
+ name="X axis",
438
+ )
439
+ )
440
+ fig.add_trace(
441
+ go.Scatter3d(
442
+ x=[origin[0], y_end[0]],
443
+ y=[origin[1], y_end[1]],
444
+ z=[origin[2], y_end[2]],
445
+ mode="lines+markers+text",
446
+ line={"width": 5, "color": "#ff7f0e"},
447
+ marker={"size": [3, 5], "color": "#ff7f0e"},
448
+ text=["", "Y+"],
449
+ textposition="top center",
450
+ name="Y axis",
451
+ )
452
+ )
453
+ fig.add_trace(
454
+ go.Scatter3d(
455
+ x=[origin[0], z_end[0]],
456
+ y=[origin[1], z_end[1]],
457
+ z=[origin[2], z_end[2]],
458
+ mode="lines+markers+text",
459
+ line={"width": 5, "color": "#2ca02c"},
460
+ marker={"size": [3, 5], "color": "#2ca02c"},
461
+ text=["", "Z+"],
462
+ textposition="top center",
463
+ name="Z axis",
464
+ )
465
+ )
466
+
467
+
468
+ def render_contour_point_cloud(
469
+ *,
470
+ left_name: str,
471
+ right_name: str,
472
+ left_raw: dict[str, Any],
473
+ right_raw: dict[str, Any],
474
+ ) -> None:
475
+ left_points = extract_contour_points(left_raw)
476
+ right_points = extract_contour_points(right_raw)
477
+ left_volume_bounds, left_volume_msg = _extract_volume_bounds_from_rtss(left_raw)
478
+ right_volume_bounds, right_volume_msg = _extract_volume_bounds_from_rtss(right_raw)
479
+
480
+ c1, c2 = st.columns(2)
481
+ with c1:
482
+ st.metric(f"{left_name} points", f"{len(left_points):,}")
483
+ with c2:
484
+ st.metric(f"{right_name} points", f"{len(right_points):,}")
485
+
486
+ if not left_points and not right_points:
487
+ st.warning("No contour points found in the selected RTSS files.")
488
+ return
489
+
490
+ contour_bounds = _bounds_from_points([*left_points, *right_points])
491
+ volume_bounds = _merge_bounds(left_volume_bounds, right_volume_bounds)
492
+ display_bounds = volume_bounds or contour_bounds
493
+ if display_bounds is None:
494
+ st.warning("Unable to determine display bounds for the contour plot.")
495
+ return
496
+
497
+ if volume_bounds is not None:
498
+ st.caption(
499
+ "Plot extents are aligned to RTSS-derived imaging volume bounds. "
500
+ f"Left: {left_volume_msg} Right: {right_volume_msg}"
501
+ )
502
+ else:
503
+ st.caption(
504
+ "Imaging volume bounds were not available in RTSS metadata. "
505
+ "Using contour-point bounds instead."
506
+ )
507
+
508
+ control_1, control_2, control_3 = st.columns(3)
509
+ with control_1:
510
+ show_left = st.checkbox(f"Show {left_name}", value=True, key="pc_show_left")
511
+ with control_2:
512
+ show_right = st.checkbox(f"Show {right_name}", value=True, key="pc_show_right")
513
+ with control_3:
514
+ point_size = st.slider("Point size", min_value=1, max_value=8, value=3, key="pc_point_size")
515
+
516
+ fig = go.Figure()
517
+
518
+ if show_left and left_points:
519
+ lx, ly, lz = zip(*left_points)
520
+ fig.add_trace(
521
+ go.Scatter3d(
522
+ x=lx,
523
+ y=ly,
524
+ z=lz,
525
+ mode="markers",
526
+ marker={"size": point_size, "color": "red", "opacity": 0.7},
527
+ name=f"First RTSS: {left_name}",
528
+ )
529
+ )
530
+
531
+ if show_right and right_points:
532
+ rx, ry, rz = zip(*right_points)
533
+ fig.add_trace(
534
+ go.Scatter3d(
535
+ x=rx,
536
+ y=ry,
537
+ z=rz,
538
+ mode="markers",
539
+ marker={"size": point_size, "color": "green", "opacity": 0.7},
540
+ name=f"Second RTSS: {right_name}",
541
+ )
542
+ )
543
+
544
+ if not fig.data:
545
+ st.info("Both point groups are hidden. Turn at least one group back on.")
546
+ return
547
+
548
+ _add_bounds_box(fig, display_bounds, color="#555555", name="Display bounds")
549
+ _add_axes_markers(fig, display_bounds)
550
+
551
+ x_span = max(display_bounds["x_max"] - display_bounds["x_min"], 1.0)
552
+ y_span = max(display_bounds["y_max"] - display_bounds["y_min"], 1.0)
553
+ z_span = max(display_bounds["z_max"] - display_bounds["z_min"], 1.0)
554
+ pad_ratio = 0.03
555
+ x_pad = x_span * pad_ratio
556
+ y_pad = y_span * pad_ratio
557
+ z_pad = z_span * pad_ratio
558
+
559
+ fig.update_layout(
560
+ title="RTSS Contour Point Cloud Diff",
561
+ scene={
562
+ "xaxis_title": "X (mm, patient Left +)",
563
+ "yaxis_title": "Y (mm, patient Posterior +)",
564
+ "zaxis_title": "Z (mm, patient Superior +)",
565
+ "xaxis": {"range": [display_bounds["x_min"] - x_pad, display_bounds["x_max"] + x_pad]},
566
+ "yaxis": {"range": [display_bounds["y_min"] - y_pad, display_bounds["y_max"] + y_pad]},
567
+ "zaxis": {"range": [display_bounds["z_min"] - z_pad, display_bounds["z_max"] + z_pad]},
568
+ },
569
+ margin={"l": 0, "r": 0, "t": 48, "b": 0},
570
+ legend={"orientation": "h", "y": 1.02, "x": 0},
571
+ )
572
+ st.plotly_chart(fig, use_container_width=True)
573
+ st.caption(
574
+ "Coordinate system uses DICOM patient coordinates (LPS): X increases toward patient Left, "
575
+ "Y increases toward Posterior, and Z increases toward Superior. "
576
+ "Use drag to rotate, scroll to zoom, and legend clicks to show or hide each group."
577
+ )
578
+
579
+
580
  def main() -> None:
581
  st.set_page_config(page_title="RTSS Diff Viewer", layout="wide")
582
  ensure_state()
583
 
584
  st.title("RTSS Diff Viewer")
585
+ st.caption("Compare RTSS DICOM files using textual diffs and 3D contour overlays.")
 
 
 
 
 
 
 
586
 
587
+ app_mode = st.radio(
588
+ "Mode",
589
+ options=["Instructions", "Pair Mode", "Batch Compare"],
590
+ index=0,
591
+ horizontal=True,
592
  )
593
 
594
+ if app_mode == "Instructions":
595
+ st.markdown("### What This App Does")
596
+ st.write("This app compares RTSS DICOM files using text-based and visual workflows.")
597
+
598
+ st.markdown("### Pair Mode")
599
+ st.write("Use Pair Mode to compare exactly two RTSS files in detail.")
600
+ st.write("1. Open Pair Mode.")
601
+ st.write("2. In Upload Pair, upload the first and second RTSS .dcm files.")
602
+ st.write("3. Click Convert pair.")
603
+ st.write("4. Open Pair Diff to review JSON differences by component.")
604
+ st.write("5. Open 3D Contour View to compare contour points visually.")
605
+ st.write("6. In 3D view, red points are from the first RTSS and green points are from the second RTSS.")
606
+
607
+ st.markdown("### Batch Compare")
608
+ st.write("Use Batch Compare to compare any two files from a larger uploaded set.")
609
+ st.write("1. Open Batch Compare.")
610
+ st.write("2. Upload multiple RTSS .dcm files.")
611
+ st.write("3. Click Convert uploaded set.")
612
+ st.write("4. Pick any two variants from the dropdowns.")
613
+ st.write("5. Review the unified text diff and download outputs if needed.")
614
+
615
+ st.markdown("### Tips")
616
+ st.write("- Large comparisons automatically switch to unified text diff for faster loading.")
617
+ st.write("- In 3D view, drag to rotate and scroll to zoom.")
618
+
619
+ elif app_mode == "Pair Mode":
620
+ tab_upload, tab_diff, tab_points_3d = st.tabs(
621
+ ["1) Upload Pair", "2) Pair Diff", "3) 3D Contour View"]
622
+ )
623
 
624
+ with tab_upload:
625
+ st.info("Step 1 of 3: Upload two RTSS files and click Convert pair.")
626
+ left_col, right_col = st.columns(2)
627
+ with left_col:
628
+ left_file = st.file_uploader("Left RTSS (.dcm)", type=["dcm"], key="left_pair")
629
+ with right_col:
630
+ right_file = st.file_uploader("Right RTSS (.dcm)", type=["dcm"], key="right_pair")
631
+
632
+ if st.button("Convert pair", type="primary"):
633
+ if left_file is None or right_file is None:
634
+ st.warning("Upload both files first.")
635
+ else:
636
+ with st.spinner("Converting files..."):
637
+ st.session_state.left_json_raw = dcm_bytes_to_json_dict(left_file.getvalue())
638
+ st.session_state.right_json_raw = dcm_bytes_to_json_dict(right_file.getvalue())
639
+ st.session_state.left_name = left_file.name
640
+ st.session_state.right_name = right_file.name
641
+ st.success("Pair conversion complete.")
642
+ st.info("Next step: open tab 2) Pair Diff to review differences.")
643
+
644
+ with tab_diff:
645
+ st.info("Step 2 of 3: Review Pair Diff, then move to tab 3) 3D Contour View.")
646
+ component_options = ["all", "metadata", *COMPONENT_KEYS.keys()]
647
+ control_col_1, control_col_2, control_col_3 = st.columns(3)
648
+ with control_col_1:
649
+ component = st.selectbox("Component", options=component_options, index=0, key="pair_component")
650
+ with control_col_2:
651
+ precision = st.slider("Float precision", min_value=2, max_value=10, value=6, key="pair_precision")
652
+ with control_col_3:
653
+ keep_volatile = st.checkbox("Keep volatile UID/time tags", value=False, key="pair_keep_volatile")
654
+
655
+ left_raw = st.session_state.left_json_raw
656
+ right_raw = st.session_state.right_json_raw
657
+ if left_raw is None or right_raw is None:
658
+ st.info("Convert a pair in tab 1 first.")
659
  else:
660
+ st.caption("If files are large, diff generation may take some time while JSON is normalized and compared.")
661
+ with st.spinner("Preparing normalized JSON and building diff view. This may take time for large files..."):
662
+ render_diff_panel(
663
+ left_name=st.session_state.left_name,
664
+ right_name=st.session_state.right_name,
665
+ left_raw=left_raw,
666
+ right_raw=right_raw,
667
+ component=component,
668
+ precision=precision,
669
+ keep_volatile=keep_volatile,
670
+ allow_rich_view=True,
671
+ max_rich_chars=400_000,
672
+ max_rich_lines=5_000,
673
+ key_prefix="pair",
674
+ )
675
+ st.info("Next step: open tab 3) 3D Contour View for point cloud comparison.")
676
+
677
+ with tab_points_3d:
678
+ st.markdown("### 3D Contour Point Cloud")
679
+ st.info("Step 3 of 3: Inspect contour points in 3D. Red is first RTSS, green is second RTSS.")
680
+ left_raw = st.session_state.left_json_raw
681
+ right_raw = st.session_state.right_json_raw
682
+ if left_raw is None or right_raw is None:
683
+ st.info("Convert a pair in tab 1 first.")
684
+ else:
685
+ st.caption("Contour extraction and 3D plot generation can take time for large RTSS files.")
686
+ with st.spinner("Extracting contour points and rendering interactive 3D view. Please wait..."):
687
+ render_contour_point_cloud(
688
+ left_name=st.session_state.left_name,
689
+ right_name=st.session_state.right_name,
690
+ left_raw=left_raw,
691
+ right_raw=right_raw,
692
+ )
693
 
694
+ else:
695
+ st.markdown("### Batch Compare")
696
  files = st.file_uploader(
697
  "Upload RTSS variant set (.dcm)",
698
  type=["dcm"],
 
717
  variants = st.session_state.batch_variants
718
  if not variants:
719
  st.info("No batch set loaded yet.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
720
  else:
721
+ names = sorted(variants.keys())
722
+ if len(names) < 2:
723
+ st.warning("Need at least two variants.")
724
+ else:
725
+ component_options = ["all", "metadata", *COMPONENT_KEYS.keys()]
726
+ control_col_1, control_col_2, control_col_3 = st.columns(3)
727
+ with control_col_1:
728
+ component = st.selectbox("Component", options=component_options, index=0, key="batch_component")
729
+ with control_col_2:
730
+ precision = st.slider("Float precision", min_value=2, max_value=10, value=6, key="batch_precision")
731
+ with control_col_3:
732
+ keep_volatile = st.checkbox("Keep volatile UID/time tags", value=False, key="batch_keep_volatile")
733
+
734
+ left_col, right_col = st.columns(2)
735
+ with left_col:
736
+ left_name = st.selectbox("Left variant", names, index=0, key="batch_left")
737
+ with right_col:
738
+ right_name = st.selectbox("Right variant", names, index=1, key="batch_right")
739
+
740
+ if left_name == right_name:
741
+ st.warning("Select two different variants.")
742
+ else:
743
+ render_diff_panel(
744
+ left_name=left_name,
745
+ right_name=right_name,
746
+ left_raw=variants[left_name],
747
+ right_raw=variants[right_name],
748
+ component=component,
749
+ precision=precision,
750
+ keep_volatile=keep_volatile,
751
+ allow_rich_view=False,
752
+ max_rich_chars=0,
753
+ max_rich_lines=0,
754
+ key_prefix="batch",
755
+ )
756
 
757
 
758
  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
tests/test_app_features.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ _extract_volume_bounds_from_rtss,
12
+ _merge_bounds,
13
+ extract_contour_points,
14
+ should_use_unified_only,
15
+ )
16
+
17
+
18
+ def test_extract_contour_points_collects_all_triplets() -> None:
19
+ sample = {
20
+ "(3006,0039) ROIContourSequence": [
21
+ {
22
+ "(3006,0040) ContourSequence": [
23
+ {
24
+ "(3006,0050) ContourData": [
25
+ [1.0, 2.0, 3.0],
26
+ [4, 5, 6],
27
+ ]
28
+ },
29
+ {
30
+ "(3006,0050) ContourData": [
31
+ [7.5, 8.5, 9.5],
32
+ ]
33
+ },
34
+ ]
35
+ }
36
+ ]
37
+ }
38
+
39
+ points = extract_contour_points(sample)
40
+
41
+ assert points == [
42
+ (1.0, 2.0, 3.0),
43
+ (4.0, 5.0, 6.0),
44
+ (7.5, 8.5, 9.5),
45
+ ]
46
+
47
+
48
+ def test_extract_contour_points_ignores_non_triplet_data() -> None:
49
+ sample = {
50
+ "(3006,0050) ContourData": [
51
+ [1.0, 2.0],
52
+ [3.0, 4.0, 5.0, 6.0],
53
+ "not-a-point",
54
+ {"unexpected": "shape"},
55
+ ]
56
+ }
57
+
58
+ points = extract_contour_points(sample)
59
+
60
+ assert points == []
61
+
62
+
63
+ def test_should_use_unified_only_for_batch_text_mode() -> None:
64
+ use_unified_only, reason = should_use_unified_only(
65
+ "left",
66
+ "right",
67
+ allow_rich_view=False,
68
+ max_rich_chars=400_000,
69
+ max_rich_lines=5_000,
70
+ )
71
+
72
+ assert use_unified_only is True
73
+ assert "unified diff text only" in reason
74
+
75
+
76
+ def test_should_use_unified_only_for_large_input() -> None:
77
+ left = "a" * 250_000
78
+ right = "b" * 250_000
79
+
80
+ use_unified_only, reason = should_use_unified_only(
81
+ left,
82
+ right,
83
+ allow_rich_view=True,
84
+ max_rich_chars=400_000,
85
+ max_rich_lines=5_000,
86
+ )
87
+
88
+ assert use_unified_only is True
89
+ assert "Large comparison detected" in reason
90
+
91
+
92
+ def test_should_use_rich_view_for_small_input() -> None:
93
+ use_unified_only, reason = should_use_unified_only(
94
+ "small-left\n",
95
+ "small-right\n",
96
+ allow_rich_view=True,
97
+ max_rich_chars=400_000,
98
+ max_rich_lines=5_000,
99
+ )
100
+
101
+ assert use_unified_only is False
102
+ assert reason == ""
103
+
104
+
105
+ def test_extract_volume_bounds_from_rtss_metadata() -> None:
106
+ sample = {
107
+ "(3006,0039) ROIContourSequence": [
108
+ {
109
+ "(3006,004A) SourcePixelPlanesCharacteristicsSequence": [
110
+ {
111
+ "(0020,0032) ImagePositionPatient": [10.0, 20.0, 30.0],
112
+ "(0020,0037) ImageOrientationPatient": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
113
+ "(0028,0030) PixelSpacing": [2.0, 1.0],
114
+ "(0028,0010) Rows": 2,
115
+ "(0028,0011) Columns": 3,
116
+ "(0028,0008) NumberOfFrames": 4,
117
+ "(0018,0088) SpacingBetweenSlices": 2.0,
118
+ }
119
+ ]
120
+ }
121
+ ]
122
+ }
123
+
124
+ bounds, msg = _extract_volume_bounds_from_rtss(sample)
125
+
126
+ assert bounds is not None
127
+ assert bounds["x_min"] == 10.0
128
+ assert bounds["x_max"] == 12.0
129
+ assert bounds["y_min"] == 20.0
130
+ assert bounds["y_max"] == 22.0
131
+ assert bounds["z_min"] == 30.0
132
+ assert bounds["z_max"] == 36.0
133
+ assert "derived" in msg
134
+
135
+
136
+ def test_extract_volume_bounds_from_rtss_missing_metadata() -> None:
137
+ sample = {
138
+ "(3006,0039) ROIContourSequence": [
139
+ {"(3006,0040) ContourSequence": [{"(3006,0050) ContourData": [[1.0, 2.0, 3.0]]}]}
140
+ ]
141
+ }
142
+
143
+ bounds, msg = _extract_volume_bounds_from_rtss(sample)
144
+
145
+ assert bounds is None
146
+ assert "incomplete" in msg
147
+
148
+
149
+ def test_merge_bounds() -> None:
150
+ 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}
151
+ 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}
152
+
153
+ merged = _merge_bounds(a, b)
154
+
155
+ assert merged == {
156
+ "x_min": -1.0,
157
+ "x_max": 2.0,
158
+ "y_min": 1.5,
159
+ "y_max": 3.5,
160
+ "z_min": 3.0,
161
+ "z_max": 6.0,
162
+ }