Skip to content

Python API

spintrack.track runs what spintrack run does and returns the records instead of writing files:

import spintrack

track = spintrack.track("examples/sample/config.toml")  # the run, without the files
df = track.to_polars()  # columns by name: forward_total, heading, ...
print(track.quality.n_dropped, df.select("forward_total", "heading").tail())

For a live camera, feed frames from your own capture code to a Tracker. The config must already describe the ball, the field of view and the camera position, so track a recorded clip of the rig first and use the config.toml the run writes.

from spintrack import Config, Tracker

cfg = Config.load("config.toml")
tracker = Tracker(cfg, width, height)
for gray, ts_ms in frames():  # your camera SDK: 2-D uint8 images, timestamps in ms
    res = tracker.process_frame(gray, ts_ms)
    if res is not None:  # None: the frame could not be tracked
        print(res.forward, res.side, res.turn)  # rad this frame; side, turn: left +

Reference

Track the recording a config describes and return its records.

config is a config file, a Config or None (the defaults); src overrides its video. The run is that of spintrack run, with the same refusals and the same geometry found in the recording, minus the files.

frames opens the frames to track instead of the video, for a caller that decodes them itself: it is called once, twice with two_pass, and the records count its frames from 0. Nothing is found in the recording then, so the config must give the ball, the field of view and the camera position.

Source code in python/spintrack/pipeline.py
def track(
    config: Config | str | Path | None,
    *,
    src: str | int | None = None,
    frames: Callable[[], FrameSource] | None = None,
    two_pass: bool = False,
    max_frames: int | None = None,
) -> Track:
    """Track the recording a config describes and return its records.

    `config` is a config file, a `Config` or None (the defaults); `src` overrides its
    `video`. The run is that of `spintrack run`, with the same refusals and the same
    geometry found in the recording, minus the files.

    `frames` opens the frames to track instead of the video, for a caller that decodes
    them itself: it is called once, twice with `two_pass`, and the records count its
    frames from 0. Nothing is found in the recording then, so the config must give the
    ball, the field of view and the camera position.
    """
    from spintrack.autofit import complete_config

    if frames is None:
        cfg, spec = open_config(config, src, two_pass=two_pass)
        complete_config(cfg, spec)
        frames = functools.partial(open_source, spec)
    else:
        cfg = _load(config)
        camera = cfg.camera
        if not (cfg.ball.rim and camera.vfov_deg and camera.to_animal() is not None):
            raise ValueError(
                "tracking frames rather than a video needs the ball (ball.rim), the "
                "field of view (camera.vfov_deg) and the camera position in the config"
            )
    rows = _Rows()
    source = frames()
    try:
        stats = run(
            cfg,
            source,
            [rows],
            two_pass_source=frames if two_pass else None,
            max_frames=max_frames,
        )
    finally:
        source.close()
    return Track(with_ball(np.array(rows), rows.ball), stats.quality)

What track returns: the records, as in tracks.parquet, and quality.

Source code in python/spintrack/pipeline.py
@dataclass
class Track:
    """What `track` returns: the records, as in `tracks.parquet`, and quality."""

    records: np.ndarray  # (n, 29), one row per tracked frame; `ball_seen` as 0 or 1
    quality: RunQuality | None
    columns: tuple[str, ...] = TABLE_COLUMNS

    def to_polars(self) -> pl.DataFrame:
        """The records as a DataFrame with named, typed columns."""
        from spintrack.io.parquet import to_frame

        return to_frame(self.records)

to_polars

to_polars() -> pl.DataFrame

The records as a DataFrame with named, typed columns.

Source code in python/spintrack/pipeline.py
def to_polars(self) -> pl.DataFrame:
    """The records as a DataFrame with named, typed columns."""
    from spintrack.io.parquet import to_frame

    return to_frame(self.records)

Bases: _Table

A rig and, through video, a recording of it.

Source code in python/spintrack/config.py
class Config(_Table):
    """A rig and, through `video`, a recording of it."""

    video: str | int | None = None  # a path, or a camera index
    camera: CameraConfig = Field(default_factory=CameraConfig)
    ball: BallConfig = Field(default_factory=BallConfig)
    mask: MaskConfig = Field(default_factory=MaskConfig)
    tracking: TrackingConfig = Field(default_factory=TrackingConfig)
    output: OutputConfig = Field(default_factory=OutputConfig)
    stream: StreamConfig = Field(default_factory=StreamConfig)

    @classmethod
    def load(cls, path: str | Path) -> Config:
        """Read a `.toml` config, resolving its relative paths against its folder."""
        path = Path(path)
        if path.suffix.lower() != ".toml":
            raise ValueError(
                f"{path}: configs are TOML files; docs/fictrac.md translates a FicTrac "
                f"config.txt"
            )
        try:
            cfg = cls.model_validate(tomllib.loads(path.read_text(encoding="utf-8")))
        except tomllib.TOMLDecodeError as exc:
            raise ValueError(f"{path}: {exc}") from None
        except ValidationError as exc:
            raise ValueError(_explain(path, exc)) from None
        for table, key in PATHS:
            owner = getattr(cfg, table) if table else cfg
            value = getattr(owner, key)
            if isinstance(value, str):
                setattr(owner, key, os.path.normpath(path.parent / value))
        return cfg

    def save(
        self, path: str | Path, comments: Sequence[str] = (), *, full: bool = False
    ) -> Path:
        """Write the config to `path` as TOML, under `comments` (lines from `#`).

        Only keys that differ from the defaults are written unless `full`. Paths are
        written relative to the file, unless the two share no folder but the root.
        """
        path = Path(path)
        data = self.model_dump(exclude_defaults=not full, exclude_none=True)
        for table, key in PATHS:
            value = getattr(getattr(self, table) if table else self, key)
            if isinstance(value, str):
                (data[table] if table else data)[key] = _relative(value, path.parent)
        header = "".join(f"{c}\n" for c in comments) + ("\n" if comments else "")
        path.write_text(header + _toml(data), "utf-8")
        return path

load classmethod

load(path: str | Path) -> Config

Read a .toml config, resolving its relative paths against its folder.

Source code in python/spintrack/config.py
@classmethod
def load(cls, path: str | Path) -> Config:
    """Read a `.toml` config, resolving its relative paths against its folder."""
    path = Path(path)
    if path.suffix.lower() != ".toml":
        raise ValueError(
            f"{path}: configs are TOML files; docs/fictrac.md translates a FicTrac "
            f"config.txt"
        )
    try:
        cfg = cls.model_validate(tomllib.loads(path.read_text(encoding="utf-8")))
    except tomllib.TOMLDecodeError as exc:
        raise ValueError(f"{path}: {exc}") from None
    except ValidationError as exc:
        raise ValueError(_explain(path, exc)) from None
    for table, key in PATHS:
        owner = getattr(cfg, table) if table else cfg
        value = getattr(owner, key)
        if isinstance(value, str):
            setattr(owner, key, os.path.normpath(path.parent / value))
    return cfg

save

save(path: str | Path, comments: Sequence[str] = (), *, full: bool = False) -> Path

Write the config to path as TOML, under comments (lines from #).

Only keys that differ from the defaults are written unless full. Paths are written relative to the file, unless the two share no folder but the root.

Source code in python/spintrack/config.py
def save(
    self, path: str | Path, comments: Sequence[str] = (), *, full: bool = False
) -> Path:
    """Write the config to `path` as TOML, under `comments` (lines from `#`).

    Only keys that differ from the defaults are written unless `full`. Paths are
    written relative to the file, unless the two share no folder but the root.
    """
    path = Path(path)
    data = self.model_dump(exclude_defaults=not full, exclude_none=True)
    for table, key in PATHS:
        value = getattr(getattr(self, table) if table else self, key)
        if isinstance(value, str):
            (data[table] if table else data)[key] = _relative(value, path.parent)
    header = "".join(f"{c}\n" for c in comments) + ("\n" if comments else "")
    path.write_text(header + _toml(data), "utf-8")
    return path
Source code in python/spintrack/tracker.py
class Tracker:
    def __init__(
        self, cfg: Config, width: int, height: int, params: TrackParams | None = None
    ):
        vfov = cfg.camera.vfov_deg
        if vfov is None:
            raise ValueError("config needs the field of view, camera.vfov_deg")
        if not cfg.ball.rim:
            raise ValueError("config needs the ball's rim points, ball.rim")
        self.cfg = cfg
        self.width, self.height = int(width), int(height)
        camera = cfg.camera
        self.camera = source_camera(width, height, vfov, camera.fisheye)
        self.center, self.half_angle = fit_ball(cfg.ball.rim, self.camera)
        self.params = params_from_config(cfg, params)
        circle = pixel_circle(self.camera, self.center, self.half_angle)
        self.ball_radius_px = circle[2]
        self._circle_px = circle[:2]  # the window's ball circle, for the watch
        if self.ball_radius_px < MIN_BALL_RADIUS_PX:
            log.warning(
                "the ball is only %.1f px in radius; rotations will be reported low, "
                "the one about the optical axis first (sideslip, for a camera behind "
                "the animal). See docs/guide.md, Troubleshooting",
                self.ball_radius_px,
            )
        mask = source_mask(self.camera, self.center, self.half_angle, cfg.mask.ignore)
        self.geometry = window_geometry(
            self.camera,
            self.center,
            self.half_angle,
            cfg.tracking.window_px,
            mask,
            prefilter=self.params.prefilter,
        )
        self.R_wc = self.geometry.to_camera  # window -> camera
        # The reporting convention is fixed to the first window frame, so a later re-fit
        # moves the window without stepping the absolute-orientation columns.
        self.R_wc0 = self.R_wc
        to_animal = cfg.camera.to_animal()
        if to_animal is None:
            # The camera-frame columns are valid without it, so this warns rather than
            # raising; `spintrack run` refuses instead, because its lab-frame columns
            # would be camera values in disguise.
            log.warning("no camera position in the config; using the identity")
            to_animal = np.eye(3)
        self.cam_to_lab = to_animal
        tracking = cfg.tracking
        if tracking.initial_map:
            self.params.global_search = True  # needed to localize against the template
        self.engine = TrackEngine(self.geometry, self.params)
        if tracking.initial_map:
            mean, weight = load_map(tracking.initial_map, self.engine.map_shape)
            self.engine.load_map(mean, weight, frozen=tracking.freeze_map)
            # The illumination field describes the rig, so a saved one is a head start.
            self._load_illumination(tracking.initial_map)
        if tracking.initial_illumination:
            self._load_illumination(tracking.initial_illumination, asked=True)
        self.path = PathIntegrator()
        self.frame = 0
        self.seq = 0
        self._prev_ts: float | None = None  # timestamp of the last tracked frame
        self.geometry_version = 0  # bumped by every window move
        self.center_initial = self.center.copy()
        # The window's ball circle center on every frame, as flat `x, y`.
        self._windows = array("d")
        self.watch = None
        if self.params.center_watch:
            from spintrack.refit import CenterWatch

            self.watch = CenterWatch(circle[:2], circle[2])

    def reset(self) -> None:
        self.engine.reset()
        self.path.reset()
        self.seq = 0

    def prime_from(self, other: Tracker) -> None:
        """Start from another tracker's map and illumination field.

        Both trackers must have been built from the same config, so their initial
        windows agree and the map needs no correction: the map lives in the window frame
        at `R = I`, and a window re-fit in `other` carried it unchanged (see
        `TrackEngine.rebuild`). The illumination field is fixed in the *window*, so it
        comes from wherever `other` ended up and is resampled into this one.
        When `other` watched the ball, this tracker's window follows the trajectory
        `other` measured, planned from all of its looks at once (`CenterWatch.replay`),
        instead of following the ball for itself.
        """
        mean, weight = other.engine.export_map()
        frozen = self.cfg.tracking.freeze_map
        self.engine.load_map(mean, weight, frozen=frozen, localize=False)
        # No accumulator weight on the field, unlike `initial_illumination`: this pass
        # sees the same lighting and re-measures it within the warmup anyway.
        self.engine.load_illumination(
            other.engine.photometry.state(), other.center, other.half_angle
        )
        if self.watch is not None and other.watch is not None:
            planned = other.watch.replay(other.frame)
            if planned is not None:
                self.watch = planned

    def _load_illumination(self, path, asked: bool = False) -> bool:
        """Take the illumination field out of a map `.npz`, if it carries a usable one.

        The field is window-shaped, so a file saved for a window of another size has
        nothing to give this run; that is worth a warning only when the run asked for
        the file by name.
        """
        fields, saved = load_illumination(path, self.geometry.size)
        if not fields:
            if asked:
                log.warning(
                    "%s carries no illumination field for a %d px window: "
                    "this run estimates its own",
                    path,
                    self.geometry.size,
                )
            return False
        self.engine.load_illumination(
            fields,
            saved.get("center", self.center),
            float(saved.get("half_angle", self.half_angle)),
            # The field describes the rig: it counts as much as this run's own memory
            # until the run's frames outweigh it.
            prior_frames=TAU,
        )
        return True

    def save_map(self, path) -> None:
        """Write the current surface map as a spintrack `.npz` template."""
        mean, weight = self.engine.export_map()
        illum = self.engine.photometry.state()
        save_map(
            path,
            mean,
            weight,
            window_size=self.geometry.size,
            center=self.center,
            half_angle=self.half_angle,
            illum_bias=illum["bias"],
            illum_gain=illum["gain"],
        )

    def process_frame(
        self, gray: np.ndarray, ts_ms: float = -1.0, wall_ms: float | None = None
    ) -> FrameResult | None:
        """Track one grayscale frame (2-D uint8); None if the frame was dropped."""
        # Move the window onto the ball before tracking this frame, so that the window
        # does not trail a moving ball by a frame.
        self._watch_center(gray)
        self._windows.extend(self._circle_px)
        window = self.geometry.remap(gray)
        step = self.engine.step(window)
        frame = self.frame
        self.frame += 1
        if not step.ok:
            # `delta_ts` spans the lost frames, like the next row's increment does.
            self.seq += 1
            return None
        if step.source == "reset" or (step.source == "global" and self.seq == 0):
            self.path.reset()
            self.seq = 0

        delta_ts = 0.0 if self._prev_ts is None else ts_ms - self._prev_ts
        self._prev_ts = ts_ms
        wall = ms_since_midnight() if wall_ms is None else wall_ms
        values, w_cam, w_lab, R_cam, R_lab = self._values(
            frame, ts_ms, wall, step, delta_ts
        )
        self.seq += 1
        return FrameResult(
            frame, int(values[22]), ts_ms, w_cam, w_lab, R_cam, R_lab, step, values
        )

    def ball_columns(self) -> np.ndarray:
        """`BALL_COLUMNS` for every frame so far, as an (n, 4) array indexed by frame.

        The ball's path is the follower's (`CenterWatch.ball_path`), smoothed over all
        of its looks, so it is only final once the run ends. The offset is the window
        circle's distance from it in ball radii, which is about the rotation, in rad,
        that the misplacement adds to the frame's orientation. All NaN, and the ball
        never seen, without a follower.
        """
        n = self.frame
        out = np.full((n, len(BALL_COLUMNS)), np.nan)
        out[:, 2] = 0.0
        if self.watch is None:
            return out
        path, seen = self.watch.ball_path(n)
        window = np.frombuffer(self._windows, dtype=np.float64).reshape(-1, 2)[:n]
        out[:, 0:2] = path
        out[:, 2] = seen
        out[:, 3] = np.hypot(*(path - window).T) / self.ball_radius_px
        return out

    def refit_center(self, new_center, circle_px=None) -> np.ndarray:
        """Point the tracking window at a new ball center, keeping the surface map.

        Returns the rotation `Q` from the old window frame to the new one, which the
        caller must apply to any orientation it recorded in the old frame. The ignore
        polygons are image-fixed (the animal has not moved), so they are not translated.
        `circle_px` is the new center's `pixel_circle` center, when already known.
        """
        center = normalize(np.asarray(new_center, dtype=np.float64))
        if circle_px is None:
            circle_px = pixel_circle(self.camera, center, self.half_angle)[:2]
        self._circle_px = circle_px
        mask = source_mask(self.camera, center, self.half_angle, self.cfg.mask.ignore)
        geometry = window_geometry(
            self.camera,
            center,
            self.half_angle,
            self.cfg.tracking.window_px,
            mask,
            prefilter=self.params.prefilter,
        )
        Q = geometry.to_camera.T @ self.R_wc
        self.engine.rebuild(geometry, Q)
        self.geometry = geometry
        self.R_wc = geometry.to_camera
        self.center = center
        self.geometry_version += 1
        return Q

    def _watch_center(self, gray: np.ndarray) -> None:
        """Keep the tracking window on a ball that is moving in its holder."""
        if self.watch is None:
            return
        target = self.watch.update(self.frame, gray)
        if target is None:
            return
        cx, cy = self._circle_px
        if float(np.hypot(target[0] - cx, target[1] - cy)) < FOLLOW_TOL_PX:
            return
        # The radius is left alone: a ball only changes apparent size by moving along
        # the optical axis, and translation on its own is much better conditioned. The
        # target is where the silhouette's circle should sit, which is not where the
        # ball's center projects, so it is inverted rather than read as a direction.
        center, circle = center_from_pixel_circle(
            self.camera, target, self.half_angle, self.center, self._circle_px
        )
        self.refit_center(center, circle)

    def _values(self, frame, ts_ms, wall_ms, step, delta_ts):
        """The 25 columns for one tracked frame (also advances the path)."""
        w_cam = self.R_wc @ step.w_win
        R_cam = self.R_wc @ step.R_win @ self.R_wc0.T
        w_lab = self.cam_to_lab @ w_cam
        R_lab = self.cam_to_lab @ R_cam @ self.cam_to_lab.T
        p = self.path.step(w_lab)
        values = np.empty(N_COLUMNS)
        values[0] = frame
        values[1:4] = w_cam
        values[4] = step.cost if np.isfinite(step.cost) else 0.0
        values[5:8] = w_lab
        values[8:11] = matrix_to_rotvec(R_cam)
        values[11:14] = matrix_to_rotvec(R_lab)
        values[14:17] = (p.pos_x, p.pos_y, p.heading)
        values[17:19] = (p.step_dir, p.step_mag)
        values[19:21] = (p.int_x, p.int_y)
        values[21] = ts_ms
        values[22] = self.seq
        values[23] = delta_ts
        values[24] = wall_ms
        return values, w_cam, w_lab, R_cam, R_lab

ball_columns

ball_columns() -> np.ndarray

BALL_COLUMNS for every frame so far, as an (n, 4) array indexed by frame.

The ball's path is the follower's (CenterWatch.ball_path), smoothed over all of its looks, so it is only final once the run ends. The offset is the window circle's distance from it in ball radii, which is about the rotation, in rad, that the misplacement adds to the frame's orientation. All NaN, and the ball never seen, without a follower.

Source code in python/spintrack/tracker.py
def ball_columns(self) -> np.ndarray:
    """`BALL_COLUMNS` for every frame so far, as an (n, 4) array indexed by frame.

    The ball's path is the follower's (`CenterWatch.ball_path`), smoothed over all
    of its looks, so it is only final once the run ends. The offset is the window
    circle's distance from it in ball radii, which is about the rotation, in rad,
    that the misplacement adds to the frame's orientation. All NaN, and the ball
    never seen, without a follower.
    """
    n = self.frame
    out = np.full((n, len(BALL_COLUMNS)), np.nan)
    out[:, 2] = 0.0
    if self.watch is None:
        return out
    path, seen = self.watch.ball_path(n)
    window = np.frombuffer(self._windows, dtype=np.float64).reshape(-1, 2)[:n]
    out[:, 0:2] = path
    out[:, 2] = seen
    out[:, 3] = np.hypot(*(path - window).T) / self.ball_radius_px
    return out

prime_from

prime_from(other: Tracker) -> None

Start from another tracker's map and illumination field.

Both trackers must have been built from the same config, so their initial windows agree and the map needs no correction: the map lives in the window frame at R = I, and a window re-fit in other carried it unchanged (see TrackEngine.rebuild). The illumination field is fixed in the window, so it comes from wherever other ended up and is resampled into this one. When other watched the ball, this tracker's window follows the trajectory other measured, planned from all of its looks at once (CenterWatch.replay), instead of following the ball for itself.

Source code in python/spintrack/tracker.py
def prime_from(self, other: Tracker) -> None:
    """Start from another tracker's map and illumination field.

    Both trackers must have been built from the same config, so their initial
    windows agree and the map needs no correction: the map lives in the window frame
    at `R = I`, and a window re-fit in `other` carried it unchanged (see
    `TrackEngine.rebuild`). The illumination field is fixed in the *window*, so it
    comes from wherever `other` ended up and is resampled into this one.
    When `other` watched the ball, this tracker's window follows the trajectory
    `other` measured, planned from all of its looks at once (`CenterWatch.replay`),
    instead of following the ball for itself.
    """
    mean, weight = other.engine.export_map()
    frozen = self.cfg.tracking.freeze_map
    self.engine.load_map(mean, weight, frozen=frozen, localize=False)
    # No accumulator weight on the field, unlike `initial_illumination`: this pass
    # sees the same lighting and re-measures it within the warmup anyway.
    self.engine.load_illumination(
        other.engine.photometry.state(), other.center, other.half_angle
    )
    if self.watch is not None and other.watch is not None:
        planned = other.watch.replay(other.frame)
        if planned is not None:
            self.watch = planned

process_frame

process_frame(gray: ndarray, ts_ms: float = -1.0, wall_ms: float | None = None) -> FrameResult | None

Track one grayscale frame (2-D uint8); None if the frame was dropped.

Source code in python/spintrack/tracker.py
def process_frame(
    self, gray: np.ndarray, ts_ms: float = -1.0, wall_ms: float | None = None
) -> FrameResult | None:
    """Track one grayscale frame (2-D uint8); None if the frame was dropped."""
    # Move the window onto the ball before tracking this frame, so that the window
    # does not trail a moving ball by a frame.
    self._watch_center(gray)
    self._windows.extend(self._circle_px)
    window = self.geometry.remap(gray)
    step = self.engine.step(window)
    frame = self.frame
    self.frame += 1
    if not step.ok:
        # `delta_ts` spans the lost frames, like the next row's increment does.
        self.seq += 1
        return None
    if step.source == "reset" or (step.source == "global" and self.seq == 0):
        self.path.reset()
        self.seq = 0

    delta_ts = 0.0 if self._prev_ts is None else ts_ms - self._prev_ts
    self._prev_ts = ts_ms
    wall = ms_since_midnight() if wall_ms is None else wall_ms
    values, w_cam, w_lab, R_cam, R_lab = self._values(
        frame, ts_ms, wall, step, delta_ts
    )
    self.seq += 1
    return FrameResult(
        frame, int(values[22]), ts_ms, w_cam, w_lab, R_cam, R_lab, step, values
    )

refit_center

refit_center(new_center, circle_px=None) -> np.ndarray

Point the tracking window at a new ball center, keeping the surface map.

Returns the rotation Q from the old window frame to the new one, which the caller must apply to any orientation it recorded in the old frame. The ignore polygons are image-fixed (the animal has not moved), so they are not translated. circle_px is the new center's pixel_circle center, when already known.

Source code in python/spintrack/tracker.py
def refit_center(self, new_center, circle_px=None) -> np.ndarray:
    """Point the tracking window at a new ball center, keeping the surface map.

    Returns the rotation `Q` from the old window frame to the new one, which the
    caller must apply to any orientation it recorded in the old frame. The ignore
    polygons are image-fixed (the animal has not moved), so they are not translated.
    `circle_px` is the new center's `pixel_circle` center, when already known.
    """
    center = normalize(np.asarray(new_center, dtype=np.float64))
    if circle_px is None:
        circle_px = pixel_circle(self.camera, center, self.half_angle)[:2]
    self._circle_px = circle_px
    mask = source_mask(self.camera, center, self.half_angle, self.cfg.mask.ignore)
    geometry = window_geometry(
        self.camera,
        center,
        self.half_angle,
        self.cfg.tracking.window_px,
        mask,
        prefilter=self.params.prefilter,
    )
    Q = geometry.to_camera.T @ self.R_wc
    self.engine.rebuild(geometry, Q)
    self.geometry = geometry
    self.R_wc = geometry.to_camera
    self.center = center
    self.geometry_version += 1
    return Q

save_map

save_map(path) -> None

Write the current surface map as a spintrack .npz template.

Source code in python/spintrack/tracker.py
def save_map(self, path) -> None:
    """Write the current surface map as a spintrack `.npz` template."""
    mean, weight = self.engine.export_map()
    illum = self.engine.photometry.state()
    save_map(
        path,
        mean,
        weight,
        window_size=self.geometry.size,
        center=self.center,
        half_angle=self.half_angle,
        illum_bias=illum["bias"],
        illum_gain=illum["gain"],
    )

One tracked frame. Distances are in ball radii (radians of ball rotation).

The animal-frame properties follow spintrack.path: lab x forward, y left, z up, so sideways motion and turning are positive to the left.

Source code in python/spintrack/tracker.py
@dataclass
class FrameResult:
    """One tracked frame. Distances are in ball radii (radians of ball rotation).

    The animal-frame properties follow `spintrack.path`: lab x forward, y left, z up, so
    sideways motion and turning are positive to the left.
    """

    frame: int
    seq: int
    ts_ms: float
    w_cam: np.ndarray  # rotation increment, camera frame
    w_lab: np.ndarray  # rotation increment, lab frame
    R_cam: np.ndarray  # absolute ball orientation, camera frame
    R_lab: np.ndarray
    step: StepResult
    values: np.ndarray  # the 25 FicTrac columns

    @property
    def forward(self) -> float:
        """Forward motion over this frame."""
        return -float(self.w_lab[1])

    @property
    def side(self) -> float:
        """Sideways motion over this frame, positive to the animal's left."""
        return float(self.w_lab[0])

    @property
    def turn(self) -> float:
        """Change of heading over this frame in rad, positive turning left."""
        return -float(self.w_lab[2])

    @property
    def heading(self) -> float:
        """Integrated heading in rad, [0, 2 pi), from the first tracked frame's."""
        return float(self.values[16])

    @property
    def x(self) -> float:
        """Integrated position along the starting heading."""
        return float(self.values[14])

    @property
    def y(self) -> float:
        """Integrated position to the left of the starting heading."""
        return float(self.values[15])

forward property

forward: float

Forward motion over this frame.

heading property

heading: float

Integrated heading in rad, [0, 2 pi), from the first tracked frame's.

side property

side: float

Sideways motion over this frame, positive to the animal's left.

turn property

turn: float

Change of heading over this frame in rad, positive turning left.

x property

x: float

Integrated position along the starting heading.

y property

y: float

Integrated position to the left of the starting heading.

The tracking options a config key, a CLI flag or a caller sets.

Source code in python/spintrack/engine.py
@dataclass
class TrackParams:
    """The tracking options a config key, a CLI flag or a caller sets."""

    norm_win_pc: float = 0.25  # normalization window, fraction of the window size
    forget_outside_view: bool = False  # forget map cells the window does not see
    illum_bias: bool = True  # separate the rig's static illumination (`photometry`)
    global_search: bool = False  # relocalize when the local solves fail
    max_step: float = 0.5  # rad per frame; larger increments are rejected
    max_bad_frames: int = -1  # lost frames in a row before a reset (-1: never)
    # Solve on a spatial subsample of about this many window pixels (None = all); the
    # map is still updated from every pixel.
    max_pixels: int | None = 4000
    # Anti-aliasing blur of the source before the window remap, in units of the
    # decimation (0 disables).
    prefilter: float = PREFILTER_SIGMA
    # Follow a ball that moves in its holder (see `spintrack.refit`).
    center_watch: bool = True