Skip to content

Use grid accelerated Poisson disk sampling in generate_unit_locations - #4730

Open
adityasingh2400 wants to merge 2 commits into
SpikeInterface:mainfrom
adityasingh2400:fix-3419
Open

Use grid accelerated Poisson disk sampling in generate_unit_locations#4730
adityasingh2400 wants to merge 2 commits into
SpikeInterface:mainfrom
adityasingh2400:fix-3419

Conversation

@adityasingh2400

Copy link
Copy Markdown

Fixes #3419.

generate_unit_locations drew every unit at once and then enforced minimum_distance by rejection. Each of up to max_iteration passes rebuilt the full dense (N, N, 3) broadcast difference, reduced it to an (N, N) distance matrix, and re-randomised the offenders. There is no spatial index, so every pass is O(N^2) in time and in peak memory, and the loop only warns when it runs out of iterations. Past a moderate packing density that means callers silently receive unit locations that violate the constraint minimum_distance promises.

This replaces that block with Bridson's Poisson disk sampling over the (minimum_x, maximum_x) x (minimum_y, maximum_y) x (minimum_z, maximum_z) box. Accepted points go into a uniform grid whose cell size is minimum_distance / sqrt(3), so a cell holds at most one point and a candidate only has to be compared against the points in the small block of cells around it. num_units of the resulting points are then drawn with rng.choice(..., replace=False).

Everything is pure numpy. No new dependency, in particular no scipy.

On a 384 contact Neuropixels shaped probe (4 columns, 20 um pitch) at the default minimum_distance=20.0 and the default max_iteration=100, medians over 5 seeds:

num_units before solved worst pair after solved worst pair
200 0.01 s 5/5 20.02 um 0.03 s 5/5 25.70 um
400 0.10 s 5/5 20.00 um 0.07 s 5/5 20.40 um
500 0.37 s 3/5 9.70 um 0.08 s 5/5 20.00 um
600 0.56 s 0/5 3.52 um 0.08 s 5/5 20.00 um

The worst pair column is the point of the issue. At 500 and 600 units the old sampler gave up and returned locations 3.5 um apart while minimum_distance was 20.

The scaling is the other half. On a 6000 contact probe where nothing saturates:

num_units before after
500 0.01 s 0.14 s
1000 0.08 s 0.21 s
2000 0.47 s 0.51 s
5000 11.23 s 0.88 s

Below a few hundred units the new sampler costs tens of milliseconds more, because a per point loop with a fixed numpy overhead cannot beat one small dense matrix multiply. Above that the quadratic term takes over and the new sampler wins, and the 200 MB (N, N) matrix at N=5000 is gone entirely.

Notes on behaviour:

  • Seeded output changes. For a given seed the returned locations differ from before, so ground truth recordings built from a fixed seed will not reproduce byte for byte against earlier versions. This is the one reviewer facing risk here. The same seed still gives the same result within a version. Spacing is also blue noise now rather than uniform with rejection, so units are a little more evenly spread at a given density.
  • distribution="multimodal" is preserved by weighting which pooled points to keep by the layer density rather than by moving points, so the distance constraint and the layering hold at the same time. Measured against the old sampler on the same probe, the fraction of units landing near a mode centre went from 0.62 to 0.74 at 200 units and from 0.57 to 0.67 at 300 units, against a flat baseline of 0.33.
  • The distance_strict raise and the warning text are unchanged, and when the box genuinely cannot hold num_units points that far apart the returned array is still (num_units, 3).
  • minimum_distance=None keeps the old plain uniform draw path untouched.
  • max_iteration stays in the signature and now bounds how many times the sampling radius is reduced while looking for enough locations.
  • The truncated ValueError message for a bad distribution ("must be 'uniform' or ") is completed, and it is now raised before any sampling work.

Tested with a 1536 configuration sweep over 4 probe geometries, 6 unit counts, 4 z ranges, 4 minimum distances, 2 margins and both distributions, comparing against the old sampler given its most favourable setting of max_iteration=500. The new sampler solves 41 configurations the old one could not, loses 1, and takes 73 s against 449 s in total. The single loss is a saturation boundary coin flip: on that configuration the old sampler solves 10 of 40 seeds and the new one solves 13 of 40, it just happened to lose on seed 0.

test_generate_unit_locations is untouched. Added test_generate_unit_locations_dense (550 units on a 384 contact probe with distance_strict=True, plus box bounds), test_generate_unit_locations_multimodal (distance plus layer concentration against a flat baseline), and test_generate_unit_locations_no_solution (raise and warn paths keep the shape). 550 rather than 500 because at the default max_iteration=100 the old sampler fails 500 on only 2 of 8 seeds but fails 550 and above on 8 of 8, so 550 is the reliable threshold. Box capacity there is 646 to 673 points across seeds, leaving headroom for CI variation.

src/spikeinterface/core/tests/ is green at 326 passed and 5 skipped. In generation/tests/, three test_template_database.py cases fail with ModuleNotFoundError: No module named 's3fs', which I confirmed fail identically with generate.py restored from main, so they are a missing optional dependency and unrelated.

Disclosure: this change was prepared with AI assistance. I have reviewed and tested it.

The old sampler drew every unit at once and then enforced minimum_distance by
rejection, rebuilding the full dense (N, N, 3) broadcast difference and reducing
it to an (N, N) distance matrix on every pass. With no spatial index that is
O(N^2) in both time and peak memory per pass, and the loop only warns when it
runs out of iterations, so past a moderate packing density callers silently got
unit locations that broke the constraint minimum_distance promises.

Bridson's algorithm with a uniform grid over the box only ever compares a
candidate against the points in the cells around it, so filling the box is
linear in the number of points placed. The box is filled and num_units points
are then drawn from that pool, which keeps the signature, the distance_strict
raise and the warning text unchanged.

Fixes SpikeInterface#3419
The Poisson disk packing changed how far apart units end up, not just the
minimum they respect. On the small probe used by the postprocessing tests it
put the first two units 36um apart in xy where the previous sampler put them
17um apart, so their sparsity no longer overlapped and merging them produced a
unit with no channels.

Restore the original draw-and-redraw loop, which only pushes units as far apart
as minimum_distance requires, and replace just the pairwise distance matrix with
a uniform grid lookup. That removes the quadratic cost the issue was about while
leaving the output identical to before. Poisson disk sampling is kept as the
fallback for the dense case the redraw loop cannot solve, which is the case the
old code silently returned invalid locations for.
@adityasingh2400

Copy link
Copy Markdown
Author

Fixed the postprocessing failures. The cause was mine, not a flaky test.

Switching to a Poisson disk packing did not just enforce minimum_distance, it changed how far apart the units end up in general, because the pool is close to a maximal packing and the kept units inherit that spread. On the 6 contact probe the postprocessing suite generates, that moved the first two units from 17.2um apart in xy to 36.0um. The probe only spans 20x40um, so at that separation their sparsity no longer overlapped, merge_units produced a unit with no channels, and compute_monopolar_triangulation hit argmax on an empty array.

So I put the original draw-and-redraw loop back, since it only ever pushes units as far apart as the constraint actually requires, and replaced only the part the issue was really about: the full pairwise distance matrix is now a uniform grid lookup in _indices_closer_than. Cells are minimum_distance wide, so a violating pair is always within one cell on each axis and the scan follows the number of units instead of its square.

Poisson disk sampling is still here, as the fallback for when redrawing does not converge. That is the dense case test_generate_unit_locations_dense covers, where the old code gave up and returned locations that broke the constraint it promises, so that fix is preserved.

Checks I ran:

  • _indices_closer_than agrees with the pairwise reference on 403 random cases including empty, single point and all-coincident inputs, 0 mismatches
  • for the postprocessing probe and seed, the output is now bit-identical to the pre-PR sampler
  • test_unit_locations.py goes from 2 failed to 7 passed, and I re-confirmed those 2 still fail on the previous commit
  • test_generate.py passes all 48, including the dense and multimodal tests added here
  • 550 units on a 384 contact probe takes 0.81s with the minimum pairwise distance holding above 20um

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve performance on generate_unit_locations

1 participant