Documentation
Release • v1.0.0Stars2Cells matches neurons across imaging sessions the way astronomers plate-solve the night sky — by encoding the relative geometry of small constellations of points into descriptors that don't change when the field rotates, translates, or shifts. Identifying any single neuron is hopeless; identifying four of them at a time is robust.
The unit of information is a quad: four neurons, with one pair forming the longest edge (the diagonal) and the other two as third-points sitting off to either side. Project the third-points into a coordinate frame defined by the diagonal, normalize by the diagonal's length, and you get a four-number signature that is invariant to translation, rotation, and uniform scale.
Two sessions' quads are then directly comparable in ℝ⁴ without ever solving for the unknown transform between them. Once enough quads agree, the transform falls out of them — recovered by RANSAC, used to convert quad-level agreement into a one-to-one neuron assignment, and finally chained transitively across sessions to produce stable global identities.
Five steps. Each step writes a self-contained file that the next step consumes; nothing is kept in memory across steps, so any stage can be re-run in isolation by deleting its outputs.
Per-session quad descriptors are generated once. Step 1.5 then samples a fixed number of quads per pair, sweeps a similarity threshold τ ∈ [0, 1], and fits the scaling law τ = C · √N across an animal's session pairs. C is the per-animal calibration constant; it propagates into Step 2.
Step 2 runs FAISS top-1 nearest neighbour search in descriptor space, gated by τ and a geometric consistency filter. Step 2.5 runs vectorised RANSAC over the surviving matches (1,000 candidate transforms estimated in one batched SVD) to find the rigid transform and reject outliers. Step 3 turns the inlier quad matches into a one-to-one neuron assignment via padded Hungarian, with a confidence score per pair and per chained track.
S2C operates on centroid-based neuron representations. One .npy file per session, all in a single input directory. The pipeline accepts two formats: a Python dict (recommended) or a raw 3D footprint matrix (the A matrix from CNMF / Suite2p / EXTRACT). Footprints, traces, and other ROI metadata are allowed alongside the centroids, but the pipeline ignores them.
Filenames must match the pattern {animal_id}_{session_id}{optional_suffix}.npy. The default regex is:
where animal_id is alphanumeric plus underscores (non-greedy), session_id is one or more digits, and the optional suffix is anything else before .npy. The suffix is preserved as part of the session name and is available to the Step 1.5 group regex for FOV grouping.
| Filename | Match? | Animal | Session |
|---|---|---|---|
408021_758519303.npy | ✓ | 408021 | 758519303 |
mouse_a_3__fov1.npy | ✓ | mouse_a | 3 |
Mouse42_3.npy | ✓ | Mouse42 | 3 |
mouse1_session1.npy | ✗ | session id is not all digits | |
408021-758519303.npy | ✗ | wrong separator (hyphen not underscore) | |
Heads-up: if your animal id ends in a digit (e.g. mouse5_3.npy), the non-greedy +? can split at the wrong underscore. Prefer animal ids that end in a letter or underscore.
A Python dict saved with np.save(..., allow_pickle=True).
centroids_x — array/list of N floats (X coords, pixels)centroids_y — array/list of N floats (Y coords, pixels)roi_ids — N unique IDs; defaults to np.arange(N) if absentsubject_id, session_id, footprints, traces, timestamps. Allowed but unread; animal and session names come from the filename, not the dict.A 3D numpy array of shape (H, W, N) — the spatial footprints for N neurons. The pipeline auto-extracts centroids and assigns sequential roi_ids via np.arange(N). Use this only if you already have an A matrix; the dict format is smaller and avoids re-running centroid extraction.
len(centroids_x) == len(centroids_y)len(roi_ids) == len(centroids_x) if providedEach step writes a .npz file (or several) into a step-specific subdirectory of the output root. The most useful files for downstream analysis are the Step 3 sweep files (per-pair match results with confidence) and the consolidated tracking file (global neuron identities across sessions).
step_1_results/{session_name}_centroids_quads.npzcentroids — (N, 2) stacked as (y, x)neuron_ids — from input roi_ids or auto-generatedquad_desc — (Q, 4) the ℝ⁴ descriptorsquad_idx — (Q, 4) vertex indices into centroidsn_neurons, n_quads, generation_time, generation_method = "diagonal_first"step_2_results/{pair_name}_matches_light.npzmatch_indices — (M, 8) int32, concatenated [ref_quad_verts, tgt_quad_verts]distances — (M,) float32, descriptor distance for each matchref_centroids, target_centroids, n_ref_neurons, n_target_neuronsthreshold_used, n_raw_matches, n_filtered_matchesstep_2_5_results/{pair_name}_filtered_matches.npzmatch_indices, ref_centroids, tgt_centroids — inliers onlytransform_matrix — (2, 2) refined rigid transformtransform_translation — (2,)max_residual_threshold — persisted from Step 2.5 config; consumed by Step 3step_3_results/{pair_name}_sweep.npzPer-pair match results. (Note: the "sweep" naming is legacy — V3.1 produces a single best assignment, not a true threshold sweep. match_counts and match_rates are constant arrays kept for backward compatibility.)
matched_ref_indices, matched_tgt_indices — the assignmentmatched_costs — (M,) float32, combined Hungarian costmatch_confidence — (M,) float32, [0, 1] score; the right field to threshold onmatch_pass — (M,) int32, 1 = pass-1 Hungarian, 2 = pass-2 recoveryref_centroids, tgt_centroids, n_inlier_quads, etc.step_3_results/{animal_id}_consolidated_tracking.npzGlobal neuron identities for one animal, chained transitively across all its sessions. neuron_tracks is a Python dict — load with allow_pickle=True and use .item().
sessions — canonical session orderneuron_tracks — dict[global_id, dict[session_idx, local_idx]]track_lengths — sessions each global neuron appears intrack_confidence — minimum link confidence (weakest-link reliability)track_mean_confidence — mean link confidencen_total_tracks, full_length_tracks, avg_track_length, max_track_lengthmatch_confidence, not raw cost. Cost values depend on whether quad voting was enabled and which dummy regime ran, so they aren't comparable across runs. match_confidence is normalised to [0, 1] from four signals (vote, distance, assignment margin, pass), making it the natural threshold for downstream analyses.
Calcium imaging data is not uniform. A 100-neuron lateral habenula recording from a head-fixed mouse and a 900-neuron cortical recording from a freely-moving rat produce fundamentally different geometric landscapes. Every parameter exposed below exists because the obvious automated default failed silently somewhere. The cost of a wrong default a user never questions is far higher than the cost of asking you to think for ten minutes.
For each knob below, three states: too low, just right, too high. The middle column is the default and what most datasets want.
Debug from Step 1. Bad inputs propagate forward; no amount of Step 3 tuning fixes bad quads.
Step 1 enumerates diagonals (KNN local + random long-range), computes perpendicular heights for all other neurons per diagonal, keeps the top-K by height, and pairs those into quads. No triangle enumeration, no C(N,3) blow-up. Each quad is emitted exactly once via an ownership guard — only from the diagonal that is its longest edge.
session_filename_regex — see Inputs for the default and how to extend it.diagonal_rng_seed — default 42. Controls long-range diagonal sampling. The KNN local set is fully deterministic. Change essentially never; if changing it produces different results, your quad count is too low (more coverage needed) and a different seed isn't the answer.The most commonly misunderstood step. The "quality" metric here is not a measure of how good your neuron matches are. It is a measure of how well the descriptor distance threshold separates signal from noise in descriptor space. The pipeline sweeps τ ∈ [0, 1], finds the per-pair quality peak, then fits the scaling law:
across all session pairs of one animal, yielding the per-animal constant C, its standard deviation C_std, and goodness of fit R². The √N form is justified because the typical nearest-neighbour distance in descriptor space scales as Q⁻¹ᐟ⁴ ~ N⁻¹ᐟ², so τ must track that to maintain a constant signal-to-noise ratio.
target_quality to 0.85 or 0.90 to push τ tighter. Counter-intuitive, but a lower target produces a tighter threshold because it commits to τ at an earlier, more conservative point on the quality curve.
C_std blows up. The √N law can't be fit reliably. Threshold ends up animal-specific in name only.s2c_api.py, not the GUI. If max_pairs_per_animal is set, large groups are randomly subsampled (seed = 42) to fit the cap. Use when all_vs_all is computationally infeasible.threshold_min / threshold_max / n_threshold_points — defaults 0.0 / 1.0 / 50. Widen threshold_max if calibration consistently hits the upper bound. Narrow the range if you know the answer is in a small window and want finer resolution.session_group_regex — default r'__(.+)$'. Extracts an FOV group key from session names. Sessions with the same key are paired. Change if your naming convention doesn't use __ as the group separator.distance_metric — default 'cosine'. Cosine is invariant to descriptor magnitude and slightly more robust. 'euclidean' is fine if your centroid coordinate frame is consistent across sessions.Step 2 runs FAISS top-1 nearest-neighbour search on L2-normalised descriptors (cosine via IndexFlatIP), keeps matches with descriptor distance below the per-animal calibrated τ, and applies a geometric consistency filter. The result is a set of quad-level correspondences for each session pair — still containing chance collisions that Step 2.5 will reject.
threshold — default None (use Step 1.5's calibrated τ). Set manually only for debugging or when you have strong reason to believe Step 1.5 produced the wrong value (e.g. R² < 0.5). The right fix is usually re-running Step 1.5 with adjusted parameters.RANSAC estimates the best rigid transform between each session pair from the Step 2 matches. 1,000 candidate transforms are drawn (m = 4 point pairs each), all estimated simultaneously via batched SVD on the cross-covariance matrices, residuals evaluated in float32 chunks sized to ~200 MB working memory, and the best transform is re-estimated on its full inlier set in float64 for precision. The max_residual_threshold used here is persisted into the output NPZ and consumed by Step 3 to set its distance cutoffs.
dist_cutoff_multiplier and postfilter_residual_multiplier.ransac_allow_scaling — default False (rigid). Set True for similarity transforms (rigid + uniform scale). Useful only when you've changed objective or zoom between sessions.ransac_max_rotation_deg — default None. Optional rejection of transforms with rotation magnitude greater than this. Set to ~5° for chronic head-fixed recordings, 30° for moderate scope repositioning, leave None for freely-moving setups.ransac_max_translation_px — default None. Optional rejection of transforms with translation magnitude greater than this. For 512×512 chronic windows, > 100 px between sessions is usually spurious.Step 3 turns the inlier quad-level correspondences from Step 2.5 into a one-to-one neuron mapping. Each surviving inlier quad votes for its 16 implied neuron pairings (4 ref × 4 tgt). Vote counts are degree-normalised, combined with spatial residual under the inferred transform, padded with rectangular dummy slots so any neuron can opt out of matching, and solved via scipy.optimize.linear_sum_assignment. Pass-1 matches with too much spatial residual are rejected; pass-2 always runs on the remaining unmatched neurons under a relaxed cutoff. Every surviving match gets a confidence score in [0, 1].
match_confidence, not raw cost. The score combines four equally-weighted signals — degree-normalised votes, spatial proximity, assignment margin (1st vs 2nd best cost), and pass origin (1.0 for pass-1, 0.5 for pass-2). It's normalised to [0, 1] and comparable across runs in a way that costs aren't.
√(degree_i · degree_j) normalisation prevents hub neurons from dominating. Strictly more information than distance alone. When to set False: debugging, or when very few inlier quads survive Step 2.5 and the voting signal is unreliable.dist_cutoff_multiplier × ransac_max_residual are assigned infinite cost.ransac_max_residual are removed after the first Hungarian solve, sent to pass-2 for re-evaluation.use_asymmetric_dummy_costs — default False. With True, dummy costs scale by per-neuron proximity to a potential partner (FOV-edge neurons can opt out more easily). Enable when you see false matches concentrated at FOV boundaries.block_zero_vote_pairs — default False. With True, neurons with zero quad votes between them get infinite cost regardless of distance. Higher precision; risks missing neurons with poor quad coverage. Leave off unless you have very high confidence in your coverage.hungarian_cost_min / max / steps, target_match_rate — accepted by the Step 3 entry point for back-compat but ignored in V3.1.Targets Python 3.10+. Conda is recommended for the numerics stack so numpy, scipy, and faiss-cpu come from conda-forge with consistent BLAS bindings.
No example data ships with the repo; the GUI and pipeline are intended to be run on your own session exports.
When results don't look right, work through these in order. Step 1 problems propagate forward; Step 3 tuning won't fix bad quads.
| Symptom | Likely cause & fix |
|---|---|
| No sessions found in Step 1 | session_filename_regex doesn't match your filenames. Check the logs — every failed match is logged with NO MATCH:. |
| Quad counts are zero or very low | Files aren't in either expected format. Should be a dict with centroids_x / centroids_y, or a 3D A matrix. Logs say Unrecognised file format. |
| Quad counts exceed the saturation cap | Reduce max_triangles_per_diagonal or quad_keep_fraction. The GUI estimate panel suggests specific values. |
| Step 1.5 C ≈ 0 or R² very low | Either too few session pairs (need ≥ 3–4), or the threshold sweep range doesn't cover the optimum. Widen threshold_min/max. Check that session_group_regex is grouping FOV-matched sessions correctly. |
| Step 2 match rates > 95% | Threshold too loose. Lower target_quality (e.g. 0.85). The most common calibration error for high-N recordings. |
| Step 2 match rates near zero | Threshold too tight, or sessions don't share neurons (different FOVs grouped together). Verify your group regex. |
| Step 2.5 inlier ratio < 5% | Most quad matches are false. Step 2 threshold issue — go back and tighten. Or increase ransac_iterations if you believe the signal is there. |
| Step 3 match rates low despite good RANSAC inlier ratios | Cost matrix too restrictive. Increase dist_cutoff_multiplier or enable use_asymmetric_dummy_costs. Check the cost distribution — if mostly infinite, the cutoff is too tight. |
| Step 3 produces obvious false matches | Look at the match_confidence distribution first — false matches usually cluster at low confidence. Then: enable block_zero_vote_pairs, reduce pass2_dummy_percentile, tighten postfilter_residual_multiplier. If concentrated at FOV edges, enable use_asymmetric_dummy_costs. If mostly match_pass == 2, reduce pass2_cutoff_multiplier. |
| Tracking shows neurons "appearing" that should have been tracked | Pass-2 not reaching far enough. Increase pass2_cutoff_multiplier or pass2_dummy_percentile. |
A CITATION.cff file in the repository gives GitHub a
"Cite this repository" button and standardises how downstream tools
format references to S2C.
If you use Stars2Cells, please cite the preprint:
Peden-Asarch AM, Honan LE, Bai JZ, Asarch EM, Quinn JA, Coffey KR, Neumaier JF. Stars2Cells: Astrometric Tracking of Neurons Across Imaging Sessions. bioRxiv, 2026. doi:10.64898/2026.07.03.736144