Skip to content

fix(render): configure the headless GL platform on the colour path#71

Merged
bamdadd merged 1 commit into
bamdadd:mainfrom
Nitjsefnie-OSC:fix/36-headless-colour-path
Jul 27, 2026
Merged

fix(render): configure the headless GL platform on the colour path#71
bamdadd merged 1 commit into
bamdadd:mainfrom
Nitjsefnie-OSC:fix/36-headless-colour-path

Conversation

@Nitjsefnie

Copy link
Copy Markdown
Contributor

Summary

PyrenderBackend.render now calls configure_headless() itself, so the colour path works on a display-less box instead of raising NoSuchDisplayException.

Related Issues and Pull Requests

Fixes #36

Changes

  • src/multicam_sim/dsl/render.pyrender() calls configure_headless() before reaching _import_pyrender. The import of it is function-local because depth.py imports _build_pyrender_scene/_import_pyrender from this module, so a module-level import is a hard circular one; function-local imports are already this file's idiom (_occluder_mesh).
  • src/multicam_sim/dsl/depth.py — updated the docstring that said the colour path "deliberately does not make" the platform decision, since it now does.
  • docs/renderer-eval.md — same correction; it stated the colour path cannot render headless, which this change overturns.
  • tests/test_render.py — the regression guard you asked for, in two layers.

On placement: _build_pyrender_scene has exactly two callers, render() and render_depth(), and the latter has configured the platform since it was written. Configuring inside _build_pyrender_scene would also be too late for the colour path — render() imports pyrender before calling it, so the call would silently no-op, which is the failure configure_headless exists to prevent.

Testing

uv run ruff check ., uv run ruff format --check . (67 files), uv run mypy src (28 files) all clean. uv run pytest went 246 passed / 3 skipped → 247 passed / 4 skipped, with ../multicam-occlusion pinned to the 59f4906 your CI checks out.

The test is in two layers because a real headless render needs system OSMesa:

  1. Always runs — asserts the ordering: configure_headless fires before pyrender is imported, and PYOPENGL_PLATFORM is already in force at the import. It spies both ends and asserts the sequence, so it needs no optional dependency. Reverting the two added lines fails it with assert ['import_pyrender'] == ['configure_headless', 'import_pyrender'].
  2. Gated real render — skips without the render extra, matching how tests/test_depth.py gates its own smoke.

I used that file's find_spec + subprocess idiom rather than pytest.importorskip, because importorskip imports pyrender in-process and after that configure_headless correctly raises RuntimeError: PyOpenGL is already imported.

This box has no libosmesa6, so the gated render can't complete here — and your existing test_render_depth_headless_smoke fails identically for the same reason, so I didn't invent a softer skip. I did verify the path end to end twice with EGL substituted for OSMesa, from a process with PYOPENGL_PLATFORM unset: render()configure_headless() → env set → pyrender imported → a real (480, 640, 3) uint8 image with 11013 non-zero pixels, no display.

Follow-ups / Known Limitations

test_render_produces_an_image_when_pyrender_present still can't pass on a bare headless box, but its error changes: previously NoSuchDisplayException, now RuntimeError: PyOpenGL is already imported, so PYOPENGL_PLATFORM='osmesa' would be ignored…. That's a pre-existing limitation rather than a new failure, and arguably a better message since it names the remedy — but converting it to a subprocess would duplicate the new smoke, so I left it. Happy to convert it if you'd prefer.

Footer

Generated by Claude Opus 5 (1M context) (brief, implementation, review)

…nder

PyrenderBackend.render took pyrender's default GL platform, so it died with
NoSuchDisplayException on any box without an X display. The colour path now
calls configure_headless() itself, immediately before it imports pyrender --
the same ordering the depth path has used since it was written, because
PyOpenGL binds its platform at import time and a later call is a silent no-op.

bamdadd asked for this in bamdadd#36: leaving the platform decision to the caller only
meant everyone rediscovered the same exception the hard way. configure_headless()
no-ops when DISPLAY exists or PYOPENGL_PLATFORM is already exported, so desktops
are unchanged and only the CI/server case is fixed.

Two tests cover it. test_render_configures_headless_before_importing_pyrender
spies both configure_headless and the pyrender import and asserts the order, so
it guards the regression without needing the render extra;
test_render_headless_smoke_with_no_display renders a real frame with DISPLAY
unset in a subprocess, gated on find_spec like the depth smoke.

The depth module's docstring note that the colour path deliberately abstains,
and the "known issue" section of docs/renderer-eval.md, are updated to match.

Fixes bamdadd#36

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bamdadd
bamdadd merged commit 47ffa0c into bamdadd:main Jul 27, 2026
1 check passed
@bamdadd

bamdadd commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Merged, thanks. The subprocess smoke plus the ordering guard is exactly the right way to test this, and I appreciate you writing down why the in-process case still needs a display rather than papering over it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

render.py cannot run headless: NoSuchDisplayException without an X display

2 participants