from __future__ import annotations
from collections.abc import Sequence
from typing import Literal, Self, cast
import attrs
import numpy as np
from matplotlib.patches import Polygon
from neuralib.util.unstable import unstable
__all__ = ['get_field_of_view',
'get_cellular_coordinate',
'FieldOfView',
'CellularCoordinates']
UNIT = Literal['mm', 'um']
[docs]
def get_field_of_view(am: Sequence[float],
pm: Sequence[float],
pl: Sequence[float],
al: Sequence[float],
*,
rotation_ml: float = 0,
rotation_ap: float = 0,
unit: UNIT = 'mm',
perpendicular: bool = True,
region_name: str | None = None) -> FieldOfView:
"""
Construct a :class:`~FieldOfView` from four corner coordinates.
:param am: anteromedial corner coordinate [x, y] in mm or µm.
:param pm: posteromedial corner coordinate [x, y] in mm or µm.
:param pl: posterolateral corner coordinate [x, y] in mm or µm.
:param al: anterolateral corner coordinate [x, y] in mm or µm.
:param rotation_ml: in-plane rotation (CCW) around the ML axis, in degrees.
:param rotation_ap: tilt around the AP axis (foreshortening), in degrees.
:param unit: unit of the corners value
:param perpendicular: imaging objective is perpendicular to the cranial windows. if True, skip rotation and tilt transforms.
:param region_name: optional identifier for this FOV region.
:returns: FieldOfView instance with corners stacked and optionally transformed.
"""
corners = np.vstack([am, pm, pl, al])
return FieldOfView(corners, rotation_ml, rotation_ap, unit,
perpendicular=perpendicular,
region_name=region_name)
def _validator_corners(instance, attribute, value: np.ndarray):
if not isinstance(value, np.ndarray):
raise TypeError(f'{attribute} should be a numpy array')
if value.shape != (4, 2):
raise ValueError(f'corners must have shape (4, 2), got {value.shape}')
@unstable()
def _rotate_and_tilt(pts: np.ndarray, ml: float, ap: float) -> np.ndarray:
"""telecentric / orthographic imaging transformation"""
pivot = pts.mean(axis=0)
theta_x = np.deg2rad(ml)
c, s = np.cos(theta_x), np.sin(theta_x)
R = np.array([[c, -s], [s, c]])
pts0 = (pts - pivot) @ R.T + pivot
theta_y = np.deg2rad(ap)
scale_x = np.cos(theta_y)
pts1 = (pts0 - pivot) * np.array([scale_x, 1.0]) + pivot
return pts1
[docs]
@attrs.frozen
class FieldOfView:
"""Field‑Of‑View defined by its four XY corners"""
corners: np.ndarray = attrs.field(validator=_validator_corners)
"""corner coordinates in order [AM, PM, PL, AL]. `Array[float, [4, 2]]`"""
rotation_ml: float = attrs.field(default=0.0)
"""in-plane rotation (CCW) around the ML axis, in degrees"""
rotation_ap: float = attrs.field(default=0.0)
"""tilt around the AP axis (foreshortening), in degrees"""
unit: UNIT = attrs.field(default='mm', validator=attrs.validators.in_(['mm', 'um']))
"""unit of the corners value"""
perpendicular: bool = attrs.field(default=True, kw_only=True)
"""imaging objective is perpendicular to the cranial windows. if True, skip rotation and tilt transforms"""
region_name: str | None = attrs.field(default=None, kw_only=True)
"""optional identifier for this FOV region"""
def __attrs_post_init__(self):
if not self.perpendicular:
new_corners = _rotate_and_tilt(
self.corners,
self.rotation_ml,
self.rotation_ap,
)
object.__setattr__(self, 'corners', new_corners)
@property
def am(self) -> np.ndarray:
"""anteromedial corner coordinate [x, y] in mm or µm."""
return self.corners[0]
@property
def pm(self) -> np.ndarray:
"""posteromedial corner coordinate [x, y] in mm or µm"""
return self.corners[1]
@property
def pl(self) -> np.ndarray:
"""posterolateral corner coordinate [x, y] in mm or µm"""
return self.corners[2]
@property
def al(self) -> np.ndarray:
"""anterolateral corner coordinate [x, y] in mm or µm"""
return self.corners[3]
@property
def ap_distance(self) -> float:
"""span along the AP axis"""
return float(np.ptp(self.corners[:, 1]))
@property
def ml_distance(self) -> float:
"""span along the ML axis"""
return float(np.ptp(self.corners[:, 0]))
[docs]
def invert_axes(self, ap: bool = True, ml: bool = True) -> Self:
"""Return a new FOV with specified axes inverted
:param ap: invert anterior-posterior (Y) axis if True.
:param ml: invert medial-lateral (X) axis if True.
"""
factors = np.array([-1. if ml else 1., -1. if ap else 1.])
return attrs.evolve(self, corners=self.corners * factors)
[docs]
def to_um(self) -> Self:
if self.unit == 'um':
raise RuntimeError('unit already in um')
return attrs.evolve(self, corners=self.corners * 1000, unit='um')
[docs]
def to_polygon(self, **kwargs) -> Polygon:
"""convert corners to a matplotlib Polygon patch"""
idx = [0, 1, 3, 2]
reorder = self.corners[idx]
return Polygon(reorder, closed=True, edgecolor='r', facecolor='none', **kwargs)
[docs]
def get_cellular_coordinate(neuron_idx: np.ndarray,
ap: np.ndarray,
ml: np.ndarray, *,
unit: UNIT = 'mm',
plane_index: np.ndarray | None = None) -> CellularCoordinates:
"""
Get cellular coordinates container for doing brain mapping / topographical analysis
:param neuron_idx: neuron index. `Array[float, N]`
:param ap: anterior posterior coordinates. `Array[float, N]`
:param ml: medial lateral coordinates. `Array[float, N]`
:param unit: unit of the ap/ml value. default in `mm`
:param plane_index: neuron's corresponding image plane. `Array[float, N]`. If None then full_zero
:return: :class:`~CellularCoordinates`
"""
return CellularCoordinates(neuron_idx, ap, ml, unit=unit, plane_index=plane_index)
def _validator_shape(instance: CellularCoordinates, attribute, value: np.ndarray | None):
if value is None:
return
else:
assert instance.neuron_idx.shape == value.shape
[docs]
@attrs.define
class CellularCoordinates:
"""Cellular Coordinates container"""
neuron_idx: np.ndarray
"""neuron index. `Array[float, N]`"""
ap: np.ndarray
"""anterior posterior coordinates (default in mm). `Array[float, N]`"""
ml: np.ndarray
"""medial lateral coordinates (default in mm). `Array[float, N]`"""
unit: UNIT = attrs.field(default='mm', validator=attrs.validators.in_(['mm', 'um']))
"""unit of the ap/ml value"""
plane_index: np.ndarray | None = attrs.field(default=None, validator=_validator_shape)
"""neuron's corresponding image plane. `Array[float, N]`"""
value: np.ndarray | None = attrs.field(default=None, validator=_validator_shape)
"""metric (i.e., used in topographical analysis). `Array[float, N]`"""
def __attrs_post_init__(self):
if self.plane_index is None:
self.plane_index = np.full_like(self.neuron_idx, 0, dtype=int)
assert self.neuron_idx.shape == self.ap.shape == self.ml.shape == self.plane_index.shape
[docs]
def relative_origin(self, fov: FieldOfView,
origin: Literal['am', 'pm', 'al', 'pl'] = 'am') -> Self:
"""
coordinates relative to :class:`~FieldOfView` origin point
:param fov: :class:`~FieldOfView`
:param origin: relative origin point
:return:
"""
factor = 1000 if self.unit == 'mm' else 1
ap_um = self.ap * factor
ml_um = self.ml * factor
if fov.unit != 'um':
fov = fov.to_um()
orig_pt = getattr(fov, origin)
pts = np.vstack([ml_um, ap_um]).T
if fov.perpendicular:
delta = orig_pt - pts # posterior to origin
else:
delta_pts = orig_pt - _rotate_and_tilt(
pts,
ml=fov.rotation_ml,
ap=fov.rotation_ap,
)
delta = delta_pts
ml_new, ap_new = delta[:, 0], delta[:, 1]
return cast(Self, attrs.evolve(self, ml=ml_new, ap=ap_new, unit='um'))
[docs]
def with_value(self, value: np.ndarray) -> Self:
"""assign value foreach neuron"""
return attrs.evolve(self, value=value)
[docs]
def with_masking(self, mask: np.ndarray) -> Self:
"""do neuronal selection by bool masking
:param mask: `Array[bool, N]`
"""
plane_index = self.plane_index
if plane_index is None:
raise RuntimeError('plane index is not initialized')
return attrs.evolve(
self,
neuron_idx=self.neuron_idx[mask],
ap=self.ap[mask],
ml=self.ml[mask],
plane_index=plane_index[mask],
value=None if self.value is None else self.value[mask]
)