neu-draw¶
Interactive 3D rendering of segment morphology in Jupyter — body meshes and skeletons, synapse points, and ROI meshes — drawn locally with pygfx.
Its sibling neu-glance builds neuroglancer states for a remote viewer. The axis that separates the two is where the rendering happens, not what is rendered.
import neu_draw
from neu_draw import build_scene, sources
from neu_draw.cache import MemoryCache
volume = "s3://my-bucket/my-dataset/segmentation" # or a local precomputed path
cache = MemoryCache()
skels = sources.body_skeletons(volume, [10014014, 10017394], cache=cache)
meshes = sources.body_meshes(volume, [10014014], cache=cache)
scene = build_scene(meshes=list(meshes.values()), skeletons=list(skels.values()))
view = neu_draw.show(scene) # displays itself in a notebook
view.save("bodies.png") # or renders offscreen anywhere
Cropping a skeleton to a region, and drawing it with its real calibre:
skel = skels[10014014].crop(neu_draw.box_predicate(some_box)) # ends at the surface
tube = neu_draw.skeleton_tube(skel) # radii as a solid tube
A skeleton is vertices plus an edge list, and renders as one object per body whatever its topology — cycles, self-loops and thousands of disconnected components included.
Coordinates¶
Two conventions, and getting either wrong puts your data somewhere plausible rather than raising:
Physical nanometres. Geometry carries a
Frameof real per-axis voxel sizes, never an integer scale and never2 ** scale— real pyramids are anisotropic. Resolving a scale index to aFrameis thesourceslayer’s job.zyx in memory, xyz at the boundary. Every array here is zyx.
to_xyzis the single conversion, called once per drawable in the backend.
Vec3 is the coordinate type those conventions apply to — three floats in zyx that do
arithmetic (b.bbox.center - a.bbox.center) and are accepted anywhere a sequence is, so
nothing that already took a tuple had to change.
Placing and arranging¶
Cells from different datasets have real, unrelated coordinates. Lining them up is a property of the drawable, not of the geometry:
scene.get("cellB").aligned_to(scene.get("cellA")) # centres coincide
scene.get("cellB").offset_by((0, 0, 5_000)) # nudge, in nm
The offset is a display transform and the vertices are never touched. Physical
nanometres are the one model space, so moving the data would make mesh.bbox report where
a thing is drawn rather than where the tissue is — and two datasets in one scene is
exactly when the real coordinates still need to be real. It is also free: re-placing a
million-vertex mesh copies nothing, because the backend sets the object’s transform.
A whole set is laid out with two methods, which are the same operation — put each object’s anchor on a target point — differing only in how the targets are generated:
scene.superimpose(anchor="min") # every object's corner on one point
scene.arrange(along="x") # a row, packed by each object's own extent
scene.arrange(along="x", spacing=20_000) # a row on a fixed 20 µm pitch
scene.arrange(along="x", wrap=5, down="z") # a 5-wide grid
They compose, and that is the useful part:
scene.superimpose(axes="z").arrange(along="x", align_cross=False)
Align on depth, spread horizontally, leave y at its true coordinates. A layout that regularises all three axes throws away whatever the axes meant — if soma depth or layer position carries information, tiling in every direction destroys it.
anchor is center, min or max throughout, so arrange(anchor="min") bottom-aligns a
row. key= sorts the layout without reordering the scene, so a legend built from
scene.names still matches. Hidden drawables reserve no slot. Scene.bake() folds the
offsets into the geometry, for when the arrangement is the output rather than the view.
The legend¶
Each row is a glyph saying what kind of thing it holds, and a label.
Left-click a row to hide it, click again to bring it back. Hidden rows are dimmed rather than removed, since a hidden drawable with no row is one nobody can turn back on.
Right-click a row to highlight it — those bodies turn white in the scene and the row lights up, until you right-click again.
Those are the two questions a crowded scene raises: what does it look like without this, and which one of these is this.
It is on by default and docked to the right. From a notebook:
view.legend.relabel("1401 mesh", "MeCN-01 (L)") # change what a row says
view.legend.relabel({"a": "Tm2", "b": "Tm2"}) # a mapping — and this MERGES the two rows
view.legend.recolor("Tm2", "tab:pink") # every body on the row, and its swatch
view.legend.toggle("presyn") # what left-clicking the row does
view.legend.set_visible("presyn", False)
view.legend.highlight("Tm2") # what right-clicking does
view.legend.highlight("LC6", exclusive=True) # …and drop the others
view.legend.highlighted # ['LC6']
view.legend.clear_highlights()
view.center() # re-fit to what is left showing
A highlight is a display override and never touches the drawable’s colour — the same
rule the placement offsets follow. scene.get(name).color still says what colour the body
is, so recolouring a highlighted entry is meaningful and shows up when the highlight comes
off, and a saved scene never has a temporary highlight baked into it. (The fastplotlib
predecessor swapped the real colour and stashed the original, which holds right up until
something else reads it.) The drawable’s alpha is preserved too: highlighting a translucent
surface must not turn it opaque, since being translucent is often why you could not find it.
Legend(highlight_color=...) is the knob for a figure whose own palette collides with
white — a body that is already white lights up only its row.
Names and labels¶
A row is a group, and several drawables can share one. That needs two ideas, because one cannot do both jobs:
what it is |
unique? |
addressed by |
|
|---|---|---|---|
|
a drawable’s identity |
yes — a duplicate raises |
|
|
its legend row, defaulting to the name |
no |
|
So forty bodies of a cell type are one row, one colour and one click, and are still forty individually addressable drawables underneath. Grouping could not be done by relaxing the name rule: a many-to-one relation cannot be an identity.
scene = build_scene(meshes=meshes, skeletons=skels,
labels={10014014: "Tm2", 10017394: "Tm2", 10022881: "LC6"})
# → two rows reading "Tm2 (4)" and "LC6 (2)", not six
labels is keyed on each item’s own name — the body id, before build_scene’s
mesh/skeleton suffix — so one entry covers every representation of that body, which is
what “label this body’s geometry as Tm2” has to mean. Keys match by value or by str(),
since a body id is an int as often as a string. Unlisted items keep a row of their own.
Passing labels also switches colouring to one colour per label, because forty palette
colours behind a swatch that can show one is most of the value of grouping thrown away.
build_scene(..., color_by="name") opts out; Scene.recolor(by="label") does it after the
fact.
Afterwards, from either side:
scene.relabel({"1401 mesh": "Tm2"}) # keyed on drawable NAMES, for bulk assignment
view.legend.relabel({"Tm2": "Tm2 (L)"}) # keyed on the row text you can see
scene.rename({"a": "b", "b": "a"}) # identity, still unique; a mapping applies as a batch
relabel sets the label and leaves names alone, so scene.get("1401 mesh") keeps working.
It replaced a rename method on the legend: once a row can hold several drawables its text
is a label and not a name, and renaming one member would not change the row at all.
Group rows: one click for many drawables, on top of their own rows¶
A label puts a drawable on exactly one row. When you want both — every cell’s Mi1 synapses on a row of their own and one row that hides all of them — declare a group:
scene.group("all Mi1", lambda d: d.name.endswith(": Mi1")) # a predicate, or…
scene.group("cell A", ["A mesh", "A skeleton", "A : Mi1"]) # …a list of names
# or up front: Scene(legend=Legend(groups={"all Mi1": [...]}))
Group rows come first, on a lighter plate, and toggle and highlight like any other row;
every member keeps its own row, and a group whose members are partly hidden shows the
partial state. Members are names — resolved once, when the group is declared — so a
relabel leaves a group intact and scene.rename carries it along. A group naming a
drawable that is not there, or whose text is also a label row’s, raises.
Edits take effect where you make them¶
Nothing needs re-running show(), and in the usual cases nothing needs a refresh either.
Three routes, in the order you are likely to meet them:
what you did |
when it shows |
|---|---|
|
immediately |
|
immediately — the scene asks for a repaint |
|
on the next frame drawn, or now with |
The last row is the one that cannot be automatic: a Scene is a plain mutable dataclass, so
assigning a field cannot notify anybody, and making every field observable would mean
properties on all of them. Half-automatic notification would be worse than none — you would
learn to rely on it and then meet the case it misses. So the backend closes the gap from the
other side: every frame re-reads the scene and rebuilds the rows if the labels changed,
or reassigns materials if only colours and visibility did. That is two cheap tuple
comparisons per frame, and it means no edit can stay invisible for longer than one repaint —
including in snapshot(), so view.save("f.png") straight after a relabel writes the new
text.
view.refresh() (and the Refresh button) forces it now, for when you have set fields
directly and nothing is going to draw a frame on its own.
A group’s swatch is the first member’s colour, since one swatch cannot show forty — and where the point of grouping is that a type shares a colour, they agree anyway. A group of mixed kinds (a type group normally holds meshes and skeletons) gets a plain square rather than a line or a marker claiming to speak for all of it. A partly hidden group gets a third row appearance, distinct from both on and off, and left-clicking it hides the rest.
The legend is drawn in the canvas, not beside it, and that is the whole design
constraint. A figure without its legend is not the figure, so it has to be part of what
snapshot() and save() produce — which rules out widgets and settles everything else:
pygfx objects rendered in a second pass into a strip, clicks resolved by the renderer’s own
pick buffer, and the camera controller registered on a viewport limited to the scene’s
rect so that clicking a row does not also spin the camera.
Sizes are logical pixels, so the panel does not grow or shrink with the camera:
neu_draw.show(scene, legend=False) # off entirely
Scene(legend=Legend(location="left", font_size=16))
Scene(legend=Legend(width=200, panel_color="w", text_color="k")) # for a light figure
Entries wrap into columns when a single column would not fit, and the whole legend shrinks if even that is not enough — it is never clipped, because in a figure there is nobody to scroll it. The strip never takes more than 45% of the canvas.
Mouse controls¶
Left drag rotates, right drag pans, the wheel zooms, and shift + left drag zooms too
(drag up to zoom in). The last is for VS Code, whose notebook scrolls on the wheel instead
of passing it to the canvas. neu_draw.backends.pygfx.EXTRA_CONTROLS holds the bindings
neu-draw adds; view.controller.controls is the full set and can be edited in place.
The toolbar, and saved viewpoints¶
In a notebook show() puts nine buttons above the canvas — no argument needed, and
nothing to remember after a kernel restart:
button |
what it does |
|---|---|
Center |
re-fit the camera to everything currently visible |
Reset |
back to the view this figure opened with — everything shown, no highlights |
Refresh |
re-read the scene, for a field you set directly on a drawable |
Save |
remember this camera as |
Restore |
go to |
Last |
go to |
Capture |
write the file named in the box below the buttons — |
Animate |
render an orbit to PNG frames, as set in Animation options, and encode an mp4 where ffmpeg is available |
Close |
close the canvas, leaving the image it last showed in its place |
Below the buttons is one opacity slider per kind the scene holds — mesh, skeleton, points — for when the surfaces hide the synapses. A slider sets every drawable of its kind to the value shown, starting from the largest they have now. From a cell, for any subset:
view.set_alpha(0.2, kind="mesh")
view.set_alpha(0.5, names=["A : Mi1"])
scene.set_alpha(0.2, kind="mesh") # the same, before show(); kept by reset()
Every one is also a method, so a notebook or a script can do the same:
view.center()
view.reset()
view.refresh()
view.save_view() # or save_view("dorsal"), for as many slots as you like
view.restore_view("dorsal") # returns None, not an error, if nothing is there
view.restore_view("last")
view.save("figure.png")
A viewpoint is kept outside the view, in neu_draw.views, and that is the point — you
save an angle in order to use it in the next figure, by which time the one you saved it
from is gone. It records the canvas size along with the camera, because a perspective
camera’s field of view follows the rect’s aspect ratio, so the same camera in a differently
shaped canvas is a different picture.
View.close() writes views["last"] on the way out, so wanting the angle back after a
figure is gone needs no foresight — and it is in close() rather than in the Close button,
so a view.close() from a cell counts too. Two slots and two buttons, deliberately: they
answer different questions (“the angle I chose” against “wherever I happened to be”), and
one button picking between them would leave it unclear which you got.
To skip the button entirely — re-run a cell and come back on the same angle:
view = neu_draw.show(scene, viewpoint="last") # or "saved", or a ViewState you held
An empty slot just means the figure keeps its own framing, so that is safe in the first cell of a session.
The store lasts one session. To keep an angle across kernel restarts, write a slot to a JSON file and read it back later:
neu_draw.save_view_file("views/dorsal.json", "dorsal") # overwrite=False to refuse
neu_draw.load_view_file("views/dorsal.json", "dorsal") # into views["dorsal"]
state = neu_draw.ViewState.load("views/dorsal.json") # or hold the state yourself
Reset is not Center. center() fits what is visible now, so after hiding half the
bodies it frames the remainder; reset() un-hides whatever you hid, drops every highlight,
and returns to the camera the figure opened at — including a viewpoint= it was opened
with. It deliberately leaves colours alone: hiding and highlighting are transient
exploration, but legend.recolor is an authored change, and a button that silently reverted
it would be destroying work rather than tidying up.
Vector export: SVG and PDF¶
Name the capture .svg or .pdf — in the toolbar box, or view.save("fig.pdf") — and
the figure is written as nested groups an editor can work with: each mesh as its own
image with a transparent surround, and the skeletons, synapse points and legend as
vectors (the legend as real text). Only what is visible is exported, highlights
included.
For Illustrator, use the SVG. Illustrator keeps an SVG’s named groups, but opens a PDF
from any other program onto a single layer; the PDF’s groups are nested layers in
Acrobat. The SVG is written for Illustrator’s importer (SVG 1.1, xlink:href images, its
own id escaping so group names read back as written).
The tree is background, meshes, skeletons, points, legend, with a group per
drawable. export_groups nests drawables deeper — here, synapse sets by cell:
scene.export_groups = lambda d: (d.name.split(" : ")[0],) if " : " in d.name else ()
# points / T4a 78044864 / T4a 78044864 : Mi1 (17)
The legend splits into panel, group rows, and rows nested by kind and by the same
function, so a cell’s rows sit together as its drawables do; each row groups its plate,
glyph and text.
Capture options (collapsed, under the path box) set the format, the resolution, the
width and height, and whether the legend is included; the same are keywords on
view.save(path, size=, pixel_ratio=, legend=, groups=), and save_svg / save_pdf
refuse the other extension.
Two things to know. Occlusion between groups is gone: every vector sits above every mesh, and meshes stack back to front by their centres — right for the faint meshes this is meant for, visibly wrong for an opaque mesh in front of synapses. And a mesh image’s colours are solved against the figure’s own background (the GPU blends in linear light, editors in sRGB), so it matches the canvas over that background exactly and is close rather than exact if you change the background in the editor.
Animation: an orbit to video frames¶
Animate renders the camera turning about an axis into numbered PNGs and, where an ffmpeg is available, encodes them to an mp4; Animation options (collapsed, beside Capture options) hold the degrees, seconds, fps, axis, easing, smoothing, whether the light turns with the camera, the frames folder, and the video. Size and legend come from Capture options. From a cell:
spin = view.orbit("figs/t4_orbit", degrees=360, seconds=12, fps=30, axis="up")
spin.video # "figs/t4_orbit.mp4" — or None, and repr(spin) is the ffmpeg line to run
ffmpeg is looked for, first match wins: ffmpeg= on the call, then the
NEU_DRAW_FFMPEG environment variable (a path or a command name), then ffmpeg on
PATH, then imageio-ffmpeg’s bundled binary if
that is installed. With none, the frames are still written and the plain command is
printed. encode=False renders frames only; encode=True insists. If your site provides
ffmpeg through a module system, load it in the shell that starts Jupyter, or point
NEU_DRAW_FFMPEG at it.
Axes:
up,right,vieware the screen’s, read at the start — a turntable of the view you set up — andx,y,z(or any xyz vector) are the data’s own. The pivot is the point at the screen centre, so frame 0 is exactly the current view, and the view itself is left where it was.light="camera"(the default) turns the lights with the camera, so shading holds still as it spins;"fixed"sweeps it across the surface.A whole number of turns loops (no doubled frame at the seam); any other sweep ends exactly on its angle.
ease="in-out"for a move that is not a loop.Frames are written atomically and a rerun into the same folder resumes — but only with the same parameters (recorded in
animation.json); a different orbit into a folder of frames is refused unlessoverwrite=True.The encode uses
-pix_fmt yuv420p, without which the mp4 will not play in PowerPoint, Keynote or Slack, and odd frame sizes are refused up front because that format needs even ones.
Measured on a 4-cell scene with meshes, skeletons and 350 synapses at 1500x900 with 2x smoothing: about 0.11 s a frame, so a 12-second turn renders in 40 s.
toolbar=False gives the bare canvas; toolbar=True insists and raises if ipywidgets is
missing or the canvas is not a widget. Offscreen and desktop renders quietly get no
toolbar, which is why the buttons never appear in a snapshot() — the legend is drawn in
the canvas instead, precisely so that it does.
Install¶
Part of the neu-suite suite, whose packages live as siblings in one conda environment:
conda activate neu-env
pip install --no-deps -e ./neu-draw
--no-deps is load-bearing across this suite: numpy and tensorstore come from
conda-forge, and letting pip re-resolve them invites an ABI mismatch.
Extras: render (pygfx, rendercanvas, jupyter-rfb, cmap), sources (pandas), dvid
(neuclease), cache (yes3), dev (pytest).
Importing neu_draw does not import a renderer, so it is safe on a headless
machine; only show() and the pygfx backend need one.
Store logging¶
Reading from S3 makes tensorstore print two ERROR-severity
AuthCredentialsProvider lines the first time each prefix is opened — the credential
providers it could not build before falling through to the one that works. They are
noise, not failures.
Every sources function already filters them, so you should not normally see them.
Two knobs if you need them:
neu_draw.logs.quiet = False # see raw store logging, benign lines included
neu_draw.install_quiet_stores() # filter for the whole session, covering reads
# made outside sources; remove_quiet_stores()
# to undo, or `with neu_draw.quiet_stores():`
The filter is a deny-list of known-benign strings rather than a severity filter, so
anything unrecognised still prints. The marker of a real problem is
PERMISSION_DENIED or AccessDenied; without one, the read succeeded.
Status¶
Early, but it works end to end against real precomputed volumes. Geometry, colours, scene assembly, the pygfx backend, the source readers, the clickable legend and the notebook toolbar are in place; DVID sources and 2D projections are not.
module |
what it is |
|---|---|
(from |
|
|
palette and colour assignment |
|
drawables, colours, camera intent — no renderer |
|
the only module that reads anything |
|
a three-method protocol; in-memory by default, |
|
saved camera viewpoints, outliving the view they came from |
|
camera orbits: the angle schedule and each frame’s pose — no renderer |
|
a figure as a tree of named groups, and its SVG and PDF writers — no renderer |
|
finding ffmpeg and encoding frames with it — standard library only |
|
the renderer seam — build, camera, canvas, snapshots |
|
the clickable legend, drawn in the canvas |
|
builds the vector figure from a live view: one image per mesh, the rest projected |
|
renders an orbit to frames |
|
the notebook buttons; the only module that needs ipywidgets |
Tests¶
python -m pytest -q