From 430f4c5b5eb6b72e90c55de725ce3e43eb3081e4 Mon Sep 17 00:00:00 2001 From: Aditya Sanjeev Date: Fri, 14 Aug 2026 22:45:11 -0700 Subject: [PATCH 1/3] Fix epai_predict.py: never actually produced output (2 bugs) Discovered while smoke-testing before enabling EPAI_SCRIPT_PATH in prod -- the fast GPU-export ePAI path has been merged but was never turned on, and for good reason: it was silently broken. Bug 1 - save_probabilities=False produced zero output, no error. The fork's export_prediction_from_logits gates its ENTIRE write path (segmentation write, tumor-stats extraction, CSV update) behind 'if save_probabilities:'. The unconditional segmentation write that used to run regardless is commented out at the bottom of that function (a leftover from whatever refactor added the CSV pipeline). save_probabilities=False looked like the obviously correct choice (we don't want a probabilities file) but actually means 'do nothing at all'. Confirmed: rc=0, 'done ' printed, zero bytes written, empty CSV. Bug 2 - wrong reader class. This model's plans specify NibabelIOWithReorient as the image_reader_writer_class, not SimpleITKIO (which the script hardcoded for reading the input CT). Reading with the wrong class produces a properties dict missing 'nibabel_stuff', which the nibabel writer needs for the reoriented affine -> KeyError deep inside write_seg the moment bug 1 is fixed and the write path actually runs. Verified after both fixes: 3 cases (small/medium/a 1060-slice large volume), all produced real segmentation files and fully-populated findings CSV rows (shape/spacing/pancreas/duct/PDAC/cyst/PNET stats with sensible confidence values) end to end. --- flask-server/scripts/epai_predict.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/flask-server/scripts/epai_predict.py b/flask-server/scripts/epai_predict.py index f42c0e0b..d21de951 100644 --- a/flask-server/scripts/epai_predict.py +++ b/flask-server/scripts/epai_predict.py @@ -27,7 +27,6 @@ import numpy as np import torch from nnunetv2.inference.predict_from_raw_data import nnUNetPredictor -from nnunetv2.imageio.simpleitk_reader_writer import SimpleITKIO from nnunetv2.preprocessing.preprocessors.default_preprocessor import DefaultPreprocessor from nnunetv2.inference.export_prediction import export_prediction_from_logits from nnunetv2.preprocessing.resampling.resample_torch import resample_torch_fornnunet @@ -80,9 +79,15 @@ def main(): checkpoint_name=os.getenv("EPAI_CHECKPOINT_NAME", "checkpoint_final.pth"), ) - rw = SimpleITKIO() pp = DefaultPreprocessor(verbose=False) pm, cm = predictor.plans_manager, predictor.configuration_manager + # Must match the writer export_prediction_from_logits uses internally + # (also pm.image_reader_writer_class() - this model's plans specify + # NibabelIOWithReorient, not SimpleITKIO). Reading with the wrong class + # produces a properties dict missing 'nibabel_stuff', which the nibabel + # writer needs for the reoriented affine -> KeyError deep inside write_seg. + # Confirmed by direct smoke test before this was wired into EPAI_SCRIPT_PATH. + rw = pm.image_reader_writer_class() ct = os.path.join(input_dir, f"{case_id}_0000.nii.gz") if not os.path.exists(ct): @@ -96,10 +101,21 @@ def main(): # The fork's own export: converts logits -> native-res segmentation (GPU resample, # thanks to the patch) AND writes the tumor-findings row into output_csv. Writes the # segmentation to /.nii.gz. + # + # save_probabilities MUST be True here despite the name: in this fork it does not + # mean "write a probabilities.npz" (that branch is commented out -- "we don't save + # probabilities as pickles anymore"). It gates the ENTIRE write path -- segmentation + # write, tumor-stats extraction, and the output-CSV update all live inside + # `if save_probabilities:`; the unconditional segmentation write that used to run + # regardless is commented out at the bottom of the function (a leftover from + # whoever refactored this in). Passing False (the "obviously correct" choice, + # since we don't want a probabilities file) silently produces ZERO output -- + # no exception, exit 0, nothing written. Confirmed by direct smoke test before + # this was wired into EPAI_SCRIPT_PATH. out_trunc = os.path.join(save_dir, case_id) export_prediction_from_logits( logits, pprops, cm, pm, predictor.dataset_json, out_trunc, - save_probabilities=False, output_csv_path=output_csv, + save_probabilities=True, output_csv_path=output_csv, ) print(f"done {case_id}", flush=True) From e78c2458b7f817ff855dc5b34399c1e2b6666e05 Mon Sep 17 00:00:00 2001 From: Aditya Sanjeev Date: Fri, 14 Aug 2026 23:05:21 -0700 Subject: [PATCH 2/3] Add ePAI warm/persistent predictor Same proven pattern as scripts/lesionseg_warm_server.py: load the model ONCE and keep it GPU-resident, eliminating the cold-start subprocess reload (interpreter + torch import + checkpoint load + cuDNN autotune) that _run_epai_inference currently pays on every single request via epai_predict.py. - epai_warm_server.py (new): persistent HTTP predictor on 127.0.0.1:8766. Applies the same two fork fixes as the just-fixed cold path (anisotropy axis coercion, correct NibabelIOWithReorient reader class, save_probabilities=True), plus the validated GPU export-resample patch. Refuses (409) a request whose step_size/disable_tta don't match what it was started with, rather than silently serving a different configuration - an invisible accuracy change in a cancer-detection tool. Same path- containment pattern as lesionseg_warm_server.py (CodeQL-verified there): requests may only name a location as a validated relative path under an allowed root, never an absolute path. - run_epai_warm.sh (new): launcher, mirrors run_lesionseg_warm.sh - setsid-detached so an SSH drop can't kill it mid-start, waits for a real health check rather than a guessed sleep. - epai_predict.py (modified): now tries EPAI_WARM_URL first if set, falling back to the existing (just-fixed) cold in-process path on any failure - server down, busy, or a configuration mismatch. Heavy imports (torch, nnunetv2) moved inside _cold_predict so the warm path never pays their cost either. Verified end-to-end on bdmap1 against a scratch sessions root (not the real one): two back-to-back requests for different cases through a running warm server, both producing real segmentation files + correct findings CSV rows matching the cold-path smoke test's output (tiny float-noise differences only, consistent with known GPU nondeterminism). Second request took 8.87s total with 0.024s of client-side CPU, confirming the model stayed resident across requests rather than reloading. NOT enabled by default - EPAI_WARM_URL is unset until the server is started via run_epai_warm.sh and the env var is set in .env, matching how the LesionSegmenter warm predictor was rolled out. --- flask-server/scripts/epai_predict.py | 255 ++++++++++++++------ flask-server/scripts/epai_warm_server.py | 295 +++++++++++++++++++++++ flask-server/scripts/run_epai_warm.sh | 64 +++++ 3 files changed, 540 insertions(+), 74 deletions(-) create mode 100644 flask-server/scripts/epai_warm_server.py create mode 100644 flask-server/scripts/run_epai_warm.sh diff --git a/flask-server/scripts/epai_predict.py b/flask-server/scripts/epai_predict.py index d21de951..450f080a 100644 --- a/flask-server/scripts/epai_predict.py +++ b/flask-server/scripts/epai_predict.py @@ -1,64 +1,164 @@ #!/usr/bin/env python3 -"""Optimized ePAI inference: GPU-resident export + the fork's own findings CSV. +"""Optimized ePAI inference: warm persistent predictor + GPU-resident export. -Drop-in for the bare `nnUNetv2_predict_from_modelfolder` call in services/auto_segmentor.py, wired in via the EPAI_SCRIPT_PATH hook (which invokes: `bash