Build and Test the Algorithm Container¶
Before you start¶
Goal of this tutorial:
By the end of this tutorial, you will have a working algorithm container and model tarball ready to upload to Grand Challenge.
Prerequisites:
To develop and test your algorithm locally, you will need to install Docker. GPU support is optional. The test script falls back to CPU automatically.
Terminology explained:
- Socket — a named input or output slot for your algorithm (e.g.
color-fundus,age-in-months). - Interface — a specific combination of input/output sockets. An algorithm can support more than one interface.
For more information about interfaces and sockets, have a look here.
Example algorithm for this tutorial:
In this tutorial, we will build an algorithm image for a U-Net that segments retinal blood vessels from the DRIVE Challenge. It takes a color fundus image (color-fundus) and the subject's age in months (age-in-months), and produces a vessel segmentation mask (binary-vessel-segmentation).
Algorithm page: Demo Vessel Segmentation
Algorithm template: https://github.com/DIAGNijmegen/demo-algorithm-template

How are algorithms run on Grand Challenge?¶
Your algorithm container will process one set of inputs at a time and should run as an HTTP server that Grand Challenge communicates with. The HTTP server in your container needs to expose 2 endpoints GET /health and POST /invoke and the lifecycle is as follows:
- Container starts → your server boots and loads the model
- Grand Challenge polls
GET /healthuntil it returns HTTP 200 - Grand Challenge places inputs at
/input - Grand Challenge calls
POST /invoke - Your code reads
/input, runs a forward pass, writes to/output, and responds HTTP 201 - Container stops and outputs are uploaded
The template provides the blueprint for a container that implements this lifecycle. Getting your algorithm ready for upload on Grand Challenge, then involves the following steps:
- Run the test
- Insert your code
- Update the Dockerfile
- Update requirements.txt
- Final test run
- Save and upload image
Template contents¶
├── Dockerfile
├── app.py
├── do_build.sh
├── do_save.sh
├── do_test_run.sh
├── inference.py
├── requirements.txt
├── model
│ └── a_tarball_subdirectory
│ └── some_tarball_resource.txt
└── test
└── input
└── interf0
├── inputs.json
├── age-in-months.json
└── images
└── color-fundus
└── 998dca01-2b74-4db5-802f-76ace545ec4b.mha
The key files are:
app.py- the HTTP server, which handles the /health and /invoke endpoints. You should NOT need to edit this.inference.py— YOUR algorithm code. This is where you implement init_model() and run(model).Dockerfile— where you configure the base image and additional resourcesrequirements.txt— where you list your Python dependencies
It also includes bash scripts for building, testing, and saving the algorithm image:
do_test_run.sh— tests your container for debugging (see below for details)do_save.sh— saves your container image as a gzipped tarball and packs the contents ofmodel/intomodel.tar.gzfor separate upload. Call this after a successful test run.do_build.sh— called by both other scripts to (re)build your container.
Step 1: Run the test¶
Before saving your container (which takes time and disk space), run a local test:
$ ./do_test_run.sh
This script:
- Builds the container image
- Creates an isolated Docker network (no internet — mimicking Grand Challenge)
- Starts the container with your model mounted at
/opt/ml/model - Polls
/healthuntil ready (up to 30 attempts, 5 seconds apart) - For each interface, provisions test inputs into
/input, calls/invoke, and collects the output
GPU support is auto-detected; if unavailable (e.g. on macOS) it falls back to CPU. This doesn't affect the exported container — on Grand Challenge, GPU access depends only on your algorithm's settings.
Note: The first build may take a while since it will download the base image layers. Rebuilds are much faster. Re-run this test frequently as you make changes, so you can isolate what broke.
Expand the test (optional)¶
To automate checks — e.g. confirming output was actually generated — wrap the script in a test file:
import subprocess from pathlib import Path OUTPUT_DIR = Path("test") / "output" def test_do_test_run(): result = subprocess.run(["./do_test_run.sh"], capture_output=True, text=True) assert result.returncode == 0, f"Script failed with error: {result.stderr}" expected_output = OUTPUT_DIR / "interf0" / "images" / "binary-vessel-segmentation" / "output.mha" assert expected_output.exists()
Run with python test.py (requires pytest). Extend it further to check output content, e.g. voxel value ranges.
Step 2: Insert your code¶
Edit inference.py. This file contains 2 functions which you will need to edit:
init_model()— runs once at server startup; loads and returns your model.run(model)— runs per case; determines the active interface, dispatches to the right handler, reads/input, runs inference, writes to/output.
Model loading is separated from inference because run(model) executes per case and is subject to a per-case timeout. Loading a large model inside the run() function could leave insufficient time for actual inference. Instead, make sure to load your model in init_model().
Model weights are stored in the model/ directory locally, which is mounted at /opt/ml/model inside the container at runtime. You can later upload them separately from the container image to your algorithm (see Step 6 below). This separation keeps the container image small and means model updates don't require a full rebuild.
Load your model weights¶
For our demo algorithm, place some your model weights in the model/ directory, then load it in init_model():
def init_model(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = monai.networks.nets.UNet( spatial_dims=2, in_channels=3, out_channels=1, channels=(16, 32, 64, 128, 256), strides=(2, 2, 2, 2), num_res_units=2, ).to(device) model_dir = Path("/opt/ml/model") model.load_state_dict(torch.load( model_dir / "best_metric_model.pth" )) model.eval() return model
How run() dispatches to interface handlers¶
When /invoke is called, Grand Challenge writes /input/inputs.json, describing which sockets are active for this case. run() reads this file and uses the sorted socket slugs as a lookup key to select the right handler:
def run(model): interface_key = get_interface_key() handler = { ("age-in-months", "color-fundus"): interf0_handler, }[interface_key] return handler(model) def get_interface_key(): """Read inputs.json to determine which interface is active.""" inputs = load_json_file(location=INPUT_PATH / "inputs.json") socket_slugs = [sv["socket"]["slug"] for sv in inputs] return tuple(sorted(socket_slugs))
An algorithm with a single interface (as in this example) has one entry in the dictionary; multiple interfaces each get their own handler function.
Write the handler, with pre/postprocessing¶
Each handler follows the same pattern: read inputs, run inference, write outputs. Typically you'll also need a preprocess step (preparing input for the model) and a postprocess step (e.g. thresholding after the forward pass):
def preprocess(image, device): input_tensor = torch.from_numpy(image).float() input_tensor = input_tensor.permute(2, 0, 1) # [H,W,C] -> [C,H,W] input_tensor = input_tensor.unsqueeze(0) # add batch dim input_tensor = input_tensor.to(device) height, width = image.shape[:2] pad_height = (16 - (height % 16)) % 16 pad_width = (16 - (width % 16)) % 16 padding = (pad_width // 2, pad_width - pad_width // 2, pad_height // 2, pad_height - pad_height // 2) return F.pad(input_tensor, padding) def postprocess(image, shape): image = transform.resize(image, shape[:-1], order=3) image = (expit(image) > 0.80) return (image * 255).astype(np.uint8) def interf0_handler(model): input_color_fundus_image = load_image_file_as_array( location=INPUT_PATH / "images/color-fundus", ) input_age_in_months = load_json_file( location=INPUT_PATH / "age-in-months.json", ) # not used in this example device = next(model.parameters()).device input_tensor = preprocess(image=input_color_fundus_image, device=device) with torch.no_grad(): out = model(input_tensor).squeeze().detach().cpu().numpy() output_binary_vessel_segmentation = postprocess( image=out, shape=input_color_fundus_image.shape ) write_array_as_image_file( location=OUTPUT_PATH / "images/binary-vessel-segmentation", array=output_binary_vessel_segmentation, ) return 0
Step 3: Update the Dockerfile¶
Set the correct base image on the first line. For this tutorial, we will use the official PyTorch image, which includes the required CUDA setup:
FROM --platform=linux/amd64 pytorch/pytorch:2.9.1-cuda12.6-cudnn9-runtime
If using TensorFlow, use TensorFlow's official base image instead. Browse Docker Hub for alternatives.
⚠️ Notes on image selection:
cuda12.6is required for the T4 GPU instances used on Grand Challenge. Theruntimeimage is sufficient for inference;develis only needed if you compile custom CUDA kernels.⚠️ Important — no internet access at runtime. All files your code needs must be included in the container via
COPY, e.g.:COPY --chown=user:user requirements.txt /opt/app/⚠️ Important — don't remove this line:
LABEL org.grand-challenge.api-method="invoke"Grand Challenge uses it to detect that your container implements the invoke API — without it, the container won't work.do_test_run.shchecks for this label and errors if it's missing.⚠️ Important —
/tmpis wiped at runtime. It's fast scratch space (NVMe-backed) for transient files only. Anything youCOPYinto/tmpduring build will not persist at runtime — put such files elsewhere, e.g. a subdirectory of/opt.
Here are some best practices for configuring your Dockerfile.
Step 4: Update requirements.txt¶
List all dependencies with pinned versions:
SimpleITK numpy monai==1.4.0 scikit-learn scipy scikit-image fastapi uvicorn
Don't include torch — it's already provided by the PyTorch base image.
Step 5: Final test run¶
Add a representative example input image to test/input/interf0/images/color-fundus, then re-run:
$ ./do_test_run.sh
This builds the container, starts the server on an isolated network (no internet, matching production), waits for /health, provisions the input, and calls /invoke. On success, a binary segmentation appears at test/output/interf0/images/binary-vessel-segmentation.
Step 6: Save and upload¶
$ ./do_save.sh
This produces:
example_algorithm_<slug>_<timestamp>.tar.gz— the container imagemodel.tar.gz— the model weights (frommodel/)
Upload the container image tarball to your algorithm (Your algorithm → Containers) and upload model.tar.gz separately under Your algorithm → Models.
Once both are uploaded and verified, your algorithm is ready to be used on Grand Challenge. Try it out via Your algorithm → Try out Algorithm.