Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.git
.gitignore
logs
static
data/wsi
tmp
**/__pycache__/
*.pyc
*.egg-info
.env
.venv
.pytest_cache
.idea
.vscode
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.sh text eol=lf
Dockerfile text eol=lf
79 changes: 79 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: Build and publish Docker image

on:
push:
branches: [main]
tags: ['v*']
pull_request:
paths:
- Dockerfile
- requirements.txt
- setup.py
- .dockerignore
- .github/workflows/docker.yml
workflow_dispatch:

concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true

jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4

- name: Set lowercase image name
id: img
run: echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/ectil-inference" >> "$GITHUB_OUTPUT"

- uses: docker/setup-buildx-action@v3

# Same-repo PRs get a publishable :pr-N tag so reviewers can `docker pull`
# the branch instead of building locally. Forked PRs stay build-only —
# they don't get a writable GITHUB_TOKEN anyway, and we don't want
# unreviewed fork code pushing tags to our registry.
- name: Decide whether to push
id: push
run: |
if [[ "${{ github.event_name }}" == "pull_request" \
&& "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi

- name: Log in to GHCR
if: steps.push.outputs.enabled == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Compute tags
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.img.outputs.name }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=short
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}

- name: Build (and push for non-PRs and same-repo PRs)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: ${{ steps.push.outputs.enabled == 'true' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# project specific
pyrightconfig.json
data/wsi
data/inference_output
model_zoo/**/*.ckpt
model_zoo/**/*.pth
tmp
Expand Down
69 changes: 69 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# ECTIL inference image.
#
# Get the image — either pull the published one or build it yourself:
# docker pull ghcr.io/nki-ai/ectil-inference:latest
# # or
# docker build -t ghcr.io/nki-ai/ectil-inference:latest .
#
# Run (mount the WSI, the weights, and an output directory):
# docker run --rm \
# -v /path/to/slides:/input:ro \
# -v /path/to/weights:/weights:ro \
# -v /path/to/output:/output \
# ghcr.io/nki-ai/ectil-inference:latest \
# --wsi /input/slide.svs \
# --classifier-weights /weights/ectil_fold_0_weights_only.ckpt \
# --retccl-weights /weights/retccl_best_ckpt.pth \
# --output /output
#
# Add `--gpus all` to `docker run` and `--device cuda` to the command for GPU.
#
# Weights are NOT bundled in the image; mount them at runtime.
# - ECTIL classifier: https://files.aiforoncology.nl/ectil (see model_zoo/ectil/tcga/readme.md)
# - RetCCL encoder: see model_zoo/retccl/readme.md
# If --retccl-weights is omitted it defaults to /app/model_zoo/retccl/retccl_best_ckpt.pth
# or the RETCCL_WEIGHTS environment variable.

# Pinned (not :latest) so the build is reproducible. The :latest tag moved to
# conda 26.x in April 2026, where the conda-forge solve for the WSI libs below
# can drag GraalPy into the env and break the pip install of torch==2.4.1
# ("Could not find a version that satisfies the requirement torch==2.4.1").
FROM continuumio/miniconda3:24.11.1-0

ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*

# Python 3.10.9 plus the WSI system libraries via conda-forge (mirrors README install).
# Force the CPython build of python and keep it pinned across the second install
# so newer conda solvers can't swap it for graalpy when resolving conda-forge deps
# (that swap silently breaks the torch==2.4.1 pip install in the next layer).
RUN conda create -y -n ectil -c conda-forge "python=3.10.9=*_cpython" \
&& echo "python 3.10.9" > /opt/conda/envs/ectil/conda-meta/pinned \
&& conda install -y -n ectil -c conda-forge openslide pixman libvips \
&& conda clean -afy

ENV PATH=/opt/conda/envs/ectil/bin:$PATH
ENV CONDA_DEFAULT_ENV=ectil

WORKDIR /app

# Install Python dependencies first for better layer caching.
# Pin the build toolchain as a matched set: an old pip (23.3.2) paired with a
# newer setuptools whose `_core_metadata` calls `canonicalize_version(..., strip_trailing_zero=)`
# needs a `packaging` >= 23.2 that actually has that kwarg, otherwise the editable
# install of this package below dies with
# TypeError: canonicalize_version() got an unexpected keyword argument 'strip_trailing_zero'
# Pinning setuptools/wheel/packaging together keeps the toolchain self-consistent.
COPY requirements.txt setup.py ./
RUN python -m pip install --no-cache-dir \
pip==23.3.2 setuptools==69.5.1 wheel==0.43.0 packaging==24.0 \
&& python -m pip install --no-cache-dir -r requirements.txt

# Install the ectil package itself.
COPY . .
RUN python -m pip install --no-cache-dir --no-deps -e .

ENTRYPOINT ["python", "-m", "ectil.inference"]
CMD ["--help"]
Loading
Loading