amithjkamath commited on
Commit
faad21c
·
unverified ·
1 Parent(s): 8c2b348

updated app view

Browse files
Files changed (4) hide show
  1. README.md +31 -3
  2. app.py +934 -42
  3. src/rtssdiffviewer/diff_core.py +175 -0
  4. tests/test_app_features.py +136 -0
README.md CHANGED
@@ -15,14 +15,42 @@ 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
  ## 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
 
 
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
 
app.py CHANGED
@@ -21,6 +21,8 @@ from rtssdiffviewer.dcm_to_json import dcm_to_json # noqa: E402
21
  from rtssdiffviewer.diff_core import ( # noqa: E402
22
  COMPONENT_KEYS,
23
  DEFAULT_VOLATILE_TAG_PREFIXES,
 
 
24
  normalize_value,
25
  pretty_json_text,
26
  select_component,
@@ -49,6 +51,539 @@ def ensure_state() -> None:
49
  st.session_state.setdefault("left_json_raw", None)
50
  st.session_state.setdefault("right_json_raw", None)
51
  st.session_state.setdefault("batch_variants", {})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
 
54
  def render_diff_panel(
@@ -69,9 +604,306 @@ def render_diff_panel(
69
  if not keep_volatile:
70
  ignore_prefixes.update(DEFAULT_VOLATILE_TAG_PREFIXES)
71
 
 
72
  left_selected = select_component(left_raw, component)
73
  right_selected = select_component(right_raw, component)
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  left_norm = normalize_value(left_selected, precision, ignore_prefixes)
76
  right_norm = normalize_value(right_selected, precision, ignore_prefixes)
77
 
@@ -582,7 +1414,7 @@ def main() -> None:
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",
@@ -593,16 +1425,20 @@ def main() -> None:
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.")
@@ -610,18 +1446,35 @@ def main() -> None:
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:
@@ -638,12 +1491,16 @@ def main() -> None:
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")
@@ -655,40 +1512,73 @@ def main() -> None:
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:
@@ -722,7 +1612,9 @@ def main() -> None:
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")
 
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(
 
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
 
 
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",
 
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.")
 
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:
 
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")
 
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:
 
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")
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 CHANGED
@@ -8,8 +8,15 @@ 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
  )
@@ -160,3 +167,132 @@ def test_merge_bounds() -> None:
160
  "z_min": 3.0,
161
  "z_max": 6.0,
162
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  )
 
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)"