Module vcd.scl

Module to handle sensor data and coordinates transformations.

This module contains functions to handle different Scenes and Camera models.

Basic principles of SCL (Scene Configuration Library)

SCL provides some routines and functions, but it is also a guide to produce scenes coherently. Read carefully these principles, and see them in practice on the samples.

SCL is based on common conventions. In some cases, SCL allows using different conventions, but eventually it enforces using specific conventions, such as 'right-hand' coordinate systems, homogeneous coordinates, right-multiplication of matrices, alias (vs alibi) rotation matrices, etc.

Numpy Arrays

All geometric data is expressed as numpy n-dimensional arrays.

Homogeneous Data

All geometric data (points, lines) are expressed in homogeneous coordinates (columns) All matrices are also expressed in homogeneous coordinates. E.g. 3D points are column vectors 4x1 camera calibration matrix (K) is 3x4 matrix, where last row is all zeros and 1.0 all poses are expressed as 4x4 matrices rotation matrices are 3x3 If not specified otherwise, all one-dimensional vectors are column vectors NOTE: Since OpenLABEL 1.0.0, quaternion + translation is also added as an option to express poses.

Internally, SCL converts this form into 4x4 matrices

Poses And Coordinates Systems

Each coordinate system may have a pose with respect to a parent coordinate system. Examples of this are sensors which are installed in vehicles. The Sensor Coordinate System (SCS) then has a pose with respect to the Local Coordinate System (LCS) of the vehicle. In SCL this LCS is a point at the ego-vehicle located at the middle of the rear axle, projected to the ground, being X-to-front, Y-to-left, Z-up as defined in the ISO8855.

There are some other usual acronyms used in the code:

- LCS : Local Coordinate System (e.g. the vehicle coordinate system at the rear axis,
        as in ISO8855)
- SCS : Sensor Coordinate System (e.g. the camera coordinate system, or the lidar
        coordinate system)
- WCS : World Coordinate System (static coordinate system for the entire scene, typically
        equal to the first LCS)
- GCS : Geographic Coordinate System (UTM Universal Transverse Mercator)
- ICS : Image coordinate system (this is the 2D image plane)

NOTE: Nevertheless, in VCD and SCL, an entirely customizable graph of dependencies can be built, declaring coordinate systems and parent-child relations. See vcd.core functions add_coordinate_system() where static poses can be defined, and add_transform()which is a function to add frame-specific poses between coordinate systems.

For readability, let's use the following letter conventions: - P : Pose 4x4 - T : Transform 4x4 - R : Rotation matrix 3x3 - C : Position of coordinate system wrt to another 3x1 - K : Camera calibration matrix 3x4 - d : Distortion coefficients 1x5 (or 1x9, or 1x14)

All Poses are defined right-handed. A Pose encodes the passive rotation and position of a coordinate system wrt (with respect to) a reference system.

Usually, P_scs_wrt_lcs = [[R_3x3, C_3x1], [0, 0, 0, 1]], where R_3x3 is a 3x3 rotation matrix, and C_3x1 is the position of the SCS expressed in the LCS.

To actually convert a point from the reference system (e.g. LCS) into another system (e.g. SCS), the transformation matrix is built as the inverse of the pose https://en.wikipedia.org/wiki/Active_and_passive_transformation

Cameras coordinate systems are defined with X-to-right, Y-to-bottom, and Z-to-front, following usual OpenCV camera model convention. This is the common practice in computer vision, so that image coordinates are defined x-to-right, y-to-bottom.

Transforming a 3D point expressed in a given coordinate system into another coordinate system is carried out using right-to-left matrix multiplication: e.g. X_scs = T_lcs_to_scs @ X_lcs (e.g. X_lcs is 4x1, Transform_lcs_to_scs is 4x4, X_scs is 4x1) X_scs = T_lcs_to_scs @ X_lcs NOTE: @ operator in Python (>3.5) is matrix multiplication, equivalent to Numpy's dot operator

In addition, transformations can be understood as inverse Poses. e.g. if T_lcs_to_scs converts a point from LCS to SCS, then T_lcs_to_scs = np.linalg.inv(P_scs_wrt_lcs) # or using utils.inv(P_scs_wrt_lcs)

Note that to build a Pose and Transform by knowing the passive rotation R and position C of a coordinate system wrt to another, it is possible to do:

(pseudo-code) P_scs_wrt_lcs = (R_scs_wrt_lcs C_scs_wrt_lcs; 0 0 0 1) or T_lcs_to_scs = (transpose(R_scs_wrt_lcs) transpose(-R_scs_wrt_lcs)C_scs_wrt_lcs; 0 0 0 1) Note P = T^-1 and T = P^-1

Since conversion from one system to another is useful, the following equations hold true: P_scs_wrt_lcs = (T_lcs_to_scs)^-1 P_scs_wrt_lcs = T_scs_to_lcs

Odometry

The sensors of the setup can move through time (e.g. if onboarded into a moving vehicle or drone), the motion of the set-up is defined by the odometry information.

As each sensor has its own capturing timestamp, not necessarily coincident with the timestamp of an odometry entry, odometry information may be provided associated to a specific sensor frame (this way, it is possible to locate globally sensing information from that sensor). The library provides tools to interpolate odometry and associate odometry entries to specific timestamps.

The SCL library is defined in a way it is possible to add odometry values from multiple set-ups e.g. V2V, a second vehicle sends location information about itself and its sensors, along with detections.

Odometry is treated just like a normal frame-specific transform. Therefore, all previous discussion applies. See vcd's add_transform() function at core.py

Geo-coordinates

Just like any other coordinate system, geo-coordinates can be specified in VCD and then are supported in SCL.

Expand source code
"""
Module to handle sensor data and coordinates transformations.

This module contains functions to handle different Scenes and Camera models.

.. include:: ../docs/modules/scl.md
"""
# VCD (Video Content Description) library.
#
# Project website: http://vcd.vicomtech.org
#
# Copyright (C) 2023, Vicomtech (http://www.vicomtech.es/),
# (Spain) all rights reserved.
#
# VCD is a Python library to create and manage OpenLABEL content.
# VCD is distributed under MIT License. See LICENSE.


from __future__ import annotations

import math
import time
import warnings
from abc import abstractmethod
from collections import deque, namedtuple
from typing import Any

import cv2 as cv
import numpy as np
import numpy.typing as npt

from vcd import core, utils

__pdoc__ = {}

# From https://dev.to/mxl/dijkstras-algorithm-in-python-algorithms-for-beginners-dkc
# we'll use infinity as a default distance to nodes.
inf = float("inf")
Edge = namedtuple("Edge", ["start", "end", "cost"])
__pdoc__["vcd.scl.Edge"] = "Class to define edges for graphs."
__pdoc__["vcd.scl.Edge.start"] = "Start of the edge."
__pdoc__["vcd.scl.Edge.end"] = "End of the edge."
__pdoc__["vcd.scl.Edge.cost"] = "Cost of the edge."


def make_edge(start: str, end: str, cost: float = 1) -> Edge:
    """Transform input arguments in `Edge` tuple."""
    return Edge(start, end, cost)


class Graph:
    def __init__(self, edges: list):
        # let's check that the data is right
        wrong_edges = [i for i in edges if len(i) not in [2, 3]]
        if wrong_edges:
            raise ValueError(f"Wrong edges data: {wrong_edges}")

        self.edges = [make_edge(*edge) for edge in edges]

    @property
    def vertices(self) -> set:
        return set(sum(([edge.start, edge.end] for edge in self.edges), []))

    @staticmethod
    def get_node_pairs(n1: str, n2: str, both_ends: bool = True) -> list[list[str]]:
        if both_ends:
            node_pairs = [[n1, n2], [n2, n1]]
        else:
            node_pairs = [[n1, n2]]
        return node_pairs

    def remove_edge(self, n1: str, n2: str, both_ends: bool = True):
        node_pairs = self.get_node_pairs(n1, n2, both_ends)
        edges = self.edges[:]
        for edge in edges:
            if [edge.start, edge.end] in node_pairs:
                self.edges.remove(edge)

    def add_edge(self, n1: str, n2: str, cost: float = 1, both_ends: bool = True):
        node_pairs = self.get_node_pairs(n1, n2, both_ends)
        for edge in self.edges:
            if [edge.start, edge.end] in node_pairs:
                raise ValueError(f"Edge {n1} {n2} already exists")

        self.edges.append(Edge(start=n1, end=n2, cost=cost))
        if both_ends:
            self.edges.append(Edge(start=n2, end=n1, cost=cost))

    @property
    def neighbours(self) -> dict:
        neighbours: dict = {vertex: set() for vertex in self.vertices}
        for edge in self.edges:
            neighbours[edge.start].add((edge.end, edge.cost))

        return neighbours

    def dijkstra(self, source: str, dest: str) -> deque[str]:
        if source not in self.vertices:
            raise ValueError("Such source node doesn't exist")
        distances = {vertex: inf for vertex in self.vertices}
        previous_vertices = {vertex: None for vertex in self.vertices}
        distances[source] = 0
        vertices = self.vertices.copy()

        while vertices:
            current_vertex = min(vertices, key=lambda vertex: distances[vertex])
            vertices.remove(current_vertex)
            if distances[current_vertex] == inf:
                break
            for neighbour, cost in self.neighbours[current_vertex]:
                alternative_route = distances[current_vertex] + cost
                if alternative_route < distances[neighbour]:
                    distances[neighbour] = alternative_route
                    previous_vertices[neighbour] = current_vertex

        path: deque = deque()
        current_vertex = dest
        while previous_vertices[current_vertex] is not None:
            path.appendleft(current_vertex)
            current_vertex = previous_vertices[current_vertex]
        if path:
            path.appendleft(current_vertex)
        return path


class Sensor:
    def __init__(self, name: str, description: str, uri: str, **properties: Any):
        self.name = name
        self.description = description
        self.uri = uri
        self.type = type(self).__name__

        self.properties = properties  # additional properties

    def is_camera(self) -> bool:
        if self.type in ("CameraPinhole", "CameraFisheye", "CameraEquirectangular"):
            return True
        return False

    def is_lidar(self) -> bool:
        if self.type == "Lidar":
            return True
        return False


class Camera(Sensor):
    def __init__(self, width: int, height: int, name: str, description: str, uri: str):
        Sensor.__init__(self, name, description, uri)
        self.width = width
        self.height = height

        # This flags chooses between OpenCV's implementation of distort functions and manually
        # written equations
        # They should render equal or very-similar results
        # OpenCV version might be faster (TBC)
        self.use_opencv = False

    @abstractmethod
    def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray:
        _ = rays3d_3xN  # Unused arguments
        raise NotImplementedError("This function is not implemented")

    @abstractmethod
    def project_points3d(
        self, points3d_4xN: npt.NDArray, remove_outside: bool = False
    ) -> tuple[npt.NDArray[np.floating], list[bool]]:
        _ = (points3d_4xN, remove_outside)
        raise NotImplementedError("This function is not implemented")

    @abstractmethod
    def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray:
        _ = points2d_3xN
        raise NotImplementedError("This function is not implemented")


class CameraPinhole(Camera):
    """
    Define the Pinhole Camera Model.

    The Pinhole camera model defines a projection mechanism composed by two steps:

    - Linear projection: using the camera_matrix (K)
    - Radial/Tangential/... distortion: using distortion coefficients

    Distortion is assumed to be radial, as in:
    https://docs.opencv.org/4.2.0/d9/d0c/group__calib3d.html
    """

    def __init__(
        self,
        camera_intrinsics: dict[str, Any],
        name: str,
        description: str,
        uri: str,
        compute_remaps: bool = False,
    ):
        self.K_3x4 = np.array(camera_intrinsics["camera_matrix_3x4"]).reshape(3, 4)
        (
            rows,
            cols,
        ) = self.K_3x4.shape
        if rows != 3 or cols != 4:
            raise ValueError(f"Invalid intrinsics dimensions: {rows}x{cols}")
        self.K_3x3 = utils.fromCameraMatrix3x4toCameraMatrix3x3(self.K_3x4)
        d_list = camera_intrinsics["distortion_coeffs_1xN"]
        self.d_1xN = np.array(d_list).reshape(1, len(d_list))

        self.r_limit = None
        if self.is_distorted():
            self.r_limit = utils.get_distortion_radius(self.d_1xN)

        # Pre-compute undistortion maps (LUTs)
        self.img_size_dist = (
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
        )
        self.img_size_undist = (
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
        )

        if self.is_distorted():
            # alpha = 0.0 means NO black points in undistorted image
            # alpha = 1.0 means ALL distorted points inside limits of undistorted image
            aux = cv.getOptimalNewCameraMatrix(
                self.K_3x3,
                self.d_1xN,
                self.img_size_dist,
                alpha=0.0,
                newImgSize=self.img_size_undist,
            )
            self.K_und_3x3 = aux[0]
            self.mapX_to_und_16SC2 = None
            self.mapY_to_und_16SC2 = None
            if compute_remaps:
                self.__compute_remaps()
        else:
            self.K_und_3x3 = self.K_3x3

        self.K_und_3x4 = utils.fromCameraMatrix3x3toCameraMatrix3x4(self.K_und_3x3)

        Camera.__init__(
            self,
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
            name,
            description,
            uri,
        )

    #################################
    # Inherited functions
    #################################
    def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray[np.float64]:
        """
        Distort rays3d using the distortion parameters of the camera.

        As a result distorted rays3d are created which can then be projected using the camera
        calibration matrix.

        :param rays3d_3xN: Array with N 3D rays, each of them as column (rx, ry, rz)
        :return: rays3d_dist_3xN: Array with N distorted 3D rays, each of them as column (rx',
            ry', rz')
        """
        n = rays3d_3xN.shape[1]
        if n == 0 or not self.is_distorted():
            rays3d_dist_3xN = np.array([[]])
            return rays3d_dist_3xN

        # Normalize so last coordinate is 1
        rays3d_3xN[0:3, :] = rays3d_3xN[0:3, :] / rays3d_3xN[2, :]

        # NOTE: there is no cv.distortPoints() function as in cv.fisheye.distortPoints()
        # It is though possible to distort points using OpenCV by using cv.projectPoints
        # function
        # As we don't want to use K matrices here, let's use an eye, so the results are
        # rays and not points
        aux = cv.projectPoints(
            objectPoints=rays3d_3xN,
            rvec=np.array([[[0.0, 0.0, 0.0]]]),
            tvec=np.array([[[0.0, 0.0, 0.0]]]),
            cameraMatrix=np.eye(3),
            distCoeffs=self.d_1xN,
        )
        rays3d_dist_3xN = utils.from_OpenCV_Nx1xM_to_MxN(aux[0])
        rays3d_dist_3xN = np.vstack((rays3d_dist_3xN.transpose(), np.ones((1, n))))

        return rays3d_dist_3xN

    def project_points3d(
        self, points3d_4xN: npt.NDArray, remove_outside: bool = False
    ) -> tuple[npt.NDArray[np.floating], list[bool]]:
        """
        Project 3D points into 2D points using the camera projection.

        All coordinates as homogeneous coordinates, and all 3D elements expressed wrt the
        camera coordinate system.

        First, the 3D points are understood as 3D rays. If distorted, the rays3D are distorted
        into distorted rays3D.

        The calibration matrix K_3x3 or K_3x3_und (according to apply_distortion) is applied to
        produce points.

        Points outside limits are removed if remove_outside is True.

        :param points3d_4xN: 3D points in camera cs, homogeneous coordinates
        :param apply_distortion: flag to determine whether to project into distorted or
            undistorted domain
        :param remove_outside: flag to remove points that fall outside the limits of the target
            image
        :return: 2D points in image plane, as 3xN array, in hom. coordinates, and boolean array
            of valid
        """
        # 0.- Pre-filter
        if points3d_4xN.ndim != 2:
            raise ValueError("Invalid argument 'point3d_4xN' dimensions")
        n = points3d_4xN.shape[1]
        if n == 0:
            return np.array([[]]), []

        # 1.- Select only those z > 0 (in front of the camera) - this assumption is good
        #  for pinhole cameras
        idx_in_front: list[bool] = (points3d_4xN[2, :] > 1e-8).tolist()
        idx_valid = idx_in_front
        rays3d_3xN_filt = points3d_4xN[0:3, idx_valid]

        # 2.- Distort rays3d if distorted
        rays3d_3xN = np.full([3, n], np.nan)  # init with NaN
        rays3d_3xN[:, idx_valid] = rays3d_3xN_filt  # copy filtered points

        if self.is_distorted():
            # Cameras with distortion: need to first apply distortion model
            # Pinhole distortion
            if self.r_limit is not None:
                # Some cameras have a valid radius limit: extreme points are weirdly
                # distorted, it is better to keep them as NaN
                for i in range(0, n):
                    if idx_valid[i]:  # ignore those already filtered
                        xp = (
                            rays3d_3xN[0, i] / rays3d_3xN[2, i]
                        )  # this is x'=x/z as in opencv docs
                        yp = rays3d_3xN[1, i] / rays3d_3xN[2, i]  # this is y'=y/z
                        r = np.sqrt(xp * xp + yp * yp)

                        if (
                            r >= self.r_limit * 0.8
                        ):  # 0.8 to also remove very close to limit
                            idx_valid[i] = False
                            rays3d_3xN[:, i] = np.nan

            # Now distort (only non-nans)
            rays3d_3xN_filt = rays3d_3xN[:, idx_valid]
            # no nan should go into it
            rays3d_3xN_filt_dist = self.distort_rays3d(rays3d_3xN_filt)

            # Add nans
            rays3d_3xN[:, idx_valid] = rays3d_3xN_filt_dist

        # 3.- Project using calibration matrix
        rays3d_4xN = np.vstack((rays3d_3xN, np.ones(rays3d_3xN.shape[1])))
        points2d_3xN = self.K_3x4 @ rays3d_4xN
        points2d_3xN /= points2d_3xN[2, :]

        if remove_outside:
            points2d_3xN, idx_valid = utils.filter_outside(
                points2d_3xN, self.img_size_dist, idx_valid
            )

        return points2d_3xN, idx_valid

    def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray[np.float64]:
        """
        Reproject points in the image domain as rays in 3D coordinates.

        (camera coordinate system)
        """
        # Get rays3d applying K^-1 and then undistortion
        rays3d_dist_3xN = utils.normalize(utils.inv(self.K_3x3).dot(points2d_3xN))
        rays3d_dist_3xN = np.array(rays3d_dist_3xN)
        rays3d_3xN = self.undistort_rays3d(rays3d_dist_3xN=rays3d_dist_3xN)

        return rays3d_3xN

    #################################
    # Inner functions
    #################################
    def __has_remaps(self) -> bool:
        if self.mapX_to_und_16SC2 is None or self.mapY_to_und_16SC2 is None:
            return False
        return True

    def __compute_remaps(self):
        start = time.time()
        self.mapX_to_und_16SC2, self.mapY_to_und_16SC2 = cv.initUndistortRectifyMap(
            self.K_3x3,
            self.d_1xN,
            R=np.eye(3),
            newCameraMatrix=self.K_und_3x3,
            size=self.img_size_undist,
            m1type=cv.CV_16SC2,
        )
        end = time.time()
        print("CameraPinhole(radial): Compute remaps for undistortion... ", end - start)

    #################################
    # Other public functions
    #################################
    def undistort_image(self, img: cv.Mat) -> cv.Mat:
        if not self.is_distorted():
            return img
        if not self.__has_remaps():
            self.__compute_remaps()
        # cv.remap works for both models cv. and cv.fisheye
        return cv.remap(
            img,
            self.mapX_to_und_16SC2,
            self.mapY_to_und_16SC2,
            interpolation=cv.INTER_LINEAR,
            borderMode=cv.BORDER_CONSTANT,
        )

    def is_distorted(self) -> bool:
        return np.count_nonzero(self.d_1xN) > 0

    def distort_points2d(self, points2d_und_3xN: npt.NDArray) -> npt.NDArray:
        """
        Project from undistorted to distorted images.

        :param points2d_und_3xN: undistorted points 3xN homogeneous coordinates
        :return: distorted points 3xN homogeneous coordinates
        """
        rays3d_und_3xN = utils.inv(self.K_und_3x3) @ points2d_und_3xN
        rays3d_dist_3xN = self.distort_rays3d(rays3d_und_3xN)
        points2d_dist_3xN = self.K_3x3 @ rays3d_dist_3xN
        return points2d_dist_3xN

    def undistort_points2d(self, points2d_dist_3xN: npt.NDArray) -> npt.NDArray:
        """
        Transfer from the distorted domain to the undistorted domain.

        E.g. img_und = self.camera.undistort_image(_img) points2d_und_3xN =
        self.camera.undistort_points2d(points2d_3xN) rows, cols = points2d_und_3xN.shape for i
        in range(0, cols):     cv.circle(img_und, (utils.round(points2d_und_3xN[0, i]),
        utils.round(points2d_und_3xN[1, i])), 2, (255, 255, 255), -1)
        cv.namedWindow('undistorted-test', cv.WINDOW_NORMAL) cv.imshow('undistorted-test',
        img_und)
        :param points2d_3xN: array of 2d points in homogeneous coordinates 3xN
        :return: array of undistorted 2d points in homogeneous coordinates 3xN
        """
        n = points2d_dist_3xN.shape[1]
        if n < 1 or not self.is_distorted():
            return points2d_dist_3xN

        # Change shape from (3, N) to (N, 1, 2) so we can use OpenCV, removing
        # homogeneous coordinate
        temp1 = points2d_dist_3xN[0:2, :]
        temp2 = utils.from_MxN_to_OpenCV_Nx1xM(temp1)

        # Use OpenCV functions
        temp3 = cv.undistortPoints(temp2, self.K_3x3, self.d_1xN)

        # Reshape to (3, N)
        temp3.shape = (n, 2)
        points2d_und_3xN = np.vstack((temp3.T, np.ones((1, n))))

        # Map into undistorted domain by using K_3x3_und
        points2d_und_3xN = self.K_und_3x3 @ points2d_und_3xN

        # TODO: Add this test to test_scl.py
        test = False
        if test:
            points2d_dist_re_3xN = self.distort_points2d(points2d_und_3xN)
            error = np.linalg.norm(points2d_dist_re_3xN - points2d_dist_3xN)
            print("Undistortion error: ", error)

        return points2d_und_3xN

    def undistort_rays3d(self, rays3d_dist_3xN: npt.NDArray) -> npt.NDArray[np.float64]:
        points2d_dist_3xN: npt.NDArray = rays3d_dist_3xN / rays3d_dist_3xN[2, :]
        n = points2d_dist_3xN.shape[1]
        if n < 1 or not self.is_distorted():
            return points2d_dist_3xN

        # Change shape from (3, N) to (N, 1, 2) so we can use OpenCV, removing homogeneous
        #  coordinate
        temp1 = points2d_dist_3xN[0:2, :]
        temp2 = utils.from_MxN_to_OpenCV_Nx1xM(temp1)

        # Use OpenCV functions (using an eye instead of K to work with rays instead of points)
        temp3: npt.NDArray = cv.undistortPoints(temp2, np.eye(3), self.d_1xN)

        # Reshape to (3, N)
        temp3.shape = (n, 2)
        points2d_und_3xN = np.vstack((temp3.T, np.ones((1, n))))
        rays3d_und_3xN = utils.normalize(points2d_und_3xN)
        rays3d_und_3xN = np.array(rays3d_und_3xN, np.float64)
        return rays3d_und_3xN


class CameraFisheye(Camera):  # pylint: disable=too-many-instance-attributes
    """
    Fisheye cameras, with field of view around 180º.

    Different distortion models are considered (see distort_rays3d) For simplicity, the
    projection process is splitted in two steps: ray distortion, and ray linear projection.
    """

    def __init__(
        self,
        camera_intrinsics: dict[str, Any],
        name: str,
        description: str,
        uri: str,
        compute_remaps: bool = False,
        limit_to_180_degrees: bool = False,
    ):
        self.cx = camera_intrinsics["center_x"]
        self.cy = camera_intrinsics["center_y"]
        self.img_size_dist = (
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
        )
        self.img_size_undist = (
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
        )
        self.limit_to_180_degrees = limit_to_180_degrees

        if "focal_length_x" in camera_intrinsics:
            self.focal_length_x = camera_intrinsics["focal_length_x"]
            if "focal_length_y" in camera_intrinsics:
                self.focal_length_y = camera_intrinsics["focal_length_y"]
                self.aspect_ratio = self.focal_length_x / self.focal_length_y
            elif "aspect_ratio" in camera_intrinsics:
                self.aspect_ratio = camera_intrinsics["aspect_ratio"]
                self.focal_length_y = self.focal_length_x / self.aspect_ratio
            else:
                self.focal_length_y = self.focal_length_x
                self.aspect_ratio = 1.0
        else:
            self.focal_length_x = 1.0
            self.focal_length_y = 1.0
            self.aspect_ratio = 1.0

        self.K_3x4 = np.array(
            [
                [self.focal_length_x, 0.0, self.cx, 0.0],
                [0.0, self.focal_length_y, self.cy, 0.0],
                [0.0, 0.0, 1.0, 0.0],
            ]
        )
        self.K_3x3 = utils.fromCameraMatrix3x4toCameraMatrix3x3(self.K_3x4)

        # Direct distortion (from angle of incidence to radius in image)
        dist_length = len(camera_intrinsics["lens_coeffs_1xN"])
        self.d_1xN = np.array(camera_intrinsics["lens_coeffs_1xN"]).reshape(
            1, dist_length
        )
        self.model = camera_intrinsics["model"]

        # Inverse distortion (from radius in image to incidence angle)
        self.error_a_deg = 0.0
        # M is not necessarily N, we can use any polynomial to invert
        self.d_inv_1xM = self.__invert_polynomial(self.d_1xN, self.model)
        if compute_remaps:
            self.K_und_3x3 = self.estimate_new_camera_matrix_for_undistort_rectify(
                img_size_orig=self.img_size_dist,
                balance=0.9,
                img_size_dst=self.img_size_undist,
                fov_scale=1.0,
            )
            self.K_und_3x4 = utils.fromCameraMatrix3x3toCameraMatrix3x4(self.K_und_3x3)

            self.mapX_to_und_16SC2 = None
            self.mapY_to_und_16SC2 = None

        if compute_remaps:
            self.__compute_remaps()

        Camera.__init__(
            self,
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
            name,
            description,
            uri,
        )

    #################################
    # Inherited functions
    #################################
    def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray[np.float64]:
        """
        This function projects 3d points in camera coordinate system using the angle-of-
        incidence projection model. Any 3D point in space P=(X,Y,Z,1)^T has a radius with
        respect to the optical axis Z r = ||X^2 + Y^2|| The angle of incidence to the optical
        center is a = atan(r/Z)

        The angle of incidence then spans from 0 to pi/2 The model of the lens relates the
        angle of incidence with the radius of the point in the image plane (in pixels):
        "radial_poly" (4 distortion coefficients)     rp = k1*a + k2*a^2 + k3*a^3 + k4*a^4
        "kannala" (5 distortion coefficients)     rp = k1*a + k2*a^3 + k3*a^5 + k4*a^7 + k5*a^9
        "opencv_fisheye" (4 distortion coefficients, equivalent to Kannala with k1=1.0, so only
        the                  last 4 terms are used     rp = a + k1*a^3 + k2*a^5 + k3*a^7 +
        k4*a^9

        Then, the distorted 3d rays are computed as: ray_dist = (X*rp/r, Y*rp/r, 1)

        :param rays3d_3xN: 3d rays as 3xN arrays
        :return: distorted 3d rays as 3xN arrays after applying distortion function.
        """  # noqa
        n = rays3d_3xN.shape[1]
        if n == 0 or not self.is_distorted():
            rays3d_dist_3xN = np.array([[]])
            return rays3d_dist_3xN

        rays3d_dist_3xN = np.zeros((3, n))
        for i in range(0, n):
            x = rays3d_3xN[0, i]
            y = rays3d_3xN[1, i]
            z = rays3d_3xN[2, i]
            r = utils.norm([x, y])
            a = math.atan2(r, z)
            # direct distortion, use specified model
            rp = self.__apply_polynomial(a, self.d_1xN.flatten().tolist(), self.model)
            rp_r = float(rp / r)
            if r > 1e-8:
                rays3d_dist_3xN[0, i] = x * rp_r
                rays3d_dist_3xN[1, i] = y * rp_r
                rays3d_dist_3xN[2, i] = 1
            else:
                rays3d_dist_3xN[0, i] = 0
                rays3d_dist_3xN[1, i] = 0
                rays3d_dist_3xN[2, i] = 1

        return rays3d_dist_3xN

    def project_points3d(
        self, points3d_4xN: npt.NDArray, remove_outside: bool = False
    ) -> tuple[npt.NDArray[np.floating], list[bool]]:
        """
        Project 3d points in camera coordinate system into the image plane.

        First, 3d points P=(X,Y,Z,1)^T are treated as 3d rays, and distorted using the angle of
        incidence equation. Distorted 3d rays are then projected using the linear camera
        calibration matrix K.

        :param points3d_4xN: 3D points in camera coordinate system
        :param remove_outside: filter out points that fall outside the limits of the image
            frame.
        :return: 2D points in image plane, as 3xN array, in hom. coordinates, and boolean array
            of valid
        """
        # 0.- Pre-filter
        if points3d_4xN.ndim != 2:
            raise ValueError("Invalid argument 'point3d_4xN' dimensions")
        n = points3d_4xN.shape[1]
        if n == 0:
            return np.array([[]]), []

        # 1.- Select only those z > 0 (in front of the camera)
        if self.limit_to_180_degrees:
            idx_in_front: list[bool] = (points3d_4xN[2, :] > 1e-8).tolist()
            idx_valid = idx_in_front
        else:
            idx_valid = [True] * n

        # 2.- Distort rays3d if distorted
        rays3d_3xN_filt = points3d_4xN[0:3, idx_valid]
        rays3d_3xN = np.full([3, n], np.nan)
        rays3d_3xN[:, idx_valid] = rays3d_3xN_filt

        # Now distort (only non-nans)
        rays3d_3xN_filt = rays3d_3xN[:, idx_valid]
        # no nan should go into it
        rays3d_3xN_filt_dist = self.distort_rays3d(rays3d_3xN_filt)

        # Add nans
        rays3d_3xN = np.full([3, n], np.nan)
        rays3d_3xN[:, idx_valid] = rays3d_3xN_filt_dist

        # 3.- Project using calibration matrix
        points2d_3xN = self.K_3x3.dot(rays3d_3xN)
        if remove_outside:
            points2d_3xN, idx_valid = utils.filter_outside(
                points2d_3xN, self.img_size_dist, idx_valid
            )

        return points2d_3xN, idx_valid

    def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray:
        # Get rays3d applying K^-1 and then undistortion
        rays3d_dist_3xN = utils.inv(self.K_3x3).dot(points2d_3xN)
        rays3d_3xN = self.undistort_rays3d(rays3d_dist_3xN=rays3d_dist_3xN)

        return rays3d_3xN

    #################################
    # Inner functions
    #################################
    def __has_remaps(self) -> bool:
        if self.mapX_to_und_16SC2 is None or self.mapY_to_und_16SC2 is None:
            return False
        return True

    def __compute_remaps(self):
        start = time.time()
        (
            self.mapX_to_und_16SC2,
            self.mapY_to_und_16SC2,
        ) = self.init_undistort_rectify_map(self.K_und_3x3, self.img_size_undist)
        end = time.time()
        print("CameraFisheye: Compute remaps for undistortion... ", end - start)

    def __polynomial_with_offset(
        self,
        x: npt.NDArray[np.floating] | float,
        k1: float,
        k2: float,
        k3: float,
        k4: float,
        k5: float,
        k6: float,
        k7: float,
        k8: float,
        k9: float,
    ) -> npt.NDArray[np.floating] | float:
        x2 = x * x
        x3 = x2 * x
        x4 = x3 * x
        x5 = x4 * x
        x6 = x5 * x
        x7 = x6 * x
        x8 = x7 * x

        temp = (
            x * k2 + x2 * k3 + x3 * k4 + x4 * k5 + x5 * k6 + x6 * k7 + x7 * k8 + x8 * k9
        )
        r = k1 + temp
        return r

    def __radialpoly_model_function(
        self,
        x: npt.NDArray[np.floating] | float,
        k1: float,
        k2: float,
        k3: float,
        k4: float,
    ) -> npt.NDArray[np.floating] | float:
        x2 = x * x
        x3 = x2 * x
        x4 = x3 * x
        return x * k1 + x2 * k2 + x3 * k3 + x4 * k4

    def __kannala_model_function(
        self,
        x: npt.NDArray[np.floating] | float,
        k1: float,
        k2: float,
        k3: float,
        k4: float,
        k5: float,
    ) -> npt.NDArray[np.floating] | float:
        x2 = x * x
        x3 = x2 * x
        x5 = x3 * x2
        x7 = x5 * x2
        x9 = x7 * x2
        return x * k1 + x3 * k2 + x5 * k3 + x7 * k4 + x9 * k5

    def __apply_polynomial(
        self, x: npt.NDArray[np.floating] | float, k: list, model: str
    ) -> npt.NDArray[np.floating] | float:
        if model == "radial_poly":
            return self.__radialpoly_model_function(x, k[0], k[1], k[2], k[3])
        if model == "kannala":
            return self.__kannala_model_function(x, k[0], k[1], k[2], k[3], k[4])
        if model == "polynomial_offset":
            return self.__polynomial_with_offset(
                x, k[0], k[1], k[2], k[3], k[4], k[5], k[6], k[7], k[8]
            )

        raise ValueError(
            "Unsupported distortion model. "
            "Use radial_poly, kannala or polynomial_offset"
        )

    def __invert_polynomial(
        self, d: npt.NDArray, model: str, n: int = 100
    ) -> npt.NDArray:
        a = np.linspace(0, np.pi / 2, num=n)
        k_list = d.flatten().tolist()

        rp = self.__apply_polynomial(a, k_list, model)  # direct distortion, using model

        # For the inverse polynomial, we can use numpy's polyfit, which fits
        # p(x)=p(0)*x**deg + ... + p(deg)
        dim = 9  # dimensions of the offset polynomial to represent the inverse distortion
        # polyfit return reversed coefficients with offset
        kp = np.polyfit(rp, a, dim - 1)
        kp_list: list = kp.tolist()
        kp_list.reverse()  # kp_list[-1] is the offset
        a_rep = self.__apply_polynomial(rp, kp_list, "polynomial_offset")

        error_a = np.sum(np.abs(a - a_rep)) / n
        self.error_a_deg = error_a * 180 / np.pi

        # fig, (ax1, ax2) = plt.subplots(1, 2)
        # fig.suptitle('Distortion')
        # ax1.plot(a, rp)
        # ax1.set(xlabel='a', ylabel='rp', title='Distortion direct')
        # ax1.grid()
        # ax2.plot(a_rep, rp)
        # ax2.set(xlabel='a_rep', ylabel='rp', title='Distortion reprojected')
        # fig.savefig('distortion_ret.png')
        # plt.show()

        if self.error_a_deg > 1e-1:
            warnings.warn(
                "WARNING: the inverse of the CameraFisheye distortion produces reprojection "
                " error > 1e-2 (i.e. higher than tenth of degree)",
                Warning,
                2,
            )

        return np.array(kp_list)

    #################################
    # Other public functions
    #################################
    def undistort_image(self, img: cv.Mat) -> cv.Mat:
        if not self.is_distorted():
            return img
        if not self.__has_remaps():
            self.__compute_remaps()
        return cv.remap(
            img,
            self.mapX_to_und_16SC2,
            self.mapY_to_und_16SC2,
            interpolation=cv.INTER_LINEAR,
            borderMode=cv.BORDER_CONSTANT,
        )

    def undistort_points2d(self, points2d_dist_3xN: npt.NDArray) -> npt.NDArray:
        # This is a transfer from the distorted to the undistorted
        rays3d_dist_3xN = utils.inv(self.K_3x3).dot(points2d_dist_3xN)
        rays3d_und_3xN = self.undistort_rays3d(rays3d_dist_3xN)
        points2d_und_3xN = self.K_und_3x3.dot(rays3d_und_3xN)

        return points2d_und_3xN

    def distort_points2d(self, points2d_und_3xN: npt.NDArray) -> npt.NDArray:
        # This is a transfer from the undistorted domain to the distorted
        rays3d_und_3xN = utils.inv(self.K_und_3x3).dot(points2d_und_3xN)
        rays3d_dist_3xN = self.distort_rays3d(
            rays3d_und_3xN
        )  # TODO: better to use self.project_points?
        points2d_dist_3xN = self.K_3x3.dot(rays3d_dist_3xN)

        return points2d_dist_3xN

    def is_distorted(self) -> bool:
        return np.count_nonzero(self.d_1xN) > 0

    def undistort_rays3d(self, rays3d_dist_3xN: npt.NDArray) -> npt.NDArray[np.floating]:
        """
        Undistort 3d rays according to the inverse polynomial function.

        :param rays3d_dist_3xN: 3d rays (connecting optical center and image plane using K)
        :return: undistorted rays as 3xN
        """
        n = rays3d_dist_3xN.shape[1]
        if n == 0 or not self.is_distorted():
            rays3d_und_3xN = np.array([[]])
            return rays3d_und_3xN

        rays3d_und_3xN = np.zeros((3, n))
        for i in range(0, n):
            x = rays3d_dist_3xN[0, i]
            y = rays3d_dist_3xN[1, i]
            rp = utils.norm([x, y])
            # NOTE: the inverse polynomial is fixed to dim=4 with offset
            a = self.__apply_polynomial(
                rp, self.d_inv_1xM.flatten().tolist(), "polynomial_offset"
            )
            r = math.tan(a)
            r_rp = float(r / rp)
            if rp > 1e-8:
                rays3d_und_3xN[0, i] = x * r_rp
                rays3d_und_3xN[1, i] = y * r_rp
                rays3d_und_3xN[2, i] = 1
            else:
                rays3d_und_3xN[0, i] = 0
                rays3d_und_3xN[1, i] = 0
                rays3d_und_3xN[2, i] = 1

        return rays3d_und_3xN

    def init_undistort_rectify_map(
        self, k_und_3x3: npt.NDArray, img_size_undist: tuple[int, int]
    ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
        # Create maps
        w = img_size_undist[0]
        h = img_size_undist[1]
        map1 = np.zeros((h, w), dtype=np.float32)
        map2 = np.zeros((h, w), dtype=np.float32)

        # Loop over undistorted domain
        for i in range(0, h):
            # Read all pixel pos of this row
            points2d_und_3xN = np.array(
                [np.linspace(0, w - 1, num=w), i * np.ones(w), np.ones(w)]
            )

            # Un-apply K_und to get the undistorted ray
            ray3d_und_3xN = utils.inv(k_und_3x3).dot(points2d_und_3xN)
            ray3d_und_3xN[0:3, :] = ray3d_und_3xN[0:3, :] / ray3d_und_3xN[2, :]
            ray3d_und_4xN = np.vstack((ray3d_und_3xN, np.ones(w)))

            # Project
            points2d_dist_3xN, idx_valid = self.project_points3d(ray3d_und_4xN)

            # Assign into map
            map1[i, :] = points2d_dist_3xN[0, :]
            map2[i, :] = points2d_dist_3xN[1, :]

        # Return the maps
        return map1, map2

    def estimate_new_camera_matrix_for_undistort_rectify(
        self,
        img_size_orig: tuple[int, int],
        balance: float,
        img_size_dst: tuple[int, int],
        fov_scale: float,
    ) -> npt.NDArray:
        balance = min(max(balance, 0.0), 1.0)
        w = img_size_orig[0]
        h = img_size_orig[1]
        romboid_points = np.array(
            [[w / 2, 0, 1], [w, h / 2, 1], [w / 2, h, 1], [0, h / 2, 1]]
        ).transpose()

        rays3d_und_3xN = self.undistort_rays3d(romboid_points)
        center_mass = cv.mean(rays3d_und_3xN)
        cn = center_mass
        aspect_ratio = 1.0

        # Find maxima
        minx = float("inf")
        miny = float("inf")
        maxx = -float("inf")
        maxy = -float("inf")
        for i in range(0, 4):
            miny = min(miny, rays3d_und_3xN[1, i])
            maxy = max(maxy, rays3d_und_3xN[1, i])
            minx = min(minx, rays3d_und_3xN[0, i])
            maxx = max(maxx, rays3d_und_3xN[0, i])

        f1 = w * 0.5 / (cn[0] - minx)
        f2 = w * 0.5 / (maxx - cn[0])
        f3 = h * 0.5 * aspect_ratio / (cn[1] - miny)
        f4 = h * 0.5 * aspect_ratio / (maxy - cn[1])

        fmin = min(f1, f2, f3, f4)
        fmax = max(f1, f2, f3, f4)

        f = balance * fmin + (1.0 - balance) * fmax
        if fov_scale > 0:
            f *= 1.0 / fov_scale

        new_f = (f, f / aspect_ratio)
        new_c = (
            w * 0.5 - cn[0] * f,
            ((h * aspect_ratio) * 0.5 - cn[0] * f) / aspect_ratio,
        )

        rx = img_size_dst[0] / img_size_orig[0]
        ry = img_size_dst[1] / img_size_orig[1]

        k_new = np.array(
            [
                [new_f[0] * rx, 0, new_c[0] * rx],
                [0, new_f[1] * ry, new_c[1] * ry],
                [0, 0, 1],
            ]
        )

        return k_new


class CameraCylindrical(Camera):
    def __init__(
        self, camera_intrinsics: dict[str, Any], name: str, description: str, uri: str
    ):
        Camera.__init__(
            self,
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
            name,
            description,
            uri,
        )

        self.img_size = (self.width, self.height)
        self.fovh = camera_intrinsics["fov_horz_rad"]
        self.fovv = camera_intrinsics["fov_vert_rad"]

        # Converting from [fovh, 0] into [0, width]
        self.mx = -self.width / self.fovh
        self.nx = self.width
        # Converting from [fovv/2, -fovv/2] into [0, height]
        self.my = -self.height / self.fovv
        self.ny = self.height / 2.0

        # Create calibration matrix
        self.K_3x4 = np.array(
            [
                [self.mx, 0.0, self.nx, 0.0],
                [0.0, self.my, self.ny, 0.0],
                [0.0, 0.0, 1.0, 0.0],
            ]
        )
        self.K_3x3 = utils.fromCameraMatrix3x4toCameraMatrix3x3(self.K_3x4)
        self.K_3x3_inv = utils.inv(self.K_3x3)

    #################################
    # Inherited functions
    #################################
    def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray:
        """Cylindrical cameras don't have distortion."""
        return rays3d_3xN

    def project_points3d(
        self, points3d_4xN: npt.NDArray, remove_outside: bool = False
    ) -> tuple[npt.NDArray[np.floating], list[bool]]:
        # 1.- Select only those z > 0 (in front of the camera)
        idx_in_front: list[bool] = (points3d_4xN[2, :] > 1e-8).tolist()
        idx_valid = idx_in_front
        # rays3d_3xN_filt = points3d_4xN[0:3, idx_valid]
        rays3d_3xN_filt = points3d_4xN[0:3]  # , idx_valid]

        # Two-step process: use Ray2LL and the LL2Pixel
        lonlat_2xN = self.__ray2ll(rays3d_3xN_filt, idx_valid)
        points2d_3xN = self.__ll2pixel(lonlat_2xN)

        # Declare as non-valid points outside the limits of the image
        if remove_outside:
            points2d_3xN, idx_valid = utils.filter_outside(
                points2d_3xN, self.img_size, idx_valid
            )

        return points2d_3xN, idx_valid

    def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray:
        lonlat_2xN = self.__pixel2ll(points2d_3xN)
        return self.__ll2ray(lonlat_2xN)

    #################################
    # Inner functions
    #################################
    # ----------------------------------------------#
    #                From 2D --> 3D                 #
    # ----------------------------------------------#
    def __ll2ray(self, lonlat_2xN: npt.NDArray) -> npt.NDArray:
        lon = lonlat_2xN[0, :]
        lat = lonlat_2xN[1, :]
        x = np.cos(lon) * np.cos(lat)
        y = -np.sin(lat)
        z = np.sin(lon) * np.cos(lat)

        points3d_3xN = np.vstack((np.vstack((x, y)), z))
        return points3d_3xN

    def __pixel2ll(self, points2d_3xN: npt.NDArray) -> npt.NDArray:
        # See LL2Ray
        # lon = (1/self.mx)*(x-self.nx)
        # lat = (1/self.my)*(y-self.ny)

        lonlat_3xN = self.K_3x3_inv @ points2d_3xN
        lonlat_3xN /= lonlat_3xN[2, :]

        return lonlat_3xN[0:2, :]

    # ----------------------------------------------
    # From 3D -> 2D
    # ----------------------------------------------
    def __ray2ll(
        self, ray3d_4xN: npt.NDArray, idx_valid: list[bool]
    ) -> npt.NDArray[np.floating]:
        # See http://paulbourke.net/dome/dualfish2sphere/
        # but modified so the camera model is z-optical axis, x-right, y-bottom
        x = ray3d_4xN[0, :]
        y = ray3d_4xN[1, :]
        z = ray3d_4xN[2, :]

        lon_1xN = np.zeros(x.shape)
        np.arctan2(z, x, out=lon_1xN, where=idx_valid)
        lat_1xN = np.zeros(y.shape)
        np.arctan2(-y, np.sqrt(x * x + z * z), out=lat_1xN, where=idx_valid)

        # lon_1xN = np.arctan2(ray3d_4xN[1, :], ray3d_4xN[0, :])
        # lat_1xN = np.arctan2(
        #     ray3d_4xN[2, :],
        #     np.sqrt(ray3d_4xN[0, :] * ray3d_4xN[0, :] + ray3d_4xN[1, :] * ray3d_4xN[1, :]),
        # )
        lonlat_2xN = np.vstack((lon_1xN, lat_1xN))
        return lonlat_2xN

    def __ll2pixel(self, lonlat_2xN: npt.NDArray) -> npt.NDArray[np.floating]:
        lonlat_3xN = np.vstack((lonlat_2xN, np.ones((1, lonlat_2xN.shape[1]))))
        points2d_3xN = self.K_3x3 @ lonlat_3xN
        # px = self.mx*lon + self.nx
        # py = self.my*lat + self.ny
        return points2d_3xN


class CameraOrthographic(Camera):
    """
    Definition of Orthographic camera model.

    This is a special type of camera which does not project 3d entities using a pinhole nor
    distorted lens. It does project 3d points into the Z_cam = 0 image plane keeping their
    X_cam and Y_cam values. A rectangular frustum is created defining the (xmax, xmin, ymax,
    ymin) clipping planes (in camera coordinate system). To keep coherency with other Cameras
    in VCD, the camera model has Z-front, X-right, Y-bottom.

    An additional scaling and centering camera calibration matrix is used to create images with
    desired number of pixels.
    """

    def __init__(
        self, camera_intrinsics: dict[str, Any], name: str, description: str, uri: str
    ):
        Camera.__init__(
            self,
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
            name,
            description,
            uri,
        )

        self.img_size = (self.width, self.height)
        self.xmax = camera_intrinsics["xmax"]
        self.xmin = camera_intrinsics["xmin"]
        self.ymax = camera_intrinsics["ymax"]
        self.ymin = camera_intrinsics["ymin"]

        # Create calibration matrix
        sx = self.width * (1.0 / (self.xmax - self.xmin))
        sy = self.height * (1.0 / (self.ymax - self.ymin))
        cx = -self.xmin * sx
        cy = -self.ymin * sy
        # self.K_3x3 = np.array([[sx, 0.0, cx],
        #                       [0.0, sy, cy],
        #                       [0.0, 0.0,1.0]])

        self.K_3x4 = np.array(
            [[sx, 0.0, 0.0, cx], [0.0, sy, 0.0, cy], [0.0, 0.0, 0.0, 1.0]]
        )
        # NOTE: K_3x3 has no meaning for an orthographic camera, because it is not a
        # pinhole model, and then there are no rays going through the optical center.
        # 4x1 need to be used along with K_3x4
        #   self.K_3x3 = utils.fromCameraMatrix3x4toCameraMatrix3x3(self.K_3x4)
        #
        # Note that the orthographic projection matrix is not invertible in homogeneous
        # form. Let's do it manually
        sx_inv = 1 / sx
        sy_inv = 1 / sy

        self.K_3x4_inv = np.array(
            [
                [sx_inv, 0.0, self.xmin, 0.0],
                [0.0, sy_inv, self.ymin, 0.0],
                [0.0, 0.0, 1.0, 0.0],
            ]
        )
        self.K_3x3_inv = utils.fromCameraMatrix3x4toCameraMatrix3x3(self.K_3x4_inv)

    #################################
    # Inherited functions
    #################################
    def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray:
        # Orthographic cameras don't have distortion
        return rays3d_3xN

    def project_points3d(
        self, points3d_4xN: npt.NDArray, remove_outside: bool = False
    ) -> tuple[npt.NDArray[np.floating], list[bool]]:
        # 0.- Pre-filter
        if points3d_4xN.ndim != 2:
            raise ValueError("Invalid argument 'point3d_4xN' dimensions")
        n = points3d_4xN.shape[1]
        if n == 0:
            return np.array([[]]), []

        # 1.- Select only those within limits
        idx_is_inside: list[bool] = np.array(
            (points3d_4xN[0, :] > self.xmin)
            & (points3d_4xN[0, :] < self.xmax)
            & (points3d_4xN[1, :] > self.ymin)
            & (points3d_4xN[1, :] < self.ymax)
            & (points3d_4xN[2, :] > 1e-8)
        ).tolist()

        idx_valid = idx_is_inside
        # rays3d_3xN_filt = points3d_4xN[0:3, idx_valid]

        # 2.- Project using calibration matrix
        # rays3d_4xN = np.vstack((rays3d_3xN_filt, np.ones(rays3d_3xN_filt.shape[1])))
        # rays3d_4xN = np.vstack((rays3d_3xN_filt, np.ones(rays3d_3xN_filt.shape[1])))

        points2d_3xN = self.K_3x4 @ points3d_4xN
        # points2d_3xN /= points2d_3xN[2,:] NOTE: Orthographic projection is no friend of
        # homogeneous coordinates

        if remove_outside:
            points2d_3xN, idx_valid = utils.filter_outside(
                points2d_3xN, (self.width, self.height), idx_valid
            )

        return points2d_3xN, idx_valid

    def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray:
        rays3d_3xN = self.K_3x3_inv @ points2d_3xN
        # rays3d_3xN = utils.normalize(rays3d_3xN)
        return rays3d_3xN


class CameraCubemap(Camera):
    """
    Definition of the Cubemap Camera Model.

    The Cubemap camera model defines 6 pinhole cameras all same calibration with FOV 90deg.

    It is intended to build fisheye images from it.
    """

    def __init__(
        self,
        camera_intrinsics: dict[str, Any],
        name: str,
        description: str,
        uri: str,
        compute_remaps: bool = False,
    ):
        Camera.__init__(
            self,
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
            name,
            description,
            uri,
        )

        # Define rotation angles for each Pinhole camera
        r_front = utils.euler2R([0, 0, 0])
        r_back = utils.euler2R([0, -np.pi, 0])
        r_top = utils.euler2R([0, 0, -np.pi / 2])
        r_bottom = utils.euler2R([0, 0, np.pi / 2])
        r_left = utils.euler2R([0, np.pi / 2, 0])
        r_right = utils.euler2R([0, -np.pi / 2, 0])

        # Obtain pose for each Pinhole camera
        c = np.array([0, 0, 0]).reshape(3, 1)
        p_front = utils.create_pose(r_front, c)
        p_back = utils.create_pose(r_back, c)
        p_left = utils.create_pose(r_left, c)
        p_right = utils.create_pose(r_right, c)
        p_top = utils.create_pose(r_top, c)
        p_bottom = utils.create_pose(r_bottom, c)

        self.cameras = {
            "frontal": (
                CameraPinhole(
                    camera_intrinsics=camera_intrinsics,
                    name=name,
                    description=description,
                    uri=uri,
                    compute_remaps=compute_remaps,
                ),
                p_front,
            ),
            "back": (
                CameraPinhole(
                    camera_intrinsics=camera_intrinsics,
                    name=name,
                    description=description,
                    uri=uri,
                    compute_remaps=compute_remaps,
                ),
                p_back,
            ),
            "left": (
                CameraPinhole(
                    camera_intrinsics=camera_intrinsics,
                    name=name,
                    description=description,
                    uri=uri,
                    compute_remaps=compute_remaps,
                ),
                p_left,
            ),
            "right": (
                CameraPinhole(
                    camera_intrinsics=camera_intrinsics,
                    name=name,
                    description=description,
                    uri=uri,
                    compute_remaps=compute_remaps,
                ),
                p_right,
            ),
            "top": (
                CameraPinhole(
                    camera_intrinsics=camera_intrinsics,
                    name=name,
                    description=description,
                    uri=uri,
                    compute_remaps=compute_remaps,
                ),
                p_top,
            ),
            "bottom": (
                CameraPinhole(
                    camera_intrinsics=camera_intrinsics,
                    name=name,
                    description=description,
                    uri=uri,
                    compute_remaps=compute_remaps,
                ),
                p_bottom,
            ),
        }

    #################################
    # Inherited functions
    #################################
    def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray:
        """
        Not implemented.

        This function distort rays3d using the distortion parameters of the camera. As a result
        distorted rays3d are created which can then be projected using the camera calibration
        matrix.

        :param rays3d_3xN: Array with N 3D rays, each of them as column (rx, ry, rz)
        :return: rays3d_dist_3xN: Array with N distorted 3D rays, each of them as column (rx',
            ry', rz')
        """
        return rays3d_3xN

    def project_points3d(
        self, points3d_4xN: npt.NDArray, remove_outside: bool = False
    ) -> tuple[npt.NDArray[np.floating], list[bool]]:
        # Choose which pinhole camera
        x = points3d_4xN[0, :]
        y = points3d_4xN[1, :]
        z = points3d_4xN[2, :]

        max_axis = np.maximum.reduce([abs(x), abs(y), abs(z)])

        right = 0
        left = 1
        bottom = 2
        top = 3
        frontal = 4
        back = 5

        face = np.full((points3d_4xN.shape), None)
        face[:, np.where(np.isclose(max_axis, x))] = right  # right
        face[:, np.where(np.isclose(max_axis, -x))] = left  # left
        face[:, np.where(np.isclose(max_axis, y))] = bottom  # bottom
        face[:, np.where(np.isclose(max_axis, -y))] = top  # top
        face[:, np.where(np.isclose(max_axis, z))] = frontal  # frontal
        face[:, np.where(np.isclose(max_axis, -z))] = back  # back

        # Apply
        frontal_points = points3d_4xN[:, np.all(face == frontal, axis=0)]
        right_points = points3d_4xN[:, np.all(face == right, axis=0)]
        left_points = points3d_4xN[:, np.all(face == left, axis=0)]
        bottom_points = points3d_4xN[:, np.all(face == bottom, axis=0)]
        top_points = points3d_4xN[:, np.all(face == top, axis=0)]
        back_points = points3d_4xN[:, np.all(face == back, axis=0)]

        right_points = utils.transform_points3d_4xN(
            right_points, self.cameras["right"][1]
        )
        left_points = utils.transform_points3d_4xN(left_points, self.cameras["left"][1])
        bottom_points = utils.transform_points3d_4xN(
            bottom_points, self.cameras["bottom"][1]
        )
        top_points = utils.transform_points3d_4xN(top_points, self.cameras["top"][1])
        back_points = utils.transform_points3d_4xN(back_points, self.cameras["back"][1])

        points2d_3xN = np.zeros((3, points3d_4xN.shape[1]))

        if frontal_points.size != 0:
            front_cords, _ = self.cameras["frontal"][0].project_points3d(
                points3d_4xN=frontal_points
            )
            # Add cubemap offset of frontal pinhole
            front_cords[0, :] = (
                front_cords[0, :] + self.cameras["frontal"][0].img_size_undist[0]
            )
            front_cords[1, :] = (
                front_cords[1, :] + self.cameras["frontal"][0].img_size_undist[1]
            )
            points2d_3xN[:, np.all(face == frontal, axis=0)] = front_cords

        if left_points.size != 0:
            left_cords, _ = self.cameras["left"][0].project_points3d(
                points3d_4xN=left_points
            )
            left_cords[1, :] = (
                left_cords[1, :] + self.cameras["left"][0].img_size_undist[0]
            )
            left_cords[0, :] = left_cords[0, :]
            points2d_3xN[:, np.all(face == left, axis=0)] = left_cords

        if right_points.size != 0:
            right_cords, _ = self.cameras["right"][0].project_points3d(
                points3d_4xN=right_points
            )
            right_cords[1, :] = (
                right_cords[1, :] + self.cameras["right"][0].img_size_undist[0]
            )
            right_cords[0, :] = (
                right_cords[0, :] + 2 * self.cameras["right"][0].img_size_undist[1]
            )
            points2d_3xN[:, np.all(face == right, axis=0)] = right_cords

        if bottom_points.size != 0:
            bottom_cords, _ = self.cameras["bottom"][0].project_points3d(
                points3d_4xN=bottom_points
            )
            bottom_cords[1, :] = (
                bottom_cords[1, :] + 2 * self.cameras["bottom"][0].img_size_undist[0]
            )
            bottom_cords[0, :] = (
                bottom_cords[0, :] + self.cameras["bottom"][0].img_size_undist[1]
            )
            points2d_3xN[:, np.all(face == bottom, axis=0)] = bottom_cords

        if top_points.size != 0:
            top_cords, _ = self.cameras["top"][0].project_points3d(
                points3d_4xN=top_points
            )
            # top_cords[1,:] = top_cords[1,:] + self.cameras["top"][0].img_size_undist[0]
            top_cords[0, :] = top_cords[0, :] + self.cameras["top"][0].img_size_undist[1]
            points2d_3xN[:, np.all(face == top, axis=0)] = top_cords

        if back_points.size != 0:
            back_cords, _ = self.cameras["back"][0].project_points3d(
                points3d_4xN=back_points
            )
            back_cords[1, :] = (
                back_cords[1, :] + self.cameras["back"][0].img_size_undist[0]
            )
            back_cords[0, :] = (
                back_cords[0, :] + 3 * self.cameras["back"][0].img_size_undist[1]
            )
            points2d_3xN[:, np.all(face == 5, axis=0)] = back_cords

        # Consider only points behind camera (>180º)
        idx_valid: list[bool] = []

        return points2d_3xN, idx_valid

    def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray:
        """
        Not needed.

        Note: The cubemap camera is used as a step in order to generate fisheye cameras.
        Therefore there is no use in reprojecting points in the cubemap image domain as
        rays in 3D coordinates.
        That is why the reprojection function has not been implemented for this camera
        model.
        """
        raise NotImplementedError(
            "There is no use in reprojecting points in the cubemap image domain"
        )


class Scene:
    """
    Scene class.

    This class defines a scene by reading a VCD file that contains coordinate systems, cameras,
    and objects. The Scene object brings projection functionalities such as creating warped
    images, converting objects from one coordinate system to another, etc.

    # Load a VCD file and create a Scene
    vcd = core.OpenLABEL('myOpenLABELfile.json')
    scene = scl.Scene(vcd)

    # Use the scene to get cameras
    camera_front = scene.get_camera('front')

    # Use cameras
    camera_front.project_points3d(points3d_4xN)

    Attributes:
        vcd (vcd.core.VCD): A vcd object.
    """

    def __init__(self, vcd: core.VCD):
        self.vcd: core.VCD = vcd
        self.cameras: dict = {}

    #########################################
    # Inner functions
    #########################################
    def __get_transform_chain(self, cs_src: str, cs_dst: str) -> deque:
        # Create graph with the poses defined for each coordinate_system
        # These are poses valid "statically"
        lista = []
        root = self.vcd.get_root()
        for cs_name, cs_body in root["coordinate_systems"].items():
            for child in cs_body["children"]:
                lista.append((cs_name, child, 1))
                lista.append((child, cs_name, 1))

        graph = Graph(lista)
        result = graph.dijkstra(cs_src, cs_dst)
        return result

    def __get_camera_object(
        self,
        stream_properties: dict,
        camera_name: str,
        description: str,
        uri: str,
        compute_remaps: bool = False,
    ) -> Camera | None:
        sp = stream_properties

        camera: (
            CameraPinhole
            | CameraFisheye
            | CameraCylindrical
            | CameraOrthographic
            | CameraOrthographic
            | CameraCubemap
            | None
        )

        if "intrinsics_pinhole" in sp:
            camera = CameraPinhole(
                sp["intrinsics_pinhole"],
                camera_name,
                description,
                uri,
                compute_remaps,
            )
        elif "intrinsics_fisheye" in sp:
            camera = CameraFisheye(
                sp["intrinsics_fisheye"],
                camera_name,
                description,
                uri,
                compute_remaps,
                limit_to_180_degrees=True,
            )
        elif "intrinsics_cylindrical" in sp:
            camera = CameraCylindrical(
                sp["intrinsics_cylindrical"], camera_name, description, uri
            )
        elif "intrinsics_orthographic" in sp:
            camera = CameraOrthographic(
                sp["intrinsics_orthographic"], camera_name, description, uri
            )
        elif "intrinsics_cubemap" in sp:
            camera = CameraCubemap(
                sp["intrinsics_cubemap"],
                camera_name,
                description,
                uri,
                compute_remaps,
            )
        else:
            warnings.warn(
                "WARNING: SCL does not support customized camera models. Supported "
                "types are CameraPinhole, CameraFisheye, CameraCylindrical, "
                "CameraOrthographic and CameraCubemap. See types.py.",
                Warning,
                2,
            )
            camera = None

        # Return the created object
        return camera

    #########################################
    # Public functions
    #########################################
    def get_camera(
        self, camera_name: str, frame_num: int | None = None, compute_remaps: bool = False
    ) -> Camera | None:
        """
        Explore the VCD content searching for the camera parameters of camera.

        The parameters are searched using "camera_name", specific for frame_num if specified
        (or static information if None).

        The function consults and updates a store of information self.cameras, to speed up some
        computations (so they are carried out only once).

        Args:
            camera_name (str): name of the camera
            frame_num (int, None): frame number (if None, static camera info is requested)
            compute_remaps: (bool)

        Returns:
            An object of type Camera, which can be used to project points, undistort
            images, etc.
        """
        # Check if already computed
        f = -1 if frame_num is None else frame_num

        # Read basic info about streams
        root = self.vcd.get_root()
        if "streams" in root:
            if camera_name in root["streams"]:
                uri = root["streams"][camera_name]["uri"]
                description = root["streams"][camera_name]["description"]

        # Check if dynamic intrinsics
        dynamic_intrinsics = False
        if frame_num is not None:
            vcd_frame = self.vcd.get_frame(frame_num)
            if "frame_properties" in vcd_frame:
                if "streams" in vcd_frame["frame_properties"]:
                    if camera_name in vcd_frame["frame_properties"]["streams"]:
                        if (
                            "stream_properties"
                            in vcd_frame["frame_properties"]["streams"][camera_name]
                        ):
                            sp = vcd_frame["frame_properties"]["streams"][camera_name][
                                "stream_properties"
                            ]
                            dynamic_intrinsics = True  # SO, there are dynamic intrinsics!

        # Read intrinsics and create/read existing camera
        camera = None
        if dynamic_intrinsics:
            # Create new camera
            if frame_num is not None:
                vcd_frame = self.vcd.get_frame(frame_num)
                sp = vcd_frame["frame_properties"]["streams"][camera_name][
                    "stream_properties"
                ]
                camera = self.__get_camera_object(
                    sp, camera_name, description, uri, compute_remaps
                )
        else:
            # Read already created camera, or, if first time here, create one
            # Let's use f=-1
            f = -1
            if camera_name in self.cameras:
                if f in self.cameras[camera_name]:
                    # return the one labeled as f=-1
                    return self.cameras[camera_name][f]["cam"]

            # Create camera m if needed
            if "streams" in root:
                if camera_name in root["streams"]:
                    uri = root["streams"][camera_name]["uri"]
                    description = root["streams"][camera_name]["description"]
                    if "stream_properties" in root["streams"][camera_name]:
                        sp = root["streams"][camera_name]["stream_properties"]
                        camera = self.__get_camera_object(
                            sp, camera_name, description, uri, compute_remaps
                        )
            else:
                return None

        # Update store (f is -1 if static intrinsics are found, otherwise, a specific
        # frame_num is used)
        self.cameras.setdefault(camera_name, {})
        self.cameras[camera_name].setdefault(f, {})
        self.cameras[camera_name][f]["cam"] = camera

        return camera

    def get_transform(
        self, cs_src: str, cs_dst: str, frame_num: int | None = None
    ) -> tuple[npt.NDArray, bool]:
        """
        Find 4x4 transform from source to destination coordinate systems.

        This function finds a 4x4 transform from the specified source coordinate system into
        the destination coordinate system, in a way points in the cs_src domain can be
        transformed to the cs_dst. The function works finding the chain of transforms needed to
        go from src to dst by exploring the parent-child dependencies declared in VCD.

        If the frame_num is specified, the function searches if any specific transform step at
        frame_num. If not found, static transforms are returned.
        :param cs_src: source coordinate frame (e.g. "CAM_LEFT", or "WORLD")
        :param cs_dst: destination coordinate frame (e.g. "VELO", or "CAM_LEFT")
        :param frame_num: frame number where to look for specific transform steps
        :return: the 4x4 transform matrix, and a boolean that specifies if the transform is
            static or not
        """
        if not self.vcd.has_coordinate_system(cs_src):
            raise ValueError(f"This VCD does not have the coordinate system {cs_src}")
        if not self.vcd.has_coordinate_system(cs_dst):
            raise ValueError(f"This VCD does not have the coordinate system {cs_dst}")

        static = True
        if cs_src == cs_dst:
            return np.eye(4), static

        # Get chain of transforms
        chain = self.__get_transform_chain(cs_src, cs_dst)

        # Let's build the transform using atomic transforms (which exist in VCD)
        t_4x4: npt.NDArray = np.identity(4, dtype=float)
        root = self.vcd.get_root()
        for counter, _value in enumerate(chain):
            # e.g. a) result = {("cam_left", "velo_top"), ("velo_top", "vehicle-iso8855")}
            # e.g. b) result = {("vehicle-iso8855", "velo_top"), ("velo_top", "cam_left")}
            if counter == len(chain) - 1:
                break
            cs_1 = chain[counter]
            cs_2 = chain[counter + 1]

            t_name = cs_1 + "_to_" + cs_2
            t_name_inv = cs_2 + "_to_" + cs_1

            # NOTE: this entire function works under the consensus that
            # pose_src_wrt_dst = transform_src_to_dst, using
            # alias rotation of coordinate systems and linear 4x4
            if frame_num is None:
                # No frame info, let's read from coordinate_system poses
                # Check if this edge is from child to parent or viceversa
                if cs_2 == root["coordinate_systems"][cs_1]["parent"]:
                    temp = utils.get_transform_as_matrix4x4(
                        root["coordinate_systems"][cs_1]["pose_wrt_parent"]
                    )
                    if temp is not None:
                        t_4x4 = temp @ t_4x4
                elif cs_1 == root["coordinate_systems"][cs_2]["parent"]:
                    temp = utils.get_transform_as_matrix4x4(
                        root["coordinate_systems"][cs_2]["pose_wrt_parent"]
                    )
                    if temp is not None:
                        t_4x4 = utils.inv(temp) @ t_4x4

            else:
                # So the user has asked for a specific frame, let's look for this frame if a
                # transform exist
                transform_at_this_frame = False
                if frame_num in root["frames"]:
                    if "frame_properties" in root["frames"][frame_num]:
                        if "transforms" in root["frames"][frame_num]["frame_properties"]:
                            if (
                                t_name
                                in root["frames"][frame_num]["frame_properties"][
                                    "transforms"
                                ]
                            ):
                                transform = root["frames"][frame_num]["frame_properties"][
                                    "transforms"
                                ][t_name]
                                temp = utils.get_transform_as_matrix4x4(
                                    transform["transform_src_to_dst"]
                                )
                                if temp is not None:
                                    t_4x4 = temp.dot(t_4x4)
                                # with one non-static step the entire chain can be
                                # considered not static
                                static = False
                                transform_at_this_frame = True
                            elif (
                                t_name_inv
                                in root["frames"][frame_num]["frame_properties"][
                                    "transforms"
                                ]
                            ):
                                transform = root["frames"][frame_num]["frame_properties"][
                                    "transforms"
                                ][t_name_inv]
                                temp = utils.get_transform_as_matrix4x4(
                                    transform["transform_src_to_dst"]
                                )
                                if temp is not None:
                                    t_4x4 = utils.inv(temp) @ t_4x4
                                static = False
                                transform_at_this_frame = True
                if not transform_at_this_frame:
                    # Reached this point means no transforms were defined at the requested
                    # frame_num
                    # Check if this edge is from child to parent or viceversa
                    if cs_2 == root["coordinate_systems"][cs_1]["parent"]:
                        t_4x4 = (
                            utils.get_transform_as_matrix4x4(
                                root["coordinate_systems"][cs_1]["pose_wrt_parent"]
                            )
                            @ t_4x4
                        )
                    elif cs_1 == root["coordinate_systems"][cs_2]["parent"]:
                        temp = utils.get_transform_as_matrix4x4(
                            root["coordinate_systems"][cs_2]["pose_wrt_parent"]
                        )
                        if temp is not None:
                            t_4x4 = utils.inv(temp) @ t_4x4

        return t_4x4, static

    def transform_points3d_4xN(
        self,
        points3d_4xN: npt.NDArray,
        cs_src: str,
        cs_dst: str,
        frame_num: int | None = None,
    ) -> npt.NDArray | None:
        transform_src_dst, _ = self.get_transform(cs_src, cs_dst, frame_num)
        if transform_src_dst is not None:
            points3d_dst_4xN = utils.transform_points3d_4xN(
                points3d_4xN, transform_src_dst
            )
            return points3d_dst_4xN
        return None

    def transform_cuboid(
        self,
        cuboid_vals: list | tuple,
        cs_src: str,
        cs_dst: str,
        frame_num: int | None = None,
    ) -> list[float]:
        transform_src_dst, _ = self.get_transform(cs_src, cs_dst, frame_num)

        if transform_src_dst is not None:
            cuboid_vals_transformed = utils.transform_cuboid(
                cuboid_vals, transform_src_dst
            )
            return cuboid_vals_transformed

        return list(cuboid_vals)

    def transform_plane(
        self,
        plane_abcd: list[float],
        cs_src: str,
        cs_dst: str,
        frame_num: int | None = None,
    ) -> list[float]:
        transform_src_dst, _ = self.get_transform(cs_src, cs_dst, frame_num)
        if transform_src_dst is not None:
            plane_abcd_transformed = utils.transform_plane(plane_abcd, transform_src_dst)
            return plane_abcd_transformed

        return plane_abcd

    def project_points3d_4xN(
        self,
        points3d_4xN: npt.NDArray,
        cs_src: str,
        cs_cam: str,
        frame_num: int | None = None,
        remove_outside: bool = False,
    ) -> tuple[npt.NDArray, list[bool]]:
        """
        Project provided 3D point into the given camera.

        This function projects 3D points into a given camera, specifying the origin coordinate
        system of the points, and a certain frame number. Optionally, distortion can be applied
        or not (e.g. sometimes is useful to project into the undistorted domain).

        :param points3d_4xN: array of 4xN 3D points in cs_src coordinate system
        :param cs_src: name of coordinate system of the points
        :param cs_cam: name of the camera
        :param frame_num: frame number (if None, static camera info is seeked)
        :param remove_outside: flag to invalidate points outside the limits of the image domain
        :return: array of 3xN 2D points in image coordinates (distorted or undistorted
            according to apply_distortion), and array of boolean declaring points valid or not
        """
        points3d_camera_cs_4xN = self.transform_points3d_4xN(
            points3d_4xN=points3d_4xN, cs_src=cs_src, cs_dst=cs_cam, frame_num=frame_num
        )
        if points3d_camera_cs_4xN is not None:
            cam = self.get_camera(camera_name=cs_cam, frame_num=frame_num)
            if cam is not None:
                points2d_3xN, idx_valid = cam.project_points3d(
                    points3d_4xN=points3d_camera_cs_4xN, remove_outside=remove_outside
                )
                return points2d_3xN, idx_valid
        return np.array([[]]), []

    @staticmethod
    def __plucker_line_plane_intersection(
        lines_3xN: npt.NDArray, plane_abcd: list[float]
    ) -> tuple[npt.NDArray, list[bool]]:
        # Plucker intersection between Line and Plane
        # Use Plucker intersection line-plane
        # Create Plucker line using 2 points: origin of camera and origin of camera + ray
        n = lines_3xN.shape[1]
        idx_valid = [True] * n
        p1 = np.vstack((0, 0, 0, 1))
        p2_array = np.vstack((lines_3xN, np.ones((1, n))))
        # Plane equation in plucker coordinates (wrt to world)
        p = np.asarray(plane_abcd).reshape(4, 1)
        # Line equation in plucker coordinates
        p3d_Nx4 = np.array([])
        count = 0
        for p2 in p2_array.T:
            p2 = p2.reshape(4, 1)
            li = np.matmul(p1, np.transpose(p2)) - np.matmul(p2, np.transpose(p1))
            # Intersection is a 3D point
            p3d_lcs = np.matmul(li, p)
            if p3d_lcs[3][0] != 0:
                p3d_lcs /= p3d_lcs[3][0]  # homogeneous
            else:
                # This is an infinite point: return direction vector instead
                norm = np.linalg.norm(p3d_lcs[:3][0])
                p3d_lcs /= norm
                idx_valid[count] = False
            p3d_Nx4 = np.append(p3d_Nx4, p3d_lcs)
            count += 1
        p3d_Nx4 = p3d_Nx4.reshape(p3d_Nx4.shape[0] // 4, 4)
        p3d_4xN = np.transpose(p3d_Nx4)
        return p3d_4xN, idx_valid

    def reproject_points2d_3xN_into_plane(
        self,
        points2d_3xN: npt.NDArray,
        plane: list[float],
        cs_cam: str,
        cs_dst: str,
        frame_num: int | None = None,
    ) -> tuple[npt.NDArray, list[bool]]:
        # This function calls a camera (cs_cam) to reproject points2d in the image plane into
        # a plane defined in the cs_dst.
        # The obtained 3D points are expressed in cs_dst.
        # idx_valid identifies which points are valid 3D points (the reprojection might point to
        # infinity)
        cam = self.get_camera(cs_cam, frame_num)

        # first convert plane into cam cs
        plane_cam = self.transform_plane(plane, cs_dst, cs_cam, frame_num)
        n = points2d_3xN.shape[1]

        # Reproject points as rays with
        if cam is not None:
            rays3d_3xN_cs_cam = cam.reproject_points2d(points2d_3xN)
            points3d_3xN_cs_cam, idx_valid = self.__plucker_line_plane_intersection(
                rays3d_3xN_cs_cam, plane_cam
            )

            if points3d_3xN_cs_cam.shape[1] > 0:
                points3d_3xN_cs_cam_filt = points3d_3xN_cs_cam[:, idx_valid]
                points3d_4xN_cs_dst_filt = self.transform_points3d_4xN(
                    points3d_3xN_cs_cam_filt, cs_cam, cs_dst, frame_num
                )
                points3d_4xN_cs_dst = np.full([4, n], np.nan)
                points3d_4xN_cs_dst[:, idx_valid] = points3d_4xN_cs_dst_filt
                return points3d_4xN_cs_dst, idx_valid
        return np.array([[]]), []

    def reproject_points2d_3xN(
        self, points2d_3xN: npt.NDArray, cs_cam: str, frame_num: int | None = None
    ) -> npt.NDArray:
        rays3d_3xN = np.array([])
        cam = self.get_camera(cs_cam, frame_num)
        if cam is not None:
            rays3d_3xN = cam.reproject_points2d(points2d_3xN)
        return rays3d_3xN

    def create_img_projection_maps(
        self,
        cam_src_name: str,
        cam_dst_name: str,
        frame_num: int | None,
        filter_z_neg: bool = False,
    ) -> tuple[npt.NDArray, npt.NDArray]:
        """
        Create image projection maps.

        In SCL, extrinsic and intrinsic parameters are detached. Extrinsic parameters are
        defined at the VCD's coordinate systems. Intrinsic parameters are defined as VCD's
        stream_properties.

        To create the projection maps, we need to take 2D pixels from cam_src, convert to 3D
        rays, then transform into 3D rays in cam_dst coordinate system, and then project into
        2D pixels
        """
        # Get cameras
        cam_src = self.get_camera(cam_src_name, frame_num)
        cam_dst = self.get_camera(cam_dst_name, frame_num)

        if cam_src is None or cam_dst is None:
            return np.array([]), np.array([])

        # Get all pixels of dst image domain as a 3xN array
        x, y = np.mgrid[0 : cam_dst.width : 1, 0 : cam_dst.height : 1]

        points2d_dst_3xN = np.row_stack(
            (
                x.T.ravel(),
                y.T.ravel(),
                np.ones(cam_dst.width * cam_dst.height, dtype=int),
            )
        ).reshape(3, cam_dst.width * cam_dst.height)

        # Reproject as rays3d in cam_dst coordinate system
        rays3d_dst_3xN = cam_dst.reproject_points2d(points2d_dst_3xN)

        if filter_z_neg:
            # Filter Z negative rays.
            # From camera reprojection matrix, x = z * ( u - cx) / fx.
            # Z is negative if and only if x and (u - cx) / fx have opposite signs.
            # Only CameraPinhole and CameraFisheye have K_3x3

            if isinstance(cam_dst, (CameraPinhole, CameraFisheye)):
                cx = cam_dst.K_3x3[0, 2]
                fx = cam_dst.K_3x3[0, 0]
                is_z_negative = (
                    rays3d_dst_3xN[0, :] * (points2d_dst_3xN[0, :] - cx) / fx < 0
                )
                rays3d_dst_3xN[:, np.where(is_z_negative)] = np.nan

        # Convert into cam_src coordinate system
        if cam_dst.__class__ is CameraOrthographic:
            n = rays3d_dst_3xN.shape[1]
            points3d_z0_4xN = np.vstack(
                (
                    rays3d_dst_3xN[0, :],
                    rays3d_dst_3xN[1, :],
                    np.zeros(n),
                    rays3d_dst_3xN[2, :],
                )
            )
            points3d_4xN = points3d_z0_4xN
        else:
            points3d_4xN = utils.add_homogeneous_row(rays3d_dst_3xN)

        rays3d_src_4xN = self.transform_points3d_4xN(
            points3d_4xN, cam_dst_name, cam_src_name, frame_num
        )

        if rays3d_src_4xN is None:
            return np.array([]), np.array([])

        rays3d_src_4xN /= rays3d_src_4xN[3, :]

        # Project into cam_src
        points2d_src_3xN, _ = cam_src.project_points3d(rays3d_src_4xN)

        # Create maps for cv2.remap
        # u_map = np.zeros((cam_dst.height, cam_dst.width, 1), dtype=np.float32)
        # v_map = np.zeros((cam_dst.height, cam_dst.width, 1), dtype=np.float32)

        u_map = (
            points2d_src_3xN[0, :]
            .reshape(cam_dst.height, cam_dst.width, 1)
            .astype("float32")
        )
        v_map = (
            points2d_src_3xN[1, :]
            .reshape(cam_dst.height, cam_dst.width, 1)
            .astype("float32")
        )

        # Optimization
        map_x, map_y = cv.convertMaps(
            u_map, v_map, dstmap1type=cv.CV_16SC2, nninterpolation=False
        )

        # Application would then be: cv.remap(image, map_x, map_y, interpolation=interp,
        # borderMode=cv.BORDER_REFLECT)

        # Return
        return map_x, map_y

Functions

def make_edge(start: str, end: str, cost: float = 1) ‑> Edge

Transform input arguments in Edge tuple.

Expand source code
def make_edge(start: str, end: str, cost: float = 1) -> Edge:
    """Transform input arguments in `Edge` tuple."""
    return Edge(start, end, cost)

Classes

class Camera (width: int, height: int, name: str, description: str, uri: str)
Expand source code
class Camera(Sensor):
    def __init__(self, width: int, height: int, name: str, description: str, uri: str):
        Sensor.__init__(self, name, description, uri)
        self.width = width
        self.height = height

        # This flags chooses between OpenCV's implementation of distort functions and manually
        # written equations
        # They should render equal or very-similar results
        # OpenCV version might be faster (TBC)
        self.use_opencv = False

    @abstractmethod
    def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray:
        _ = rays3d_3xN  # Unused arguments
        raise NotImplementedError("This function is not implemented")

    @abstractmethod
    def project_points3d(
        self, points3d_4xN: npt.NDArray, remove_outside: bool = False
    ) -> tuple[npt.NDArray[np.floating], list[bool]]:
        _ = (points3d_4xN, remove_outside)
        raise NotImplementedError("This function is not implemented")

    @abstractmethod
    def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray:
        _ = points2d_3xN
        raise NotImplementedError("This function is not implemented")

Ancestors

Subclasses

Methods

def distort_rays3d(self, rays3d_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]]
Expand source code
@abstractmethod
def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray:
    _ = rays3d_3xN  # Unused arguments
    raise NotImplementedError("This function is not implemented")
def project_points3d(self, points3d_4xN: npt.NDArray, remove_outside: bool = False) ‑> tuple[numpy.ndarray[typing.Any, numpy.dtype[numpy.floating]], list[bool]]
Expand source code
@abstractmethod
def project_points3d(
    self, points3d_4xN: npt.NDArray, remove_outside: bool = False
) -> tuple[npt.NDArray[np.floating], list[bool]]:
    _ = (points3d_4xN, remove_outside)
    raise NotImplementedError("This function is not implemented")
def reproject_points2d(self, points2d_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]]
Expand source code
@abstractmethod
def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray:
    _ = points2d_3xN
    raise NotImplementedError("This function is not implemented")
class CameraCubemap (camera_intrinsics: dict[str, Any], name: str, description: str, uri: str, compute_remaps: bool = False)

Definition of the Cubemap Camera Model.

The Cubemap camera model defines 6 pinhole cameras all same calibration with FOV 90deg.

It is intended to build fisheye images from it.

Expand source code
class CameraCubemap(Camera):
    """
    Definition of the Cubemap Camera Model.

    The Cubemap camera model defines 6 pinhole cameras all same calibration with FOV 90deg.

    It is intended to build fisheye images from it.
    """

    def __init__(
        self,
        camera_intrinsics: dict[str, Any],
        name: str,
        description: str,
        uri: str,
        compute_remaps: bool = False,
    ):
        Camera.__init__(
            self,
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
            name,
            description,
            uri,
        )

        # Define rotation angles for each Pinhole camera
        r_front = utils.euler2R([0, 0, 0])
        r_back = utils.euler2R([0, -np.pi, 0])
        r_top = utils.euler2R([0, 0, -np.pi / 2])
        r_bottom = utils.euler2R([0, 0, np.pi / 2])
        r_left = utils.euler2R([0, np.pi / 2, 0])
        r_right = utils.euler2R([0, -np.pi / 2, 0])

        # Obtain pose for each Pinhole camera
        c = np.array([0, 0, 0]).reshape(3, 1)
        p_front = utils.create_pose(r_front, c)
        p_back = utils.create_pose(r_back, c)
        p_left = utils.create_pose(r_left, c)
        p_right = utils.create_pose(r_right, c)
        p_top = utils.create_pose(r_top, c)
        p_bottom = utils.create_pose(r_bottom, c)

        self.cameras = {
            "frontal": (
                CameraPinhole(
                    camera_intrinsics=camera_intrinsics,
                    name=name,
                    description=description,
                    uri=uri,
                    compute_remaps=compute_remaps,
                ),
                p_front,
            ),
            "back": (
                CameraPinhole(
                    camera_intrinsics=camera_intrinsics,
                    name=name,
                    description=description,
                    uri=uri,
                    compute_remaps=compute_remaps,
                ),
                p_back,
            ),
            "left": (
                CameraPinhole(
                    camera_intrinsics=camera_intrinsics,
                    name=name,
                    description=description,
                    uri=uri,
                    compute_remaps=compute_remaps,
                ),
                p_left,
            ),
            "right": (
                CameraPinhole(
                    camera_intrinsics=camera_intrinsics,
                    name=name,
                    description=description,
                    uri=uri,
                    compute_remaps=compute_remaps,
                ),
                p_right,
            ),
            "top": (
                CameraPinhole(
                    camera_intrinsics=camera_intrinsics,
                    name=name,
                    description=description,
                    uri=uri,
                    compute_remaps=compute_remaps,
                ),
                p_top,
            ),
            "bottom": (
                CameraPinhole(
                    camera_intrinsics=camera_intrinsics,
                    name=name,
                    description=description,
                    uri=uri,
                    compute_remaps=compute_remaps,
                ),
                p_bottom,
            ),
        }

    #################################
    # Inherited functions
    #################################
    def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray:
        """
        Not implemented.

        This function distort rays3d using the distortion parameters of the camera. As a result
        distorted rays3d are created which can then be projected using the camera calibration
        matrix.

        :param rays3d_3xN: Array with N 3D rays, each of them as column (rx, ry, rz)
        :return: rays3d_dist_3xN: Array with N distorted 3D rays, each of them as column (rx',
            ry', rz')
        """
        return rays3d_3xN

    def project_points3d(
        self, points3d_4xN: npt.NDArray, remove_outside: bool = False
    ) -> tuple[npt.NDArray[np.floating], list[bool]]:
        # Choose which pinhole camera
        x = points3d_4xN[0, :]
        y = points3d_4xN[1, :]
        z = points3d_4xN[2, :]

        max_axis = np.maximum.reduce([abs(x), abs(y), abs(z)])

        right = 0
        left = 1
        bottom = 2
        top = 3
        frontal = 4
        back = 5

        face = np.full((points3d_4xN.shape), None)
        face[:, np.where(np.isclose(max_axis, x))] = right  # right
        face[:, np.where(np.isclose(max_axis, -x))] = left  # left
        face[:, np.where(np.isclose(max_axis, y))] = bottom  # bottom
        face[:, np.where(np.isclose(max_axis, -y))] = top  # top
        face[:, np.where(np.isclose(max_axis, z))] = frontal  # frontal
        face[:, np.where(np.isclose(max_axis, -z))] = back  # back

        # Apply
        frontal_points = points3d_4xN[:, np.all(face == frontal, axis=0)]
        right_points = points3d_4xN[:, np.all(face == right, axis=0)]
        left_points = points3d_4xN[:, np.all(face == left, axis=0)]
        bottom_points = points3d_4xN[:, np.all(face == bottom, axis=0)]
        top_points = points3d_4xN[:, np.all(face == top, axis=0)]
        back_points = points3d_4xN[:, np.all(face == back, axis=0)]

        right_points = utils.transform_points3d_4xN(
            right_points, self.cameras["right"][1]
        )
        left_points = utils.transform_points3d_4xN(left_points, self.cameras["left"][1])
        bottom_points = utils.transform_points3d_4xN(
            bottom_points, self.cameras["bottom"][1]
        )
        top_points = utils.transform_points3d_4xN(top_points, self.cameras["top"][1])
        back_points = utils.transform_points3d_4xN(back_points, self.cameras["back"][1])

        points2d_3xN = np.zeros((3, points3d_4xN.shape[1]))

        if frontal_points.size != 0:
            front_cords, _ = self.cameras["frontal"][0].project_points3d(
                points3d_4xN=frontal_points
            )
            # Add cubemap offset of frontal pinhole
            front_cords[0, :] = (
                front_cords[0, :] + self.cameras["frontal"][0].img_size_undist[0]
            )
            front_cords[1, :] = (
                front_cords[1, :] + self.cameras["frontal"][0].img_size_undist[1]
            )
            points2d_3xN[:, np.all(face == frontal, axis=0)] = front_cords

        if left_points.size != 0:
            left_cords, _ = self.cameras["left"][0].project_points3d(
                points3d_4xN=left_points
            )
            left_cords[1, :] = (
                left_cords[1, :] + self.cameras["left"][0].img_size_undist[0]
            )
            left_cords[0, :] = left_cords[0, :]
            points2d_3xN[:, np.all(face == left, axis=0)] = left_cords

        if right_points.size != 0:
            right_cords, _ = self.cameras["right"][0].project_points3d(
                points3d_4xN=right_points
            )
            right_cords[1, :] = (
                right_cords[1, :] + self.cameras["right"][0].img_size_undist[0]
            )
            right_cords[0, :] = (
                right_cords[0, :] + 2 * self.cameras["right"][0].img_size_undist[1]
            )
            points2d_3xN[:, np.all(face == right, axis=0)] = right_cords

        if bottom_points.size != 0:
            bottom_cords, _ = self.cameras["bottom"][0].project_points3d(
                points3d_4xN=bottom_points
            )
            bottom_cords[1, :] = (
                bottom_cords[1, :] + 2 * self.cameras["bottom"][0].img_size_undist[0]
            )
            bottom_cords[0, :] = (
                bottom_cords[0, :] + self.cameras["bottom"][0].img_size_undist[1]
            )
            points2d_3xN[:, np.all(face == bottom, axis=0)] = bottom_cords

        if top_points.size != 0:
            top_cords, _ = self.cameras["top"][0].project_points3d(
                points3d_4xN=top_points
            )
            # top_cords[1,:] = top_cords[1,:] + self.cameras["top"][0].img_size_undist[0]
            top_cords[0, :] = top_cords[0, :] + self.cameras["top"][0].img_size_undist[1]
            points2d_3xN[:, np.all(face == top, axis=0)] = top_cords

        if back_points.size != 0:
            back_cords, _ = self.cameras["back"][0].project_points3d(
                points3d_4xN=back_points
            )
            back_cords[1, :] = (
                back_cords[1, :] + self.cameras["back"][0].img_size_undist[0]
            )
            back_cords[0, :] = (
                back_cords[0, :] + 3 * self.cameras["back"][0].img_size_undist[1]
            )
            points2d_3xN[:, np.all(face == 5, axis=0)] = back_cords

        # Consider only points behind camera (>180º)
        idx_valid: list[bool] = []

        return points2d_3xN, idx_valid

    def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray:
        """
        Not needed.

        Note: The cubemap camera is used as a step in order to generate fisheye cameras.
        Therefore there is no use in reprojecting points in the cubemap image domain as
        rays in 3D coordinates.
        That is why the reprojection function has not been implemented for this camera
        model.
        """
        raise NotImplementedError(
            "There is no use in reprojecting points in the cubemap image domain"
        )

Ancestors

Methods

def distort_rays3d(self, rays3d_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]]

Not implemented.

This function distort rays3d using the distortion parameters of the camera. As a result distorted rays3d are created which can then be projected using the camera calibration matrix.

:param rays3d_3xN: Array with N 3D rays, each of them as column (rx, ry, rz) :return: rays3d_dist_3xN: Array with N distorted 3D rays, each of them as column (rx', ry', rz')

Expand source code
def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray:
    """
    Not implemented.

    This function distort rays3d using the distortion parameters of the camera. As a result
    distorted rays3d are created which can then be projected using the camera calibration
    matrix.

    :param rays3d_3xN: Array with N 3D rays, each of them as column (rx, ry, rz)
    :return: rays3d_dist_3xN: Array with N distorted 3D rays, each of them as column (rx',
        ry', rz')
    """
    return rays3d_3xN
def project_points3d(self, points3d_4xN: npt.NDArray, remove_outside: bool = False) ‑> tuple[numpy.ndarray[typing.Any, numpy.dtype[numpy.floating]], list[bool]]
Expand source code
def project_points3d(
    self, points3d_4xN: npt.NDArray, remove_outside: bool = False
) -> tuple[npt.NDArray[np.floating], list[bool]]:
    # Choose which pinhole camera
    x = points3d_4xN[0, :]
    y = points3d_4xN[1, :]
    z = points3d_4xN[2, :]

    max_axis = np.maximum.reduce([abs(x), abs(y), abs(z)])

    right = 0
    left = 1
    bottom = 2
    top = 3
    frontal = 4
    back = 5

    face = np.full((points3d_4xN.shape), None)
    face[:, np.where(np.isclose(max_axis, x))] = right  # right
    face[:, np.where(np.isclose(max_axis, -x))] = left  # left
    face[:, np.where(np.isclose(max_axis, y))] = bottom  # bottom
    face[:, np.where(np.isclose(max_axis, -y))] = top  # top
    face[:, np.where(np.isclose(max_axis, z))] = frontal  # frontal
    face[:, np.where(np.isclose(max_axis, -z))] = back  # back

    # Apply
    frontal_points = points3d_4xN[:, np.all(face == frontal, axis=0)]
    right_points = points3d_4xN[:, np.all(face == right, axis=0)]
    left_points = points3d_4xN[:, np.all(face == left, axis=0)]
    bottom_points = points3d_4xN[:, np.all(face == bottom, axis=0)]
    top_points = points3d_4xN[:, np.all(face == top, axis=0)]
    back_points = points3d_4xN[:, np.all(face == back, axis=0)]

    right_points = utils.transform_points3d_4xN(
        right_points, self.cameras["right"][1]
    )
    left_points = utils.transform_points3d_4xN(left_points, self.cameras["left"][1])
    bottom_points = utils.transform_points3d_4xN(
        bottom_points, self.cameras["bottom"][1]
    )
    top_points = utils.transform_points3d_4xN(top_points, self.cameras["top"][1])
    back_points = utils.transform_points3d_4xN(back_points, self.cameras["back"][1])

    points2d_3xN = np.zeros((3, points3d_4xN.shape[1]))

    if frontal_points.size != 0:
        front_cords, _ = self.cameras["frontal"][0].project_points3d(
            points3d_4xN=frontal_points
        )
        # Add cubemap offset of frontal pinhole
        front_cords[0, :] = (
            front_cords[0, :] + self.cameras["frontal"][0].img_size_undist[0]
        )
        front_cords[1, :] = (
            front_cords[1, :] + self.cameras["frontal"][0].img_size_undist[1]
        )
        points2d_3xN[:, np.all(face == frontal, axis=0)] = front_cords

    if left_points.size != 0:
        left_cords, _ = self.cameras["left"][0].project_points3d(
            points3d_4xN=left_points
        )
        left_cords[1, :] = (
            left_cords[1, :] + self.cameras["left"][0].img_size_undist[0]
        )
        left_cords[0, :] = left_cords[0, :]
        points2d_3xN[:, np.all(face == left, axis=0)] = left_cords

    if right_points.size != 0:
        right_cords, _ = self.cameras["right"][0].project_points3d(
            points3d_4xN=right_points
        )
        right_cords[1, :] = (
            right_cords[1, :] + self.cameras["right"][0].img_size_undist[0]
        )
        right_cords[0, :] = (
            right_cords[0, :] + 2 * self.cameras["right"][0].img_size_undist[1]
        )
        points2d_3xN[:, np.all(face == right, axis=0)] = right_cords

    if bottom_points.size != 0:
        bottom_cords, _ = self.cameras["bottom"][0].project_points3d(
            points3d_4xN=bottom_points
        )
        bottom_cords[1, :] = (
            bottom_cords[1, :] + 2 * self.cameras["bottom"][0].img_size_undist[0]
        )
        bottom_cords[0, :] = (
            bottom_cords[0, :] + self.cameras["bottom"][0].img_size_undist[1]
        )
        points2d_3xN[:, np.all(face == bottom, axis=0)] = bottom_cords

    if top_points.size != 0:
        top_cords, _ = self.cameras["top"][0].project_points3d(
            points3d_4xN=top_points
        )
        # top_cords[1,:] = top_cords[1,:] + self.cameras["top"][0].img_size_undist[0]
        top_cords[0, :] = top_cords[0, :] + self.cameras["top"][0].img_size_undist[1]
        points2d_3xN[:, np.all(face == top, axis=0)] = top_cords

    if back_points.size != 0:
        back_cords, _ = self.cameras["back"][0].project_points3d(
            points3d_4xN=back_points
        )
        back_cords[1, :] = (
            back_cords[1, :] + self.cameras["back"][0].img_size_undist[0]
        )
        back_cords[0, :] = (
            back_cords[0, :] + 3 * self.cameras["back"][0].img_size_undist[1]
        )
        points2d_3xN[:, np.all(face == 5, axis=0)] = back_cords

    # Consider only points behind camera (>180º)
    idx_valid: list[bool] = []

    return points2d_3xN, idx_valid
def reproject_points2d(self, points2d_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]]

Not needed.

Note: The cubemap camera is used as a step in order to generate fisheye cameras. Therefore there is no use in reprojecting points in the cubemap image domain as rays in 3D coordinates. That is why the reprojection function has not been implemented for this camera model.

Expand source code
def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray:
    """
    Not needed.

    Note: The cubemap camera is used as a step in order to generate fisheye cameras.
    Therefore there is no use in reprojecting points in the cubemap image domain as
    rays in 3D coordinates.
    That is why the reprojection function has not been implemented for this camera
    model.
    """
    raise NotImplementedError(
        "There is no use in reprojecting points in the cubemap image domain"
    )
class CameraCylindrical (camera_intrinsics: dict[str, Any], name: str, description: str, uri: str)
Expand source code
class CameraCylindrical(Camera):
    def __init__(
        self, camera_intrinsics: dict[str, Any], name: str, description: str, uri: str
    ):
        Camera.__init__(
            self,
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
            name,
            description,
            uri,
        )

        self.img_size = (self.width, self.height)
        self.fovh = camera_intrinsics["fov_horz_rad"]
        self.fovv = camera_intrinsics["fov_vert_rad"]

        # Converting from [fovh, 0] into [0, width]
        self.mx = -self.width / self.fovh
        self.nx = self.width
        # Converting from [fovv/2, -fovv/2] into [0, height]
        self.my = -self.height / self.fovv
        self.ny = self.height / 2.0

        # Create calibration matrix
        self.K_3x4 = np.array(
            [
                [self.mx, 0.0, self.nx, 0.0],
                [0.0, self.my, self.ny, 0.0],
                [0.0, 0.0, 1.0, 0.0],
            ]
        )
        self.K_3x3 = utils.fromCameraMatrix3x4toCameraMatrix3x3(self.K_3x4)
        self.K_3x3_inv = utils.inv(self.K_3x3)

    #################################
    # Inherited functions
    #################################
    def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray:
        """Cylindrical cameras don't have distortion."""
        return rays3d_3xN

    def project_points3d(
        self, points3d_4xN: npt.NDArray, remove_outside: bool = False
    ) -> tuple[npt.NDArray[np.floating], list[bool]]:
        # 1.- Select only those z > 0 (in front of the camera)
        idx_in_front: list[bool] = (points3d_4xN[2, :] > 1e-8).tolist()
        idx_valid = idx_in_front
        # rays3d_3xN_filt = points3d_4xN[0:3, idx_valid]
        rays3d_3xN_filt = points3d_4xN[0:3]  # , idx_valid]

        # Two-step process: use Ray2LL and the LL2Pixel
        lonlat_2xN = self.__ray2ll(rays3d_3xN_filt, idx_valid)
        points2d_3xN = self.__ll2pixel(lonlat_2xN)

        # Declare as non-valid points outside the limits of the image
        if remove_outside:
            points2d_3xN, idx_valid = utils.filter_outside(
                points2d_3xN, self.img_size, idx_valid
            )

        return points2d_3xN, idx_valid

    def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray:
        lonlat_2xN = self.__pixel2ll(points2d_3xN)
        return self.__ll2ray(lonlat_2xN)

    #################################
    # Inner functions
    #################################
    # ----------------------------------------------#
    #                From 2D --> 3D                 #
    # ----------------------------------------------#
    def __ll2ray(self, lonlat_2xN: npt.NDArray) -> npt.NDArray:
        lon = lonlat_2xN[0, :]
        lat = lonlat_2xN[1, :]
        x = np.cos(lon) * np.cos(lat)
        y = -np.sin(lat)
        z = np.sin(lon) * np.cos(lat)

        points3d_3xN = np.vstack((np.vstack((x, y)), z))
        return points3d_3xN

    def __pixel2ll(self, points2d_3xN: npt.NDArray) -> npt.NDArray:
        # See LL2Ray
        # lon = (1/self.mx)*(x-self.nx)
        # lat = (1/self.my)*(y-self.ny)

        lonlat_3xN = self.K_3x3_inv @ points2d_3xN
        lonlat_3xN /= lonlat_3xN[2, :]

        return lonlat_3xN[0:2, :]

    # ----------------------------------------------
    # From 3D -> 2D
    # ----------------------------------------------
    def __ray2ll(
        self, ray3d_4xN: npt.NDArray, idx_valid: list[bool]
    ) -> npt.NDArray[np.floating]:
        # See http://paulbourke.net/dome/dualfish2sphere/
        # but modified so the camera model is z-optical axis, x-right, y-bottom
        x = ray3d_4xN[0, :]
        y = ray3d_4xN[1, :]
        z = ray3d_4xN[2, :]

        lon_1xN = np.zeros(x.shape)
        np.arctan2(z, x, out=lon_1xN, where=idx_valid)
        lat_1xN = np.zeros(y.shape)
        np.arctan2(-y, np.sqrt(x * x + z * z), out=lat_1xN, where=idx_valid)

        # lon_1xN = np.arctan2(ray3d_4xN[1, :], ray3d_4xN[0, :])
        # lat_1xN = np.arctan2(
        #     ray3d_4xN[2, :],
        #     np.sqrt(ray3d_4xN[0, :] * ray3d_4xN[0, :] + ray3d_4xN[1, :] * ray3d_4xN[1, :]),
        # )
        lonlat_2xN = np.vstack((lon_1xN, lat_1xN))
        return lonlat_2xN

    def __ll2pixel(self, lonlat_2xN: npt.NDArray) -> npt.NDArray[np.floating]:
        lonlat_3xN = np.vstack((lonlat_2xN, np.ones((1, lonlat_2xN.shape[1]))))
        points2d_3xN = self.K_3x3 @ lonlat_3xN
        # px = self.mx*lon + self.nx
        # py = self.my*lat + self.ny
        return points2d_3xN

Ancestors

Methods

def distort_rays3d(self, rays3d_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]]

Cylindrical cameras don't have distortion.

Expand source code
def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray:
    """Cylindrical cameras don't have distortion."""
    return rays3d_3xN
def project_points3d(self, points3d_4xN: npt.NDArray, remove_outside: bool = False) ‑> tuple[numpy.ndarray[typing.Any, numpy.dtype[numpy.floating]], list[bool]]
Expand source code
def project_points3d(
    self, points3d_4xN: npt.NDArray, remove_outside: bool = False
) -> tuple[npt.NDArray[np.floating], list[bool]]:
    # 1.- Select only those z > 0 (in front of the camera)
    idx_in_front: list[bool] = (points3d_4xN[2, :] > 1e-8).tolist()
    idx_valid = idx_in_front
    # rays3d_3xN_filt = points3d_4xN[0:3, idx_valid]
    rays3d_3xN_filt = points3d_4xN[0:3]  # , idx_valid]

    # Two-step process: use Ray2LL and the LL2Pixel
    lonlat_2xN = self.__ray2ll(rays3d_3xN_filt, idx_valid)
    points2d_3xN = self.__ll2pixel(lonlat_2xN)

    # Declare as non-valid points outside the limits of the image
    if remove_outside:
        points2d_3xN, idx_valid = utils.filter_outside(
            points2d_3xN, self.img_size, idx_valid
        )

    return points2d_3xN, idx_valid
def reproject_points2d(self, points2d_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]]
Expand source code
def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray:
    lonlat_2xN = self.__pixel2ll(points2d_3xN)
    return self.__ll2ray(lonlat_2xN)
class CameraFisheye (camera_intrinsics: dict[str, Any], name: str, description: str, uri: str, compute_remaps: bool = False, limit_to_180_degrees: bool = False)

Fisheye cameras, with field of view around 180º.

Different distortion models are considered (see distort_rays3d) For simplicity, the projection process is splitted in two steps: ray distortion, and ray linear projection.

Expand source code
class CameraFisheye(Camera):  # pylint: disable=too-many-instance-attributes
    """
    Fisheye cameras, with field of view around 180º.

    Different distortion models are considered (see distort_rays3d) For simplicity, the
    projection process is splitted in two steps: ray distortion, and ray linear projection.
    """

    def __init__(
        self,
        camera_intrinsics: dict[str, Any],
        name: str,
        description: str,
        uri: str,
        compute_remaps: bool = False,
        limit_to_180_degrees: bool = False,
    ):
        self.cx = camera_intrinsics["center_x"]
        self.cy = camera_intrinsics["center_y"]
        self.img_size_dist = (
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
        )
        self.img_size_undist = (
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
        )
        self.limit_to_180_degrees = limit_to_180_degrees

        if "focal_length_x" in camera_intrinsics:
            self.focal_length_x = camera_intrinsics["focal_length_x"]
            if "focal_length_y" in camera_intrinsics:
                self.focal_length_y = camera_intrinsics["focal_length_y"]
                self.aspect_ratio = self.focal_length_x / self.focal_length_y
            elif "aspect_ratio" in camera_intrinsics:
                self.aspect_ratio = camera_intrinsics["aspect_ratio"]
                self.focal_length_y = self.focal_length_x / self.aspect_ratio
            else:
                self.focal_length_y = self.focal_length_x
                self.aspect_ratio = 1.0
        else:
            self.focal_length_x = 1.0
            self.focal_length_y = 1.0
            self.aspect_ratio = 1.0

        self.K_3x4 = np.array(
            [
                [self.focal_length_x, 0.0, self.cx, 0.0],
                [0.0, self.focal_length_y, self.cy, 0.0],
                [0.0, 0.0, 1.0, 0.0],
            ]
        )
        self.K_3x3 = utils.fromCameraMatrix3x4toCameraMatrix3x3(self.K_3x4)

        # Direct distortion (from angle of incidence to radius in image)
        dist_length = len(camera_intrinsics["lens_coeffs_1xN"])
        self.d_1xN = np.array(camera_intrinsics["lens_coeffs_1xN"]).reshape(
            1, dist_length
        )
        self.model = camera_intrinsics["model"]

        # Inverse distortion (from radius in image to incidence angle)
        self.error_a_deg = 0.0
        # M is not necessarily N, we can use any polynomial to invert
        self.d_inv_1xM = self.__invert_polynomial(self.d_1xN, self.model)
        if compute_remaps:
            self.K_und_3x3 = self.estimate_new_camera_matrix_for_undistort_rectify(
                img_size_orig=self.img_size_dist,
                balance=0.9,
                img_size_dst=self.img_size_undist,
                fov_scale=1.0,
            )
            self.K_und_3x4 = utils.fromCameraMatrix3x3toCameraMatrix3x4(self.K_und_3x3)

            self.mapX_to_und_16SC2 = None
            self.mapY_to_und_16SC2 = None

        if compute_remaps:
            self.__compute_remaps()

        Camera.__init__(
            self,
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
            name,
            description,
            uri,
        )

    #################################
    # Inherited functions
    #################################
    def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray[np.float64]:
        """
        This function projects 3d points in camera coordinate system using the angle-of-
        incidence projection model. Any 3D point in space P=(X,Y,Z,1)^T has a radius with
        respect to the optical axis Z r = ||X^2 + Y^2|| The angle of incidence to the optical
        center is a = atan(r/Z)

        The angle of incidence then spans from 0 to pi/2 The model of the lens relates the
        angle of incidence with the radius of the point in the image plane (in pixels):
        "radial_poly" (4 distortion coefficients)     rp = k1*a + k2*a^2 + k3*a^3 + k4*a^4
        "kannala" (5 distortion coefficients)     rp = k1*a + k2*a^3 + k3*a^5 + k4*a^7 + k5*a^9
        "opencv_fisheye" (4 distortion coefficients, equivalent to Kannala with k1=1.0, so only
        the                  last 4 terms are used     rp = a + k1*a^3 + k2*a^5 + k3*a^7 +
        k4*a^9

        Then, the distorted 3d rays are computed as: ray_dist = (X*rp/r, Y*rp/r, 1)

        :param rays3d_3xN: 3d rays as 3xN arrays
        :return: distorted 3d rays as 3xN arrays after applying distortion function.
        """  # noqa
        n = rays3d_3xN.shape[1]
        if n == 0 or not self.is_distorted():
            rays3d_dist_3xN = np.array([[]])
            return rays3d_dist_3xN

        rays3d_dist_3xN = np.zeros((3, n))
        for i in range(0, n):
            x = rays3d_3xN[0, i]
            y = rays3d_3xN[1, i]
            z = rays3d_3xN[2, i]
            r = utils.norm([x, y])
            a = math.atan2(r, z)
            # direct distortion, use specified model
            rp = self.__apply_polynomial(a, self.d_1xN.flatten().tolist(), self.model)
            rp_r = float(rp / r)
            if r > 1e-8:
                rays3d_dist_3xN[0, i] = x * rp_r
                rays3d_dist_3xN[1, i] = y * rp_r
                rays3d_dist_3xN[2, i] = 1
            else:
                rays3d_dist_3xN[0, i] = 0
                rays3d_dist_3xN[1, i] = 0
                rays3d_dist_3xN[2, i] = 1

        return rays3d_dist_3xN

    def project_points3d(
        self, points3d_4xN: npt.NDArray, remove_outside: bool = False
    ) -> tuple[npt.NDArray[np.floating], list[bool]]:
        """
        Project 3d points in camera coordinate system into the image plane.

        First, 3d points P=(X,Y,Z,1)^T are treated as 3d rays, and distorted using the angle of
        incidence equation. Distorted 3d rays are then projected using the linear camera
        calibration matrix K.

        :param points3d_4xN: 3D points in camera coordinate system
        :param remove_outside: filter out points that fall outside the limits of the image
            frame.
        :return: 2D points in image plane, as 3xN array, in hom. coordinates, and boolean array
            of valid
        """
        # 0.- Pre-filter
        if points3d_4xN.ndim != 2:
            raise ValueError("Invalid argument 'point3d_4xN' dimensions")
        n = points3d_4xN.shape[1]
        if n == 0:
            return np.array([[]]), []

        # 1.- Select only those z > 0 (in front of the camera)
        if self.limit_to_180_degrees:
            idx_in_front: list[bool] = (points3d_4xN[2, :] > 1e-8).tolist()
            idx_valid = idx_in_front
        else:
            idx_valid = [True] * n

        # 2.- Distort rays3d if distorted
        rays3d_3xN_filt = points3d_4xN[0:3, idx_valid]
        rays3d_3xN = np.full([3, n], np.nan)
        rays3d_3xN[:, idx_valid] = rays3d_3xN_filt

        # Now distort (only non-nans)
        rays3d_3xN_filt = rays3d_3xN[:, idx_valid]
        # no nan should go into it
        rays3d_3xN_filt_dist = self.distort_rays3d(rays3d_3xN_filt)

        # Add nans
        rays3d_3xN = np.full([3, n], np.nan)
        rays3d_3xN[:, idx_valid] = rays3d_3xN_filt_dist

        # 3.- Project using calibration matrix
        points2d_3xN = self.K_3x3.dot(rays3d_3xN)
        if remove_outside:
            points2d_3xN, idx_valid = utils.filter_outside(
                points2d_3xN, self.img_size_dist, idx_valid
            )

        return points2d_3xN, idx_valid

    def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray:
        # Get rays3d applying K^-1 and then undistortion
        rays3d_dist_3xN = utils.inv(self.K_3x3).dot(points2d_3xN)
        rays3d_3xN = self.undistort_rays3d(rays3d_dist_3xN=rays3d_dist_3xN)

        return rays3d_3xN

    #################################
    # Inner functions
    #################################
    def __has_remaps(self) -> bool:
        if self.mapX_to_und_16SC2 is None or self.mapY_to_und_16SC2 is None:
            return False
        return True

    def __compute_remaps(self):
        start = time.time()
        (
            self.mapX_to_und_16SC2,
            self.mapY_to_und_16SC2,
        ) = self.init_undistort_rectify_map(self.K_und_3x3, self.img_size_undist)
        end = time.time()
        print("CameraFisheye: Compute remaps for undistortion... ", end - start)

    def __polynomial_with_offset(
        self,
        x: npt.NDArray[np.floating] | float,
        k1: float,
        k2: float,
        k3: float,
        k4: float,
        k5: float,
        k6: float,
        k7: float,
        k8: float,
        k9: float,
    ) -> npt.NDArray[np.floating] | float:
        x2 = x * x
        x3 = x2 * x
        x4 = x3 * x
        x5 = x4 * x
        x6 = x5 * x
        x7 = x6 * x
        x8 = x7 * x

        temp = (
            x * k2 + x2 * k3 + x3 * k4 + x4 * k5 + x5 * k6 + x6 * k7 + x7 * k8 + x8 * k9
        )
        r = k1 + temp
        return r

    def __radialpoly_model_function(
        self,
        x: npt.NDArray[np.floating] | float,
        k1: float,
        k2: float,
        k3: float,
        k4: float,
    ) -> npt.NDArray[np.floating] | float:
        x2 = x * x
        x3 = x2 * x
        x4 = x3 * x
        return x * k1 + x2 * k2 + x3 * k3 + x4 * k4

    def __kannala_model_function(
        self,
        x: npt.NDArray[np.floating] | float,
        k1: float,
        k2: float,
        k3: float,
        k4: float,
        k5: float,
    ) -> npt.NDArray[np.floating] | float:
        x2 = x * x
        x3 = x2 * x
        x5 = x3 * x2
        x7 = x5 * x2
        x9 = x7 * x2
        return x * k1 + x3 * k2 + x5 * k3 + x7 * k4 + x9 * k5

    def __apply_polynomial(
        self, x: npt.NDArray[np.floating] | float, k: list, model: str
    ) -> npt.NDArray[np.floating] | float:
        if model == "radial_poly":
            return self.__radialpoly_model_function(x, k[0], k[1], k[2], k[3])
        if model == "kannala":
            return self.__kannala_model_function(x, k[0], k[1], k[2], k[3], k[4])
        if model == "polynomial_offset":
            return self.__polynomial_with_offset(
                x, k[0], k[1], k[2], k[3], k[4], k[5], k[6], k[7], k[8]
            )

        raise ValueError(
            "Unsupported distortion model. "
            "Use radial_poly, kannala or polynomial_offset"
        )

    def __invert_polynomial(
        self, d: npt.NDArray, model: str, n: int = 100
    ) -> npt.NDArray:
        a = np.linspace(0, np.pi / 2, num=n)
        k_list = d.flatten().tolist()

        rp = self.__apply_polynomial(a, k_list, model)  # direct distortion, using model

        # For the inverse polynomial, we can use numpy's polyfit, which fits
        # p(x)=p(0)*x**deg + ... + p(deg)
        dim = 9  # dimensions of the offset polynomial to represent the inverse distortion
        # polyfit return reversed coefficients with offset
        kp = np.polyfit(rp, a, dim - 1)
        kp_list: list = kp.tolist()
        kp_list.reverse()  # kp_list[-1] is the offset
        a_rep = self.__apply_polynomial(rp, kp_list, "polynomial_offset")

        error_a = np.sum(np.abs(a - a_rep)) / n
        self.error_a_deg = error_a * 180 / np.pi

        # fig, (ax1, ax2) = plt.subplots(1, 2)
        # fig.suptitle('Distortion')
        # ax1.plot(a, rp)
        # ax1.set(xlabel='a', ylabel='rp', title='Distortion direct')
        # ax1.grid()
        # ax2.plot(a_rep, rp)
        # ax2.set(xlabel='a_rep', ylabel='rp', title='Distortion reprojected')
        # fig.savefig('distortion_ret.png')
        # plt.show()

        if self.error_a_deg > 1e-1:
            warnings.warn(
                "WARNING: the inverse of the CameraFisheye distortion produces reprojection "
                " error > 1e-2 (i.e. higher than tenth of degree)",
                Warning,
                2,
            )

        return np.array(kp_list)

    #################################
    # Other public functions
    #################################
    def undistort_image(self, img: cv.Mat) -> cv.Mat:
        if not self.is_distorted():
            return img
        if not self.__has_remaps():
            self.__compute_remaps()
        return cv.remap(
            img,
            self.mapX_to_und_16SC2,
            self.mapY_to_und_16SC2,
            interpolation=cv.INTER_LINEAR,
            borderMode=cv.BORDER_CONSTANT,
        )

    def undistort_points2d(self, points2d_dist_3xN: npt.NDArray) -> npt.NDArray:
        # This is a transfer from the distorted to the undistorted
        rays3d_dist_3xN = utils.inv(self.K_3x3).dot(points2d_dist_3xN)
        rays3d_und_3xN = self.undistort_rays3d(rays3d_dist_3xN)
        points2d_und_3xN = self.K_und_3x3.dot(rays3d_und_3xN)

        return points2d_und_3xN

    def distort_points2d(self, points2d_und_3xN: npt.NDArray) -> npt.NDArray:
        # This is a transfer from the undistorted domain to the distorted
        rays3d_und_3xN = utils.inv(self.K_und_3x3).dot(points2d_und_3xN)
        rays3d_dist_3xN = self.distort_rays3d(
            rays3d_und_3xN
        )  # TODO: better to use self.project_points?
        points2d_dist_3xN = self.K_3x3.dot(rays3d_dist_3xN)

        return points2d_dist_3xN

    def is_distorted(self) -> bool:
        return np.count_nonzero(self.d_1xN) > 0

    def undistort_rays3d(self, rays3d_dist_3xN: npt.NDArray) -> npt.NDArray[np.floating]:
        """
        Undistort 3d rays according to the inverse polynomial function.

        :param rays3d_dist_3xN: 3d rays (connecting optical center and image plane using K)
        :return: undistorted rays as 3xN
        """
        n = rays3d_dist_3xN.shape[1]
        if n == 0 or not self.is_distorted():
            rays3d_und_3xN = np.array([[]])
            return rays3d_und_3xN

        rays3d_und_3xN = np.zeros((3, n))
        for i in range(0, n):
            x = rays3d_dist_3xN[0, i]
            y = rays3d_dist_3xN[1, i]
            rp = utils.norm([x, y])
            # NOTE: the inverse polynomial is fixed to dim=4 with offset
            a = self.__apply_polynomial(
                rp, self.d_inv_1xM.flatten().tolist(), "polynomial_offset"
            )
            r = math.tan(a)
            r_rp = float(r / rp)
            if rp > 1e-8:
                rays3d_und_3xN[0, i] = x * r_rp
                rays3d_und_3xN[1, i] = y * r_rp
                rays3d_und_3xN[2, i] = 1
            else:
                rays3d_und_3xN[0, i] = 0
                rays3d_und_3xN[1, i] = 0
                rays3d_und_3xN[2, i] = 1

        return rays3d_und_3xN

    def init_undistort_rectify_map(
        self, k_und_3x3: npt.NDArray, img_size_undist: tuple[int, int]
    ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
        # Create maps
        w = img_size_undist[0]
        h = img_size_undist[1]
        map1 = np.zeros((h, w), dtype=np.float32)
        map2 = np.zeros((h, w), dtype=np.float32)

        # Loop over undistorted domain
        for i in range(0, h):
            # Read all pixel pos of this row
            points2d_und_3xN = np.array(
                [np.linspace(0, w - 1, num=w), i * np.ones(w), np.ones(w)]
            )

            # Un-apply K_und to get the undistorted ray
            ray3d_und_3xN = utils.inv(k_und_3x3).dot(points2d_und_3xN)
            ray3d_und_3xN[0:3, :] = ray3d_und_3xN[0:3, :] / ray3d_und_3xN[2, :]
            ray3d_und_4xN = np.vstack((ray3d_und_3xN, np.ones(w)))

            # Project
            points2d_dist_3xN, idx_valid = self.project_points3d(ray3d_und_4xN)

            # Assign into map
            map1[i, :] = points2d_dist_3xN[0, :]
            map2[i, :] = points2d_dist_3xN[1, :]

        # Return the maps
        return map1, map2

    def estimate_new_camera_matrix_for_undistort_rectify(
        self,
        img_size_orig: tuple[int, int],
        balance: float,
        img_size_dst: tuple[int, int],
        fov_scale: float,
    ) -> npt.NDArray:
        balance = min(max(balance, 0.0), 1.0)
        w = img_size_orig[0]
        h = img_size_orig[1]
        romboid_points = np.array(
            [[w / 2, 0, 1], [w, h / 2, 1], [w / 2, h, 1], [0, h / 2, 1]]
        ).transpose()

        rays3d_und_3xN = self.undistort_rays3d(romboid_points)
        center_mass = cv.mean(rays3d_und_3xN)
        cn = center_mass
        aspect_ratio = 1.0

        # Find maxima
        minx = float("inf")
        miny = float("inf")
        maxx = -float("inf")
        maxy = -float("inf")
        for i in range(0, 4):
            miny = min(miny, rays3d_und_3xN[1, i])
            maxy = max(maxy, rays3d_und_3xN[1, i])
            minx = min(minx, rays3d_und_3xN[0, i])
            maxx = max(maxx, rays3d_und_3xN[0, i])

        f1 = w * 0.5 / (cn[0] - minx)
        f2 = w * 0.5 / (maxx - cn[0])
        f3 = h * 0.5 * aspect_ratio / (cn[1] - miny)
        f4 = h * 0.5 * aspect_ratio / (maxy - cn[1])

        fmin = min(f1, f2, f3, f4)
        fmax = max(f1, f2, f3, f4)

        f = balance * fmin + (1.0 - balance) * fmax
        if fov_scale > 0:
            f *= 1.0 / fov_scale

        new_f = (f, f / aspect_ratio)
        new_c = (
            w * 0.5 - cn[0] * f,
            ((h * aspect_ratio) * 0.5 - cn[0] * f) / aspect_ratio,
        )

        rx = img_size_dst[0] / img_size_orig[0]
        ry = img_size_dst[1] / img_size_orig[1]

        k_new = np.array(
            [
                [new_f[0] * rx, 0, new_c[0] * rx],
                [0, new_f[1] * ry, new_c[1] * ry],
                [0, 0, 1],
            ]
        )

        return k_new

Ancestors

Methods

def distort_points2d(self, points2d_und_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]]
Expand source code
def distort_points2d(self, points2d_und_3xN: npt.NDArray) -> npt.NDArray:
    # This is a transfer from the undistorted domain to the distorted
    rays3d_und_3xN = utils.inv(self.K_und_3x3).dot(points2d_und_3xN)
    rays3d_dist_3xN = self.distort_rays3d(
        rays3d_und_3xN
    )  # TODO: better to use self.project_points?
    points2d_dist_3xN = self.K_3x3.dot(rays3d_dist_3xN)

    return points2d_dist_3xN
def distort_rays3d(self, rays3d_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[numpy.float64]]

This function projects 3d points in camera coordinate system using the angle-of- incidence projection model. Any 3D point in space P=(X,Y,Z,1)^T has a radius with respect to the optical axis Z r = ||X^2 + Y^2|| The angle of incidence to the optical center is a = atan(r/Z)

The angle of incidence then spans from 0 to pi/2 The model of the lens relates the angle of incidence with the radius of the point in the image plane (in pixels): "radial_poly" (4 distortion coefficients) rp = k1a + k2a^2 + k3a^3 + k4a^4 "kannala" (5 distortion coefficients) rp = k1a + k2a^3 + k3a^5 + k4a^7 + k5a^9 "opencv_fisheye" (4 distortion coefficients, equivalent to Kannala with k1=1.0, so only the last 4 terms are used rp = a + k1a^3 + k2a^5 + k3a^7 + k4*a^9

Then, the distorted 3d rays are computed as: ray_dist = (Xrp/r, Yrp/r, 1)

:param rays3d_3xN: 3d rays as 3xN arrays :return: distorted 3d rays as 3xN arrays after applying distortion function.

Expand source code
def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray[np.float64]:
    """
    This function projects 3d points in camera coordinate system using the angle-of-
    incidence projection model. Any 3D point in space P=(X,Y,Z,1)^T has a radius with
    respect to the optical axis Z r = ||X^2 + Y^2|| The angle of incidence to the optical
    center is a = atan(r/Z)

    The angle of incidence then spans from 0 to pi/2 The model of the lens relates the
    angle of incidence with the radius of the point in the image plane (in pixels):
    "radial_poly" (4 distortion coefficients)     rp = k1*a + k2*a^2 + k3*a^3 + k4*a^4
    "kannala" (5 distortion coefficients)     rp = k1*a + k2*a^3 + k3*a^5 + k4*a^7 + k5*a^9
    "opencv_fisheye" (4 distortion coefficients, equivalent to Kannala with k1=1.0, so only
    the                  last 4 terms are used     rp = a + k1*a^3 + k2*a^5 + k3*a^7 +
    k4*a^9

    Then, the distorted 3d rays are computed as: ray_dist = (X*rp/r, Y*rp/r, 1)

    :param rays3d_3xN: 3d rays as 3xN arrays
    :return: distorted 3d rays as 3xN arrays after applying distortion function.
    """  # noqa
    n = rays3d_3xN.shape[1]
    if n == 0 or not self.is_distorted():
        rays3d_dist_3xN = np.array([[]])
        return rays3d_dist_3xN

    rays3d_dist_3xN = np.zeros((3, n))
    for i in range(0, n):
        x = rays3d_3xN[0, i]
        y = rays3d_3xN[1, i]
        z = rays3d_3xN[2, i]
        r = utils.norm([x, y])
        a = math.atan2(r, z)
        # direct distortion, use specified model
        rp = self.__apply_polynomial(a, self.d_1xN.flatten().tolist(), self.model)
        rp_r = float(rp / r)
        if r > 1e-8:
            rays3d_dist_3xN[0, i] = x * rp_r
            rays3d_dist_3xN[1, i] = y * rp_r
            rays3d_dist_3xN[2, i] = 1
        else:
            rays3d_dist_3xN[0, i] = 0
            rays3d_dist_3xN[1, i] = 0
            rays3d_dist_3xN[2, i] = 1

    return rays3d_dist_3xN
def estimate_new_camera_matrix_for_undistort_rectify(self, img_size_orig: tuple[int, int], balance: float, img_size_dst: tuple[int, int], fov_scale: float) ‑> numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]]
Expand source code
def estimate_new_camera_matrix_for_undistort_rectify(
    self,
    img_size_orig: tuple[int, int],
    balance: float,
    img_size_dst: tuple[int, int],
    fov_scale: float,
) -> npt.NDArray:
    balance = min(max(balance, 0.0), 1.0)
    w = img_size_orig[0]
    h = img_size_orig[1]
    romboid_points = np.array(
        [[w / 2, 0, 1], [w, h / 2, 1], [w / 2, h, 1], [0, h / 2, 1]]
    ).transpose()

    rays3d_und_3xN = self.undistort_rays3d(romboid_points)
    center_mass = cv.mean(rays3d_und_3xN)
    cn = center_mass
    aspect_ratio = 1.0

    # Find maxima
    minx = float("inf")
    miny = float("inf")
    maxx = -float("inf")
    maxy = -float("inf")
    for i in range(0, 4):
        miny = min(miny, rays3d_und_3xN[1, i])
        maxy = max(maxy, rays3d_und_3xN[1, i])
        minx = min(minx, rays3d_und_3xN[0, i])
        maxx = max(maxx, rays3d_und_3xN[0, i])

    f1 = w * 0.5 / (cn[0] - minx)
    f2 = w * 0.5 / (maxx - cn[0])
    f3 = h * 0.5 * aspect_ratio / (cn[1] - miny)
    f4 = h * 0.5 * aspect_ratio / (maxy - cn[1])

    fmin = min(f1, f2, f3, f4)
    fmax = max(f1, f2, f3, f4)

    f = balance * fmin + (1.0 - balance) * fmax
    if fov_scale > 0:
        f *= 1.0 / fov_scale

    new_f = (f, f / aspect_ratio)
    new_c = (
        w * 0.5 - cn[0] * f,
        ((h * aspect_ratio) * 0.5 - cn[0] * f) / aspect_ratio,
    )

    rx = img_size_dst[0] / img_size_orig[0]
    ry = img_size_dst[1] / img_size_orig[1]

    k_new = np.array(
        [
            [new_f[0] * rx, 0, new_c[0] * rx],
            [0, new_f[1] * ry, new_c[1] * ry],
            [0, 0, 1],
        ]
    )

    return k_new
def init_undistort_rectify_map(self, k_und_3x3: npt.NDArray, img_size_undist: tuple[int, int]) ‑> tuple[numpy.ndarray[typing.Any, numpy.dtype[numpy.floating]], numpy.ndarray[typing.Any, numpy.dtype[numpy.floating]]]
Expand source code
def init_undistort_rectify_map(
    self, k_und_3x3: npt.NDArray, img_size_undist: tuple[int, int]
) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
    # Create maps
    w = img_size_undist[0]
    h = img_size_undist[1]
    map1 = np.zeros((h, w), dtype=np.float32)
    map2 = np.zeros((h, w), dtype=np.float32)

    # Loop over undistorted domain
    for i in range(0, h):
        # Read all pixel pos of this row
        points2d_und_3xN = np.array(
            [np.linspace(0, w - 1, num=w), i * np.ones(w), np.ones(w)]
        )

        # Un-apply K_und to get the undistorted ray
        ray3d_und_3xN = utils.inv(k_und_3x3).dot(points2d_und_3xN)
        ray3d_und_3xN[0:3, :] = ray3d_und_3xN[0:3, :] / ray3d_und_3xN[2, :]
        ray3d_und_4xN = np.vstack((ray3d_und_3xN, np.ones(w)))

        # Project
        points2d_dist_3xN, idx_valid = self.project_points3d(ray3d_und_4xN)

        # Assign into map
        map1[i, :] = points2d_dist_3xN[0, :]
        map2[i, :] = points2d_dist_3xN[1, :]

    # Return the maps
    return map1, map2
def is_distorted(self) ‑> bool
Expand source code
def is_distorted(self) -> bool:
    return np.count_nonzero(self.d_1xN) > 0
def project_points3d(self, points3d_4xN: npt.NDArray, remove_outside: bool = False) ‑> tuple[numpy.ndarray[typing.Any, numpy.dtype[numpy.floating]], list[bool]]

Project 3d points in camera coordinate system into the image plane.

First, 3d points P=(X,Y,Z,1)^T are treated as 3d rays, and distorted using the angle of incidence equation. Distorted 3d rays are then projected using the linear camera calibration matrix K.

:param points3d_4xN: 3D points in camera coordinate system :param remove_outside: filter out points that fall outside the limits of the image frame. :return: 2D points in image plane, as 3xN array, in hom. coordinates, and boolean array of valid

Expand source code
def project_points3d(
    self, points3d_4xN: npt.NDArray, remove_outside: bool = False
) -> tuple[npt.NDArray[np.floating], list[bool]]:
    """
    Project 3d points in camera coordinate system into the image plane.

    First, 3d points P=(X,Y,Z,1)^T are treated as 3d rays, and distorted using the angle of
    incidence equation. Distorted 3d rays are then projected using the linear camera
    calibration matrix K.

    :param points3d_4xN: 3D points in camera coordinate system
    :param remove_outside: filter out points that fall outside the limits of the image
        frame.
    :return: 2D points in image plane, as 3xN array, in hom. coordinates, and boolean array
        of valid
    """
    # 0.- Pre-filter
    if points3d_4xN.ndim != 2:
        raise ValueError("Invalid argument 'point3d_4xN' dimensions")
    n = points3d_4xN.shape[1]
    if n == 0:
        return np.array([[]]), []

    # 1.- Select only those z > 0 (in front of the camera)
    if self.limit_to_180_degrees:
        idx_in_front: list[bool] = (points3d_4xN[2, :] > 1e-8).tolist()
        idx_valid = idx_in_front
    else:
        idx_valid = [True] * n

    # 2.- Distort rays3d if distorted
    rays3d_3xN_filt = points3d_4xN[0:3, idx_valid]
    rays3d_3xN = np.full([3, n], np.nan)
    rays3d_3xN[:, idx_valid] = rays3d_3xN_filt

    # Now distort (only non-nans)
    rays3d_3xN_filt = rays3d_3xN[:, idx_valid]
    # no nan should go into it
    rays3d_3xN_filt_dist = self.distort_rays3d(rays3d_3xN_filt)

    # Add nans
    rays3d_3xN = np.full([3, n], np.nan)
    rays3d_3xN[:, idx_valid] = rays3d_3xN_filt_dist

    # 3.- Project using calibration matrix
    points2d_3xN = self.K_3x3.dot(rays3d_3xN)
    if remove_outside:
        points2d_3xN, idx_valid = utils.filter_outside(
            points2d_3xN, self.img_size_dist, idx_valid
        )

    return points2d_3xN, idx_valid
def reproject_points2d(self, points2d_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]]
Expand source code
def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray:
    # Get rays3d applying K^-1 and then undistortion
    rays3d_dist_3xN = utils.inv(self.K_3x3).dot(points2d_3xN)
    rays3d_3xN = self.undistort_rays3d(rays3d_dist_3xN=rays3d_dist_3xN)

    return rays3d_3xN
def undistort_image(self, img: cv.Mat) ‑> cv2.Mat
Expand source code
def undistort_image(self, img: cv.Mat) -> cv.Mat:
    if not self.is_distorted():
        return img
    if not self.__has_remaps():
        self.__compute_remaps()
    return cv.remap(
        img,
        self.mapX_to_und_16SC2,
        self.mapY_to_und_16SC2,
        interpolation=cv.INTER_LINEAR,
        borderMode=cv.BORDER_CONSTANT,
    )
def undistort_points2d(self, points2d_dist_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]]
Expand source code
def undistort_points2d(self, points2d_dist_3xN: npt.NDArray) -> npt.NDArray:
    # This is a transfer from the distorted to the undistorted
    rays3d_dist_3xN = utils.inv(self.K_3x3).dot(points2d_dist_3xN)
    rays3d_und_3xN = self.undistort_rays3d(rays3d_dist_3xN)
    points2d_und_3xN = self.K_und_3x3.dot(rays3d_und_3xN)

    return points2d_und_3xN
def undistort_rays3d(self, rays3d_dist_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[numpy.floating]]

Undistort 3d rays according to the inverse polynomial function.

:param rays3d_dist_3xN: 3d rays (connecting optical center and image plane using K) :return: undistorted rays as 3xN

Expand source code
def undistort_rays3d(self, rays3d_dist_3xN: npt.NDArray) -> npt.NDArray[np.floating]:
    """
    Undistort 3d rays according to the inverse polynomial function.

    :param rays3d_dist_3xN: 3d rays (connecting optical center and image plane using K)
    :return: undistorted rays as 3xN
    """
    n = rays3d_dist_3xN.shape[1]
    if n == 0 or not self.is_distorted():
        rays3d_und_3xN = np.array([[]])
        return rays3d_und_3xN

    rays3d_und_3xN = np.zeros((3, n))
    for i in range(0, n):
        x = rays3d_dist_3xN[0, i]
        y = rays3d_dist_3xN[1, i]
        rp = utils.norm([x, y])
        # NOTE: the inverse polynomial is fixed to dim=4 with offset
        a = self.__apply_polynomial(
            rp, self.d_inv_1xM.flatten().tolist(), "polynomial_offset"
        )
        r = math.tan(a)
        r_rp = float(r / rp)
        if rp > 1e-8:
            rays3d_und_3xN[0, i] = x * r_rp
            rays3d_und_3xN[1, i] = y * r_rp
            rays3d_und_3xN[2, i] = 1
        else:
            rays3d_und_3xN[0, i] = 0
            rays3d_und_3xN[1, i] = 0
            rays3d_und_3xN[2, i] = 1

    return rays3d_und_3xN
class CameraOrthographic (camera_intrinsics: dict[str, Any], name: str, description: str, uri: str)

Definition of Orthographic camera model.

This is a special type of camera which does not project 3d entities using a pinhole nor distorted lens. It does project 3d points into the Z_cam = 0 image plane keeping their X_cam and Y_cam values. A rectangular frustum is created defining the (xmax, xmin, ymax, ymin) clipping planes (in camera coordinate system). To keep coherency with other Cameras in VCD, the camera model has Z-front, X-right, Y-bottom.

An additional scaling and centering camera calibration matrix is used to create images with desired number of pixels.

Expand source code
class CameraOrthographic(Camera):
    """
    Definition of Orthographic camera model.

    This is a special type of camera which does not project 3d entities using a pinhole nor
    distorted lens. It does project 3d points into the Z_cam = 0 image plane keeping their
    X_cam and Y_cam values. A rectangular frustum is created defining the (xmax, xmin, ymax,
    ymin) clipping planes (in camera coordinate system). To keep coherency with other Cameras
    in VCD, the camera model has Z-front, X-right, Y-bottom.

    An additional scaling and centering camera calibration matrix is used to create images with
    desired number of pixels.
    """

    def __init__(
        self, camera_intrinsics: dict[str, Any], name: str, description: str, uri: str
    ):
        Camera.__init__(
            self,
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
            name,
            description,
            uri,
        )

        self.img_size = (self.width, self.height)
        self.xmax = camera_intrinsics["xmax"]
        self.xmin = camera_intrinsics["xmin"]
        self.ymax = camera_intrinsics["ymax"]
        self.ymin = camera_intrinsics["ymin"]

        # Create calibration matrix
        sx = self.width * (1.0 / (self.xmax - self.xmin))
        sy = self.height * (1.0 / (self.ymax - self.ymin))
        cx = -self.xmin * sx
        cy = -self.ymin * sy
        # self.K_3x3 = np.array([[sx, 0.0, cx],
        #                       [0.0, sy, cy],
        #                       [0.0, 0.0,1.0]])

        self.K_3x4 = np.array(
            [[sx, 0.0, 0.0, cx], [0.0, sy, 0.0, cy], [0.0, 0.0, 0.0, 1.0]]
        )
        # NOTE: K_3x3 has no meaning for an orthographic camera, because it is not a
        # pinhole model, and then there are no rays going through the optical center.
        # 4x1 need to be used along with K_3x4
        #   self.K_3x3 = utils.fromCameraMatrix3x4toCameraMatrix3x3(self.K_3x4)
        #
        # Note that the orthographic projection matrix is not invertible in homogeneous
        # form. Let's do it manually
        sx_inv = 1 / sx
        sy_inv = 1 / sy

        self.K_3x4_inv = np.array(
            [
                [sx_inv, 0.0, self.xmin, 0.0],
                [0.0, sy_inv, self.ymin, 0.0],
                [0.0, 0.0, 1.0, 0.0],
            ]
        )
        self.K_3x3_inv = utils.fromCameraMatrix3x4toCameraMatrix3x3(self.K_3x4_inv)

    #################################
    # Inherited functions
    #################################
    def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray:
        # Orthographic cameras don't have distortion
        return rays3d_3xN

    def project_points3d(
        self, points3d_4xN: npt.NDArray, remove_outside: bool = False
    ) -> tuple[npt.NDArray[np.floating], list[bool]]:
        # 0.- Pre-filter
        if points3d_4xN.ndim != 2:
            raise ValueError("Invalid argument 'point3d_4xN' dimensions")
        n = points3d_4xN.shape[1]
        if n == 0:
            return np.array([[]]), []

        # 1.- Select only those within limits
        idx_is_inside: list[bool] = np.array(
            (points3d_4xN[0, :] > self.xmin)
            & (points3d_4xN[0, :] < self.xmax)
            & (points3d_4xN[1, :] > self.ymin)
            & (points3d_4xN[1, :] < self.ymax)
            & (points3d_4xN[2, :] > 1e-8)
        ).tolist()

        idx_valid = idx_is_inside
        # rays3d_3xN_filt = points3d_4xN[0:3, idx_valid]

        # 2.- Project using calibration matrix
        # rays3d_4xN = np.vstack((rays3d_3xN_filt, np.ones(rays3d_3xN_filt.shape[1])))
        # rays3d_4xN = np.vstack((rays3d_3xN_filt, np.ones(rays3d_3xN_filt.shape[1])))

        points2d_3xN = self.K_3x4 @ points3d_4xN
        # points2d_3xN /= points2d_3xN[2,:] NOTE: Orthographic projection is no friend of
        # homogeneous coordinates

        if remove_outside:
            points2d_3xN, idx_valid = utils.filter_outside(
                points2d_3xN, (self.width, self.height), idx_valid
            )

        return points2d_3xN, idx_valid

    def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray:
        rays3d_3xN = self.K_3x3_inv @ points2d_3xN
        # rays3d_3xN = utils.normalize(rays3d_3xN)
        return rays3d_3xN

Ancestors

Methods

def distort_rays3d(self, rays3d_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]]
Expand source code
def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray:
    # Orthographic cameras don't have distortion
    return rays3d_3xN
def project_points3d(self, points3d_4xN: npt.NDArray, remove_outside: bool = False) ‑> tuple[numpy.ndarray[typing.Any, numpy.dtype[numpy.floating]], list[bool]]
Expand source code
def project_points3d(
    self, points3d_4xN: npt.NDArray, remove_outside: bool = False
) -> tuple[npt.NDArray[np.floating], list[bool]]:
    # 0.- Pre-filter
    if points3d_4xN.ndim != 2:
        raise ValueError("Invalid argument 'point3d_4xN' dimensions")
    n = points3d_4xN.shape[1]
    if n == 0:
        return np.array([[]]), []

    # 1.- Select only those within limits
    idx_is_inside: list[bool] = np.array(
        (points3d_4xN[0, :] > self.xmin)
        & (points3d_4xN[0, :] < self.xmax)
        & (points3d_4xN[1, :] > self.ymin)
        & (points3d_4xN[1, :] < self.ymax)
        & (points3d_4xN[2, :] > 1e-8)
    ).tolist()

    idx_valid = idx_is_inside
    # rays3d_3xN_filt = points3d_4xN[0:3, idx_valid]

    # 2.- Project using calibration matrix
    # rays3d_4xN = np.vstack((rays3d_3xN_filt, np.ones(rays3d_3xN_filt.shape[1])))
    # rays3d_4xN = np.vstack((rays3d_3xN_filt, np.ones(rays3d_3xN_filt.shape[1])))

    points2d_3xN = self.K_3x4 @ points3d_4xN
    # points2d_3xN /= points2d_3xN[2,:] NOTE: Orthographic projection is no friend of
    # homogeneous coordinates

    if remove_outside:
        points2d_3xN, idx_valid = utils.filter_outside(
            points2d_3xN, (self.width, self.height), idx_valid
        )

    return points2d_3xN, idx_valid
def reproject_points2d(self, points2d_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]]
Expand source code
def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray:
    rays3d_3xN = self.K_3x3_inv @ points2d_3xN
    # rays3d_3xN = utils.normalize(rays3d_3xN)
    return rays3d_3xN
class CameraPinhole (camera_intrinsics: dict[str, Any], name: str, description: str, uri: str, compute_remaps: bool = False)

Define the Pinhole Camera Model.

The Pinhole camera model defines a projection mechanism composed by two steps:

  • Linear projection: using the camera_matrix (K)
  • Radial/Tangential/… distortion: using distortion coefficients

Distortion is assumed to be radial, as in: https://docs.opencv.org/4.2.0/d9/d0c/group__calib3d.html

Expand source code
class CameraPinhole(Camera):
    """
    Define the Pinhole Camera Model.

    The Pinhole camera model defines a projection mechanism composed by two steps:

    - Linear projection: using the camera_matrix (K)
    - Radial/Tangential/... distortion: using distortion coefficients

    Distortion is assumed to be radial, as in:
    https://docs.opencv.org/4.2.0/d9/d0c/group__calib3d.html
    """

    def __init__(
        self,
        camera_intrinsics: dict[str, Any],
        name: str,
        description: str,
        uri: str,
        compute_remaps: bool = False,
    ):
        self.K_3x4 = np.array(camera_intrinsics["camera_matrix_3x4"]).reshape(3, 4)
        (
            rows,
            cols,
        ) = self.K_3x4.shape
        if rows != 3 or cols != 4:
            raise ValueError(f"Invalid intrinsics dimensions: {rows}x{cols}")
        self.K_3x3 = utils.fromCameraMatrix3x4toCameraMatrix3x3(self.K_3x4)
        d_list = camera_intrinsics["distortion_coeffs_1xN"]
        self.d_1xN = np.array(d_list).reshape(1, len(d_list))

        self.r_limit = None
        if self.is_distorted():
            self.r_limit = utils.get_distortion_radius(self.d_1xN)

        # Pre-compute undistortion maps (LUTs)
        self.img_size_dist = (
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
        )
        self.img_size_undist = (
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
        )

        if self.is_distorted():
            # alpha = 0.0 means NO black points in undistorted image
            # alpha = 1.0 means ALL distorted points inside limits of undistorted image
            aux = cv.getOptimalNewCameraMatrix(
                self.K_3x3,
                self.d_1xN,
                self.img_size_dist,
                alpha=0.0,
                newImgSize=self.img_size_undist,
            )
            self.K_und_3x3 = aux[0]
            self.mapX_to_und_16SC2 = None
            self.mapY_to_und_16SC2 = None
            if compute_remaps:
                self.__compute_remaps()
        else:
            self.K_und_3x3 = self.K_3x3

        self.K_und_3x4 = utils.fromCameraMatrix3x3toCameraMatrix3x4(self.K_und_3x3)

        Camera.__init__(
            self,
            camera_intrinsics["width_px"],
            camera_intrinsics["height_px"],
            name,
            description,
            uri,
        )

    #################################
    # Inherited functions
    #################################
    def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray[np.float64]:
        """
        Distort rays3d using the distortion parameters of the camera.

        As a result distorted rays3d are created which can then be projected using the camera
        calibration matrix.

        :param rays3d_3xN: Array with N 3D rays, each of them as column (rx, ry, rz)
        :return: rays3d_dist_3xN: Array with N distorted 3D rays, each of them as column (rx',
            ry', rz')
        """
        n = rays3d_3xN.shape[1]
        if n == 0 or not self.is_distorted():
            rays3d_dist_3xN = np.array([[]])
            return rays3d_dist_3xN

        # Normalize so last coordinate is 1
        rays3d_3xN[0:3, :] = rays3d_3xN[0:3, :] / rays3d_3xN[2, :]

        # NOTE: there is no cv.distortPoints() function as in cv.fisheye.distortPoints()
        # It is though possible to distort points using OpenCV by using cv.projectPoints
        # function
        # As we don't want to use K matrices here, let's use an eye, so the results are
        # rays and not points
        aux = cv.projectPoints(
            objectPoints=rays3d_3xN,
            rvec=np.array([[[0.0, 0.0, 0.0]]]),
            tvec=np.array([[[0.0, 0.0, 0.0]]]),
            cameraMatrix=np.eye(3),
            distCoeffs=self.d_1xN,
        )
        rays3d_dist_3xN = utils.from_OpenCV_Nx1xM_to_MxN(aux[0])
        rays3d_dist_3xN = np.vstack((rays3d_dist_3xN.transpose(), np.ones((1, n))))

        return rays3d_dist_3xN

    def project_points3d(
        self, points3d_4xN: npt.NDArray, remove_outside: bool = False
    ) -> tuple[npt.NDArray[np.floating], list[bool]]:
        """
        Project 3D points into 2D points using the camera projection.

        All coordinates as homogeneous coordinates, and all 3D elements expressed wrt the
        camera coordinate system.

        First, the 3D points are understood as 3D rays. If distorted, the rays3D are distorted
        into distorted rays3D.

        The calibration matrix K_3x3 or K_3x3_und (according to apply_distortion) is applied to
        produce points.

        Points outside limits are removed if remove_outside is True.

        :param points3d_4xN: 3D points in camera cs, homogeneous coordinates
        :param apply_distortion: flag to determine whether to project into distorted or
            undistorted domain
        :param remove_outside: flag to remove points that fall outside the limits of the target
            image
        :return: 2D points in image plane, as 3xN array, in hom. coordinates, and boolean array
            of valid
        """
        # 0.- Pre-filter
        if points3d_4xN.ndim != 2:
            raise ValueError("Invalid argument 'point3d_4xN' dimensions")
        n = points3d_4xN.shape[1]
        if n == 0:
            return np.array([[]]), []

        # 1.- Select only those z > 0 (in front of the camera) - this assumption is good
        #  for pinhole cameras
        idx_in_front: list[bool] = (points3d_4xN[2, :] > 1e-8).tolist()
        idx_valid = idx_in_front
        rays3d_3xN_filt = points3d_4xN[0:3, idx_valid]

        # 2.- Distort rays3d if distorted
        rays3d_3xN = np.full([3, n], np.nan)  # init with NaN
        rays3d_3xN[:, idx_valid] = rays3d_3xN_filt  # copy filtered points

        if self.is_distorted():
            # Cameras with distortion: need to first apply distortion model
            # Pinhole distortion
            if self.r_limit is not None:
                # Some cameras have a valid radius limit: extreme points are weirdly
                # distorted, it is better to keep them as NaN
                for i in range(0, n):
                    if idx_valid[i]:  # ignore those already filtered
                        xp = (
                            rays3d_3xN[0, i] / rays3d_3xN[2, i]
                        )  # this is x'=x/z as in opencv docs
                        yp = rays3d_3xN[1, i] / rays3d_3xN[2, i]  # this is y'=y/z
                        r = np.sqrt(xp * xp + yp * yp)

                        if (
                            r >= self.r_limit * 0.8
                        ):  # 0.8 to also remove very close to limit
                            idx_valid[i] = False
                            rays3d_3xN[:, i] = np.nan

            # Now distort (only non-nans)
            rays3d_3xN_filt = rays3d_3xN[:, idx_valid]
            # no nan should go into it
            rays3d_3xN_filt_dist = self.distort_rays3d(rays3d_3xN_filt)

            # Add nans
            rays3d_3xN[:, idx_valid] = rays3d_3xN_filt_dist

        # 3.- Project using calibration matrix
        rays3d_4xN = np.vstack((rays3d_3xN, np.ones(rays3d_3xN.shape[1])))
        points2d_3xN = self.K_3x4 @ rays3d_4xN
        points2d_3xN /= points2d_3xN[2, :]

        if remove_outside:
            points2d_3xN, idx_valid = utils.filter_outside(
                points2d_3xN, self.img_size_dist, idx_valid
            )

        return points2d_3xN, idx_valid

    def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray[np.float64]:
        """
        Reproject points in the image domain as rays in 3D coordinates.

        (camera coordinate system)
        """
        # Get rays3d applying K^-1 and then undistortion
        rays3d_dist_3xN = utils.normalize(utils.inv(self.K_3x3).dot(points2d_3xN))
        rays3d_dist_3xN = np.array(rays3d_dist_3xN)
        rays3d_3xN = self.undistort_rays3d(rays3d_dist_3xN=rays3d_dist_3xN)

        return rays3d_3xN

    #################################
    # Inner functions
    #################################
    def __has_remaps(self) -> bool:
        if self.mapX_to_und_16SC2 is None or self.mapY_to_und_16SC2 is None:
            return False
        return True

    def __compute_remaps(self):
        start = time.time()
        self.mapX_to_und_16SC2, self.mapY_to_und_16SC2 = cv.initUndistortRectifyMap(
            self.K_3x3,
            self.d_1xN,
            R=np.eye(3),
            newCameraMatrix=self.K_und_3x3,
            size=self.img_size_undist,
            m1type=cv.CV_16SC2,
        )
        end = time.time()
        print("CameraPinhole(radial): Compute remaps for undistortion... ", end - start)

    #################################
    # Other public functions
    #################################
    def undistort_image(self, img: cv.Mat) -> cv.Mat:
        if not self.is_distorted():
            return img
        if not self.__has_remaps():
            self.__compute_remaps()
        # cv.remap works for both models cv. and cv.fisheye
        return cv.remap(
            img,
            self.mapX_to_und_16SC2,
            self.mapY_to_und_16SC2,
            interpolation=cv.INTER_LINEAR,
            borderMode=cv.BORDER_CONSTANT,
        )

    def is_distorted(self) -> bool:
        return np.count_nonzero(self.d_1xN) > 0

    def distort_points2d(self, points2d_und_3xN: npt.NDArray) -> npt.NDArray:
        """
        Project from undistorted to distorted images.

        :param points2d_und_3xN: undistorted points 3xN homogeneous coordinates
        :return: distorted points 3xN homogeneous coordinates
        """
        rays3d_und_3xN = utils.inv(self.K_und_3x3) @ points2d_und_3xN
        rays3d_dist_3xN = self.distort_rays3d(rays3d_und_3xN)
        points2d_dist_3xN = self.K_3x3 @ rays3d_dist_3xN
        return points2d_dist_3xN

    def undistort_points2d(self, points2d_dist_3xN: npt.NDArray) -> npt.NDArray:
        """
        Transfer from the distorted domain to the undistorted domain.

        E.g. img_und = self.camera.undistort_image(_img) points2d_und_3xN =
        self.camera.undistort_points2d(points2d_3xN) rows, cols = points2d_und_3xN.shape for i
        in range(0, cols):     cv.circle(img_und, (utils.round(points2d_und_3xN[0, i]),
        utils.round(points2d_und_3xN[1, i])), 2, (255, 255, 255), -1)
        cv.namedWindow('undistorted-test', cv.WINDOW_NORMAL) cv.imshow('undistorted-test',
        img_und)
        :param points2d_3xN: array of 2d points in homogeneous coordinates 3xN
        :return: array of undistorted 2d points in homogeneous coordinates 3xN
        """
        n = points2d_dist_3xN.shape[1]
        if n < 1 or not self.is_distorted():
            return points2d_dist_3xN

        # Change shape from (3, N) to (N, 1, 2) so we can use OpenCV, removing
        # homogeneous coordinate
        temp1 = points2d_dist_3xN[0:2, :]
        temp2 = utils.from_MxN_to_OpenCV_Nx1xM(temp1)

        # Use OpenCV functions
        temp3 = cv.undistortPoints(temp2, self.K_3x3, self.d_1xN)

        # Reshape to (3, N)
        temp3.shape = (n, 2)
        points2d_und_3xN = np.vstack((temp3.T, np.ones((1, n))))

        # Map into undistorted domain by using K_3x3_und
        points2d_und_3xN = self.K_und_3x3 @ points2d_und_3xN

        # TODO: Add this test to test_scl.py
        test = False
        if test:
            points2d_dist_re_3xN = self.distort_points2d(points2d_und_3xN)
            error = np.linalg.norm(points2d_dist_re_3xN - points2d_dist_3xN)
            print("Undistortion error: ", error)

        return points2d_und_3xN

    def undistort_rays3d(self, rays3d_dist_3xN: npt.NDArray) -> npt.NDArray[np.float64]:
        points2d_dist_3xN: npt.NDArray = rays3d_dist_3xN / rays3d_dist_3xN[2, :]
        n = points2d_dist_3xN.shape[1]
        if n < 1 or not self.is_distorted():
            return points2d_dist_3xN

        # Change shape from (3, N) to (N, 1, 2) so we can use OpenCV, removing homogeneous
        #  coordinate
        temp1 = points2d_dist_3xN[0:2, :]
        temp2 = utils.from_MxN_to_OpenCV_Nx1xM(temp1)

        # Use OpenCV functions (using an eye instead of K to work with rays instead of points)
        temp3: npt.NDArray = cv.undistortPoints(temp2, np.eye(3), self.d_1xN)

        # Reshape to (3, N)
        temp3.shape = (n, 2)
        points2d_und_3xN = np.vstack((temp3.T, np.ones((1, n))))
        rays3d_und_3xN = utils.normalize(points2d_und_3xN)
        rays3d_und_3xN = np.array(rays3d_und_3xN, np.float64)
        return rays3d_und_3xN

Ancestors

Methods

def distort_points2d(self, points2d_und_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]]

Project from undistorted to distorted images.

:param points2d_und_3xN: undistorted points 3xN homogeneous coordinates :return: distorted points 3xN homogeneous coordinates

Expand source code
def distort_points2d(self, points2d_und_3xN: npt.NDArray) -> npt.NDArray:
    """
    Project from undistorted to distorted images.

    :param points2d_und_3xN: undistorted points 3xN homogeneous coordinates
    :return: distorted points 3xN homogeneous coordinates
    """
    rays3d_und_3xN = utils.inv(self.K_und_3x3) @ points2d_und_3xN
    rays3d_dist_3xN = self.distort_rays3d(rays3d_und_3xN)
    points2d_dist_3xN = self.K_3x3 @ rays3d_dist_3xN
    return points2d_dist_3xN
def distort_rays3d(self, rays3d_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[numpy.float64]]

Distort rays3d using the distortion parameters of the camera.

As a result distorted rays3d are created which can then be projected using the camera calibration matrix.

:param rays3d_3xN: Array with N 3D rays, each of them as column (rx, ry, rz) :return: rays3d_dist_3xN: Array with N distorted 3D rays, each of them as column (rx', ry', rz')

Expand source code
def distort_rays3d(self, rays3d_3xN: npt.NDArray) -> npt.NDArray[np.float64]:
    """
    Distort rays3d using the distortion parameters of the camera.

    As a result distorted rays3d are created which can then be projected using the camera
    calibration matrix.

    :param rays3d_3xN: Array with N 3D rays, each of them as column (rx, ry, rz)
    :return: rays3d_dist_3xN: Array with N distorted 3D rays, each of them as column (rx',
        ry', rz')
    """
    n = rays3d_3xN.shape[1]
    if n == 0 or not self.is_distorted():
        rays3d_dist_3xN = np.array([[]])
        return rays3d_dist_3xN

    # Normalize so last coordinate is 1
    rays3d_3xN[0:3, :] = rays3d_3xN[0:3, :] / rays3d_3xN[2, :]

    # NOTE: there is no cv.distortPoints() function as in cv.fisheye.distortPoints()
    # It is though possible to distort points using OpenCV by using cv.projectPoints
    # function
    # As we don't want to use K matrices here, let's use an eye, so the results are
    # rays and not points
    aux = cv.projectPoints(
        objectPoints=rays3d_3xN,
        rvec=np.array([[[0.0, 0.0, 0.0]]]),
        tvec=np.array([[[0.0, 0.0, 0.0]]]),
        cameraMatrix=np.eye(3),
        distCoeffs=self.d_1xN,
    )
    rays3d_dist_3xN = utils.from_OpenCV_Nx1xM_to_MxN(aux[0])
    rays3d_dist_3xN = np.vstack((rays3d_dist_3xN.transpose(), np.ones((1, n))))

    return rays3d_dist_3xN
def is_distorted(self) ‑> bool
Expand source code
def is_distorted(self) -> bool:
    return np.count_nonzero(self.d_1xN) > 0
def project_points3d(self, points3d_4xN: npt.NDArray, remove_outside: bool = False) ‑> tuple[numpy.ndarray[typing.Any, numpy.dtype[numpy.floating]], list[bool]]

Project 3D points into 2D points using the camera projection.

All coordinates as homogeneous coordinates, and all 3D elements expressed wrt the camera coordinate system.

First, the 3D points are understood as 3D rays. If distorted, the rays3D are distorted into distorted rays3D.

The calibration matrix K_3x3 or K_3x3_und (according to apply_distortion) is applied to produce points.

Points outside limits are removed if remove_outside is True.

:param points3d_4xN: 3D points in camera cs, homogeneous coordinates :param apply_distortion: flag to determine whether to project into distorted or undistorted domain :param remove_outside: flag to remove points that fall outside the limits of the target image :return: 2D points in image plane, as 3xN array, in hom. coordinates, and boolean array of valid

Expand source code
def project_points3d(
    self, points3d_4xN: npt.NDArray, remove_outside: bool = False
) -> tuple[npt.NDArray[np.floating], list[bool]]:
    """
    Project 3D points into 2D points using the camera projection.

    All coordinates as homogeneous coordinates, and all 3D elements expressed wrt the
    camera coordinate system.

    First, the 3D points are understood as 3D rays. If distorted, the rays3D are distorted
    into distorted rays3D.

    The calibration matrix K_3x3 or K_3x3_und (according to apply_distortion) is applied to
    produce points.

    Points outside limits are removed if remove_outside is True.

    :param points3d_4xN: 3D points in camera cs, homogeneous coordinates
    :param apply_distortion: flag to determine whether to project into distorted or
        undistorted domain
    :param remove_outside: flag to remove points that fall outside the limits of the target
        image
    :return: 2D points in image plane, as 3xN array, in hom. coordinates, and boolean array
        of valid
    """
    # 0.- Pre-filter
    if points3d_4xN.ndim != 2:
        raise ValueError("Invalid argument 'point3d_4xN' dimensions")
    n = points3d_4xN.shape[1]
    if n == 0:
        return np.array([[]]), []

    # 1.- Select only those z > 0 (in front of the camera) - this assumption is good
    #  for pinhole cameras
    idx_in_front: list[bool] = (points3d_4xN[2, :] > 1e-8).tolist()
    idx_valid = idx_in_front
    rays3d_3xN_filt = points3d_4xN[0:3, idx_valid]

    # 2.- Distort rays3d if distorted
    rays3d_3xN = np.full([3, n], np.nan)  # init with NaN
    rays3d_3xN[:, idx_valid] = rays3d_3xN_filt  # copy filtered points

    if self.is_distorted():
        # Cameras with distortion: need to first apply distortion model
        # Pinhole distortion
        if self.r_limit is not None:
            # Some cameras have a valid radius limit: extreme points are weirdly
            # distorted, it is better to keep them as NaN
            for i in range(0, n):
                if idx_valid[i]:  # ignore those already filtered
                    xp = (
                        rays3d_3xN[0, i] / rays3d_3xN[2, i]
                    )  # this is x'=x/z as in opencv docs
                    yp = rays3d_3xN[1, i] / rays3d_3xN[2, i]  # this is y'=y/z
                    r = np.sqrt(xp * xp + yp * yp)

                    if (
                        r >= self.r_limit * 0.8
                    ):  # 0.8 to also remove very close to limit
                        idx_valid[i] = False
                        rays3d_3xN[:, i] = np.nan

        # Now distort (only non-nans)
        rays3d_3xN_filt = rays3d_3xN[:, idx_valid]
        # no nan should go into it
        rays3d_3xN_filt_dist = self.distort_rays3d(rays3d_3xN_filt)

        # Add nans
        rays3d_3xN[:, idx_valid] = rays3d_3xN_filt_dist

    # 3.- Project using calibration matrix
    rays3d_4xN = np.vstack((rays3d_3xN, np.ones(rays3d_3xN.shape[1])))
    points2d_3xN = self.K_3x4 @ rays3d_4xN
    points2d_3xN /= points2d_3xN[2, :]

    if remove_outside:
        points2d_3xN, idx_valid = utils.filter_outside(
            points2d_3xN, self.img_size_dist, idx_valid
        )

    return points2d_3xN, idx_valid
def reproject_points2d(self, points2d_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[numpy.float64]]

Reproject points in the image domain as rays in 3D coordinates.

(camera coordinate system)

Expand source code
def reproject_points2d(self, points2d_3xN: npt.NDArray) -> npt.NDArray[np.float64]:
    """
    Reproject points in the image domain as rays in 3D coordinates.

    (camera coordinate system)
    """
    # Get rays3d applying K^-1 and then undistortion
    rays3d_dist_3xN = utils.normalize(utils.inv(self.K_3x3).dot(points2d_3xN))
    rays3d_dist_3xN = np.array(rays3d_dist_3xN)
    rays3d_3xN = self.undistort_rays3d(rays3d_dist_3xN=rays3d_dist_3xN)

    return rays3d_3xN
def undistort_image(self, img: cv.Mat) ‑> cv2.Mat
Expand source code
def undistort_image(self, img: cv.Mat) -> cv.Mat:
    if not self.is_distorted():
        return img
    if not self.__has_remaps():
        self.__compute_remaps()
    # cv.remap works for both models cv. and cv.fisheye
    return cv.remap(
        img,
        self.mapX_to_und_16SC2,
        self.mapY_to_und_16SC2,
        interpolation=cv.INTER_LINEAR,
        borderMode=cv.BORDER_CONSTANT,
    )
def undistort_points2d(self, points2d_dist_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]]

Transfer from the distorted domain to the undistorted domain.

E.g. img_und = self.camera.undistort_image(_img) points2d_und_3xN = self.camera.undistort_points2d(points2d_3xN) rows, cols = points2d_und_3xN.shape for i in range(0, cols): cv.circle(img_und, (utils.round(points2d_und_3xN[0, i]), utils.round(points2d_und_3xN[1, i])), 2, (255, 255, 255), -1) cv.namedWindow('undistorted-test', cv.WINDOW_NORMAL) cv.imshow('undistorted-test', img_und) :param points2d_3xN: array of 2d points in homogeneous coordinates 3xN :return: array of undistorted 2d points in homogeneous coordinates 3xN

Expand source code
def undistort_points2d(self, points2d_dist_3xN: npt.NDArray) -> npt.NDArray:
    """
    Transfer from the distorted domain to the undistorted domain.

    E.g. img_und = self.camera.undistort_image(_img) points2d_und_3xN =
    self.camera.undistort_points2d(points2d_3xN) rows, cols = points2d_und_3xN.shape for i
    in range(0, cols):     cv.circle(img_und, (utils.round(points2d_und_3xN[0, i]),
    utils.round(points2d_und_3xN[1, i])), 2, (255, 255, 255), -1)
    cv.namedWindow('undistorted-test', cv.WINDOW_NORMAL) cv.imshow('undistorted-test',
    img_und)
    :param points2d_3xN: array of 2d points in homogeneous coordinates 3xN
    :return: array of undistorted 2d points in homogeneous coordinates 3xN
    """
    n = points2d_dist_3xN.shape[1]
    if n < 1 or not self.is_distorted():
        return points2d_dist_3xN

    # Change shape from (3, N) to (N, 1, 2) so we can use OpenCV, removing
    # homogeneous coordinate
    temp1 = points2d_dist_3xN[0:2, :]
    temp2 = utils.from_MxN_to_OpenCV_Nx1xM(temp1)

    # Use OpenCV functions
    temp3 = cv.undistortPoints(temp2, self.K_3x3, self.d_1xN)

    # Reshape to (3, N)
    temp3.shape = (n, 2)
    points2d_und_3xN = np.vstack((temp3.T, np.ones((1, n))))

    # Map into undistorted domain by using K_3x3_und
    points2d_und_3xN = self.K_und_3x3 @ points2d_und_3xN

    # TODO: Add this test to test_scl.py
    test = False
    if test:
        points2d_dist_re_3xN = self.distort_points2d(points2d_und_3xN)
        error = np.linalg.norm(points2d_dist_re_3xN - points2d_dist_3xN)
        print("Undistortion error: ", error)

    return points2d_und_3xN
def undistort_rays3d(self, rays3d_dist_3xN: npt.NDArray) ‑> numpy.ndarray[typing.Any, numpy.dtype[numpy.float64]]
Expand source code
def undistort_rays3d(self, rays3d_dist_3xN: npt.NDArray) -> npt.NDArray[np.float64]:
    points2d_dist_3xN: npt.NDArray = rays3d_dist_3xN / rays3d_dist_3xN[2, :]
    n = points2d_dist_3xN.shape[1]
    if n < 1 or not self.is_distorted():
        return points2d_dist_3xN

    # Change shape from (3, N) to (N, 1, 2) so we can use OpenCV, removing homogeneous
    #  coordinate
    temp1 = points2d_dist_3xN[0:2, :]
    temp2 = utils.from_MxN_to_OpenCV_Nx1xM(temp1)

    # Use OpenCV functions (using an eye instead of K to work with rays instead of points)
    temp3: npt.NDArray = cv.undistortPoints(temp2, np.eye(3), self.d_1xN)

    # Reshape to (3, N)
    temp3.shape = (n, 2)
    points2d_und_3xN = np.vstack((temp3.T, np.ones((1, n))))
    rays3d_und_3xN = utils.normalize(points2d_und_3xN)
    rays3d_und_3xN = np.array(rays3d_und_3xN, np.float64)
    return rays3d_und_3xN
class Edge (start, end, cost)

Edge(start, end, cost)

Ancestors

  • builtins.tuple

Instance variables

var cost

Alias for field number 2

var end

Alias for field number 1

var start

Alias for field number 0

class Graph (edges: list)
Expand source code
class Graph:
    def __init__(self, edges: list):
        # let's check that the data is right
        wrong_edges = [i for i in edges if len(i) not in [2, 3]]
        if wrong_edges:
            raise ValueError(f"Wrong edges data: {wrong_edges}")

        self.edges = [make_edge(*edge) for edge in edges]

    @property
    def vertices(self) -> set:
        return set(sum(([edge.start, edge.end] for edge in self.edges), []))

    @staticmethod
    def get_node_pairs(n1: str, n2: str, both_ends: bool = True) -> list[list[str]]:
        if both_ends:
            node_pairs = [[n1, n2], [n2, n1]]
        else:
            node_pairs = [[n1, n2]]
        return node_pairs

    def remove_edge(self, n1: str, n2: str, both_ends: bool = True):
        node_pairs = self.get_node_pairs(n1, n2, both_ends)
        edges = self.edges[:]
        for edge in edges:
            if [edge.start, edge.end] in node_pairs:
                self.edges.remove(edge)

    def add_edge(self, n1: str, n2: str, cost: float = 1, both_ends: bool = True):
        node_pairs = self.get_node_pairs(n1, n2, both_ends)
        for edge in self.edges:
            if [edge.start, edge.end] in node_pairs:
                raise ValueError(f"Edge {n1} {n2} already exists")

        self.edges.append(Edge(start=n1, end=n2, cost=cost))
        if both_ends:
            self.edges.append(Edge(start=n2, end=n1, cost=cost))

    @property
    def neighbours(self) -> dict:
        neighbours: dict = {vertex: set() for vertex in self.vertices}
        for edge in self.edges:
            neighbours[edge.start].add((edge.end, edge.cost))

        return neighbours

    def dijkstra(self, source: str, dest: str) -> deque[str]:
        if source not in self.vertices:
            raise ValueError("Such source node doesn't exist")
        distances = {vertex: inf for vertex in self.vertices}
        previous_vertices = {vertex: None for vertex in self.vertices}
        distances[source] = 0
        vertices = self.vertices.copy()

        while vertices:
            current_vertex = min(vertices, key=lambda vertex: distances[vertex])
            vertices.remove(current_vertex)
            if distances[current_vertex] == inf:
                break
            for neighbour, cost in self.neighbours[current_vertex]:
                alternative_route = distances[current_vertex] + cost
                if alternative_route < distances[neighbour]:
                    distances[neighbour] = alternative_route
                    previous_vertices[neighbour] = current_vertex

        path: deque = deque()
        current_vertex = dest
        while previous_vertices[current_vertex] is not None:
            path.appendleft(current_vertex)
            current_vertex = previous_vertices[current_vertex]
        if path:
            path.appendleft(current_vertex)
        return path

Static methods

def get_node_pairs(n1: str, n2: str, both_ends: bool = True) ‑> list[list[str]]
Expand source code
@staticmethod
def get_node_pairs(n1: str, n2: str, both_ends: bool = True) -> list[list[str]]:
    if both_ends:
        node_pairs = [[n1, n2], [n2, n1]]
    else:
        node_pairs = [[n1, n2]]
    return node_pairs

Instance variables

var neighbours : dict
Expand source code
@property
def neighbours(self) -> dict:
    neighbours: dict = {vertex: set() for vertex in self.vertices}
    for edge in self.edges:
        neighbours[edge.start].add((edge.end, edge.cost))

    return neighbours
var vertices : set
Expand source code
@property
def vertices(self) -> set:
    return set(sum(([edge.start, edge.end] for edge in self.edges), []))

Methods

def add_edge(self, n1: str, n2: str, cost: float = 1, both_ends: bool = True)
Expand source code
def add_edge(self, n1: str, n2: str, cost: float = 1, both_ends: bool = True):
    node_pairs = self.get_node_pairs(n1, n2, both_ends)
    for edge in self.edges:
        if [edge.start, edge.end] in node_pairs:
            raise ValueError(f"Edge {n1} {n2} already exists")

    self.edges.append(Edge(start=n1, end=n2, cost=cost))
    if both_ends:
        self.edges.append(Edge(start=n2, end=n1, cost=cost))
def dijkstra(self, source: str, dest: str) ‑> collections.deque[str]
Expand source code
def dijkstra(self, source: str, dest: str) -> deque[str]:
    if source not in self.vertices:
        raise ValueError("Such source node doesn't exist")
    distances = {vertex: inf for vertex in self.vertices}
    previous_vertices = {vertex: None for vertex in self.vertices}
    distances[source] = 0
    vertices = self.vertices.copy()

    while vertices:
        current_vertex = min(vertices, key=lambda vertex: distances[vertex])
        vertices.remove(current_vertex)
        if distances[current_vertex] == inf:
            break
        for neighbour, cost in self.neighbours[current_vertex]:
            alternative_route = distances[current_vertex] + cost
            if alternative_route < distances[neighbour]:
                distances[neighbour] = alternative_route
                previous_vertices[neighbour] = current_vertex

    path: deque = deque()
    current_vertex = dest
    while previous_vertices[current_vertex] is not None:
        path.appendleft(current_vertex)
        current_vertex = previous_vertices[current_vertex]
    if path:
        path.appendleft(current_vertex)
    return path
def remove_edge(self, n1: str, n2: str, both_ends: bool = True)
Expand source code
def remove_edge(self, n1: str, n2: str, both_ends: bool = True):
    node_pairs = self.get_node_pairs(n1, n2, both_ends)
    edges = self.edges[:]
    for edge in edges:
        if [edge.start, edge.end] in node_pairs:
            self.edges.remove(edge)
class Scene (vcd: core.VCD)

Scene class.

This class defines a scene by reading a VCD file that contains coordinate systems, cameras, and objects. The Scene object brings projection functionalities such as creating warped images, converting objects from one coordinate system to another, etc.

Load a VCD file and create a Scene

vcd = core.OpenLABEL('myOpenLABELfile.json') scene = scl.Scene(vcd)

Use the scene to get cameras

camera_front = scene.get_camera('front')

Use cameras

camera_front.project_points3d(points3d_4xN)

Attributes

vcd : VCD
A vcd object.
Expand source code
class Scene:
    """
    Scene class.

    This class defines a scene by reading a VCD file that contains coordinate systems, cameras,
    and objects. The Scene object brings projection functionalities such as creating warped
    images, converting objects from one coordinate system to another, etc.

    # Load a VCD file and create a Scene
    vcd = core.OpenLABEL('myOpenLABELfile.json')
    scene = scl.Scene(vcd)

    # Use the scene to get cameras
    camera_front = scene.get_camera('front')

    # Use cameras
    camera_front.project_points3d(points3d_4xN)

    Attributes:
        vcd (vcd.core.VCD): A vcd object.
    """

    def __init__(self, vcd: core.VCD):
        self.vcd: core.VCD = vcd
        self.cameras: dict = {}

    #########################################
    # Inner functions
    #########################################
    def __get_transform_chain(self, cs_src: str, cs_dst: str) -> deque:
        # Create graph with the poses defined for each coordinate_system
        # These are poses valid "statically"
        lista = []
        root = self.vcd.get_root()
        for cs_name, cs_body in root["coordinate_systems"].items():
            for child in cs_body["children"]:
                lista.append((cs_name, child, 1))
                lista.append((child, cs_name, 1))

        graph = Graph(lista)
        result = graph.dijkstra(cs_src, cs_dst)
        return result

    def __get_camera_object(
        self,
        stream_properties: dict,
        camera_name: str,
        description: str,
        uri: str,
        compute_remaps: bool = False,
    ) -> Camera | None:
        sp = stream_properties

        camera: (
            CameraPinhole
            | CameraFisheye
            | CameraCylindrical
            | CameraOrthographic
            | CameraOrthographic
            | CameraCubemap
            | None
        )

        if "intrinsics_pinhole" in sp:
            camera = CameraPinhole(
                sp["intrinsics_pinhole"],
                camera_name,
                description,
                uri,
                compute_remaps,
            )
        elif "intrinsics_fisheye" in sp:
            camera = CameraFisheye(
                sp["intrinsics_fisheye"],
                camera_name,
                description,
                uri,
                compute_remaps,
                limit_to_180_degrees=True,
            )
        elif "intrinsics_cylindrical" in sp:
            camera = CameraCylindrical(
                sp["intrinsics_cylindrical"], camera_name, description, uri
            )
        elif "intrinsics_orthographic" in sp:
            camera = CameraOrthographic(
                sp["intrinsics_orthographic"], camera_name, description, uri
            )
        elif "intrinsics_cubemap" in sp:
            camera = CameraCubemap(
                sp["intrinsics_cubemap"],
                camera_name,
                description,
                uri,
                compute_remaps,
            )
        else:
            warnings.warn(
                "WARNING: SCL does not support customized camera models. Supported "
                "types are CameraPinhole, CameraFisheye, CameraCylindrical, "
                "CameraOrthographic and CameraCubemap. See types.py.",
                Warning,
                2,
            )
            camera = None

        # Return the created object
        return camera

    #########################################
    # Public functions
    #########################################
    def get_camera(
        self, camera_name: str, frame_num: int | None = None, compute_remaps: bool = False
    ) -> Camera | None:
        """
        Explore the VCD content searching for the camera parameters of camera.

        The parameters are searched using "camera_name", specific for frame_num if specified
        (or static information if None).

        The function consults and updates a store of information self.cameras, to speed up some
        computations (so they are carried out only once).

        Args:
            camera_name (str): name of the camera
            frame_num (int, None): frame number (if None, static camera info is requested)
            compute_remaps: (bool)

        Returns:
            An object of type Camera, which can be used to project points, undistort
            images, etc.
        """
        # Check if already computed
        f = -1 if frame_num is None else frame_num

        # Read basic info about streams
        root = self.vcd.get_root()
        if "streams" in root:
            if camera_name in root["streams"]:
                uri = root["streams"][camera_name]["uri"]
                description = root["streams"][camera_name]["description"]

        # Check if dynamic intrinsics
        dynamic_intrinsics = False
        if frame_num is not None:
            vcd_frame = self.vcd.get_frame(frame_num)
            if "frame_properties" in vcd_frame:
                if "streams" in vcd_frame["frame_properties"]:
                    if camera_name in vcd_frame["frame_properties"]["streams"]:
                        if (
                            "stream_properties"
                            in vcd_frame["frame_properties"]["streams"][camera_name]
                        ):
                            sp = vcd_frame["frame_properties"]["streams"][camera_name][
                                "stream_properties"
                            ]
                            dynamic_intrinsics = True  # SO, there are dynamic intrinsics!

        # Read intrinsics and create/read existing camera
        camera = None
        if dynamic_intrinsics:
            # Create new camera
            if frame_num is not None:
                vcd_frame = self.vcd.get_frame(frame_num)
                sp = vcd_frame["frame_properties"]["streams"][camera_name][
                    "stream_properties"
                ]
                camera = self.__get_camera_object(
                    sp, camera_name, description, uri, compute_remaps
                )
        else:
            # Read already created camera, or, if first time here, create one
            # Let's use f=-1
            f = -1
            if camera_name in self.cameras:
                if f in self.cameras[camera_name]:
                    # return the one labeled as f=-1
                    return self.cameras[camera_name][f]["cam"]

            # Create camera m if needed
            if "streams" in root:
                if camera_name in root["streams"]:
                    uri = root["streams"][camera_name]["uri"]
                    description = root["streams"][camera_name]["description"]
                    if "stream_properties" in root["streams"][camera_name]:
                        sp = root["streams"][camera_name]["stream_properties"]
                        camera = self.__get_camera_object(
                            sp, camera_name, description, uri, compute_remaps
                        )
            else:
                return None

        # Update store (f is -1 if static intrinsics are found, otherwise, a specific
        # frame_num is used)
        self.cameras.setdefault(camera_name, {})
        self.cameras[camera_name].setdefault(f, {})
        self.cameras[camera_name][f]["cam"] = camera

        return camera

    def get_transform(
        self, cs_src: str, cs_dst: str, frame_num: int | None = None
    ) -> tuple[npt.NDArray, bool]:
        """
        Find 4x4 transform from source to destination coordinate systems.

        This function finds a 4x4 transform from the specified source coordinate system into
        the destination coordinate system, in a way points in the cs_src domain can be
        transformed to the cs_dst. The function works finding the chain of transforms needed to
        go from src to dst by exploring the parent-child dependencies declared in VCD.

        If the frame_num is specified, the function searches if any specific transform step at
        frame_num. If not found, static transforms are returned.
        :param cs_src: source coordinate frame (e.g. "CAM_LEFT", or "WORLD")
        :param cs_dst: destination coordinate frame (e.g. "VELO", or "CAM_LEFT")
        :param frame_num: frame number where to look for specific transform steps
        :return: the 4x4 transform matrix, and a boolean that specifies if the transform is
            static or not
        """
        if not self.vcd.has_coordinate_system(cs_src):
            raise ValueError(f"This VCD does not have the coordinate system {cs_src}")
        if not self.vcd.has_coordinate_system(cs_dst):
            raise ValueError(f"This VCD does not have the coordinate system {cs_dst}")

        static = True
        if cs_src == cs_dst:
            return np.eye(4), static

        # Get chain of transforms
        chain = self.__get_transform_chain(cs_src, cs_dst)

        # Let's build the transform using atomic transforms (which exist in VCD)
        t_4x4: npt.NDArray = np.identity(4, dtype=float)
        root = self.vcd.get_root()
        for counter, _value in enumerate(chain):
            # e.g. a) result = {("cam_left", "velo_top"), ("velo_top", "vehicle-iso8855")}
            # e.g. b) result = {("vehicle-iso8855", "velo_top"), ("velo_top", "cam_left")}
            if counter == len(chain) - 1:
                break
            cs_1 = chain[counter]
            cs_2 = chain[counter + 1]

            t_name = cs_1 + "_to_" + cs_2
            t_name_inv = cs_2 + "_to_" + cs_1

            # NOTE: this entire function works under the consensus that
            # pose_src_wrt_dst = transform_src_to_dst, using
            # alias rotation of coordinate systems and linear 4x4
            if frame_num is None:
                # No frame info, let's read from coordinate_system poses
                # Check if this edge is from child to parent or viceversa
                if cs_2 == root["coordinate_systems"][cs_1]["parent"]:
                    temp = utils.get_transform_as_matrix4x4(
                        root["coordinate_systems"][cs_1]["pose_wrt_parent"]
                    )
                    if temp is not None:
                        t_4x4 = temp @ t_4x4
                elif cs_1 == root["coordinate_systems"][cs_2]["parent"]:
                    temp = utils.get_transform_as_matrix4x4(
                        root["coordinate_systems"][cs_2]["pose_wrt_parent"]
                    )
                    if temp is not None:
                        t_4x4 = utils.inv(temp) @ t_4x4

            else:
                # So the user has asked for a specific frame, let's look for this frame if a
                # transform exist
                transform_at_this_frame = False
                if frame_num in root["frames"]:
                    if "frame_properties" in root["frames"][frame_num]:
                        if "transforms" in root["frames"][frame_num]["frame_properties"]:
                            if (
                                t_name
                                in root["frames"][frame_num]["frame_properties"][
                                    "transforms"
                                ]
                            ):
                                transform = root["frames"][frame_num]["frame_properties"][
                                    "transforms"
                                ][t_name]
                                temp = utils.get_transform_as_matrix4x4(
                                    transform["transform_src_to_dst"]
                                )
                                if temp is not None:
                                    t_4x4 = temp.dot(t_4x4)
                                # with one non-static step the entire chain can be
                                # considered not static
                                static = False
                                transform_at_this_frame = True
                            elif (
                                t_name_inv
                                in root["frames"][frame_num]["frame_properties"][
                                    "transforms"
                                ]
                            ):
                                transform = root["frames"][frame_num]["frame_properties"][
                                    "transforms"
                                ][t_name_inv]
                                temp = utils.get_transform_as_matrix4x4(
                                    transform["transform_src_to_dst"]
                                )
                                if temp is not None:
                                    t_4x4 = utils.inv(temp) @ t_4x4
                                static = False
                                transform_at_this_frame = True
                if not transform_at_this_frame:
                    # Reached this point means no transforms were defined at the requested
                    # frame_num
                    # Check if this edge is from child to parent or viceversa
                    if cs_2 == root["coordinate_systems"][cs_1]["parent"]:
                        t_4x4 = (
                            utils.get_transform_as_matrix4x4(
                                root["coordinate_systems"][cs_1]["pose_wrt_parent"]
                            )
                            @ t_4x4
                        )
                    elif cs_1 == root["coordinate_systems"][cs_2]["parent"]:
                        temp = utils.get_transform_as_matrix4x4(
                            root["coordinate_systems"][cs_2]["pose_wrt_parent"]
                        )
                        if temp is not None:
                            t_4x4 = utils.inv(temp) @ t_4x4

        return t_4x4, static

    def transform_points3d_4xN(
        self,
        points3d_4xN: npt.NDArray,
        cs_src: str,
        cs_dst: str,
        frame_num: int | None = None,
    ) -> npt.NDArray | None:
        transform_src_dst, _ = self.get_transform(cs_src, cs_dst, frame_num)
        if transform_src_dst is not None:
            points3d_dst_4xN = utils.transform_points3d_4xN(
                points3d_4xN, transform_src_dst
            )
            return points3d_dst_4xN
        return None

    def transform_cuboid(
        self,
        cuboid_vals: list | tuple,
        cs_src: str,
        cs_dst: str,
        frame_num: int | None = None,
    ) -> list[float]:
        transform_src_dst, _ = self.get_transform(cs_src, cs_dst, frame_num)

        if transform_src_dst is not None:
            cuboid_vals_transformed = utils.transform_cuboid(
                cuboid_vals, transform_src_dst
            )
            return cuboid_vals_transformed

        return list(cuboid_vals)

    def transform_plane(
        self,
        plane_abcd: list[float],
        cs_src: str,
        cs_dst: str,
        frame_num: int | None = None,
    ) -> list[float]:
        transform_src_dst, _ = self.get_transform(cs_src, cs_dst, frame_num)
        if transform_src_dst is not None:
            plane_abcd_transformed = utils.transform_plane(plane_abcd, transform_src_dst)
            return plane_abcd_transformed

        return plane_abcd

    def project_points3d_4xN(
        self,
        points3d_4xN: npt.NDArray,
        cs_src: str,
        cs_cam: str,
        frame_num: int | None = None,
        remove_outside: bool = False,
    ) -> tuple[npt.NDArray, list[bool]]:
        """
        Project provided 3D point into the given camera.

        This function projects 3D points into a given camera, specifying the origin coordinate
        system of the points, and a certain frame number. Optionally, distortion can be applied
        or not (e.g. sometimes is useful to project into the undistorted domain).

        :param points3d_4xN: array of 4xN 3D points in cs_src coordinate system
        :param cs_src: name of coordinate system of the points
        :param cs_cam: name of the camera
        :param frame_num: frame number (if None, static camera info is seeked)
        :param remove_outside: flag to invalidate points outside the limits of the image domain
        :return: array of 3xN 2D points in image coordinates (distorted or undistorted
            according to apply_distortion), and array of boolean declaring points valid or not
        """
        points3d_camera_cs_4xN = self.transform_points3d_4xN(
            points3d_4xN=points3d_4xN, cs_src=cs_src, cs_dst=cs_cam, frame_num=frame_num
        )
        if points3d_camera_cs_4xN is not None:
            cam = self.get_camera(camera_name=cs_cam, frame_num=frame_num)
            if cam is not None:
                points2d_3xN, idx_valid = cam.project_points3d(
                    points3d_4xN=points3d_camera_cs_4xN, remove_outside=remove_outside
                )
                return points2d_3xN, idx_valid
        return np.array([[]]), []

    @staticmethod
    def __plucker_line_plane_intersection(
        lines_3xN: npt.NDArray, plane_abcd: list[float]
    ) -> tuple[npt.NDArray, list[bool]]:
        # Plucker intersection between Line and Plane
        # Use Plucker intersection line-plane
        # Create Plucker line using 2 points: origin of camera and origin of camera + ray
        n = lines_3xN.shape[1]
        idx_valid = [True] * n
        p1 = np.vstack((0, 0, 0, 1))
        p2_array = np.vstack((lines_3xN, np.ones((1, n))))
        # Plane equation in plucker coordinates (wrt to world)
        p = np.asarray(plane_abcd).reshape(4, 1)
        # Line equation in plucker coordinates
        p3d_Nx4 = np.array([])
        count = 0
        for p2 in p2_array.T:
            p2 = p2.reshape(4, 1)
            li = np.matmul(p1, np.transpose(p2)) - np.matmul(p2, np.transpose(p1))
            # Intersection is a 3D point
            p3d_lcs = np.matmul(li, p)
            if p3d_lcs[3][0] != 0:
                p3d_lcs /= p3d_lcs[3][0]  # homogeneous
            else:
                # This is an infinite point: return direction vector instead
                norm = np.linalg.norm(p3d_lcs[:3][0])
                p3d_lcs /= norm
                idx_valid[count] = False
            p3d_Nx4 = np.append(p3d_Nx4, p3d_lcs)
            count += 1
        p3d_Nx4 = p3d_Nx4.reshape(p3d_Nx4.shape[0] // 4, 4)
        p3d_4xN = np.transpose(p3d_Nx4)
        return p3d_4xN, idx_valid

    def reproject_points2d_3xN_into_plane(
        self,
        points2d_3xN: npt.NDArray,
        plane: list[float],
        cs_cam: str,
        cs_dst: str,
        frame_num: int | None = None,
    ) -> tuple[npt.NDArray, list[bool]]:
        # This function calls a camera (cs_cam) to reproject points2d in the image plane into
        # a plane defined in the cs_dst.
        # The obtained 3D points are expressed in cs_dst.
        # idx_valid identifies which points are valid 3D points (the reprojection might point to
        # infinity)
        cam = self.get_camera(cs_cam, frame_num)

        # first convert plane into cam cs
        plane_cam = self.transform_plane(plane, cs_dst, cs_cam, frame_num)
        n = points2d_3xN.shape[1]

        # Reproject points as rays with
        if cam is not None:
            rays3d_3xN_cs_cam = cam.reproject_points2d(points2d_3xN)
            points3d_3xN_cs_cam, idx_valid = self.__plucker_line_plane_intersection(
                rays3d_3xN_cs_cam, plane_cam
            )

            if points3d_3xN_cs_cam.shape[1] > 0:
                points3d_3xN_cs_cam_filt = points3d_3xN_cs_cam[:, idx_valid]
                points3d_4xN_cs_dst_filt = self.transform_points3d_4xN(
                    points3d_3xN_cs_cam_filt, cs_cam, cs_dst, frame_num
                )
                points3d_4xN_cs_dst = np.full([4, n], np.nan)
                points3d_4xN_cs_dst[:, idx_valid] = points3d_4xN_cs_dst_filt
                return points3d_4xN_cs_dst, idx_valid
        return np.array([[]]), []

    def reproject_points2d_3xN(
        self, points2d_3xN: npt.NDArray, cs_cam: str, frame_num: int | None = None
    ) -> npt.NDArray:
        rays3d_3xN = np.array([])
        cam = self.get_camera(cs_cam, frame_num)
        if cam is not None:
            rays3d_3xN = cam.reproject_points2d(points2d_3xN)
        return rays3d_3xN

    def create_img_projection_maps(
        self,
        cam_src_name: str,
        cam_dst_name: str,
        frame_num: int | None,
        filter_z_neg: bool = False,
    ) -> tuple[npt.NDArray, npt.NDArray]:
        """
        Create image projection maps.

        In SCL, extrinsic and intrinsic parameters are detached. Extrinsic parameters are
        defined at the VCD's coordinate systems. Intrinsic parameters are defined as VCD's
        stream_properties.

        To create the projection maps, we need to take 2D pixels from cam_src, convert to 3D
        rays, then transform into 3D rays in cam_dst coordinate system, and then project into
        2D pixels
        """
        # Get cameras
        cam_src = self.get_camera(cam_src_name, frame_num)
        cam_dst = self.get_camera(cam_dst_name, frame_num)

        if cam_src is None or cam_dst is None:
            return np.array([]), np.array([])

        # Get all pixels of dst image domain as a 3xN array
        x, y = np.mgrid[0 : cam_dst.width : 1, 0 : cam_dst.height : 1]

        points2d_dst_3xN = np.row_stack(
            (
                x.T.ravel(),
                y.T.ravel(),
                np.ones(cam_dst.width * cam_dst.height, dtype=int),
            )
        ).reshape(3, cam_dst.width * cam_dst.height)

        # Reproject as rays3d in cam_dst coordinate system
        rays3d_dst_3xN = cam_dst.reproject_points2d(points2d_dst_3xN)

        if filter_z_neg:
            # Filter Z negative rays.
            # From camera reprojection matrix, x = z * ( u - cx) / fx.
            # Z is negative if and only if x and (u - cx) / fx have opposite signs.
            # Only CameraPinhole and CameraFisheye have K_3x3

            if isinstance(cam_dst, (CameraPinhole, CameraFisheye)):
                cx = cam_dst.K_3x3[0, 2]
                fx = cam_dst.K_3x3[0, 0]
                is_z_negative = (
                    rays3d_dst_3xN[0, :] * (points2d_dst_3xN[0, :] - cx) / fx < 0
                )
                rays3d_dst_3xN[:, np.where(is_z_negative)] = np.nan

        # Convert into cam_src coordinate system
        if cam_dst.__class__ is CameraOrthographic:
            n = rays3d_dst_3xN.shape[1]
            points3d_z0_4xN = np.vstack(
                (
                    rays3d_dst_3xN[0, :],
                    rays3d_dst_3xN[1, :],
                    np.zeros(n),
                    rays3d_dst_3xN[2, :],
                )
            )
            points3d_4xN = points3d_z0_4xN
        else:
            points3d_4xN = utils.add_homogeneous_row(rays3d_dst_3xN)

        rays3d_src_4xN = self.transform_points3d_4xN(
            points3d_4xN, cam_dst_name, cam_src_name, frame_num
        )

        if rays3d_src_4xN is None:
            return np.array([]), np.array([])

        rays3d_src_4xN /= rays3d_src_4xN[3, :]

        # Project into cam_src
        points2d_src_3xN, _ = cam_src.project_points3d(rays3d_src_4xN)

        # Create maps for cv2.remap
        # u_map = np.zeros((cam_dst.height, cam_dst.width, 1), dtype=np.float32)
        # v_map = np.zeros((cam_dst.height, cam_dst.width, 1), dtype=np.float32)

        u_map = (
            points2d_src_3xN[0, :]
            .reshape(cam_dst.height, cam_dst.width, 1)
            .astype("float32")
        )
        v_map = (
            points2d_src_3xN[1, :]
            .reshape(cam_dst.height, cam_dst.width, 1)
            .astype("float32")
        )

        # Optimization
        map_x, map_y = cv.convertMaps(
            u_map, v_map, dstmap1type=cv.CV_16SC2, nninterpolation=False
        )

        # Application would then be: cv.remap(image, map_x, map_y, interpolation=interp,
        # borderMode=cv.BORDER_REFLECT)

        # Return
        return map_x, map_y

Methods

def create_img_projection_maps(self, cam_src_name: str, cam_dst_name: str, frame_num: int | None, filter_z_neg: bool = False) ‑> tuple[numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]], numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]]]

Create image projection maps.

In SCL, extrinsic and intrinsic parameters are detached. Extrinsic parameters are defined at the VCD's coordinate systems. Intrinsic parameters are defined as VCD's stream_properties.

To create the projection maps, we need to take 2D pixels from cam_src, convert to 3D rays, then transform into 3D rays in cam_dst coordinate system, and then project into 2D pixels

Expand source code
def create_img_projection_maps(
    self,
    cam_src_name: str,
    cam_dst_name: str,
    frame_num: int | None,
    filter_z_neg: bool = False,
) -> tuple[npt.NDArray, npt.NDArray]:
    """
    Create image projection maps.

    In SCL, extrinsic and intrinsic parameters are detached. Extrinsic parameters are
    defined at the VCD's coordinate systems. Intrinsic parameters are defined as VCD's
    stream_properties.

    To create the projection maps, we need to take 2D pixels from cam_src, convert to 3D
    rays, then transform into 3D rays in cam_dst coordinate system, and then project into
    2D pixels
    """
    # Get cameras
    cam_src = self.get_camera(cam_src_name, frame_num)
    cam_dst = self.get_camera(cam_dst_name, frame_num)

    if cam_src is None or cam_dst is None:
        return np.array([]), np.array([])

    # Get all pixels of dst image domain as a 3xN array
    x, y = np.mgrid[0 : cam_dst.width : 1, 0 : cam_dst.height : 1]

    points2d_dst_3xN = np.row_stack(
        (
            x.T.ravel(),
            y.T.ravel(),
            np.ones(cam_dst.width * cam_dst.height, dtype=int),
        )
    ).reshape(3, cam_dst.width * cam_dst.height)

    # Reproject as rays3d in cam_dst coordinate system
    rays3d_dst_3xN = cam_dst.reproject_points2d(points2d_dst_3xN)

    if filter_z_neg:
        # Filter Z negative rays.
        # From camera reprojection matrix, x = z * ( u - cx) / fx.
        # Z is negative if and only if x and (u - cx) / fx have opposite signs.
        # Only CameraPinhole and CameraFisheye have K_3x3

        if isinstance(cam_dst, (CameraPinhole, CameraFisheye)):
            cx = cam_dst.K_3x3[0, 2]
            fx = cam_dst.K_3x3[0, 0]
            is_z_negative = (
                rays3d_dst_3xN[0, :] * (points2d_dst_3xN[0, :] - cx) / fx < 0
            )
            rays3d_dst_3xN[:, np.where(is_z_negative)] = np.nan

    # Convert into cam_src coordinate system
    if cam_dst.__class__ is CameraOrthographic:
        n = rays3d_dst_3xN.shape[1]
        points3d_z0_4xN = np.vstack(
            (
                rays3d_dst_3xN[0, :],
                rays3d_dst_3xN[1, :],
                np.zeros(n),
                rays3d_dst_3xN[2, :],
            )
        )
        points3d_4xN = points3d_z0_4xN
    else:
        points3d_4xN = utils.add_homogeneous_row(rays3d_dst_3xN)

    rays3d_src_4xN = self.transform_points3d_4xN(
        points3d_4xN, cam_dst_name, cam_src_name, frame_num
    )

    if rays3d_src_4xN is None:
        return np.array([]), np.array([])

    rays3d_src_4xN /= rays3d_src_4xN[3, :]

    # Project into cam_src
    points2d_src_3xN, _ = cam_src.project_points3d(rays3d_src_4xN)

    # Create maps for cv2.remap
    # u_map = np.zeros((cam_dst.height, cam_dst.width, 1), dtype=np.float32)
    # v_map = np.zeros((cam_dst.height, cam_dst.width, 1), dtype=np.float32)

    u_map = (
        points2d_src_3xN[0, :]
        .reshape(cam_dst.height, cam_dst.width, 1)
        .astype("float32")
    )
    v_map = (
        points2d_src_3xN[1, :]
        .reshape(cam_dst.height, cam_dst.width, 1)
        .astype("float32")
    )

    # Optimization
    map_x, map_y = cv.convertMaps(
        u_map, v_map, dstmap1type=cv.CV_16SC2, nninterpolation=False
    )

    # Application would then be: cv.remap(image, map_x, map_y, interpolation=interp,
    # borderMode=cv.BORDER_REFLECT)

    # Return
    return map_x, map_y
def get_camera(self, camera_name: str, frame_num: int | None = None, compute_remaps: bool = False) ‑> Camera | None

Explore the VCD content searching for the camera parameters of camera.

The parameters are searched using "camera_name", specific for frame_num if specified (or static information if None).

The function consults and updates a store of information self.cameras, to speed up some computations (so they are carried out only once).

Args

camera_name : str
name of the camera
frame_num : int, None
frame number (if None, static camera info is requested)
compute_remaps
(bool)

Returns

An object of type Camera, which can be used to project points, undistort images, etc.

Expand source code
def get_camera(
    self, camera_name: str, frame_num: int | None = None, compute_remaps: bool = False
) -> Camera | None:
    """
    Explore the VCD content searching for the camera parameters of camera.

    The parameters are searched using "camera_name", specific for frame_num if specified
    (or static information if None).

    The function consults and updates a store of information self.cameras, to speed up some
    computations (so they are carried out only once).

    Args:
        camera_name (str): name of the camera
        frame_num (int, None): frame number (if None, static camera info is requested)
        compute_remaps: (bool)

    Returns:
        An object of type Camera, which can be used to project points, undistort
        images, etc.
    """
    # Check if already computed
    f = -1 if frame_num is None else frame_num

    # Read basic info about streams
    root = self.vcd.get_root()
    if "streams" in root:
        if camera_name in root["streams"]:
            uri = root["streams"][camera_name]["uri"]
            description = root["streams"][camera_name]["description"]

    # Check if dynamic intrinsics
    dynamic_intrinsics = False
    if frame_num is not None:
        vcd_frame = self.vcd.get_frame(frame_num)
        if "frame_properties" in vcd_frame:
            if "streams" in vcd_frame["frame_properties"]:
                if camera_name in vcd_frame["frame_properties"]["streams"]:
                    if (
                        "stream_properties"
                        in vcd_frame["frame_properties"]["streams"][camera_name]
                    ):
                        sp = vcd_frame["frame_properties"]["streams"][camera_name][
                            "stream_properties"
                        ]
                        dynamic_intrinsics = True  # SO, there are dynamic intrinsics!

    # Read intrinsics and create/read existing camera
    camera = None
    if dynamic_intrinsics:
        # Create new camera
        if frame_num is not None:
            vcd_frame = self.vcd.get_frame(frame_num)
            sp = vcd_frame["frame_properties"]["streams"][camera_name][
                "stream_properties"
            ]
            camera = self.__get_camera_object(
                sp, camera_name, description, uri, compute_remaps
            )
    else:
        # Read already created camera, or, if first time here, create one
        # Let's use f=-1
        f = -1
        if camera_name in self.cameras:
            if f in self.cameras[camera_name]:
                # return the one labeled as f=-1
                return self.cameras[camera_name][f]["cam"]

        # Create camera m if needed
        if "streams" in root:
            if camera_name in root["streams"]:
                uri = root["streams"][camera_name]["uri"]
                description = root["streams"][camera_name]["description"]
                if "stream_properties" in root["streams"][camera_name]:
                    sp = root["streams"][camera_name]["stream_properties"]
                    camera = self.__get_camera_object(
                        sp, camera_name, description, uri, compute_remaps
                    )
        else:
            return None

    # Update store (f is -1 if static intrinsics are found, otherwise, a specific
    # frame_num is used)
    self.cameras.setdefault(camera_name, {})
    self.cameras[camera_name].setdefault(f, {})
    self.cameras[camera_name][f]["cam"] = camera

    return camera
def get_transform(self, cs_src: str, cs_dst: str, frame_num: int | None = None) ‑> tuple[numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]], bool]

Find 4x4 transform from source to destination coordinate systems.

This function finds a 4x4 transform from the specified source coordinate system into the destination coordinate system, in a way points in the cs_src domain can be transformed to the cs_dst. The function works finding the chain of transforms needed to go from src to dst by exploring the parent-child dependencies declared in VCD.

If the frame_num is specified, the function searches if any specific transform step at frame_num. If not found, static transforms are returned. :param cs_src: source coordinate frame (e.g. "CAM_LEFT", or "WORLD") :param cs_dst: destination coordinate frame (e.g. "VELO", or "CAM_LEFT") :param frame_num: frame number where to look for specific transform steps :return: the 4x4 transform matrix, and a boolean that specifies if the transform is static or not

Expand source code
def get_transform(
    self, cs_src: str, cs_dst: str, frame_num: int | None = None
) -> tuple[npt.NDArray, bool]:
    """
    Find 4x4 transform from source to destination coordinate systems.

    This function finds a 4x4 transform from the specified source coordinate system into
    the destination coordinate system, in a way points in the cs_src domain can be
    transformed to the cs_dst. The function works finding the chain of transforms needed to
    go from src to dst by exploring the parent-child dependencies declared in VCD.

    If the frame_num is specified, the function searches if any specific transform step at
    frame_num. If not found, static transforms are returned.
    :param cs_src: source coordinate frame (e.g. "CAM_LEFT", or "WORLD")
    :param cs_dst: destination coordinate frame (e.g. "VELO", or "CAM_LEFT")
    :param frame_num: frame number where to look for specific transform steps
    :return: the 4x4 transform matrix, and a boolean that specifies if the transform is
        static or not
    """
    if not self.vcd.has_coordinate_system(cs_src):
        raise ValueError(f"This VCD does not have the coordinate system {cs_src}")
    if not self.vcd.has_coordinate_system(cs_dst):
        raise ValueError(f"This VCD does not have the coordinate system {cs_dst}")

    static = True
    if cs_src == cs_dst:
        return np.eye(4), static

    # Get chain of transforms
    chain = self.__get_transform_chain(cs_src, cs_dst)

    # Let's build the transform using atomic transforms (which exist in VCD)
    t_4x4: npt.NDArray = np.identity(4, dtype=float)
    root = self.vcd.get_root()
    for counter, _value in enumerate(chain):
        # e.g. a) result = {("cam_left", "velo_top"), ("velo_top", "vehicle-iso8855")}
        # e.g. b) result = {("vehicle-iso8855", "velo_top"), ("velo_top", "cam_left")}
        if counter == len(chain) - 1:
            break
        cs_1 = chain[counter]
        cs_2 = chain[counter + 1]

        t_name = cs_1 + "_to_" + cs_2
        t_name_inv = cs_2 + "_to_" + cs_1

        # NOTE: this entire function works under the consensus that
        # pose_src_wrt_dst = transform_src_to_dst, using
        # alias rotation of coordinate systems and linear 4x4
        if frame_num is None:
            # No frame info, let's read from coordinate_system poses
            # Check if this edge is from child to parent or viceversa
            if cs_2 == root["coordinate_systems"][cs_1]["parent"]:
                temp = utils.get_transform_as_matrix4x4(
                    root["coordinate_systems"][cs_1]["pose_wrt_parent"]
                )
                if temp is not None:
                    t_4x4 = temp @ t_4x4
            elif cs_1 == root["coordinate_systems"][cs_2]["parent"]:
                temp = utils.get_transform_as_matrix4x4(
                    root["coordinate_systems"][cs_2]["pose_wrt_parent"]
                )
                if temp is not None:
                    t_4x4 = utils.inv(temp) @ t_4x4

        else:
            # So the user has asked for a specific frame, let's look for this frame if a
            # transform exist
            transform_at_this_frame = False
            if frame_num in root["frames"]:
                if "frame_properties" in root["frames"][frame_num]:
                    if "transforms" in root["frames"][frame_num]["frame_properties"]:
                        if (
                            t_name
                            in root["frames"][frame_num]["frame_properties"][
                                "transforms"
                            ]
                        ):
                            transform = root["frames"][frame_num]["frame_properties"][
                                "transforms"
                            ][t_name]
                            temp = utils.get_transform_as_matrix4x4(
                                transform["transform_src_to_dst"]
                            )
                            if temp is not None:
                                t_4x4 = temp.dot(t_4x4)
                            # with one non-static step the entire chain can be
                            # considered not static
                            static = False
                            transform_at_this_frame = True
                        elif (
                            t_name_inv
                            in root["frames"][frame_num]["frame_properties"][
                                "transforms"
                            ]
                        ):
                            transform = root["frames"][frame_num]["frame_properties"][
                                "transforms"
                            ][t_name_inv]
                            temp = utils.get_transform_as_matrix4x4(
                                transform["transform_src_to_dst"]
                            )
                            if temp is not None:
                                t_4x4 = utils.inv(temp) @ t_4x4
                            static = False
                            transform_at_this_frame = True
            if not transform_at_this_frame:
                # Reached this point means no transforms were defined at the requested
                # frame_num
                # Check if this edge is from child to parent or viceversa
                if cs_2 == root["coordinate_systems"][cs_1]["parent"]:
                    t_4x4 = (
                        utils.get_transform_as_matrix4x4(
                            root["coordinate_systems"][cs_1]["pose_wrt_parent"]
                        )
                        @ t_4x4
                    )
                elif cs_1 == root["coordinate_systems"][cs_2]["parent"]:
                    temp = utils.get_transform_as_matrix4x4(
                        root["coordinate_systems"][cs_2]["pose_wrt_parent"]
                    )
                    if temp is not None:
                        t_4x4 = utils.inv(temp) @ t_4x4

    return t_4x4, static
def project_points3d_4xN(self, points3d_4xN: npt.NDArray, cs_src: str, cs_cam: str, frame_num: int | None = None, remove_outside: bool = False) ‑> tuple[numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]], list[bool]]

Project provided 3D point into the given camera.

This function projects 3D points into a given camera, specifying the origin coordinate system of the points, and a certain frame number. Optionally, distortion can be applied or not (e.g. sometimes is useful to project into the undistorted domain).

:param points3d_4xN: array of 4xN 3D points in cs_src coordinate system :param cs_src: name of coordinate system of the points :param cs_cam: name of the camera :param frame_num: frame number (if None, static camera info is seeked) :param remove_outside: flag to invalidate points outside the limits of the image domain :return: array of 3xN 2D points in image coordinates (distorted or undistorted according to apply_distortion), and array of boolean declaring points valid or not

Expand source code
def project_points3d_4xN(
    self,
    points3d_4xN: npt.NDArray,
    cs_src: str,
    cs_cam: str,
    frame_num: int | None = None,
    remove_outside: bool = False,
) -> tuple[npt.NDArray, list[bool]]:
    """
    Project provided 3D point into the given camera.

    This function projects 3D points into a given camera, specifying the origin coordinate
    system of the points, and a certain frame number. Optionally, distortion can be applied
    or not (e.g. sometimes is useful to project into the undistorted domain).

    :param points3d_4xN: array of 4xN 3D points in cs_src coordinate system
    :param cs_src: name of coordinate system of the points
    :param cs_cam: name of the camera
    :param frame_num: frame number (if None, static camera info is seeked)
    :param remove_outside: flag to invalidate points outside the limits of the image domain
    :return: array of 3xN 2D points in image coordinates (distorted or undistorted
        according to apply_distortion), and array of boolean declaring points valid or not
    """
    points3d_camera_cs_4xN = self.transform_points3d_4xN(
        points3d_4xN=points3d_4xN, cs_src=cs_src, cs_dst=cs_cam, frame_num=frame_num
    )
    if points3d_camera_cs_4xN is not None:
        cam = self.get_camera(camera_name=cs_cam, frame_num=frame_num)
        if cam is not None:
            points2d_3xN, idx_valid = cam.project_points3d(
                points3d_4xN=points3d_camera_cs_4xN, remove_outside=remove_outside
            )
            return points2d_3xN, idx_valid
    return np.array([[]]), []
def reproject_points2d_3xN(self, points2d_3xN: npt.NDArray, cs_cam: str, frame_num: int | None = None) ‑> numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]]
Expand source code
def reproject_points2d_3xN(
    self, points2d_3xN: npt.NDArray, cs_cam: str, frame_num: int | None = None
) -> npt.NDArray:
    rays3d_3xN = np.array([])
    cam = self.get_camera(cs_cam, frame_num)
    if cam is not None:
        rays3d_3xN = cam.reproject_points2d(points2d_3xN)
    return rays3d_3xN
def reproject_points2d_3xN_into_plane(self, points2d_3xN: npt.NDArray, plane: list[float], cs_cam: str, cs_dst: str, frame_num: int | None = None) ‑> tuple[numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]], list[bool]]
Expand source code
def reproject_points2d_3xN_into_plane(
    self,
    points2d_3xN: npt.NDArray,
    plane: list[float],
    cs_cam: str,
    cs_dst: str,
    frame_num: int | None = None,
) -> tuple[npt.NDArray, list[bool]]:
    # This function calls a camera (cs_cam) to reproject points2d in the image plane into
    # a plane defined in the cs_dst.
    # The obtained 3D points are expressed in cs_dst.
    # idx_valid identifies which points are valid 3D points (the reprojection might point to
    # infinity)
    cam = self.get_camera(cs_cam, frame_num)

    # first convert plane into cam cs
    plane_cam = self.transform_plane(plane, cs_dst, cs_cam, frame_num)
    n = points2d_3xN.shape[1]

    # Reproject points as rays with
    if cam is not None:
        rays3d_3xN_cs_cam = cam.reproject_points2d(points2d_3xN)
        points3d_3xN_cs_cam, idx_valid = self.__plucker_line_plane_intersection(
            rays3d_3xN_cs_cam, plane_cam
        )

        if points3d_3xN_cs_cam.shape[1] > 0:
            points3d_3xN_cs_cam_filt = points3d_3xN_cs_cam[:, idx_valid]
            points3d_4xN_cs_dst_filt = self.transform_points3d_4xN(
                points3d_3xN_cs_cam_filt, cs_cam, cs_dst, frame_num
            )
            points3d_4xN_cs_dst = np.full([4, n], np.nan)
            points3d_4xN_cs_dst[:, idx_valid] = points3d_4xN_cs_dst_filt
            return points3d_4xN_cs_dst, idx_valid
    return np.array([[]]), []
def transform_cuboid(self, cuboid_vals: list | tuple, cs_src: str, cs_dst: str, frame_num: int | None = None) ‑> list[float]
Expand source code
def transform_cuboid(
    self,
    cuboid_vals: list | tuple,
    cs_src: str,
    cs_dst: str,
    frame_num: int | None = None,
) -> list[float]:
    transform_src_dst, _ = self.get_transform(cs_src, cs_dst, frame_num)

    if transform_src_dst is not None:
        cuboid_vals_transformed = utils.transform_cuboid(
            cuboid_vals, transform_src_dst
        )
        return cuboid_vals_transformed

    return list(cuboid_vals)
def transform_plane(self, plane_abcd: list[float], cs_src: str, cs_dst: str, frame_num: int | None = None) ‑> list[float]
Expand source code
def transform_plane(
    self,
    plane_abcd: list[float],
    cs_src: str,
    cs_dst: str,
    frame_num: int | None = None,
) -> list[float]:
    transform_src_dst, _ = self.get_transform(cs_src, cs_dst, frame_num)
    if transform_src_dst is not None:
        plane_abcd_transformed = utils.transform_plane(plane_abcd, transform_src_dst)
        return plane_abcd_transformed

    return plane_abcd
def transform_points3d_4xN(self, points3d_4xN: npt.NDArray, cs_src: str, cs_dst: str, frame_num: int | None = None) ‑> numpy.ndarray[typing.Any, numpy.dtype[+_ScalarType_co]] | None
Expand source code
def transform_points3d_4xN(
    self,
    points3d_4xN: npt.NDArray,
    cs_src: str,
    cs_dst: str,
    frame_num: int | None = None,
) -> npt.NDArray | None:
    transform_src_dst, _ = self.get_transform(cs_src, cs_dst, frame_num)
    if transform_src_dst is not None:
        points3d_dst_4xN = utils.transform_points3d_4xN(
            points3d_4xN, transform_src_dst
        )
        return points3d_dst_4xN
    return None
class Sensor (name: str, description: str, uri: str, **properties: Any)
Expand source code
class Sensor:
    def __init__(self, name: str, description: str, uri: str, **properties: Any):
        self.name = name
        self.description = description
        self.uri = uri
        self.type = type(self).__name__

        self.properties = properties  # additional properties

    def is_camera(self) -> bool:
        if self.type in ("CameraPinhole", "CameraFisheye", "CameraEquirectangular"):
            return True
        return False

    def is_lidar(self) -> bool:
        if self.type == "Lidar":
            return True
        return False

Subclasses

Methods

def is_camera(self) ‑> bool
Expand source code
def is_camera(self) -> bool:
    if self.type in ("CameraPinhole", "CameraFisheye", "CameraEquirectangular"):
        return True
    return False
def is_lidar(self) ‑> bool
Expand source code
def is_lidar(self) -> bool:
    if self.type == "Lidar":
        return True
    return False