Skip to content
Merged
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
16 changes: 11 additions & 5 deletions PanTS-Demo/src/components/viewer/MeshViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,21 @@
crosshairMm: Vec3 | null
customOrgans?: CheckBoxData[];
labelColorMap?: { [key: number]: Color };
// Uploaded scans have no pre-baked meshes; fetch from the session route, which
// builds them on demand from the session's combined_labels.
isSession?: boolean;
};

export async function fetchMeshManifest(caseId: string): Promise<MeshManifest> {
const res = await fetch(`${APP_CONSTANTS.API_ORIGIN}/api/cases/${caseId}/mesh-manifest`);
export async function fetchMeshManifest(caseId: string, isSession = false): Promise<MeshManifest> {

Check failure on line 27 in PanTS-Demo/src/components/viewer/MeshViewer.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 22)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

Check failure on line 27 in PanTS-Demo/src/components/viewer/MeshViewer.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 20)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const base = isSession
? `${APP_CONSTANTS.API_ORIGIN}/api/sessions/${caseId}/mesh-manifest`
: `${APP_CONSTANTS.API_ORIGIN}/api/cases/${caseId}/mesh-manifest`;
const res = await fetch(base);
if (!res.ok) throw new Error(`Failed to fetch mesh manifest: ${res.status}`);
return res.json();
}

export function SegmentationMeshViewer({ caseId, checkState, loading, opacity, crosshairMm, customOrgans = [], labelColorMap = {}}: SegmentationMeshViewerProps) {
export function SegmentationMeshViewer({ caseId, checkState, loading, opacity, crosshairMm, customOrgans = [], labelColorMap = {}, isSession = false}: SegmentationMeshViewerProps) {
const [manifest, setManifest] = useState<MeshManifest | null>(null);
const [loaded, setLoaded] = useState<Record<number, boolean>>({});
// Bumped on every mask edit so editedSegments below is recomputed — the 3D pane
Expand All @@ -41,7 +47,7 @@

// Segment indices touched since the case loaded — includes edits to the STATIC
// 32-organ catalog, not just brand-new custom classes.
const editedSegments = useMemo(() => getEditedSegments(), [editVersion]);

Check warning on line 50 in PanTS-Demo/src/components/viewer/MeshViewer.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 22)

React Hook useMemo has an unnecessary dependency: 'editVersion'. Either exclude it or remove the dependency array

Check warning on line 50 in PanTS-Demo/src/components/viewer/MeshViewer.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Node 20)

React Hook useMemo has an unnecessary dependency: 'editVersion'. Either exclude it or remove the dependency array

const crosshairPosition = useMemo(() => {
if (!manifest || !crosshairMm) return null;
Expand All @@ -50,7 +56,7 @@

useEffect(() => {
let alive = true;
fetchMeshManifest(caseId)
fetchMeshManifest(caseId, isSession)
.then((data) => {
if (!alive) return;
setManifest(data);
Expand All @@ -60,7 +66,7 @@
})
.catch((err) => console.error(err));
return () => { alive = false; };
}, [caseId]);
}, [caseId, isSession]);

const organs = useMemo(() => manifest?.organs ?? [], [manifest]);

Expand Down
2 changes: 1 addition & 1 deletion PanTS-Demo/src/routes/VisualizationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3445,7 +3445,7 @@ const aiAvailableOrgans = useMemo(() => {
<span>(switch to Volume rendering above)</span>
</div>
) : (
<SegmentationMeshViewer caseId={caseId} crosshairMm={crosshairMm} checkState={checkState} loading={loading} opacity={opacityValue} customOrgans={customOrgans} labelColorMap={labelColorMap} />
<SegmentationMeshViewer caseId={caseId} isSession={!!sessionId && !pantsCase} crosshairMm={crosshairMm} checkState={checkState} loading={loading} opacity={opacityValue} customOrgans={customOrgans} labelColorMap={labelColorMap} />
)}
</div>
{!loading && (
Expand Down
50 changes: 50 additions & 0 deletions flask-server/api/api_blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -1527,6 +1527,56 @@
return response


def _session_seg_path(session_id):
"""Return the combined_labels.nii.gz path for a finished session, or None."""
job = _get_inference_job(session_id) or {}
output_mask_dir = job.get("output_mask_dir")
if not output_mask_dir:
return None
seg_path = os.path.join(output_mask_dir, "combined_labels.nii.gz")
return seg_path if os.path.exists(seg_path) else None


# 3D organ meshes for an UPLOADED scan. Unlike dataset cases (whose meshes are
# pre-baked into MESH_PATH by preprocess_meshes.py), a session has no pre-baked
# meshes, so we build them on demand from the session's combined_labels and cache
# them next to it. Without these routes the 3D pane hangs on "Loading 3D
# segmentation..." because /cases/<id>/mesh-manifest 404s for a session id.
@api_blueprint.route('/sessions/<session_id>/mesh-manifest', methods=['GET'])
def get_session_mesh_manifest(session_id):
if not _is_safe_id(session_id):
return jsonify({"error": "Invalid id"}), 400
seg_path = _session_seg_path(session_id)
if not seg_path:
return jsonify({"error": "Segmentation not ready for session"}), 404
manifest = generate_mesh_manifest(session_id, seg_path, route_base="sessions")
return jsonify(manifest)


@api_blueprint.route('/sessions/<session_id>/render_only/<filename>', methods=['GET'])
def get_session_mesh_file(session_id, filename):
if not _is_safe_id(session_id):
return jsonify({"error": "Invalid id"}), 400
filename = secure_filename(filename)
seg_path = _session_seg_path(session_id)
if not seg_path:
return jsonify({"error": "Segmentation not ready for session"}), 404
# Cache the generated GLB next to the mask so repeat views / organ toggles
# don't re-run marching cubes.
cache_dir = os.path.join(os.path.dirname(seg_path), "render_only")
os.makedirs(cache_dir, exist_ok=True)
glb_path = os.path.join(cache_dir, filename)
if not os.path.exists(glb_path):
organ_key = os.path.splitext(filename)[0]
try:
glb_bytes = generate_organ_glb_bytes(organ_key, seg_path)
except ValueError as e:
return jsonify({"error": str(e)}), 404
with open(glb_path, "wb") as f:
f.write(glb_bytes)
return send_file(glb_path, mimetype="model/gltf-binary", conditional=False)


@api_blueprint.route('/session-reconstruction/<session_id>', methods=['GET'])
def get_session_reconstruction(session_id):
"""Serves the OpenVAE reconstructed CT for a session."""
Expand Down
9 changes: 8 additions & 1 deletion flask-server/services/mesh_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
30: {"key": "renal_vein_left", "name": "Left Renal Vein"},
31: {"key": "renal_vein_right", "name": "Right Renal Vein"},
32: {"key": "cbd_stent", "name": "Common Bile Duct Stent"},
33: {"key": "liver_lesion", "name": "Liver Lesion"},
34: {"key": "kidney_lesion", "name": "Kidney Lesion"},
35: {"key": "colon_lesion", "name": "Colon Lesion"},
}


Expand Down Expand Up @@ -176,7 +179,11 @@ def generate_organ_glb_bytes(
def generate_mesh_manifest(
case_id: str,
label_nifti_path: str,
route_base: str = "cases",
) -> dict:
# route_base selects which serving route the per-organ GLB URLs point at:
# "cases" for pre-baked dataset meshes, "sessions" for on-demand meshes
# generated from an uploaded scan's combined_labels.
img, data = load_clean_label_data(label_nifti_path)

present_labels = set(np.unique(data).astype(int).tolist())
Expand All @@ -200,7 +207,7 @@ def generate_mesh_manifest(
"id": key,
"key": meta["key"],
"name": meta["name"],
"url": f"{os.getenv('API_ORIGIN', 'http://localhost:5001')}/api/cases/{case_id}/render_only/{filename}",
"url": f"{os.getenv('API_ORIGIN', 'http://localhost:5001')}/api/{route_base}/{case_id}/render_only/{filename}",
}
)

Expand Down
Loading