diff --git a/PanTS-Demo/package-lock.json b/PanTS-Demo/package-lock.json index c2b8c005..97f84b13 100644 --- a/PanTS-Demo/package-lock.json +++ b/PanTS-Demo/package-lock.json @@ -20,6 +20,7 @@ "@tabler/icons-react": "^3.34.1", "@tailwindcss/vite": "^4.1.12", "esbuild": "^0.25.9", + "framer-motion": "^12.42.2", "jszip": "^3.10.1", "pako": "^3.0.1", "pdfjs-dist": "^6.2.108", @@ -8169,6 +8170,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", + "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.42.2", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -9437,6 +9465,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/motion-dom": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", + "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -10901,6 +10944,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tunnel-rat": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", diff --git a/PanTS-Demo/package.json b/PanTS-Demo/package.json index d465e2bd..1b2b506d 100644 --- a/PanTS-Demo/package.json +++ b/PanTS-Demo/package.json @@ -25,6 +25,7 @@ "@tabler/icons-react": "^3.34.1", "@tailwindcss/vite": "^4.1.12", "esbuild": "^0.25.9", + "framer-motion": "^12.42.2", "jszip": "^3.10.1", "pako": "^3.0.1", "pdfjs-dist": "^6.2.108", diff --git a/PanTS-Demo/src/App.tsx b/PanTS-Demo/src/App.tsx index 521db449..5ee401da 100644 --- a/PanTS-Demo/src/App.tsx +++ b/PanTS-Demo/src/App.tsx @@ -30,6 +30,7 @@ const AnalyticsSettings = lazy(() => import("./routes/Settings/AnalyticsSettings const PeopleSettings = lazy(() => import("./routes/Settings/PeopleSettings")); const SignupRedirect = lazy(() => import("./routes/SignupRedirect")); const LegalPage = lazy(() => import("./routes/LegalPage")); +const SharePatientCard = lazy(() => import("./routes/SharePatientCard")); const RotatingHeartLoader = lazy(() => import("./components/Loading")); const BASENAME = import.meta.env.VITE_BASENAME; @@ -97,6 +98,7 @@ function App() { /> } /> } /> + } /> } @@ -152,4 +154,4 @@ function App() { ); } -export default App; +export default App; \ No newline at end of file diff --git a/PanTS-Demo/src/components/ReportScreen/ReportScreen.tsx b/PanTS-Demo/src/components/ReportScreen/ReportScreen.tsx index 530488cd..0c732f5a 100644 --- a/PanTS-Demo/src/components/ReportScreen/ReportScreen.tsx +++ b/PanTS-Demo/src/components/ReportScreen/ReportScreen.tsx @@ -34,7 +34,7 @@ interface ReportData { type Lang = 'patient' | 'clinical'; type Step = number; -const cache: { [k: string]: ReportData } = {}; +export const cache: { [key: string]: ReportData } = {}; // ─── Styles ─────────────────────────────────────────────────────────────────── @@ -80,10 +80,15 @@ function getDetail(organ: string, comments: string): string | null { function organRoot(organ: string): string { if (organ.startsWith('pancreas')) return 'pancreas'; if (organ.startsWith('kidney')) return 'kidney'; + // BUG FIX: this used to end with .split(' ')[0], which truncated every + // multi-word organ down to its first word — "adrenal_gland_right" became + // just "adrenal", "common_bile_duct" became just "common". That broke + // section-heading lookup (the report heading wouldn't match a truncated + // root) and produced mangled patient sentences ("...in your adrenal."). + // Only the trailing location/anatomy-suffix word should ever be stripped. return organ .replace(/_(gland|body|tail|head|left|right)$/, '') .replace(/_/g, ' ') - .split(' ')[0] .toLowerCase(); } @@ -96,12 +101,18 @@ function getReportSection(organ: string, comments: string): string | null { return cleaned === root || cleaned === `${root}s` || cleaned.startsWith(`${root}:`); }); if (start === -1) return getDetail(organ, comments); - const collected: string[] = []; + let lesionsHeadingSeen = false; for (let i = start; i < lines.length; i++) { const line = lines[i]; const trimmed = line.trim(); - if (i > start && /^[A-Za-z][A-Za-z\s_/-]*:\s*$/.test(trimmed)) break; + const cleanedHeading = trimmed.replace(/:$/, '').toLowerCase(); + if (i > start && cleanedHeading === `${root} lesions`) { + lesionsHeadingSeen = true; + continue; + } + if (i > start && !lesionsHeadingSeen && /^[A-Za-z][A-Za-z\s_/-]*:\s*$/.test(trimmed)) break; + if (i > start && lesionsHeadingSeen && /^[A-Za-z][A-Za-z\s_/-]*:\s*$/.test(trimmed) && !cleanedHeading.startsWith(root)) break; if (i > start && /^IMPRESSION:\s*$/i.test(trimmed)) break; if (trimmed) collected.push(trimmed); } @@ -111,26 +122,108 @@ function getReportSection(organ: string, comments: string): string | null { type ReportMeasurements = { section: string | null; volumeCc: number | null; + lesionVolumeCc: number | null; + organVolumeCc: number | null; meanHu: number | null; + organMeanHu: number | null; huSd: number | null; sizeCm: string | null; + lesionCount: number; }; function getReportMeasurements(organ: string, comments: string): ReportMeasurements { const section = getReportSection(organ, comments); - const volumeMatch = section?.match(/volume:\s*([\d.]+)\s*cc/i); - const huMatch = section?.match(/Mean HU value:\s*([\d.]+)(?:\s*\+\/\-\s*([\d.]+))?/i); - const sizeMatch = section?.match(/Size:\s*([^().]+?)\s*cm/i); + // Prefer the lesion's own numbers over the organ's baseline stats when a + // lesion block is present in this section, since those matter more clinically. + const lesionVolumeMatch = section?.match(/lesion[\s\S]*?volume:\s*([\d.]+)\s*cc/i); + const lesionHuMatch = section?.match(/hu\s*value\s*is\s*(-?[\d.]+)(?:\s*\+\/\-\s*([\d.]+))?/i); + const volumeMatch = lesionVolumeMatch ?? section?.match(/volume:\s*([\d.]+)\s*cc/i); + const huMatch = lesionHuMatch ?? section?.match(/Mean HU value:\s*([\d.]+)(?:\s*\+\/\-\s*([\d.]+))?/i); + // BUG FIX: the size capture excluded '.' from its own character class, so it + // could never match decimal sizes like "1.0 x 0.5 cm" — only whole numbers. + // That silently broke "Report size" for virtually every real lesion. + const sizeMatch = section?.match(/Size:\s*([^()]+?)\s*cm/i); + + // Organ-level baseline stats (what the report states for the whole organ, + // e.g. "Pancreas: Normal size (volume: 9.0 cc). Mean HU value: 8.4 +/- 29.6.") + // — deliberately NOT lesion-preferred, since the metrics card needs these + // distinct from the lesion's own (and often much smaller, or relative-to- + // organ) numbers. "Mean HU value:" only ever appears for the organ baseline; + // the lesion's enhancement line reads "HU value is X", a different phrase, + // so this regex can't accidentally pick up the lesion's number. + const organVolumeMatch = section?.match(/volume:\s*([\d.]+)\s*cc/i); + const organHuMatch = section?.match(/Mean HU value:\s*([\d.]+)/i); + + // Each distinct lesion in a report section carries its own "Size: ... cm" line, + // so counting those is a reasonable proxy for lesion count without the backend + // needing to add a dedicated field. + const sizeMatches = section?.match(/Size:\s*[^()]+?cm/gi) ?? []; + const lesionCount = sizeMatches.length || (lesionVolumeMatch ? 1 : 0); return { section, volumeCc: volumeMatch ? Number(volumeMatch[1]) : null, + lesionVolumeCc: lesionVolumeMatch ? Number(lesionVolumeMatch[1]) : null, + organVolumeCc: organVolumeMatch ? Number(organVolumeMatch[1]) : null, meanHu: huMatch ? Number(huMatch[1]) : null, + organMeanHu: organHuMatch ? Number(organHuMatch[1]) : null, huSd: huMatch?.[2] ? Number(huMatch[2]) : null, sizeCm: sizeMatch ? sizeMatch[1].trim() : null, + lesionCount, }; } +// Suffix on organ keys like "pancreas_tail" / "kidney_left" already encodes the +// anatomical location the backend split out — reuse it instead of re-parsing +// the report text for location. Paired organs (left/right) read better as +// "your left kidney"; sub-regions of a single organ (head/body/tail) read +// better as "in the tail of your pancreas" — so these are kept distinct +// rather than forced through one phrasing template. +function organLocation(organ: string): { type: 'lateral' | 'subregion'; word: string } | null { + const suffix = organ.split('_').pop() ?? ''; + if (suffix === 'left' || suffix === 'right') return { type: 'lateral', word: suffix }; + if (suffix === 'tail' || suffix === 'head' || suffix === 'body') return { type: 'subregion', word: suffix }; + return null; +} + +// Rough qualitative size bucket from whichever number we have — used only to +// pick a plain-language adjective, not for any clinical claim. +function sizeDescriptor(volumeCc: number | null, sizeCm: string | null): string { + let maxDim: number | null = null; + if (sizeCm) { + const nums = sizeCm.match(/[\d.]+/g)?.map(Number) ?? []; + if (nums.length) maxDim = Math.max(...nums); + } + if (maxDim !== null) { + if (maxDim < 1) return 'tiny'; + if (maxDim < 2) return 'small'; + if (maxDim < 5) return 'noticeable'; + return 'sizable'; + } + if (volumeCc !== null) { + if (volumeCc < 1) return 'tiny'; + if (volumeCc < 5) return 'small'; + if (volumeCc < 20) return 'noticeable'; + return 'sizable'; + } + return ''; +} + +// Returns null (deliberately) when the text doesn't actually describe a +// lesion/mass — some organs get flagged purely on a numeric HU-range check +// with no lesion mentioned anywhere in the report text, and it previously +// defaulted to "spot" regardless, inventing a finding the text never stated. +function findingNoun(detail: string): string | null { + const d = detail.toLowerCase(); + if (d.includes('cyst')) return 'fluid-filled spot'; + if (d.includes('nodule')) return 'small bump'; + if (d.includes('mass') || d.includes('tumor')) return 'growth'; + if (d.includes('enlarged')) return 'enlarged area'; + if (d.includes('dilated') || d.includes('widened')) return 'widened area'; + if (d.includes('lesion')) return 'spot'; + return null; +} + function getImpressionText(data: ReportData | null): string { if (!data?.impression?.length) return ''; return data.impression @@ -138,15 +231,43 @@ function getImpressionText(data: ReportData | null): string { .join(' '); } -function patientFindingText(organ: string, detail: string | null): string { - const name = labelize(organ).toLowerCase(); - const d = (detail || '').toLowerCase(); +function capFirst(s: string): string { + return s.length ? s[0].toUpperCase() + s.slice(1) : s; +} - if (d.includes('enlarged')) return `The report says the ${name} appears enlarged.`; - if (d.includes('mass') || d.includes('lesion') || d.includes('tumor')) return `The report found a finding near the ${name}.`; - if (d.includes('widened') || d.includes('dilated')) return `The report says the ${name} appears widened.`; - if (d.includes('normal size')) return `The report mentions the ${name}.`; - return `The report found something in the ${name} that should be reviewed.`; +// Turns the parsed report measurements into a real plain-language sentence +// instead of generic keyword-matched boilerplate — e.g. "The scan found a +// small spot (1.0 x 0.5 cm) in the tail of your pancreas." Falls back to an +// honest, still-specific sentence when the report text doesn't describe an +// actual lesion (e.g. flagged purely on an HU-range anomaly) — it never +// invents "a spot" or similar when none is described. +function patientFindingText(organ: string, measurements: ReportMeasurements): string { + const organLabel = labelize(organRoot(organ)).toLowerCase(); + const loc = organLocation(organ); + const subject = + loc?.type === 'lateral' ? `your ${loc.word} ${organLabel}` + : loc?.type === 'subregion' ? `the ${loc.word} of your ${organLabel}` + : `your ${organLabel}`; + const detail = measurements.section || ''; + + if (!detail) { + return `The scan flagged ${subject} for your doctor to review — the report text wasn't specific enough to describe here.`; + } + + const noun = findingNoun(detail); + if (!noun) { + // Flagged, but the text doesn't describe an actual lesion/mass — don't + // invent one. Most common cause: flagged on an HU-range check, not a + // described finding. + return `${capFirst(subject)} was flagged for review, but the report doesn't describe a specific spot or growth — ask your doctor what stood out.`; + } + + const sizeWord = sizeDescriptor(measurements.lesionVolumeCc ?? measurements.volumeCc, measurements.sizeCm); + const sizePart = measurements.sizeCm ? ` (${measurements.sizeCm} cm)` : ''; + const article = sizeWord ? `a ${sizeWord} ${noun}` : `a ${noun}`; + const countPart = measurements.lesionCount > 1 ? `${measurements.lesionCount} spots` : article; + + return `The scan found ${countPart}${sizePart} in ${subject}.`; } // ─── Small UI pieces ────────────────────────────────────────────────────────── @@ -260,6 +381,66 @@ function MetricRow({ label, value, sub, tone = 'white' }: { label: string; value ); } +function Badge({ tone, children }: { tone: 'amber' | 'green'; children: React.ReactNode }) { + const color = tone === 'amber' ? '#fbbf24' : '#6ee7b7'; + const bg = tone === 'amber' ? 'rgba(251,191,36,0.14)' : 'rgba(110,231,183,0.12)'; + const border = tone === 'amber' ? 'rgba(251,191,36,0.32)' : 'rgba(110,231,183,0.28)'; + return ( + + {children} + + ); +} + +function MetricLine({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +// Structured doctor-view card: labeled metric rows + a review badge, in place +// of dumping the raw report-comments string. Deliberately sources HU/volume +// from the report's organ-baseline numbers (report.organMeanHu/organVolumeCc) +// rather than curData — curData can be a small anatomical sub-label (e.g. +// "pancreas_tail") whose own segmented mask is tiny, which previously showed +// as a misleading "0 HU / 0 cc" even though the organ itself had real values. +function OrganMetricsCard({ + curData, + report, + needsReview, +}: { + curData: OrganData; + report: ReportMeasurements; + needsReview: boolean; +}) { + const meanHu = report.organMeanHu ?? curData.mean_hu; + const organVolume = report.organVolumeCc ?? curData.volume; + return ( +
+
+
+ Organ Metrics +
+ {needsReview && NEEDS REVIEW} +
+ + + + +
+ ); +} + function EvidencePanel({ step, lang, @@ -281,6 +462,7 @@ function EvidencePanel({ }) { const firstFinding = flagged[0]?.[0] ?? null; const firstDetail = firstFinding ? getDetail(firstFinding, data.comments) : null; + const firstMeasurements = firstFinding ? getReportMeasurements(firstFinding, data.comments) : null; const impression = getImpressionText(data); const report = curOrgan ? getReportMeasurements(curOrgan, data.comments) : null; const reportVolume = report?.volumeCc ?? null; @@ -304,7 +486,7 @@ function EvidencePanel({

{lang === 'patient' - ? patientFindingText(firstFinding!, firstDetail) + ? patientFindingText(firstFinding!, firstMeasurements!) : (firstDetail || impression || 'See the report finding for details.')}

@@ -326,7 +508,6 @@ function EvidencePanel({ } if (step >= 2 && curOrgan && curData) { - const detail = getDetail(curOrgan, data.comments); return (
@@ -335,18 +516,8 @@ function EvidencePanel({ - {reportVolume !== null ? ( - - ) : ( - - )} - - {lang === 'clinical' && report?.meanHu !== null && report?.meanHu !== undefined && ( - + {lang === 'clinical' && report && ( + )} {lang === 'clinical' && report?.sizeCm && ( @@ -361,19 +532,19 @@ function EvidencePanel({ /> )} - {lang === 'clinical' && ( -
-
Report text
-

- {report?.section || detail || impression || 'No report detail available.'} -

-
- )} + {/* Full report-text paragraph deliberately omitted here — it's the exact + same string already shown in the left story panel (medLocal), so + showing it again just duplicated the same paragraph on screen. */} {lang === 'patient' && ( -

- This panel shows the key measurement from the report. Your doctor can explain what it means for you. -

+ <> + {reportVolume !== null && ( + + )} +

+ This panel shows the key measurement from the report. Your doctor can explain what it means for you. +

+ )}
); @@ -403,6 +574,14 @@ export default function ReportScreen({ id, onClose, onViewChange, onOrganHighlig const [modePromptOpen, setModePromptOpen] = useState(false); const [plain2, setPlain2] = useState([]); const [pLoad, setPLoad] = useState(false); + const [shareOpen, setShareOpen] = useState(false); + const [copied, setCopied] = useState(false); + // De-identified share link, minted on demand (see mintShareLink below) — + // this used to be a raw `${API_ORIGIN}/api/report/${id}` string built + // straight from the real case id. That exposed the real id in the URL and + // skipped the token system the rest of the app now uses for sharing. + const [shareUrl, setShareUrl] = useState(null); + const [shareLoading, setShareLoading] = useState(false); const startRef = useRef(Date.now()); useEffect(() => { @@ -419,6 +598,56 @@ export default function ReportScreen({ id, onClose, onViewChange, onOrganHighlig .catch(() => setLoading(false)); }, [id]); + // Reset any previously-minted link when the case changes, so a stale + // token for a different case can never be shown/copied. + useEffect(() => { + setShareUrl(null); + }, [id]); + + // Mints (or re-derives — the backend token is deterministic per case id) + // an opaque share token and builds the link to the new de-identified + // /share/:token card. Safe to call repeatedly; no-ops if already minted + // or in flight. + const mintShareLink = useCallback(async () => { + if (shareUrl || shareLoading) return; + setShareLoading(true); + try { + const r = await fetch(`${APP_CONSTANTS.API_ORIGIN}/api/share/${id}/token`, { method: 'POST' }); + const j = await r.json(); + const token = typeof j.url === 'string' ? j.url.split('/').pop() : null; + if (token) setShareUrl(`${window.location.origin}/share/${token}`); + } catch (e) { + console.error('Failed to create share link:', e); + } finally { + setShareLoading(false); + } + }, [id, shareUrl, shareLoading]); + + useEffect(() => { + if (!shareOpen) return; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setShareOpen(false); }; + const onClick = () => setShareOpen(false); + document.addEventListener('keydown', onKey); + // Deferred so the same click that opened the popover doesn't immediately close it. + const t = setTimeout(() => document.addEventListener('click', onClick), 0); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('click', onClick); + clearTimeout(t); + }; + }, [shareOpen]); + + const handleCopyShareLink = async () => { + if (!shareUrl) return; + try { + await navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (e) { + console.error('Copy failed:', e); + } + }; + const fetchPlain = useCallback(async () => { if (plain2.length || !data) return; setPLoad(true); @@ -440,7 +669,7 @@ export default function ReportScreen({ id, onClose, onViewChange, onOrganHighlig setStep(s); }, [step]); - const all = React.useMemo(() => data ? Object.entries(data.organ_volumes).filter(([_, v]) => v.volume > 5) : [], [data]); + const all = React.useMemo(() => data ? Object.entries(data.organ_volumes).filter(([_, v]) => v.volume > 5 || v.status === 'check') : [], [data]); const flagged = React.useMemo(() => all.filter(([_, v]) => v.status === 'check'), [all]); const normal = React.useMemo(() => all.filter(([_, v]) => v.status !== 'check'), [all]); const totalSteps = 2 + flagged.length + 1; @@ -464,8 +693,9 @@ export default function ReportScreen({ id, onClose, onViewChange, onOrganHighlig if (!data) return null; const curOrganLocal = step >= 2 && step < 2 + flagged.length ? flagged[step - 2]?.[0] : null; const curDataLocal = step >= 2 && step < 2 + flagged.length ? flagged[step - 2]?.[1] : null; - const medLocal = curOrganLocal ? getDetail(curOrganLocal, data.comments) : null; - const patientLocal = curOrganLocal ? patientFindingText(curOrganLocal, medLocal) : ''; + const medLocal = curOrganLocal ? getReportSection(curOrganLocal, data.comments) : null; + const measurementsLocal = curOrganLocal ? getReportMeasurements(curOrganLocal, data.comments) : null; + const patientLocal = curOrganLocal && measurementsLocal ? patientFindingText(curOrganLocal, measurementsLocal) : ''; const impressionText = getImpressionText(data); if (step === 0) return ( @@ -638,6 +868,67 @@ export default function ReportScreen({ id, onClose, onViewChange, onOrganHighlig
)} +
+ + + {shareOpen && ( +
e.stopPropagation()} + style={{ + position: 'absolute', top: 'calc(100% + 10px)', right: 0, zIndex: 20000, + width: 340, background: '#141518', border: '1px solid rgba(255,255,255,0.14)', + borderRadius: 14, padding: 16, boxShadow: '0 18px 60px rgba(0,0,0,0.5)', + }}> +
+ Share this link with anyone — a family member, your doctor, whoever needs it. It opens a + de-identified, readable summary of this scan. +
+
+
+ {shareLoading ? 'Generating link…' : (shareUrl || 'Link unavailable')} +
+ +
+
+ )} +
+ + + + {/* Big navy advertising footer — JHU shield, BodyMaps, link, and + the QR code all together, replacing the smaller de-identified + text block that used to sit above it. */} + +
+ Johns Hopkins University { (e.target as HTMLImageElement).style.display = 'none'; }} + /> +
+
+
BodyMaps
+
bodymaps.wse.jhu.edu
+
+
+
+ QR code to this BodyMap +
+
+
+ )} + + + ); +} + +function GearIcon() { + return ; +} +function CubeIcon() { + return ; +} +function PulseIcon() { + return ; +} +function DocIcon() { + return ; +} +function ShareIcon() { + return ; +} + +// ─── Style tokens ─────────────────────────────────────────────────────────── + +const page: React.CSSProperties = { + minHeight: '100vh', width: '100%', background: '#FBFCFE', + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: '36px 20px', fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", +}; + +const stage: React.CSSProperties = { width: '100%', maxWidth: CARD_MAX }; + +const loadingWrap: React.CSSProperties = { display: 'flex', justifyContent: 'center', padding: '80px 0' }; +const spinnerRing: React.CSSProperties = { width: 22, height: 22, borderRadius: '50%', border: `2px solid ${HAIRLINE}`, borderTopColor: NAVY, animation: 'spin 0.8s linear infinite' }; + +const emptyState: React.CSSProperties = { textAlign: 'center', padding: '44px 24px', border: `1px solid ${HAIRLINE}`, borderRadius: 16, background: '#fff' }; +const emptyStateTitle: React.CSSProperties = { fontSize: 14.5, fontWeight: 600, color: MUTED }; + +const card: React.CSSProperties = { + background: '#ffffff', borderRadius: 18, border: `1px solid ${HAIRLINE}`, + boxShadow: '0 12px 32px rgba(20,38,92,0.08)', overflow: 'hidden', +}; +const cardBody: React.CSSProperties = { padding: '24px 26px 6px' }; + +const brandLockup: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 10, textDecoration: 'none', marginBottom: 14 }; +const brandMark: React.CSSProperties = { width: 30, height: 30, borderRadius: 8, background: NAVY, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }; +const brandName: React.CSSProperties = { fontFamily: "'Poppins', sans-serif", fontWeight: 700, fontSize: 19, color: NAVY, lineHeight: 1.15 }; +const brandSub: React.CSSProperties = { fontSize: 11, color: MUTED, fontWeight: 500 }; + +const eyebrow: React.CSSProperties = { fontSize: 10.5, fontWeight: 750, letterSpacing: '0.1em', color: SPIRIT_TEXT }; +const statLineWrap: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 10, marginTop: 5 }; +const statAccentBar: React.CSSProperties = { width: 4, height: 24, borderRadius: 2, flexShrink: 0 }; +const statLine: React.CSSProperties = { fontSize: 26, fontWeight: 700, fontFamily: "'Poppins', sans-serif" }; +const subStatLine: React.CSSProperties = { fontSize: 13, color: MUTED, marginTop: 6, marginBottom: 4, marginLeft: 14 }; + +const chipRow: React.CSSProperties = { display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 14 }; +const chip = (active: boolean): React.CSSProperties => ({ + fontSize: 11.5, fontWeight: 650, padding: '5px 11px', borderRadius: 999, + background: active ? NAVY : '#F1F4F9', color: active ? '#fff' : MUTED, +}); + +const panel: React.CSSProperties = { marginTop: 14, padding: '18px 20px', borderRadius: 14, background: PANEL, border: `1px solid ${HAIRLINE}` }; +const panelInner: React.CSSProperties = { display: 'flex', gap: 18, flexWrap: 'wrap' }; +const organImageSlot: React.CSSProperties = { + width: 140, height: 140, borderRadius: 12, flexShrink: 0, + background: '#fff', border: `1px dashed ${HAIRLINE}`, +}; +const panelLabel: React.CSSProperties = { fontSize: 10, fontWeight: 750, letterSpacing: '0.08em', color: SPIRIT_TEXT }; +const organNameBig: React.CSSProperties = { fontFamily: "'Poppins', sans-serif", fontSize: 26, fontWeight: 700, color: INK, marginTop: 4, marginBottom: 10 }; +const findingBadge: React.CSSProperties = { + display: 'inline-flex', alignItems: 'center', fontSize: 12, fontWeight: 700, color: AMBER, background: AMBER_BG, + padding: '5px 12px', borderRadius: 999, border: '1px solid #EAD2A6', +}; + +const sectionLabel: React.CSSProperties = { fontSize: 10.5, fontWeight: 750, letterSpacing: '0.08em', color: SPIRIT_TEXT, marginTop: 20, marginBottom: 8 }; +const measureCardsRow: React.CSSProperties = { display: 'flex', gap: 10 }; +const measureCard: React.CSSProperties = { flex: 1, padding: '13px 14px', borderRadius: 12, background: PANEL, border: `1px solid ${HAIRLINE}` }; +const measureIconWrap: React.CSSProperties = { width: 26, height: 26, borderRadius: 8, background: '#E8F0FB', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 8 }; +const measureLabel: React.CSSProperties = { fontSize: 10, fontWeight: 700, color: SPIRIT_TEXT, marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.02em' }; +const measureValue: React.CSSProperties = { fontFamily: "'Poppins', sans-serif", fontSize: 19, fontWeight: 700, color: INK }; +const measureSub: React.CSSProperties = { fontSize: 10.5, color: '#9AA5B8', marginTop: 2 }; + +const impressionPanel: React.CSSProperties = { + display: 'flex', gap: 14, marginTop: 14, padding: '18px 20px', + background: '#EFF4FB', border: `1px solid ${HAIRLINE}`, borderRadius: 14, +}; +const impressionIconWrap: React.CSSProperties = { width: 32, height: 32, borderRadius: 9, background: SPIRIT_TEXT, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginTop: 1 }; +const impressionLabel: React.CSSProperties = { fontSize: 11, fontWeight: 750, letterSpacing: '0.08em', color: SPIRIT_TEXT, marginBottom: 7 }; +const impressionText: React.CSSProperties = { fontSize: 17, lineHeight: 1.5, color: INK, margin: 0, fontWeight: 500 }; + +const hairline: React.CSSProperties = { height: 1, background: HAIRLINE, margin: '16px 0 12px' }; + +const actionsRow: React.CSSProperties = { display: 'flex', justifyContent: 'center', marginBottom: 16 }; +const shareInlineBtn: React.CSSProperties = { + display: 'inline-flex', alignItems: 'center', padding: '13px 28px', borderRadius: 999, border: 'none', + color: '#fff', fontSize: 14, fontWeight: 700, letterSpacing: '0.01em', cursor: 'pointer', fontFamily: 'inherit', +}; + +// Bigger, single "advertising" footer — replaces both the old small +// de-identified text block and the thin bottom bar with one prominent band. +const adBar: React.CSSProperties = { + display: 'flex', alignItems: 'center', justifyContent: 'space-between', + padding: '22px 28px', background: NAVY, textDecoration: 'none', transition: 'background 0.2s', +}; +const adLeft: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: 16 }; +const adShield: React.CSSProperties = { height: 40, width: 'auto' }; +const adDivider: React.CSSProperties = { width: 1, height: 34, background: 'rgba(255,255,255,0.22)' }; +const adTitle: React.CSSProperties = { fontFamily: "'Poppins', sans-serif", fontSize: 16, fontWeight: 700, color: '#fff' }; +const adUrl: React.CSSProperties = { fontSize: 12, color: '#AFC2E8', marginTop: 2, fontWeight: 600 }; +const adQrWrap: React.CSSProperties = { padding: 5, background: '#fff', borderRadius: 10, flexShrink: 0 }; +const adQrImg: React.CSSProperties = { display: 'block', borderRadius: 4 }; diff --git a/PanTS-Demo/src/routes/VisualizationPage.tsx b/PanTS-Demo/src/routes/VisualizationPage.tsx index 9fef53c8..50542ea7 100644 --- a/PanTS-Demo/src/routes/VisualizationPage.tsx +++ b/PanTS-Demo/src/routes/VisualizationPage.tsx @@ -45,6 +45,7 @@ import { track } from "../helpers/analytics"; import { buildViewerActions } from "../components/AIAssistant/assistantActions"; import MeasurementPanel from "../components/MeasurementPanel/MeasurementPanel"; import { SegmentationMeshViewer } from "../components/viewer/MeshViewer"; +import { cache as reportDataCache } from '../components/ReportScreen/ReportScreen'; import { captureMeshCanvas } from "../helpers/viewer/meshCapture"; import OrganCheckbox from "../components/OrganCheckbox"; import PercentileBar from "../components/PercentileBar"; @@ -412,6 +413,8 @@ function useToolbarFlyout() { }; }, [open]); + + return { open, pos, groupRef, btnRef, menuRef, toggle, close }; } @@ -528,6 +531,21 @@ function VisualizationPage() { typeof window !== "undefined" && new URLSearchParams(window.location.search).get("hd") === "1"; + useEffect(() => { + if (isDicom || !caseId || reportDataCache[caseId]) return; + // Deferred ~2s so this doesn't compete with the CT/segmentation load for + // server CPU and disk I/O right when the viewer first opens — the scan + // is what the user needs immediately; the report data just needs to be + // warm in the cache well before anyone would actually click "Report." + const t = setTimeout(() => { + fetch(`${API_BASE}/api/get-report-data/${caseId}`) + .then(r => r.json()) + .then(j => { if (!j.error) reportDataCache[caseId] = j; }) + .catch(() => {}); + }, 2000); + return () => clearTimeout(t); + }, [caseId, isDicom]); + const [showAnnotationToolbar, setShowAnnotationToolbar] = useState(false); const [isEditRendering, setIsEditRendering] = useState(false); // Mirrors SegmentsPopup's "something is currently being deleted" state up @@ -2434,7 +2452,16 @@ function VisualizationPage() { // flow doesn't reflow the other three — their viewports stay valid, so switching back // to MPR is instant (no resize/re-fit of the 2D views, no animation, correct sizes). if (viewMode === "3d") { - return panel === "3d" ? { position: "absolute", inset: 0, zIndex: 20 } : {}; + if (panel === "3d") return { position: "absolute", inset: 0, zIndex: 20 }; + // Keep the pane mounted at its normal grid size (no display:none — that's + // what preserves the "instant, no resize" swap back to MPR described above), + // but strip it from paint and hit-testing entirely. Previously this branch + // left these panes fully visible/interactive and relied on the 3D pane's + // z-index to visually cover them — but the slice scrollbar/counter overlays + // (rendered by renderPaneOverlays, see below) carry their own z-index for + // sitting above the Cornerstone canvas, which could equal or exceed the 3D + // pane's z-index:20 and bleed through on top of it. + return { visibility: "hidden", pointerEvents: "none" }; } // 2D single view: collapse the grid to one cell and hide the rest. return viewMode === panel ? {} : { display: "none" }; @@ -2461,6 +2488,9 @@ function VisualizationPage() { // and mixing React-rendered children into the same node risks the two fighting over // the same DOM nodes. const renderPaneOverlays = (pane: CinePane) => { + // Never show 2D slice-counter/W-L overlays while the 3D pane is fullscreen — + // these are the elements that were bleeding through on top of the 3D render. + if (viewMode === "3d") return null; const info = sliceInfo[pane]; return ( <> @@ -3570,14 +3600,18 @@ const aiAvailableOrgans = useMemo(() => { )} {!isLocal && ( - + )} {/* HD and AI stay inline: HD is a live status indicator (streaming %), diff --git a/flask-server/api/api_blueprint.py b/flask-server/api/api_blueprint.py index 54c9e1a9..1741f863 100644 --- a/flask-server/api/api_blueprint.py +++ b/flask-server/api/api_blueprint.py @@ -1,6 +1,5 @@ -from flask import Blueprint, send_file, make_response, request, jsonify, Response, stream_with_context +from flask import Blueprint, send_file, make_response, request, jsonify, Response, current_app, stream_with_context from werkzeug.utils import secure_filename -from services.nifti_processor import NiftiProcessor from services.session_manager import SessionManager, generate_uuid from services.auto_segmentor import run_auto_segmentation, cancel_session, cancel_all_inference from services.mesh_generation import bake_case_meshes, generate_mesh_manifest, generate_organ_glb_bytes, LABELS as MESH_LABELS @@ -68,6 +67,7 @@ LOWRES_ROOT = os.environ.get("PANTS_LOWRES_PATH", "/home/visitor/pants_lowres") import hmac +from itsdangerous import URLSafeSerializer, BadSignature import threading # Session/case ids come straight from client requests and are joined into @@ -84,33 +84,53 @@ def _metadata_xlsx_path(): # metadata.xlsx ships either at the root of PANTS_PATH or under data/ # depending on the checkout/deployment; use whichever exists. for candidate in ( - os.path.join(Constants.PANTS_PATH, "metadata.xlsx"), os.path.join(Constants.PANTS_PATH, "data", "metadata.xlsx"), + os.path.join(Constants.PANTS_PATH, "metadata.xlsx"), ): if os.path.exists(candidate): return candidate return None +def _norm_colname(s): + import re + return re.sub(r'[^a-z0-9]', '', str(s).lower()) + + def _load_metadata_cache(): try: xlsx_path = _metadata_xlsx_path() if not xlsx_path: return {} df = pd.read_excel(xlsx_path, engine="openpyxl") + # Column-NAME lookup instead of position. This used to be + # row.iloc[0]/[4]/[5]/[13], which reads whatever happens to sit in + # those positions — silently wrong if the sheet's column order ever + # changes, same failure mode as OncoKit's hardcoded iloc indices. + # Matches by normalized name (case/whitespace/punctuation-insensitive) + # against the real header: "PanTS ID", "sex", "age", "tumor?". + col_map = {_norm_colname(c): c for c in df.columns} + pid_col = col_map.get(_norm_colname("PanTS ID")) or df.columns[0] + sex_col = col_map.get(_norm_colname("sex")) + age_col = col_map.get(_norm_colname("age")) + tumor_col = col_map.get(_norm_colname("tumor")) # matches "tumor?" too + cache = {} for _, row in df.iterrows(): - pid = str(row.iloc[0]) + pid = str(row[pid_col]) cache[pid] = { - "sex": row.iloc[4] if pd.notna(row.iloc[4]) else "", - "age": row.iloc[5] if pd.notna(row.iloc[5]) else "", - "tumor": int(row.iloc[13]) if pd.notna(row.iloc[13]) else 0, + "sex": row[sex_col] if sex_col and pd.notna(row[sex_col]) else "", + "age": row[age_col] if age_col and pd.notna(row[age_col]) else "", + "tumor": int(row[tumor_col]) if tumor_col and pd.notna(row[tumor_col]) else 0, } return cache except Exception: return {} _METADATA_CACHE = _load_metadata_cache() +_REPORT_DATA_CACHE = {} # {case_id: report_data_dict} — avoids reloading/recomputing + # the full CT+mask volume on every report/PDF request +progress_tracker = {} # {session_id: (start_time, expected_total_seconds)} # Lazy cache of {PanTS id: (contrast, study_detail)} for /get-report-data. # The old code re-loaded the entire ~10k-row workbook (non-read-only!) on @@ -127,12 +147,28 @@ def _report_study_meta(pid): if xlsx_path: wb = load_workbook(xlsx_path, read_only=True, data_only=True) sheet = wb["PanTS_metadata"] if "PanTS_metadata" in wb.sheetnames else wb.active - for row in sheet.iter_rows(min_row=2, values_only=True): + rows_iter = sheet.iter_rows(min_row=1, values_only=True) + header = next(rows_iter, None) or () + # Derived from the real header row ("ct phase", "study type") + # rather than hardcoded row[3]/row[8] — if columns get + # reordered upstream, this re-resolves correctly on next load + # instead of silently reading the wrong field. + header_map = {_norm_colname(h): i for i, h in enumerate(header) if h is not None} + ct_phase_idx = header_map.get(_norm_colname("ct phase")) + study_type_idx = header_map.get(_norm_colname("study type")) + for row in rows_iter: if row and row[0]: - cache[str(row[0])] = ( - row[3] if len(row) > 3 and row[3] is not None else "", - row[8] if len(row) > 8 and row[8] is not None else "", + contrast = ( + row[ct_phase_idx] + if ct_phase_idx is not None and ct_phase_idx < len(row) and row[ct_phase_idx] is not None + else "" ) + study_detail = ( + row[study_type_idx] + if study_type_idx is not None and study_type_idx < len(row) and row[study_type_idx] is not None + else "" + ) + cache[str(row[0])] = (contrast, study_detail) wb.close() except Exception as e: print(f"[report meta] metadata load failed: {e}") @@ -654,19 +690,22 @@ def define_term(): }), 200 -@api_blueprint.route('/get-report-data/', methods=['GET']) -def get_report_data(id): - # CancerVerse has no masks/RadGPT report yet — respond gracefully. +def _build_report_data(id): + """Gathers everything the report needs (RadGPT text, organ volumes/status/ + centroid/dimensions, lesions) into a plain dict. Shared by the JSON + endpoint and the PDF-generation endpoint so both show identical data.""" + # CancerVerse has no masks/RadGPT report yet -- respond gracefully. if get_dataset_from_case_id(secure_filename(str(id))) == "CancerVerse": - return jsonify({"masks_available": False}), 200 + return {"masks_available": False} if id is None or not str(id).isdigit(): - return jsonify({"error": "Invalid id parameter"}), 400 + return {"error": "Invalid id parameter"} case_id = int(id) + id = str(case_id) + + if id in _REPORT_DATA_CACHE: + return _REPORT_DATA_CACHE[id] + try: - id = str(case_id) - # ── Try RadGPT structured report from metadata.xlsx first ───────────── - # This uses Zongwei Zhou's own RadGPT model output — more accurate - # than Ollama-generated impressions. Falls back to Ollama if not found. radgpt_comments = None radgpt_impression = None try: @@ -684,9 +723,7 @@ def get_report_data(id): row_id = str(row[id_col] or '').strip() if row_id == pants_id: raw = str(row[report_col] or '') - # Clean Windows carriage return artifacts raw = raw.replace('_x000D_', '\n').replace('\r\n', '\n').replace('\r', '\n') - # Collapse multiple blank lines import re as _re raw = _re.sub(r'\n{3,}', '\n\n', raw) findings_match = re.search(r'FINDINGS:(.*?)(?=IMPRESSION:|$)', raw, re.DOTALL) @@ -695,7 +732,6 @@ def get_report_data(id): radgpt_comments = findings_match.group(1).strip() if impression_match: imp_text = impression_match.group(1).strip() - # Keep full impression, split into sentences sentences = [s.strip() for s in re.split(r'(?<=[.!?])\s+', imp_text) if s.strip()] radgpt_impression = sentences if sentences else [imp_text] print(f"[RadGPT] Found report for case {id}: {radgpt_impression}") @@ -703,26 +739,23 @@ def get_report_data(id): wb.close() except Exception as e: print(f"[RadGPT] metadata lookup failed: {e}") - # ───────────────────────────────────────────────────────────────────── + subfolder = "ImageTr" if case_id < 9000 else "ImageTe" label_subfolder = "LabelTr" if case_id < 9000 else "LabelTe" - # Check image_only first (new structure), fall back to data/ImageTr image_only_path = f"{Constants.PANTS_PATH}/image_only/{get_panTS_id(case_id)}/{Constants.MAIN_NIFTI_FILENAME}" data_ct_path = f"{Constants.PANTS_PATH}/data/{subfolder}/{get_panTS_id(case_id)}/{Constants.MAIN_NIFTI_FILENAME}" ct_path = image_only_path if os.path.exists(image_only_path) else data_ct_path - # Check mask_only first (new structure), fall back to data/LabelTe mask_only_path = f"{Constants.PANTS_PATH}/mask_only/{get_panTS_id(case_id)}/{Constants.COMBINED_LABELS_NIFTI_FILENAME}" data_mask_path = f"{Constants.PANTS_PATH}/data/{label_subfolder}/{get_panTS_id(case_id)}/{Constants.COMBINED_LABELS_NIFTI_FILENAME}" mask_path = mask_only_path if os.path.exists(mask_only_path) else data_mask_path - seg_dir = f"{Constants.PANTS_PATH}/data/{label_subfolder}/{get_panTS_id(case_id)}/segmentations" - + seg_dir = f"{Constants.PANTS_PATH}/mask_only/{get_panTS_id(case_id)}/segmentations" pid = get_panTS_id(case_id) meta = _METADATA_CACHE.get(pid, {}) age = meta.get("age", "N/A") sex = meta.get("sex", "N/A") - + contrast, study_detail = _report_study_meta(pid) - + # If local files don't exist, download from HuggingFace if not os.path.exists(ct_path) or not os.path.exists(mask_path): import requests, tempfile @@ -745,46 +778,27 @@ def get_report_data(id): with open(mask_path, 'wb') as f: for chunk in r.iter_content(chunk_size=8192): f.write(chunk) - + ct_nii = nib.load(ct_path) spacing = ct_nii.header.get_zooms() shape = ct_nii.shape ct_array = ct_nii.get_fdata() mask_nii = nib.load(mask_path) mask_array = mask_nii.get_fdata().astype(np.uint8) - # Crop both arrays to the minimum shape along each axis - # to handle slight size mismatches between CT and mask min_shape = tuple(min(c, m) for c, m in zip(ct_array.shape, mask_array.shape)) ct_array = ct_array[:min_shape[0], :min_shape[1], :min_shape[2]] mask_array = mask_array[:min_shape[0], :min_shape[1], :min_shape[2]] voxel_volume = np.prod(mask_nii.header.get_zooms()) / 1000 - # World-space affine - converts a voxel index (i, j, k) to real - # millimeter coordinates. This is what makes the centroid below a - # REAL position usable by moveCornerstoneCrosshairToMm, rather - # than a placeholder. affine = mask_nii.affine - + LABELS = {v: k for k, v in Constants.PREDEFINED_LABELS.items()} - - # Soft physiological sanity ranges per organ type, used only to - # flag "this needs review" vs "looks normal" internally. NOT - # shown to the user as raw numbers - the frontend only ever sees - # the `status` field. This is a stopgap for a known upstream - # segmentation/data issue where some organs read in the air range; - # rather than silently showing a wrong number as fact, or trying - # to "correct" the data here, we flag it so the UI can say - # "needs review" instead of presenting a confident wrong reading. - SOLID_ORGAN_HU_RANGE = (-20, 150) # liver, spleen, kidney, pancreas, etc. - GI_HOLLOW_ORGAN_HU_RANGE = (-300, 200) # colon, stomach, intestine, duodenum - tightened from - # -1000 so a mean this close to pure air (e.g. -756 for - # colon) correctly flags "check" instead of "normal" - - # a real colon has enough wall/stool tissue that a mean - # in the deep-air range usually signals a segmentation - # issue, not a genuinely normal reading. - LUNG_HU_RANGE = (-1000, -200) # lungs are genuinely air-filled - this range is correct as-is + + SOLID_ORGAN_HU_RANGE = (-20, 150) + GI_HOLLOW_ORGAN_HU_RANGE = (-300, 200) + LUNG_HU_RANGE = (-1000, -200) GI_HOLLOW_ORGANS = {"colon", "stomach", "intestine", "duodenum"} LUNG_ORGANS = {"lung_left", "lung_right"} - + organ_volumes = {} NO_FLAG_ORGANS = { "femur_left", "femur_right", "aorta", "postcava", "veins", @@ -799,7 +813,7 @@ def get_report_data(id): continue volume = float(np.sum(mask) * voxel_volume) mean_hu = float(np.mean(ct_array[mask])) - + if organ in LUNG_ORGANS: lo, hi = LUNG_HU_RANGE elif organ in GI_HOLLOW_ORGANS: @@ -807,85 +821,107 @@ def get_report_data(id): else: lo, hi = SOLID_ORGAN_HU_RANGE status = "normal" if organ in NO_FLAG_ORGANS else ("check" if (mean_hu < lo or mean_hu > hi) else "normal") - - # Real centroid: voxel-space center of mass, converted to mm - # via the affine. This is genuine anatomical position - not a - # placeholder - and feeds the same crosshair-navigation - # plumbing already used elsewhere (moveCornerstoneCrosshairToMm - # / moveNiiVueCrosshairToMm) for click-to-jump. + voxel_coords = np.argwhere(mask) - centroid_voxel = voxel_coords.mean(axis=0) # (i, j, k) in voxel space + centroid_voxel = voxel_coords.mean(axis=0) centroid_world = nib.affines.apply_affine(affine, centroid_voxel) - - # Bounding box dimensions in cm (real physical size of the organ) + bbox_min = voxel_coords.min(axis=0) bbox_max = voxel_coords.max(axis=0) bbox_voxels = bbox_max - bbox_min + 1 - # Convert voxel counts to mm using spacing, then to cm spacing_mm = np.abs([affine[0,0], affine[1,1], affine[2,2]]) dims_mm = bbox_voxels * spacing_mm dims_cm = [round(float(d)/10, 1) for d in dims_mm] - + organ_volumes[organ] = { - "volume": round(volume, 2), - "mean_hu": round(mean_hu, 1), + "volume": round(float(volume), 2), + "mean_hu": round(float(mean_hu), 1), "status": status, "centroid_mm": [round(float(c), 2) for c in centroid_world], "dimensions": dims_cm, } - + lesions = {} lesion_files = { - "pancreas": "pancreatic_lesion.npz", - "liver": "liver_lesion.npz", - "kidney": "kidney_lesion.npz", + "pancreas": "pancreatic_lesion.nii.gz", + "liver": "liver_lesion.nii.gz", + "kidney": "kidney_lesion.nii.gz", } for organ, filename in lesion_files.items(): path = os.path.join(seg_dir, filename) if os.path.exists(path): - data = np.load(path)["data"] - voxels = int(np.sum(data > 0)) + lesion_data = nib.load(path).get_fdata() + voxels = int(np.sum(lesion_data > 0)) if voxels > 0: lesion_volume = round(voxels * voxel_volume, 2) - lesions[organ] = {"voxels": voxels, "volume": lesion_volume} - - organ_data_str = "" - for organ, vals in organ_volumes.items(): - organ_data_str += f"{organ.replace('_', ' ')}: volume={vals['volume']}cc, mean HU={vals['mean_hu']}\n" - - # If we have a RadGPT report, it is the authoritative source for organ status. - # Reset everything to normal first, then flag only what RadGPT calls abnormal. + lesions[organ] = {"voxels": voxels, "volume": round(float(lesion_volume), 2)} + + print(f"[DEBUG] seg_dir={seg_dir}") + print(f"[DEBUG] lesions found: {lesions}") + if radgpt_comments: for organ in list(organ_volumes.keys()): organ_volumes[organ]['status'] = 'normal' + # NOTE: 'hypoattenuating'/'hyperattenuating' added — RadGPT's actual + # enhancement-description vocabulary ("Enhancement relative to + # pancreas: Hypoattenuating...") wasn't covered by the old list, + # which only had the unrelated words 'hypodense'/'hyperdense'. abnormal_keywords = ['enlarged', 'mass', 'lesion', 'tumor', 'abnormal', 'dilated', 'obstruction', 'isoattenuating', 'hypodense', - 'hyperdense', 'cyst', 'nodule', 'atrophy', 'bilateral'] - # Build stripped root for flexible matching - organ_roots = {} + 'hyperdense', 'hypoattenuating', 'hyperattenuating', + 'cyst', 'nodule', 'atrophy', 'bilateral'] + subtype_suffixes = ('_body', '_head', '_tail', '_left', '_right') + + def _base_root(organ_name): + r = organ_name + for suf in ('_left', '_right', '_body', '_head', '_tail', '_gland', '_duct', '_lesion'): + r = r.replace(suf, '') + return r.replace('_', '').lower() + + organs_by_root = {} for organ in organ_volumes.keys(): - root = organ.replace('_left','').replace('_right','').replace('_body','') \ - .replace('_head','').replace('_tail','').replace('_gland','') \ - .replace('_duct','').replace('_lesion','').replace('_','') - organ_roots[organ] = root.lower() - for line in radgpt_comments.split('\n'): - line_stripped = line.lower().replace(' ','').replace('_','') - if any(kw in line.lower() for kw in abnormal_keywords): - for organ, root in organ_roots.items(): - # Skip subtypes unless explicitly mentioned - # e.g. "pancreas enlarged" shouldn't flag pancreas_body/head/tail - if '_body' in organ or '_head' in organ or '_tail' in organ or '_duct' in organ: - # Only flag subtype if the subtype word is in the line - subtype = organ.split('_')[-1] - if root in line_stripped and subtype in line.lower(): - organ_volumes[organ]['status'] = 'check' - else: - if root in line_stripped: - organ_volumes[organ]['status'] = 'check' + organs_by_root.setdefault(_base_root(organ), []).append(organ) + + # Find each organ-heading line (a line that IS, after stripping a + # trailing ':', one of the known organ roots or "s") — same + # rule the frontend uses to scope report text per organ — then + # scan the WHOLE block between consecutive headings for both the + # abnormal keyword and the subtype word, rather than requiring + # both on one line. A lesion's location ("Location: pancreas + # tail.") is routinely on a different line than the keyword that + # flags it ("Pancreas lesions:"), so the old per-line check could + # never flag a sub-label organ even when its own block clearly + # documented a finding there. + lines = radgpt_comments.split('\n') + heading_idx = [] + for i, line in enumerate(lines): + cleaned = line.strip().rstrip(':').lower() + for root in organs_by_root: + if root and (cleaned == root or cleaned == f'{root}s'): + heading_idx.append((i, root)) + break + + for pos, (start, root) in enumerate(heading_idx): + end = heading_idx[pos + 1][0] if pos + 1 < len(heading_idx) else len(lines) + block_lower = '\n'.join(lines[start:end]).lower() + if not any(kw in block_lower for kw in abnormal_keywords): + continue + for organ in organs_by_root.get(root, []): + has_subtype = organ.endswith(subtype_suffixes) + if not has_subtype: + # Generic/base organ label (no left/right/body/head/tail + # subtype) — any abnormal keyword anywhere in its block + # flags it, same as before. + organ_volumes[organ]['status'] = 'check' + elif organ.rsplit('_', 1)[-1] in block_lower: + # Sub-label organ — only flag it if ITS specific + # subtype word (e.g. "tail") appears anywhere in the + # block, not just on the same line as the keyword. + organ_volumes[organ]['status'] = 'check' + comments = radgpt_comments or "Clinical comments unavailable." impression_items = radgpt_impression or ["No impression available for this case."] - - return jsonify({ + result = { "case_id": id, "patient": {"age": age, "sex": sex}, "imaging": { @@ -898,14 +934,982 @@ def get_report_data(id): "lesions": lesions, "comments": comments, "impression": impression_items, - }) - - except Exception as e: - import traceback - traceback.print_exc() - return jsonify({"error": "An internal error occurred."}), 500 - + } + _REPORT_DATA_CACHE[id] = result + return result + except Exception: + return {"error": "Failed to build report data for the given id."} + + +@api_blueprint.route('/get-report-data/', methods=['GET']) +def get_report_data(id): + data = _build_report_data(id) + if "error" in data: + status = 400 if data["error"] == "Invalid id parameter" else 500 + return jsonify(data), status + return jsonify(data) + + +@api_blueprint.route('/report/', methods=['GET']) +def report_html(id): + """Shareable Apple-Health-style HTML report for a case -- same underlying + data as get_report_data/generate_report_pdf (_build_report_data), just + rendered as an interactive patient/clinician-toggle page instead of JSON + or a static PDF. This is a live server-rendered page (not a static file + a client fetches data into), so the same auth/session that already + protects case access here protects this route too -- no new data-exposure + surface. The 'Download radiology report' button on the clinician side + links to the existing /generate-report-pdf/ route.""" + resolved = _resolve_case_id_or_token(id) + if resolved is None: + return jsonify({"error": "Invalid or expired link"}), 404 + data = _build_report_data(resolved) + if "error" in data: + status = 400 if data["error"] == "Invalid id parameter" else 500 + return jsonify(data), status + html_out = _build_report_html(data) + return Response(html_out, mimetype="text/html") + +import html as _html_mod +import json as _json_mod + + +def _plain_organ_name(root): + return root + + +_SUBREGION_PLAIN = { + ("pancreas", "tail"): "the tail of your pancreas (the end farthest from your stomach)", + ("pancreas", "body"): "the body of your pancreas (the middle section)", + ("pancreas", "head"): "the head of your pancreas (the end closest to your small intestine)", +} + +_FINDING_WORD = [ + ("cyst", "fluid-filled spot"), + ("nodule", "small bump"), + ("mass", "growth"), + ("tumor", "growth"), + ("enlarged", "enlarged area"), + ("dilated", "widened area"), + ("lesion", "spot"), +] + + +def _finding_word(detail_text): + d = (detail_text or "").lower() + for keyword, word in _FINDING_WORD: + if keyword in d: + return word + return None + + +def _where_phrase(root, location_word): + name = _plain_organ_name(root) + if not location_word: + return f"your {name}" + if location_word in ("left", "right"): + return f"your {location_word} {name}" + if (root, location_word) in _SUBREGION_PLAIN: + return _SUBREGION_PLAIN[(root, location_word)] + # Unknown or compound locator (e.g. "segment 4", parsed straight from the + # report text) — forcing this into "the X of your Y" reads wrong for + # anything that isn't a short single anatomical word. This construction + # stays grammatically correct for any locator string. + return f"your {name} ({location_word})" + + +def _organ_base_stats(root, organ_volumes): + """Looks up an organ's baseline volume/HU. Some organs (kidney, lung, + adrenal gland) have no bare aggregate key in organ_volumes — only + _left/_right — in which case this sums volume and averages + HU across both sides instead of returning nothing.""" + if root in organ_volumes: + return organ_volumes[root] + parts = [organ_volumes[k] for k in (f"{root}_left", f"{root}_right") if k in organ_volumes] + if not parts: + return {} + vols = [p.get("volume") for p in parts if p.get("volume") is not None] + hus = [p.get("mean_hu") for p in parts if p.get("mean_hu") is not None] + return { + "volume": sum(vols) if vols else None, + "mean_hu": (sum(hus) / len(hus)) if hus else None, + } + + +def _patient_lesion_sentence(root, lesion, location_word, detail_text): + where = _where_phrase(root, location_word) + size = lesion.get("size") + word = _finding_word(detail_text) or "spot" + size_part = f" measuring {size} cm" if size else "" + return f"Your scan found a {word}{size_part} in {where}." + + +def _patient_no_lesion_sentence(root): + name = _plain_organ_name(root) + return (f"Your {name} was flagged for your doctor to review, but the report " + f"doesn't describe a specific spot or growth \u2014 your doctor can " + f"tell you exactly what stood out.") + + +def _doctor_lesion_sentence(root, lesion, location_word): + loc = f" ({location_word})" if location_word else "" + return (f"{root.title()}{loc} lesion, {lesion.get('size', 'N/A')} cm, " + f"volume {lesion.get('volume', 0):.1f} cc. " + f"{lesion.get('enhancement', '')} relative to {root}, " + f"HU {lesion.get('hu', 0):.1f} \u00b1 {lesion.get('hu_sd', 0):.1f}.").strip() + + +def _base_root(organ): + r = organ + for suf in ('_left', '_right', '_body', '_head', '_tail', '_gland', '_duct', '_lesion'): + r = r.replace(suf, '') + return r.replace('_', '').lower() + + +def _e(s): + """HTML-escape any real data before it goes in the page — this is a + document assembled from case data, not a trusted template string.""" + return _html_mod.escape(str(s), quote=True) + + +def _build_report_html(report_data): + """Builds the full Apple-Health-inspired shareable HTML report from a + real report_data dict (same shape _draw_report_pdf and the JSON API use: + case_id, patient, imaging, organ_volumes, lesions, comments, impression). + No placeholder content — every value is pulled from report_data itself, + and the layout adapts to however many findings/organs actually exist. + """ + case_id = report_data.get("case_id", "N/A") + patient = report_data.get("patient", {}) + imaging = report_data.get("imaging", {}) + organ_volumes = report_data.get("organ_volumes", {}) + lesions = report_data.get("lesions", {}) + comments = str(report_data.get("comments", "")) + + roots_present = sorted(set(_base_root(o) for o in organ_volumes.keys())) + parsed_organs = _parse_findings(comments, roots_present) + organ_lookup = {o['root']: o for o in parsed_organs} + + # ---- Build one "finding" entry per flagged organ (0, 1, or many) ---- + flagged_roots = sorted(set( + _base_root(o) for o, v in organ_volumes.items() if v.get("status") == "check" + )) + findings = [] + for root in flagged_roots: + entry = organ_lookup.get(root, {"baseline_lines": [], "lesions": []}) + detail_text = " ".join(entry.get("baseline_lines", [])) + organ_base = _organ_base_stats(root, organ_volumes) + + if entry.get("lesions"): + for lesion in entry["lesions"]: + loc_word = lesion["location"].replace(root, "").strip() or None + findings.append({ + "title": f"{root.title()}" + (f" \u2014 {loc_word.title()}" if loc_word else ""), + "patient_html": _e(_patient_lesion_sentence(root, lesion, loc_word, detail_text)), + "doctor_html": _e(_doctor_lesion_sentence(root, lesion, loc_word)), + "metrics": [ + {"label": "Organ volume", "value": f"{organ_base.get('volume', 0):.1f} cc" if organ_base.get('volume') is not None else "N/A"}, + {"label": "Lesion volume", "value": f"{lesion.get('volume', 0):.1f} cc"}, + {"label": "Mean HU (organ)", "value": f"{organ_base.get('mean_hu', 0):.1f}" if organ_base.get('mean_hu') is not None else "N/A"}, + {"label": "Lesion count", "value": str(len(entry["lesions"]))}, + ], + }) + else: + findings.append({ + "title": root.title(), + "patient_html": _e(_patient_no_lesion_sentence(root)), + "doctor_html": _e(detail_text or f"{root.title()} flagged; no lesion described in report text."), + "metrics": [ + {"label": "Organ volume", "value": f"{organ_base.get('volume', 0):.1f} cc" if organ_base.get('volume') is not None else "N/A"}, + {"label": "Mean HU (organ)", "value": f"{organ_base.get('mean_hu', 0):.1f}" if organ_base.get('mean_hu') is not None else "N/A"}, + ], + }) + + # ---- "Everything else looked normal" — organs RadGPT actually commented + # on that are NOT flagged (real prose-covered organs only, not every + # unrelated bone/vessel) ---- + clean_cards = [] + for organ in parsed_organs: + root = organ["root"] + if root in flagged_roots: + continue + base = _organ_base_stats(root, organ_volumes) + clean_cards.append({ + "name": root.title(), + "volume": f"{base.get('volume', 0):.0f}" if base.get("volume") is not None else "\u2014", + "hu": f"Mean HU {base.get('mean_hu', 0):.1f}" if base.get("mean_hu") is not None else "", + }) + + # ---- Full organs-reviewed checklist (every organ in this case) ---- + organ_rows = sorted( + (organ.replace("_", " ").title(), vals.get("status", "normal")) + for organ, vals in organ_volumes.items() + ) + flagged_rows = [r for r in organ_rows if r[1] == "check"] + normal_rows = [r for r in organ_rows if r[1] != "check"] + visible_rows = flagged_rows + normal_rows[:max(0, 3 - len(flagged_rows))] + hidden_rows = normal_rows[len(visible_rows) - len(flagged_rows):] + + def _row_html(name, status): + cls = "row flagged" if status == "check" else "row" + icon = '!' if status == "check" else '' + name_cls = ' class="name"' if status != "check" else ' class="name"' + return f'
{_e(name)}{icon}
' + + visible_rows_html = "\n".join(_row_html(n, s) for n, s in visible_rows) + hidden_rows_html = "\n".join(_row_html(n, s) for n, s in hidden_rows) + + clean_grid_html = "\n".join( + f'''
{_e(c['name'])}
+
{_e(c['volume'])} cc
+
{_e(c['hu'])}
''' + for c in clean_cards + ) or '

No additional measured organs for this case.

' + + age = patient.get("age", "N/A") + sex = patient.get("sex", "N/A") + study = imaging.get("study_type", "N/A") + contrast = imaging.get("contrast", "N/A") + + findings_json = _json_mod.dumps(findings) + pdf_url = f"/api/generate-report-pdf/{case_id}" + + findings_count = len(findings) + flagged_summary = ( + f"{findings_count} finding{'s' if findings_count != 1 else ''} need{'s' if findings_count == 1 else ''} review" + if findings_count else "No findings flagged for review" + ) + + return f""" + + + + +CT Scan Report \u2014 Case {_e(case_id)} + + + +
+ +
+ + +
+ +
+ +
+

CT scan results

+
Case {_e(case_id)} · {_e(study)} · {_e(age)}y, {_e(sex)} · {_e(contrast)}
+
+ + + +
+
+
+ +
+ {clean_grid_html} +
+
+
+ + +
+ {visible_rows_html} +
+ {f'
{hidden_rows_html}
' if hidden_rows else ''} + +
+ + + +""" + + +# ============================================================ +# DROP-IN REPLACEMENT for _REPORT_CSS in api_blueprint.py +# ============================================================ + +_REPORT_CSS = """ + :root { + --jhu-heritage: #002D72; + --jhu-spirit: #68ACE5; + --jhu-white: #FFFFFF; + + --ink: #071C3C; --muted: #4C6584; --faint: #7C93AE; + --surface: #F2F6FB; --card: #ffffff; --line: #D6E4F2; + --accent: var(--jhu-heritage); --accent-bg: #E7EFFA; + --green: #1C7C4F; --green-bg: #EAF7EF; + } + * { box-sizing: border-box; } + body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: var(--surface); color: var(--ink); } + .wrap { max-width: 640px; margin: 0 auto; padding: 20px 18px 60px; } + @media (min-width: 720px) { + .wrap { max-width: 900px; padding: 32px 32px 80px; } + .top-grid { display: grid; grid-template-columns: 1.3fr 1fr; gap: 20px; align-items: start; } + .clean-grid { grid-template-columns: repeat(3, 1fr); } + .metrics-grid { grid-template-columns: repeat(4, 1fr); } + } + @media (min-width: 1080px) { + .wrap { max-width: 1080px; } + .clean-grid { grid-template-columns: repeat(4, 1fr); } + } + .toggle { display: flex; background: var(--card); border: 1px solid var(--line); border-radius: 999px; padding: 4px; margin-bottom: 18px; position: sticky; top: 12px; z-index: 10; box-shadow: 0 2px 10px rgba(0,45,114,0.08); } + .toggle button { flex: 1; border: none; background: transparent; border-radius: 999px; padding: 10px; font-size: 14px; font-weight: 600; color: var(--muted); cursor: pointer; } + .toggle button.active { background: var(--jhu-heritage); color: var(--jhu-white); } + .disclaimer { font-size: 11.5px; color: var(--faint); font-style: italic; margin: 0 2px 18px; line-height: 1.5; } + .header { margin-bottom: 18px; border-bottom: 3px solid var(--jhu-spirit); padding-bottom: 14px; } + .header h1 { font-size: 22px; font-weight: 700; margin: 0 0 2px; color: var(--jhu-heritage); } + .header .sub { font-size: 13px; color: var(--muted); } + .download-btn { display: inline-flex; align-items: center; gap: 8px; background: var(--jhu-heritage); color: var(--jhu-white); text-decoration: none; font-size: 14px; font-weight: 600; padding: 10px 16px; border-radius: 10px; } + .download-btn:hover { background: #001F52; } + .card { background: var(--card); border-radius: 16px; padding: 16px 18px; margin-bottom: 12px; border: 1px solid var(--line); box-shadow: 0 2px 8px rgba(0,45,114,0.05); } + .card.flag { background: var(--accent-bg); border-color: var(--jhu-spirit); border-left: 4px solid var(--jhu-heritage); } + .flag-label { display: flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 700; color: var(--jhu-heritage); text-transform: uppercase; letter-spacing: 0.02em; margin-bottom: 8px; } + .flag-label .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--jhu-heritage); } + .organ-title { font-size: 17px; font-weight: 700; margin-bottom: 6px; color: var(--jhu-heritage); } + .body-text { font-size: 15.5px; line-height: 1.55; color: var(--ink); } + .metrics-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 10px; } + .metric { background: var(--jhu-white); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; } + .metric .label { font-size: 11.5px; color: var(--jhu-heritage); font-weight: 600; text-transform: uppercase; } + .metric .value { font-size: 17px; font-weight: 700; margin-top: 2px; color: var(--ink); } + .badge { display: inline-flex; align-items: center; gap: 6px; background: var(--jhu-heritage); color: var(--jhu-white); font-size: 11px; font-weight: 700; letter-spacing: 0.03em; padding: 4px 10px; border-radius: 999px; margin-top: 10px; } + .section-label { font-size: 12.5px; font-weight: 700; color: var(--jhu-heritage); text-transform: uppercase; letter-spacing: 0.03em; margin: 20px 4px 10px; } + .clean-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; } + .clean-card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; } + .clean-card .name { font-size: 13px; color: var(--muted); } + .clean-card .num { font-size: 19px; font-weight: 700; margin-top: 2px; color: var(--jhu-heritage); } + .clean-card .num .unit { font-size: 12px; font-weight: 500; color: var(--muted); } + .clean-card .hu { font-size: 11.5px; color: var(--faint); margin-top: 2px; } + .checklist { background: var(--card); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; } + .row { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--line); font-size: 14.5px; } + .row:last-child { border-bottom: none; } + .row.flagged { background: var(--accent-bg); } + .row.flagged span.name { color: var(--jhu-heritage); font-weight: 600; } + .check { color: var(--green); font-weight: 700; } + .exclaim { color: var(--jhu-heritage); font-weight: 700; } + .show-more { width: 100%; background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px; font-size: 14px; font-weight: 600; color: var(--jhu-heritage); margin-top: 8px; cursor: pointer; } + #more-organs { display: none; } + #more-organs.open { display: block; } + + .bm-footer { margin-top: 32px; padding: 22px 24px; border-radius: 16px; background: var(--jhu-heritage); display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap; } + .bm-footer__text { color: var(--jhu-white); } + .bm-footer__title { font-size: 15px; font-weight: 700; margin: 0 0 4px; } + .bm-footer__sub { font-size: 12.5px; color: var(--jhu-spirit); margin: 0; } + .bm-footer__cta { display: inline-flex; align-items: center; gap: 8px; background: var(--jhu-white); color: var(--jhu-heritage); text-decoration: none; font-size: 13.5px; font-weight: 700; padding: 10px 18px; border-radius: 999px; white-space: nowrap; } + .bm-footer__cta:hover { background: var(--jhu-spirit); } +""" + + +# ============================================================ +# In _build_report_html, add this block right before the +# closing "" of
(i.e. right after the +# checklist / #more-organs block, before the final
\n\n