neu-glance¶
Neuroglancer viewer states, annotation layers and shareable links.
Everything that produces something a viewer consumes, and nothing that produces data. neu-vol writes volumes, neu-mark writes precomputed annotation sources, and neither of them knows a viewer exists — so a shader that reads a synapse property lives here rather than being pushed down into whichever library happens to own the store access.
neu-glance gen --image s3://bucket/em --seg s3://bucket/seg_v1 \
--annotations s3://bucket/seg_v1/synapses_v1 --segments 12345
Synapses¶
A synapse source becomes one layer, filtered on both relationships, so it shows the
selected bodies’ inputs and outputs with a line from each T-bar to its partner. The synapse
shader’s show_pre / show_post boxes show either end alone, show_line turns the line
off, and the layer’s Annotations tab turns the filter on or off per relationship.
That layer also draws the partners’ end of each synapse. --presyn-postsyn adds a presyn
and a postsyn layer, filtered on body_pre and body_post and each showing only that
end, so together they mark where the selected bodies’ outputs and inputs are and nothing
on the partners — clearer in 3D, where the meshes are what you are looking at.
The shader gets a color_by_* box for each colouring the source has the properties for
(neu-glance shaders lists them); the first one ticked wins:
box |
reads |
colours |
|---|---|---|
|
|
both ends by the synapse’s top-level neuropil |
|
|
both ends by its ROI |
|
|
each end by its body |
|
|
the T-bar by its partner count (viridis, up to |
With a colouring on, each marker’s border keeps its end’s own colour (pre_color /
post_color, border_width wide), so the two ends stay distinguishable.
A source written with neu-mark annotation-source --listed also gets two filters:
hide_unlisted hides synapses with neither end on a listed body, and
hide_unlisted_partner keeps only those between listed bodies.
An id colouring uses neuroglancer’s own segment colour for the id (colour seed 0), so a
synapse coloured by neuropil matches that neuropil’s mesh in an ROI layer, and one coloured by
neuron matches the body. Id 0 means none and is white; hide_uncolored drops those.
neu-mark annotation-source --roi-spec writes roi and neuropil by the ROI spec’s ids,
which are the ids the ROI layers paint.
Display defaults¶
Volume layers leave their bounding-box annotation off, and the synapse line is yellow, which reads over greyscale EM. The rest is per layer, by name:
neu-glance gen --seg s3://bucket/seg --seg s3://bucket/rois/head \
--silhouette head=5 --opacity-2d head=0 --opacity-3d head=1 \
--color head=white --no-pick head
Choices a site makes for every state go in a TOML file passed as --config (read only when
named, and a flag wins over it). neu_glance.config documents the keys: [volume] bounds,
[annotations.shader_controls], [view] (position, projection_orientation,
projection_scale, cross_section_scale, layout, select, in neuroglancer’s own units
and xyz order, so they can be copied from a saved state), and [layer.NAME] with
silhouette, opacity_2d, opacity_3d, color, pick, skeletons (false leaves the
source’s skeletons unloaded, until ticked in its Source tab), tab, hide, all_segments and
color_by.
--color-by LAYER=PROP[,PROP...] colours bodies by their segment properties, which a
segmentation layer cannot do itself (it has no shader). A property is a tag facet (group,
ol_column), a flag tag or label; each body takes the first one it has a value for, so
ol_column,group colours by column and falls back to the cell type. Each value gets a fixed
colour from neuroglancer’s segment palette, the same in every state, written into
segmentColors — so it is fixed when the state is made (regenerate after the properties
change), adds ~25 bytes per coloured body to the state, and has no legend. Bodies with no
value keep their hash colour. A layer the state does not have is skipped with a
note, so one file serves many states; an unknown key or a shader control the shader does not
declare is refused, since neuroglancer would ignore it.
Subcommands¶
produces |
|
|---|---|
|
a state from volumes, annotation sources and layer files |
|
a layer from coordinates or a CSV |
|
a layer from a volume’s occupancy |
|
a running viewer over arrays held in this process |
|
a URL back into its state JSON |
|
lists or prints the built-in shaders |
|
what |
The three producers share one output stage. --format {layer,state,url} chooses the
serialization — a bare layer to paste into a state’s layers array, a whole state for
neuroglancer’s {} editor, or a link carrying that state — and --out writes it somewhere
instead of stdout (repeatable; --out - is stdout as well).
--into merges the new layers into an existing state, given as a URL or a JSON file. The
state’s own dimensions, position and zoom are kept, so adding a layer does not move your
view, and a layer whose name is already taken is renamed and reported rather than silently
shadowing the one already there.
The layer flags (--color-by, --color, --silhouette, --opacity-2d, --opacity-3d,
--no-pick, --hide, --select) reach the state’s own layers too, named as the viewer shows
them or by their source location (s3://bucket/seg, with or without precomputed://), so a
saved scene can be recoloured without rebuilding it:
neu-glance gen --into s3://my-bucket/states/v1.json --color-by seg=group \
--format state --out s3://my-bucket/states/v1-by-group.json
--color-by reads the layer’s segment properties from its source. A --config applies only
to the layers the call adds: its [layer.NAME] sections and [view] leave a saved state’s
layers and view as they are, and say so.
With nothing to add, gen --into re-emits the state. For a state file on s3:// or gs://
the link then names the file (<viewer>/#!s3://bucket/states/v1.json) and the viewer
fetches it: the link stays short and follows later rewrites of the file, which must be
anonymously readable with CORS. --expand puts the whole state in the link instead, as it
always is for a local file; a short link cannot carry added layers or view options, so asking
for both is refused.
Put a neuroglancer URL in single quotes. Inside double quotes an interactive bash expands
!, and neuroglancer uses it twice: after the # that opens the state, and before a hidden
segment’s id ("!12345"). Losing the first gives a link starting #7B and an error; the
second can silently splice an old command from your history into the segment list.
neu-glance gen --into 'https://neuroglancer-demo.appspot.com/#!%7B...' --layer boxes.json
Looking at something that is not published¶
serve hosts arrays from this process and prints a viewer link — a ground-truth crop, a
box out of a volume, a probability map straight out of a model. It replaces serving HDF5
chunks through chunkflow to eyeball them.
neu-glance serve --seg piece.h5 # a crop, labels and all
neu-glance serve --image vol --seg gt --crop-bbox 0,0,0,64,512,512
neu-glance serve --image piece.h5:/raw --prob piece.h5:/affinity
Needs the optional extra, pip install 'neu-glance[serve]', because building a state needs
none of it and CI has no business downloading a viewer bundle.
From a notebook it is the same thing with a handle on it, and the handle is the point — the server runs in your kernel, so the browser’s state is readable here:
from neu_glance import serve, ServedLayer
layers = [
ServedLayer.from_hdf5("gt.h5", "/z07901", "segmentation"), # frame from the file
ServedLayer.from_volume("s3://my-bucket/em", level=1, # kind from its info
crop=((0, 0, 0), (64, 512, 512))),
ServedLayer.from_array(prob, "probability", voxel_size=(40, 8, 8)),
]
srv = serve(layers)
srv # renders the link
srv.boxes() # boxes you drew, as (lo, hi) in zyx voxels
srv.selected_segments() # label ids you clicked
srv.on_click(lambda c: print(c.voxel, c.values))
Taking the same physical box as something else — the “show me the image under this
ground-truth crop” case — is what crop= does with a layer or a neu_lib.Piece:
gt = ServedLayer.from_hdf5("gt.h5", "/vol_03700", "segmentation")
em = ServedLayer.from_volume("s3://my-bucket/em", crop=gt) # the same box, in nm
em2 = ServedLayer.from_volume("s3://my-bucket/em", level=2, crop=gt) # and coarser
serve([em, gt])
Nanometres are the only space that transfers: the two frames have different voxel sizes
and different origins, so a voxel box from one means nothing in the other. layer.bbox,
layer.bounds_nm and layer.piece report where a layer is, and a box clipped to a
fraction of what was asked for warns — losing most of it usually means it came from a
different dataset than the volume.
A probability map is drawn in one colour with the opacity carrying the value — red by
default, so the EM under it stays visible where the model is unsure and is progressively
covered where it is confident. color= picks another, which is how two maps go into one
viewer distinguishably; the viewer’s own control changes it afterwards, and
shader="colormap" restores the older two-colour gradient.
srv.add_layer(ServedLayer.from_array(pred, "probability", voxel_size=(40, 8, 8)),
color="green") # or "#00ff00", (0, 1, 0), (0, 255, 0)
Colours take a name, #rrggbb, or a 3-sequence (floats 0..1, ints 0..255). A short list of
names — red, green, blue, cyan, magenta, yellow, orange, purple, lime, white, black, gray —
needs nothing installed; anything else matplotlib names ("forestgreen", "tab:blue")
works where matplotlib happens to be there, and where it is not you are asked for hex or a
tuple. matplotlib is not a dependency and this does not make it one — every built-in
name carries matplotlib’s own value for it, so installing it later cannot change what a
colour means.
The colour is set as a shaderControls override rather than by generating a shader with a
different default, so every layer carries the same code and the viewer’s panel shows what
was set. It applies to a shader that has a single colour: grayscale has none and rgb has
three, and asking for one there raises rather than setting a control neuroglancer would
ignore in silence.
A neu_lib.Piece is itself a layer input, to serve and add_layer alike — it already
carries the frame, the kind and a name, so a crop read (or cleaned) with neu-vol goes
straight to a viewer:
serve([neu_vol.read_piece("gt.h5:/vol_03700", "segmentation")])
A piece whose kind is None needs ServedLayer.from_piece(piece, kind), since neither
call has a kind= to pass.
The constructors follow one rule: infer what the source records, require what it
does not. A frame, a dataset name and the channel axis are all written down — in an HDF5
file’s attributes, a precomputed info, or the array’s own rank — so reading them is not
guessing, and dropping them is the silent failure. kind is asked for, because an HDF5 file
has nowhere agreed-on to record it and reading it off the dtype is the mistake neuroglancer
itself makes. A volume that records info["type"] is the exception, and then from_volume
needs nothing.
The browser follows the state, so a viewer already open can grow layers. Nothing is
re-served and the URL does not change — srv.add_layer() hosts one more array, taking the
same three inputs (and going through the same builder) that serve does:
srv = (serve([em])
.add_layer(ServedLayer.from_hdf5("gt.h5", "/vol_03700", "segmentation"))
.add_layer(ServedLayer.from_array(pred, "probability", voxel_size=(40, 8, 8)),
name="prediction"))
add_layer returns the server, so the calls chain. It does not move the view — adding a
layer must not change where you are looking — so a layer whose data is elsewhere lands off
screen, and srv.bounds("prediction") is how to find it. A name already taken is renamed,
not replaced; the resolved name is read off the viewer, list(srv.volumes)[-1] being the
one just added.
srv.boxes() closes the loop: pick a region in the viewer, hand it straight to
--crop-bbox, extract_roi or neu-vol write. It needs an annotation layer to draw in,
which is opt-in — because a viewer that opens with a layer nobody asked for reads as a
bug. srv.annotate() adds one to a viewer that is already open, which is usually where you
realise you want it; serve(..., annotations=True) and --annotate open with one already
there. (The CLI flag is --annotate, not --annotations: gen --annotations SOURCE already
means a precomputed annotation source to load.)
srv.annotate() # a layer to draw in, box tool armed and selected
# ... ctrl+mousedown0 in the viewer to place boxes
srv.boxes() # -> [((lo), (hi)), ...]
srv.annotate(tool="point") # same layer, now placing points — nothing drawn is lost
srv.points()
The browser follows the state, so the layer appears in the tab you already have open —
nothing is re-served. Calling it again keeps what is already drawn, and tool is "box"
or "point" only: those are the two boxes() and points() can read back, and arming a
line or an ellipsoid would be drawing that never comes back.
Dragging a box to exact corners in neuroglancer is fiddly; clicking a point at each corner
is not. srv.enclose() is the conversion — the containing box of everything drawn,
added to the layer and returned as a neu_lib.BBox in whole voxels:
srv.annotate(tool="point") # click a point at each extreme
box = srv.enclose(margin=(1, 8, 8), # grown per axis, zyx, in voxels
clip="volume", # ... but not off the end of the data
replace=True) # the points were scaffolding; drop them
lo, hi = box # a BBox unpacks like the pair it is
Everything in the layer goes in — points, boxes, and the lines, ellipsoids and polylines
the viewer’s own toolbar can place. So the other half of it is fixing a box that is nearly
right: draw roughly, click a point where it should have reached, enclose(replace=True).
Corners round outward where boxes() rounds to nearest, since a box that exists to
contain things must not round in past the point that put it there. With replace=False the
new box is itself an annotation, so calling again encloses it too and a margin grows the
box each time — which is what “enclose what is drawn” means, and the reason replace=True
is the usual call.
A margin reaches past the end of the array happily, so clip= bounds the result against
the two extents the server can see for itself:
srv.bounds() # the served arrays' extent — clip="volume"
srv.data_bounds() # where their non-zero voxels are — clip="data"
srv.bounds("seg") # one layer, for clip=srv.bounds("seg")
Both are BBoxes in viewer voxels, unioned over the served layers or taken one by name, and
both read each layer’s own recorded voxel size — so a second layer served at a coarser scale
converts rather than being assumed to share the first one’s grid. clip also takes a BBox
or a plain (lo, hi) pair. It is off by default: enclose reads a viewer somebody is
drawing in, and handing back a smaller box than the one now on their screen is worse than
handing back what they asked for — so unclipped it warns when the box starts below zero, and
a clip that leaves nothing raises rather than returning an empty box.
The viewer opens centred on the served data and zoomed to fit it. Neuroglancer’s own default is the origin corner at one voxel per pixel, which for a crop sitting at voxel 3700 of its parent is a view of empty space a long way from anything.
Three things about it that are not obvious:
A served link is not shareable. Each array is addressed
python://volume/<viewer-token>, scoped to the process and dead when it exits. Soservehas no--format url; usegenagainst a volume that is actually published.--segis served as labels whatever the dtype. neuroglancer guesses segmentation only for uint16/32/64, so a uint8 label array would be read as an image — averaging label ids on downsample and losing the colour hashing and the selection UI, silently.The frame travels with the array. A crop keeps its origin, so it lands on top of the volume it came from rather than at nm zero. A source that records one (
neu-vol to-hdf5writes it) needs no--voxel-size.
Animating a state¶
A Timeline wraps one base state — the link you tuned in the browser — and a list of tweens
over it. Each tween names one property, a window in time and an easing; everything no tween
mentions holds its base value.
from neu_glance import load_state
from neu_glance.movie import Timeline, record
tl = Timeline(load_state("scene.json"), fps=30)
# Neuroglancer's objectAlpha is per LAYER and there is no per-segment alpha, so an object
# that fades on its own has to be a layer on its own. split() does that, and hands back
# {segment_id: layer_name} so groups are written against segment ids.
neurons = tl.split("my-segmentation", prefix="neuron")
rois = tl.split("my-rois", prefix="roi")
tl.tween(layer="my-head-mask", at=1, seconds=3, ease="out",
meshSilhouetteRendering=10) # opaque shell -> transparent one
tl.group("chain-1", [neurons["3832372"], rois["20"], rois["30"]])
tl.group("chain-2", [neurons["10063300"], rois["26"]])
tl.sequence(["chain-1", "chain-2"], start=4, seconds=1.2, stagger=0.8)
tl.view("https://neuroglancer-demo.appspot.com/#!...", at=6, seconds=9)
record(tl, "frames/", size=(1920, 1080)) # prints a URL; open it and wait
Moving the camera¶
Three ways, which compose:
tl.view(url, at=6, seconds=9) # ABSOLUTE: fly it in the browser, paste the link
tl.orbit(360, at=6, seconds=10) # RELATIVE: a turntable about the screen's vertical
tl.orbit(180, axis="z", at=6, seconds=8) # ...or about a volume axis, reproducibly
tl.zoom(0.5, at=6, seconds=10) # RELATIVE: half the scale = twice as close
tl.frame_on(box, at=2, seconds=3) # point at an object and fit it
orbit is not sugar for two view() calls. Slerp takes the shortest arc between two
orientations, so a 360° turn is a no-op — the two ends are the same rotation — and 200°
silently becomes 160° the other way. Orbit varies the angle, so any rotation works. Verified
against a real render: 0° and 360° come back pixel-identical.
frame_on takes (lo_zyx, hi_zyx) in nanometres by default (the suite’s model space), and
converts to the viewer’s own voxels using the state’s dimensions. Boxes can come from
anywhere; sources.segment_boxes(volume, ids) reads them from the segments’ meshes.
boxes = neu_glance.sources.segment_boxes(volume, ["3832372"])
tl.frame_on(boxes["3832372"], at=2, seconds=3)
tl.orbit(360, at=5, seconds=12) # ...then turn around it
record prints the ffmpeg line to turn the frames into an mp4 and does not run it — a
render takes hours and an encode takes seconds, so a failed encode should cost nothing, and
ffmpeg is often a module rather than something on PATH.
Four things worth knowing:
view()takes a URL. Framing a 3D view is done by dragging, not by writing quaternions, so the intended loop is to fly the camera in the browser, copy the link, and paste it. Only the camera keys are taken — the target’s layers and layout are ignored.The cursor moves only on
hold(). Two tweens written one after the other run together, because fading while moving is the common case.Interrupting is safe and re-running resumes. Frames are written atomically and a resumed render checks the PNG signature, not just that a file exists.
neu_glance.movie.animateis pure — stdlib only, no neuroglancer — so a timeline can be built, sampled and tested with nothing installed.tl.at(4.5)is just a state dict, andstate_urlturns any moment into a link you can open.
Everything in animate is deliberately ours rather than neuroglancer.ViewerState.interpolate,
which in 2.41.2 raises AttributeError for a state carrying any layer (Layer.interpolate
reads layer_position; the property is localPosition), covers neither
meshSilhouetteRendering nor segmentColors, and materialises orientation keys the input
never had.
Things that fail silently, and where they are handled¶
Neuroglancer is forgiving in the worst way: a wrong state loads cleanly and shows you something plausible. The comments in each module say which mistake they exist to prevent, but the four worth knowing up front:
A
dimensionsblock that disagrees with the data puts every layer in the wrong place and still loads.genderives it from a volume’s recorded voxel size rather than assuming.A volume and an annotation source are both
precomputed://with aninfoat the root, so nothing about a URL tells them apart — and an annotation layer pointed at a volume draws nothing at all.sources.read_annotation_infochecks the@type.A shader naming a
prop_the source does not declare fails to compile, and the layer then draws nothing, with the error visible only in the layer’s shader tab. It does not fall back.shaders.pick_shaderrefuses the pairing instead.linkedSegmentationLayeris what makes a relationship index do anything. The source keys its relationships on segment id, but the viewer only consults them once each is bound to a layer whose selection it can read. Without the binding there is no “this body’s synapses” at all.
Layout¶
neu_glance/
├── layers.py local annotation layers — from coordinates, or from occupancy boxes
├── sources.py layers for something on a store: a volume, an annotation source
├── served_layer.py an array to host, and how to show it
├── serving.py host arrays HERE and run a viewer on them
├── shaders.py GLSL, and the rule for choosing one — annotation and image families
├── config.py site display defaults for `gen --config`
├── state.py assemble a state, encode a URL, read one back, merge into one, split a layer
├── movie/
│ ├── animate.py a timeline of tweens over one state, and the interpolation under it
│ └── rendering.py drive a viewer frame by frame and write PNGs
└── cli.py neu-glance
layers.py, state.py and movie/animate.py are pure — plain data in, plain data out, no
store access — and sources.py is the only module that reads anything. That line is
deliberate: a layer whose source is a locally served volume has no store to inspect, so state
assembly must never require one, and a timeline’s interpolation stays covered by a CI that
installs no extras.
serving.py and movie/rendering.py are the two that need a viewer. Neither imports
neuroglancer at module scope — every such import is inside the function that needs it,
which is what keeps import neu_glance and neu-glance --help off that import graph. A test
checks the indentation rather than the filename, because “only serving.py imports it” was
only ever a proxy for the rule that matters.
Install¶
Part of the neu-env conda environment, installed editable alongside its siblings:
pip install --no-deps -e ./neu-glance
--no-deps is load-bearing across this family — see the neu-suite notes.