From d21237d06ae3292fc7a7ac7e5d418af68ae5b02e Mon Sep 17 00:00:00 2001 From: eireawerawer Date: Fri, 17 Jul 2026 19:42:33 -0400 Subject: [PATCH] Enable cancerverse viewing. Segmentation-related functions disabled. --- .gitignore | 4 + CancerVerse/download_partial_cancerverse.sh | 32 +++++ PanTS-Demo/src/components/Preview.tsx | 9 +- PanTS-Demo/src/helpers/savedCases.test.ts | 45 ++++--- PanTS-Demo/src/helpers/savedCases.ts | 11 +- PanTS-Demo/src/helpers/search.test.ts | 18 ++- PanTS-Demo/src/helpers/search.ts | 21 +++- PanTS-Demo/src/helpers/utils.test.ts | 15 +++ PanTS-Demo/src/helpers/utils.ts | 17 +++ PanTS-Demo/src/routes/ComparePage.tsx | 7 +- PanTS-Demo/src/routes/Homepage.tsx | 21 ++-- PanTS-Demo/src/routes/VisualizationPage.css | 17 +++ PanTS-Demo/src/routes/VisualizationPage.tsx | 130 +++++++++++++++++--- README.md | 5 + flask-server/api/api_blueprint.py | 75 ++++++++--- flask-server/api/utils.py | 81 +++++++++++- flask-server/constants.py | 4 + 17 files changed, 437 insertions(+), 75 deletions(-) create mode 100644 CancerVerse/download_partial_cancerverse.sh diff --git a/.gitignore b/.gitignore index 3e2f06e..328ba07 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ tmp/* downloaded.nii.gz PanTS/data/pdf/ .DS_Store + +*.nii.gz +CancerVerse/CancerVerse_dataset_metadata.csv +CancerVerse/~/.cache/huggingface/.agent_harnesses.json diff --git a/CancerVerse/download_partial_cancerverse.sh b/CancerVerse/download_partial_cancerverse.sh new file mode 100644 index 0000000..f3a8f4b --- /dev/null +++ b/CancerVerse/download_partial_cancerverse.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +set -u + +REPO_ID="BodyMaps/CancerVerse" +REPO_TYPE="dataset" +LOCAL_DIR="." + +while true; do + echo "==========================================" + echo "Starting/retrying download at $(date)" + echo "Downloading CV_00000001 through CV_00000100" + echo "==========================================" + + hf download "$REPO_ID" \ + --repo-type "$REPO_TYPE" \ + --local-dir "$LOCAL_DIR" \ + --include "CancerVerse/CV_0000000[1-9]/*" \ + --include "CancerVerse/CV_000000[1-9][0-9]/*" \ + --include "CancerVerse/CV_00000100/*" + + status=$? + + if [ "$status" -eq 0 ]; then + echo "Download completed successfully at $(date)" + break + fi + + echo "Download failed with exit code $status at $(date)" + echo "Retrying in 30 minutes..." + sleep 1800 +done \ No newline at end of file diff --git a/PanTS-Demo/src/components/Preview.tsx b/PanTS-Demo/src/components/Preview.tsx index 1f554ba..db5c7d4 100644 --- a/PanTS-Demo/src/components/Preview.tsx +++ b/PanTS-Demo/src/components/Preview.tsx @@ -1,11 +1,12 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { API_BASE } from "../helpers/constants"; +import { getCaseDisplay } from "../helpers/utils"; import { prefetchViewer } from "../helpers/prefetchViewer"; import type { PreviewType } from "../types"; type Props = { - id: number; + id: string; previewMetadata: PreviewType; saved?: boolean; onToggleSave?: () => void; @@ -35,14 +36,16 @@ export default function Preview({ if (!previewMetadata) return null; - const caseIdStr = `PanTS_${id.toString().padStart(8, "0")}`; + const { dataset, label: caseIdStr } = getCaseDisplay(id); // HuggingFace fallback, routed through the backend's same-origin proxy. A *direct* // cross-origin image is blocked by the viewer's COEP: require-corp header (which is // why thumbnails went missing); the proxy keeps it same-origin, matching home.html. + // CancerVerse has no HuggingFace profile-image mirror, so it skips straight to the + // "both sources failed" placeholder if the local endpoint 404s. const hfProfileUrl = `https://huggingface.co/datasets/BodyMaps/iPanTSMini/resolve/main/profile_only/${caseIdStr}/profile.jpg`; const proxyThumbUrl = `${API_BASE}/api/proxy-image?url=${encodeURIComponent(hfProfileUrl)}`; const handleImgError = () => { - if (thumbUrl !== proxyThumbUrl) { + if (dataset !== "CancerVerse" && thumbUrl !== proxyThumbUrl) { setThumbUrl(proxyThumbUrl); // local failed — retry via the same-origin HF proxy } else { setImgError(true); // both sources failed diff --git a/PanTS-Demo/src/helpers/savedCases.test.ts b/PanTS-Demo/src/helpers/savedCases.test.ts index 4560901..a7fb75c 100644 --- a/PanTS-Demo/src/helpers/savedCases.test.ts +++ b/PanTS-Demo/src/helpers/savedCases.test.ts @@ -1,47 +1,62 @@ import { beforeEach, describe, expect, it } from "vitest"; import { isSavedCase, loadSavedCases, SAVED_CASES_KEY, toggleSavedCase } from "./savedCases"; -const meta = (id: number) => ({ id, sex: "M", age: 50, tumor: 0 }); +const meta = (id: string) => ({ id, sex: "M", age: 50, tumor: 0 }); describe("savedCases", () => { beforeEach(() => localStorage.clear()); it("starts empty", () => { expect(loadSavedCases()).toEqual([]); - expect(isSavedCase(17)).toBe(false); + expect(isSavedCase("17")).toBe(false); }); it("toggles a case on and off", () => { - toggleSavedCase(meta(17)); - expect(isSavedCase(17)).toBe(true); - expect(loadSavedCases().map((c) => c.id)).toEqual([17]); + toggleSavedCase(meta("17")); + expect(isSavedCase("17")).toBe(true); + expect(loadSavedCases().map((c) => c.id)).toEqual(["17"]); - toggleSavedCase(meta(17)); - expect(isSavedCase(17)).toBe(false); + toggleSavedCase(meta("17")); + expect(isSavedCase("17")).toBe(false); expect(loadSavedCases()).toEqual([]); }); it("stores the card metadata and a savedAt timestamp", () => { const before = Date.now(); - toggleSavedCase({ id: 30, sex: "F", age: 66, tumor: 1 }); + toggleSavedCase({ id: "30", sex: "F", age: 66, tumor: 1 }); const [saved] = loadSavedCases(); - expect(saved).toMatchObject({ id: 30, sex: "F", age: 66, tumor: 1 }); + expect(saved).toMatchObject({ id: "30", sex: "F", age: 66, tumor: 1 }); expect(saved.savedAt).toBeGreaterThanOrEqual(before); }); it("keeps most-recently-saved first and de-dupes by id", () => { - toggleSavedCase(meta(1)); - toggleSavedCase(meta(2)); - expect(loadSavedCases().map((c) => c.id)).toEqual([2, 1]); + toggleSavedCase(meta("1")); + toggleSavedCase(meta("2")); + expect(loadSavedCases().map((c) => c.id)).toEqual(["2", "1"]); // re-adding an already-saved id removes it (toggle), it does not duplicate - toggleSavedCase(meta(2)); - expect(loadSavedCases().map((c) => c.id)).toEqual([1]); + toggleSavedCase(meta("2")); + expect(loadSavedCases().map((c) => c.id)).toEqual(["1"]); + }); + + it("works with a CancerVerse id, unchanged behavior", () => { + toggleSavedCase(meta("CV_00000012")); + expect(isSavedCase("CV_00000012")).toBe(true); + expect(loadSavedCases().map((c) => c.id)).toEqual(["CV_00000012"]); }); it("ignores corrupt storage instead of throwing", () => { localStorage.setItem(SAVED_CASES_KEY, "not json"); expect(loadSavedCases()).toEqual([]); localStorage.setItem(SAVED_CASES_KEY, JSON.stringify([{ nope: true }, { id: 5, sex: "M", age: 1, tumor: 0, savedAt: 1 }])); - expect(loadSavedCases().map((c) => c.id)).toEqual([5]); + expect(loadSavedCases().map((c) => c.id)).toEqual(["5"]); + }); + + it("coerces legacy numeric ids (saved before ids became strings) to strings", () => { + localStorage.setItem( + SAVED_CASES_KEY, + JSON.stringify([{ id: 17, sex: "M", age: 40, tumor: 0, savedAt: 1 }]) + ); + expect(loadSavedCases().map((c) => c.id)).toEqual(["17"]); + expect(isSavedCase("17")).toBe(true); }); }); diff --git a/PanTS-Demo/src/helpers/savedCases.ts b/PanTS-Demo/src/helpers/savedCases.ts index 909ce89..c8ef6f0 100644 --- a/PanTS-Demo/src/helpers/savedCases.ts +++ b/PanTS-Demo/src/helpers/savedCases.ts @@ -5,7 +5,7 @@ // (cross-tab) to stay in sync. export type SavedCase = { - id: number; + id: string; sex: string; age: number; tumor: number; @@ -18,7 +18,12 @@ export const SAVED_CASES_EVENT = "savedcaseschange"; export const loadSavedCases = (): SavedCase[] => { try { const arr = JSON.parse(localStorage.getItem(SAVED_CASES_KEY) || "[]"); - return Array.isArray(arr) ? arr.filter((c) => c && typeof c.id === "number") : []; + // Accept legacy entries saved before case ids became strings (`id: number`) so + // existing bookmarks in a user's localStorage don't silently vanish. + if (!Array.isArray(arr)) return []; + return arr + .filter((c) => c && (typeof c.id === "number" || typeof c.id === "string")) + .map((c) => ({ ...c, id: String(c.id) })); } catch { return []; } @@ -38,7 +43,7 @@ const persistSavedCases = (list: SavedCase[]) => { } }; -export const isSavedCase = (id: number): boolean => loadSavedCases().some((c) => c.id === id); +export const isSavedCase = (id: string): boolean => loadSavedCases().some((c) => c.id === id); // Add the case if it isn't saved, otherwise remove it. Returns the updated list (most // recently saved first). diff --git a/PanTS-Demo/src/helpers/search.test.ts b/PanTS-Demo/src/helpers/search.test.ts index 29828cb..9a6f225 100644 --- a/PanTS-Demo/src/helpers/search.test.ts +++ b/PanTS-Demo/src/helpers/search.test.ts @@ -10,18 +10,23 @@ import { describe("itemToId", () => { it("parses the numeric id out of a PanTS case id", () => { - expect(itemToId({ case_id: "PanTS_00008854" })).toBe(8854); - expect(itemToId({ case_id: "PanTS_00000001" })).toBe(1); + expect(itemToId({ case_id: "PanTS_00008854" })).toBe("8854"); + expect(itemToId({ case_id: "PanTS_00000001" })).toBe("1"); }); it("falls back across id fields and handles numbers", () => { - expect(itemToId({ "PanTS ID": "PanTS_00000900" })).toBe(900); - expect(itemToId({ id: 42 })).toBe(42); + expect(itemToId({ "PanTS ID": "PanTS_00000900" })).toBe("900"); + expect(itemToId({ id: 42 })).toBe("42"); }); it("returns 0 when no usable id is present", () => { - expect(itemToId({})).toBe(0); - expect(itemToId({ case_id: "no-digits-here" })).toBe(0); + expect(itemToId({})).toBe("0"); + expect(itemToId({ case_id: "no-digits-here" })).toBe("0"); + }); + + it("keeps CancerVerse ids as-is, no offset/renumbering", () => { + expect(itemToId({ case_id: "CV_00000012" })).toBe("CV_00000012"); + expect(itemToId({ dataset: "CancerVerse", case_id: "CV_00000012" })).toBe("CV_00000012"); }); }); @@ -76,6 +81,7 @@ describe("parseFiltersFromParams", () => { ctPhase: ["Venous"], siteNat: ["US"], year: ["2020"], + dataset: ["CancerVerse"], }; const restored = parseFiltersFromParams(buildSearchParams(filters)); expect(restored).toEqual(filters); diff --git a/PanTS-Demo/src/helpers/search.ts b/PanTS-Demo/src/helpers/search.ts index ffb12ee..8712ae1 100644 --- a/PanTS-Demo/src/helpers/search.ts +++ b/PanTS-Demo/src/helpers/search.ts @@ -11,6 +11,7 @@ export type SearchFilters = { ctPhase: string[]; // CT phase, e.g. Arterial (from facets) siteNat: string[]; // site nationality, e.g. US (from facets) year: string[]; // study year (from facets) + dataset: string[]; // "PanTS" / "CancerVerse" (from facets) }; export const EMPTY_FILTERS: SearchFilters = { @@ -21,27 +22,32 @@ export const EMPTY_FILTERS: SearchFilters = { ctPhase: [], siteNat: [], year: [], + dataset: [], }; // The multi-select array keys (everything except `tumor`). -export type MultiFilterKey = "sex" | "age" | "manufacturer" | "ctPhase" | "siteNat" | "year"; +export type MultiFilterKey = "sex" | "age" | "manufacturer" | "ctPhase" | "siteNat" | "year" | "dataset"; // Minimal shape of an item returned by /api/search and /api/random. export type SearchItem = { case_id?: string | number; "PanTS ID"?: string | number; id?: string | number; + dataset?: "PanTS" | "CancerVerse"; tumor?: number | null; sex?: string | null; age?: number | string | null; }; -// Parse the numeric case id out of any of the id-ish fields, e.g. -// "PanTS_00008854" -> 8854. Returns 0 when nothing usable is present. -export const itemToId = (it: SearchItem): number => { +// Case identity as a string, e.g. "PanTS_00008854" -> "8854" (PanTS's existing bare- +// number id, unchanged) or "CV_00000012" -> "CV_00000012" (CancerVerse's real id, +// kept as-is — no offset/renumbering, so it stays identical to the folder name on +// disk). Returns "0" when nothing usable is present. +export const itemToId = (it: SearchItem): string => { const raw = String(it.case_id ?? it["PanTS ID"] ?? it.id ?? ""); + if (/^cv_?\d+$/i.test(raw.trim())) return raw.trim().toUpperCase(); const m = raw.match(/\d+/); - return m ? Number(m[0]) : 0; + return m ? String(Number(m[0])) : "0"; }; // Build the /api/search (and /api/facets, and URL) query string from the active @@ -61,6 +67,7 @@ export const buildSearchParams = ( (filters.ctPhase ?? []).forEach((v) => params.append("ct_phase[]", v)); (filters.siteNat ?? []).forEach((v) => params.append("site_nat[]", v)); (filters.year ?? []).forEach((v) => params.append("year[]", v)); + (filters.dataset ?? []).forEach((v) => params.append("dataset[]", v)); if (opts.sortBy) params.set("sort_by", opts.sortBy); if (opts.perPage) params.set("per_page", String(opts.perPage)); return params; @@ -79,6 +86,7 @@ export const parseFiltersFromParams = (params: URLSearchParams): SearchFilters = ctPhase: params.getAll("ct_phase[]"), siteNat: params.getAll("site_nat[]"), year: params.getAll("year[]"), + dataset: params.getAll("dataset[]"), }; }; @@ -89,4 +97,5 @@ export const countActiveFilters = (f: SearchFilters): number => f.manufacturer.length + f.ctPhase.length + f.siteNat.length + - f.year.length; + f.year.length + + f.dataset.length; diff --git a/PanTS-Demo/src/helpers/utils.test.ts b/PanTS-Demo/src/helpers/utils.test.ts index 61411b5..854898f 100644 --- a/PanTS-Demo/src/helpers/utils.test.ts +++ b/PanTS-Demo/src/helpers/utils.test.ts @@ -7,7 +7,9 @@ import { closestColorIndex, deepIsEqual, filenameToName, + getCaseDisplay, getPanTSId, + isCancerVerseId, prettify_segmentation_category, roundDigits, } from "./utils"; @@ -27,6 +29,19 @@ describe("case-ID formatting", () => { expect(cleanName("PanTS_00008854")).toBe("8854"); expect(cleanName("PanTS_00000001")).toBe("1"); }); + + it("isCancerVerseId recognizes CV-prefixed ids, not plain PanTS numbers", () => { + expect(isCancerVerseId("CV_00000012")).toBe(true); + expect(isCancerVerseId("cv12")).toBe(true); + expect(isCancerVerseId("17")).toBe(false); + expect(isCancerVerseId("PanTS_00000017")).toBe(false); + }); + + it("getCaseDisplay keeps PanTS's existing label and gives CancerVerse its real id, no offset", () => { + expect(getCaseDisplay("17")).toEqual({ dataset: "PanTS", label: "PanTS_00000017" }); + expect(getCaseDisplay("CV_00000012")).toEqual({ dataset: "CancerVerse", label: "CV_00000012" }); + expect(getCaseDisplay("cv12")).toEqual({ dataset: "CancerVerse", label: "CV_00000012" }); + }); }); describe("equality helpers", () => { diff --git a/PanTS-Demo/src/helpers/utils.ts b/PanTS-Demo/src/helpers/utils.ts index a0bd5f1..8cca5ef 100644 --- a/PanTS-Demo/src/helpers/utils.ts +++ b/PanTS-Demo/src/helpers/utils.ts @@ -99,6 +99,23 @@ export const getPanTSId = (case_id: string) => { return `PanTS_${new_id}`; } +// Whether a case id string identifies a CancerVerse case ("CV_00000012", or a bare +// "cv12" as typed by a user) rather than a PanTS case (a plain number, "17"). +// CancerVerse has no offset/renumbering — the id here is exactly the on-disk folder +// name, "CV_########". +export const isCancerVerseId = (case_id: string): boolean => /^cv_?\d+$/i.test(case_id.trim()); + +// Dataset-aware display label for a case id: PanTS keeps its existing +// `PanTS_<8-digit>` form (unchanged), CancerVerse cases show their real +// `CV_<8-digit>` folder name (no arithmetic — id "12" is CV_00000012's actual number). +export const getCaseDisplay = (case_id: string): { dataset: "PanTS" | "CancerVerse"; label: string } => { + const m = case_id.trim().match(/^cv_?(\d+)$/i); + if (m) { + return { dataset: "CancerVerse", label: `CV_${m[1].padStart(8, "0")}` }; + } + return { dataset: "PanTS", label: getPanTSId(case_id) }; +} + export function capitalize(word: string) { if (!word) return ""; return word.charAt(0).toUpperCase() + word.slice(1); diff --git a/PanTS-Demo/src/routes/ComparePage.tsx b/PanTS-Demo/src/routes/ComparePage.tsx index f244f81..2fc290d 100644 --- a/PanTS-Demo/src/routes/ComparePage.tsx +++ b/PanTS-Demo/src/routes/ComparePage.tsx @@ -10,6 +10,7 @@ import { alignStatRows } from "../helpers/compareStats"; import { API_BASE } from "../helpers/constants"; import { loadOrganNorms, type OrganNorms } from "../helpers/organNorms"; import { computeStatRows, type OrganMetric } from "../helpers/organStatsExport"; +import { getCaseDisplay } from "../helpers/utils"; import "./ComparePage.css"; type Demographics = { sex: string | null; age: number | null; tumor: number | null }; @@ -78,13 +79,15 @@ function useCaseData(id: string): CaseData { // (mirrors the dashboard Preview's chain). In dev/demo both endpoints 404 → placeholder. function Thumbnail({ id }: { id: string }) { const local = `${API_BASE}/api/get_image_preview/${id}`; - const caseIdStr = `PanTS_${id.toString().padStart(8, "0")}`; + const { dataset, label: caseIdStr } = getCaseDisplay(id); const hf = `${API_BASE}/api/proxy-image?url=${encodeURIComponent( `https://huggingface.co/datasets/BodyMaps/iPanTSMini/resolve/main/profile_only/${caseIdStr}/profile.jpg` )}`; + // CancerVerse has no HuggingFace profile-image mirror to fall back to. const [stage, setStage] = useState<0 | 1 | 2>(0); useEffect(() => setStage(0), [id]); - if (stage === 2) return
No preview
; + if (stage === 2 || (stage === 1 && dataset === "CancerVerse")) + return
No preview
; return ( ([]); + const [PREVIEW_IDS, SET_PREVIEW_IDS] = useState([]); const navigation = useNavigate(); const [previewMetadata, setPreviewMetadata] = useState<{ [key: string]: PreviewType; @@ -163,37 +164,39 @@ export default function Homepage() { }; }, []); - const handleToggleSave = (id: number, meta?: PreviewType) => { + const handleToggleSave = (id: string, meta?: PreviewType) => { const m = meta ?? previewMetadata[id]; toggleSavedCase({ id, sex: m?.sex ?? "", age: m?.age ?? 0, tumor: m?.tumor ?? 0 }); }; // Cases picked for side-by-side comparison (max 2). Adding a third drops the oldest, // so the two most recent picks are always what get compared. - const [compareIds, setCompareIds] = useState([]); + const [compareIds, setCompareIds] = useState([]); const [compareTyped, setCompareTyped] = useState(""); - const toggleCompare = (id: number) => { + const toggleCompare = (id: string) => { setCompareIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id].slice(-2) ); }; // Add a case by id typed into the tray (idempotent; caps at the two most recent). - const addCompareId = (id: number) => { + const addCompareId = (id: string) => { setCompareIds((prev) => (prev.includes(id) ? prev : [...prev, id].slice(-2))); }; + // The typed-compare box only takes plain numbers, i.e. PanTS ids — matches the + // jump-to-case box above, which is the same PanTS-only convenience. const submitTypedCompare = () => { const n = parseInt(compareTyped.trim(), 10); - if (Number.isFinite(n) && n > 0) addCompareId(n); + if (Number.isFinite(n) && n > 0) addCompareId(String(n)); setCompareTyped(""); }; // Turn /api/search (or /api/random) items into the ids + metadata the grid needs. const ingestItems = (items: SearchItem[]) => { - const ids: number[] = []; + const ids: string[] = []; const meta: { [key: string]: PreviewType } = {}; for (const it of items) { const id = itemToId(it); - if (!id) continue; + if (!id || id === "0") continue; ids.push(id); meta[id] = { sex: it.sex ?? "", @@ -272,7 +275,7 @@ export default function Homepage() { const loadFacetOptions = async () => { try { const params = new URLSearchParams(); - params.set("fields", "tumor,sex,manufacturer,ct_phase,site_nat,year"); + params.set("fields", "tumor,sex,manufacturer,ct_phase,site_nat,year,dataset"); params.set("top_k", "8"); const res = await fetch(`${API_BASE}/api/facets?${params.toString()}`); const data = await res.json(); diff --git a/PanTS-Demo/src/routes/VisualizationPage.css b/PanTS-Demo/src/routes/VisualizationPage.css index 09a15a1..1c3cb7b 100644 --- a/PanTS-Demo/src/routes/VisualizationPage.css +++ b/PanTS-Demo/src/routes/VisualizationPage.css @@ -1370,6 +1370,23 @@ text-align: right; } +/* ---- Report notes panel — free-text radiology report (CancerVerse), shares the + .vp-stats dock shell like the metadata/stats panels above. ---- */ +.vp-report__body { + overflow-y: auto; + padding: 12px 16px 16px; + font-family: var(--vp-font); + font-size: 13px; + line-height: 1.55; + color: var(--vp-text); + white-space: pre-wrap; +} +.vp-report__body::-webkit-scrollbar { width: 8px; } +.vp-report__body::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.12); + border-radius: 99px; +} + /* ---- Floating organ tooltip ---- */ .vp-organ-tip { position: fixed; diff --git a/PanTS-Demo/src/routes/VisualizationPage.tsx b/PanTS-Demo/src/routes/VisualizationPage.tsx index 8a49953..f03cb07 100644 --- a/PanTS-Demo/src/routes/VisualizationPage.tsx +++ b/PanTS-Demo/src/routes/VisualizationPage.tsx @@ -18,6 +18,7 @@ import { IconClick, IconDownload, IconEye, + IconFileText, IconFlipHorizontal, IconGrid3x3, IconHome, @@ -130,7 +131,7 @@ import { type SessionResult, } from "../helpers/readingSession"; import { toolDisplayName, type ReportMeasurement } from "../helpers/sessionReport"; -import { filenameToName, getPanTSId } from "../helpers/utils"; +import { filenameToName, getPanTSId, isCancerVerseId } from "../helpers/utils"; import { decodeViewerState, encodeViewerState } from "../helpers/viewerShareState"; import { type CheckBoxData } from "../types"; import "./VisualizationPage.css"; @@ -316,6 +317,20 @@ function VisualizationPage() { const isLocal = isDicom || isLocalNifti; const [dicomError, setDicomError] = useState(null); + // CancerVerse: a real backend case (unlike isLocal), but CT-only — no + // segmentations/meshes exist for it. Segmentation-dependent tools stay visible but + // rendered faded/disabled (fadedProps below) rather than hidden or silently broken. + const isCancerVerse = !isLocal && isCancerVerseId(pantsCase ?? ""); + const FADED_STYLE = { opacity: 0.4, pointerEvents: "none" as const, cursor: "not-allowed" }; + const fadedProps = isCancerVerse + ? { style: FADED_STYLE, title: "Not available — CancerVerse has no segmentation data", "aria-disabled": true } + : {}; + // The inverse: report notes only exist for CancerVerse (free-text radiology reports + // from its metadata CSV) — fade the tool for every other dataset/mode. + const reportNotesFadedProps = !isCancerVerse + ? { style: FADED_STYLE, title: "Not available — no report notes for this dataset", "aria-disabled": true } + : {}; + // Where to load the volumes from. Per the maintainer's rule, dataset cases load // from the lab's LOCAL endpoints (served off disk on the JHU server — much faster // for big full-body scans than streaming the .nii.gz from HuggingFace). We probe @@ -340,6 +355,14 @@ function VisualizationPage() { setSegUrl(`${API_BASE}/api/session-segmentation/${sessionId}`); return; } + if (isCancerVerse) { + // CT-only: no masks, no HuggingFace mirror, no low-res tier generated for + // this dataset — always full res straight from the local backend. + setLocalAvailable(false); + setCtUrl(`${API_BASE}/api/get-main-nifti/${pantsCase}.nii.gz`); + setSegUrl(null); + return; + } const id = pantsCase ?? "1"; const p = getPanTSId(id); const localCt = `${API_BASE}/api/get-main-nifti/${id}.nii.gz`; @@ -358,7 +381,7 @@ function VisualizationPage() { }; resolveSources(); return () => { cancelled = true; }; - }, [pantsCase, sessionId, isHd, isLocal]); + }, [pantsCase, sessionId, isHd, isLocal, isCancerVerse]); // Flip between low-res and full-res by reloading the route — a fresh mount cleanly // re-inits the Cornerstone/NiiVue contexts (re-running them in place is fragile). @@ -436,6 +459,9 @@ function VisualizationPage() { const [showMetadata, setShowMetadata] = useState(false); const [caseMetadata, setCaseMetadata] = useState | null>(null); const demographicsTriedRef = useRef(false); + // Free-text radiology report notes — CancerVerse-only (comes from caseMetadata.report, + // same /api/search row PanTS cases just don't carry that field on). + const [showReportNotes, setShowReportNotes] = useState(false); // Measured download progress for the loading screen (from the nifti loader's real // bytes-loaded/total — accurate, not a guess). const [dlPct, setDlPct] = useState(null); @@ -494,7 +520,7 @@ function VisualizationPage() { useEffect(() => { checkBoxDataRef.current = checkBoxData; }, [checkBoxData]); // 3D pane rendering mode: organ meshes (dataset cases) or shaded GPU volume // rendering of the CT itself (the only 3D option for local DICOM). - const [threeDMode, setThreeDMode] = useState<"mesh" | "volume">(isLocal ? "volume" : "mesh"); + const [threeDMode, setThreeDMode] = useState<"mesh" | "volume">(isLocal || isCancerVerse ? "volume" : "mesh"); const [volumePreset, setVolumePreset] = useState(VOLUME_3D_PRESETS[0].name); // CT presets by default; swapped for the MR set when a local DICOM turns out to be MR. const [volume3DPresets, setVolume3DPresets] = useState(VOLUME_3D_PRESETS); @@ -774,6 +800,7 @@ function VisualizationPage() { } else if (key === "m") { setShowStats(false); setShowMetadata(false); + setShowReportNotes(false); setShowEditPanel(false); setEditMode(null); setShowMeasurePanel((v) => !v); @@ -1020,7 +1047,9 @@ function VisualizationPage() { if ( !ctUrl || - !segUrl || + // CancerVerse cases never get a segUrl (no masks exist) — that's expected, + // not a "still loading" state, so it doesn't block rendering the CT. + (!segUrl && !isCancerVerse) || !axial_ref.current || !sagittal_ref.current || !coronal_ref.current || @@ -1037,7 +1066,7 @@ function VisualizationPage() { coronal_ref.current, cmap, ctUrl, - segUrl, + segUrl ?? undefined, setLoading ); @@ -1081,6 +1110,7 @@ function VisualizationPage() { segUrl, isDicom, isLocalNifti, + isCancerVerse, axial_ref, sagittal_ref, coronal_ref, @@ -1262,7 +1292,7 @@ function VisualizationPage() { // Group-level "something inside is active" flags, so each collapsed toolbar dropdown // still visually reflects its contents' state without having to be open. const viewGroupActive = hoverIdentifyEnabled || referenceLinesOn; - const panelsGroupActive = showOrganDetails || showStats || showMetadata || showMeasurePanel; + const panelsGroupActive = showOrganDetails || showStats || showMetadata || showReportNotes || showMeasurePanel; // The Layout ▾ trigger shows the pane-layout preset's name when one is active // (it's the more specific choice), otherwise the current view mode. @@ -1574,8 +1604,9 @@ function VisualizationPage() { }; const handleToggleStats = () => { - // The right-side slot is shared by stats / metadata / measurements / mask editing. + // The right-side slot is shared by stats / metadata / report notes / measurements / mask editing. setShowMetadata(false); + setShowReportNotes(false); setShowMeasurePanel(false); setShowEditPanel(false); setEditMode(null); @@ -1586,6 +1617,7 @@ function VisualizationPage() { const handleToggleMetadata = () => { setShowStats(false); + setShowReportNotes(false); setShowMeasurePanel(false); setShowEditPanel(false); setEditMode(null); @@ -1593,6 +1625,18 @@ function VisualizationPage() { loadPercentileContext(); }; + // Report notes only ever has data for CancerVerse cases (see reportNotesFadedProps), + // but the toggle itself just reuses the same shared right-side slot + metadata fetch. + const handleToggleReportNotes = () => { + setShowStats(false); + setShowMetadata(false); + setShowMeasurePanel(false); + setShowEditPanel(false); + setEditMode(null); + setShowReportNotes((v) => !v); + loadPercentileContext(); + }; + const handleToggleAISidebar = () => { const opening = !showAISidebar; @@ -1601,6 +1645,7 @@ function VisualizationPage() { if (opening) { setShowStats(false); setShowMetadata(false); + setShowReportNotes(false); setShowMeasurePanel(false); setShowEditPanel(false); setEditMode(null); @@ -2091,7 +2136,9 @@ const aiAvailableOrgans = useMemo(() => { className={`vp-flyout__item ${hoverIdentifyEnabled ? "is-active" : ""}`} role="menuitem" title="Name the organ under the cursor" + {...fadedProps} onClick={() => { + if (isCancerVerse) return; setHoverIdentifyEnabled((v) => !v); setHoverOrganTip((t) => (t.visible ? { ...t, visible: false } : t)); viewFlyout.close(); @@ -2217,10 +2264,13 @@ const aiAvailableOrgans = useMemo(() => { {!isLocal && ( )} + @@ -2850,7 +2922,9 @@ const aiAvailableOrgans = useMemo(() => {
{METADATA_FIELDS.map(({ key, label }, i) => (
- {label} + + {key === "PanTS ID" && caseMetadata.dataset === "CancerVerse" ? "CancerVerse ID" : label} + {formatMetaValue(key, caseMetadata[key])} @@ -2861,6 +2935,34 @@ const aiAvailableOrgans = useMemo(() => {
)} + {showReportNotes && ( +
+
+ Report Notes + +
+ {!isCancerVerse ? ( +
+ Report notes are only available for CancerVerse cases. +
+ ) : !caseMetadata ? ( +
+ {demographicsTriedRef.current ? "No report notes available for this case." : "Loading…"} +
+ ) : !caseMetadata.report ? ( +
No report notes available for this case.
+ ) : ( +
{String(caseMetadata.report)}
+ )} +
+ )} + {showMeasurePanel && ( setShowMeasurePanel(false)} diff --git a/README.md b/README.md index 33c570d..9928b00 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ BASE_PATH=/ PANTS_PATH=/folder/where/PanTS +# Enables Cancerverse +# Points at the folder containing CV_######## case dirs directly. +CANCERVERSE_PATH=/folder/where/CancerVerse/CancerVerse + USE_SSL=false ``` @@ -68,6 +72,7 @@ git pull ``` If `git pull` (or the checkout) refuses because of "local changes would be overwritten," someone edited files directly on the server. Do **not** force past it. Run `git status` to see what changed, then discard each file with `git checkout -- ` (or ask the maintainer) before pulling again. The server should never carry local edits. + #### 2. Rebuild the frontend and refresh backend dependencies ``` cd /home/visitor/PanTS-Viewer/PanTS-Demo && npm ci && npm run build diff --git a/flask-server/api/api_blueprint.py b/flask-server/api/api_blueprint.py index d74fd89..73f3dc4 100644 --- a/flask-server/api/api_blueprint.py +++ b/flask-server/api/api_blueprint.py @@ -195,14 +195,37 @@ def proxy_image(): def _arg(name: str, default=None): return request.args.get(name, default) +def _cancerverse_preview_entry(formatted_id): + """sex/age/tumor for a CancerVerse card, from the merged DF (CancerVerse has no + positional metadata.xlsx cache like PanTS's _METADATA_CACHE, so this looks the + row up directly by its exact case-id string -- safe/unique since CV_######## ids + don't collide with PanTS's).""" + match = DF[DF.get("__case_str", "") == formatted_id] + if not len(match): + return {"sex": "", "age": "", "tumor": 0} + row = match.iloc[0] + tumor = row.get("__tumor01") + return { + "sex": row.get("__sex") or "", + "age": row.get("__age") if pd.notna(row.get("__age")) else "", + "tumor": int(tumor) if pd.notna(tumor) else 0, + } + + @api_blueprint.route('/get_preview/', methods=['GET']) def get_preview(clabel_ids): clabel_ids = clabel_ids.split(",") res = {} for clabel_id in clabel_ids: - pid = get_panTS_id(clabel_id) - entry = _METADATA_CACHE.get(pid, {"sex": "", "age": "", "tumor": 0}) - res[clabel_id] = entry + try: + dataset, formatted_id = resolve_dataset_case(clabel_id) + except ValueError: + continue + if dataset == "CancerVerse": + res[clabel_id] = _cancerverse_preview_entry(formatted_id) + else: + entry = _METADATA_CACHE.get(formatted_id, {"sex": "", "age": "", "tumor": 0}) + res[clabel_id] = entry return jsonify(res) # if not preloaded @@ -210,12 +233,19 @@ def get_preview(clabel_ids): def get_image_preview(clabel_id): if not _is_safe_id(clabel_id): return jsonify({"error": "Invalid id"}), 400 - path = os.path.join(Constants.PANTS_PATH, "profile_only", get_panTS_id(secure_filename(clabel_id)), "profile.jpg") + try: + dataset, formatted_id = resolve_dataset_case(secure_filename(clabel_id)) + except ValueError: + return jsonify({"error": "Invalid id"}), 400 + if dataset == "CancerVerse": + # No profile/thumbnail images exist for CancerVerse cases. + return jsonify({"error": "No preview image for this dataset"}), 404 + path = os.path.join(Constants.PANTS_PATH, "profile_only", formatted_id, "profile.jpg") if not os.path.exists(path): return jsonify({"error": f"File not found: {path} "}), 404 return send_file( path, - mimetype="image/jpg", + mimetype="image/jpg", as_attachment=False, download_name=f"{clabel_id}_slice.jpg" ) @@ -348,17 +378,28 @@ def get_mask_data(): def get_main_nifti(clabel_id): if not _is_safe_id(clabel_id): return jsonify({"error": "Invalid id"}), 400 - case_dir = f"{Constants.PANTS_PATH}/image_only/{get_panTS_id(secure_filename(clabel_id))}" - main_nifti_path = f"{case_dir}/{Constants.MAIN_NIFTI_FILENAME}" + try: + dataset, formatted_id = resolve_dataset_case(secure_filename(clabel_id)) + except ValueError: + return jsonify({"error": "Invalid id"}), 400 - # ?res=low → serve the precomputed low-res copy when present (much smaller/faster - # for big full-body scans). It lives under LOWRES_ROOT (a writable disk), NOT the - # read-only dataset mount. Falls back to full res if it hasn't been generated. - if (request.args.get('res') or '').strip().lower() == 'low': - low_name = Constants.MAIN_NIFTI_FILENAME.replace('.nii.gz', '_lowres.nii.gz') - low_path = f"{LOWRES_ROOT}/image_only/{get_panTS_id(secure_filename(clabel_id))}/{low_name}" - if os.path.exists(low_path): - main_nifti_path = low_path + if dataset == "CancerVerse": + # CT-only dataset: no image_only/ split, no low-res copy generated -- ?res=low + # is simply a no-op here (always full res). + case_dir = f"{Constants.CANCERVERSE_PATH}/{formatted_id}" + main_nifti_path = f"{case_dir}/{Constants.MAIN_NIFTI_FILENAME}" + else: + case_dir = f"{Constants.PANTS_PATH}/image_only/{formatted_id}" + main_nifti_path = f"{case_dir}/{Constants.MAIN_NIFTI_FILENAME}" + + # ?res=low → serve the precomputed low-res copy when present (much smaller/faster + # for big full-body scans). It lives under LOWRES_ROOT (a writable disk), NOT the + # read-only dataset mount. Falls back to full res if it hasn't been generated. + if (request.args.get('res') or '').strip().lower() == 'low': + low_name = Constants.MAIN_NIFTI_FILENAME.replace('.nii.gz', '_lowres.nii.gz') + low_path = f"{LOWRES_ROOT}/image_only/{formatted_id}/{low_name}" + if os.path.exists(low_path): + main_nifti_path = low_path if os.path.exists(main_nifti_path): response = make_response(send_file(main_nifti_path, mimetype='application/gzip')) @@ -1715,6 +1756,7 @@ def _facet_counts_with_unknown(df: pd.DataFrame, col_key: str, top_k: int = 6) - "study_type": ("study_type", str), "site_nat": ("site_nationality", str), "site_nationality": ("site_nationality", str), + "dataset": ("dataset", str), } if col_key not in key_to_col: return {"rows": [], "unknown": 0} @@ -1791,7 +1833,7 @@ def api_facets(): valid = { "ct_phase","manufacturer","year","sex","tumor", - "model","study_type","site_nat","site_nationality" + "model","study_type","site_nat","site_nationality","dataset" } fields = [f for f in fields if f in valid] or ["ct_phase","manufacturer"] top_k = to_int(_arg("top_k","6")) or 6 @@ -1815,6 +1857,7 @@ def api_facets(): "study_type": {"study_type"}, "site_nat": {"site_nat","site_nationality"}, "site_nationality": {"site_nat","site_nationality"}, + "dataset": {"dataset"}, } for f in fields: diff --git a/flask-server/api/utils.py b/flask-server/api/utils.py index ace931a..7ca3e7e 100644 --- a/flask-server/api/utils.py +++ b/flask-server/api/utils.py @@ -53,9 +53,21 @@ def get_panTS_id(index): iter = max(0, 8 - len(index_str)) for _ in range(iter): cur_case_id = "0" + cur_case_id - cur_case_id = "PanTS_" + cur_case_id + cur_case_id = "PanTS_" + cur_case_id return cur_case_id +def resolve_dataset_case(raw_id): + """Identify which dataset a case id belongs to and normalize it to its on-disk + folder name. PanTS ids are bare digits ("17" -> "PanTS_00000017", unchanged + behavior). CancerVerse ids are already the real folder name ("CV_00000012", + case-insensitive / optional underscore) -- no offset or renumbering, so the id + a user sees is exactly the id used to look the case up on disk.""" + s = str(raw_id).strip() + m = re.fullmatch(r"(?i)cv_?(\d+)", s) + if m: + return "CancerVerse", f"CV_{int(m.group(1)):08d}" + return "PanTS", get_panTS_id(s) + def clean_nan(obj): """Recursively replace NaN with None for JSON serialization.""" if isinstance(obj, dict): @@ -1129,6 +1141,60 @@ def _find_col(prefer, keyword_sets=None): return df +def _load_cancerverse_metadata() -> Optional[pd.DataFrame]: + """CancerVerse's own metadata CSV, read directly (no derived file) and filtered + down to whichever CV_######## case folders actually exist under CANCERVERSE_PATH + right now. Downloading more cases later just means more rows show up next + restart -- nothing here needs regenerating. Columns are renamed to the exact + names PanTS's metadata.xlsx already uses (sex/age/ct phase/manufacturer/study + year) so _norm_cols picks them up with zero extra detection logic; columns with + no CancerVerse equivalent (tumor?, study type, site nationality) are simply left + absent, so those fields come back unknown/NaN after the concat below. The CSV's + free-text radiology report is carried through as "report" -- surfaced in the + viewer's "Report notes" panel, a CancerVerse-only feature (PanTS rows never get + this column, so it's simply absent/None for them after the concat). + """ + root = Constants.CANCERVERSE_PATH + if not root or not os.path.isdir(root): + return None + csv_path = os.path.join(os.path.dirname(root), "CancerVerse_dataset_metadata.csv") + if not os.path.exists(csv_path): + return None + try: + present = { + name for name in os.listdir(root) + if re.fullmatch(r"(?i)cv_\d+", name) and os.path.isdir(os.path.join(root, name)) + } + if not present: + return None + raw = pd.read_csv(csv_path) + raw = raw[raw["CancerVerse ID"].isin(present)].copy() + if not len(raw): + return None + + def _prettify_phase(v): + if pd.isna(v) or not str(v).strip(): + return np.nan + return str(v).strip().replace("_", " ").title() + + def _year_from_date(v): + dt = pd.to_datetime(v, errors="coerce") + return dt.year if pd.notna(dt) else np.nan + + return pd.DataFrame({ + "case_id": raw["CancerVerse ID"], + "sex": raw.get("sex"), + "age": raw.get("age"), + "ct phase": raw["phase"].map(_prettify_phase) if "phase" in raw else np.nan, + "manufacturer": raw.get("scanner"), + "study year": raw["exam_date"].map(_year_from_date) if "exam_date" in raw else np.nan, + "report": raw.get("report"), + "dataset": "CancerVerse", + }) + except Exception: + return None + + def _safe_float(x) -> Optional[float]: try: if x is None: return None @@ -1214,6 +1280,12 @@ def ensure_sort_cols(df: pd.DataFrame) -> pd.DataFrame: if not os.path.exists(META_FILE): raise FileNotFoundError(f"metadata not found: {META_FILE}") DF_RAW = pd.read_excel(META_FILE) +DF_RAW["dataset"] = "PanTS" + +_cancerverse_df = _load_cancerverse_metadata() +if _cancerverse_df is not None and len(_cancerverse_df): + DF_RAW = pd.concat([DF_RAW, _cancerverse_df], ignore_index=True, sort=False) + DF = _norm_cols(DF_RAW) def apply_filters(base: pd.DataFrame, exclude: Optional[Set[str]] = None) -> pd.DataFrame: @@ -1409,6 +1481,11 @@ def apply_filters(base: pd.DataFrame, exclude: Optional[Set[str]] = None) -> pd. df = df[mask] + # --- Dataset (PanTS / CancerVerse) --- + ds_list = _collect_list_params(["dataset", "dataset[]"]) + if ds_list and "dataset" in df.columns and "dataset" not in exclude: + wants = {d.strip().lower() for d in ds_list if d.strip()} + df = df[df["dataset"].astype(str).str.strip().str.lower().isin(wants)] return df @@ -1425,6 +1502,7 @@ def pick(k, fallback=None): return { "PanTS ID": _nan2none(pick("case") or row.get("__case_str")), "case_id": _nan2none(pick("case") or row.get("__case_str")), + "dataset": _nan2none(row.get("dataset")) or "PanTS", "tumor": (int(row.get("__tumor01")) if pd.notna(row.get("__tumor01")) else None), "sex": _nan2none(row.get("__sex")), "age": _nan2none(row.get("__age")), @@ -1434,6 +1512,7 @@ def pick(k, fallback=None): "study year": _nan2none(row.get("__year_int")), "study type": _nan2none(pick("study_type") or row.get("study_type")), "site nationality": _nan2none(pick("site_nationality") or row.get("site_nationality")), + "report": _nan2none(row.get("report")), # 排序輔助輸出 "spacing_sum": _nan2none(row.get("__spacing_sum")), "shape_sum": _nan2none(row.get("__shape_sum")), diff --git a/flask-server/constants.py b/flask-server/constants.py index b2dc8d2..7cb2962 100644 --- a/flask-server/constants.py +++ b/flask-server/constants.py @@ -19,6 +19,10 @@ class Constants: # api_blueprint variables BASE_PATH = os.environ.get('BASE_PATH', '/') PANTS_PATH = os.environ.get('PANTS_PATH') + # CancerVerse: CT-only cases (no segmentations/meshes), a second dataset browsable + # alongside PanTS. Points at the folder that directly contains CV_######## case + # dirs (mirrors how PANTS_PATH points at the folder containing image_only/). + CANCERVERSE_PATH = os.environ.get('CANCERVERSE_PATH') PERMISSIONS_DIR = os.environ.get('PERMISSIONS_DIR', "/home/visitor/data") MESH_PATH = PERMISSIONS_DIR + "/render_only" MAIN_NIFTI_FORM_NAME = 'MAIN_NIFTI'