Headless plots#

A K3D plot is a browser widget, so anything that draws one needs a browser. k3d.headless gives you one without a notebook: it starts a small HTTP server, points a Selenium-driven browser at it, and hands back an object that pushes the plot’s state over and asks for pictures.

import k3d
from k3d.headless import k3d_remote, get_headless_driver

plot = k3d.plot(screenshot_scale=1)
plot += k3d.points([[0, 0, 0], [1, 1, 1]], point_size=0.2)

headless = k3d_remote(plot, get_headless_driver(), width=1280, height=720)
headless.sync()

with open('plot.png', 'wb') as f:
    f.write(headless.get_screenshot())

headless.close()

This is what the library’s own reference images and this documentation’s plots are made with, and it is the supported way to render a sequence of frames from a script.

Warning

Neither of the two packages this needs is a dependency of k3d itself: pip install selenium flask, or pip install k3d[dev], which brings them along with the test suite’s own requirements. The Chrome driver additionally needs Chrome or Chromium on the machine; Selenium 4.6 and later fetches the matching chromedriver by itself.

The session#

k3d_remote(plot, driver, width=1280, height=720, port=8080) owns three things - the server, the browser and the plot - and close() releases all of them. It quits the driver rather than closing its window, so nothing is left behind; a script that forgets it leaves a browser and a chromedriver running.

width and height are the browser window, and the plot fills it. They are what sets the output resolution, together with screenshot_scale:

plot = k3d.plot(screenshot_scale=2)
headless = k3d_remote(plot, get_headless_driver(), width=1920, height=1080)
# every get_screenshot() is now 3840x2160

Two drivers ship with the module. get_headless_driver() is Chrome, and by default it runs with --enable-unsafe-swiftshader, so it renders in software and works on a machine with no usable GPU at all - a CI runner, a container without passthrough. Pass gpu=True for the real card, no_headless=True to watch the window, and extra_args=[...] for switches the library should not choose for everyone. get_headless_firefox_driver() is the Firefox equivalent.

Note

Software rendering is correct and slow. Every image comes out right, so the only thing that tells you which one you got is the clock - see get_gl_info() below, and Renderers for why the texture limits are not the tell.

Pushing the state#

Nothing reaches the browser until you say so. sync() sends whatever changed since the last call - a diff, so pushing a moved camera does not re-upload the volume - and returns as soon as the page has been asked to apply it.

For a screenshot that is not enough:

for i in range(frames):
    plot.camera = camera_for(i)
    headless.sync(hold_until_refreshed=True)

    with open('frame_%06d.png' % i, 'wb') as f:
        f.write(headless.get_screenshot(True))

The state travels over an asynchronous request, so a screenshot taken straight after a bare sync() renders whichever scene the page happens to be holding - sometimes the one you just set, sometimes the previous one. The symptom is two byte-identical files in the middle of a sequence. hold_until_refreshed=True waits for the page to confirm it has the new state.

camera_reset(factor=1.5) frames the whole scene, the same as the panel’s button, for when you are not driving the camera yourself.

Outputs#

get_screenshot(only_canvas=False) returns the PNG as bytes. It always renders the full sample budget, so a cinematic frame is as clean as cinematic_samples allows however long that takes. only_canvas=True gives you the 3D canvas alone, without the HTML overlay - labels, the colour-map legend - rasterised on top of it, which is both faster and what you want when the overlay would sit in the middle of a video frame.

The image is posted back over the session’s own HTTP server rather than returned from the browser, because every value returned from a browser script stays in the page’s heap for the life of that browser and nothing releases it. At 4K that was about 10 MB a frame, so a few hundred frames used to end an animation with a heap exhaustion.

get_browser_screenshot() is the driver’s own capture of the whole window - the plot as the browser composited it, at window resolution, with the panel in the corner. It is a debugging view, not an output format.

get_gltf() returns the scene geometry as a binary .glb; see glTF export for what a glTF can and cannot carry from a K3D scene.

Diagnostics#

headless.get_gl_info()
# {'vendor': 'Google Inc. (NVIDIA)', 'renderer': 'ANGLE (NVIDIA, ...)', ...}

What the browser actually got. A container that loses its GPU passthrough does not fail, it falls back to software rendering, and the renderer string is where that shows.

driver = get_headless_driver(extra_args=['--enable-precise-memory-info',
                                         '--js-flags=--expose-gc'])
headless = k3d_remote(plot, driver)

headless.get_memory()
# {'used_mb': 878.1, 'total_mb': 906.5, 'limit_mb': 4192.0,
#  'collected': True, 'precise': True}

The page’s JS heap, for watching a long run. Both switches are needed and neither is assumed: without --enable-precise-memory-info Chrome answers with a frozen constant whatever the page allocates, and without --js-flags=--expose-gc a reading carries the last frame’s garbage. precise and collected report which of them you have, so a flat line means a flat heap rather than a frozen counter. used_mb against limit_mb is the headroom; used_mb against total_mb is the fragmentation.

Volume data shows up in used_mb but cannot exhaust limit_mb: a typed array’s backing store is counted in the reading and allocated outside the heap the limit applies to. Only strings and objects - the PNGs a frame loop makes, for instance - are heap resident.

Logging#

A session says nothing about its own progress. It answers a /ping every few seconds and sync() runs once per frame of an animation, so a line for each would bury whatever the cell was asked to show. Warnings and errors are never suppressed. To get the rest back:

import logging

logging.getLogger('k3d.headless').setLevel(logging.DEBUG)

A startup or a refresh that never completes raises with the reason and whatever the browser console said, rather than hanging.