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