Regions¶
Use named regions to mark reward zones, maze arms or other areas relevant to an analysis. A region is an analysis label; it does not change the arena, bin geometry or physical walls. Regions may extend outside the arena.
Build regions before publishing them¶
Regions is a mutable builder. An environment stores an immutable snapshot, so
editing the builder later cannot change an encoder or decoder using that
environment.
import numpy as np
from shapely.geometry import Point, Polygon
from neurospatial import Environment, Regions
regions = Regions()
regions.add("start", point=(5.0, 5.0))
regions.add("reward", polygon=Point(25.0, 25.0).buffer(5.0))
regions.add("arm", polygon=Polygon([(0, 0), (10, 0), (10, 30), (0, 30)]))
rng = np.random.default_rng(0)
positions = rng.uniform(0.0, 40.0, size=(200, 2))
env = Environment.from_samples(
positions, bin_size=5.0, units="cm", frame="arena", regions=regions
)
Point coordinates have shape (n_dims,). Polygon regions use a Shapely
Polygon and require a 2D environment. Region coordinates must use the same
units and frame as the environment.
A region is checked when it is built. Coordinates must be finite, and a
polygon must enclose a positive area without crossing itself. A hand-drawn
outline that loops over itself (a "bowtie") is refused with the reason and a
suggested repair (shapely.make_valid, or redrawing it), because the bins it
would cover are not well defined. Metadata must be standard JSON: string keys,
finite numbers, and None for a missing value. It is stored read-only.
Query the published snapshot¶
env.regions is a read-only mapping from names to regions, in the order they
were added:
from neurospatial.regions import regions_table
names = list(env.regions) # ["start", "reward", "arm"]
reward = env.regions["reward"]
area = reward.area # In coordinate units squared
center = reward.center # Centroid, shape (2,)
reward_bins = env.bins_in_region("reward")
table = regions_table(env.regions) # One row per region: kind, area, center
A bin belongs to a polygon region when its center lies inside the polygon, and
to a point region when the point lies inside the bin. A center exactly on a
polygon's edge belongs to it when nudging the center a hair north-east would
move it inside. Regions that share an edge, such as the four quadrants of an
open field or the arms of a maze cut along a line, therefore split the bins on
that edge: every bin is in exactly one of them, so time in each quadrant adds up
to the session. A rectangle holds the centers on its south and west edges but
not those on its north and east edges, as a histogram bin [a, b) holds a.
The same rule decides positions in regions_containing_points and
points_in_any_region, and the rim of a point region's point_radius disc.
Those functions know no environment, so where the nudge north-east would
leave every polygon region passed, as on an arena's north or east wall, a
position is nudged south-west instead. Tracking clipped to the walls of an
arena tiled by quadrants keeps every wall sample in one quadrant, and a
position on the edge of a lone box is in the box.
A polygon's center is
its centroid, which can lie outside a non-convex region such as a U-shaped
arm, so it is not necessarily a place the animal can be.
Point coordinates are read-only. Use region.data.copy() when you need an
editable array; editing that copy does not update the region.
Test raw positions against regions¶
env.bins_in_region and env.region_membership answer which bins a region
covers. To ask which tracked positions fall inside a region without binning
them, use regions_containing_points or points_in_any_region:
from neurospatial.regions import points_in_any_region, regions_containing_points
membership = regions_containing_points(
positions, env.regions, region_names=["reward", "arm"]
) # DataFrame: one bool column per region, one row per position
fraction_in_reward = membership["reward"].mean()
Positions must be finite. A tracking dropout is not a sample outside every
region, so NaN rows are refused; drop or interpolate them first, for example
with positions[np.isfinite(positions).all(axis=1)].
A point region has no area, so testing positions against it needs a distance
you choose, in the regions' units. Pass point_radius=, for example the radius
of a reward well:
Regions drawn on a video frame¶
Regions drawn in LabelMe or CVAT, or traced from a binary mask, start in image
pixels: x is the column and y the row, increasing downward, with pixel
(x, y) centred on (x, y) as in a video frame array (both tools store
pixel corners; the readers shift their shapes by half a pixel). The
video annotation guide shows the calibrated path,
regions_from_labelme(..., calibration=...) and
regions_from_cvat(..., calibration=...), which converts pixels to
centimeters and flips the y axis. The lower-level readers take any
pixel_to_world transform:
import json
from pathlib import Path
from neurospatial.ops.transforms import simple_scale
from neurospatial.regions import load_labelme_json, plot_regions
Path("arena.json").write_text(
json.dumps(
{
"imagePath": "frame_0001.png",
"shapes": [
{
"label": "reward",
"shape_type": "rectangle",
"points": [[200.0, 200.0], [300.0, 260.0]],
}
],
}
)
)
drawn = load_labelme_json("arena.json", pixel_to_world=simple_scale(px_per_cm=10.0))
assert drawn["reward"].area == 60.0 # cm^2: 10 cm by 6 cm
ax = plot_regions(drawn) # Check the outline before using it
LabelMe polygons, rectangles, circles and points become regions. An open line
is refused because it encloses no area; store it as a
neurospatial.Landmark(name, "line", points) instead. A label used more than
once is numbered label_0, label_1, ... in file order. A CVAT export with
several annotated images needs image= to pick one.
A region loaded without a transform records metadata["coord_system"] =
"pixels". An environment in centimeters refuses it and says how to convert
it, so pixel coordinates cannot silently cover the wrong bins.
Regions on an egocentric polar environment¶
An environment from Environment.from_polar_egocentric takes positions as
(distance, angle) rows, angles in radians (0 = ahead, +π/2 = left). Regions
use the same coordinates: a polygon's x is distance and its y is angle, so a
box is a wedge of a distance band.
Angles are compared modulo a full turn, as for positions. A polygon covers a bin when the bin's center angle, plus any whole number of turns, lies inside it, so a wedge behind the animal that crosses the ±π seam is one polygon from 3π/4 to 5π/4. A point region is located like any position, with its angle wrapped into the range.
from shapely.geometry import box
ego = Environment.from_polar_egocentric(
distance_range=(0.0, 20.0),
angle_range=(-np.pi, np.pi),
distance_bin_size=10.0,
angle_bin_size=np.pi / 4,
)
behind = Regions()
behind.add("behind", polygon=box(0.0, 3 * np.pi / 4, 20.0, 5 * np.pi / 4))
ego = ego.with_regions(behind)
behind_mask = ego.region_mask("behind") # 4 bins, on both sides of the seam
Replace regions on an existing environment¶
Start from the current values when you want to retain other names. Build all edits, then publish once:
edited = Regions(env.regions.values())
edited.update_region("start", point=(8.0, 5.0))
edited.update_region("reward", metadata={"reward": "milk"}) # Geometry kept
edited.remove("arm")
edited.buffer("start", distance=3.0, new_name="start_zone")
updated = env.with_regions(edited)
assert list(updated.regions) == ["start", "reward", "start_zone"]
assert "arm" in env.regions
with_regions replaces the whole collection and returns a new environment.
It preserves the bin geometry and labels, and takes any mapping from names to
regions. Pass Regions() to clear the collection. env.regions cannot be
edited: env.regions.add(...) raises an error, and a type checker flags it.
Keep the returned environment for subsequent analysis.
The builder's add and mapping assignment reject duplicate names. Use set
to replace a named Region, or update_region to change an existing region's
geometry, its metadata, or both. The region keeps its place in the order, so
the columns of env.region_membership() stay put.
Save and load regions¶
from neurospatial.regions import regions_from_json, regions_to_json
regions_to_json(env.regions, "regions.json")
restored = regions_from_json("regions.json")
assert list(restored) == list(env.regions)
The file is standard JSON tagged with its format version; a file without that tag is refused rather than guessed at. Environments saved to NWB keep their regions as well.
See the core concepts and API reference for related operations.