Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 32 additions & 0 deletions CancerVerse/download_partial_cancerverse.sh
Original file line number Diff line number Diff line change
@@ -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
9 changes: 6 additions & 3 deletions PanTS-Demo/src/components/Preview.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand Down
45 changes: 30 additions & 15 deletions PanTS-Demo/src/helpers/savedCases.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
11 changes: 8 additions & 3 deletions PanTS-Demo/src/helpers/savedCases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// (cross-tab) to stay in sync.

export type SavedCase = {
id: number;
id: string;
sex: string;
age: number;
tumor: number;
Expand All @@ -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 [];
}
Expand All @@ -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).
Expand Down
18 changes: 12 additions & 6 deletions PanTS-Demo/src/helpers/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});

Expand Down Expand Up @@ -76,6 +81,7 @@ describe("parseFiltersFromParams", () => {
ctPhase: ["Venous"],
siteNat: ["US"],
year: ["2020"],
dataset: ["CancerVerse"],
};
const restored = parseFiltersFromParams(buildSearchParams(filters));
expect(restored).toEqual(filters);
Expand Down
21 changes: 15 additions & 6 deletions PanTS-Demo/src/helpers/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -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[]"),
};
};

Expand All @@ -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;
15 changes: 15 additions & 0 deletions PanTS-Demo/src/helpers/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
closestColorIndex,
deepIsEqual,
filenameToName,
getCaseDisplay,
getPanTSId,
isCancerVerseId,
prettify_segmentation_category,
roundDigits,
} from "./utils";
Expand All @@ -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", () => {
Expand Down
17 changes: 17 additions & 0 deletions PanTS-Demo/src/helpers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions PanTS-Demo/src/routes/ComparePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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 <div className="cmp-thumb cmp-thumb--empty">No preview</div>;
if (stage === 2 || (stage === 1 && dataset === "CancerVerse"))
return <div className="cmp-thumb cmp-thumb--empty">No preview</div>;
return (
<img
className="cmp-thumb"
Expand Down
Loading
Loading