Alignment & Transforms¶
neurospatial provides tools for transforming and aligning spatial representations between 2D and 3D environments.
2D Affine Transformations¶
Every transform is an AffineND; for 2D environments, build one with the
factory functions:
import numpy as np
from neurospatial.ops import scale_2d, translate
# Factory functions (composable)
T = translate(10, 20)
S = scale_2d(1.5, 1.5)
# Compose transformations
transform = T @ S
# Apply to points
points_2d = np.array([[0.0, 0.0], [1.0, 2.0]])
transformed_points = transform(points_2d)
3D Affine Transformations¶
For 3D environments, use AffineND:
import numpy as np
from neurospatial.ops import from_rotation_matrix, scale_3d, translate_3d
from scipy.spatial.transform import Rotation
# 3D translation
T = translate_3d(10, 20, 30)
# 3D scaling (uniform or anisotropic)
S_uniform = scale_3d(1.5) # Scale all axes equally
S_aniso = scale_3d(1.5, 2.0, 0.8) # Different scale per axis
# 3D rotation using scipy
R = Rotation.from_euler('z', 45, degrees=True).as_matrix()
Rot = from_rotation_matrix(R)
# Rotation with translation
transform = from_rotation_matrix(R, translation=[10, 20, 30])
# Compose transformations
combined = T @ Rot @ S_uniform
# Apply to 3D points
points_3d = np.array([[0.0, 0.0, 0.0], [1.0, 2.0, 3.0]])
transformed_points = combined(points_3d)
Transform Estimation from Point Correspondences¶
Estimate transformations from matching point pairs (works for 2D or 3D):
import numpy as np
from neurospatial.ops import estimate_transform
# Given source and destination point correspondences
src_points = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]])
dst_points = np.array([[5, 5, 5], [6, 5, 5], [5, 6, 5], [5, 5, 6]])
# Estimate rigid transformation (rotation + translation)
T_rigid = estimate_transform(src_points, dst_points, kind="rigid")
# Estimate similarity transformation (rotation + translation + uniform scaling)
T_sim = estimate_transform(src_points, dst_points, kind="similarity")
# Estimate full affine transformation
T_affine = estimate_transform(src_points, dst_points, kind="affine")
# Apply to new points
new_points = np.array([[2, 2, 2], [3, 3, 3]])
aligned_points = T_rigid(new_points)
Transforming Environments¶
env.apply_transform(transform) returns an environment in the new coordinates.
It moves cells, the arena, physical walls and regions together. Bin IDs and the
order of neighbor pairs stay the same, including after reflection. Supply query
positions in the new world coordinates. The name is kept. Name the new
coordinate frame with frame= (for example frame="room"); without it the
frame is None, because the coordinates are no longer in the source frame.
A transform that keeps lengths (a translation, rotation or reflection) keeps
the units. A transform that scales or shears lengths needs units= when the
environment has units: a pixel-to-centimeter calibration takes units="cm",
and an arena stretched in the same units takes units=env.units. The package
cannot tell the two apart, so it asks instead of keeping a unit that may now be
wrong.
A grid keeps its original coordinate axes internally. Interpolation and linear movement allocation use those axes; plots transform the cell corners into world coordinates. This lets a rotation preserve interpolation and draw the actual cells instead of replacing them with dots.
Start with an image mask¶
This simulated example starts with an image mask whose center pixel is excluded.
A mask defines the domain included in the analysis; it does not establish where
physical walls exist. pixel_size gives the length of each pixel in the selected
units. Estimate a map from corresponding landmarks, transform the environment
and tracking positions together, then use the usual field and encoding APIs.
import matplotlib.pyplot as plt
import numpy as np
from neurospatial import Environment, compute_spatial_rates
from neurospatial.ops import estimate_transform
from neurospatial.ops.transforms import AffineND
mask = np.ones((3, 3), dtype=bool)
mask[1, 1] = False
source = Environment.from_pixel_mask(mask, pixel_size=1.0, units="cm")
assert source.arena.outline is None
assert not source.arena.walls
landmarks = np.array([[0., 0.], [3., 0.], [0., 3.], [3., 3.]])
known_map = AffineND(np.array([[.8, -.6, 10.], [.6, .8, 20.], [0., 0., 1.]]))
transform = estimate_transform(landmarks, known_map(landmarks), kind="affine")
room = source.apply_transform(transform, frame="room")
np.testing.assert_array_equal(room.bin_at(transform(source.bin_centers)), np.arange(source.n_bins))
# Values indexed by bin ID keep their order. Queries use room coordinates.
field = source.bin_centers @ np.array([2., 3.])
queries = np.array([[.5, 1.1], [.5, 1.75]])
np.testing.assert_allclose(
room.interpolate(field, transform(queries), mode="linear"),
source.interpolate(field, queries, mode="linear"),
)
ax = room.plot_field(field, colorbar=False)
plt.close(ax.figure)
# Straight movement keeps the same time fractions after an affine map.
times = np.array([0., 2.5])
positions = np.array([[.25, .5], [2.75, .5]])
spikes = [np.array([.25, .75, 1.25, 1.75, 2.25])]
options = {
"method": "binned", "bandwidth": 0,
"time_allocation": "linear", "max_gap": None, "warn_on_drop": False,
}
source_rates = compute_spatial_rates(source, spikes, times, positions, **options)
room_rates = compute_spatial_rates(room, spikes, times, transform(positions), **options)
np.testing.assert_allclose(room_rates.occupancy, source_rates.occupancy)
np.testing.assert_allclose(room_rates.firing_rates, source_rates.firing_rates, equal_nan=True)
restored = Environment.from_dict(room.to_dict())
np.testing.assert_allclose(restored.interpolate(field, transform(queries), mode="linear"), [4.3, 6.25])
compute_spatial_rate and compute_spatial_rates use the same retained
movement intervals for spike counts and dwell time when
time_allocation="linear". The example disables smoothing to compare the
allocation directly. Smoothing requires valid numerical face geometry.
Spike encoding with time_allocation="linear" requires planar 2-D cells. 3-D
grids support linear occupancy, but for 3-D spike encoding use
time_allocation="start".
Start with position samples¶
If only tracking positions are available, from_samples can infer which bins
were visited. That estimated domain also asserts no physical walls. You can
transform it with the same API; add measured arena geometry separately when it
is available.
import numpy as np
from neurospatial import Environment
from neurospatial.ops.transforms import AffineND
x = np.linspace(0., 10., 101)
positions = np.column_stack((x, 2. + np.sin(x)))
source = Environment.from_samples(positions, bin_size=.5, units="cm")
reflection = AffineND(np.diag([1., -1., 1.]))
aligned = source.apply_transform(reflection, frame="room")
assert aligned.arena.outline is None
assert not aligned.arena.walls
np.testing.assert_array_equal(aligned.bin_at(reflection(source.bin_centers)), np.arange(source.n_bins))
Geometry and numerical capabilities¶
Exact cell geometry and a valid diffusion calculation are separate capabilities. The current diffusion solver needs each center-to-center link to be perpendicular to its shared physical face. A shear generally breaks that condition, even though lookup, plotting and dwell allocation remain well defined.
| Current cells / transform | Linear field interpolation | Exact 2-D field plot | Smoothing and differential operators |
|---|---|---|---|
| Cartesian grid, translation or positive axis scaling | Yes | QuadMesh | Retained |
| Cartesian 2-D/3-D grid, rotation or reflection | Yes, in source axes | QuadMesh | Retained |
| Grid, unequal scaling along retained orthogonal axes | Yes | QuadMesh | Enabled when normals and flux distances certify the geometry |
| Grid, shear | Yes | QuadMesh | Refused if shared faces lose orthogonality |
| Whole-bin subset of a transformed grid | Yes; missing interpolation corners stay NaN | QuadMesh with the retained mask | Retains the parent's available geometry |
| Partial polygon clipping | No tensor grid; use nearest mode | Exact polygon cells | Only when the clipping certificate remains valid |
| Transformed triangles or deformed hexagons | No tensor grid; use nearest mode | Exact vertices/polygon cells | Existing similarity support; other maps require a valid face certificate |
| Embedded track | Use existing track operations | Existing track renderer | Similarity transforms only |
Landmarks measured from video carry tracking noise. A general affine fit
(kind="affine") absorbs that noise as a small stretch or shear, so smoothing on
the transformed environment refuses and reports the measured stretch. When the
arena's shape is known, fit kind="similarity" instead (or kind="rigid" when
both coordinate systems use the same length units). The fitted map keeps right
angles, so smoothing with the bandwidth multiplied by the fitted scale matches
smoothing in the source coordinates.
import numpy as np
from neurospatial import Environment
from neurospatial.ops import estimate_transform
rng = np.random.default_rng(0)
camera = Environment.from_samples(rng.uniform(0, 100, (5000, 2)), bin_size=5.0, units="px")
corners_px = np.array([[0., 0.], [100., 0.], [0., 100.], [100., 100.]])
detected_px = corners_px + rng.normal(0, 0.5, corners_px.shape) # 0.5 px tracking noise
corners_cm = np.array([[0., 0.], [50., 0.], [0., 50.], [50., 50.]])
to_cm = estimate_transform(detected_px, corners_cm, kind="similarity")
room = camera.apply_transform(to_cm, units="cm")
cm_per_px = np.sqrt(abs(np.linalg.det(to_cm.A[:2, :2])))
field = rng.random(camera.n_bins)
np.testing.assert_allclose(
room.smooth(field, bandwidth=5.0, mode="average"),
camera.smooth(field, bandwidth=5.0 / cm_per_px, mode="average"),
)
New face certification checks normalized orthogonality residuals against 1e-12.
Legacy records without stored face normals keep their existing guaranteed
transforms, but cannot gain new numerical support from inferred directions.
Once a transform discards numerical faces, applying its inverse does not restore
them. Rebuild from the original environment to retain that authority.
Floating-point projection can land just across a source center or edge plane. Interpolation and linear allocation recover a plane only within a calculated roundoff bound. The bound must remain below one quarter of the smallest source cell width on that axis; otherwise the operation asks you to recenter or rescale coordinates. Resolved missing support remains missing.
Matplotlib plots and video, HTML and widget animations draw transformed grid
corners. Napari's current raster/coordinate helpers explicitly refuse these
framed grids. Raster FFT/grid metrics and 2-D circular grid shuffling also keep
their world-aligned-grid restriction; saved source axes are not exposed as world
grid_edges or a world-aligned grid_shape.
rebin(bin_size=...) uses its separate construction specification and physical
axis lengths. It creates new bins over the retained arena; it does not reuse
current bin IDs or transfer fitted values. Partial clipping drops the current
tensor-grid component even when a construction specification remains available.
Transforms update geometry and cell measures, while bin-aligned arrays keep
their indexing. They do not refit models or rescale firing-rate values. A matrix
does not supply a unit label: use with_metadata(units=..., frame=...) when the
coordinate conversion changes those labels. Dict/file/NWB round trips retain
source frames and normals. New files use nested binning codec version 3; current
readers accept versions 1 and 2, while older readers reject the new nested
version. The outer Environment-v3 and NWB 3.0 envelopes are unchanged.
Probability Alignment¶
Map probability distributions between environments (works for 2D or 3D).
map_probabilities moves probability mass: each source bin's mass goes to its
nearest target bin and the masses are summed, so the total is kept. Use it for
posteriors and occupancy probabilities, not for rate maps. To move a rate map
onto other bins, use resample_field, which copies each rate.
Pass source_transform= to first move the source bin centers into the
target's coordinates. It is one AffineND: fit it from landmarks with
estimate_transform(source_landmarks, target_landmarks), or compose the
factories, such as rotate_2d(angle, center=c) for a turn about the arena
center c (a rotation without center= turns about the origin).
Source bins that hold probability but land outside the target would be piled
onto the target's edge bins. A source center is outside when it lies in no
target bin and farther than max_distance from every target bin center; by
default that limit is the target's bin_spacing, so a small unvisited hole in
the target does not count. When the bins outside hold more than
max_outside_fraction of the probability (default 1e-6) the mapping is
refused and reports their share. A smaller share, such as the far tail of a
decoded posterior, goes to its nearest target bin. Raise
max_outside_fraction to accept a known mismatch, or pass
max_distance=math.inf to accept any distance.
Here the second session recorded the same square arena turned 30 degrees about its center, and a posterior decoded in session 2 is mapped onto session 1's bins:
import numpy as np
from neurospatial import Environment
from neurospatial.ops import map_probabilities, rotate_2d
rng = np.random.default_rng(0)
session1 = rng.uniform(0, 100, (5000, 2))
# Radians, counter-clockwise, about the arena center
turn = rotate_2d(np.pi / 6, center=(50.0, 50.0))
session2 = turn(session1)
target_env = Environment.from_samples(session1, bin_size=5.0, units="cm")
source_env = Environment.from_samples(session2, bin_size=5.0, units="cm")
# A posterior peaked where session 2 puts session 1's point (20, 70)
peak = turn(np.array([[20.0, 70.0]]))
distance = np.linalg.norm(source_env.bin_centers - peak, axis=1)
posterior = np.exp(-0.5 * (distance / 5.0) ** 2)
posterior /= posterior.sum()
# Map session 2's coordinates back into session 1's
mapped = map_probabilities(
source_env, target_env, posterior, source_transform=turn.inverse()
)
assert np.isclose(mapped.sum(), 1.0)
assert np.linalg.norm(target_env.bin_centers[mapped.argmax()] - [20, 70]) < 5
For 3D environments, build the rotation with
from_rotation_matrix(scipy.spatial.transform.Rotation(...).as_matrix()), or
fit it with estimate_transform, and pass it the same way.
Complete Alignment Workflow¶
Here's a complete example aligning two 3D environments:
import numpy as np
from scipy.spatial.transform import Rotation
from neurospatial import Environment
from neurospatial.ops import estimate_transform
# Create two 3D environments
positions_session1 = np.random.randn(1000, 3) * 20
positions_session2 = positions_session1 @ Rotation.from_euler('z', 30, degrees=True).as_matrix().T + [10, 5, 2]
env1 = Environment.from_samples(positions_session1, bin_size=5.0, name="session1")
env2 = Environment.from_samples(positions_session2, bin_size=5.0, name="session2")
# Define landmark correspondences (e.g., from manual annotation)
landmarks_env1 = np.array([[0, 0, 0], [10, 0, 0], [0, 10, 0], [0, 0, 10]])
landmarks_env2 = np.array([[10, 5, 2], [18.66, 10, 2], [1.34, 13.66, 2], [10, 5, 12]])
# Estimate transformation
transform = estimate_transform(landmarks_env1, landmarks_env2, kind="rigid")
# Align env1 to env2's coordinate frame
env1_aligned = env1.apply_transform(transform, name="session1_aligned")
# Now compare neural activity, place fields, etc. in aligned coordinate frame
See the API Reference for complete documentation.