From 161878b3542394a09a1184a7536fb0d0798573bc Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Fri, 27 Feb 2026 22:30:21 +0100 Subject: [PATCH 01/11] replace EGL with Mesa for CPU off-screen rendering --- .github/workflows/pytest.yml | 12 +- DOCKER.md | 9 +- Dockerfile | 5 +- README.md | 10 +- tests/test_array_and_rendering.py | 31 ++- whippersnappy/__init__.py | 39 +-- whippersnappy/cli/whippersnap.py | 142 ++++++++++- whippersnappy/gl/__init__.py | 22 +- whippersnappy/gl/_platform.py | 17 -- whippersnappy/gl/egl_context.py | 374 ----------------------------- whippersnappy/gl/osmesa_context.py | 263 ++++++++++++++++++++ whippersnappy/gl/utils.py | 111 ++++----- whippersnappy/gl/views.py | 199 --------------- whippersnappy/snap.py | 3 +- whippersnappy/utils/types.py | 47 ++++ 15 files changed, 548 insertions(+), 736 deletions(-) delete mode 100644 whippersnappy/gl/_platform.py delete mode 100644 whippersnappy/gl/egl_context.py create mode 100644 whippersnappy/gl/osmesa_context.py delete mode 100644 whippersnappy/gl/views.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 59f2311..35deb68 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -32,9 +32,17 @@ 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 + # Rendering strategy per platform: + # Ubuntu: no display → GLFW fails → OSMesa headless (libosmesa6) + # macOS: runner has a real GPU → GLFW invisible window succeeds + # Windows: runner has GPU drivers → GLFW invisible window succeeds + # On all three platforms the rendering tests in TestSnap1Rendering + # are expected to run (not skip). They only skip if the OpenGL context + # creation fails entirely (RuntimeError), which should not happen on + # any of these hosted runners. - name: Install package run: | python -m pip install --progress-bar off --upgrade pip setuptools wheel diff --git a/DOCKER.md b/DOCKER.md index fbdbc33..fe2fba4 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -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 @@ -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. diff --git a/Dockerfile b/Dockerfile index ea56592..1d6686d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/README.md b/README.md index 0a7f81a..ce8308f 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,10 @@ 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 Docker guide for headless usage. +Off-screen (headless) rendering is supported natively via OSMesa on Linux — no +`xvfb` or GPU required. On macOS and Windows a GLFW invisible window is used +instead (both platforms provide GPU drivers). See the +Docker guide for headless usage. ## Command-Line Usage @@ -157,8 +159,8 @@ 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 DOCKER.md for details. +The Docker image provides a fully headless OSMesa rendering environment — no +display server, `xvfb`, or GPU required. See DOCKER.md for details. ## API Documentation diff --git a/tests/test_array_and_rendering.py b/tests/test_array_and_rendering.py index b5c723d..2d65a76 100644 --- a/tests/test_array_and_rendering.py +++ b/tests/test_array_and_rendering.py @@ -189,13 +189,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. - - Skips the test automatically if no OpenGL context can be created - (headless CI without GPU or EGL support). + Forces ``visible=False`` so that: + + - On **macOS** the Cocoa compositor is bypassed and ``glReadPixels`` + returns correct pixel data immediately. + - On **Windows** no on-screen window needs to be created. + - On **Linux CI** (no display) GLFW will fail and + :func:`~whippersnappy.gl.utils.create_window_with_fallback` will + automatically fall back to OSMesa software rendering. + + The tests are expected to **run** on all CI platforms (Ubuntu/macOS/ + Windows). The ``pytest.skip`` is a safety net for exceptional cases + where the runner has no OpenGL support at all (e.g. a bare container + without Mesa and without a GPU). """ import whippersnappy.gl.utils as gl_utils # noqa: PLC0415 @@ -224,8 +230,15 @@ class TestSnap1Rendering: 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 + are expected to **run** on all CI platforms: + + - **Ubuntu**: OSMesa headless rendering (``libosmesa6`` installed in CI). + - **macOS**: GLFW invisible window backed by the runner's GPU. + - **Windows**: GLFW invisible window backed by the runner's GPU drivers. + + A ``pytest.skip`` is issued only if context creation fails completely, + which should not happen on any standard hosted runner. """ def test_snap1_basic(self): diff --git a/whippersnappy/__init__.py b/whippersnappy/__init__.py index d656cbc..5309c76 100644 --- a/whippersnappy/__init__.py +++ b/whippersnappy/__init__.py @@ -7,7 +7,8 @@ - **3D plotting**: For Jupyter notebooks with mouse-controlled 3D (via Three.js) - **GUI**: Interactive desktop viewer via the ``whippersnap`` command - **CLI tools**: ``whippersnap1`` and ``whippersnap4`` for batch processing -- **Local mesh IO**: OFF, VTK ASCII PolyData, and PLY in addition to FreeSurfer surfaces +- **Local mesh IO**: OFF, VTK ASCII PolyData, PLY, and GIFTI in addition to + FreeSurfer surfaces For static image generation:: @@ -40,42 +41,6 @@ """ -import os -import sys - - -def _check_display(): - """Return True if a display is available or the platform handles GL natively. - - On macOS (CGL) and Windows (WGL) PyOpenGL does not use EGL, so we always - return True to avoid setting ``PYOPENGL_PLATFORM=egl`` on those systems. - On Linux we probe for an X11/Wayland display server. - """ - if sys.platform != "linux": - # macOS uses CGL, Windows uses WGL — no EGL needed on either. - return True - display = os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY") - if not display: - return False - try: - import ctypes - import ctypes.util - libx11 = ctypes.CDLL(ctypes.util.find_library("X11") or "libX11.so.6") - libx11.XOpenDisplay.restype = ctypes.c_void_p - libx11.XOpenDisplay.argtypes = [ctypes.c_char_p] - libx11.XCloseDisplay.restype = None - libx11.XCloseDisplay.argtypes = [ctypes.c_void_p] - dpy = libx11.XOpenDisplay(display.encode()) - if dpy: - libx11.XCloseDisplay(dpy) - return True - return False - except Exception: - return False - -if "PYOPENGL_PLATFORM" not in os.environ: - if not _check_display(): - os.environ["PYOPENGL_PLATFORM"] = "egl" from ._config import sys_info # noqa: F401, E402 from ._version import __version__ # noqa: F401, E402 diff --git a/whippersnappy/cli/whippersnap.py b/whippersnappy/cli/whippersnap.py index 9e9bd19..1a5c074 100644 --- a/whippersnappy/cli/whippersnap.py +++ b/whippersnappy/cli/whippersnap.py @@ -28,6 +28,7 @@ import logging import os import sys +from dataclasses import dataclass, field if __name__ == "__main__" and __package__ is None: # Replace the current process with `python -m whippersnappy.cli.whippersnap` @@ -36,6 +37,7 @@ os.execv(sys.executable, [sys.executable, "-m", "whippersnappy.cli.whippersnap"] + sys.argv[1:]) import glfw +import numpy as np import OpenGL.GL as gl import pyrr @@ -48,20 +50,148 @@ from .._version import __version__ from ..geometry import get_surf_name, prepare_geometry from ..gl import ( - ViewState, - arcball_rotation_matrix, - arcball_vector, capture_window, - compute_view_matrix, - get_view_matrices, init_window, setup_shader, ) -from ..utils.types import ViewType +from ..utils.types import ViewType, get_view_matrices # Module logger logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# ViewState — interactive GUI view parameters (GUI-only, not part of gl pkg) +# --------------------------------------------------------------------------- + +@dataclass +class ViewState: + """Mutable view parameters for the interactive render loop. + + All mouse/keyboard interaction updates this object; the view matrix is + recomputed from it each frame via :func:`compute_view_matrix`. + + Attributes + ---------- + rotation : np.ndarray + 4×4 float32 rotation matrix (identity = no rotation applied). + pan : np.ndarray + (x, y) pan offset in normalised screen-space units. + zoom : float + Z-translation contribution to the transform matrix. + last_mouse_pos : np.ndarray or None + Last recorded mouse position in pixels; ``None`` when no button held. + left_button_down : bool + Whether the left mouse button is currently pressed. + right_button_down : bool + Whether the right mouse button is currently pressed. + middle_button_down : bool + Whether the middle mouse button is currently pressed. + """ + rotation: np.ndarray = field( + default_factory=lambda: np.eye(4, dtype=np.float32) + ) + pan: np.ndarray = field( + default_factory=lambda: np.zeros(2, dtype=np.float32) + ) + zoom: float = 0.4 + last_mouse_pos: np.ndarray | None = None + left_button_down: bool = False + right_button_down: bool = False + middle_button_down: bool = False + + +def arcball_vector(x: float, y: float, width: int, height: int) -> np.ndarray: + """Map a 2-D screen pixel to a point on the unit arcball sphere. + + Parameters + ---------- + x, y : float + Mouse position in pixels. + width, height : int + Window dimensions in pixels. + + Returns + ------- + np.ndarray + Unit 3-vector on (or clamped to) the arcball sphere. + """ + s = min(width, height) + p = np.array([ + (2.0 * x - width) / s, + -(2.0 * y - height) / s, + 0.0, + ], dtype=np.float64) + sq = p[0] ** 2 + p[1] ** 2 + if sq <= 1.0: + p[2] = np.sqrt(1.0 - sq) + else: + p /= np.sqrt(sq) + n = np.linalg.norm(p) + return p / n if n > 0 else p + + +def arcball_rotation_matrix(v1: np.ndarray, v2: np.ndarray) -> np.ndarray: + """Return a 4×4 rotation matrix that rotates unit vector *v1* to *v2*. + + Uses Rodrigues' rotation formula in pure numpy. + Returns identity when *v1* and *v2* are coincident. + + Parameters + ---------- + v1, v2 : np.ndarray + Unit 3-vectors on the arcball sphere. + + Returns + ------- + np.ndarray + 4×4 float32 rotation matrix. + """ + axis = np.cross(v1, v2) + axis_len = np.linalg.norm(axis) + if axis_len < 1e-10: + return np.eye(4, dtype=np.float32) + axis = axis / axis_len + angle = np.arctan2(axis_len, np.dot(v1, v2)) + c, s_a = np.cos(angle), np.sin(angle) + t = 1.0 - c + x, y, z = axis + r3 = np.array([ + [t*x*x + c, t*x*y - s_a*z, t*x*z + s_a*y], + [t*x*y + s_a*z, t*y*y + c, t*y*z - s_a*x], + [t*x*z - s_a*y, t*y*z + s_a*x, t*z*z + c ], + ], dtype=np.float32) + r4 = np.eye(4, dtype=np.float32) + r4[:3, :3] = r3 + return r4 + + +def compute_view_matrix(view_state: ViewState, base_view: np.ndarray) -> np.ndarray: + """Return the ``transform`` uniform for the current :class:`ViewState`. + + Packs ``transl * rotation * base_view`` matching the snap_rotate convention. + + Parameters + ---------- + view_state : ViewState + Current interactive view state. + base_view : np.ndarray + Fixed 4×4 orientation preset from :func:`~whippersnappy.utils.types.get_view_matrices`. + + Returns + ------- + np.ndarray + 4×4 float32 matrix for the ``transform`` shader uniform. + """ + transl = pyrr.Matrix44.from_translation(( + view_state.pan[0], + view_state.pan[1], + 0.4 + view_state.zoom, + )) + rot = pyrr.Matrix44(view_state.rotation) + return np.array(transl * rot * pyrr.Matrix44(base_view), dtype=np.float32) + + # Global thresholds shared between the GL render loop and the Qt config panel. # All access is from the main thread — no locking needed. current_fthresh_ = None diff --git a/whippersnappy/gl/__init__.py b/whippersnappy/gl/__init__.py index 4b458bf..e1f57e9 100644 --- a/whippersnappy/gl/__init__.py +++ b/whippersnappy/gl/__init__.py @@ -1,13 +1,15 @@ """OpenGL helper utilities (gl package). -This package replaces the previous `gl_utils.py` module. -Functions are re-exported at package level for convenience, e.g.: +This package contains the low-level OpenGL helpers used by the renderers. +View preset matrices (:func:`~whippersnappy.utils.types.get_view_matrices`) +live in :mod:`whippersnappy.utils.types` alongside :class:`~whippersnappy.utils.types.ViewType`. + +Functions are re-exported at package level for convenience:: from whippersnappy.gl import init_window, setup_shader """ -from . import _platform # noqa: F401 — MUST be first; sets PYOPENGL_PLATFORM from .camera import make_model, make_projection, make_view from .shaders import get_default_shaders, get_webgl_shaders from .utils import ( @@ -25,22 +27,12 @@ setup_vertex_attributes, terminate_context, ) -from .views import ( - ViewState, - arcball_rotation_matrix, - arcball_vector, - compute_view_matrix, - get_view_matrices, - get_view_matrix, -) __all__ = [ 'create_vao', 'compile_shader_program', 'setup_buffers', 'setup_vertex_attributes', 'set_default_gl_state', 'set_camera_uniforms', 'set_lighting_uniforms', 'init_window', 'render_scene', 'setup_shader', 'capture_window', + 'create_window_with_fallback', 'terminate_context', 'make_model', 'make_projection', 'make_view', - 'get_default_shaders', 'get_view_matrices', 'get_view_matrix', - 'get_webgl_shaders', 'terminate_context', - 'ViewState', 'compute_view_matrix', - 'arcball_vector', 'arcball_rotation_matrix', + 'get_default_shaders', 'get_webgl_shaders', ] diff --git a/whippersnappy/gl/_platform.py b/whippersnappy/gl/_platform.py deleted file mode 100644 index 8a94ad4..0000000 --- a/whippersnappy/gl/_platform.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Bootstrap PyOpenGL platform selection — must be imported first. - -Imported unconditionally at the top of gl/__init__.py before any other -OpenGL symbol. Sets PYOPENGL_PLATFORM=egl when running headless on Linux -so that PyOpenGL uses the EGL backend instead of GLX. - -On macOS PyOpenGL uses CGL and on Windows it uses WGL — both are handled -natively without EGL. If the user has already set PYOPENGL_PLATFORM that -value is always respected. -""" -import os -import sys - -if "PYOPENGL_PLATFORM" not in os.environ and sys.platform == "linux": - # No X11/Wayland display on Linux → force EGL headless backend. - if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): - os.environ["PYOPENGL_PLATFORM"] = "egl" diff --git a/whippersnappy/gl/egl_context.py b/whippersnappy/gl/egl_context.py deleted file mode 100644 index 950dffa..0000000 --- a/whippersnappy/gl/egl_context.py +++ /dev/null @@ -1,374 +0,0 @@ -"""EGL off-screen (headless) OpenGL context via pbuffer + FBO. - -This module provides a drop-in alternative to GLFW window creation for -headless environments (CI, Docker, HPC clusters) where no X11/Wayland -display is available. It requires: - - - A system EGL library (``libegl1`` on Debian/Ubuntu, already present - in the WhipperSnapPy Dockerfile). - - PyOpenGL >= 3.1 (already a project dependency), which ships - ``OpenGL.EGL`` bindings. - - Either an NVIDIA GPU with the EGL driver, or Mesa ``libEGL-mesa0`` - (llvmpipe software renderer) for CPU-only systems. - -Typical usage (internal, called from ``create_window_with_fallback``):: - - from whippersnappy.gl.egl_context import EGLContext - - ctx = EGLContext(width, height) - ctx.make_current() - # ... OpenGL calls ... - img = ctx.read_pixels() - ctx.destroy() -""" - -import ctypes -import logging -import os -import sys - -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. -if os.environ.get("PYOPENGL_PLATFORM") != "egl": - os.environ["PYOPENGL_PLATFORM"] = "egl" - -import OpenGL.GL as gl -from PIL import Image - -logger = logging.getLogger(__name__) - -# --------------------------------------------------------------------------- -# EGL constants not exposed by all PyOpenGL versions -# --------------------------------------------------------------------------- -_EGL_SURFACE_TYPE = 0x3033 -_EGL_PBUFFER_BIT = 0x0001 -_EGL_RENDERABLE_TYPE = 0x3040 -_EGL_OPENGL_BIT = 0x0008 -_EGL_NONE = 0x3038 -_EGL_WIDTH = 0x3057 -_EGL_HEIGHT = 0x3056 -_EGL_OPENGL_API = 0x30A2 -_EGL_CONTEXT_MAJOR_VERSION = 0x3098 -_EGL_CONTEXT_MINOR_VERSION = 0x30FB -_EGL_PLATFORM_DEVICE_EXT = 0x313F - - -class EGLContext: - """A headless OpenGL 3.3 Core context backed by an EGL pbuffer + FBO. - - The pbuffer surface is created solely to satisfy EGL's requirement for - a surface when calling ``eglMakeCurrent``. All rendering is directed - into an off-screen Framebuffer Object (FBO) so that ``glReadPixels`` - captures exactly what was rendered regardless of platform quirks with - pbuffer readback. - - Parameters - ---------- - width, height : int - Dimensions of the off-screen render target in pixels. - - Attributes - ---------- - width, height : int - Render target dimensions. - fbo : int - OpenGL FBO handle (valid after ``make_current`` is called). - - Raises - ------ - ImportError - If ``OpenGL.EGL`` bindings are not available. - RuntimeError - If any EGL initialisation step fails. - """ - - def __init__(self, width: int, height: int): - self.width = width - self.height = height - self._libegl = None - self._display = None - self._surface = None - self._context = None - self._config = None - self.fbo = None - self._rbo_color = None - self._rbo_depth = None - self._init_egl() - - # ------------------------------------------------------------------ - # Private helpers - # ------------------------------------------------------------------ - - def _get_ext_fn(self, name, restype, argtypes): - """Load an EGL extension function via eglGetProcAddress.""" - addr = self._libegl.eglGetProcAddress(name.encode()) - if not addr: - raise RuntimeError( - f"eglGetProcAddress('{name}') returned NULL — " - f"extension not available on this driver." - ) - FuncType = ctypes.CFUNCTYPE(restype, *argtypes) - return FuncType(addr) - - def _init_egl(self): - import ctypes.util - - egl_name = ctypes.util.find_library("EGL") or "libEGL.so.1" - try: - libegl = ctypes.CDLL(egl_name) - except OSError as e: - raise RuntimeError( - f"Could not load {egl_name}. " - "Install libegl1-mesa and retry." - ) from e - self._libegl = libegl # keep reference alive - - # Set signatures for direct (non-extension) EGL symbols - libegl.eglGetProcAddress.restype = ctypes.c_void_p - libegl.eglGetProcAddress.argtypes = [ctypes.c_char_p] - libegl.eglQueryString.restype = ctypes.c_char_p - libegl.eglQueryString.argtypes = [ctypes.c_void_p, ctypes.c_int] - libegl.eglInitialize.restype = ctypes.c_bool - libegl.eglInitialize.argtypes = [ctypes.c_void_p, - ctypes.POINTER(ctypes.c_int), - ctypes.POINTER(ctypes.c_int)] - libegl.eglBindAPI.restype = ctypes.c_bool - libegl.eglBindAPI.argtypes = [ctypes.c_uint] - libegl.eglChooseConfig.restype = ctypes.c_bool - libegl.eglChooseConfig.argtypes = [ctypes.c_void_p, - ctypes.POINTER(ctypes.c_int), - ctypes.c_void_p, ctypes.c_int, - ctypes.POINTER(ctypes.c_int)] - libegl.eglCreatePbufferSurface.restype = ctypes.c_void_p - libegl.eglCreatePbufferSurface.argtypes = [ctypes.c_void_p, ctypes.c_void_p, - ctypes.POINTER(ctypes.c_int)] - libegl.eglCreateContext.restype = ctypes.c_void_p - libegl.eglCreateContext.argtypes = [ctypes.c_void_p, ctypes.c_void_p, - ctypes.c_void_p, - ctypes.POINTER(ctypes.c_int)] - libegl.eglMakeCurrent.restype = ctypes.c_bool - libegl.eglMakeCurrent.argtypes = [ctypes.c_void_p, ctypes.c_void_p, - ctypes.c_void_p, ctypes.c_void_p] - libegl.eglDestroyContext.restype = ctypes.c_bool - libegl.eglDestroyContext.argtypes = [ctypes.c_void_p, ctypes.c_void_p] - libegl.eglDestroySurface.restype = ctypes.c_bool - libegl.eglDestroySurface.argtypes = [ctypes.c_void_p, ctypes.c_void_p] - libegl.eglTerminate.restype = ctypes.c_bool - libegl.eglTerminate.argtypes = [ctypes.c_void_p] - - # Check extensions and load ext functions via eglGetProcAddress - _EGL_EXTENSIONS = 0x3055 - client_exts = libegl.eglQueryString(None, _EGL_EXTENSIONS) or b"" - logger.debug("EGL client extensions: %s", client_exts.decode()) - - has_device_enum = b"EGL_EXT_device_enumeration" in client_exts - has_platform_base = b"EGL_EXT_platform_base" in client_exts - - display = None - if has_device_enum and has_platform_base: - eglQueryDevicesEXT = self._get_ext_fn( - "eglQueryDevicesEXT", - ctypes.c_bool, - [ctypes.c_int, ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)], - ) - eglGetPlatformDisplayEXT = self._get_ext_fn( - "eglGetPlatformDisplayEXT", - ctypes.c_void_p, - [ctypes.c_int, ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)], - ) - display = self._open_device_display( - eglQueryDevicesEXT, eglGetPlatformDisplayEXT - ) - - if display is None: - logger.debug("Falling back to eglGetDisplay(EGL_DEFAULT_DISPLAY)") - libegl.eglGetDisplay.restype = ctypes.c_void_p - libegl.eglGetDisplay.argtypes = [ctypes.c_void_p] - display = libegl.eglGetDisplay(ctypes.c_void_p(0)) - - if not display: - raise RuntimeError( - "Could not obtain any EGL display. " - "Install libegl1-mesa for CPU rendering." - ) - self._display = display - - major, minor = ctypes.c_int(0), ctypes.c_int(0) - if not libegl.eglInitialize( - self._display, ctypes.byref(major), ctypes.byref(minor) - ): - raise RuntimeError("eglInitialize failed.") - logger.debug("EGL %d.%d", major.value, minor.value) - - if not libegl.eglBindAPI(_EGL_OPENGL_API): - raise RuntimeError("eglBindAPI(OpenGL) failed.") - - cfg_attribs = (ctypes.c_int * 7)( - _EGL_SURFACE_TYPE, _EGL_PBUFFER_BIT, - _EGL_RENDERABLE_TYPE, _EGL_OPENGL_BIT, - _EGL_NONE, - ) - configs = (ctypes.c_void_p * 1)() - num_cfgs = ctypes.c_int(0) - if not libegl.eglChooseConfig( - self._display, cfg_attribs, configs, 1, ctypes.byref(num_cfgs) - ) or num_cfgs.value == 0: - raise RuntimeError("eglChooseConfig: no suitable config.") - self._config = configs[0] - - pbuf_attribs = (ctypes.c_int * 5)( - _EGL_WIDTH, 1, _EGL_HEIGHT, 1, _EGL_NONE - ) - self._surface = libegl.eglCreatePbufferSurface( - self._display, self._config, pbuf_attribs - ) - if not self._surface: - raise RuntimeError("eglCreatePbufferSurface failed.") - - ctx_attribs = (ctypes.c_int * 5)( - _EGL_CONTEXT_MAJOR_VERSION, 3, - _EGL_CONTEXT_MINOR_VERSION, 3, - _EGL_NONE, - ) - self._context = libegl.eglCreateContext( - self._display, self._config, None, ctx_attribs - ) - if not self._context: - raise RuntimeError( - "eglCreateContext for OpenGL 3.3 Core failed. " - "Try: MESA_GL_VERSION_OVERRIDE=3.3 MESA_GLSL_VERSION_OVERRIDE=330" - ) - logger.info("EGL context created (%dx%d)", self.width, self.height) - - - def _open_device_display(self, eglQueryDevicesEXT, eglGetPlatformDisplayEXT): - """Enumerate EGL devices and return first usable display pointer.""" - n = ctypes.c_int(0) - if not eglQueryDevicesEXT(0, None, ctypes.byref(n)) or n.value == 0: - logger.warning("eglQueryDevicesEXT: no devices.") - return None - logger.debug("EGL: %d device(s) found", n.value) - devices = (ctypes.c_void_p * n.value)() - eglQueryDevicesEXT(n.value, devices, ctypes.byref(n)) - no_attribs = (ctypes.c_int * 1)(_EGL_NONE) - for i, dev in enumerate(devices): - dpy = eglGetPlatformDisplayEXT( - _EGL_PLATFORM_DEVICE_EXT, ctypes.c_void_p(dev), no_attribs - ) - if dpy: - logger.debug("EGL: using device %d", i) - return dpy - return None - - - def make_current(self): - """Make this EGL context current and set up the FBO render target. - - Must be called before any OpenGL commands. Creates and binds an - FBO backed by two renderbuffers (RGBA color + depth/stencil). - """ - if not self._libegl.eglMakeCurrent( - self._display, self._surface, self._surface, self._context - ): - raise RuntimeError("eglMakeCurrent failed.") - - # Force PyOpenGL to discover and cache the context we just made current. - # PyOpenGL's contextdata module only recognizes contexts it has "seen" - # via at least one GL call; glGetError() is the cheapest trigger. - gl.glGetError() - - # Build FBO so rendering is directed off-screen - self.fbo = gl.glGenFramebuffers(1) - gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.fbo) - - # Color renderbuffer - self._rbo_color = gl.glGenRenderbuffers(1) - gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self._rbo_color) - gl.glRenderbufferStorage( - gl.GL_RENDERBUFFER, gl.GL_RGBA8, self.width, self.height - ) - gl.glFramebufferRenderbuffer( - gl.GL_FRAMEBUFFER, - gl.GL_COLOR_ATTACHMENT0, - gl.GL_RENDERBUFFER, - self._rbo_color, - ) - - # Depth + stencil renderbuffer - self._rbo_depth = gl.glGenRenderbuffers(1) - gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self._rbo_depth) - gl.glRenderbufferStorage( - gl.GL_RENDERBUFFER, - gl.GL_DEPTH24_STENCIL8, - self.width, - self.height, - ) - gl.glFramebufferRenderbuffer( - gl.GL_FRAMEBUFFER, - gl.GL_DEPTH_STENCIL_ATTACHMENT, - gl.GL_RENDERBUFFER, - self._rbo_depth, - ) - - status = gl.glCheckFramebufferStatus(gl.GL_FRAMEBUFFER) - if status != gl.GL_FRAMEBUFFER_COMPLETE: - raise RuntimeError( - f"FBO is not complete after EGL setup (status=0x{status:X})." - ) - - # Set the viewport to match the render target - gl.glViewport(0, 0, self.width, self.height) - logger.debug("EGL FBO complete and bound (%dx%d)", self.width, self.height) - - def read_pixels(self) -> Image.Image: - """Read the FBO contents and return a PIL RGB Image. - - Returns - ------- - PIL.Image.Image - Captured frame, vertically flipped to convert from OpenGL's - bottom-left origin to image top-left convention. - """ - gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.fbo) - gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) - buf = gl.glReadPixels( - 0, 0, self.width, self.height, gl.GL_RGB, gl.GL_UNSIGNED_BYTE - ) - img = Image.frombytes("RGB", (self.width, self.height), buf) - return img.transpose(Image.FLIP_TOP_BOTTOM) - - def destroy(self): - libegl = self._libegl - # GL cleanup first (context must be current) - if self.fbo is not None: - gl.glDeleteFramebuffers(1, [self.fbo]) - self.fbo = None - if self._rbo_color is not None: - gl.glDeleteRenderbuffers(1, [self._rbo_color]) - self._rbo_color = None - if self._rbo_depth is not None: - gl.glDeleteRenderbuffers(1, [self._rbo_depth]) - self._rbo_depth = None - if self._display: - libegl.eglMakeCurrent(self._display, None, None, None) - if self._context: - libegl.eglDestroyContext(self._display, self._context) - if self._surface: - libegl.eglDestroySurface(self._display, self._surface) - libegl.eglTerminate(self._display) - self._display = None - self._context = None - self._surface = None - logger.debug("EGL context destroyed.") - - # Allow use as a context manager - def __enter__(self): - self.make_current() - return self - - def __exit__(self, *_): - self.destroy() diff --git a/whippersnappy/gl/osmesa_context.py b/whippersnappy/gl/osmesa_context.py new file mode 100644 index 0000000..59f8547 --- /dev/null +++ b/whippersnappy/gl/osmesa_context.py @@ -0,0 +1,263 @@ +"""OSMesa off-screen (headless) OpenGL context via software rendering + FBO. + +This module provides a drop-in alternative to GLFW window creation for +headless environments (CI, Docker, HPC clusters) where no X11/Wayland +display is available and no GPU is required. It requires: + + - The system OSMesa library (``libosmesa6`` on Debian/Ubuntu, + ``mesa-libOSMesa`` on RHEL/Fedora — loaded via ctypes at runtime). + - PyOpenGL >= 3.1 (already a project dependency). + - No GPU, no ``/dev/dri/`` devices, no display server. + +Typical usage (internal, called from ``create_window_with_fallback``):: + + from whippersnappy.gl.osmesa_context import OSMesaContext + + ctx = OSMesaContext(width, height) + ctx.make_current() + # ... OpenGL calls ... + img = ctx.read_pixels() + ctx.destroy() + +Notes +----- +OSMesa renders entirely in software (CPU). The OpenGL API surface is +identical to EGL or GLFW — shaders, FBOs, VAOs etc. all work unchanged. +""" + +import ctypes +import ctypes.util +import logging +import sys + +if sys.platform != "linux": + raise ImportError( + "OSMesaContext is only supported on Linux. " + "On macOS use GLFW/CGL and on Windows use GLFW/WGL instead." + ) + +import OpenGL.GL as gl +from PIL import Image + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# OSMesa constants +# --------------------------------------------------------------------------- +_OSMESA_RGBA = 0x1908 +_OSMESA_ROW_LENGTH = 0x10 +_OSMESA_Y_UP = 0x11 +_GL_UNSIGNED_BYTE = 0x1401 + + +def _load_libosmesa(): + """Try several candidate names and return the loaded ctypes CDLL.""" + candidates = ["OSMesa", "libOSMesa.so.8", "libOSMesa.so.6", "libOSMesa.so"] + # ctypes.util.find_library may give us a name that LoadLibrary knows + found = ctypes.util.find_library("OSMesa") + if found: + candidates.insert(0, found) + for name in candidates: + try: + lib = ctypes.CDLL(name) + # Verify it has the symbol we need + _ = lib.OSMesaCreateContextExt + logger.debug("Loaded OSMesa from: %s", name) + return lib + except (OSError, AttributeError): + continue + raise RuntimeError( + "Could not load libOSMesa. " + "Install it with: sudo apt-get install libosmesa6 (Debian/Ubuntu) " + "or: sudo dnf install mesa-libOSMesa (RHEL/Fedora)." + ) + + +class OSMesaContext: + """A headless OpenGL 3.3-compatible context backed by OSMesa + FBO. + + OSMesa renders entirely in software (CPU). The rendered pixels are + read via the same FBO + ``glReadPixels`` path used by the EGL context, + so the output is identical. + + Parameters + ---------- + width, height : int + Dimensions of the off-screen render target in pixels. + + Raises + ------ + ImportError + If run on a non-Linux platform. + RuntimeError + If libOSMesa cannot be loaded or context creation fails. + """ + + def __init__(self, width: int, height: int): + self.width = width + self.height = height + self._libosmesa = None + self._ctx = None + self._buf = None + self.fbo = None + self._rbo_color = None + self._rbo_depth = None + self._init_osmesa() + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _init_osmesa(self): + lib = _load_libosmesa() + self._libosmesa = lib + + # Set ctypes signatures + lib.OSMesaCreateContextExt.restype = ctypes.c_void_p + lib.OSMesaCreateContextExt.argtypes = [ + ctypes.c_uint, # format (OSMESA_RGBA) + ctypes.c_int, # depthBits (24) + ctypes.c_int, # stencilBits (8) + ctypes.c_int, # accumBits (0) + ctypes.c_void_p, # sharelist (None) + ] + lib.OSMesaMakeCurrent.restype = ctypes.c_bool + lib.OSMesaMakeCurrent.argtypes = [ + ctypes.c_void_p, # ctx + ctypes.c_void_p, # buffer + ctypes.c_uint, # type (GL_UNSIGNED_BYTE) + ctypes.c_int, # width + ctypes.c_int, # height + ] + lib.OSMesaDestroyContext.restype = None + lib.OSMesaDestroyContext.argtypes = [ctypes.c_void_p] + + ctx = lib.OSMesaCreateContextExt( + _OSMESA_RGBA, # pixel format + 24, # depth bits + 8, # stencil bits + 0, # accum bits + None, # no shared context + ) + if not ctx: + raise RuntimeError( + "OSMesaCreateContextExt failed. " + "Try: MESA_GL_VERSION_OVERRIDE=3.3 MESA_GLSL_VERSION_OVERRIDE=330" + ) + self._ctx = ctx + + # Allocate the pixel buffer that OSMesa renders into + buf_size = self.width * self.height * 4 # RGBA bytes + self._buf = (ctypes.c_ubyte * buf_size)() + logger.info("OSMesa context created (%dx%d)", self.width, self.height) + + # ------------------------------------------------------------------ + # Public API (same as EGLContext) + # ------------------------------------------------------------------ + + def make_current(self): + """Make this OSMesa context current and set up the FBO render target. + + Must be called before any OpenGL commands. Creates and binds an + FBO backed by two renderbuffers (RGBA color + depth/stencil). + """ + ok = self._libosmesa.OSMesaMakeCurrent( + self._ctx, + self._buf, + _GL_UNSIGNED_BYTE, + self.width, + self.height, + ) + if not ok: + raise RuntimeError("OSMesaMakeCurrent failed.") + + # Force PyOpenGL to discover and cache the context we just made current. + gl.glGetError() + + # Build FBO so rendering is directed off-screen into a proper + # renderbuffer (same pattern as EGLContext.make_current) + self.fbo = gl.glGenFramebuffers(1) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.fbo) + + # Color renderbuffer + self._rbo_color = gl.glGenRenderbuffers(1) + gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self._rbo_color) + gl.glRenderbufferStorage( + gl.GL_RENDERBUFFER, gl.GL_RGBA8, self.width, self.height + ) + gl.glFramebufferRenderbuffer( + gl.GL_FRAMEBUFFER, + gl.GL_COLOR_ATTACHMENT0, + gl.GL_RENDERBUFFER, + self._rbo_color, + ) + + # Depth + stencil renderbuffer + self._rbo_depth = gl.glGenRenderbuffers(1) + gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self._rbo_depth) + gl.glRenderbufferStorage( + gl.GL_RENDERBUFFER, + gl.GL_DEPTH24_STENCIL8, + self.width, + self.height, + ) + gl.glFramebufferRenderbuffer( + gl.GL_FRAMEBUFFER, + gl.GL_DEPTH_STENCIL_ATTACHMENT, + gl.GL_RENDERBUFFER, + self._rbo_depth, + ) + + status = gl.glCheckFramebufferStatus(gl.GL_FRAMEBUFFER) + if status != gl.GL_FRAMEBUFFER_COMPLETE: + raise RuntimeError( + f"FBO is not complete after OSMesa setup (status=0x{status:X})." + ) + + gl.glViewport(0, 0, self.width, self.height) + logger.debug("OSMesa FBO complete and bound (%dx%d)", self.width, self.height) + + def read_pixels(self) -> Image.Image: + """Read the FBO contents and return a PIL RGB Image. + + Returns + ------- + PIL.Image.Image + Captured frame, vertically flipped to convert from OpenGL's + bottom-left origin to image top-left convention. + """ + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.fbo) + gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) + buf = gl.glReadPixels( + 0, 0, self.width, self.height, gl.GL_RGB, gl.GL_UNSIGNED_BYTE + ) + img = Image.frombytes("RGB", (self.width, self.height), buf) + return img.transpose(Image.Transpose.FLIP_TOP_BOTTOM) + + def destroy(self): + """Release the FBO resources and destroy the OSMesa context.""" + # GL cleanup first (context must still be current) + if self.fbo is not None: + gl.glDeleteFramebuffers(1, [self.fbo]) + self.fbo = None + if self._rbo_color is not None: + gl.glDeleteRenderbuffers(1, [self._rbo_color]) + self._rbo_color = None + if self._rbo_depth is not None: + gl.glDeleteRenderbuffers(1, [self._rbo_depth]) + self._rbo_depth = None + if self._ctx is not None: + self._libosmesa.OSMesaDestroyContext(self._ctx) + self._ctx = None + self._buf = None + logger.debug("OSMesa context destroyed.") + + # Allow use as a context manager + def __enter__(self): + self.make_current() + return self + + def __exit__(self, *_): + self.destroy() + + diff --git a/whippersnappy/gl/utils.py b/whippersnappy/gl/utils.py index b24bd08..79f2204 100644 --- a/whippersnappy/gl/utils.py +++ b/whippersnappy/gl/utils.py @@ -1,11 +1,13 @@ """GL helper utilities. Contains the implementation of OpenGL helpers used by the package. +Headless rendering on Linux uses OSMesa (CPU software renderer) via +:class:`~whippersnappy.gl.osmesa_context.OSMesaContext` as a fallback +when no display server or GPU is available. No EGL or GPU driver is +required for headless operation. """ import logging -import os -import sys import warnings from typing import Any @@ -20,8 +22,9 @@ # Module logger logger = logging.getLogger(__name__) -# Module-level EGL context handle (None when GLFW is used instead) -_egl_context: Any = None +# Module-level offscreen context handle (None when GLFW is used instead). +# May hold an OSMesaContext instance on headless environments. +_offscreen_context: Any = None def create_vao(): @@ -204,19 +207,20 @@ def init_window(width, height, title="PyOpenGL", visible=True): def create_window_with_fallback(width, height, title="WhipperSnapPy", visible=True): - """Create an OpenGL context, trying GLFW first and EGL as a fallback. + """Create an OpenGL context, trying GLFW first and OSMesa as a fallback. The function attempts context creation in this priority order: 1. **GLFW visible window** — normal path on workstations. - 2. **GLFW invisible window** — when a display exists but no screen - is needed (e.g. a remote desktop session). - 3. **EGL pbuffer** — fully headless; no display server required. - Works with NVIDIA/AMD GPU drivers and Mesa (llvmpipe) on CPU-only - systems. Requires ``libegl1`` (already installed in the Docker - image) and ``pyopengl >= 3.1``. - - When EGL is used the module-level ``_egl_context`` is set and + 2. **GLFW invisible window** — when a display exists but no screen is + needed. On **macOS** and **Windows** this is typically sufficient + even in CI because both platforms provide GPU drivers to the runner. + 3. **OSMesa software rendering** — fully headless; no display server, + no GPU, and no ``/dev/dri/`` devices required. Used on **Linux CI** + (Docker, GitHub Actions) where no display is available. Requires + ``libosmesa6`` (Debian/Ubuntu) or ``mesa-libOSMesa`` (RHEL/Fedora). + + When OSMesa is used the module-level ``_offscreen_context`` is set and ``make_current()`` is called so that subsequent OpenGL calls work identically to the GLFW path. @@ -234,38 +238,15 @@ def create_window_with_fallback(width, height, title="WhipperSnapPy", visible=Tr Returns ------- GLFWwindow or None - GLFW window handle when GLFW succeeded, ``None`` when EGL is used - (the context is already current via ``_egl_context.make_current()``). + GLFW window handle when GLFW succeeded, ``None`` when OSMesa is used + (the context is already current via ``_offscreen_context.make_current()``). Raises ------ RuntimeError If all three methods fail to produce a usable OpenGL context. """ - global _egl_context - - # Fast-path: if _check_display() already determined there is no working - # display, skip the two doomed GLFW attempts and go straight to EGL. - # This avoids warning noise and wasted time in Docker/CI/headless SSH. - # The sys.platform guard is preserved — EGL is Linux-only. - if os.environ.get("PYOPENGL_PLATFORM") == "egl": - if sys.platform != "linux": - raise RuntimeError( - f"Could not create any OpenGL context via GLFW on {sys.platform}. " - "Ensure a display is available." - ) - logger.info("No working display detected — using EGL headless directly.") - try: - from .egl_context import EGLContext - ctx = EGLContext(width, height) - ctx.make_current() - _egl_context = ctx - logger.info("Using EGL headless context — no display server required.") - return None - except (ImportError, RuntimeError) as exc: - raise RuntimeError( - f"EGL headless context failed: {exc}" - ) from exc + global _offscreen_context # --- Step 1: GLFW visible window --- window = init_window(width, height, title, visible=visible) @@ -281,27 +262,21 @@ def create_window_with_fallback(width, height, title="WhipperSnapPy", visible=Tr if window: return window - # --- Step 3: EGL headless pbuffer (Linux only) --- - logger.warning( - "GLFW context creation failed entirely (no display?). " - "Attempting EGL headless context." - ) - if sys.platform != "linux": - raise RuntimeError( - f"Could not create any OpenGL context via GLFW on {sys.platform}. " - "Ensure a display is available." - ) + # --- Step 3: OSMesa software rendering (Linux headless, no display needed) --- + # On macOS and Windows GLFW should have succeeded above via the GPU driver. + # OSMesa is the fallback for Linux environments without a display server. + logger.info("No display detected — trying OSMesa software rendering (CPU).") try: - from .egl_context import EGLContext - ctx = EGLContext(width, height) + from .osmesa_context import OSMesaContext + ctx = OSMesaContext(width, height) ctx.make_current() - _egl_context = ctx - logger.info("Using EGL headless context — no display server required.") + _offscreen_context = ctx + logger.info("Using OSMesa headless context — no display server or GPU required.") return None except (ImportError, RuntimeError) as exc: raise RuntimeError( "Could not create any OpenGL context (tried GLFW visible, " - f"GLFW invisible, EGL pbuffer). Last error: {exc}" + f"GLFW invisible, OSMesa). Last error: {exc}" ) from exc @@ -309,19 +284,19 @@ 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 EGL path. Call it at the end of every rendering function - instead of calling ``glfw.terminate()`` directly. + handles the OSMesa headless path. 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 ``create_window_with_fallback``, - or ``None`` when EGL is active. + or ``None`` when an OSMesa context is active. """ - global _egl_context - if _egl_context is not None: - _egl_context.destroy() # type: ignore[union-attr] - _egl_context = None + global _offscreen_context + if _offscreen_context is not None: + _offscreen_context.destroy() # type: ignore[union-attr] + _offscreen_context = None else: glfw.terminate() @@ -371,15 +346,15 @@ def setup_shader(meshdata, triangles, width, height, specular=True, ambient=0.0) def capture_window(window): """Read the current GL framebuffer and return it as a PIL Image (RGB). - Works for both GLFW windows and EGL headless contexts. When EGL is + Works for both GLFW windows and OSMesa headless contexts. When OSMesa is active (``window`` is ``None``) the pixels are read from the FBO that - was set up by :class:`~whippersnappy.gl.egl_context.EGLContext`; in + was set up by :class:`~whippersnappy.gl.osmesa_context.OSMesaContext`; in that case there is no HiDPI scaling to account for. Parameters ---------- window : GLFWwindow or None - GLFW window handle, or ``None`` when an EGL context is active. + GLFW window handle, or ``None`` when an OSMesa context is active. Returns ------- @@ -387,11 +362,11 @@ def capture_window(window): RGB image of the rendered frame, with the vertical flip applied so that the origin is at the top-left (image convention). """ - global _egl_context + global _offscreen_context - # --- EGL path: read directly from the FBO --- - if _egl_context is not None: - return _egl_context.read_pixels() # type: ignore[union-attr] + # --- OSMesa path: read directly from the FBO --- + if _offscreen_context is not None: + return _offscreen_context.read_pixels() # type: ignore[union-attr] # --- GLFW path: read from the default framebuffer --- monitor = glfw.get_primary_monitor() diff --git a/whippersnappy/gl/views.py b/whippersnappy/gl/views.py deleted file mode 100644 index 0d2af24..0000000 --- a/whippersnappy/gl/views.py +++ /dev/null @@ -1,199 +0,0 @@ -"""View matrices, presets, and interactive view state under gl package.""" - -from dataclasses import dataclass, field - -import numpy as np -import pyrr # still needed for compute_view_matrix - -from ..utils.types import ViewType - -# --------------------------------------------------------------------------- -# ViewState — single source of truth for all mutable view parameters -# --------------------------------------------------------------------------- - -@dataclass -class ViewState: - """Mutable view parameters for the interactive GUI render loop. - - All mouse/keyboard interaction updates this object; the view matrix is - recomputed from it each frame via :func:`compute_view_matrix`. - - Parameters - ---------- - rotation : np.ndarray - 4×4 float32 rotation matrix (identity = no rotation applied). - pan : np.ndarray - (x, y) pan offset in normalised screen-space units. - zoom : float - Z-translation packed into the transform matrix. - last_mouse_pos : np.ndarray or None - Last recorded mouse position in pixels; ``None`` when no button held. - left_button_down : bool - Whether the left mouse button is currently pressed. - right_button_down : bool - Whether the right mouse button is currently pressed. - middle_button_down : bool - Whether the middle mouse button is currently pressed. - """ - rotation: np.ndarray = field( - default_factory=lambda: np.eye(4, dtype=np.float32) - ) - pan: np.ndarray = field( - default_factory=lambda: np.zeros(2, dtype=np.float32) - ) - zoom: float = 0.4 - last_mouse_pos: np.ndarray | None = None - left_button_down: bool = False - right_button_down: bool = False - middle_button_down: bool = False - - -def compute_view_matrix(view_state: ViewState, base_view: np.ndarray) -> np.ndarray: - """Return the ``transform`` uniform — exactly as snap_rotate does it. - - Packs ``transl * rotation * base_view`` into a single matrix, matching - the snap_rotate convention (line: ``viewmat = transl * rot * base_view``). - The ``model`` and ``view`` uniforms are left as set by ``setup_shader`` - (identity and camera respectively) and must not be overwritten. - - Parameters - ---------- - view_state : ViewState - Current interactive view state. - base_view : np.ndarray - Fixed 4×4 orientation preset from :func:`get_view_matrices`. - - Returns - ------- - np.ndarray - 4×4 float32 matrix for the ``transform`` shader uniform. - """ - transl = pyrr.Matrix44.from_translation(( - view_state.pan[0], - view_state.pan[1], - 0.4 + view_state.zoom, - )) - rot = pyrr.Matrix44(view_state.rotation) - return np.array(transl * rot * pyrr.Matrix44(base_view), dtype=np.float32) - - - -# --------------------------------------------------------------------------- -# Arcball helpers -# --------------------------------------------------------------------------- - -def arcball_vector(x: float, y: float, width: int, height: int) -> np.ndarray: - """Map a 2-D screen pixel to a point on the unit arcball sphere. - - Normalises (x, y) to [-1, 1] NDC, then projects onto the unit sphere. - Points outside the sphere radius are clamped to the rim (z = 0). - - Parameters - ---------- - x, y : float - Mouse position in pixels. - width, height : int - Window dimensions in pixels. - - Returns - ------- - np.ndarray - Unit 3-vector on (or clamped to) the arcball sphere. - """ - s = min(width, height) - p = np.array([ - (2.0 * x - width) / s, - -(2.0 * y - height) / s, - 0.0, - ], dtype=np.float64) - sq = p[0] ** 2 + p[1] ** 2 - if sq <= 1.0: - p[2] = np.sqrt(1.0 - sq) - else: - p /= np.sqrt(sq) # clamp to rim - n = np.linalg.norm(p) - return p / n if n > 0 else p - - -def arcball_rotation_matrix(v1: np.ndarray, v2: np.ndarray) -> np.ndarray: - """Return a 4×4 rotation matrix that rotates unit vector *v1* to *v2*. - - Uses Rodrigues' rotation formula in pure numpy — no pyrr dependency. - Returns identity when *v1* and *v2* are coincident. - - Parameters - ---------- - v1, v2 : np.ndarray - Unit 3-vectors on the arcball sphere. - - Returns - ------- - np.ndarray - 4×4 float32 rotation matrix compatible with pyrr. - """ - axis = np.cross(v1, v2) - axis_len = np.linalg.norm(axis) - if axis_len < 1e-10: - return np.eye(4, dtype=np.float32) - - axis = axis / axis_len - angle = np.arctan2(axis_len, np.dot(v1, v2)) - - # Rodrigues' formula: R = I cos(a) + sin(a) [axis]× + (1-cos(a)) axis⊗axis - c, s = np.cos(angle), np.sin(angle) - t = 1.0 - c - x, y, z = axis - r3 = np.array([ - [t*x*x + c, t*x*y - s*z, t*x*z + s*y], - [t*x*y + s*z, t*y*y + c, t*y*z - s*x], - [t*x*z - s*y, t*y*z + s*x, t*z*z + c ], - ], dtype=np.float32) - - r4 = np.eye(4, dtype=np.float32) - r4[:3, :3] = r3 - return r4 - - -def get_view_matrices(): - """Return canonical 4x4 view matrices for common brain orientations. - - The returned dictionary maps :class:`whippersnappy.utils.types.ViewType` - enum members to corresponding 4x4 view matrices (dtype float32) that - can be used as camera/view transforms in the OpenGL renderer. - - Returns - ------- - dict - Mapping of :class:`ViewType` -> 4x4 numpy.ndarray view matrix. - """ - view_left = np.array([[0, 0, -1, 0], [-1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1]], dtype=np.float32) - view_right = np.array([[0, 0, 1, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1]], dtype=np.float32) - view_back = np.array([[1, 0, 0, 0], [0, 0, -1, 0], [0, 1, 0, 0], [0, 0, 0, 1]], dtype=np.float32) - view_front = np.array([[-1, 0, 0, 0], [0, 0, 1, 0], [0, 1, 0, 0], [0, 0, 0, 1]], dtype=np.float32) - view_bottom = np.array([[-1, 0, 0, 0], [0, 1, 0, 0], [0, 0, -1, 0], [0, 0, 0, 1]], dtype=np.float32) - view_top = np.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=np.float32) - - return { - ViewType.LEFT: view_left, - ViewType.RIGHT: view_right, - ViewType.BACK: view_back, - ViewType.FRONT: view_front, - ViewType.TOP: view_top, - ViewType.BOTTOM: view_bottom, - } - - -def get_view_matrix(view_type): - """Return the 4x4 view matrix for a single :class:`ViewType`. - - Parameters - ---------- - view_type : ViewType - Enum member indicating the requested view. - - Returns - ------- - numpy.ndarray - 4x4 float32 view matrix. - """ - return get_view_matrices()[view_type] diff --git a/whippersnappy/snap.py b/whippersnappy/snap.py index f3dbcd2..4706a10 100644 --- a/whippersnappy/snap.py +++ b/whippersnappy/snap.py @@ -12,9 +12,8 @@ from .geometry import estimate_overlay_thresholds, get_surf_name from .geometry.prepare import prepare_and_validate_geometry from .gl.utils import capture_window, create_window_with_fallback, render_scene, setup_shader, terminate_context -from .gl.views import get_view_matrices from .utils.image import create_colorbar, draw_caption, draw_colorbar, load_roboto_font, text_size -from .utils.types import ColorSelection, OrientationType, ViewType +from .utils.types import ColorSelection, OrientationType, ViewType, get_view_matrices # Module logger logger = logging.getLogger(__name__) diff --git a/whippersnappy/utils/types.py b/whippersnappy/utils/types.py index 8d0e417..2869e72 100644 --- a/whippersnappy/utils/types.py +++ b/whippersnappy/utils/types.py @@ -2,6 +2,7 @@ This module defines small enumeration types used across the package for controlling color selection, colorbar orientation, and predefined views. +It also provides the canonical per-view 4×4 matrices used by the renderers. Classes ------- @@ -11,10 +12,19 @@ Orientation of UI elements such as the colorbar (horizontal or vertical). ViewType Predefined canonical view orientations for rendering the brain surface. + +Functions +--------- +get_view_matrices + Return a dict mapping every :class:`ViewType` to its 4×4 numpy matrix. +get_view_matrix + Return the 4×4 numpy matrix for a single :class:`ViewType`. """ import enum +import numpy as np + class ColorSelection(enum.Enum): """Enum to select which sign(s) of overlay values to color. @@ -91,3 +101,40 @@ class ViewType(enum.Enum): TOP = 5 BOTTOM = 6 + +def get_view_matrices() -> dict: + """Return canonical 4×4 view matrices for every :class:`ViewType`. + + The matrices are pure numpy arrays (no OpenGL calls) and can be used + as the ``base_view`` argument to any renderer. + + Returns + ------- + dict + Mapping of :class:`ViewType` → 4×4 float32 numpy.ndarray. + """ + return { + ViewType.LEFT: np.array([[ 0, 0,-1, 0],[-1, 0, 0, 0],[ 0, 1, 0, 0],[0, 0, 0, 1]], dtype=np.float32), + ViewType.RIGHT: np.array([[ 0, 0, 1, 0],[ 1, 0, 0, 0],[ 0, 1, 0, 0],[0, 0, 0, 1]], dtype=np.float32), + ViewType.BACK: np.array([[ 1, 0, 0, 0],[ 0, 0,-1, 0],[ 0, 1, 0, 0],[0, 0, 0, 1]], dtype=np.float32), + ViewType.FRONT: np.array([[-1, 0, 0, 0],[ 0, 0, 1, 0],[ 0, 1, 0, 0],[0, 0, 0, 1]], dtype=np.float32), + ViewType.TOP: np.array([[ 1, 0, 0, 0],[ 0, 1, 0, 0],[ 0, 0, 1, 0],[0, 0, 0, 1]], dtype=np.float32), + ViewType.BOTTOM: np.array([[-1, 0, 0, 0],[ 0, 1, 0, 0],[ 0, 0,-1, 0],[0, 0, 0, 1]], dtype=np.float32), + } + + +def get_view_matrix(view_type: "ViewType") -> np.ndarray: + """Return the 4×4 view matrix for a single :class:`ViewType`. + + Parameters + ---------- + view_type : ViewType + Enum member indicating the requested view. + + Returns + ------- + numpy.ndarray + 4×4 float32 view matrix. + """ + return get_view_matrices()[view_type] + From 6cc6a95df3d416e0e70e1af068ef6f9733e87e9d Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Fri, 27 Feb 2026 22:48:08 +0100 Subject: [PATCH 02/11] try different GL profiles for windows and mac off-screen rendering --- .github/workflows/build.yml | 2 +- .github/workflows/doc.yml | 2 +- .github/workflows/publish.yml | 2 +- .github/workflows/pytest.yml | 13 ++++--- tests/test_array_and_rendering.py | 28 +++++++++------ whippersnappy/gl/utils.py | 58 ++++++++++++++++++++++++++----- 6 files changed, 75 insertions(+), 30 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b04065..ffe43bd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,7 +30,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install system OpenGL/EGL 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 diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index bf01390..88077c3 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2501a08..ad73e82 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ jobs: with: python-version: '3.13' - name: Install system dependencies - 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 diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 35deb68..05f473f 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -36,13 +36,12 @@ jobs: if: matrix.os == 'ubuntu' run: sudo apt-get install -y --no-install-recommends libosmesa6 libgl1 # Rendering strategy per platform: - # Ubuntu: no display → GLFW fails → OSMesa headless (libosmesa6) - # macOS: runner has a real GPU → GLFW invisible window succeeds - # Windows: runner has GPU drivers → GLFW invisible window succeeds - # On all three platforms the rendering tests in TestSnap1Rendering - # are expected to run (not skip). They only skip if the OpenGL context - # creation fails entirely (RuntimeError), which should not happen on - # any of these hosted runners. + # Ubuntu: no display → GLFW fails → OSMesa headless (libosmesa6) + # macOS: no real GPU on runner → GLFW retries with Compatibility + # Profile (drops FORWARD_COMPAT + CORE_PROFILE) → succeeds + # Windows: Basic Render Driver only → same Compatibility Profile + # fallback in init_window → succeeds + # On all three platforms TestSnap1Rendering is expected to RUN, not skip. - name: Install package run: | python -m pip install --progress-bar off --upgrade pip setuptools wheel diff --git a/tests/test_array_and_rendering.py b/tests/test_array_and_rendering.py index 2d65a76..34e66f8 100644 --- a/tests/test_array_and_rendering.py +++ b/tests/test_array_and_rendering.py @@ -192,16 +192,20 @@ def _snap1_offscreen(**kwargs): Forces ``visible=False`` so that: - On **macOS** the Cocoa compositor is bypassed and ``glReadPixels`` - returns correct pixel data immediately. - - On **Windows** no on-screen window needs to be created. - - On **Linux CI** (no display) GLFW will fail and - :func:`~whippersnappy.gl.utils.create_window_with_fallback` will - automatically fall back to OSMesa software rendering. + returns correct pixel data immediately. If the runner has no real GPU, + :func:`~whippersnappy.gl.utils.init_window` automatically retries with + an OpenGL 3.3 Compatibility Profile (dropping ``FORWARD_COMPAT`` and + ``CORE_PROFILE``), which the macOS software renderer accepts. + - On **Windows** the Microsoft Basic Render Driver does not support Core + Profile; the same Compatibility Profile retry allows context creation + to succeed without a real GPU. + - On **Linux CI** (no display) GLFW fails entirely and + :func:`~whippersnappy.gl.utils.create_window_with_fallback` falls back + to OSMesa software rendering. The tests are expected to **run** on all CI platforms (Ubuntu/macOS/ Windows). The ``pytest.skip`` is a safety net for exceptional cases - where the runner has no OpenGL support at all (e.g. a bare container - without Mesa and without a GPU). + where the runner has no OpenGL support at all. """ import whippersnappy.gl.utils as gl_utils # noqa: PLC0415 @@ -234,11 +238,13 @@ class TestSnap1Rendering: are expected to **run** on all CI platforms: - **Ubuntu**: OSMesa headless rendering (``libosmesa6`` installed in CI). - - **macOS**: GLFW invisible window backed by the runner's GPU. - - **Windows**: GLFW invisible window backed by the runner's GPU drivers. + - **macOS**: GLFW invisible window; Compatibility Profile fallback for + runners without a real GPU (macOS software renderer accepts OpenGL 3.3 + Compat). + - **Windows**: GLFW invisible window; Compatibility Profile fallback for + Microsoft Basic Render Driver (no Core Profile support without Mesa). - A ``pytest.skip`` is issued only if context creation fails completely, - which should not happen on any standard hosted runner. + A ``pytest.skip`` is issued only if context creation fails completely. """ def test_snap1_basic(self): diff --git a/whippersnappy/gl/utils.py b/whippersnappy/gl/utils.py index 79f2204..dc82d9a 100644 --- a/whippersnappy/gl/utils.py +++ b/whippersnappy/gl/utils.py @@ -167,9 +167,45 @@ def set_lighting_uniforms(shader, specular=True, ambient=0.0, light_color=(1.0, gl.glUniform1f(ambient_loc, ambient) +def _try_glfw_window(width, height, title, visible, core_profile): + """Attempt to create a single GLFW window with the given profile settings. + + Parameters + ---------- + core_profile : bool + If True request OpenGL 3.3 Core Profile + FORWARD_COMPAT (ideal). + If False request OpenGL 3.3 Compatibility Profile (works on Windows + Basic Render Driver and macOS software renderer in CI). + + Returns + ------- + window or None + """ + glfw.default_window_hints() + glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, 3) + glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, 3) + if core_profile: + glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, True) + glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE) + else: + glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, False) + glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_COMPAT_PROFILE) + if not visible: + glfw.window_hint(glfw.VISIBLE, glfw.FALSE) + return glfw.create_window(width, height, title, None, None) or None + + def init_window(width, height, title="PyOpenGL", visible=True): """Create a GLFW window, make an OpenGL context current and return the window handle. + Tries OpenGL 3.3 Core Profile first, then falls back to 3.3 Compatibility + Profile. The compatibility fallback is needed on: + + - **Windows** GitHub Actions runners (Microsoft Basic Render Driver, + no Core Profile support without a Mesa ``opengl32.dll``). + - **macOS** GitHub Actions runners (Apple software renderer, which rejects + ``OPENGL_FORWARD_COMPAT`` on some configurations). + Parameters ---------- width, height : int @@ -190,13 +226,15 @@ def init_window(width, height, title="PyOpenGL", visible=True): if not glfw.init(): return False - glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, 3) - glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, 3) - glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, True) - glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE) - if not visible: - glfw.window_hint(glfw.VISIBLE, glfw.FALSE) - window = glfw.create_window(width, height, title, None, None) + # Try Core Profile first (preferred — stricter, better error reporting). + window = _try_glfw_window(width, height, title, visible, core_profile=True) + if not window: + # Fall back to Compatibility Profile for CI runners without a real GPU + # (Windows Basic Render Driver, macOS software renderer). + logger.debug( + "OpenGL 3.3 Core Profile unavailable; retrying with Compatibility Profile." + ) + window = _try_glfw_window(width, height, title, visible, core_profile=False) if not window: glfw.terminate() return False @@ -213,8 +251,10 @@ def create_window_with_fallback(width, height, title="WhipperSnapPy", visible=Tr 1. **GLFW visible window** — normal path on workstations. 2. **GLFW invisible window** — when a display exists but no screen is - needed. On **macOS** and **Windows** this is typically sufficient - even in CI because both platforms provide GPU drivers to the runner. + needed. On **macOS** and **Windows** CI runners this path succeeds + because :func:`init_window` automatically retries with a Compatibility + Profile when Core Profile is unavailable (e.g. Microsoft Basic Render + Driver or macOS software renderer). 3. **OSMesa software rendering** — fully headless; no display server, no GPU, and no ``/dev/dri/`` devices required. Used on **Linux CI** (Docker, GitHub Actions) where no display is available. Requires From 8b0a614d1b5ca673526b914b4eba44e3049afb2b Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Fri, 27 Feb 2026 22:56:54 +0100 Subject: [PATCH 03/11] more testing for windows and mac --- .github/workflows/pytest.yml | 24 ++++++-- tests/test_array_and_rendering.py | 23 ++++---- whippersnappy/gl/utils.py | 91 +++++++++++++++++++------------ 3 files changed, 86 insertions(+), 52 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 05f473f..2f6cfe4 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -35,12 +35,28 @@ jobs: - name: Install system OSMesa library (Ubuntu) if: matrix.os == 'ubuntu' run: sudo apt-get install -y --no-install-recommends libosmesa6 libgl1 + - name: Install Mesa software OpenGL (Windows) + if: matrix.os == 'windows' + # mesa-dist-win provides a software OpenGL 3.3 Core implementation + # (opengl32.dll / libglapi.dll) that replaces the stub shipped with + # the Windows Basic Render Driver, which has no real OpenGL support. + # We drop the DLLs next to the Python executable so they are found + # before the system driver. + shell: pwsh + run: | + $url = "https://github.com/pal1000/mesa-dist-win/releases/download/24.3.4/mesa3d-24.3.4-release-mingw.7z" + $archive = "$env:TEMP\mesa.7z" + Invoke-WebRequest -Uri $url -OutFile $archive + 7z e $archive -o"$env:TEMP\mesa" x64\opengl32.dll x64\libglapi.dll -y + $pydir = Split-Path (python -c "import sys; print(sys.executable)") + Copy-Item "$env:TEMP\mesa\opengl32.dll" "$pydir\opengl32.dll" -Force + Copy-Item "$env:TEMP\mesa\libglapi.dll" "$pydir\libglapi.dll" -Force # Rendering strategy per platform: # Ubuntu: no display → GLFW fails → OSMesa headless (libosmesa6) - # macOS: no real GPU on runner → GLFW retries with Compatibility - # Profile (drops FORWARD_COMPAT + CORE_PROFILE) → succeeds - # Windows: Basic Render Driver only → same Compatibility Profile - # fallback in init_window → succeeds + # macOS: GLFW invisible window, Core Profile + FORWARD_COMPAT + # (NSGL does not support Compatibility Profile at all) + # Windows: Mesa software opengl32.dll → GLFW invisible window, + # Core Profile succeeds via llvmpipe # On all three platforms TestSnap1Rendering is expected to RUN, not skip. - name: Install package run: | diff --git a/tests/test_array_and_rendering.py b/tests/test_array_and_rendering.py index 34e66f8..6b3e8eb 100644 --- a/tests/test_array_and_rendering.py +++ b/tests/test_array_and_rendering.py @@ -191,14 +191,12 @@ def _snap1_offscreen(**kwargs): Forces ``visible=False`` so that: - - On **macOS** the Cocoa compositor is bypassed and ``glReadPixels`` - returns correct pixel data immediately. If the runner has no real GPU, - :func:`~whippersnappy.gl.utils.init_window` automatically retries with - an OpenGL 3.3 Compatibility Profile (dropping ``FORWARD_COMPAT`` and - ``CORE_PROFILE``), which the macOS software renderer accepts. - - On **Windows** the Microsoft Basic Render Driver does not support Core - Profile; the same Compatibility Profile retry allows context creation - to succeed without a real GPU. + - On **macOS** the Cocoa compositor is bypassed. Only Core Profile + + ``FORWARD_COMPAT`` is attempted — NSGL does not support Compatibility + Profile at all. + - On **Windows** CI, Mesa ``opengl32.dll`` (installed by the workflow) + provides software OpenGL 3.3 Core so the invisible window succeeds + without a real GPU. - On **Linux CI** (no display) GLFW fails entirely and :func:`~whippersnappy.gl.utils.create_window_with_fallback` falls back to OSMesa software rendering. @@ -238,11 +236,10 @@ class TestSnap1Rendering: are expected to **run** on all CI platforms: - **Ubuntu**: OSMesa headless rendering (``libosmesa6`` installed in CI). - - **macOS**: GLFW invisible window; Compatibility Profile fallback for - runners without a real GPU (macOS software renderer accepts OpenGL 3.3 - Compat). - - **Windows**: GLFW invisible window; Compatibility Profile fallback for - Microsoft Basic Render Driver (no Core Profile support without Mesa). + - **macOS**: GLFW invisible window, Core Profile + ``FORWARD_COMPAT`` + (the only profile NSGL supports). + - **Windows**: GLFW invisible window backed by Mesa ``opengl32.dll`` + (software OpenGL 3.3 Core, installed by the CI workflow). A ``pytest.skip`` is issued only if context creation fails completely. """ diff --git a/whippersnappy/gl/utils.py b/whippersnappy/gl/utils.py index dc82d9a..fef0d26 100644 --- a/whippersnappy/gl/utils.py +++ b/whippersnappy/gl/utils.py @@ -8,6 +8,7 @@ """ import logging +import sys import warnings from typing import Any @@ -167,20 +168,30 @@ def set_lighting_uniforms(shader, specular=True, ambient=0.0, light_color=(1.0, gl.glUniform1f(ambient_loc, ambient) + def _try_glfw_window(width, height, title, visible, core_profile): """Attempt to create a single GLFW window with the given profile settings. + Calls ``glfw.init()`` before and ``glfw.terminate()`` on failure so that + each attempt starts from a clean GLFW state. + Parameters ---------- core_profile : bool - If True request OpenGL 3.3 Core Profile + FORWARD_COMPAT (ideal). - If False request OpenGL 3.3 Compatibility Profile (works on Windows - Basic Render Driver and macOS software renderer in CI). + If True request OpenGL 3.3 Core Profile + FORWARD_COMPAT (required on + macOS; preferred everywhere). + If False request OpenGL 3.3 Compatibility Profile (Windows CI with a + software renderer that supports compat but not core). Returns ------- window or None """ + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + if not glfw.init(): + return None + glfw.default_window_hints() glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, 3) glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, 3) @@ -192,19 +203,26 @@ def _try_glfw_window(width, height, title, visible, core_profile): glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_COMPAT_PROFILE) if not visible: glfw.window_hint(glfw.VISIBLE, glfw.FALSE) - return glfw.create_window(width, height, title, None, None) or None + + window = glfw.create_window(width, height, title, None, None) + if not window: + glfw.terminate() + return None + return window def init_window(width, height, title="PyOpenGL", visible=True): """Create a GLFW window, make an OpenGL context current and return the window handle. - Tries OpenGL 3.3 Core Profile first, then falls back to 3.3 Compatibility - Profile. The compatibility fallback is needed on: + On **macOS** only OpenGL 3.3 Core Profile + ``FORWARD_COMPAT`` is + attempted — NSGL does not support Compatibility Profile at all. + + On **Windows** and **Linux** Core Profile is tried first; if it fails + (e.g. on a Windows CI runner with a basic software renderer that supports + compat but not core) Compatibility Profile is retried. - - **Windows** GitHub Actions runners (Microsoft Basic Render Driver, - no Core Profile support without a Mesa ``opengl32.dll``). - - **macOS** GitHub Actions runners (Apple software renderer, which rejects - ``OPENGL_FORWARD_COMPAT`` on some configurations). + Each attempt calls ``glfw.init()`` / ``glfw.terminate()`` independently + so that a failed attempt leaves no stale GLFW state for the next. Parameters ---------- @@ -213,35 +231,38 @@ def init_window(width, height, title="PyOpenGL", visible=True): title : str, optional, default 'PyOpenGL' Window title. visible : bool, optional, default True - If False create an invisible/offscreen window (useful for headless - rendering when a display is available but no screen is needed). + If False create an invisible/offscreen window. Returns ------- window or False GLFW window handle on success, or False on failure. """ - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - if not glfw.init(): - return False - - # Try Core Profile first (preferred — stricter, better error reporting). + # Core Profile (required on macOS, preferred everywhere). window = _try_glfw_window(width, height, title, visible, core_profile=True) - if not window: - # Fall back to Compatibility Profile for CI runners without a real GPU - # (Windows Basic Render Driver, macOS software renderer). - logger.debug( - "OpenGL 3.3 Core Profile unavailable; retrying with Compatibility Profile." - ) - window = _try_glfw_window(width, height, title, visible, core_profile=False) - if not window: - glfw.terminate() + if window: + glfw.set_input_mode(window, glfw.STICKY_KEYS, gl.GL_TRUE) + glfw.make_context_current(window) + glfw.swap_interval(0) + return window + + # macOS NSGL does not support Compatibility Profile — don't retry. + if sys.platform == "darwin": return False - glfw.set_input_mode(window, glfw.STICKY_KEYS, gl.GL_TRUE) - glfw.make_context_current(window) - glfw.swap_interval(0) - return window + + # Non-macOS: retry with Compatibility Profile (helps on some Windows CI + # runners with software renderers that support compat but not core). + logger.debug( + "OpenGL 3.3 Core Profile unavailable; retrying with Compatibility Profile." + ) + window = _try_glfw_window(width, height, title, visible, core_profile=False) + if window: + glfw.set_input_mode(window, glfw.STICKY_KEYS, gl.GL_TRUE) + glfw.make_context_current(window) + glfw.swap_interval(0) + return window + + return False def create_window_with_fallback(width, height, title="WhipperSnapPy", visible=True): @@ -251,10 +272,10 @@ def create_window_with_fallback(width, height, title="WhipperSnapPy", visible=Tr 1. **GLFW visible window** — normal path on workstations. 2. **GLFW invisible window** — when a display exists but no screen is - needed. On **macOS** and **Windows** CI runners this path succeeds - because :func:`init_window` automatically retries with a Compatibility - Profile when Core Profile is unavailable (e.g. Microsoft Basic Render - Driver or macOS software renderer). + needed. On **macOS** only Core Profile is attempted (NSGL has no + Compatibility Profile). On **Windows** CI, Mesa ``opengl32.dll`` + (installed by the CI workflow) provides a software OpenGL 3.3 Core + implementation so the invisible-window path succeeds. 3. **OSMesa software rendering** — fully headless; no display server, no GPU, and no ``/dev/dri/`` devices required. Used on **Linux CI** (Docker, GitHub Actions) where no display is available. Requires From 62845278c197de44c46674e229fef5daeadc980e Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Fri, 27 Feb 2026 23:04:13 +0100 Subject: [PATCH 04/11] do not import OpenGL.GL before mesa if headless --- whippersnappy/gl/osmesa_context.py | 181 +++++++++++------------------ whippersnappy/gl/utils.py | 23 +++- 2 files changed, 93 insertions(+), 111 deletions(-) diff --git a/whippersnappy/gl/osmesa_context.py b/whippersnappy/gl/osmesa_context.py index 59f8547..99e19ef 100644 --- a/whippersnappy/gl/osmesa_context.py +++ b/whippersnappy/gl/osmesa_context.py @@ -1,4 +1,4 @@ -"""OSMesa off-screen (headless) OpenGL context via software rendering + FBO. +"""OSMesa off-screen (headless) OpenGL context via software rendering. This module provides a drop-in alternative to GLFW window creation for headless environments (CI, Docker, HPC clusters) where no X11/Wayland @@ -9,20 +9,28 @@ - PyOpenGL >= 3.1 (already a project dependency). - No GPU, no ``/dev/dri/`` devices, no display server. +**Important:** ``PYOPENGL_PLATFORM=osmesa`` must be set in the environment +*before* the first ``import OpenGL.GL`` anywhere in the process. +:func:`~whippersnappy.gl.utils.create_window_with_fallback` takes care of +this automatically. + Typical usage (internal, called from ``create_window_with_fallback``):: + import os + os.environ["PYOPENGL_PLATFORM"] = "osmesa" # must be first from whippersnappy.gl.osmesa_context import OSMesaContext ctx = OSMesaContext(width, height) ctx.make_current() - # ... OpenGL calls ... + # ... OpenGL calls render into ctx's internal pixel buffer ... img = ctx.read_pixels() ctx.destroy() Notes ----- -OSMesa renders entirely in software (CPU). The OpenGL API surface is -identical to EGL or GLFW — shaders, FBOs, VAOs etc. all work unchanged. +OSMesa renders into its own pixel buffer which acts as the default +framebuffer (FBO 0). No explicit FBO creation is needed — ``glReadPixels`` +reads directly from the OSMesa buffer. """ import ctypes @@ -30,12 +38,6 @@ import logging import sys -if sys.platform != "linux": - raise ImportError( - "OSMesaContext is only supported on Linux. " - "On macOS use GLFW/CGL and on Windows use GLFW/WGL instead." - ) - import OpenGL.GL as gl from PIL import Image @@ -51,34 +53,63 @@ def _load_libosmesa(): - """Try several candidate names and return the loaded ctypes CDLL.""" - candidates = ["OSMesa", "libOSMesa.so.8", "libOSMesa.so.6", "libOSMesa.so"] - # ctypes.util.find_library may give us a name that LoadLibrary knows + """Try several candidate names and return the loaded ctypes CDLL. + + Candidate names are platform-specific but we always try all of them so + that e.g. a macOS user with a non-default Homebrew prefix still has a + chance of success. + """ + if sys.platform == "win32": + candidates = ["osmesa", "osmesa.dll", "libOSMesa.dll"] + elif sys.platform == "darwin": + # Homebrew mesa does NOT build libOSMesa — a custom build with + # -Dosmesa=true is needed. We still try in case the user has one. + candidates = ["libOSMesa.dylib", "libOSMesa.8.dylib"] + else: # Linux / WSL2 + candidates = ["libOSMesa.so.8", "libOSMesa.so.6", "libOSMesa.so"] + + # ctypes.util.find_library may resolve a shorter name to the real path found = ctypes.util.find_library("OSMesa") - if found: + if found and found not in candidates: candidates.insert(0, found) + + last_err = None for name in candidates: try: lib = ctypes.CDLL(name) - # Verify it has the symbol we need _ = lib.OSMesaCreateContextExt logger.debug("Loaded OSMesa from: %s", name) return lib - except (OSError, AttributeError): - continue - raise RuntimeError( - "Could not load libOSMesa. " - "Install it with: sudo apt-get install libosmesa6 (Debian/Ubuntu) " - "or: sudo dnf install mesa-libOSMesa (RHEL/Fedora)." - ) + except (OSError, AttributeError) as exc: + last_err = exc + + if sys.platform == "win32": + hint = "Install via MSYS2: pacman -S mingw-w64-x86_64-mesa" + elif sys.platform == "darwin": + hint = ( + "libOSMesa is not available via 'brew install mesa' (Homebrew does not " + "build OSMesa). A custom Mesa build with -Dosmesa=true is required, or " + "use a display/GLFW for rendering on macOS." + ) + else: + hint = ( + "Install with: sudo apt-get install libosmesa6 (Debian/Ubuntu) " + "or sudo dnf install mesa-libOSMesa (RHEL/Fedora)" + ) + + raise RuntimeError(f"Could not load libOSMesa ({last_err}). {hint}") class OSMesaContext: - """A headless OpenGL 3.3-compatible context backed by OSMesa + FBO. + """A headless OpenGL context backed by OSMesa software rendering. + + OSMesa renders into its own pixel buffer which serves as the default + framebuffer. No explicit FBO is required — ``glReadPixels`` reads + directly from the OSMesa buffer. - OSMesa renders entirely in software (CPU). The rendered pixels are - read via the same FBO + ``glReadPixels`` path used by the EGL context, - so the output is identical. + ``PYOPENGL_PLATFORM=osmesa`` **must** already be set in the environment + before this class is imported (handled by + :func:`~whippersnappy.gl.utils.create_window_with_fallback`). Parameters ---------- @@ -87,8 +118,6 @@ class OSMesaContext: Raises ------ - ImportError - If run on a non-Linux platform. RuntimeError If libOSMesa cannot be loaded or context creation fails. """ @@ -99,20 +128,12 @@ def __init__(self, width: int, height: int): self._libosmesa = None self._ctx = None self._buf = None - self.fbo = None - self._rbo_color = None - self._rbo_depth = None self._init_osmesa() - # ------------------------------------------------------------------ - # Private helpers - # ------------------------------------------------------------------ - def _init_osmesa(self): lib = _load_libosmesa() self._libosmesa = lib - # Set ctypes signatures lib.OSMesaCreateContextExt.restype = ctypes.c_void_p lib.OSMesaCreateContextExt.argtypes = [ ctypes.c_uint, # format (OSMESA_RGBA) @@ -133,11 +154,7 @@ def _init_osmesa(self): lib.OSMesaDestroyContext.argtypes = [ctypes.c_void_p] ctx = lib.OSMesaCreateContextExt( - _OSMESA_RGBA, # pixel format - 24, # depth bits - 8, # stencil bits - 0, # accum bits - None, # no shared context + _OSMESA_RGBA, 24, 8, 0, None, ) if not ctx: raise RuntimeError( @@ -146,20 +163,17 @@ def _init_osmesa(self): ) self._ctx = ctx - # Allocate the pixel buffer that OSMesa renders into buf_size = self.width * self.height * 4 # RGBA bytes self._buf = (ctypes.c_ubyte * buf_size)() logger.info("OSMesa context created (%dx%d)", self.width, self.height) - # ------------------------------------------------------------------ - # Public API (same as EGLContext) - # ------------------------------------------------------------------ - def make_current(self): - """Make this OSMesa context current and set up the FBO render target. + """Make this OSMesa context current. - Must be called before any OpenGL commands. Creates and binds an - FBO backed by two renderbuffers (RGBA color + depth/stencil). + OSMesa renders into its own internal pixel buffer which acts as the + default framebuffer (FBO 0). No FBO creation is needed. + ``PYOPENGL_PLATFORM=osmesa`` must already be set so that PyOpenGL + resolves function pointers via OSMesaGetProcAddress. """ ok = self._libosmesa.OSMesaMakeCurrent( self._ctx, @@ -171,62 +185,21 @@ def make_current(self): if not ok: raise RuntimeError("OSMesaMakeCurrent failed.") - # Force PyOpenGL to discover and cache the context we just made current. - gl.glGetError() - - # Build FBO so rendering is directed off-screen into a proper - # renderbuffer (same pattern as EGLContext.make_current) - self.fbo = gl.glGenFramebuffers(1) - gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.fbo) - - # Color renderbuffer - self._rbo_color = gl.glGenRenderbuffers(1) - gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self._rbo_color) - gl.glRenderbufferStorage( - gl.GL_RENDERBUFFER, gl.GL_RGBA8, self.width, self.height - ) - gl.glFramebufferRenderbuffer( - gl.GL_FRAMEBUFFER, - gl.GL_COLOR_ATTACHMENT0, - gl.GL_RENDERBUFFER, - self._rbo_color, - ) - - # Depth + stencil renderbuffer - self._rbo_depth = gl.glGenRenderbuffers(1) - gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self._rbo_depth) - gl.glRenderbufferStorage( - gl.GL_RENDERBUFFER, - gl.GL_DEPTH24_STENCIL8, - self.width, - self.height, - ) - gl.glFramebufferRenderbuffer( - gl.GL_FRAMEBUFFER, - gl.GL_DEPTH_STENCIL_ATTACHMENT, - gl.GL_RENDERBUFFER, - self._rbo_depth, - ) - - status = gl.glCheckFramebufferStatus(gl.GL_FRAMEBUFFER) - if status != gl.GL_FRAMEBUFFER_COMPLETE: - raise RuntimeError( - f"FBO is not complete after OSMesa setup (status=0x{status:X})." - ) - gl.glViewport(0, 0, self.width, self.height) - logger.debug("OSMesa FBO complete and bound (%dx%d)", self.width, self.height) + logger.debug("OSMesa context is current (%dx%d)", self.width, self.height) def read_pixels(self) -> Image.Image: - """Read the FBO contents and return a PIL RGB Image. + """Read the OSMesa framebuffer and return a PIL RGB Image. + + Reads from the default framebuffer (FBO 0), which is OSMesa's own + pixel buffer. No FBO bind needed. Returns ------- PIL.Image.Image - Captured frame, vertically flipped to convert from OpenGL's - bottom-left origin to image top-left convention. + Captured frame, vertically flipped to top-left origin convention. """ - gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.fbo) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0) gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) buf = gl.glReadPixels( 0, 0, self.width, self.height, gl.GL_RGB, gl.GL_UNSIGNED_BYTE @@ -235,24 +208,13 @@ def read_pixels(self) -> Image.Image: return img.transpose(Image.Transpose.FLIP_TOP_BOTTOM) def destroy(self): - """Release the FBO resources and destroy the OSMesa context.""" - # GL cleanup first (context must still be current) - if self.fbo is not None: - gl.glDeleteFramebuffers(1, [self.fbo]) - self.fbo = None - if self._rbo_color is not None: - gl.glDeleteRenderbuffers(1, [self._rbo_color]) - self._rbo_color = None - if self._rbo_depth is not None: - gl.glDeleteRenderbuffers(1, [self._rbo_depth]) - self._rbo_depth = None + """Destroy the OSMesa context and release the pixel buffer.""" if self._ctx is not None: self._libosmesa.OSMesaDestroyContext(self._ctx) self._ctx = None self._buf = None logger.debug("OSMesa context destroyed.") - # Allow use as a context manager def __enter__(self): self.make_current() return self @@ -260,4 +222,3 @@ def __enter__(self): def __exit__(self, *_): self.destroy() - diff --git a/whippersnappy/gl/utils.py b/whippersnappy/gl/utils.py index fef0d26..e597ca7 100644 --- a/whippersnappy/gl/utils.py +++ b/whippersnappy/gl/utils.py @@ -5,13 +5,31 @@ :class:`~whippersnappy.gl.osmesa_context.OSMesaContext` as a fallback when no display server or GPU is available. No EGL or GPU driver is required for headless operation. + +On Linux without a display, ``PYOPENGL_PLATFORM=osmesa`` is set +automatically at import time (before ``OpenGL.GL`` is imported) so that +PyOpenGL resolves all function pointers via ``OSMesaGetProcAddress``. """ import logging +import os import sys import warnings from typing import Any +# On Linux, if no display is available, pre-set PYOPENGL_PLATFORM=osmesa so +# that PyOpenGL resolves function pointers via OSMesaGetProcAddress rather +# than GLX (which returns null pointers when there is no X11 display). +# This must happen *before* "import OpenGL.GL" below. +# On macOS and Windows we leave the default (CGL / WGL) so GLFW works. +if ( + sys.platform == "linux" + and "PYOPENGL_PLATFORM" not in os.environ + and not os.environ.get("DISPLAY") + and not os.environ.get("WAYLAND_DISPLAY") +): + os.environ["PYOPENGL_PLATFORM"] = "osmesa" + import glfw import OpenGL.GL as gl import OpenGL.GL.shaders as shaders @@ -326,9 +344,12 @@ def create_window_with_fallback(width, height, title="WhipperSnapPy", visible=Tr # --- Step 3: OSMesa software rendering (Linux headless, no display needed) --- # On macOS and Windows GLFW should have succeeded above via the GPU driver. # OSMesa is the fallback for Linux environments without a display server. + # PYOPENGL_PLATFORM=osmesa was already set at module import time (top of + # this file) when no display was detected, so PyOpenGL already uses + # OSMesaGetProcAddress for function pointer resolution. logger.info("No display detected — trying OSMesa software rendering (CPU).") try: - from .osmesa_context import OSMesaContext + from .osmesa_context import OSMesaContext # noqa: PLC0415 ctx = OSMesaContext(width, height) ctx.make_current() _offscreen_context = ctx From cddd6fee6987367c3d04742b3c31ef038c6ec476 Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Fri, 27 Feb 2026 23:12:05 +0100 Subject: [PATCH 05/11] more windows and mac CI tests --- .github/workflows/pytest.yml | 24 +++++++++++------------- tests/test_array_and_rendering.py | 19 ++++++++++--------- whippersnappy/gl/utils.py | 27 +++++++++++++++++---------- 3 files changed, 38 insertions(+), 32 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 2f6cfe4..2bdef0e 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -37,25 +37,23 @@ jobs: run: sudo apt-get install -y --no-install-recommends libosmesa6 libgl1 - name: Install Mesa software OpenGL (Windows) if: matrix.os == 'windows' - # mesa-dist-win provides a software OpenGL 3.3 Core implementation - # (opengl32.dll / libglapi.dll) that replaces the stub shipped with - # the Windows Basic Render Driver, which has no real OpenGL support. - # We drop the DLLs next to the Python executable so they are found - # before the system driver. + # 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-mingw.7z" + $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 - 7z e $archive -o"$env:TEMP\mesa" x64\opengl32.dll x64\libglapi.dll -y - $pydir = Split-Path (python -c "import sys; print(sys.executable)") - Copy-Item "$env:TEMP\mesa\opengl32.dll" "$pydir\opengl32.dll" -Force - Copy-Item "$env:TEMP\mesa\libglapi.dll" "$pydir\libglapi.dll" -Force + 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: GLFW invisible window, Core Profile + FORWARD_COMPAT - # (NSGL does not support Compatibility Profile at all) - # Windows: Mesa software opengl32.dll → GLFW invisible window, + # macOS: GLFW invisible window, Core Profile WITHOUT FORWARD_COMPAT + # (CGL on ARM runners rejects pixel format with FORWARD_COMPAT) + # Windows: Mesa opengl32.dll on PATH → GLFW invisible window, # Core Profile succeeds via llvmpipe # On all three platforms TestSnap1Rendering is expected to RUN, not skip. - name: Install package diff --git a/tests/test_array_and_rendering.py b/tests/test_array_and_rendering.py index 6b3e8eb..ac5a95a 100644 --- a/tests/test_array_and_rendering.py +++ b/tests/test_array_and_rendering.py @@ -191,12 +191,13 @@ def _snap1_offscreen(**kwargs): Forces ``visible=False`` so that: - - On **macOS** the Cocoa compositor is bypassed. Only Core Profile + - ``FORWARD_COMPAT`` is attempted — NSGL does not support Compatibility - Profile at all. - - On **Windows** CI, Mesa ``opengl32.dll`` (installed by the workflow) - provides software OpenGL 3.3 Core so the invisible window succeeds - without a real GPU. + - On **macOS** the Cocoa compositor is bypassed. Core Profile is + requested *without* ``OPENGL_FORWARD_COMPAT`` — Apple CGL on ARM + runners (macOS 14+) rejects the pixel format when that flag is set + for invisible windows. + - On **Windows** CI, Mesa ``opengl32.dll`` is on ``PATH`` (installed by + the workflow), providing software OpenGL 3.3 Core via llvmpipe so the + invisible window succeeds without a real GPU. - On **Linux CI** (no display) GLFW fails entirely and :func:`~whippersnappy.gl.utils.create_window_with_fallback` falls back to OSMesa software rendering. @@ -236,10 +237,10 @@ class TestSnap1Rendering: are expected to **run** on all CI platforms: - **Ubuntu**: OSMesa headless rendering (``libosmesa6`` installed in CI). - - **macOS**: GLFW invisible window, Core Profile + ``FORWARD_COMPAT`` - (the only profile NSGL supports). + - **macOS**: GLFW invisible window, Core Profile without ``FORWARD_COMPAT`` + (CGL on ARM runners rejects pixel format when it is set). - **Windows**: GLFW invisible window backed by Mesa ``opengl32.dll`` - (software OpenGL 3.3 Core, installed by the CI workflow). + (software OpenGL 3.3 Core via llvmpipe, on ``PATH`` from CI workflow). A ``pytest.skip`` is issued only if context creation fails completely. """ diff --git a/whippersnappy/gl/utils.py b/whippersnappy/gl/utils.py index e597ca7..448f069 100644 --- a/whippersnappy/gl/utils.py +++ b/whippersnappy/gl/utils.py @@ -196,10 +196,12 @@ def _try_glfw_window(width, height, title, visible, core_profile): Parameters ---------- core_profile : bool - If True request OpenGL 3.3 Core Profile + FORWARD_COMPAT (required on - macOS; preferred everywhere). - If False request OpenGL 3.3 Compatibility Profile (Windows CI with a - software renderer that supports compat but not core). + If True request OpenGL 3.3 Core Profile (preferred everywhere). + ``OPENGL_FORWARD_COMPAT`` is only set on non-macOS platforms — + macOS CGL rejects the pixel format when it is set for invisible + windows on some runners (ARM, macOS 14+). + If False request OpenGL 3.3 Compatibility Profile (fallback for + some Windows software renderers). Returns ------- @@ -214,7 +216,11 @@ def _try_glfw_window(width, height, title, visible, core_profile): glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, 3) glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, 3) if core_profile: - glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, True) + # FORWARD_COMPAT is needed on Linux/Windows to exclude deprecated + # features but macOS CGL rejects the pixel format when it is set + # together with an invisible window on ARM runners. + if sys.platform != "darwin": + glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, True) glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE) else: glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, False) @@ -232,12 +238,13 @@ def _try_glfw_window(width, height, title, visible, core_profile): def init_window(width, height, title="PyOpenGL", visible=True): """Create a GLFW window, make an OpenGL context current and return the window handle. - On **macOS** only OpenGL 3.3 Core Profile + ``FORWARD_COMPAT`` is - attempted — NSGL does not support Compatibility Profile at all. + Tries OpenGL 3.3 Core Profile first. ``OPENGL_FORWARD_COMPAT`` is set + on Linux/Windows but **not** on macOS — Apple CGL rejects the pixel + format for invisible windows when it is set on ARM runners (macOS 14+). - On **Windows** and **Linux** Core Profile is tried first; if it fails - (e.g. on a Windows CI runner with a basic software renderer that supports - compat but not core) Compatibility Profile is retried. + On **macOS** only Core Profile is attempted — NSGL has no Compatibility + Profile. On **Windows** and **Linux**, Compatibility Profile is retried + if Core Profile fails. Each attempt calls ``glfw.init()`` / ``glfw.terminate()`` independently so that a failed attempt leaves no stale GLFW state for the next. From ab23abdaf59031e3ee0f76be0d11b06e38325b02 Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Fri, 27 Feb 2026 23:51:38 +0100 Subject: [PATCH 06/11] fix doc build and tests skip render on mac --- .github/workflows/pytest.yml | 7 +++-- doc/conf.py | 5 +++- tests/test_array_and_rendering.py | 44 ++++++++++++++++++++----------- 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 2bdef0e..6ff6694 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -51,11 +51,10 @@ jobs: 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: GLFW invisible window, Core Profile WITHOUT FORWARD_COMPAT - # (CGL on ARM runners rejects pixel format with FORWARD_COMPAT) + # macOS: rendering tests SKIPPED — NSGL requires a real display + # connection even for invisible windows; CI runners have none # Windows: Mesa opengl32.dll on PATH → GLFW invisible window, - # Core Profile succeeds via llvmpipe - # On all three platforms TestSnap1Rendering is expected to RUN, not skip. + # Core Profile succeeds via llvmpipe; tests run normally - name: Install package run: | python -m pip install --progress-bar off --upgrade pip setuptools wheel diff --git a/doc/conf.py b/doc/conf.py index de7fd73..627b05d 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -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 @@ -194,6 +194,9 @@ # Imported third-party objects exposed in plot3d module r"\.HTML$", r"\.VBox$", + # stdlib dataclasses re-exported into cli module scope + r"\.dataclass$", + r"\.field$", } # -- sphinxcontrib-bibtex ---------------------------------------------------- diff --git a/tests/test_array_and_rendering.py b/tests/test_array_and_rendering.py index ac5a95a..ff1bc5d 100644 --- a/tests/test_array_and_rendering.py +++ b/tests/test_array_and_rendering.py @@ -5,6 +5,9 @@ triangle meshes. """ +import os +import sys + import numpy as np import pytest @@ -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) # --------------------------------------------------------------------------- @@ -191,20 +203,17 @@ def _snap1_offscreen(**kwargs): Forces ``visible=False`` so that: - - On **macOS** the Cocoa compositor is bypassed. Core Profile is - requested *without* ``OPENGL_FORWARD_COMPAT`` — Apple CGL on ARM - runners (macOS 14+) rejects the pixel format when that flag is set - for invisible windows. - - On **Windows** CI, Mesa ``opengl32.dll`` is on ``PATH`` (installed by - the workflow), providing software OpenGL 3.3 Core via llvmpipe so the - invisible window succeeds without a real GPU. + - 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 tests are expected to **run** on all CI platforms (Ubuntu/macOS/ - Windows). The ``pytest.skip`` is a safety net for exceptional cases - where the runner has no OpenGL support at all. + 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 @@ -226,6 +235,7 @@ 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. @@ -234,15 +244,17 @@ class TestSnap1Rendering: appear edge-on and produce an all-black image. Tests use an offscreen GLFW context (see :func:`_snap1_offscreen`) and - are expected to **run** on all CI platforms: + run on: - - **Ubuntu**: OSMesa headless rendering (``libosmesa6`` installed in CI). - - **macOS**: GLFW invisible window, Core Profile without ``FORWARD_COMPAT`` - (CGL on ARM runners rejects pixel format when it is set). - - **Windows**: GLFW invisible window backed by Mesa ``opengl32.dll`` + - **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). - A ``pytest.skip`` is issued only if context creation fails completely. + The ``pytest.skip`` inside ``_snap1_offscreen`` is a safety net for any + other environment where context creation fails completely. """ def test_snap1_basic(self): From 26443400cad43f25ae650081d116c9b39bf4d8a3 Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Sat, 28 Feb 2026 00:00:42 +0100 Subject: [PATCH 07/11] fix doc build --- doc/conf.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/conf.py b/doc/conf.py index 627b05d..73bcc56 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -197,6 +197,10 @@ # 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 ---------------------------------------------------- From b4c639071d3734e56835791f19a9b908a684eaec Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Sat, 28 Feb 2026 00:28:56 +0100 Subject: [PATCH 08/11] simplify code and improve comments --- .github/workflows/pytest.yml | 4 +- whippersnappy/gl/osmesa_context.py | 83 ++++++++++-------------------- whippersnappy/gl/utils.py | 57 +++++++++----------- 3 files changed, 52 insertions(+), 92 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 6ff6694..f678530 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -53,8 +53,8 @@ jobs: # 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 on PATH → GLFW invisible window, - # Core Profile succeeds via llvmpipe; tests run normally + # 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 diff --git a/whippersnappy/gl/osmesa_context.py b/whippersnappy/gl/osmesa_context.py index 99e19ef..e011642 100644 --- a/whippersnappy/gl/osmesa_context.py +++ b/whippersnappy/gl/osmesa_context.py @@ -9,22 +9,11 @@ - PyOpenGL >= 3.1 (already a project dependency). - No GPU, no ``/dev/dri/`` devices, no display server. -**Important:** ``PYOPENGL_PLATFORM=osmesa`` must be set in the environment -*before* the first ``import OpenGL.GL`` anywhere in the process. -:func:`~whippersnappy.gl.utils.create_window_with_fallback` takes care of -this automatically. - -Typical usage (internal, called from ``create_window_with_fallback``):: - - import os - os.environ["PYOPENGL_PLATFORM"] = "osmesa" # must be first - from whippersnappy.gl.osmesa_context import OSMesaContext - - ctx = OSMesaContext(width, height) - ctx.make_current() - # ... OpenGL calls render into ctx's internal pixel buffer ... - img = ctx.read_pixels() - ctx.destroy() +This module is not intended to be used directly. It is instantiated by +:func:`~whippersnappy.gl.utils.create_window_with_fallback` when GLFW +cannot create a window (Linux headless). That function also sets +``PYOPENGL_PLATFORM=osmesa`` at import time so that PyOpenGL resolves +function pointers via ``OSMesaGetProcAddress``. Notes ----- @@ -36,7 +25,6 @@ import ctypes import ctypes.util import logging -import sys import OpenGL.GL as gl from PIL import Image @@ -53,20 +41,12 @@ def _load_libosmesa(): - """Try several candidate names and return the loaded ctypes CDLL. + """Try several candidate library names and return the loaded ctypes CDLL. - Candidate names are platform-specific but we always try all of them so - that e.g. a macOS user with a non-default Homebrew prefix still has a - chance of success. + Only called on Linux — OSMesa is not attempted on macOS or Windows + (GLFW handles those platforms). """ - if sys.platform == "win32": - candidates = ["osmesa", "osmesa.dll", "libOSMesa.dll"] - elif sys.platform == "darwin": - # Homebrew mesa does NOT build libOSMesa — a custom build with - # -Dosmesa=true is needed. We still try in case the user has one. - candidates = ["libOSMesa.dylib", "libOSMesa.8.dylib"] - else: # Linux / WSL2 - candidates = ["libOSMesa.so.8", "libOSMesa.so.6", "libOSMesa.so"] + candidates = ["libOSMesa.so.8", "libOSMesa.so.6", "libOSMesa.so"] # ctypes.util.find_library may resolve a shorter name to the real path found = ctypes.util.find_library("OSMesa") @@ -83,21 +63,11 @@ def _load_libosmesa(): except (OSError, AttributeError) as exc: last_err = exc - if sys.platform == "win32": - hint = "Install via MSYS2: pacman -S mingw-w64-x86_64-mesa" - elif sys.platform == "darwin": - hint = ( - "libOSMesa is not available via 'brew install mesa' (Homebrew does not " - "build OSMesa). A custom Mesa build with -Dosmesa=true is required, or " - "use a display/GLFW for rendering on macOS." - ) - else: - hint = ( - "Install with: sudo apt-get install libosmesa6 (Debian/Ubuntu) " - "or sudo dnf install mesa-libOSMesa (RHEL/Fedora)" - ) - - raise RuntimeError(f"Could not load libOSMesa ({last_err}). {hint}") + raise RuntimeError( + f"Could not load libOSMesa ({last_err}). " + "Install with: sudo apt-get install libosmesa6 (Debian/Ubuntu) " + "or: sudo dnf install mesa-libOSMesa (RHEL/Fedora)" + ) class OSMesaContext: @@ -107,10 +77,6 @@ class OSMesaContext: framebuffer. No explicit FBO is required — ``glReadPixels`` reads directly from the OSMesa buffer. - ``PYOPENGL_PLATFORM=osmesa`` **must** already be set in the environment - before this class is imported (handled by - :func:`~whippersnappy.gl.utils.create_window_with_fallback`). - Parameters ---------- width, height : int @@ -154,7 +120,11 @@ def _init_osmesa(self): lib.OSMesaDestroyContext.argtypes = [ctypes.c_void_p] ctx = lib.OSMesaCreateContextExt( - _OSMESA_RGBA, 24, 8, 0, None, + _OSMESA_RGBA, # pixel format + 24, # depth bits + 8, # stencil bits + 0, # accum bits + None, # no shared context ) if not ctx: raise RuntimeError( @@ -163,6 +133,7 @@ def _init_osmesa(self): ) self._ctx = ctx + # Allocate the pixel buffer that OSMesa renders into buf_size = self.width * self.height * 4 # RGBA bytes self._buf = (ctypes.c_ubyte * buf_size)() logger.info("OSMesa context created (%dx%d)", self.width, self.height) @@ -170,10 +141,11 @@ def _init_osmesa(self): def make_current(self): """Make this OSMesa context current. - OSMesa renders into its own internal pixel buffer which acts as the - default framebuffer (FBO 0). No FBO creation is needed. - ``PYOPENGL_PLATFORM=osmesa`` must already be set so that PyOpenGL - resolves function pointers via OSMesaGetProcAddress. + ``OSMesaMakeCurrent`` binds ``self._buf`` as the default framebuffer + (FBO 0) of this context. The buffer already has colour (RGBA8), + depth (24-bit) and stencil (8-bit) attached — requested via + ``OSMesaCreateContextExt`` — so no explicit FBO creation is needed. + All rendering goes into ``self._buf`` automatically. """ ok = self._libosmesa.OSMesaMakeCurrent( self._ctx, @@ -191,15 +163,14 @@ def make_current(self): def read_pixels(self) -> Image.Image: """Read the OSMesa framebuffer and return a PIL RGB Image. - Reads from the default framebuffer (FBO 0), which is OSMesa's own - pixel buffer. No FBO bind needed. + FBO 0 is always current (set by ``OSMesaMakeCurrent``), so + ``glReadPixels`` reads from ``self._buf`` directly. Returns ------- PIL.Image.Image Captured frame, vertically flipped to top-left origin convention. """ - gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0) gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1) buf = gl.glReadPixels( 0, 0, self.width, self.height, gl.GL_RGB, gl.GL_UNSIGNED_BYTE diff --git a/whippersnappy/gl/utils.py b/whippersnappy/gl/utils.py index 448f069..25951b9 100644 --- a/whippersnappy/gl/utils.py +++ b/whippersnappy/gl/utils.py @@ -6,9 +6,10 @@ when no display server or GPU is available. No EGL or GPU driver is required for headless operation. -On Linux without a display, ``PYOPENGL_PLATFORM=osmesa`` is set -automatically at import time (before ``OpenGL.GL`` is imported) so that -PyOpenGL resolves all function pointers via ``OSMesaGetProcAddress``. +On Linux with no ``DISPLAY`` or ``WAYLAND_DISPLAY`` set, +``PYOPENGL_PLATFORM=osmesa`` is set automatically at import time (before +``OpenGL.GL`` is imported) so that PyOpenGL resolves all function pointers +via ``OSMesaGetProcAddress`` rather than GLX. """ import logging @@ -17,11 +18,10 @@ import warnings from typing import Any -# On Linux, if no display is available, pre-set PYOPENGL_PLATFORM=osmesa so -# that PyOpenGL resolves function pointers via OSMesaGetProcAddress rather -# than GLX (which returns null pointers when there is no X11 display). -# This must happen *before* "import OpenGL.GL" below. -# On macOS and Windows we leave the default (CGL / WGL) so GLFW works. +# On Linux with no display, pre-set PYOPENGL_PLATFORM=osmesa before +# importing OpenGL.GL so PyOpenGL uses OSMesaGetProcAddress for function +# pointer resolution instead of GLX (which returns null pointers without +# an X11/Wayland display). Has no effect on macOS or Windows. if ( sys.platform == "linux" and "PYOPENGL_PLATFORM" not in os.environ @@ -196,12 +196,10 @@ def _try_glfw_window(width, height, title, visible, core_profile): Parameters ---------- core_profile : bool - If True request OpenGL 3.3 Core Profile (preferred everywhere). - ``OPENGL_FORWARD_COMPAT`` is only set on non-macOS platforms — - macOS CGL rejects the pixel format when it is set for invisible - windows on some runners (ARM, macOS 14+). + If True request OpenGL 3.3 Core Profile + ``FORWARD_COMPAT`` + (preferred on all platforms). If False request OpenGL 3.3 Compatibility Profile (fallback for - some Windows software renderers). + Windows software renderers that don't support Core Profile). Returns ------- @@ -216,11 +214,7 @@ def _try_glfw_window(width, height, title, visible, core_profile): glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, 3) glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, 3) if core_profile: - # FORWARD_COMPAT is needed on Linux/Windows to exclude deprecated - # features but macOS CGL rejects the pixel format when it is set - # together with an invisible window on ARM runners. - if sys.platform != "darwin": - glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, True) + glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, True) glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE) else: glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, False) @@ -238,13 +232,9 @@ def _try_glfw_window(width, height, title, visible, core_profile): def init_window(width, height, title="PyOpenGL", visible=True): """Create a GLFW window, make an OpenGL context current and return the window handle. - Tries OpenGL 3.3 Core Profile first. ``OPENGL_FORWARD_COMPAT`` is set - on Linux/Windows but **not** on macOS — Apple CGL rejects the pixel - format for invisible windows when it is set on ARM runners (macOS 14+). - - On **macOS** only Core Profile is attempted — NSGL has no Compatibility - Profile. On **Windows** and **Linux**, Compatibility Profile is retried - if Core Profile fails. + Tries OpenGL 3.3 Core Profile + ``FORWARD_COMPAT`` first, then falls + back to Compatibility Profile on non-macOS platforms. NSGL (macOS) does + not support Compatibility Profile, so only one attempt is made there. Each attempt calls ``glfw.init()`` / ``glfw.terminate()`` independently so that a failed attempt leaves no stale GLFW state for the next. @@ -295,16 +285,15 @@ def create_window_with_fallback(width, height, title="WhipperSnapPy", visible=Tr The function attempts context creation in this priority order: - 1. **GLFW visible window** — normal path on workstations. - 2. **GLFW invisible window** — when a display exists but no screen is - needed. On **macOS** only Core Profile is attempted (NSGL has no - Compatibility Profile). On **Windows** CI, Mesa ``opengl32.dll`` - (installed by the CI workflow) provides a software OpenGL 3.3 Core - implementation so the invisible-window path succeeds. + 1. **GLFW visible window** — normal path on workstations with a display. + 2. **GLFW invisible window** — when a display exists but no on-screen + window is needed (e.g. batch rendering). Core Profile + + ``FORWARD_COMPAT`` is tried first; Compatibility Profile is retried + on non-macOS platforms (NSGL does not support Compatibility Profile). 3. **OSMesa software rendering** — fully headless; no display server, - no GPU, and no ``/dev/dri/`` devices required. Used on **Linux CI** - (Docker, GitHub Actions) where no display is available. Requires - ``libosmesa6`` (Debian/Ubuntu) or ``mesa-libOSMesa`` (RHEL/Fedora). + no GPU, and no ``/dev/dri/`` devices required. Only attempted on + Linux. Requires ``libosmesa6`` (Debian/Ubuntu) or + ``mesa-libOSMesa`` (RHEL/Fedora). When OSMesa is used the module-level ``_offscreen_context`` is set and ``make_current()`` is called so that subsequent OpenGL calls work From 643231ac378e25bdac2d4bfd8f5cc869b8ad2ba4 Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Sat, 28 Feb 2026 00:45:29 +0100 Subject: [PATCH 09/11] update readme --- README.md | 63 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index ce8308f..dee21c6 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ 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 @@ -14,7 +15,7 @@ passed directly as a NumPy ``(vertices, faces)`` tuple. pip install whippersnappy ``` -For rotation video support (MP4/WebM): +For rotation video support (MP4/WebM — GIF works without this): ```bash pip install 'whippersnappy[video]' @@ -32,10 +33,11 @@ For interactive 3D in Jupyter notebooks: pip install 'whippersnappy[notebook]' ``` -Off-screen (headless) rendering is supported natively via OSMesa on Linux — no -`xvfb` or GPU required. On macOS and Windows a GLFW invisible window is used -instead (both platforms provide GPU drivers). See the -Docker guide 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 Docker guide for +headless Linux usage. ## Command-Line Usage @@ -66,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 ``` @@ -106,7 +114,8 @@ 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 +from whippersnappy import plot3d # requires whippersnappy[notebook] ``` | Function | Description | @@ -117,12 +126,16 @@ from whippersnappy import snap1, snap4, snap_rotate, plot3d | `plot3d` | Interactive 3D WebGL viewer for Jupyter notebooks | **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 @@ -135,25 +148,25 @@ img = snap1('lh.white', img.save('snap1.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. From 3111537633df955e744e1007e96578a9abce8879 Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Sat, 28 Feb 2026 00:55:28 +0100 Subject: [PATCH 10/11] update workflows titles and utils.py --- .github/workflows/build.yml | 2 +- .github/workflows/code-style.yml | 2 +- .github/workflows/publish.yml | 2 +- whippersnappy/gl/utils.py | 38 +++++++++++++++++++++----------- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ffe43bd..f416def 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,7 +28,7 @@ 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 libosmesa6 libgl1 - name: Install dependencies diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml index ecc3e00..69cb7af 100644 --- a/.github/workflows/code-style.yml +++ b/.github/workflows/code-style.yml @@ -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] diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ad73e82..cfe8e9d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: python-version: '3.13' - name: Install system dependencies run: sudo apt-get install -y --no-install-recommends libosmesa6 libgl1 - - 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 .[build] diff --git a/whippersnappy/gl/utils.py b/whippersnappy/gl/utils.py index 25951b9..55ffc1b 100644 --- a/whippersnappy/gl/utils.py +++ b/whippersnappy/gl/utils.py @@ -290,10 +290,11 @@ def create_window_with_fallback(width, height, title="WhipperSnapPy", visible=Tr window is needed (e.g. batch rendering). Core Profile + ``FORWARD_COMPAT`` is tried first; Compatibility Profile is retried on non-macOS platforms (NSGL does not support Compatibility Profile). - 3. **OSMesa software rendering** — fully headless; no display server, - no GPU, and no ``/dev/dri/`` devices required. Only attempted on - Linux. Requires ``libosmesa6`` (Debian/Ubuntu) or - ``mesa-libOSMesa`` (RHEL/Fedora). + 3. **OSMesa software rendering** — Linux only. Used when both GLFW + attempts fail (no display server). Requires ``libosmesa6`` + (Debian/Ubuntu) or ``mesa-libOSMesa`` (RHEL/Fedora). On macOS and + Windows a platform-specific ``RuntimeError`` is raised instead, + because neither platform supports OSMesa in standard distributions. When OSMesa is used the module-level ``_offscreen_context`` is set and ``make_current()`` is called so that subsequent OpenGL calls work @@ -319,7 +320,9 @@ def create_window_with_fallback(width, height, title="WhipperSnapPy", visible=Tr Raises ------ RuntimeError - If all three methods fail to produce a usable OpenGL context. + If no usable OpenGL context can be created. On Linux this means + both GLFW and OSMesa failed. On macOS/Windows it means GLFW failed + (those platforms have no OSMesa fallback). """ global _offscreen_context @@ -337,11 +340,19 @@ def create_window_with_fallback(width, height, title="WhipperSnapPy", visible=Tr if window: return window - # --- Step 3: OSMesa software rendering (Linux headless, no display needed) --- - # On macOS and Windows GLFW should have succeeded above via the GPU driver. - # OSMesa is the fallback for Linux environments without a display server. + # --- Step 3: OSMesa software rendering (Linux headless only) --- + # Only reached on Linux when both GLFW attempts failed (no display server). + # On macOS and Windows GLFW is the only supported headless path; if it + # failed here it means the system has no usable OpenGL driver at all. + if sys.platform != "linux": + raise RuntimeError( + "Could not create a GLFW OpenGL context. " + "On macOS a display connection is required (NSGL does not support " + "headless rendering). " + "On Windows ensure a GPU driver or Mesa opengl32.dll is available." + ) # PYOPENGL_PLATFORM=osmesa was already set at module import time (top of - # this file) when no display was detected, so PyOpenGL already uses + # this file) when no display was detected, so PyOpenGL uses # OSMesaGetProcAddress for function pointer resolution. logger.info("No display detected — trying OSMesa software rendering (CPU).") try: @@ -425,9 +436,10 @@ def capture_window(window): """Read the current GL framebuffer and return it as a PIL Image (RGB). Works for both GLFW windows and OSMesa headless contexts. When OSMesa is - active (``window`` is ``None``) the pixels are read from the FBO that - was set up by :class:`~whippersnappy.gl.osmesa_context.OSMesaContext`; in - that case there is no HiDPI scaling to account for. + active (``window`` is ``None``) the pixels are read directly from the + OSMesa pixel buffer, which acts as the default framebuffer (FBO 0) — no + explicit FBO is created by :class:`~whippersnappy.gl.osmesa_context.OSMesaContext`; + in that case there is no HiDPI scaling to account for. Parameters ---------- @@ -442,7 +454,7 @@ def capture_window(window): """ global _offscreen_context - # --- OSMesa path: read directly from the FBO --- + # --- OSMesa path: read from the OSMesa pixel buffer (default framebuffer) --- if _offscreen_context is not None: return _offscreen_context.read_pixels() # type: ignore[union-attr] From 143f44d0f3fc21644273df02163cd5673d3d53cd Mon Sep 17 00:00:00 2001 From: Martin Reuter Date: Sat, 28 Feb 2026 11:24:34 +0100 Subject: [PATCH 11/11] add better ViewType doc in readme --- README.md | 25 +++++++++++++++++++++---- whippersnappy/__init__.py | 3 +-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index dee21c6..1b56854 100644 --- a/README.md +++ b/README.md @@ -114,16 +114,29 @@ For all options run `whippersnap4 --help`, `whippersnap1 --help`, or `whippersna ## Python API ```python -from whippersnappy import snap1, snap4, snap_rotate +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 @@ -138,15 +151,19 @@ GIfTI functional/label (`.func.gii`, `.label.gii`). ### 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(lh_overlay='/path/to/lh.thickness', rh_overlay='/path/to/rh.thickness', diff --git a/whippersnappy/__init__.py b/whippersnappy/__init__.py index 5309c76..95df89f 100644 --- a/whippersnappy/__init__.py +++ b/whippersnappy/__init__.py @@ -12,8 +12,7 @@ For static image generation:: - from whippersnappy import snap1, snap4 - from whippersnappy.utils.types import ViewType + from whippersnappy import snap1, snap4, ViewType from IPython.display import display img = snap1('path/to/lh.white', view=ViewType.LEFT)