KingmaoQ commited on
Commit
76319d3
·
1 Parent(s): 0c04d9c

Support raw imaging zip uploads

Browse files
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 or uploaded RLDT imaging packages, with heuristic lesion highlighting when no mask is available."
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 packaged case for visualization."
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 Package (.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 imaging package into the progression viewer.",
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 package, then click **Load Imaging Case**.")
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 not manifest_path.exists():
470
- raise ValueError("Uploaded package is missing manifest.json.")
471
- manifest = _json_load(manifest_path)
472
- if manifest.get("format") != PACKAGE_MAGIC:
473
- raise ValueError("Uploaded package is not a supported RLDT ovarian imaging package.")
474
- return target_dir
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 a `.zip` package generated by the RLDT ovarian imaging exporter.\n"
688
- "- Each package contains one case, multiple timepoints, and preprocessed `.npz` imaging assets.\n"
 
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