Capture screenshots¶
Every screenshot cellier takes is rendered on a dedicated offscreen canvas, never read back from the canvas on screen. That one decision is what makes the result reproducible: two captures of the same viewer state produce byte-identical arrays, at exactly the size you asked for, with or without a window open, whichever GUI toolkit the viewer targets.
The short version¶
frame = viewer.screenshot(size=(1600, 1200)) # RGBA uint8, (1200, 1600, 4)
viewer.screenshot(size=(1600, 1200), save="figure.png")
frame is an (height, width, 4) uint8 array. save= writes a PNG using
only the standard library, so it never fails for want of an image package.
Not using the convenience API? Everything here works on
CellierController directly -- see Without the convenience
API.
Capture what is loaded, not what is loading¶
A capture shows the data currently resident on the GPU. It does not reslice, so capture after the load has finished:
Two consequences worth knowing up front:
- Slice requests are planned per canvas. A viewer that has never called
add_canvashas never requested any data, so capturing it returns a correctly-rendered picture of an empty scene. Add a canvas first. - A larger
scaleenlarges the level of detail already loaded; it does not fetch a finer one. A 4x capture of a multiscale volume shows the on-screen level, bigger.
Sharp images: the frames argument¶
frames= |
What you get |
|---|---|
1 (default) |
temporal accumulation off, one draw. Crisp and instant. |
"converged" |
accumulate for as long as the accumulator needs to settle. |
N |
accumulate exactly N frames. |
Use frames="converged" whenever ambient occlusion is on -- a single-sample AO
frame is visibly noisy, and the accumulation pass is what averages the
per-frame kernel rotation away:
viewer.ambient_occlusion_enabled = True
viewer.screenshot(size=(1600, 1200), frames="converged", save="ao.png")
"converged" does not watch the pixels and wait for them to stop moving;
it computes how many frames the accumulator needs. Each frame leaves
1 - temporal_blend_weight of the error, so reaching a 1% residual takes
log(0.01) / log(1 - blend_weight) frames -- 44 at the default weight of 0.1,
7 at 0.5, 228 at 0.02. (Watching the pixels does not work: with ambient
occlusion on, a few hundred pixels dither forever and the image never becomes
literally still. cellier.render._capture.frames_to_settle records the
measurements.)
Two knobs follow from that:
residual=-- how close to settled is close enough. Default0.01.max_frames=-- the ceiling, 64 by default. A blend weight below ~0.07 needs more, and asking for"converged"there raises with the exact number rather than returning an unsettled image.
viewer.temporal_blend_weight = 0.02
viewer.screenshot(frames="converged", max_frames=300) # 228 frames
Several canvases, and orthoviewers¶
Viewer and OrthoViewer address canvases differently, because they select
different things. On a Viewer the argument picks a viewpoint onto one
scene; on an OrthoViewer it picks which of four scenes to render.
viewer.canvases # tuple[UUID, ...]
viewer.screenshot() # one canvas: uses it
# several: raises, naming them
viewer.screenshot(canvas=viewer.canvases[1]) # pick a viewpoint
ortho.screenshot(panel="xy", size=(800, 800)) # one panel -> (800, 800, 4)
ortho.screenshot(size=(800, 800)) # 2x2 grid -> (1600, 1600, 4)
The orthoviewer grid is laid out the way the on-screen grid is -- XY and XZ on
the top row, YZ and the 3D volume below -- and size is per panel. It is
canvases only: the XY / XZ / YZ / 3D titles are chrome and do not
appear. frames="converged" matters more here than elsewhere, because the
vol panel accumulates while the three slice panels do not.
Without the convenience API¶
CellierController.screenshot is the primary entry point, not a fallback:
Viewer.screenshot, OrthoViewer.screenshot and screenshot_window are all
thin wrappers over it. Everything above is available at the controller level;
what the convenience layer adds is addressing and a save= argument, not
capability.
The body below is a coroutine -- on_scene_ready is a callback, and awaiting
it needs an event loop:
import asyncio
from cellier.controller import CellierController
from cellier.render._capture import write_png
controller = CellierController(gui="offscreen")
scene = controller.add_scene(dim="3d", name="scene")
controller.add_image(data=store, scene_id=scene.id, appearance=appearance)
# Required: slice requests are planned per canvas, so a scene without one
# never loads anything and every capture of it is a picture of emptiness.
controller.add_canvas(scene_id=scene.id, canvas_size=(400, 300))
# on_scene_ready is the supported wait; the convenience on_ready wraps it.
ready = asyncio.Event()
controller.on_scene_ready(scene.id, ready.set)
await ready.wait()
controller.fit_camera(scene.id)
canvas_id = controller.get_canvas_ids(scene.id)[0]
frame = controller.screenshot(canvas_id, size=(400, 300)) # (300, 400, 4) uint8
write_png("shot.png", frame)
The two methods¶
controller.screenshot(canvas_id, size=None, scale=1.0, *, frames=1, **kwargs)
controller.screenshot_scene(scene_id, size=None, scale=1.0, *, frames=1, dim=None, **kwargs)
screenshot copies canvas_id's camera, dimensionality and depth range, then
renders its own frame -- the argument names a viewpoint, not a surface.
size defaults to that canvas's physical size, so an unqualified call
reproduces the on-screen framing.
screenshot_scene fits the camera to the scene instead of copying one, for
when a scene has no canvas or when no existing canvas has the viewpoint you
want. dim ("2d" / "3d") is inferred from the scene's displayed axes.
Both take the same frames, max_frames and residual arguments described
above, and both return an (height, width, 4) uint8 array.
Finding canvases¶
controller.get_canvas_ids(scene_id) # canvases on one scene, creation order
controller.canvas_ids # every canvas, across all scenes
controller.get_canvas_view(canvas_id) # the render-layer CanvasView
get_canvas_ids is the one to reach for. canvas_ids exists for code that
must find canvases without knowing their scene -- it is what
screenshot_window walks to discover which canvases live inside a window.
There is no raise-on-ambiguity here: a controller-level call names its canvas
explicitly, so there is nothing to disambiguate. That guard belongs to
Viewer.screenshot, which is allowed to omit the argument.
Writing the PNG¶
save= is a convenience-layer argument. At the controller level, call the
writer directly:
from cellier.render._capture import write_png
write_png("shot.png", controller.screenshot(canvas_id))
It takes an (h, w, 4) uint8 array, refuses anything else, and is built on
zlib alone -- so it cannot fail for want of Pillow or imageio, neither of
which cellier depends on. It currently lives in a private module; treat the
import as provisional.
A whole window, chrome included¶
Qt's QWidget.grab() captures docks, labels and sliders faithfully but cannot
see a render canvas -- a wgpu surface is not part of Qt's paint pipeline, so the
canvas rectangles come back as flat fill. screenshot_window assembles the
picture instead: Qt supplies the chrome, and each canvas rectangle is filled in
by an offscreen capture.
from cellier.convenience import screenshot_window
window = launch(viewer, layout)
screenshot_window(window, viewer.controller, save="window.png")
Only the canvas regions are reproducible; chrome depends on the platform's
widget style and font rendering. On a real display the OS screenshot tool does
this job too -- screenshot_window exists for the headless case
(QT_QPA_PLATFORM=offscreen), where there is no screen to shoot.
There is no window capture for notebook front ends: nothing on the Python side can reach the browser's compositor. Canvas capture works there exactly as it does under Qt.
Headless, from the command line¶
cellier.convenience.capture turns a viewer into a PNG with no display attached --
useful in CI, and the fastest way for an agent to actually look at a render
change:
.venv/bin/python -m cellier.convenience.capture my_demo.py --size 900x700 --frames converged --out /tmp/shot.png
The target is either a Python file exposing build() that returns a populated
Viewer or OrthoViewer (it must not call launch/show), or a file written
by Viewer.to_file. The script gives every scene a canvas, drives the slicer to
quiescence, fits the cameras, and captures -- so the picture shows loaded data
rather than an empty scene.
# my_demo.py
def build():
viewer = Viewer(spatial_axes("z", "y", "x"), gui="offscreen")
viewer.add_image(data=store, appearance=InMemoryImageAppearance())
viewer.set_displayed_dimensions(("z", "y", "x"))
return viewer
The offscreen gui¶
gui="offscreen" builds a viewer with no window at all -- the right choice for
scripts and tests that only ever capture:
It has no embeddable widget, so build_canvas_widget, launch, show and
display reject it and say so. Capture does not require it: screenshot()
renders offscreen whatever gui the viewer targets, so a Qt or notebook viewer
captures identically.
What the contract does not promise¶
Identical bytes across different GPUs or driver versions. Assertions in tests should stay structural -- an alpha mask, a hue, a mean -- as the render suite's own tests do.