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:

  1. Container starts → your server boots and loads the model
  2. Grand Challenge polls GET /health until it returns HTTP 200
  3. Grand Challenge places inputs at /input
  4. Grand Challenge calls POST /invoke
  5. Your code reads /input, runs a forward pass, writes to /output, and responds HTTP 201
  6. 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:

  1. Run the test
  2. Insert your code
  3. Update the Dockerfile
  4. Update requirements.txt
  5. Final test run
  6. 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 resources
  • requirements.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 of model/ into model.tar.gz for 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:

  1. Builds the container image
  2. Creates an isolated Docker network (no internet — mimicking Grand Challenge)
  3. Starts the container with your model mounted at /opt/ml/model
  4. Polls /health until ready (up to 30 attempts, 5 seconds apart)
  5. 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.6 is required for the T4 GPU instances used on Grand Challenge. The runtime image is sufficient for inference; devel is 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.sh checks for this label and errors if it's missing.

⚠️ Important — /tmp is wiped at runtime. It's fast scratch space (NVMe-backed) for transient files only. Anything you COPY into /tmp during 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 image
  • model.tar.gz — the model weights (from model/)

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.