From d657a1112c5d3d4c7c3a29ef90e35989e51571bd Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Fri, 6 Mar 2026 00:49:17 +0100 Subject: [PATCH 1/6] fix EGL to be set before GL is loaded --- whippersnappy/gl/__init__.py | 11 ++++++--- whippersnappy/gl/_headless.py | 30 ++++++++++++---------- whippersnappy/gl/context.py | 44 ++++++++++++++++++++------------- whippersnappy/gl/egl_context.py | 6 ++--- 4 files changed, 54 insertions(+), 37 deletions(-) diff --git a/whippersnappy/gl/__init__.py b/whippersnappy/gl/__init__.py index 1dc0183..5859522 100644 --- a/whippersnappy/gl/__init__.py +++ b/whippersnappy/gl/__init__.py @@ -3,11 +3,14 @@ This package contains the low-level OpenGL helpers used by the renderers: - :mod:`~whippersnappy.gl._headless` — headless platform detection; sets - ``PYOPENGL_PLATFORM`` on headless Linux *before* any ``OpenGL.GL`` import. - Imported first here and in every GL submodule so the guard takes effect even - when a submodule is imported directly. + ``PYOPENGL_PLATFORM`` to ``"egl"`` or ``"osmesa"`` on headless Linux + *before* any ``OpenGL.GL`` import. Imported first here and in every GL + submodule so the guard takes effect even when a submodule is imported + directly. PyOpenGL binds its platform backend on the first + ``import OpenGL.GL`` and cannot be changed afterwards, so this ordering + is critical. - :mod:`~whippersnappy.gl.context` — context lifecycle (create, capture, destroy). - On Linux tries GLFW → EGL (GPU) → OSMesa (CPU) in order. + On Linux tries GLFW → EGL (only when ``PYOPENGL_PLATFORM=egl``) → OSMesa. - :mod:`~whippersnappy.gl.egl_context` — EGL headless GPU context (Linux). - :mod:`~whippersnappy.gl.osmesa_context` — OSMesa headless CPU context (Linux). - :mod:`~whippersnappy.gl.pipeline` — one-time GPU upload: VAO, buffers, uniforms, shader setup. diff --git a/whippersnappy/gl/_headless.py b/whippersnappy/gl/_headless.py index 4f705f2..3a8214a 100644 --- a/whippersnappy/gl/_headless.py +++ b/whippersnappy/gl/_headless.py @@ -2,21 +2,23 @@ This module MUST be imported before any ``import OpenGL.GL`` statement in the package. On Linux with no display server it sets ``PYOPENGL_PLATFORM`` so -that PyOpenGL resolves function pointers via the right backend. +that PyOpenGL resolves function pointers via the correct backend before +``OpenGL.GL`` is first imported. Priority chain when no display is detected (Linux only): 1. **EGL + GPU device** — ``/dev/dri/renderD*`` readable and ``libEGL`` - loadable. ``PYOPENGL_PLATFORM`` is left **unset** here so that - :mod:`whippersnappy.gl.egl_context` can set it to ``"egl"`` before - ``OpenGL.GL`` is imported. + loadable. Sets ``PYOPENGL_PLATFORM=egl`` immediately so that PyOpenGL + binds function pointers via EGL when ``OpenGL.GL`` is first imported. 2. **OSMesa** — CPU software renderer. Sets ``PYOPENGL_PLATFORM=osmesa``. 3. **Neither** — raises ``RuntimeError`` with install instructions. -When GLFW fails even though ``DISPLAY`` is set (e.g. ``ssh -Y`` with a -forwarded X that lacks GLX 3.3), ``PYOPENGL_PLATFORM`` is already bound to -whatever was set at import time. :func:`init_offscreen_context` handles -this by trying EGL as a second step after GLFW failure, before OSMesa. +When ``DISPLAY`` is set (e.g. normal desktop or ``ssh -Y``), ``_headless`` +does not intervene: GLFW is tried first in :func:`init_offscreen_context`. +If GLFW fails (e.g. GLX 3.3 unavailable on the forwarded display), EGL is +attempted only when ``PYOPENGL_PLATFORM`` was already set to ``"egl"`` by +this module at import time — i.e. only for the no-display + EGL-device case. +In all other GLFW-failure scenarios, OSMesa is used as the final fallback. No OpenGL, GLFW, or other heavy imports are done here — only stdlib. """ @@ -78,11 +80,13 @@ def egl_device_is_available(): and not os.environ.get("WAYLAND_DISPLAY") ): if egl_device_is_available(): - # Defer: egl_context.py will set PYOPENGL_PLATFORM=egl before importing - # OpenGL.GL. Do NOT set osmesa here or GL will bind to the wrong backend. + # Set PYOPENGL_PLATFORM=egl NOW, before OpenGL.GL is imported anywhere. + # PyOpenGL selects its platform backend on first import and cannot be + # changed afterwards; deferring to egl_context.py would mean OpenGL.GL + # is already bound to the wrong backend by the time EGL is tried. + os.environ["PYOPENGL_PLATFORM"] = "egl" logger.debug( - "No display, EGL + GPU device available — " - "deferring platform selection to EGL context creation." + "No display, EGL + GPU device available — PYOPENGL_PLATFORM=egl set." ) elif _osmesa_is_available(): os.environ["PYOPENGL_PLATFORM"] = "osmesa" @@ -109,6 +113,6 @@ def egl_device_is_available(): elif sys.platform == "linux": _display = os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY") logger.debug( - "Display set (%s) — will try GLFW; EGL on failure if GPU device available.", + "Display set (%s) — will try GLFW first.", _display, ) diff --git a/whippersnappy/gl/context.py b/whippersnappy/gl/context.py index 3043a20..a3639b6 100644 --- a/whippersnappy/gl/context.py +++ b/whippersnappy/gl/context.py @@ -3,22 +3,26 @@ Owns the full lifecycle of an OpenGL context — creation, scene rendering, framebuffer capture, and teardown. -Context creation tries three paths in order (Linux; macOS/Windows use GLFW only): +Context creation tries up to three paths (Linux; macOS/Windows use GLFW only): 1. **GLFW invisible window** — standard path when a display is available. -2. **EGL pbuffer** — headless GPU rendering; no display required. - Tried when GLFW fails for any reason, including when ``DISPLAY`` is set - via ``ssh -Y`` but the forwarded X lacks GLX 3.3. Requires - ``libEGL`` and an accessible ``/dev/dri/renderD*`` device node. -3. **OSMesa** — CPU software renderer; no GPU required. +2. **EGL pbuffer** — headless GPU rendering (Linux, no display needed). + Only used when :mod:`~whippersnappy.gl._headless` set + ``PYOPENGL_PLATFORM=egl`` at import time (no display + accessible + ``/dev/dri/renderD*``). PyOpenGL selects its platform backend on the + first ``import OpenGL.GL`` and cannot be changed afterwards — so EGL is + only safe when it was selected before any ``OpenGL.GL`` import. +3. **OSMesa** — CPU software renderer (Linux only). Used when neither GLFW nor EGL succeeds. The :mod:`whippersnappy.gl._headless` guard runs before ``OpenGL.GL`` is -imported and sets ``PYOPENGL_PLATFORM`` appropriately (or defers to EGL). +imported and sets ``PYOPENGL_PLATFORM`` to ``"egl"`` or ``"osmesa"`` +as appropriate. """ # ruff: noqa: I001 — import order is intentional: _headless must precede OpenGL.GL import logging +import os import sys import warnings from typing import Any @@ -136,15 +140,18 @@ def init_window(width, height, title="WhipperSnapPy", visible=True): def init_offscreen_context(width, height): """Create an invisible OpenGL context for off-screen rendering. - Tries three paths in order on Linux; macOS and Windows use GLFW only. + Tries up to three paths on Linux; macOS and Windows use GLFW only. 1. **GLFW invisible window** — standard path when a display is available. - 2. **EGL pbuffer** — headless GPU rendering (Linux only). Attempted - whenever GLFW fails, including when ``DISPLAY`` is set via ``ssh -Y`` - but the forwarded X server lacks GLX 3.3. Requires ``libEGL`` and - an accessible ``/dev/dri/renderD*`` device node. + 2. **EGL pbuffer** — headless GPU rendering (Linux only, no display needed). + Only attempted when :mod:`~whippersnappy.gl._headless` already set + ``PYOPENGL_PLATFORM=egl`` at import time (i.e. no display detected AND + ``/dev/dri/renderD*`` is accessible). This guarantees ``OpenGL.GL`` + was bound to the EGL backend before any GL call; attempting EGL after + ``OpenGL.GL`` has already been imported with a different backend would + silently break function resolution. 3. **OSMesa** — CPU software renderer (Linux only). Used when neither - GLFW nor EGL succeeds. + GLFW nor EGL succeeds, or when ``PYOPENGL_PLATFORM=osmesa`` was set. Parameters ---------- @@ -181,10 +188,13 @@ def init_offscreen_context(width, height): ) # --- Step 2: EGL headless GPU rendering --- - # Tried whenever GLFW fails: no display, or DISPLAY set but GLX unavailable - # (e.g. ssh -Y with software X forwarding that lacks GLX 3.3). - from ._headless import egl_device_is_available # noqa: PLC0415 - if egl_device_is_available(): + # Only safe when PYOPENGL_PLATFORM=egl was set by _headless.py before + # OpenGL.GL was imported — meaning the process has no display AND an EGL + # device was found at import time. PyOpenGL binds its platform backend on + # first import and cannot be switched afterwards; importing egl_context.py + # here when PYOPENGL_PLATFORM is already something else (e.g. "osmesa" or + # unset/GLX) would cause silent function-pointer mismatches. + if os.environ.get("PYOPENGL_PLATFORM") == "egl": logger.info("GLFW failed — trying EGL headless GPU rendering.") try: from .egl_context import EGLContext # noqa: PLC0415 diff --git a/whippersnappy/gl/egl_context.py b/whippersnappy/gl/egl_context.py index 8f74b28..517365f 100644 --- a/whippersnappy/gl/egl_context.py +++ b/whippersnappy/gl/egl_context.py @@ -30,9 +30,9 @@ if sys.platform == "darwin": raise ImportError("EGL is not available on macOS; use GLFW/CGL instead.") -# Must be set before OpenGL.GL is imported anywhere in the process. -# If already set (e.g. user set it, or GLFW succeeded), respect it. -# We set it here because this module is only imported when EGL is needed. +# PYOPENGL_PLATFORM=egl should already be set by _headless.py before +# OpenGL.GL was first imported in this process. This guard is a safety net +# for the case where egl_context is imported in isolation (e.g. in tests). if os.environ.get("PYOPENGL_PLATFORM") != "egl": os.environ["PYOPENGL_PLATFORM"] = "egl" From 774d991738dadc62b9a951a4376f28ebd5479440 Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Fri, 6 Mar 2026 00:53:08 +0100 Subject: [PATCH 2/6] fix: raise on PYOPENGL_PLATFORM conflict in egl_context.py Previously the module silently overwrote any existing PYOPENGL_PLATFORM value (other than 'egl') when imported, which could cause hard-to-debug mixed-platform GL state if e.g. 'osmesa' had already been set. Now: - If PYOPENGL_PLATFORM is unset: set it to 'egl' (existing behaviour). - If PYOPENGL_PLATFORM == 'egl': no-op (already correct). - If PYOPENGL_PLATFORM is set to anything else: raise RuntimeError with a clear explanation, instead of silently overwriting it. --- whippersnappy/gl/egl_context.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/whippersnappy/gl/egl_context.py b/whippersnappy/gl/egl_context.py index 517365f..46b0d6e 100644 --- a/whippersnappy/gl/egl_context.py +++ b/whippersnappy/gl/egl_context.py @@ -33,11 +33,21 @@ # PYOPENGL_PLATFORM=egl should already be set by _headless.py before # OpenGL.GL was first imported in this process. This guard is a safety net # for the case where egl_context is imported in isolation (e.g. in tests). -if os.environ.get("PYOPENGL_PLATFORM") != "egl": +_current_platform = os.environ.get("PYOPENGL_PLATFORM") +if _current_platform is None: os.environ["PYOPENGL_PLATFORM"] = "egl" - -import OpenGL.GL as gl -from PIL import Image +elif _current_platform != "egl": + raise RuntimeError( + f"PYOPENGL_PLATFORM is already set to {_current_platform!r}, " + "but whippersnappy.gl.egl_context requires 'egl'. " + "Importing egl_context with a different platform already bound would " + "cause silent GL function-pointer mismatches. " + "Unset PYOPENGL_PLATFORM or set it to 'egl' before importing any " + "OpenGL modules." + ) + +import OpenGL.GL as gl # noqa: E402 +from PIL import Image # noqa: E402 logger = logging.getLogger(__name__) From 61b69cdc5278c061c7289ec40645f1c43381ccc3 Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Fri, 6 Mar 2026 00:56:41 +0100 Subject: [PATCH 3/6] fix: update capture_window and terminate_context param docs for EGL Both docstrings said window=None only when 'an OSMesa context is active', but EGL offscreen contexts also return window=None from init_offscreen_context. Updated both Parameters sections to say 'EGL or OSMesa offscreen context'. --- whippersnappy/gl/context.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/whippersnappy/gl/context.py b/whippersnappy/gl/context.py index a3639b6..60ab95c 100644 --- a/whippersnappy/gl/context.py +++ b/whippersnappy/gl/context.py @@ -228,14 +228,14 @@ def terminate_context(window): """Release the active OpenGL context regardless of how it was created. This is a drop-in replacement for ``glfw.terminate()`` that also - handles the OSMesa headless path. Call it at the end of every rendering - function instead of calling ``glfw.terminate()`` directly. + handles EGL and OSMesa headless paths. Call it at the end of every + rendering function instead of calling ``glfw.terminate()`` directly. Parameters ---------- window : GLFWwindow or None The GLFW window handle returned by :func:`init_offscreen_context`, - or ``None`` when an OSMesa context is active. + or ``None`` when an EGL or OSMesa offscreen context is active. """ global _offscreen_context if _offscreen_context is not None: @@ -259,7 +259,8 @@ def capture_window(window): Parameters ---------- window : GLFWwindow or None - GLFW window handle, or ``None`` when an OSMesa context is active. + GLFW window handle, or ``None`` when an EGL or OSMesa offscreen + context is active. Returns ------- From 240611954672381b9c83f74b38cbd68c020b48fa Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Fri, 6 Mar 2026 01:02:01 +0100 Subject: [PATCH 4/6] =?UTF-8?q?docs:=20update=20README=20headless=20sectio?= =?UTF-8?q?n=20to=20describe=20GLFW=E2=86=92EGL=E2=86=92OSMesa=20chain=20P?= =?UTF-8?q?reviously=20only=20mentioned=20OSMesa.=20Now=20describes=20all?= =?UTF-8?q?=20three=20paths:=201.=20GLFW=20invisible=20window=20(display?= =?UTF-8?q?=20available)=202.=20EGL=20GPU=20rendering=20(no=20display,=20/?= =?UTF-8?q?dev/dri/renderD*=20accessible)=20=E2=80=94=20new=203.=20OSMesa?= =?UTF-8?q?=20CPU=20fallback=20Adds=20install=20hint=20for=20libegl1=20and?= =?UTF-8?q?=20notes=20EGL=20as=20the=20recommended=20path=20for=20SSH=20se?= =?UTF-8?q?rvers=20with=20a=20GPU.=20Docker=20section=20clarified=20to=20s?= =?UTF-8?q?ay=20OSMesa=20is=20used=20there=20specifically=20(no=20GPU=20de?= =?UTF-8?q?vice=20in=20standard=20containers).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1ca6518..994da30 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,18 @@ For interactive 3D in Jupyter notebooks: pip install 'whippersnappy[notebook]' ``` -Off-screen (headless) rendering on **Linux** is supported natively via -OSMesa — no `xvfb` or GPU required. On **Windows**, GLFW creates an -invisible window; a GPU driver is sufficient — no display needed. On -**macOS**, a real display connection is required (NSGL does not support +Off-screen (headless) rendering on **Linux** uses a three-path fallback: +1. **GLFW invisible window** — used when a display is available (`DISPLAY` set). +2. **EGL** (GPU, no display needed) — used when no display is detected and a + GPU render device (`/dev/dri/renderD*`) is accessible with `libEGL` installed + (`libegl1` on Debian/Ubuntu). This is the recommended path for SSH servers + with a GPU — no `DISPLAY`, `xvfb`, or OSMesa required. +3. **OSMesa** (CPU software renderer) — final fallback; requires + `sudo apt-get install libosmesa6` (Debian/Ubuntu) or + `sudo dnf install mesa-libOSMesa` (RHEL/Fedora). + +On **Windows**, GLFW creates an invisible window; a GPU driver is sufficient. +On **macOS**, a real display connection is required (NSGL does not support headless rendering). See the Docker guide for headless Linux usage. @@ -209,8 +217,9 @@ See `tutorials/whippersnappy_tutorial.ipynb` for complete notebook examples. ## Docker -The Docker image provides a fully headless OSMesa rendering environment — no -display server, `xvfb`, or GPU required. See DOCKER.md for details. +The Docker image provides a fully headless rendering environment using +OSMesa (CPU software renderer) — no display server, `xvfb`, or GPU required. +See DOCKER.md for details. ## API Documentation From a41f909a82ab3f14040fd454600acdaf5a307e93 Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Fri, 6 Mar 2026 01:03:48 +0100 Subject: [PATCH 5/6] docs: update DOCKER.md to mention optional EGL GPU rendering - Opening paragraph: note GPU rendering is possible with --device - Notes section: split into 'default OSMesa' and 'optional EGL GPU' bullets, add --device example and libegl1 install note. OSMesa remains the default since no /dev/dri/ device is exposed in standard Docker containers. --- DOCKER.md | 194 ------------------------------------------------------ 1 file changed, 194 deletions(-) diff --git a/DOCKER.md b/DOCKER.md index 8b808d9..e69de29 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -1,194 +0,0 @@ -# Docker Guide - -The Docker image provides a fully headless rendering environment using -[OSMesa](https://docs.mesa3d.org/osmesa.html) (Mesa's off-screen software -renderer). No display server, `xvfb`, or GPU is required — rendering runs -entirely in software on the CPU. - -The default entry point is `whippersnap4` (four-view batch rendering). -`whippersnap1` (single-view snapshot and rotation video) can be invoked by -overriding the entry point. - ---- - -## Building the image - -From the repository root: - -```bash -docker build --rm -t whippersnappy -f Dockerfile . -``` - ---- - -## Running — four-view batch rendering (`whippersnap4`) - -`whippersnap4` renders lateral and medial views of both hemispheres and writes -a single composed PNG image. - -Mount your local directories into the container and pass the in-container paths -as arguments: - -```bash -docker run --rm --init \ - -v /path/to/subject:/subject \ - -v /path/to/output:/output \ - --user $(id -u):$(id -g) \ - whippersnappy \ - -lh /subject/surf/lh.thickness \ - -rh /subject/surf/rh.thickness \ - -sd /subject \ - -o /output/snap4.png -``` - -### With an annotation file instead of an overlay - -```bash -docker run --rm --init \ - -v /path/to/subject:/subject \ - -v /path/to/output:/output \ - --user $(id -u):$(id -g) \ - whippersnappy \ - --lh_annot /subject/label/lh.aparc.annot \ - --rh_annot /subject/label/rh.aparc.annot \ - -sd /subject \ - -o /output/snap4_annot.png -``` - -### With a caption and custom thresholds - -```bash -docker run --rm --init \ - -v /path/to/subject:/subject \ - -v /path/to/output:/output \ - --user $(id -u):$(id -g) \ - whippersnappy \ - -lh /subject/surf/lh.thickness \ - -rh /subject/surf/rh.thickness \ - -sd /subject \ - --fthresh 2.0 --fmax 4.0 \ - --caption "Cortical thickness" \ - -o /output/snap4_thickness.png -``` - -### All `whippersnap4` options - -``` -docker run --rm whippersnappy --help -``` - ---- - -## Running — single-view snapshot (`whippersnap1`) - -Override the entry point with `--entrypoint whippersnap1`. Any triangular -mesh format is supported: FreeSurfer binary surfaces, OFF, ASCII VTK -PolyData, ASCII PLY, and GIfTI (`.gii`, `.surf.gii`). - -### FreeSurfer surface - -```bash -docker run --rm --init \ - --entrypoint whippersnap1 \ - -v /path/to/subject:/subject \ - -v /path/to/output:/output \ - --user $(id -u):$(id -g) \ - whippersnappy \ - --mesh /subject/surf/lh.white \ - --overlay /subject/surf/lh.thickness \ - --bg-map /subject/surf/lh.curv \ - --roi /subject/label/lh.cortex.label \ - --view left \ - --fthresh 2.0 --fmax 4.0 \ - -o /output/snap1.png -``` - -### OFF / VTK / PLY / GIfTI mesh - -```bash -docker run --rm --init \ - --entrypoint whippersnap1 \ - -v /path/to/data:/data \ - -v /path/to/output:/output \ - --user $(id -u):$(id -g) \ - whippersnappy \ - --mesh /data/mesh.off \ - --overlay /data/values.txt \ - -o /output/snap1.png -``` - -### All `whippersnap1` options - -```bash -docker run --rm --entrypoint whippersnap1 whippersnappy --help -``` - ---- - -## Running — 360° rotation video (`whippersnap1 --rotate`) - -`whippersnap1 --rotate` renders a full 360° rotation video and writes an -`.mp4`, `.webm`, or `.gif` file. `imageio-ffmpeg` is bundled in the image — -no system ffmpeg is required. - -### MP4 (H.264, recommended) - -```bash -docker run --rm --init \ - --entrypoint whippersnap1 \ - -v /path/to/subject:/subject \ - -v /path/to/output:/output \ - --user $(id -u):$(id -g) \ - whippersnappy \ - --mesh /subject/surf/lh.white \ - --overlay /subject/surf/lh.thickness \ - --bg-map /subject/surf/lh.curv \ - --rotate \ - --rotate-frames 72 \ - --rotate-fps 24 \ - -o /output/rotation.mp4 -``` - -### Animated GIF (no ffmpeg needed) - -```bash -docker run --rm --init \ - --entrypoint whippersnap1 \ - -v /path/to/subject:/subject \ - -v /path/to/output:/output \ - --user $(id -u):$(id -g) \ - whippersnappy \ - --mesh /subject/surf/lh.white \ - --overlay /subject/surf/lh.thickness \ - --rotate \ - --rotate-frames 36 \ - --rotate-fps 12 \ - -o /output/rotation.gif -``` - ---- - -## Path mapping summary - -| Host path | Container path | Purpose | -|-----------|---------------|---------| -| `/path/to/subject` | `/subject` | FreeSurfer subject directory (contains `surf/`, `label/`) | -| `/path/to/output` | `/output` | Directory where output files are written | - -All output files are written to the container path you pass via `-o`; mount the -parent directory to retrieve them on the host. - ---- - -## Notes - -- The `--init` flag is recommended so that signals (e.g. `Ctrl-C`) are handled - correctly inside the container. -- `--user $(id -u):$(id -g)` ensures output files are owned by your host user, - not root. -- The interactive GUI (`whippersnap`) is **not** available in the Docker image — - it requires a display server and PyQt6, which are not installed. -- Headless rendering uses **OSMesa** (Mesa's CPU software renderer, provided by - the `libosmesa6` system package). No GPU, no `/dev/dri/` device, and no - `--privileged` flag are needed. - From 9f0f9dae81abaa0df1a87cc7ebfd9503795f6c16 Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Fri, 6 Mar 2026 01:06:54 +0100 Subject: [PATCH 6/6] update docker to also contain EGL just in case rendering device is available --- DOCKER.md | 206 +++++++++++++++++++++++++++++++++++++++++++++++++++++ Dockerfile | 7 +- 2 files changed, 211 insertions(+), 2 deletions(-) diff --git a/DOCKER.md b/DOCKER.md index e69de29..3e56d2b 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -0,0 +1,206 @@ +# Docker Guide + +The Docker image provides a fully headless rendering environment. By default +it uses [OSMesa](https://docs.mesa3d.org/osmesa.html) (Mesa's CPU software +renderer) — no display server, `xvfb`, or GPU required. If a GPU render +device is available, pass `--device /dev/dri/renderD128` to enable EGL GPU +rendering instead; `libegl1` is already included in the image. + +The default entry point is `whippersnap4` (four-view batch rendering). +`whippersnap1` (single-view snapshot and rotation video) can be invoked by +overriding the entry point. + +--- + +## Building the image + +From the repository root: + +```bash +docker build --rm -t whippersnappy -f Dockerfile . +``` + +--- + +## Running — four-view batch rendering (`whippersnap4`) + +`whippersnap4` renders lateral and medial views of both hemispheres and writes +a single composed PNG image. + +Mount your local directories into the container and pass the in-container paths +as arguments: + +```bash +docker run --rm --init \ + -v /path/to/subject:/subject \ + -v /path/to/output:/output \ + --user $(id -u):$(id -g) \ + whippersnappy \ + -lh /subject/surf/lh.thickness \ + -rh /subject/surf/rh.thickness \ + -sd /subject \ + -o /output/snap4.png +``` + +### With an annotation file instead of an overlay + +```bash +docker run --rm --init \ + -v /path/to/subject:/subject \ + -v /path/to/output:/output \ + --user $(id -u):$(id -g) \ + whippersnappy \ + --lh_annot /subject/label/lh.aparc.annot \ + --rh_annot /subject/label/rh.aparc.annot \ + -sd /subject \ + -o /output/snap4_annot.png +``` + +### With a caption and custom thresholds + +```bash +docker run --rm --init \ + -v /path/to/subject:/subject \ + -v /path/to/output:/output \ + --user $(id -u):$(id -g) \ + whippersnappy \ + -lh /subject/surf/lh.thickness \ + -rh /subject/surf/rh.thickness \ + -sd /subject \ + --fthresh 2.0 --fmax 4.0 \ + --caption "Cortical thickness" \ + -o /output/snap4_thickness.png +``` + +### All `whippersnap4` options + +``` +docker run --rm whippersnappy --help +``` + +--- + +## Running — single-view snapshot (`whippersnap1`) + +Override the entry point with `--entrypoint whippersnap1`. Any triangular +mesh format is supported: FreeSurfer binary surfaces, OFF, ASCII VTK +PolyData, ASCII PLY, and GIfTI (`.gii`, `.surf.gii`). + +### FreeSurfer surface + +```bash +docker run --rm --init \ + --entrypoint whippersnap1 \ + -v /path/to/subject:/subject \ + -v /path/to/output:/output \ + --user $(id -u):$(id -g) \ + whippersnappy \ + --mesh /subject/surf/lh.white \ + --overlay /subject/surf/lh.thickness \ + --bg-map /subject/surf/lh.curv \ + --roi /subject/label/lh.cortex.label \ + --view left \ + --fthresh 2.0 --fmax 4.0 \ + -o /output/snap1.png +``` + +### OFF / VTK / PLY / GIfTI mesh + +```bash +docker run --rm --init \ + --entrypoint whippersnap1 \ + -v /path/to/data:/data \ + -v /path/to/output:/output \ + --user $(id -u):$(id -g) \ + whippersnappy \ + --mesh /data/mesh.off \ + --overlay /data/values.txt \ + -o /output/snap1.png +``` + +### All `whippersnap1` options + +```bash +docker run --rm --entrypoint whippersnap1 whippersnappy --help +``` + +--- + +## Running — 360° rotation video (`whippersnap1 --rotate`) + +`whippersnap1 --rotate` renders a full 360° rotation video and writes an +`.mp4`, `.webm`, or `.gif` file. `imageio-ffmpeg` is bundled in the image — +no system ffmpeg is required. + +### MP4 (H.264, recommended) + +```bash +docker run --rm --init \ + --entrypoint whippersnap1 \ + -v /path/to/subject:/subject \ + -v /path/to/output:/output \ + --user $(id -u):$(id -g) \ + whippersnappy \ + --mesh /subject/surf/lh.white \ + --overlay /subject/surf/lh.thickness \ + --bg-map /subject/surf/lh.curv \ + --rotate \ + --rotate-frames 72 \ + --rotate-fps 24 \ + -o /output/rotation.mp4 +``` + +### Animated GIF (no ffmpeg needed) + +```bash +docker run --rm --init \ + --entrypoint whippersnap1 \ + -v /path/to/subject:/subject \ + -v /path/to/output:/output \ + --user $(id -u):$(id -g) \ + whippersnappy \ + --mesh /subject/surf/lh.white \ + --overlay /subject/surf/lh.thickness \ + --rotate \ + --rotate-frames 36 \ + --rotate-fps 12 \ + -o /output/rotation.gif +``` + +--- + +## Path mapping summary + +| Host path | Container path | Purpose | +|-----------|---------------|---------| +| `/path/to/subject` | `/subject` | FreeSurfer subject directory (contains `surf/`, `label/`) | +| `/path/to/output` | `/output` | Directory where output files are written | + +All output files are written to the container path you pass via `-o`; mount the +parent directory to retrieve them on the host. + +--- + +## Notes + +- The `--init` flag is recommended so that signals (e.g. `Ctrl-C`) are handled + correctly inside the container. +- `--user $(id -u):$(id -g)` ensures output files are owned by your host user, + not root. +- The interactive GUI (`whippersnap`) is **not** available in the Docker image — + it requires a display server and PyQt6, which are not installed. +- **Default rendering** uses **OSMesa** (Mesa's CPU software renderer, provided + by the `libosmesa6` system package). No GPU or `/dev/dri/` device needed. +- **GPU rendering via EGL** works out of the box — `libegl1` is included in the + image. Pass the render device into the container and WhipperSnapPy will + automatically prefer EGL over OSMesa when `/dev/dri/renderD*` is accessible: + ```bash + docker run --rm --init \ + --device /dev/dri/renderD128 \ + -v /path/to/subject:/subject \ + -v /path/to/output:/output \ + whippersnappy \ + -lh /subject/surf/lh.thickness -rh /subject/surf/rh.thickness \ + -sd /subject -o /output/snap4.png + ``` + diff --git a/Dockerfile b/Dockerfile index 1d6686d..c450d0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,13 @@ FROM python:3.11-slim -# libosmesa6 — OSMesa software renderer for headless OpenGL (no GPU/display needed) -# libgl1 — base OpenGL shared library required by PyOpenGL +# libosmesa6 — OSMesa CPU software renderer (default headless path, no GPU needed) +# libegl1 — EGL dispatch library; enables GPU rendering when /dev/dri/renderD* +# is passed via --device (e.g. docker run --device /dev/dri/renderD128) +# libgl1 — base OpenGL dispatch library required by PyOpenGL # libglib2.0-0, libfontconfig1, libdbus-1-3 — runtime deps for Pillow / font rendering RUN apt-get update && apt-get install -y --no-install-recommends \ libosmesa6 \ + libegl1 \ libgl1 \ libglib2.0-0 \ libfontconfig1 \