AI-powered lung nodule detection from CT scans.
Pulmodex currently focuses on a MONAI-backed inference web app: upload a zipped
DICOM CT series or a .nii.gz volume, run asynchronous detection, and review
rendered CT slices, nodule candidates, confidence maps, and downloadable
artefacts.
Input: zipped DICOM CT series or .nii.gz CT volume
Output: rendered slice PNGs · candidate boxes · confidence maps · saliency maps · JSON report
The web worker supports three primary detector sources through MODEL_CHECKPOINT
and MODEL_BACKEND:
| Backend | Model source | Notes |
|---|---|---|
monai_bundle |
MONAI bundle directory with configs/inference.json and models/model.pt |
Preferred production path |
monai_tutorial |
standalone TorchScript .pt from MONAI LUNA16 tutorial luna16_training.py |
Uses tutorial RetinaNet defaults |
native |
Pulmodex project checkpoint .ckpt |
Uses local sliding-window segmentation |
auto |
path-based detection | Default; infers one of the above |
After the primary detector, the worker can apply the local false-positive
reduction model from FP_CHECKPOINT. If that checkpoint is missing, the FP
stage is skipped and detection still runs.
Project-native model checkpoints are still supported:
| Model | Architecture | Loss | Patch |
|---|---|---|---|
| Baseline | 3D U-Net encoder-decoder with skip connections | Dice + BCE | 128³ |
| Hybrid | Res-U-Net + Swin Transformer bottleneck + deep supervision | Dice-Focal | 128³ |
| FP reduction | 3D CNN classifier on candidate patches | OHEM | 32³ |
| Tool | Minimum version | Notes |
|---|---|---|
| Python | 3.11 | python3 --version |
| pip | 23+ | bundled with Python 3.11 |
| Node.js | 18 LTS | node --version |
| npm | 9+ | bundled with Node.js 18 |
| Docker | 24+ | required for Redis / Compose workflow |
| Docker Compose | v2 plugin | docker compose version |
| CUDA GPU | optional | recommended for inference and required for practical training |
On Linux, make sure your user can access Docker without sudo:
sudo usermod -aG docker $USER
exec su -l $USERpython3 -m venv .venv
source .venv/bin/activate
pip install -e .
npm --prefix webapp installCreate the runtime environment file:
cp .env.example .envSet at least these values in .env:
DEVICE=cuda
MODEL_BACKEND=auto
MODEL_CHECKPOINT=checkpoints/monai_lung_nodule_ct_detection
FP_CHECKPOINT=checkpoints/fp_reduction_best.ckpt
FP_THRESHOLD=0.5Use DEVICE=cpu for CPU-only local smoke testing. Use DEVICE=cuda in the
worker when CUDA is available.
The app has three runtime pieces:
- FastAPI API at
http://localhost:8010 - Celery worker for inference
- React/Vite frontend at
http://localhost:3000
Redis is used as the Celery broker and result backend.
Start Redis in Docker and run API, worker, and frontend on the host:
make devEquivalent separate terminals:
make redis-up
make dev-api
make dev-worker
make dev-frontendOpen:
| Service | URL |
|---|---|
| Frontend | http://localhost:3000 |
| API | http://localhost:8010 |
Run the full stack in containers:
make docker-upStop or inspect it with:
make docker-down
make docker-logsThe Compose stack mounts:
./checkpointsread-only into API/worker containers./outputsfor generated reports and rendered images./uploadsfor staged uploaded scans
Configuration precedence for web inference:
.envand process environment variablesconfigs/experiment/webapp.yamlconfigs/config.yaml
Important environment variables:
| Variable | Purpose |
|---|---|
DEVICE |
cpu, cuda, or a CUDA device such as cuda:0 |
MODEL_CHECKPOINT |
project .ckpt, MONAI bundle directory, or MONAI tutorial .pt |
MODEL_BACKEND |
auto, native, monai_bundle, or monai_tutorial |
FP_CHECKPOINT |
optional local false-positive classifier checkpoint |
FP_THRESHOLD |
FP classifier probability threshold |
CANDIDATE_THRESHOLD |
native segmentation candidate threshold |
MIN_CANDIDATE_VOXELS |
native segmentation connected-component minimum size |
PRIMARY_PATCH_SIZE |
native sliding-window patch size |
CELERY_BROKER_URL |
Redis broker URL |
CELERY_RESULT_BACKEND |
Redis result backend URL |
API_WORKERS |
API process worker count in container mode |
CELERY_WORKER_CONCURRENCY |
Celery worker concurrency |
For MONAI bundle inference, MODEL_CHECKPOINT must point to a bundle directory
that contains configs/inference.json and models/model.pt. The bundle supplies
its own detector configuration; FP_THRESHOLD still controls the optional local
FP reduction stage.
For MONAI tutorial inference, MODEL_CHECKPOINT must point to a standalone
TorchScript .pt file. The adapter uses the LUNA16 tutorial defaults for
anchors, score threshold, NMS threshold, spacing, and sliding-window ROI size.
Supported upload formats:
.zip: DICOM files are unpacked, the largest CT series is selected, then the API converts it to a staged.nii.gz.nii.gz: validated with SimpleITK and passed directly to the worker
The DICOM conversion preserves physical geometry, resolves slice spacing from DICOM metadata when possible, and conservatively crops obvious air/background.
POST /predict upload .zip or .nii.gz -> {job_id, seriesuid}
GET /status/{job_id} poll Celery state/result/error
GET /scans list persisted scan history
DELETE /scans/{uid} delete scan artefacts and upload staging data
GET /report/{uid} fetch report.json
GET /volume/{uid} download persisted original_scan.nii.gz
GET /markups/{uid} download detected boxes as OBJ
GET /slices/{uid}/{view}?idx=N fetch rendered PNG slice
GET /slices/{uid}/{view}/index list available slice indices
view must be one of axial, coronal, or sagittal.
Each completed scan writes to outputs/<seriesuid>/:
meta.json upload metadata
original_scan.nii.gz persisted staged input volume
ct_volume.nii.gz preprocessed CT proxy volume for rendering
seg_mask.nii.gz detection mask / visualisation mask
confidence_map.nii.gz confidence map
saliency_map.nii.gz Grad-CAM, Swin attention, or zero fallback for MONAI paths
candidates.csv detected candidates and confidence values
report.json summary and candidate payload
slices/ axial, coronal, and sagittal PNG slices
/markups/{uid} exports detected nodule boxes as an OBJ file in RAS world
coordinates for loading into tools such as 3D Slicer.
The same inference adapters are available from the CLI:
pulmodex infer \
--checkpoint checkpoints/monai_lung_nodule_ct_detection \
--fp_checkpoint checkpoints/fp_reduction_best.ckpt \
--input_dir data/processed \
--output_dir outputs \
--fp_threshold 0.5--checkpoint accepts:
- a MONAI bundle directory
- a MONAI tutorial TorchScript
.pt - a Pulmodex project
.ckpt
For native checkpoints, these extra controls affect candidate generation:
pulmodex infer \
--checkpoint checkpoints/hybrid_best.ckpt \
--fp_checkpoint checkpoints/fp_reduction_best.ckpt \
--candidate_threshold 0.5 \
--min_candidate_voxels 10 \
--primary_patch_size 256 \
--fp_threshold 0.5 \
--input_dir data/processedpulmodex dicom-to-luna16 \
--input_dir data/raw_dicom \
--output_dir data/processedAfter conversion, edit:
data/processed/annotations.csv
data/processed/candidates.csv
Expected LUNA16-style layout:
data/processed/
subset0..subset9/
<seriesuid>.mhd
<seriesuid>.raw
annotations.csv
candidates.csv
For local smoke tests:
pulmodex generate-mock-data --cleanThis writes a small synthetic LUNA16-style dataset to data/mock_luna16/.
Training can cache isotropically resampled, HU-normalised CT volumes so repeated candidates from the same scan do not re-run full-volume preprocessing.
pulmodex preprocess-cache \
--data_dir data/processed \
--cache_dir data/processed/.cache/luna16_isoEach cached scan writes:
<seriesuid>_vol.npy
<seriesuid>_meta.json
Delete and rebuild the cache after changes to resampling, HU normalisation, or metadata handling.
Training uses Hydra configs in configs/ and LUNA16-style folds.
pulmodex train experiment=baseline
pulmodex train experiment=hybrid
pulmodex train experiment=fp_reductionCheckpoints are saved to checkpoints/. W&B logging is enabled automatically if
wandb is installed; disable it with WANDB_MODE=disabled.
Mock-data smoke test:
source .venv/bin/activate
pulmodex generate-mock-data --clean
WANDB_MODE=disabled pulmodex train experiment=baseline \
trainer.device=cuda \
data_dir=data/mock_luna16 \
data.patch_size=32 \
data.batch_size=1 \
data.num_workers=0 \
trainer.max_epochs=1With a precomputed mock cache:
pulmodex preprocess-cache \
--data_dir data/mock_luna16 \
--cache_dir data/mock_luna16/.cache/luna16_iso
WANDB_MODE=disabled pulmodex train experiment=baseline \
trainer.device=cuda \
data_dir=data/mock_luna16 \
data.cache_dir=data/mock_luna16/.cache/luna16_iso \
data.patch_size=32 \
data.batch_size=1 \
data.num_workers=0 \
trainer.max_epochs=1Mac CPU smoke test:
PYTORCH_ENABLE_MPS_FALLBACK=1 pulmodex train experiment=baseline \
trainer.max_epochs=1 \
data.patch_size=32 \
data.batch_size=1pulmodex evaluate \
--checkpoint checkpoints/baseline_best.ckpt \
--data_dir data/processed \
--split test \
--output outputs/eval_results.jsonEvaluation reports CPM, sensitivity at LUNA16 false-positive rates, and mean Dice.
CPM is the mean sensitivity at FP/scan values:
0.125, 0.25, 0.5, 1, 2, 4, 8
Matching is greedy by descending confidence. A prediction is counted as a true
positive when its centroid falls within diameter_mm / 2 of an annotation
centroid.
pulmodex export-onnx \
--checkpoint checkpoints/baseline_best.ckpt \
--model unet3d \
--output checkpoints/model.onnxONNX export currently supports Pulmodex project checkpoints only. It does not export MONAI bundle directories or standalone MONAI tutorial TorchScript files.
The inference web app can run fully in Docker Compose, while local development runs the app processes on the host and keeps Redis in Docker.
make docker-up
make docker-down
make docker-logsFor detached Docker mode:
make docker-startTo install Pulmodex as a systemd service that starts the Docker Compose stack at boot:
make install-systemdThis writes /etc/systemd/system/pulmodex.service, enables it, and starts it. Removing it is:
make uninstall-systemdThe production-style API container uses environment-driven worker settings. Adjust the .env values below as needed.
Configuration precedence for the web inference stack:
.envand process environment variablesconfigs/experiment/webapp.yamlconfigs/config.yaml
In other words, webapp.yaml provides web-specific defaults, and .env is the final override layer used by the running API and worker processes.
Important .env groups:
- Shared runtime:
DEVICE,LOG_LEVEL,CUDA_VISIBLE_DEVICES - Redis / async backend:
REDIS_URL,CELERY_BROKER_URL,CELERY_RESULT_BACKEND - API / worker process settings:
API_WORKERS,CELERY_WORKER_CONCURRENCY,CELERY_WORKER_LOGLEVEL - Primary detection model:
MODEL_CHECKPOINT,MODEL_BACKENDMODEL_CHECKPOINTcan point to either a project.ckptfile, a MONAI bundle directory, or a MONAI tutorial TorchScript.ptfile. SetMODEL_BACKEND=monai_bundleto force bundle mode,MODEL_BACKEND=monai_tutorialto force tutorial TorchScript mode, orMODEL_BACKEND=nativeto force the project-native loader. Leave it atautoto use path-based detection. - False-positive reduction model:
FP_CHECKPOINT,FP_THRESHOLD - Project-native candidate generation knobs:
CANDIDATE_THRESHOLD,MIN_CANDIDATE_VOXELS,PRIMARY_PATCH_SIZE
If MODEL_BACKEND=monai_bundle, MODEL_CHECKPOINT must point at a MONAI bundle directory. The worker uses the bundle's own preprocessing and detection config and then applies the local FP reduction model.
If MODEL_BACKEND=monai_tutorial, or if MODEL_BACKEND=auto and MODEL_CHECKPOINT points at a standalone .pt file produced by the MONAI tutorial luna16_training.py, the worker loads it as a TorchScript RetinaNet detector using the tutorial's LUNA16 defaults for anchors, score thresholds, and sliding-window patch size.
For local development, start Redis in Docker and run the API, worker, and frontend on the host:
make redis-up
make dev-api
make dev-worker
make dev-frontendOr run everything needed for development in one command:
make dev| Service | URL | Description |
|---|---|---|
| API | http://localhost:8011 | FastAPI — upload scans, poll jobs, fetch slices |
| Frontend | http://localhost:3000 | React — drag-drop upload, CT viewer, nodule list |
Docker host ports are configurable with API_HOST_PORT and FRONTEND_HOST_PORT in .env. The frontend proxies to the API over the internal Compose network, so changing API_HOST_PORT only affects direct host access to the API.
API endpoints:
POST /predict upload .zip DICOM series → {job_id, seriesuid}
GET /status/{job_id} poll Celery task state → {state, progress, result, error}
GET /slices/{uid}/{view}?idx=N&layer=... fetch rendered PNG slice layer
GET /slices/{uid}/{view}/index list available slice indices for a view
GET /scans list all completed scans (scan history), newest first
DELETE /scans/{uid} delete a saved scan and its rendered artefacts
GET /report/{uid} fetch JSON inference report for a scan
Supported slice layers:
layer=compositerenders the combined PNGlayer=basereturns the windowed CT slice with nodule square boxes drawn directly on itlayer=overlayreturns the transparent heatmap (warm yellow-orange overlay with per-pixel alpha from saliency intensity; final opacity controlled client-side)
Upload inputs:
- The frontend and API both only accept
.zipupload for DICOM series .zipuploads are unpacked on the API side; the largest enclosed DICOM series is converted to a temporary.mhdbefore the worker runs
python -m pytest -q
npm --prefix webapp test
npm --prefix webapp run test:e2eOr run the full configured suite:
make testsrc/
data/ LUNA16 dataset and preprocessing utilities
models/
baseline/ 3D U-Net
hybrid/ Res-U-Net + Swin Transformer
shared/ residual blocks, SE attention, losses
training/ Trainer class
evaluation/ FROC and Dice metrics
fp_reduction/ FP classifier and OHEM loss
interpretability/ Grad-CAM and Swin attention helpers
inference/ native pipeline, MONAI bundle adapter, MONAI tutorial adapter
webapp/ FastAPI API, Celery tasks, slice renderer
configs/
config.yaml
experiment/ baseline, hybrid, fp_reduction, webapp
scripts/ data conversion, cache, mock data, ONNX export
webapp/ React frontend
docker/ API, worker, and frontend Dockerfiles
tests/ Python tests
- Web/MONAI reports use RAS world coordinates for candidate boxes.
- LUNA16 data utilities work with
.mhd/.rawvolumes and CSV annotations. - HU preprocessing clamps lung CT values and normalises intensities for model input.
- Default native patch sizes: 128³ for training, 256³ for inference, 32³ for FP reduction.
- DICOM upload conversion uses
pydicomand SimpleITK.
MIT
[1] Project MONAI Tutorials, Detection workflows and examples:
https://github.com/Project-MONAI/tutorials/tree/main/detection
[2] Cardoso MJ, Li W, Brown R, et al. MONAI: An open-source framework for deep learning in healthcare.
https://arxiv.org/abs/2211.02701
[3] Lin TY, Goyal P, Girshick R, He K, Dollar P. Focal Loss for Dense Object Detection.
https://arxiv.org/abs/1708.02002
[4] Lin TY, Dollar P, Girshick R, He K, Hariharan B, Belongie S. Feature Pyramid Networks for Object Detection.
https://arxiv.org/abs/1612.03144
[5] Setio AAA, Traverso A, de Bel T, et al. Validation, comparison, and combination of algorithms for automatic detection of pulmonary nodules in CT: the LUNA16 challenge.
https://doi.org/10.1016/j.media.2017.06.015