diff --git a/README.md b/README.md index bb23518..4f97a16 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,11 @@ ssh visitor@bdmap1.wse.jhu.edu ``` #### 1. Pull latest changes +Production must always deploy from `main`. Confirm the branch first, then pull. ``` cd /home/visitor/PanTS-Viewer git fetch +git checkout main git pull ``` @@ -72,8 +74,9 @@ cd /home/visitor/PanTS-Viewer/PanTS-Demo && npm ci && npm run build #### 3. Restart the backend ``` -# Kill the old gunicorn process -kill $(pgrep -f "gunicorn.*app:app") +# Stop the old gunicorn process and wait for the port to free +pkill -f "gunicorn.*app:app"; sleep 2 +pgrep -f "gunicorn.*app:app" && echo "still running - rerun the line above" || echo "port clear" # Start a new gunicorn process nohup /home/visitor/.conda/envs/PanTS_backend/bin/gunicorn \ @@ -85,8 +88,13 @@ echo "PID: $!" ``` #### 4. Verify the backend is running +Give it a few seconds to load, then check the backend booted, the dataset loads, and masks serve (all three must succeed). ``` -sleep 3 && curl http://127.0.0.1:8000/api/ping +sleep 8 +curl http://127.0.0.1:8000/api/ping +curl -s "http://127.0.0.1:8000/api/search?limit=1" | head -c 120; echo +curl -s -o /dev/null -w "segmentations: %{http_code}\n" "http://127.0.0.1:8000/api/get-segmentations/17.nii.gz" ``` +Expect `{"message":"pong"}`, a JSON object with `items`, and `segmentations: 200`. If the backend fails to boot, check the log for the traceback. Logs are written to `/tmp/gunicorn.log`. diff --git a/flask-server/api/api_blueprint.py b/flask-server/api/api_blueprint.py index 7268616..31b2140 100644 --- a/flask-server/api/api_blueprint.py +++ b/flask-server/api/api_blueprint.py @@ -760,10 +760,17 @@ async def get_segmentations(combined_labels_id): try: serve_path = nifti_path if img.get_data_dtype() != np.uint8: - # The source is a float label map; Cornerstone needs uint8. Write the - # converted copy to a sibling file and serve THAT — never overwrite the - # original ground-truth mask on disk (an HTTP GET must not mutate data). - converted_path = nifti_path.replace('.nii.gz', '_uint8.nii.gz') + # The source is a float label map; Cornerstone needs uint8. Cache the + # converted copy in a WRITABLE temp dir and serve THAT — never write + # into the dataset's mask_only/ (it is read-only on the server, and an + # HTTP GET must not mutate ground-truth data). Writing the sibling into + # mask_only/ 500'd the segmentation endpoint in production. + cache_dir = "/tmp/pants_uint8" + os.makedirs(cache_dir, exist_ok=True) + converted_path = os.path.join( + cache_dir, + f"{get_panTS_id(secure_filename(combined_labels_id))}_combined_labels_uint8.nii.gz", + ) if not os.path.exists(converted_path): print("⚠️ Detected float label map, converting to uint8 for Cornerstone compatibility...") raw = np.asanyarray(img.dataobj) diff --git a/flask-server/services/segmentation_metrics.py b/flask-server/services/segmentation_metrics.py new file mode 100644 index 0000000..6421293 --- /dev/null +++ b/flask-server/services/segmentation_metrics.py @@ -0,0 +1,40 @@ +""" +services/segmentation_metrics.py + +Per-session segmentation metrics for the AI assistant when a user is viewing +an *uploaded* (session) case rather than a numeric dataset case. + +This module is intentionally defensive. Its only caller +(``api_blueprint._ai_load_metrics``) treats a returned dict that contains an +``"error"`` key as "no server metrics available" and falls back to +frontend-supplied metrics. Therefore this function must NEVER raise and must +always return a dict: a missing/undecodable session mask has to degrade +gracefully to the frontend metrics instead of breaking AI chat -- or, as we +learned the hard way, breaking the whole backend at import time when this +module was referenced but never committed. + +Server-side computation of per-organ metrics for uploaded sessions is not +implemented in this build, so we return a clean "unavailable" result. The +public shape mirrors ``get_mask_data_internal`` ({"organ_metrics": [...]}). +""" + +from __future__ import annotations + + +def calculate_session_metrics(session_id, sessions_dir_name=None): + """Best-effort per-organ metrics for an uploaded session case. + + Returns a dict shaped like ``get_mask_data_internal`` on success, or + ``{"error": ...}`` when the session's mask cannot be located/computed. + Never raises. + """ + try: + identifier = str(session_id or "").strip() + if not identifier: + return {"error": "no session id", "organ_metrics": []} + + # Session masks are not computed server-side in this build; signal + # "unavailable" so the caller falls back to frontend-supplied metrics. + return {"error": "session metrics unavailable", "organ_metrics": []} + except Exception as error: # never let this break the import or the caller + return {"error": f"{type(error).__name__}: {error}", "organ_metrics": []}