Spaces:
Runtime error
Runtime error
Commit ·
a98fbc5
0
Parent(s):
Initial standalone RTSS diff viewer scaffold
Browse files- .gitignore +6 -0
- Makefile +47 -0
- README.md +37 -0
- app.py +221 -0
- deploy.sh +42 -0
- pyproject.toml +20 -0
- requirements.txt +3 -0
- src/rtssdiffviewer/__init__.py +5 -0
- src/rtssdiffviewer/dcm_to_json.py +117 -0
- src/rtssdiffviewer/diff_core.py +83 -0
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.venv/
|
| 4 |
+
.env
|
| 5 |
+
.DS_Store
|
| 6 |
+
.streamlit/
|
Makefile
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
PYTHON ?= python3
|
| 2 |
+
VENV ?= .venv
|
| 3 |
+
PIP := $(VENV)/bin/pip
|
| 4 |
+
STREAMLIT := $(VENV)/bin/streamlit
|
| 5 |
+
|
| 6 |
+
SPACE ?= amithjkamath/rtssdiffviewer
|
| 7 |
+
HF_REMOTE ?= hf
|
| 8 |
+
DEPLOY_BRANCH ?= deploy
|
| 9 |
+
|
| 10 |
+
.PHONY: install run fmt lint clean deploy-init deploy status
|
| 11 |
+
|
| 12 |
+
install:
|
| 13 |
+
$(PYTHON) -m venv $(VENV)
|
| 14 |
+
$(PIP) install --upgrade pip
|
| 15 |
+
$(PIP) install -r requirements.txt
|
| 16 |
+
|
| 17 |
+
run:
|
| 18 |
+
$(STREAMLIT) run app.py
|
| 19 |
+
|
| 20 |
+
fmt:
|
| 21 |
+
@echo "No formatter configured."
|
| 22 |
+
|
| 23 |
+
lint:
|
| 24 |
+
$(VENV)/bin/python -m py_compile app.py src/rtssdiffviewer/*.py
|
| 25 |
+
|
| 26 |
+
clean:
|
| 27 |
+
rm -rf $(VENV) __pycache__ src/rtssdiffviewer/__pycache__
|
| 28 |
+
|
| 29 |
+
deploy-init:
|
| 30 |
+
@if git remote | grep -q '^$(HF_REMOTE)$$'; then \
|
| 31 |
+
git remote set-url $(HF_REMOTE) https://huggingface.co/spaces/$(SPACE); \
|
| 32 |
+
else \
|
| 33 |
+
git remote add $(HF_REMOTE) https://huggingface.co/spaces/$(SPACE); \
|
| 34 |
+
fi
|
| 35 |
+
@git fetch $(HF_REMOTE) || true
|
| 36 |
+
@if git show-ref --verify --quiet refs/heads/$(DEPLOY_BRANCH); then \
|
| 37 |
+
echo "Deploy branch $(DEPLOY_BRANCH) already exists"; \
|
| 38 |
+
else \
|
| 39 |
+
git checkout -b $(DEPLOY_BRANCH); \
|
| 40 |
+
git checkout -; \
|
| 41 |
+
fi
|
| 42 |
+
|
| 43 |
+
deploy:
|
| 44 |
+
bash deploy.sh $(SPACE)
|
| 45 |
+
|
| 46 |
+
status:
|
| 47 |
+
@echo "Space URL: https://huggingface.co/spaces/$(SPACE)"
|
README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: RTSS Diff Viewer
|
| 3 |
+
emoji: CT
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: cyan
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
sdk_version: 1.55.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# RTSS Diff Viewer
|
| 13 |
+
|
| 14 |
+
A standalone Streamlit app for:
|
| 15 |
+
|
| 16 |
+
- converting RTSS `.dcm` files to JSON
|
| 17 |
+
- downloading normalized JSON outputs
|
| 18 |
+
- visualizing git-like diffs between two RTSS versions
|
| 19 |
+
- batch-uploading multiple variants and switching any two versions for comparison
|
| 20 |
+
|
| 21 |
+
## Local Setup
|
| 22 |
+
|
| 23 |
+
```bash
|
| 24 |
+
make install
|
| 25 |
+
make run
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
## Hugging Face Spaces Deployment
|
| 29 |
+
|
| 30 |
+
Use the deploy workflow in this repository:
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
make deploy-init SPACE=amithjkamath/rtssdiffviewer
|
| 34 |
+
make deploy
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
This pushes local `deploy` branch to Space `main`.
|
app.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Streamlit RTSS diff viewer app."""
|
| 3 |
+
|
| 4 |
+
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
|
| 14 |
+
SRC_DIR = ROOT / "src"
|
| 15 |
+
if str(SRC_DIR) not in sys.path:
|
| 16 |
+
sys.path.insert(0, str(SRC_DIR))
|
| 17 |
+
|
| 18 |
+
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,
|
| 25 |
+
unified_diff_text,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
from st_diff_viewer import diff_viewer
|
| 30 |
+
except Exception:
|
| 31 |
+
diff_viewer = None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def dcm_bytes_to_json_dict(payload: bytes) -> dict[str, Any]:
|
| 35 |
+
with tempfile.NamedTemporaryFile(suffix=".dcm", delete=False) as tmp:
|
| 36 |
+
tmp.write(payload)
|
| 37 |
+
tmp_path = Path(tmp.name)
|
| 38 |
+
try:
|
| 39 |
+
return dcm_to_json(tmp_path)
|
| 40 |
+
finally:
|
| 41 |
+
tmp_path.unlink(missing_ok=True)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def ensure_state() -> None:
|
| 45 |
+
st.session_state.setdefault("left_name", None)
|
| 46 |
+
st.session_state.setdefault("right_name", 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(
|
| 53 |
+
*,
|
| 54 |
+
left_name: str,
|
| 55 |
+
right_name: str,
|
| 56 |
+
left_raw: dict[str, Any],
|
| 57 |
+
right_raw: dict[str, Any],
|
| 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 |
+
|
| 73 |
+
left_text = pretty_json_text(left_norm)
|
| 74 |
+
right_text = pretty_json_text(right_norm)
|
| 75 |
+
|
| 76 |
+
col1, col2, col3 = st.columns(3)
|
| 77 |
+
with col1:
|
| 78 |
+
st.download_button(
|
| 79 |
+
"Download left JSON",
|
| 80 |
+
data=left_text,
|
| 81 |
+
file_name=f"{Path(left_name).stem}.{component}.json",
|
| 82 |
+
mime="application/json",
|
| 83 |
+
key=f"{key_prefix}_dl_left",
|
| 84 |
+
)
|
| 85 |
+
with col2:
|
| 86 |
+
st.download_button(
|
| 87 |
+
"Download right JSON",
|
| 88 |
+
data=right_text,
|
| 89 |
+
file_name=f"{Path(right_name).stem}.{component}.json",
|
| 90 |
+
mime="application/json",
|
| 91 |
+
key=f"{key_prefix}_dl_right",
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
diff_text = unified_diff_text(left_text, right_text, left_name, right_name)
|
| 95 |
+
with col3:
|
| 96 |
+
st.download_button(
|
| 97 |
+
"Download unified diff",
|
| 98 |
+
data=diff_text,
|
| 99 |
+
file_name=f"{Path(left_name).stem}__{Path(right_name).stem}.{component}.diff",
|
| 100 |
+
mime="text/plain",
|
| 101 |
+
key=f"{key_prefix}_dl_diff",
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
st.markdown("### Diff View")
|
| 105 |
+
if diff_viewer is not None:
|
| 106 |
+
diff_viewer(left_text, right_text, split_view=True)
|
| 107 |
+
else:
|
| 108 |
+
if diff_text.strip():
|
| 109 |
+
st.code(diff_text, language="diff")
|
| 110 |
+
else:
|
| 111 |
+
st.success("No differences after normalization and filtering.")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def main() -> None:
|
| 115 |
+
st.set_page_config(page_title="RTSS Diff Viewer", layout="wide")
|
| 116 |
+
ensure_state()
|
| 117 |
+
|
| 118 |
+
st.title("RTSS Diff Viewer")
|
| 119 |
+
st.caption("Upload RTSS .dcm files, convert to JSON, and compare with git-like diffs.")
|
| 120 |
+
|
| 121 |
+
with st.sidebar:
|
| 122 |
+
st.header("Diff Controls")
|
| 123 |
+
component_options = ["all", "metadata", *COMPONENT_KEYS.keys()]
|
| 124 |
+
component = st.selectbox("Component", options=component_options, index=0)
|
| 125 |
+
precision = st.slider("Float precision", min_value=2, max_value=10, value=6)
|
| 126 |
+
keep_volatile = st.checkbox("Keep volatile UID/time tags", value=False)
|
| 127 |
+
|
| 128 |
+
tab_upload, tab_diff, tab_batch = st.tabs(
|
| 129 |
+
["1) Upload Pair", "2) Pair Diff", "3) Batch Compare"]
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
with tab_upload:
|
| 133 |
+
left_col, right_col = st.columns(2)
|
| 134 |
+
with left_col:
|
| 135 |
+
left_file = st.file_uploader("Left RTSS (.dcm)", type=["dcm"], key="left_pair")
|
| 136 |
+
with right_col:
|
| 137 |
+
right_file = st.file_uploader("Right RTSS (.dcm)", type=["dcm"], key="right_pair")
|
| 138 |
+
|
| 139 |
+
if st.button("Convert pair", type="primary"):
|
| 140 |
+
if left_file is None or right_file is None:
|
| 141 |
+
st.warning("Upload both files first.")
|
| 142 |
+
else:
|
| 143 |
+
with st.spinner("Converting files..."):
|
| 144 |
+
st.session_state.left_json_raw = dcm_bytes_to_json_dict(left_file.getvalue())
|
| 145 |
+
st.session_state.right_json_raw = dcm_bytes_to_json_dict(right_file.getvalue())
|
| 146 |
+
st.session_state.left_name = left_file.name
|
| 147 |
+
st.session_state.right_name = right_file.name
|
| 148 |
+
st.success("Pair conversion complete.")
|
| 149 |
+
|
| 150 |
+
with tab_diff:
|
| 151 |
+
left_raw = st.session_state.left_json_raw
|
| 152 |
+
right_raw = st.session_state.right_json_raw
|
| 153 |
+
if left_raw is None or right_raw is None:
|
| 154 |
+
st.info("Convert a pair in tab 1 first.")
|
| 155 |
+
else:
|
| 156 |
+
render_diff_panel(
|
| 157 |
+
left_name=st.session_state.left_name,
|
| 158 |
+
right_name=st.session_state.right_name,
|
| 159 |
+
left_raw=left_raw,
|
| 160 |
+
right_raw=right_raw,
|
| 161 |
+
component=component,
|
| 162 |
+
precision=precision,
|
| 163 |
+
keep_volatile=keep_volatile,
|
| 164 |
+
key_prefix="pair",
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
with tab_batch:
|
| 168 |
+
files = st.file_uploader(
|
| 169 |
+
"Upload RTSS variant set (.dcm)",
|
| 170 |
+
type=["dcm"],
|
| 171 |
+
accept_multiple_files=True,
|
| 172 |
+
key="batch_upload",
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
if st.button("Convert uploaded set", type="primary", key="batch_convert"):
|
| 176 |
+
if not files:
|
| 177 |
+
st.warning("Upload at least two RTSS files.")
|
| 178 |
+
else:
|
| 179 |
+
converted: dict[str, dict[str, Any]] = {}
|
| 180 |
+
with st.spinner("Converting variant set..."):
|
| 181 |
+
for idx, file in enumerate(files, start=1):
|
| 182 |
+
name = file.name
|
| 183 |
+
if name in converted:
|
| 184 |
+
name = f"{idx:02d}_{name}"
|
| 185 |
+
converted[name] = dcm_bytes_to_json_dict(file.getvalue())
|
| 186 |
+
st.session_state.batch_variants = converted
|
| 187 |
+
st.success(f"Converted {len(converted)} file(s).")
|
| 188 |
+
|
| 189 |
+
variants = st.session_state.batch_variants
|
| 190 |
+
if not variants:
|
| 191 |
+
st.info("No batch set loaded yet.")
|
| 192 |
+
return
|
| 193 |
+
|
| 194 |
+
names = sorted(variants.keys())
|
| 195 |
+
if len(names) < 2:
|
| 196 |
+
st.warning("Need at least two variants.")
|
| 197 |
+
return
|
| 198 |
+
|
| 199 |
+
left_col, right_col = st.columns(2)
|
| 200 |
+
with left_col:
|
| 201 |
+
left_name = st.selectbox("Left variant", names, index=0, key="batch_left")
|
| 202 |
+
with right_col:
|
| 203 |
+
right_name = st.selectbox("Right variant", names, index=1, key="batch_right")
|
| 204 |
+
|
| 205 |
+
if left_name == right_name:
|
| 206 |
+
st.warning("Select two different variants.")
|
| 207 |
+
else:
|
| 208 |
+
render_diff_panel(
|
| 209 |
+
left_name=left_name,
|
| 210 |
+
right_name=right_name,
|
| 211 |
+
left_raw=variants[left_name],
|
| 212 |
+
right_raw=variants[right_name],
|
| 213 |
+
component=component,
|
| 214 |
+
precision=precision,
|
| 215 |
+
keep_volatile=keep_volatile,
|
| 216 |
+
key_prefix="batch",
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
if __name__ == "__main__":
|
| 221 |
+
main()
|
deploy.sh
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
SPACE_NAME="${1:-}"
|
| 5 |
+
if [[ -z "$SPACE_NAME" ]]; then
|
| 6 |
+
echo "Usage: bash deploy.sh <username/space-name>"
|
| 7 |
+
exit 1
|
| 8 |
+
fi
|
| 9 |
+
|
| 10 |
+
if [[ ! -d .git ]]; then
|
| 11 |
+
echo "Error: run from a git repository"
|
| 12 |
+
exit 1
|
| 13 |
+
fi
|
| 14 |
+
|
| 15 |
+
HF_REMOTE="hf"
|
| 16 |
+
DEPLOY_BRANCH="deploy"
|
| 17 |
+
SPACE_URL="https://huggingface.co/spaces/${SPACE_NAME}"
|
| 18 |
+
|
| 19 |
+
if git remote | grep -q "^${HF_REMOTE}$"; then
|
| 20 |
+
git remote set-url "${HF_REMOTE}" "${SPACE_URL}"
|
| 21 |
+
else
|
| 22 |
+
git remote add "${HF_REMOTE}" "${SPACE_URL}"
|
| 23 |
+
fi
|
| 24 |
+
|
| 25 |
+
if ! git show-ref --verify --quiet "refs/heads/${DEPLOY_BRANCH}"; then
|
| 26 |
+
git checkout -b "${DEPLOY_BRANCH}"
|
| 27 |
+
fi
|
| 28 |
+
|
| 29 |
+
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
| 30 |
+
|
| 31 |
+
if [[ "$CURRENT_BRANCH" != "$DEPLOY_BRANCH" ]]; then
|
| 32 |
+
git checkout "$DEPLOY_BRANCH"
|
| 33 |
+
fi
|
| 34 |
+
|
| 35 |
+
git add .
|
| 36 |
+
if ! git diff --staged --quiet; then
|
| 37 |
+
git commit -m "Deploy RTSS diff viewer"
|
| 38 |
+
fi
|
| 39 |
+
|
| 40 |
+
git push "${HF_REMOTE}" "${DEPLOY_BRANCH}:main"
|
| 41 |
+
|
| 42 |
+
echo "Deployment complete: ${SPACE_URL}"
|
pyproject.toml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "rtssdiffviewer"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Streamlit app for RTSS DICOM to JSON conversion and git-like diffing"
|
| 5 |
+
requires-python = ">=3.10"
|
| 6 |
+
dependencies = [
|
| 7 |
+
"streamlit>=1.43",
|
| 8 |
+
"st-diff-viewer>=1.0.7",
|
| 9 |
+
"pydicom>=3.0.1",
|
| 10 |
+
]
|
| 11 |
+
|
| 12 |
+
[build-system]
|
| 13 |
+
requires = ["setuptools>=68", "wheel"]
|
| 14 |
+
build-backend = "setuptools.build_meta"
|
| 15 |
+
|
| 16 |
+
[tool.setuptools]
|
| 17 |
+
package-dir = {"" = "src"}
|
| 18 |
+
|
| 19 |
+
[tool.setuptools.packages.find]
|
| 20 |
+
where = ["src"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.43
|
| 2 |
+
st-diff-viewer>=1.0.7
|
| 3 |
+
pydicom>=3.0.1
|
src/rtssdiffviewer/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""rtssdiffviewer package."""
|
| 2 |
+
|
| 3 |
+
from .dcm_to_json import dcm_to_json
|
| 4 |
+
|
| 5 |
+
__all__ = ["dcm_to_json"]
|
src/rtssdiffviewer/dcm_to_json.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DICOM RTSS to JSON conversion helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
import pydicom
|
| 11 |
+
from pydicom.dataelem import DataElement
|
| 12 |
+
from pydicom.dataset import Dataset
|
| 13 |
+
from pydicom.uid import UID
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _tag_key(elem: DataElement) -> str:
|
| 17 |
+
tag_str = f"({elem.tag.group:04X},{elem.tag.element:04X})"
|
| 18 |
+
keyword = elem.keyword or ""
|
| 19 |
+
return f"{tag_str} {keyword}".strip()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _bytes_summary(data: bytes) -> dict[str, Any]:
|
| 23 |
+
return {
|
| 24 |
+
"_type": "bytes",
|
| 25 |
+
"length": len(data),
|
| 26 |
+
"sha256": hashlib.sha256(data).hexdigest(),
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _scalar(value: Any) -> Any:
|
| 31 |
+
if value is None:
|
| 32 |
+
return None
|
| 33 |
+
if isinstance(value, (int, float, bool)):
|
| 34 |
+
return value
|
| 35 |
+
if isinstance(value, bytes):
|
| 36 |
+
return _bytes_summary(value)
|
| 37 |
+
return str(value)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _convert_value(elem: DataElement) -> Any:
|
| 41 |
+
vr = elem.VR
|
| 42 |
+
if vr == "SQ":
|
| 43 |
+
return [_dataset_to_dict(item) for item in (elem.value or [])]
|
| 44 |
+
|
| 45 |
+
if vr in ("OB", "OW", "OD", "OF", "OL", "OV", "UN"):
|
| 46 |
+
raw = elem.value
|
| 47 |
+
if isinstance(raw, bytes):
|
| 48 |
+
return _bytes_summary(raw)
|
| 49 |
+
return {"_type": "bytes", "length": 0, "sha256": ""}
|
| 50 |
+
|
| 51 |
+
if vr == "DS":
|
| 52 |
+
raw = elem.value
|
| 53 |
+
if raw is None:
|
| 54 |
+
return None
|
| 55 |
+
if hasattr(raw, "__iter__") and not isinstance(raw, str):
|
| 56 |
+
try:
|
| 57 |
+
return [float(v) for v in raw]
|
| 58 |
+
except (TypeError, ValueError):
|
| 59 |
+
return [str(v) for v in raw]
|
| 60 |
+
try:
|
| 61 |
+
return float(raw)
|
| 62 |
+
except (TypeError, ValueError):
|
| 63 |
+
return str(raw)
|
| 64 |
+
|
| 65 |
+
if vr == "IS":
|
| 66 |
+
raw = elem.value
|
| 67 |
+
if raw is None:
|
| 68 |
+
return None
|
| 69 |
+
if hasattr(raw, "__iter__") and not isinstance(raw, str):
|
| 70 |
+
try:
|
| 71 |
+
return [int(v) for v in raw]
|
| 72 |
+
except (TypeError, ValueError):
|
| 73 |
+
return [str(v) for v in raw]
|
| 74 |
+
try:
|
| 75 |
+
return int(raw)
|
| 76 |
+
except (TypeError, ValueError):
|
| 77 |
+
return str(raw)
|
| 78 |
+
|
| 79 |
+
raw = elem.value
|
| 80 |
+
if hasattr(raw, "__iter__") and not isinstance(raw, (str, bytes, UID)):
|
| 81 |
+
items = list(raw)
|
| 82 |
+
if len(items) == 1:
|
| 83 |
+
return _scalar(items[0])
|
| 84 |
+
return [_scalar(v) for v in items]
|
| 85 |
+
|
| 86 |
+
return _scalar(raw)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _dataset_to_dict(ds: Dataset) -> dict[str, Any]:
|
| 90 |
+
out: dict[str, Any] = {}
|
| 91 |
+
for elem in ds:
|
| 92 |
+
key = _tag_key(elem)
|
| 93 |
+
if elem.keyword == "ContourData" and elem.value:
|
| 94 |
+
flat = [float(v) for v in elem.value]
|
| 95 |
+
out[key] = [flat[i : i + 3] for i in range(0, len(flat), 3)]
|
| 96 |
+
else:
|
| 97 |
+
out[key] = _convert_value(elem)
|
| 98 |
+
return out
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def dcm_to_json(dcm_path: str | Path, output_path: str | Path | None = None) -> dict[str, Any]:
|
| 102 |
+
"""Convert DICOM file to a deterministic JSON dict and optionally write it."""
|
| 103 |
+
dcm_path = Path(dcm_path)
|
| 104 |
+
ds = pydicom.dcmread(str(dcm_path), force=True)
|
| 105 |
+
|
| 106 |
+
result: dict[str, Any] = {}
|
| 107 |
+
if hasattr(ds, "file_meta") and ds.file_meta:
|
| 108 |
+
result["_file_meta"] = _dataset_to_dict(ds.file_meta)
|
| 109 |
+
result.update(_dataset_to_dict(ds))
|
| 110 |
+
|
| 111 |
+
if output_path is not None:
|
| 112 |
+
output_path = Path(output_path)
|
| 113 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 114 |
+
with output_path.open("w", encoding="utf-8") as fh:
|
| 115 |
+
json.dump(result, fh, indent=2, ensure_ascii=False, sort_keys=False)
|
| 116 |
+
|
| 117 |
+
return result
|
src/rtssdiffviewer/diff_core.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core RTSS JSON normalization and diff helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import difflib
|
| 6 |
+
import json
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
DEFAULT_VOLATILE_TAG_PREFIXES = {
|
| 10 |
+
"(0008,0012)",
|
| 11 |
+
"(0008,0013)",
|
| 12 |
+
"(0008,0018)",
|
| 13 |
+
"(0020,000D)",
|
| 14 |
+
"(0020,000E)",
|
| 15 |
+
"(0020,0052)",
|
| 16 |
+
"(3006,0008)",
|
| 17 |
+
"(3006,0009)",
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
COMPONENT_KEYS = {
|
| 21 |
+
"references": ["(3006,0010) ReferencedFrameOfReferenceSequence"],
|
| 22 |
+
"structures": [
|
| 23 |
+
"(3006,0020) StructureSetROISequence",
|
| 24 |
+
"(3006,0080) RTROIObservationsSequence",
|
| 25 |
+
],
|
| 26 |
+
"contours": ["(3006,0039) ROIContourSequence"],
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def is_volatile_tag(key: str, ignore_prefixes: set[str]) -> bool:
|
| 31 |
+
if not key.startswith("("):
|
| 32 |
+
return False
|
| 33 |
+
tag_prefix = key.split(" ", 1)[0]
|
| 34 |
+
return tag_prefix in ignore_prefixes
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def normalize_value(value: Any, precision: int, ignore_prefixes: set[str]) -> Any:
|
| 38 |
+
if isinstance(value, dict):
|
| 39 |
+
out: dict[str, Any] = {}
|
| 40 |
+
for key in sorted(value.keys()):
|
| 41 |
+
if is_volatile_tag(key, ignore_prefixes):
|
| 42 |
+
continue
|
| 43 |
+
out[key] = normalize_value(value[key], precision, ignore_prefixes)
|
| 44 |
+
return out
|
| 45 |
+
|
| 46 |
+
if isinstance(value, list):
|
| 47 |
+
return [normalize_value(v, precision, ignore_prefixes) for v in value]
|
| 48 |
+
|
| 49 |
+
if isinstance(value, float):
|
| 50 |
+
return round(value, precision)
|
| 51 |
+
|
| 52 |
+
return value
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def select_component(data: dict[str, Any], component: str) -> dict[str, Any]:
|
| 56 |
+
if component == "all":
|
| 57 |
+
return data
|
| 58 |
+
|
| 59 |
+
if component == "metadata":
|
| 60 |
+
excluded: set[str] = set()
|
| 61 |
+
for keys in COMPONENT_KEYS.values():
|
| 62 |
+
excluded.update(keys)
|
| 63 |
+
return {k: v for k, v in data.items() if k not in excluded}
|
| 64 |
+
|
| 65 |
+
keys = COMPONENT_KEYS[component]
|
| 66 |
+
return {k: data[k] for k in keys if k in data}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def pretty_json_text(data: dict[str, Any]) -> str:
|
| 70 |
+
return json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def unified_diff_text(left_text: str, right_text: str, left_name: str, right_name: str) -> str:
|
| 74 |
+
left_lines = [line + "\n" for line in left_text.splitlines()]
|
| 75 |
+
right_lines = [line + "\n" for line in right_text.splitlines()]
|
| 76 |
+
diff = difflib.unified_diff(
|
| 77 |
+
left_lines,
|
| 78 |
+
right_lines,
|
| 79 |
+
fromfile=left_name,
|
| 80 |
+
tofile=right_name,
|
| 81 |
+
lineterm="",
|
| 82 |
+
)
|
| 83 |
+
return "\n".join(diff)
|