Packing¶
Arranging shapes on a page without letting any of them touch — what a collage of neurons needs, and the kind of thing that is easy to write in numpy and slow to run.
Three primitives, meant to be used together:
masks = fastcore.rasterize_segments(coords, edges, scale=40, pad=1)
positions, variant, grid = fastcore.pack_masks(
masks, page_shape=(1300, 980)
)
rasterize_segments turns line work — a skeleton's node-to-parent edges, a mesh's face
edges — into binary masks. pack_masks lays those out so that no two share a pixel.
pack_rectangles is the cheaper bounding-box alternative, useful on its own or as a
starting point for the mask packing.
Packing shapes, not boxes¶
A bounding box is mostly empty for anything branching, so packing boxes wastes most of the page. Packing the shapes themselves lets one reach into another's empty space — even sit inside a loop of it — as long as no cable actually meets:
ring = np.zeros((9, 9), dtype=bool)
ring[0, :] = ring[-1, :] = ring[:, 0] = ring[:, -1] = True
small = np.ones((3, 3), dtype=bool)
fastcore.pack_masks([[ring], [small]], (9, 9))[0] # -> [[0, 0], [1, 1]]
fastcore.pack_rectangles([[9, 9], [3, 3]], (9, 9))[0] # -> None, no room
Why this is not a cross correlation¶
The obvious way to test every position of a mask against the page at once is a
correlation: fftconvolve(page, mask[::-1, ::-1], mode="valid") gives the number of
shared pixels at each of the ~1.3M positions of a 1300x980 page, and the free ones are the
zeros. One vectorised call — which is why it is what you write in numpy.
It is also the wrong computation. It produces an exact overlap count at every position, in floating point, when the question is boolean and the answer is wanted at exactly one position: the best one. Measured on 200 synthetic arbors at 100 px per page unit, on a 1300x980 page:
| step | numpy/scipy | fastcore |
|---|---|---|
| rasterise 200 arbors | 32 ms | 1.8 ms |
...with fill=True |
50 ms | 2.1 ms |
| pack, bottom-up, 1 variant | 1.55 s | 25 ms |
| pack, bottom-up, 2 variants | 3.07 s | 38 ms |
| pack, under a cost surface | 4.39 s | 69 ms |
| pack 200 bounding boxes | 14 ms | 2.0 ms |
The correlation is about 70% of the layout, and it grows as O(N² res²) — doubling the
resolution quadruples it. What replaces it:
- Positions are visited in the order you score them. Both cost models — bottom-up, and a cost surface — define a total order on positions that does not depend on the shape, so the best free position is the first free one in that order. The scan stops there. A correlation cannot stop early; that is the whole difference.
- Collisions are bit operations. A page row is
u64words, a mask row isu64words, and the test is anAND— 64 pixels per instruction, no allocation, and a page small enough to stay in cache (160 kB packed against 1.3 MB as bytes). - The densest rows are tried first. A position that collides usually collides on the shape's heaviest row, so most rejections cost one or two words.
- Provably empty space is skipped. Everything above the highest occupied row is free, so a bottom-up scan is bounded by the fill line rather than by the page.
- The search runs on every core, and so do the variants of a shape against each other. Placement itself does not, and cannot: each shape goes down against the page the one before it left behind, which is what the packing means.
Rasterising is the same story on a smaller scale. Interpolating every edge and scattering the result materialises one element per pixel-step of every edge: on a mesh with 300k edges averaging five pixels each that is a 1.5M-element index array, plus the same again for the parameter and both coordinates, to set a few tens of thousands of distinct pixels. Here the walk writes straight into the mask — on every core at once for a shape with enough edges to be worth splitting, so that one outsized mesh in a neuron list does not set the pace for all of it.
Cost surfaces and masks¶
By default shapes go as far down, and then as far left, as they will go, which fills a
rectangle neatly. Pass cost to say what a good position is instead — each shape lands
where the surface is lowest under its centre:
rows, cols = np.ogrid[0:height, 0:width]
cost = np.hypot(rows - height / 2, cols - width / 2) # fill from the middle outwards
To confine shapes to a silhouette, mark everything outside it as already taken and hand
that in as grid. Combining the two — a grid that blocks the outside and a cost that
grows from the middle of the shape — is what fills an arbitrary outline, since bottom-up
would pile everything into the bottom of it.
grid also serves the two-pass case: pack one set of shapes, then hand the page back to
squeeze a second set into whatever room is left.
Reference¶
navis_fastcore.rasterize_segments(coords, edges, scale=1.0, pad=0, fill=False, turn=0, threads=None)
¶
Mark every pixel a set of line segments passes through.
Each shape is shifted so its own lower-left corner sits at (pad, pad) and
scaled by scale; the mask comes out just big enough to hold it with pad
pixels of margin on every side. Shapes are rasterised one per core.
Doing this in numpy means interpolating every edge and scattering the result, which materialises one element per pixel-step of every edge: on a mesh with 300k edges averaging five pixels each that is a 1.5M-element index array, plus the same again for the parameter and both coordinates, to set a few tens of thousands of distinct pixels. Here the walk writes straight into the mask.
| PARAMETER | DESCRIPTION |
|---|---|
coords
|
TYPE:
|
edges
|
TYPE:
|
scale
|
TYPE:
|
pad
|
TYPE:
|
fill
|
TYPE:
|
turn
|
TYPE:
|
threads
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
masks
|
One mask per shape, matching the shape of
TYPE:
|
Examples:
>>> import navis_fastcore as fastcore
>>> import numpy as np
>>> coords = np.array([[0., 0.], [4., 4.]])
>>> edges = np.array([[0, 1]])
>>> mask = fastcore.rasterize_segments(coords, edges)
>>> mask.astype(int)
array([[1, 0, 0, 0, 0],
[0, 1, 0, 0, 0],
[0, 0, 1, 0, 0],
[0, 0, 0, 1, 0],
[0, 0, 0, 0, 1]])
navis_fastcore.pack_masks(masks, page_shape, grid=None, cost=None, optional=False, threads=None)
¶
Place binary masks onto a page so that no two of them share a pixel.
Masks go down largest-first, each at the best position at which not one of its pixels collides with what is already there - so a shape may sit inside the loop of another as long as no ink actually meets.
The obvious way to do this is a cross correlation:
fftconvolve(page, mask[::-1, ::-1], mode="valid") gives the number of shared
pixels at every position at once, and the free ones are the zeros. It is also
the wrong computation - an exact overlap count everywhere, in floating point,
when the question is boolean and the answer is wanted at one position. Measured
on 200 synthetic arbors at 100 px per page unit it is about 70% of the layout,
and it grows as O(N^2 res^2). This visits positions in the order you score
them and stops at the first free one, testing collisions 64 pixels at a time.
| PARAMETER | DESCRIPTION |
|---|---|
masks
|
TYPE:
|
page_shape
|
TYPE:
|
grid
|
TYPE:
|
cost
|
TYPE:
|
optional
|
TYPE:
|
threads
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
positions
|
TYPE:
|
variant
|
Which variant was used. Meaningless where
TYPE:
|
grid
|
The page with everything drawn onto it - hand it back in as
TYPE:
|
All three are ``None`` if a mask fit nowhere and ``optional=False``.
|
|
Examples:
navis_fastcore.pack_rectangles(sizes, page_size, allow_rotation=False, optional=False, free=None)
¶
Pack rectangles into a page using the MaxRects heuristic.
Rectangles are inserted largest-first into the free rectangle that leaves the least slack (best short side fit), which packs them tightly against each other and against the edges of the page.
Cheap, but a bounding box is mostly empty for anything branching - see
:func:~navis_fastcore.pack_masks for packing the shapes themselves.
| PARAMETER | DESCRIPTION |
|---|---|
sizes
|
TYPE:
|
page_size
|
TYPE:
|
allow_rotation
|
TYPE:
|
optional
|
TYPE:
|
free
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
positions
|
TYPE:
|
rotated
|
TYPE:
|
free
|
TYPE:
|
All three are ``None`` if a rectangle fit nowhere and ``optional=False``.
|
|
Examples: