Support raw imaging zip uploads
Browse files- RL0910/enhanced_chat_ui.py +5 -5
- RL0910/imaging_viewer.py +225 -8
- requirements.txt +1 -0
RL0910/enhanced_chat_ui.py
CHANGED
|
@@ -1051,7 +1051,7 @@ def create_gradio_interface():
|
|
| 1051 |
|
| 1052 |
gr.Markdown("### Ovarian Cancer Existing Progression Viewer")
|
| 1053 |
gr.Markdown(
|
| 1054 |
-
"> Existing progression visualization only. This workspace shows preloaded TCGA-OV CT time series
|
| 1055 |
)
|
| 1056 |
|
| 1057 |
with gr.Row():
|
|
@@ -1059,7 +1059,7 @@ def create_gradio_interface():
|
|
| 1059 |
choices=["Demo Cases", "Upload Package"],
|
| 1060 |
value="Demo Cases",
|
| 1061 |
label="Imaging source",
|
| 1062 |
-
info="Use the preloaded TCGA-OV cases or upload one
|
| 1063 |
)
|
| 1064 |
imaging_case_dropdown = gr.Dropdown(
|
| 1065 |
choices=imaging_demo_choices,
|
|
@@ -1101,19 +1101,19 @@ def create_gradio_interface():
|
|
| 1101 |
with gr.Row():
|
| 1102 |
with gr.Column(scale=2):
|
| 1103 |
imaging_upload = gr.File(
|
| 1104 |
-
label="Upload Imaging
|
| 1105 |
file_types=[".zip"],
|
| 1106 |
visible=False,
|
| 1107 |
)
|
| 1108 |
with gr.Column(scale=1):
|
| 1109 |
imaging_load_btn = _register_button_with_help(
|
| 1110 |
"Load Imaging Case",
|
| 1111 |
-
"Load the selected demo case or uploaded
|
| 1112 |
variant="primary",
|
| 1113 |
)
|
| 1114 |
|
| 1115 |
imaging_requirements = gr.Markdown(imaging_upload_requirements_md())
|
| 1116 |
-
imaging_case_info = gr.Markdown("Choose a demo case or upload a
|
| 1117 |
imaging_timepoint_slider = gr.Slider(
|
| 1118 |
minimum=0,
|
| 1119 |
maximum=0,
|
|
|
|
| 1051 |
|
| 1052 |
gr.Markdown("### Ovarian Cancer Existing Progression Viewer")
|
| 1053 |
gr.Markdown(
|
| 1054 |
+
"> Existing progression visualization only. This workspace shows preloaded TCGA-OV CT time series, uploaded RLDT imaging packages, or uploaded raw CT zip files that will be converted on load."
|
| 1055 |
)
|
| 1056 |
|
| 1057 |
with gr.Row():
|
|
|
|
| 1059 |
choices=["Demo Cases", "Upload Package"],
|
| 1060 |
value="Demo Cases",
|
| 1061 |
label="Imaging source",
|
| 1062 |
+
info="Use the preloaded TCGA-OV cases or upload one zip file for visualization. Raw DICOM/NIfTI zip files are converted automatically."
|
| 1063 |
)
|
| 1064 |
imaging_case_dropdown = gr.Dropdown(
|
| 1065 |
choices=imaging_demo_choices,
|
|
|
|
| 1101 |
with gr.Row():
|
| 1102 |
with gr.Column(scale=2):
|
| 1103 |
imaging_upload = gr.File(
|
| 1104 |
+
label="Upload Imaging ZIP (.zip)",
|
| 1105 |
file_types=[".zip"],
|
| 1106 |
visible=False,
|
| 1107 |
)
|
| 1108 |
with gr.Column(scale=1):
|
| 1109 |
imaging_load_btn = _register_button_with_help(
|
| 1110 |
"Load Imaging Case",
|
| 1111 |
+
"Load the selected demo case or uploaded zip into the progression viewer. Raw source zips may take longer because they are converted on the fly.",
|
| 1112 |
variant="primary",
|
| 1113 |
)
|
| 1114 |
|
| 1115 |
imaging_requirements = gr.Markdown(imaging_upload_requirements_md())
|
| 1116 |
+
imaging_case_info = gr.Markdown("Choose a demo case or upload a zip file, then click **Load Imaging Case**.")
|
| 1117 |
imaging_timepoint_slider = gr.Slider(
|
| 1118 |
minimum=0,
|
| 1119 |
maximum=0,
|
RL0910/imaging_viewer.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import base64
|
|
|
|
| 4 |
import io
|
| 5 |
import json
|
| 6 |
import math
|
|
@@ -23,6 +24,8 @@ MODULE_DIR = Path(__file__).resolve().parent
|
|
| 23 |
DEMO_ROOT = MODULE_DIR / "demo_imaging_cases"
|
| 24 |
UPLOAD_CACHE_ROOT = Path(tempfile.gettempdir()) / "rldt_ov_imaging_uploads"
|
| 25 |
PACKAGE_MAGIC = "rldt_ov_imaging_package"
|
|
|
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
def _json_load(path: Path) -> dict[str, Any]:
|
|
@@ -466,12 +469,225 @@ def _package_root_from_upload(upload_path: str) -> Path:
|
|
| 466 |
with zipfile.ZipFile(src, "r") as zf:
|
| 467 |
zf.extractall(target_dir)
|
| 468 |
manifest_path = target_dir / "manifest.json"
|
| 469 |
-
if
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
|
| 476 |
|
| 477 |
def available_demo_case_choices() -> list[tuple[str, str]]:
|
|
@@ -684,8 +900,9 @@ def select_pointcloud_layer(evt: gr.SelectData, state: dict[str, Any]):
|
|
| 684 |
def imaging_upload_requirements_md() -> str:
|
| 685 |
return (
|
| 686 |
"#### Imaging package format\n"
|
| 687 |
-
"- Upload
|
| 688 |
-
"-
|
|
|
|
| 689 |
"- Optional lesion masks are supported. If missing, the viewer falls back to a heuristic pelvic lesion candidate.\n"
|
| 690 |
"- This workspace is for **existing progression visualization only**; it does not run predictive modeling."
|
| 691 |
)
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import base64
|
| 4 |
+
import hashlib
|
| 5 |
import io
|
| 6 |
import json
|
| 7 |
import math
|
|
|
|
| 24 |
DEMO_ROOT = MODULE_DIR / "demo_imaging_cases"
|
| 25 |
UPLOAD_CACHE_ROOT = Path(tempfile.gettempdir()) / "rldt_ov_imaging_uploads"
|
| 26 |
PACKAGE_MAGIC = "rldt_ov_imaging_package"
|
| 27 |
+
RAW_PACKAGE_MAGIC = "rldt_ov_raw_imaging_source"
|
| 28 |
+
TARGET_SHAPE = (80, 160, 160)
|
| 29 |
|
| 30 |
|
| 31 |
def _json_load(path: Path) -> dict[str, Any]:
|
|
|
|
| 469 |
with zipfile.ZipFile(src, "r") as zf:
|
| 470 |
zf.extractall(target_dir)
|
| 471 |
manifest_path = target_dir / "manifest.json"
|
| 472 |
+
if manifest_path.exists():
|
| 473 |
+
manifest = _json_load(manifest_path)
|
| 474 |
+
if manifest.get("format") == PACKAGE_MAGIC:
|
| 475 |
+
return target_dir
|
| 476 |
+
|
| 477 |
+
converted_root = target_dir / "_converted_package"
|
| 478 |
+
converted_manifest = converted_root / "manifest.json"
|
| 479 |
+
if converted_manifest.exists():
|
| 480 |
+
manifest = _json_load(converted_manifest)
|
| 481 |
+
if manifest.get("format") == PACKAGE_MAGIC:
|
| 482 |
+
return converted_root
|
| 483 |
+
|
| 484 |
+
return _convert_raw_upload_to_package(target_dir, converted_root, src.name)
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
def _lazy_import_sitk():
|
| 488 |
+
import SimpleITK as sitk
|
| 489 |
+
|
| 490 |
+
return sitk
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
def _read_series_dir(series_dir: Path) -> tuple[np.ndarray, tuple[float, float, float], str]:
|
| 494 |
+
sitk = _lazy_import_sitk()
|
| 495 |
+
reader = sitk.ImageSeriesReader()
|
| 496 |
+
ids = reader.GetGDCMSeriesIDs(str(series_dir))
|
| 497 |
+
if not ids:
|
| 498 |
+
raise FileNotFoundError(f"No readable DICOM series found in {series_dir}")
|
| 499 |
+
files = reader.GetGDCMSeriesFileNames(str(series_dir), ids[0])
|
| 500 |
+
reader.SetFileNames(files)
|
| 501 |
+
image = reader.Execute()
|
| 502 |
+
arr = sitk.GetArrayFromImage(image).astype(np.int16)
|
| 503 |
+
spacing = image.GetSpacing() # x, y, z
|
| 504 |
+
description = ""
|
| 505 |
+
try:
|
| 506 |
+
if image.HasMetaDataKey("0008|103e"):
|
| 507 |
+
description = image.GetMetaData("0008|103e")
|
| 508 |
+
except Exception:
|
| 509 |
+
description = ""
|
| 510 |
+
return arr, (float(spacing[2]), float(spacing[1]), float(spacing[0])), description
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
def _read_nifti_file(path: Path) -> tuple[np.ndarray, tuple[float, float, float], str]:
|
| 514 |
+
sitk = _lazy_import_sitk()
|
| 515 |
+
image = sitk.ReadImage(str(path))
|
| 516 |
+
arr = sitk.GetArrayFromImage(image).astype(np.int16)
|
| 517 |
+
spacing = image.GetSpacing() # x, y, z
|
| 518 |
+
return arr, (float(spacing[2]), float(spacing[1]), float(spacing[0])), path.stem
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
def _resample_nn(arr: np.ndarray, out_shape: tuple[int, int, int], order: int) -> np.ndarray:
|
| 522 |
+
zoom = [o / i for o, i in zip(out_shape, arr.shape)]
|
| 523 |
+
return ndi.zoom(arr, zoom=zoom, order=order)
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
def _build_body_mask_hu(volume_hu: np.ndarray) -> np.ndarray:
|
| 527 |
+
body = volume_hu > -350
|
| 528 |
+
labels, n = ndi.label(body)
|
| 529 |
+
if n <= 0:
|
| 530 |
+
return body
|
| 531 |
+
sizes = np.bincount(labels.ravel())
|
| 532 |
+
sizes[0] = 0
|
| 533 |
+
return labels == int(sizes.argmax())
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
def _heuristic_lesion_mask(volume_hu: np.ndarray, body_mask: np.ndarray) -> tuple[np.ndarray, np.ndarray, float, str]:
|
| 537 |
+
z_idx = np.where(body_mask.any(axis=(1, 2)))[0]
|
| 538 |
+
if len(z_idx) == 0:
|
| 539 |
+
center = np.array([volume_hu.shape[0] // 2, volume_hu.shape[1] // 2, volume_hu.shape[2] // 2], dtype=np.int16)
|
| 540 |
+
lesion = np.zeros_like(body_mask, dtype=np.uint8)
|
| 541 |
+
lesion[max(center[0] - 3, 0):center[0] + 4, max(center[1] - 10, 0):center[1] + 11, max(center[2] - 10, 0):center[2] + 11] = 1
|
| 542 |
+
return lesion, center, 0.1, "fallback_box"
|
| 543 |
+
z0 = int(np.percentile(z_idx, 60))
|
| 544 |
+
z1 = int(np.percentile(z_idx, 90))
|
| 545 |
+
proj = body_mask[z0:z1].max(axis=0)
|
| 546 |
+
yy, xx = np.where(proj)
|
| 547 |
+
cy = float(np.mean(yy)) if len(yy) else volume_hu.shape[1] / 2
|
| 548 |
+
cx = float(np.mean(xx)) if len(xx) else volume_hu.shape[2] / 2
|
| 549 |
+
h, w = volume_hu.shape[1:]
|
| 550 |
+
y_grid, x_grid = np.ogrid[:h, :w]
|
| 551 |
+
central = ((y_grid - cy) ** 2 / (0.18 * h) ** 2 + (x_grid - cx) ** 2 / (0.16 * w) ** 2) <= 1.0
|
| 552 |
+
roi = np.zeros_like(body_mask, dtype=bool)
|
| 553 |
+
roi[z0:z1] = central
|
| 554 |
+
candidate = (volume_hu > 145) & (volume_hu < 280) & body_mask & roi
|
| 555 |
+
candidate = ndi.binary_opening(candidate, structure=np.ones((1, 3, 3)))
|
| 556 |
+
candidate = ndi.binary_closing(candidate, structure=np.ones((1, 5, 5)))
|
| 557 |
+
labels, n = ndi.label(candidate)
|
| 558 |
+
best_mask = None
|
| 559 |
+
best_score = -1.0
|
| 560 |
+
center = np.array([int((z0 + z1) / 2), int(cy), int(cx)], dtype=np.int16)
|
| 561 |
+
if n > 0:
|
| 562 |
+
coords_center = center.astype(float)
|
| 563 |
+
for idx in range(1, n + 1):
|
| 564 |
+
comp = labels == idx
|
| 565 |
+
vox = int(comp.sum())
|
| 566 |
+
if vox < 200:
|
| 567 |
+
continue
|
| 568 |
+
pts = np.argwhere(comp)
|
| 569 |
+
centroid = pts.mean(axis=0)
|
| 570 |
+
dist = float(np.linalg.norm((centroid - coords_center) / np.array([8.0, 18.0, 18.0])))
|
| 571 |
+
score = vox / (1.0 + dist * 10.0)
|
| 572 |
+
if score > best_score:
|
| 573 |
+
best_score = score
|
| 574 |
+
best_mask = comp
|
| 575 |
+
center = centroid.astype(np.int16)
|
| 576 |
+
if best_mask is None:
|
| 577 |
+
best_mask = np.zeros_like(body_mask, dtype=bool)
|
| 578 |
+
cz, cyi, cxi = [int(v) for v in center]
|
| 579 |
+
rz, ry, rx = 4, 12, 12
|
| 580 |
+
z_grid, y_grid, x_grid = np.ogrid[:volume_hu.shape[0], :volume_hu.shape[1], :volume_hu.shape[2]]
|
| 581 |
+
ellipsoid = ((z_grid - cz) ** 2 / rz**2 + (y_grid - cyi) ** 2 / ry**2 + (x_grid - cxi) ** 2 / rx**2) <= 1.0
|
| 582 |
+
best_mask = ellipsoid & body_mask
|
| 583 |
+
confidence = 0.22
|
| 584 |
+
source = "heuristic_ellipsoid"
|
| 585 |
+
else:
|
| 586 |
+
confidence = 0.42
|
| 587 |
+
source = "heuristic_component"
|
| 588 |
+
return best_mask.astype(np.uint8), center.astype(np.int16), confidence, source
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
def _normalize_to_u8(volume_hu: np.ndarray) -> np.ndarray:
|
| 592 |
+
windowed = np.clip(volume_hu, -150, 250)
|
| 593 |
+
scaled = ((windowed + 150.0) / 400.0) * 255.0
|
| 594 |
+
return scaled.astype(np.uint8)
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
def _discover_raw_timepoints(extracted_root: Path) -> list[dict[str, Any]]:
|
| 598 |
+
nii_files = sorted(
|
| 599 |
+
[
|
| 600 |
+
path
|
| 601 |
+
for path in extracted_root.rglob("*")
|
| 602 |
+
if path.is_file() and (path.name.endswith(".nii") or path.name.endswith(".nii.gz"))
|
| 603 |
+
]
|
| 604 |
+
)
|
| 605 |
+
if nii_files:
|
| 606 |
+
return [
|
| 607 |
+
{
|
| 608 |
+
"kind": "nifti",
|
| 609 |
+
"path": path,
|
| 610 |
+
"label": path.stem.replace(".nii", ""),
|
| 611 |
+
"relative_time": float(idx + 1),
|
| 612 |
+
}
|
| 613 |
+
for idx, path in enumerate(nii_files)
|
| 614 |
+
]
|
| 615 |
+
|
| 616 |
+
dicom_dirs: list[Path] = []
|
| 617 |
+
for candidate in sorted({path.parent for path in extracted_root.rglob("*.dcm")}):
|
| 618 |
+
if any(child.is_file() and child.suffix.lower() == ".dcm" for child in candidate.iterdir()):
|
| 619 |
+
dicom_dirs.append(candidate)
|
| 620 |
+
if dicom_dirs:
|
| 621 |
+
return [
|
| 622 |
+
{
|
| 623 |
+
"kind": "dicom",
|
| 624 |
+
"path": path,
|
| 625 |
+
"label": path.name,
|
| 626 |
+
"relative_time": float(idx + 1),
|
| 627 |
+
}
|
| 628 |
+
for idx, path in enumerate(sorted(dicom_dirs))
|
| 629 |
+
]
|
| 630 |
+
|
| 631 |
+
raise ValueError(
|
| 632 |
+
"Uploaded zip is neither an RLDT imaging package nor a supported raw CT source zip. "
|
| 633 |
+
"Expected one case containing either multiple NIfTI volumes or multiple DICOM series folders."
|
| 634 |
+
)
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
def _convert_raw_upload_to_package(extracted_root: Path, converted_root: Path, upload_name: str) -> Path:
|
| 638 |
+
converted_root.mkdir(parents=True, exist_ok=True)
|
| 639 |
+
discovered = _discover_raw_timepoints(extracted_root)
|
| 640 |
+
case_id = hashlib.sha1(upload_name.encode("utf-8")).hexdigest()[:10]
|
| 641 |
+
manifest = {
|
| 642 |
+
"format": PACKAGE_MAGIC,
|
| 643 |
+
"version": 1,
|
| 644 |
+
"case_id": f"uploaded-{case_id}",
|
| 645 |
+
"patient_id": f"uploaded-{case_id}",
|
| 646 |
+
"display_name": f"Uploaded CT Case {case_id}",
|
| 647 |
+
"modality": "CT",
|
| 648 |
+
"summary_note": "Existing progression visualization built from an uploaded raw CT zip. Lesion highlighting is heuristic when no mask is available.",
|
| 649 |
+
"timepoints": [],
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
for tp_idx, info in enumerate(discovered, start=1):
|
| 653 |
+
if info["kind"] == "dicom":
|
| 654 |
+
volume_hu, spacing_zyx, series_description = _read_series_dir(info["path"])
|
| 655 |
+
series_label = series_description or info["label"]
|
| 656 |
+
else:
|
| 657 |
+
volume_hu, spacing_zyx, series_label = _read_nifti_file(info["path"])
|
| 658 |
+
|
| 659 |
+
body = _build_body_mask_hu(volume_hu)
|
| 660 |
+
volume_small = _resample_nn(volume_hu, TARGET_SHAPE, order=1).astype(np.int16)
|
| 661 |
+
body_small = _resample_nn(body.astype(np.uint8), TARGET_SHAPE, order=0) > 0
|
| 662 |
+
lesion_mask, roi_center, confidence, source = _heuristic_lesion_mask(volume_small, body_small)
|
| 663 |
+
volume_u8 = _normalize_to_u8(volume_small)
|
| 664 |
+
|
| 665 |
+
asset_name = f"tp_{tp_idx:02d}.json"
|
| 666 |
+
payload = {
|
| 667 |
+
"shape": list(volume_u8.shape),
|
| 668 |
+
"roi_center_zyx": [int(v) for v in roi_center.tolist()],
|
| 669 |
+
"volume_u8_b85": base64.b85encode(zlib.compress(volume_u8.tobytes(), level=9)).decode("ascii"),
|
| 670 |
+
"lesion_mask_b85": base64.b85encode(zlib.compress(lesion_mask.astype(np.uint8).tobytes(), level=9)).decode("ascii"),
|
| 671 |
+
}
|
| 672 |
+
(converted_root / asset_name).write_text(json.dumps(payload), encoding="utf-8")
|
| 673 |
+
manifest["timepoints"].append(
|
| 674 |
+
{
|
| 675 |
+
"timepoint_id": f"t{tp_idx:02d}",
|
| 676 |
+
"label": str(info["label"]),
|
| 677 |
+
"relative_time": float(info["relative_time"]),
|
| 678 |
+
"asset_path": asset_name,
|
| 679 |
+
"series_description": str(series_label),
|
| 680 |
+
"num_slices_original": int(volume_hu.shape[0]),
|
| 681 |
+
"spacing_zyx_mm": [float(v) for v in spacing_zyx],
|
| 682 |
+
"lesion_confidence": float(confidence),
|
| 683 |
+
"lesion_source": source,
|
| 684 |
+
"lesion_voxel_count": int(lesion_mask.sum()),
|
| 685 |
+
"source_type": info["kind"],
|
| 686 |
+
}
|
| 687 |
+
)
|
| 688 |
+
|
| 689 |
+
(converted_root / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
| 690 |
+
return converted_root
|
| 691 |
|
| 692 |
|
| 693 |
def available_demo_case_choices() -> list[tuple[str, str]]:
|
|
|
|
| 900 |
def imaging_upload_requirements_md() -> str:
|
| 901 |
return (
|
| 902 |
"#### Imaging package format\n"
|
| 903 |
+
"- Upload either an RLDT imaging package `.zip` or a raw source `.zip`.\n"
|
| 904 |
+
"- Raw source zip: one patient, multiple timepoints, each timepoint as a DICOM series folder or a NIfTI volume.\n"
|
| 905 |
+
"- RLDT package zip: preprocessed internal visualization package with one case and multiple timepoints.\n"
|
| 906 |
"- Optional lesion masks are supported. If missing, the viewer falls back to a heuristic pelvic lesion candidate.\n"
|
| 907 |
"- This workspace is for **existing progression visualization only**; it does not run predictive modeling."
|
| 908 |
)
|
requirements.txt
CHANGED
|
@@ -10,6 +10,7 @@ matplotlib>=3.8,<3.10
|
|
| 10 |
plotly>=5.24,<6
|
| 11 |
seaborn>=0.13,<0.14
|
| 12 |
pillow>=10.2,<11
|
|
|
|
| 13 |
torch>=2.2,<2.6
|
| 14 |
tqdm>=4.66,<5
|
| 15 |
pyyaml>=6.0,<7
|
|
|
|
| 10 |
plotly>=5.24,<6
|
| 11 |
seaborn>=0.13,<0.14
|
| 12 |
pillow>=10.2,<11
|
| 13 |
+
SimpleITK>=2.3,<3
|
| 14 |
torch>=2.2,<2.6
|
| 15 |
tqdm>=4.66,<5
|
| 16 |
pyyaml>=6.0,<7
|