Spaces:
Runtime error
Runtime error
updated app view
Browse files- README.md +31 -3
- app.py +934 -42
- src/rtssdiffviewer/diff_core.py +175 -0
- 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
|
| 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
|
| 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
|
| 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
|
| 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("
|
| 605 |
-
st.write("
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
|
| 615 |
st.markdown("### Tips")
|
| 616 |
-
st.write("-
|
| 617 |
-
st.write("-
|
|
|
|
|
|
|
| 618 |
|
| 619 |
elif app_mode == "Pair Mode":
|
| 620 |
-
|
| 621 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 622 |
)
|
|
|
|
| 623 |
|
| 624 |
-
|
| 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.
|
| 642 |
-
st.
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 659 |
else:
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 676 |
|
| 677 |
-
|
| 678 |
-
st.markdown("###
|
| 679 |
-
st.info(
|
|
|
|
|
|
|
|
|
|
| 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
|
| 684 |
else:
|
| 685 |
-
st.
|
| 686 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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)"
|