Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MV (0): Add multiview classes #145

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
312 changes: 312 additions & 0 deletions sleap_io/model/camera.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
"""Data structure for a single camera view in a multi-camera setup."""

from __future__ import annotations

import attrs
import cv2
import numpy as np
from attrs import define, field

from sleap_io.model.video import Video


@define
class CameraGroup:
"""A group of cameras used to record a multi-view `RecordingSession`.

Attributes:
cameras: List of `Camera` objects in the group.
"""

cameras: list[Camera] = field(factory=list)


@define(eq=False) # Set eq to false to make class hashable
class RecordingSession:
"""A recording session with multiple cameras.

Attributes:
camera_group: `CameraGroup` object containing cameras in the session.
_video_by_camera: Dictionary mapping `Camera` to `Video`.
_camera_by_video: Dictionary mapping `Video` to `Camera`.
"""

camera_group: CameraGroup = field(factory=CameraGroup)
_video_by_camera: dict[Camera, Video] = field(factory=dict)
_camera_by_video: dict[Video, Camera] = field(factory=dict)

def get_video(self, camera: Camera) -> Video | None:
"""Get `Video` associated with `Camera`.

Args:
camera: Camera to get video

Returns:
Video associated with camera or None if not found
"""
return self._video_by_camera.get(camera, None)

def add_video(self, video: Video, camera: Camera):
"""Add `Video` to `RecordingSession` and mapping to `Camera`.

Args:
video: `Video` object to add to `RecordingSession`.
camera: `Camera` object to associate with `Video`.

Raises:
ValueError: If `Camera` is not in associated `CameraGroup`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ValueError: If `Camera` is not in associated `CameraGroup`.
ValueError: If `camera` is not in associated `CameraGroup`.

ValueError: If `Video` is not a `Video` object.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ValueError: If `Video` is not a `Video` object.
ValueError: If `video` is not a `Video` object.

"""
# Raise ValueError if camera is not in associated camera group
self.camera_group.cameras.index(camera)

# Raise ValueError if `Video` is not a `Video` object
if not isinstance(video, Video):
raise ValueError(

Check warning on line 65 in sleap_io/model/camera.py

View check run for this annotation

Codecov / codecov/patch

sleap_io/model/camera.py#L65

Added line #L65 was not covered by tests
f"Expected `Video` object, but received {type(video)} object."
)

# Add camera to video mapping
self._video_by_camera[camera] = video

# Add video to camera mapping
self._camera_by_video[video] = camera

def remove_video(self, video: Video):
"""Remove `Video` from `RecordingSession` and mapping to `Camera`.

Args:
video: `Video` object to remove from `RecordingSession`.

Raises:
ValueError: If `Video` is not in associated `RecordingSession`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ValueError: If `Video` is not in associated `RecordingSession`.
ValueError: If `video` is not in associated `RecordingSession`.

"""
# Remove video from camera mapping
camera = self._camera_by_video.pop(video)

# Remove camera from video mapping
self._video_by_camera.pop(camera)


@define(eq=False) # Set eq to false to make class hashable
class Camera:
"""A camera used to record in a multi-view `RecordingSession`.

Attributes:
matrix: Intrinsic camera matrix of size (3, 3) and type float64.
dist: Radial-tangential distortion coefficients [k_1, k_2, p_1, p_2, k_3] of
size (5,) and type float64.
size: Image size of camera in pixels of size (2,) and type int.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this (height, width) or (width, height)?

rvec: Rotation vector in unnormalized axis-angle representation of size (3,) and
type float64.
tvec: Translation vector of size (3,) and type float64.
extrinsic_matrix: Extrinsic matrix of camera of size (4, 4) and type float64.
name: Camera name.
_video_by_session: Dictionary mapping `RecordingSession` to `Video`.
"""

matrix: np.ndarray = field(
default=np.eye(3),
converter=lambda x: np.array(x, dtype="float64"),
)
dist: np.ndarray = field(
default=np.zeros(5), converter=lambda x: np.array(x, dtype="float64").ravel()
)
size: tuple[int, int] = field(
default=None, converter=attrs.converters.optional(tuple)
)
_rvec: np.ndarray = field(
default=np.zeros(3), converter=lambda x: np.array(x, dtype="float64").ravel()
)
_tvec: np.ndarray = field(
default=np.zeros(3), converter=lambda x: np.array(x, dtype="float64").ravel()
)
name: str = field(default=None, converter=attrs.converters.optional(str))
_extrinsic_matrix: np.ndarray = field(init=False)

@matrix.validator
@dist.validator
@size.validator
@_rvec.validator
@_tvec.validator
@_extrinsic_matrix.validator
def _validate_shape(self, attribute: attrs.Attribute, value):
"""Validate shape of attribute based on metadata.

Args:
attribute: Attribute to validate.
value: Value of attribute to validate.

Raises:
ValueError: If attribute shape is not as expected.
"""
# Define metadata for each attribute
attr_metadata = {
"matrix": {"shape": (3, 3), "type": np.ndarray},
"dist": {"shape": (5,), "type": np.ndarray},
"size": {"shape": (2,), "type": tuple},
"_rvec": {"shape": (3,), "type": np.ndarray},
"_tvec": {"shape": (3,), "type": np.ndarray},
"_extrinsic_matrix": {"shape": (4, 4), "type": np.ndarray},
}
optional_attrs = ["size"]

# Skip validation if optional attribute is None
if attribute.name in optional_attrs and value is None:
return

# Validate shape of attribute
expected_shape = attr_metadata[attribute.name]["shape"]
expected_type = attr_metadata[attribute.name]["type"]
if np.shape(value) != expected_shape:
raise ValueError(
f"{attribute.name} must be a {expected_type} of size {expected_shape}, "
f"but recieved shape: {np.shape(value)} and type: {type(value)} for "
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
f"but recieved shape: {np.shape(value)} and type: {type(value)} for "
f"but received shape: {np.shape(value)} and type: {type(value)} for "

f"value: {value}"
)

def __attrs_post_init__(self):
"""Initialize extrinsic matrix from rotation and translation vectors."""
# Initialize extrinsic matrix
self._extrinsic_matrix = np.eye(4, dtype="float64")
self._extrinsic_matrix[:3, :3] = cv2.Rodrigues(self._rvec)[0]
self._extrinsic_matrix[:3, 3] = self._tvec

@property
def rvec(self) -> np.ndarray:
"""Get rotation vector of camera.

Returns:
Rotation vector of camera of size 3.
"""
return self._rvec

@rvec.setter
def rvec(self, value: np.ndarray):
"""Set rotation vector and update extrinsic matrix.

Args:
value: Rotation vector of size 3.
"""
self._rvec = value

# Update extrinsic matrix
rotation_matrix, _ = cv2.Rodrigues(self._rvec)
self._extrinsic_matrix[:3, :3] = rotation_matrix

@property
def tvec(self) -> np.ndarray:
"""Get translation vector of camera.

Returns:
Translation vector of camera of size 3.
"""
return self._tvec

@tvec.setter
def tvec(self, value: np.ndarray):
"""Set translation vector and update extrinsic matrix.

Args:
value: Translation vector of size 3.
"""
self._tvec = value

# Update extrinsic matrix
self._extrinsic_matrix[:3, 3] = self._tvec

@property
def extrinsic_matrix(self) -> np.ndarray:
"""Get extrinsic matrix of camera.

Returns:
Extrinsic matrix of camera of size 4 x 4.
"""
return self._extrinsic_matrix

@extrinsic_matrix.setter
def extrinsic_matrix(self, value: np.ndarray):
"""Set extrinsic matrix and update rotation and translation vectors.

Args:
value: Extrinsic matrix of size 4 x 4.
"""
self._extrinsic_matrix = value

# Update rotation and translation vectors
self._rvec, _ = cv2.Rodrigues(self._extrinsic_matrix[:3, :3])
self._tvec = self._extrinsic_matrix[:3, 3]

def undistort_points(self, points: np.ndarray) -> np.ndarray:
"""Undistort points using camera matrix and distortion coefficients.

Args:
points: Points to undistort of shape (N, 2).

Returns:
Undistorted points of shape (N, 2).
"""
shape = points.shape
points = points.reshape(-1, 1, 2)
out = cv2.undistortPoints(points.astype("float64"), self.matrix, self.dist)
return out.reshape(shape)

def project(self, points: np.ndarray) -> np.ndarray:
"""Project 3D points to 2D using camera matrix and distortion coefficients.

Args:
points: 3D points to project of shape (N, 3) or (N, 1, 3).

Returns:
Projected 2D points of shape (N, 1, 2).
"""
points = points.reshape(-1, 1, 3)
out, _ = cv2.projectPoints(
points,
self.rvec,
self.tvec,
self.matrix,
self.dist,
)
return out

def get_video(self, session: RecordingSession) -> Video | None:
"""Get video associated with recording session.

Args:
session: Recording session to get video for.

Returns:
Video associated with recording session or None if not found.
"""
return session.get_video(camera=self)

# TODO: Remove this when we implement triangulation without aniposelib
def __getattr__(self, name: str):
"""Get attribute by name.

Args:
name: Name of attribute to get.

Returns:
Value of attribute.

Raises:
AttributeError: If attribute does not exist.
"""
if name in self.__attrs_attrs__:
return getattr(self, name)

Check warning on line 298 in sleap_io/model/camera.py

View check run for this annotation

Codecov / codecov/patch

sleap_io/model/camera.py#L298

Added line #L298 was not covered by tests

# The aliases for methods called when triangulate with sleap_anipose
method_aliases = {
"get_name": self.name,
"get_extrinsic_matrix": self.extrinsic_matrix,
}

def return_callable_method_alias():
return method_aliases[name]

if name in method_aliases:
return return_callable_method_alias

raise AttributeError(f"'Camera' object has no attribute or method '{name}'")

Check warning on line 312 in sleap_io/model/camera.py

View check run for this annotation

Codecov / codecov/patch

sleap_io/model/camera.py#L312

Added line #L312 was not covered by tests
Loading
Loading