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
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install system OpenGL/EGL libraries (Ubuntu)
- name: Install system OSMesa/OpenGL libraries (Ubuntu)
if: matrix.os == 'ubuntu'
run: sudo apt-get install -y --no-install-recommends libegl1 libgl1
run: sudo apt-get install -y --no-install-recommends libosmesa6 libgl1
- name: Install dependencies
run: |
python -m pip install --progress-bar off --upgrade pip setuptools wheel
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/code-style.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
with:
python-version: '3.13'
cache: 'pip'
- name: Install dependencies
- name: Install python dependencies
run: |
python -m pip install --progress-bar off --upgrade pip setuptools wheel
python -m pip install --progress-bar off .[style]
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/doc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get -y install libgl1 libegl1 libxcb-cursor0 pandoc
sudo apt-get -y install libgl1 libosmesa6 libxcb-cursor0 pandoc
- name: Install package
run: |
python -m pip install --progress-bar off --upgrade pip setuptools wheel
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ jobs:
with:
python-version: '3.13'
- name: Install system dependencies
run: sudo apt-get install -y --no-install-recommends libegl1 libgl1
- name: Install dependencies
run: sudo apt-get install -y --no-install-recommends libosmesa6 libgl1
- name: Install python dependencies
run: |
python -m pip install --progress-bar off --upgrade pip setuptools wheel
python -m pip install --progress-bar off .[build]
Expand Down
24 changes: 22 additions & 2 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,29 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install system OpenGL/EGL libraries (Ubuntu)
- name: Install system OSMesa library (Ubuntu)
if: matrix.os == 'ubuntu'
run: sudo apt-get install -y --no-install-recommends libegl1 libgl1
run: sudo apt-get install -y --no-install-recommends libosmesa6 libgl1
- name: Install Mesa software OpenGL (Windows)
if: matrix.os == 'windows'
# mesa-dist-win (MSVC build) provides software OpenGL 3.3 Core via
# llvmpipe — compatible with Python's MSVC runtime.
# All x64 DLLs are extracted to C:\mesa and prepended to PATH so that
# Windows DLL search finds opengl32.dll and all its Mesa dependencies.
shell: pwsh
run: |
$url = "https://github.com/pal1000/mesa-dist-win/releases/download/24.3.4/mesa3d-24.3.4-release-msvc.7z"
$archive = "$env:TEMP\mesa.7z"
Invoke-WebRequest -Uri $url -OutFile $archive
New-Item -ItemType Directory -Force -Path "C:\mesa" | Out-Null
7z e $archive -o"C:\mesa" "x64\*" -y
echo "C:\mesa" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
# Rendering strategy per platform:
# Ubuntu: no display → GLFW fails → OSMesa headless (libosmesa6)
# macOS: rendering tests SKIPPED — NSGL requires a real display
# connection even for invisible windows; CI runners have none
# Windows: Mesa opengl32.dll (MSVC build) on PATH → GLFW invisible
# window with Core Profile + FORWARD_COMPAT via llvmpipe
- name: Install package
run: |
python -m pip install --progress-bar off --upgrade pip setuptools wheel
Expand Down
9 changes: 7 additions & 2 deletions DOCKER.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Docker Guide

The Docker image provides a fully headless rendering environment with EGL
off-screen support. No display server or `xvfb` is required.
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
Expand Down Expand Up @@ -167,4 +169,7 @@ parent directory to retrieve them on the host.
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.

5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
FROM python:3.11-slim

# libosmesa6 — OSMesa software renderer for headless OpenGL (no GPU/display needed)
# libgl1 — base OpenGL shared 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 \
libegl1 \
libosmesa6 \
libgl1 \
libglib2.0-0 \
libfontconfig1 \
Expand Down
88 changes: 60 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@ with color overlays or parcellations and generate screenshots — from the
command line, in Jupyter notebooks, or via a desktop GUI.

It works with FreeSurfer and FastSurfer brain surfaces as well as any
triangle mesh in OFF, legacy ASCII VTK PolyData, ASCII PLY, or GIfTI (.gii, .surf.gii) format, or
passed directly as a NumPy ``(vertices, faces)`` tuple.
triangle mesh in OFF, legacy ASCII VTK PolyData, ASCII PLY, or GIfTI
(`.gii`, `.surf.gii`) format, or passed directly as a NumPy
`(vertices, faces)` tuple.

## Installation

```bash
pip install whippersnappy
```

For rotation video support (MP4/WebM):
For rotation video support (MP4/WebM — GIF works without this):

```bash
pip install 'whippersnappy[video]'
Expand All @@ -32,8 +33,11 @@ For interactive 3D in Jupyter notebooks:
pip install 'whippersnappy[notebook]'
```

Off-screen (headless) rendering is supported natively via EGL on Linux — no
`xvfb` required. See the <a href="DOCKER.md">Docker guide</a> for headless usage.
Off-screen (headless) rendering on **Linux** is supported natively via
OSMesa — no `xvfb` or GPU required. On **macOS** and **Windows** a real
display connection is needed (GLFW creates an invisible window backed by the
system GPU driver). See the <a href="DOCKER.md">Docker guide</a> for
headless Linux usage.

## Command-Line Usage

Expand Down Expand Up @@ -64,31 +68,37 @@ whippersnap1 --mesh $SUBJECT_DIR/surf/lh.white \
--view left \
-o snap1.png

# Also works with OFF / VTK / PLY
whippersnap1 --mesh mesh.off --overlay values.mgh -o snap1.png
# Also works with OFF / VTK / PLY / GIfTI
whippersnap1 --mesh mesh.off --overlay values.txt -o snap1.png
whippersnap1 --mesh surface.surf.gii --overlay overlay.func.gii -o snap1.png
```

### Rotation video (`whippersnap1 --rotate`)

Renders a 360° animation of any triangular surface mesh:
Renders a 360° animation of any triangular surface mesh. GIF output uses
pure PIL (no extra install); MP4/WebM requires `pip install 'whippersnappy[video]'`.

```bash
whippersnap1 --mesh $SUBJECT_DIR/surf/lh.white \
--overlay $LH_OVERLAY \
--rotate \
-o rotation.mp4

whippersnap1 --mesh $SUBJECT_DIR/surf/lh.white \
--rotate \
-o rotation.gif
```

### Desktop GUI (`whippersnap`)

Launches an interactive Qt window with live threshold controls.
Launches an interactive Qt window with live threshold controls and
mouse-driven rotation, pan, and zoom. Requires
`pip install 'whippersnappy[gui]'`.

**General mode** — any triangular mesh:

```bash
pip install 'whippersnappy[gui]'
whippersnap --mesh mesh.off --overlay values.mgh
whippersnap --mesh mesh.off --overlay values.txt
whippersnap --mesh lh.white --overlay lh.thickness --bg-map lh.curv
```

Expand All @@ -104,61 +114,83 @@ For all options run `whippersnap4 --help`, `whippersnap1 --help`, or `whippersna
## Python API

```python
from whippersnappy import snap1, snap4, snap_rotate, plot3d
from whippersnappy import snap1, snap4, snap_rotate, ViewType
from whippersnappy import plot3d # requires whippersnappy[notebook]
```

| Function | Description |
| Function / Class | Description |
|---|---|
| `snap1` | Single-view snapshot of any triangular mesh → PIL Image |
| `snap4` | Four-view composed image (FreeSurfer subject, lateral/medial both hemispheres) |
| `snap_rotate` | 360° rotation video of any triangular surface mesh (MP4, WebM, or GIF) |
| `plot3d` | Interactive 3D WebGL viewer for Jupyter notebooks |
| `ViewType` | Enum of camera presets used by `snap1` and `snap_rotate` |

**`ViewType` values** — pass to the `view` parameter of `snap1` or the
`start_view` parameter of `snap_rotate`:

| Value | Description |
|---|---|
| `ViewType.LEFT` | Left lateral view *(default)* |
| `ViewType.RIGHT` | Right lateral view |
| `ViewType.FRONT` | Frontal / anterior view |
| `ViewType.BACK` | Posterior view |
| `ViewType.TOP` | Superior / dorsal view |
| `ViewType.BOTTOM` | Inferior / ventral view |

**Supported mesh inputs for `snap1`, `snap_rotate`, and `plot3d`:**
FreeSurfer binary surfaces (e.g. `lh.white`), OFF (`.off`), legacy ASCII VTK PolyData (`.vtk`), ASCII PLY (`.ply`), GIfTI surface (`.gii`, `.surf.gii`), or a `(vertices, faces)` NumPy array tuple.
FreeSurfer binary surfaces (e.g. `lh.white`), OFF (`.off`), legacy ASCII
VTK PolyData (`.vtk`), ASCII PLY (`.ply`), GIfTI surface
(`.gii`, `.surf.gii`), or a `(vertices, faces)` NumPy array tuple.

**Supported overlay/label inputs:**
FreeSurfer morph (`.curv`, `.thickness`), MGH/MGZ, ASCII (`.txt`, `.csv`), NumPy (`.npy`, `.npz`), GIfTI functional/label (`.func.gii`, `.label.gii`, `.gii`).
FreeSurfer morph (`.curv`, `.thickness`), MGH/MGZ (`.mgh`, `.mgz`),
plain text (`.txt`, `.csv`), NumPy (`.npy`, `.npz`),
GIfTI functional/label (`.func.gii`, `.label.gii`).

### Example
### Examples

```python
from whippersnappy import snap1, snap4
from whippersnappy import snap1, snap4, ViewType

# FreeSurfer surface with overlay
# FreeSurfer surface with overlay — default left lateral view
img = snap1('lh.white',
overlay='lh.thickness',
bg_map='lh.curv',
roi='lh.cortex.label')
img.save('snap1.png')

# Specific view
img = snap1('lh.white', overlay='lh.thickness', view=ViewType.FRONT)
img.save('snap1_front.png')

# Four-view overview (FreeSurfer subject directory)
img = snap4(sdir='/path/to/subject',
lh_overlay='/path/to/lh.thickness',
img = snap4(lh_overlay='/path/to/lh.thickness',
rh_overlay='/path/to/rh.thickness',
colorbar=True, caption='Cortical Thickness (mm)')
sdir='/path/to/subject',
colorbar=True,
caption='Cortical Thickness (mm)')
img.save('snap4.png')

# OFF / VTK / PLY / GIfTI mesh
img = snap1('mesh.off', overlay='values.mgh')
img = snap1('mesh.off', overlay='values.txt')
img = snap1('surface.surf.gii', overlay='overlay.func.gii')

# Array inputs (e.g. from LaPy or trimesh)
import numpy as np
v = np.random.randn(1000, 3).astype(np.float32)
f = np.array([[0, 1, 2]], dtype=np.uint32)
overlay = np.random.randn(1000).astype(np.float32)
v = np.array([[0,0,0],[1,0,0],[0,1,0],[0,0,1]], dtype=np.float32)
f = np.array([[0,2,1],[0,1,3],[0,3,2],[1,2,3]], dtype=np.uint32)
overlay = np.array([0.1, 0.5, 0.9, 0.3], dtype=np.float32)
img = snap1((v, f), overlay=overlay)
```


See `tutorials/whippersnappy_tutorial.ipynb` for complete notebook examples.


## Docker

The Docker image provides a fully headless EGL rendering environment — no
display server or `xvfb` required. See <a href="DOCKER.md"><strong>DOCKER.md</strong></a> for details.
The Docker image provides a fully headless OSMesa rendering environment — no
display server, `xvfb`, or GPU required. See <a href="DOCKER.md"><strong>DOCKER.md</strong></a> for details.

## API Documentation

Expand Down
9 changes: 8 additions & 1 deletion doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@
# Whether to create a Sphinx table of contents for the lists of class methods and attributes.
# If a table of contents is made, Sphinx expects each entry to have a separate page. True by default.
#numpydoc_class_members_toctree = False
#numpydoc_attributes_as_param_list = False
numpydoc_attributes_as_param_list = False # dataclass fields → Attributes, not Parameters
numpydoc_show_class_members = False

# x-ref
Expand Down Expand Up @@ -194,6 +194,13 @@
# Imported third-party objects exposed in plot3d module
r"\.HTML$",
r"\.VBox$",
# stdlib dataclasses re-exported into cli module scope
r"\.dataclass$",
r"\.field$",
# GUI-only dataclass: fields are documented as Attributes; numpydoc also
# validates the auto-generated __init__ signature and raises PR01 because
# the same names are not repeated in a Parameters section.
r"\.ViewState$",
}

# -- sphinxcontrib-bibtex ----------------------------------------------------
Expand Down
45 changes: 37 additions & 8 deletions tests/test_array_and_rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
triangle meshes.
"""

import os
import sys

import numpy as np
import pytest

Expand All @@ -21,6 +24,15 @@
prepare_geometry_from_arrays,
)

# macOS CI runners (GitHub Actions) have no display connection.
# NSGL (Apple's OpenGL layer) requires a real display even for invisible
# windows — glfwCreateWindow always fails with "NSGL: Failed to find a
# suitable pixel format" regardless of hints. Skip rendering tests there.
_SKIP_RENDER_MACOS = pytest.mark.skipif(
sys.platform == "darwin" and "CI" in os.environ,
reason="macOS CI runners have no display; NSGL cannot create an OpenGL context.",
)

# ---------------------------------------------------------------------------
# Minimal synthetic mesh (tetrahedron)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -189,13 +201,19 @@ def test_invalid_mesh_type_raises(self):
def _snap1_offscreen(**kwargs):
"""Call snap1 with an invisible (offscreen) GLFW context.

On macOS a visible GLFW window goes through the Cocoa compositor; the
first glReadPixels call may return all-black before the compositor has
finished its first composite pass. An invisible context renders
directly to the driver framebuffer and reads back correctly.
Forces ``visible=False`` so that:

Skips the test automatically if no OpenGL context can be created
(headless CI without GPU or EGL support).
- On **Windows CI**, Mesa ``opengl32.dll`` is on ``PATH`` (installed by
the workflow), providing software OpenGL 3.3 Core via llvmpipe.
- On **Linux CI** (no display) GLFW fails entirely and
:func:`~whippersnappy.gl.utils.create_window_with_fallback` falls back
to OSMesa software rendering.
- On **macOS CI** the containing :class:`TestSnap1Rendering` is skipped
entirely via ``_SKIP_RENDER_MACOS`` — NSGL requires a real display
connection even for invisible windows, which CI runners don't provide.

The ``pytest.skip`` inside this function is a safety net for any other
environment where context creation fails unexpectedly.
"""
import whippersnappy.gl.utils as gl_utils # noqa: PLC0415

Expand All @@ -217,15 +235,26 @@ def _invisible(*args, **kw):
gl_utils.create_window_with_fallback = original


@_SKIP_RENDER_MACOS
class TestSnap1Rendering:
"""End-to-end rendering tests: snap1 must return a non-empty PIL Image.

All tests use the tetrahedron mesh (_V, _F) — a true 3-D shape that is
visible from any camera direction, unlike a flat surface which can
appear edge-on and produce an all-black image.

Tests use an offscreen GLFW context (see ``_snap1_offscreen``) and are
skipped automatically when no OpenGL context is available.
Tests use an offscreen GLFW context (see :func:`_snap1_offscreen`) and
run on:

- **Ubuntu CI**: OSMesa headless rendering (``libosmesa6`` installed).
- **Windows CI**: GLFW invisible window backed by Mesa ``opengl32.dll``
(software OpenGL 3.3 Core via llvmpipe, on ``PATH`` from CI workflow).
- **macOS CI**: **skipped** — NSGL requires a real display connection
even for invisible windows; GitHub Actions runners have none.
- **local macOS**: runs if a display is connected (normal developer use).

The ``pytest.skip`` inside ``_snap1_offscreen`` is a safety net for any
other environment where context creation fails completely.
"""

def test_snap1_basic(self):
Expand Down
Loading