|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +# ruff: noqa: E402 |
3 | 4 | # MUST set environment variables BEFORE any Qt/napari/vispy imports |
4 | 5 | # to enable headless mode in CI environments (Ubuntu/Linux without display) |
5 | 6 | import os |
|
10 | 11 | os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") |
11 | 12 |
|
12 | 13 | os.environ.setdefault("NAPARI_HEADLESS", "1") |
| 14 | + |
| 15 | + |
| 16 | +def _patch_napari_gl_for_headless() -> None: |
| 17 | + """Patch napari's OpenGL utility functions to work without a real display. |
| 18 | +
|
| 19 | + The patch implements two workaround that are no-ops in environments that |
| 20 | + have a real display (CI with Xvfb, macOS, local dev). Once the upstreams |
| 21 | + bugs are addressed this patch should be removed. |
| 22 | +
|
| 23 | + In the Qt offscreen platform ``glGetString(GL_EXTENSIONS)`` returns ``None`` |
| 24 | + (raising AttributeError on ``.decode()``) and ``glGetIntegerv`` returns an |
| 25 | + empty tuple instead of an integer. napari then stores ``None`` as the max |
| 26 | + texture size, which later crashes ``TiledImageNode``. |
| 27 | +
|
| 28 | + Upstream bugs: |
| 29 | + * **vispy** – ``vispy/gloo/gl/_pyopengl2.py`` does not guard against |
| 30 | + ``GL.glGetString()`` returning ``None`` (no valid OpenGL context). |
| 31 | +
|
| 32 | + * **napari** – ``get_gl_extensions()`` and ``get_max_texture_sizes()`` in |
| 33 | + ``napari/_vispy/utils/gl.py`` do not handle the failure/empty-result |
| 34 | + case from the underlying GL calls, crashing when run in offscreen mode. |
| 35 | +
|
| 36 | + Fixes applied here: |
| 37 | + * ``vispy.gloo.gl._pyopengl2.glGetParameter`` – return ``""`` for string |
| 38 | + queries when the result is ``None``, and ``0`` for empty-tuple results. |
| 39 | + * ``napari._vispy.utils.gl.get_max_texture_sizes`` (and every module that |
| 40 | + imported it) – fall back to ``(2048, 2048)`` when the GL query returns 0. |
| 41 | + """ |
| 42 | + try: |
| 43 | + import vispy.gloo.gl as _vgl |
| 44 | + import vispy.gloo.gl._pyopengl2 as _pyopengl2_mod |
| 45 | + |
| 46 | + _orig_get_param = _pyopengl2_mod.glGetParameter |
| 47 | + |
| 48 | + def _safe_get_param(pname): # type: ignore[no-untyped-def] |
| 49 | + try: |
| 50 | + result = _orig_get_param(pname) |
| 51 | + except AttributeError: |
| 52 | + # glGetString returned None – no valid OpenGL context yet |
| 53 | + return "" |
| 54 | + if result is None: |
| 55 | + return "" |
| 56 | + if isinstance(result, tuple) and len(result) == 0: |
| 57 | + return 0 |
| 58 | + return result |
| 59 | + |
| 60 | + _pyopengl2_mod.glGetParameter = _safe_get_param |
| 61 | + _vgl.glGetParameter = _safe_get_param |
| 62 | + |
| 63 | + # get_max_texture_sizes caches (None, None) when GL returns 0/empty; |
| 64 | + # replace it everywhere it was imported so image layers get valid sizes. |
| 65 | + from functools import lru_cache |
| 66 | + |
| 67 | + @lru_cache(maxsize=1) |
| 68 | + def _safe_get_max_texture_sizes(): # type: ignore[no-untyped-def] |
| 69 | + try: |
| 70 | + from napari._vispy.utils.gl import _opengl_context |
| 71 | + |
| 72 | + with _opengl_context(): |
| 73 | + max_2d = _vgl.glGetParameter(_vgl.GL_MAX_TEXTURE_SIZE) |
| 74 | + max_3d = _vgl.glGetParameter(32883) # GL_MAX_3D_TEXTURE_SIZE |
| 75 | + return (int(max_2d) if max_2d else 2048, int(max_3d) if max_3d else 2048) |
| 76 | + except Exception: # noqa: BLE001 |
| 77 | + return 2048, 2048 |
| 78 | + |
| 79 | + import napari._vispy.canvas as _canvas_mod |
| 80 | + import napari._vispy.layers.base as _base_mod |
| 81 | + import napari._vispy.layers.image as _img_mod |
| 82 | + import napari._vispy.layers.labels as _lbl_mod |
| 83 | + import napari._vispy.utils.gl as _gl_mod |
| 84 | + |
| 85 | + for _mod in (_gl_mod, _img_mod, _lbl_mod, _base_mod, _canvas_mod): |
| 86 | + if hasattr(_mod, "get_max_texture_sizes"): |
| 87 | + _mod.get_max_texture_sizes = _safe_get_max_texture_sizes |
| 88 | + |
| 89 | + except Exception as exc: # noqa: BLE001 # pragma: no cover |
| 90 | + import warnings |
| 91 | + |
| 92 | + warnings.warn(f"Could not patch napari GL functions for headless mode: {exc}", stacklevel=2) |
| 93 | + |
| 94 | + |
| 95 | +if os.environ.get("QT_QPA_PLATFORM") == "offscreen": |
| 96 | + _patch_napari_gl_for_headless() |
| 97 | + |
13 | 98 | import random |
14 | 99 | import string |
15 | 100 | from abc import ABC, ABCMeta |
|
0 commit comments