"""
Abstract base class and hierarchy for camera models (perspective, fisheye, catadioptric, etc.).
"""
from __future__ import annotations
import copy
from abc import ABC, abstractmethod
from collections import namedtuple
from math import cos, pi, sin, sqrt, tan
from typing import TYPE_CHECKING, Any, Callable
import cv2
import matplotlib.pyplot as plt
import numpy as np
import scipy
import spatialmath.base as smb
from matplotlib.axes import Axes
from matplotlib.figure import Figure
# from mpl_toolkits.mplot3d import Axes3D, art3d
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
from spatialmath import SE3, Line3
from machinevisiontoolbox import Image
from machinevisiontoolbox.base import idisp
from machinevisiontoolbox.base.imageio import _isnotebook
from machinevisiontoolbox.ImagePointFeatures import FeatureMatch
from machinevisiontoolbox.mvtb_types import ArrayLike
# from machinevisiontoolbox.classes import Image
# import CameraVisualizer as CamVis
class CameraBase(ABC):
_name: str
_camtype: str
_imagesize: np.ndarray | None
_pp: np.ndarray | None
_rhou: float
_rhov: float
_image: np.ndarray | None
_ax: Axes | None
def __init__(
self,
name: str | None = None,
camtype: str = "central",
rho: ArrayLike = 1,
imagesize: ArrayLike | None = None,
sensorsize: ArrayLike | None = None,
pp: ArrayLike | None = None,
noise: float | None = None,
pose: SE3 | None = None,
limits: ArrayLike | None = None,
labels: tuple[str, str] | None = None,
seed: int | None = None,
) -> None:
"""Abstract camera base class
:param name: camera instance name, defaults to ``None``
:type name: str, optional
:param camtype: camera projection type, defaults to 'central'
:type camtype: str, optional
:param rho: pixel size, defaults to 1
:type rho: scalar or array_like(2), optional
:param imagesize: image dimension (width, height) in pixels, defaults to ``None``
:type imagesize: int or array_like(2), optional
:param sensorsize: image sensor size (width, height), defaults to ``None``
:type sensorsize: array_like(2), optional
:param pp: principal point position, defaults to ``None``
:type pp: array_like(2), optional
:param noise: standard deviation of image plane projection noise, defaults to ``None``
:type noise: float, optional
:param pose: camera pose, defaults to ``None``
:type pose: :class:`~spatialmath.pose3d.SE3`, optional
:param limits: bounds of virtual image plane [umin, umax, vmin, vmax], defaults to ``None``
:type limits: array_like(4), optional
:param labels: axis labels for virtual image plane, defaults to ``('u', 'v')``
:type labels: 2-tuple of str, optional
:param seed: random number seed for projection noise, defaults to ``None``
:type seed: int, optional
:raises TypeError: name must be a string
:raises TypeError: camtype must be a string
:raises ValueError: rho must be a 1- or 2-element vector
This abstract class is the base for all camera projection model
classes. All baseclass constructors support these options.
"""
# initialise nullable attributes before any setter that checks them
self._imagesize = None
self._pp = None
if name is None:
self._name = camtype
else:
if not isinstance(name, str):
raise TypeError(name, "name must be a string")
self._name = name
if not isinstance(camtype, str):
raise TypeError(camtype, "camtype must be a string")
self._camtype = camtype
if imagesize is None:
if pp is None:
self.pp = (0, 0)
else:
self.pp = pp
else:
self.imagesize = imagesize
if pp is None:
self.pp = [x / 2 for x in self.imagesize]
else:
self.pp = pp
if sensorsize is not None:
assert (
self._imagesize is not None
), "imagesize must be set when sensorsize is given"
self._rhou = sensorsize[0] / self._imagesize[0]
self._rhov = sensorsize[1] / self._imagesize[1]
else:
rho = smb.getvector(rho)
if len(rho) == 1:
self._rhou = rho[0]
self._rhov = rho[0]
elif len(rho) == 2:
self._rhou = rho[0]
self._rhov = rho[1]
else:
raise ValueError(rho, "rho must be a 1- or 2-element vector")
if noise is not None:
self._noise = noise
self._random = np.random.default_rng(seed)
if pose is None:
self._pose = SE3()
else:
self._pose = SE3(pose)
self.pose0 = self.pose
self._noise = noise
self._image = None
self._ax = None
self._distortion = None
self.labels = labels
self.limits = limits
def reset(self) -> None:
"""
Reset camera pose (base method)
Restore camera to a copy of the pose given to the constructor. The copy
means that the camera pose can be modified freely, without destroying
the initial pose value.
"""
self.pose = self.pose0.copy()
def __str__(self) -> str:
"""
String representation of camera parameters (base method)
:return: string representation
:rtype: str
Multi-line string representation of camera intrinsic and extrinsic
parameters.
"""
# TODO, imagesize should be integers
s = ""
self.fmt = "{:>15s}: {}\n"
s += self.fmt.format("Name", self.name + " [" + self.__class__.__name__ + "]")
s += self.fmt.format("pixel size", " x ".join([str(x) for x in self.rho]))
if self.imagesize is not None:
s += self.fmt.format(
"image size", " x ".join([str(x) for x in self.imagesize])
)
s += self.fmt.format("pose", self.pose.strline(fmt="{:.3g}", orient="rpy/yxz"))
return s
def __repr__(self) -> str:
"""
Readable representation of camera parameters (base method)
:return: string representation
:rtype: str
Single-line string representation including camera name and pixel size.
"""
def fmt(x: tuple[float, float], spec: str = ".3g") -> str:
return f"({x[0]:{spec}}, {x[1]:{spec}})"
s = f"{self.__class__.__name__}(name='{self.name}'"
s += f", pixel_size={fmt(self.rho)}"
if self.imagesize is not None:
s += f", image_size={fmt(self._imagesize, spec='d')}"
s += f", pose=[{self.pose.strline()}]"
return s + ")"
@abstractmethod
def project_point(self, P, **kwargs: Any) -> np.ndarray:
pass
def project_line(self, *args: Any, **kwargs: Any) -> np.ndarray:
raise NotImplementedError("not implemented for this camera model")
def project_conic(self, *args: Any, **kwargs: Any) -> np.ndarray:
raise NotImplementedError("not implemented for this camera model")
@property
def name(self) -> str:
"""
Set/get camera name (base method)
A camera has a string-valued name that can be read and written.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera();
>>> camera.name
>>> camera.name = "foo"
>>> camera.name
"""
return self._name
@name.setter
def name(self, newname: str) -> None:
"""
Set camera name
:param newname: camera name
:type newname: str
"""
if isinstance(newname, str):
self._name = newname
else:
raise TypeError(newname, "name must be a string")
@property
def camtype(self) -> str:
"""
Set/get camera type (base method)
A camera has a string-valued type that can be read and written. This
is unique to the camera subclass and projection model.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera();
>>> camera.camtype
>>> camera.camtype = "foo"
>>> camera.camtype
"""
return self._camtype
@camtype.setter
def camtype(self, newcamtype: str) -> None:
"""
Set camera type
:param newcamtype: camera projection type
:type newcamtype: str
"""
if isinstance(newcamtype, str):
self._camtype = newcamtype
else:
raise TypeError(newcamtype, "camtype must be a string")
@property
def imagesize(self) -> np.ndarray | None:
"""
Set/get size of virtual image plane (base method)
The dimensions of the virtual image plane is a 2-tuple (width, height) that can
be read or written. For writing the size must be an iterable of length 2.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default();
>>> camera.imagesize
>>> camera.imagesize = (500, 500)
>>> camera.imagesize
.. note:: If the principal point is not set, then setting imagesize
sets the principal point to the centre of the image plane.
:seealso: :meth:`width` :meth:`height` :meth:`nu` :meth:`nv`
"""
return self._imagesize
@imagesize.setter
def imagesize(self, npix: ArrayLike) -> None:
"""
Set image plane size
:param npix: [description]
:type npix: array_like(2)
:raises ValueError: bad value
Sets the size of the virtual image plane.
.. note:: If the principle point is not set, then it
is set to the centre of the image plane.
:seealso: :meth:`width` :meth:`height` :meth:`nu` :meth:`nv`
"""
npix = smb.getvector(npix, dtype="int")
if len(npix) == 1:
self._imagesize = np.r_[npix[0], npix[0]]
elif len(npix) in (2, 3):
# ignore color dimension in case it is given
self._imagesize = npix[:2]
else:
raise ValueError(npix, "imagesize must be a 1- or 2-element vector")
if self._pp is None:
assert self._imagesize is not None
self._pp = self._imagesize / 2
@property
def nu(self) -> int:
"""
Get image plane width (base method)
:return: width (width)
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default();
>>> camera.nu
:seealso: :meth:`nv` :meth:`width` :meth:`imagesize`
"""
assert self._imagesize is not None, "imagesize not set"
return self._imagesize[0]
@property
def nv(self) -> int:
"""
Get image plane height (base method)
:return: height (height)
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default();
>>> camera.nv
:seealso: :meth:`nu` :meth:`height` :meth:`imagesize`
"""
assert self._imagesize is not None, "imagesize not set"
return self._imagesize[1]
@property
def width(self) -> int:
"""
Get image plane width (base method)
:return: width
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default();
>>> camera.width
:seealso: :meth:`nu` :meth:`height`
"""
assert self._imagesize is not None, "imagesize not set"
return self._imagesize[0]
@property
def height(self) -> int:
"""
Get image plane height (base method)
:return: height
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default();
>>> camera.height
:seealso: :meth:`nv` :meth:`width`
"""
assert self._imagesize is not None, "imagesize not set"
return self._imagesize[1]
@property
def pp(self) -> np.ndarray | None:
"""
Set/get principal point coordinate (base method)
The principal point is the coordinate of the point where
the optical axis pierces the image plane. It is a 2-tuple which can
be read or written. For writing the size must be an
iterable of length 2.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default();
>>> camera.pp
:seealso: :meth:`u0` :meth:`v0`
"""
return self._pp
@pp.setter
def pp(self, pp: ArrayLike) -> None:
"""
Set principal point coordinate
:param pp: principal point
:type pp: array_like(2)
:seealso: :meth:`pp` :meth:`u0` :meth:`v0`
"""
pp = smb.getvector(pp)
if len(pp) == 1:
self._pp = np.r_[pp[0], pp[0]]
elif len(pp) == 2:
self._pp = pp
else:
raise ValueError(pp, "pp must be a 1- or 2-element vector")
@property
def u0(self) -> float:
"""
Get principal point: horizontal coordinate (base method)
:return: horizontal component of principal point
:rtype: float
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera();
>>> camera.u0
:seealso: :meth:`v0` :meth:`pp`
"""
assert self._pp is not None
return self._pp[0]
@property
def v0(self) -> float:
"""
Get principal point: vertical coordinate (base method)
:return: vertical component of principal point
:rtype: float
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera();
>>> camera.v0
:seealso: :meth:`u0` :meth:`pp`
"""
assert self._pp is not None
return self._pp[1]
@property
def rhou(self) -> float:
"""
Get pixel width (base method)
:return: horizontal pixel size
:rtype: float
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default();
>>> camera.rhou
:seealso: :meth:`rhov` :meth:`rho`
"""
return self._rhou
# this is generally the centre of the image, has special meaning for
# perspective camera
@property
def rhov(self) -> float:
"""
Get pixel width (base method)
:return: vertical pixel size
:rtype: float
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default();
>>> camera.rhov
:seealso: :meth:`rhov` :meth:`rho`
"""
return self._rhov
@property
def rho(self) -> np.ndarray:
"""
Get pixel dimensions (base method)
:return: horizontal pixel size
:rtype: ndarray(2)
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera();
>>> camera.rhov
:seealso: :meth:`rhou` :meth:`rhov`
"""
return np.array([self._rhou, self._rhov])
# @property
# def image(self):
# return self._image
# @image.setter
# def image(self, newimage):
# """
# :param newimage: [description]
# :type newimage: [type]
# """
# self._image = Image(newimage)
@property
def pose(self) -> SE3:
"""
Set/get camera pose (base method)
The camera pose with respect to the global frame can be read or written
as an :class:`~spatialmath..pose3d.SE3` instance.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> from spatialmath import SE3
>>> camera = CentralCamera();
>>> camera.pose
>>> camera.pose = SE3.Trans(1, 2, 3)
>>> camera
.. note:: Changes the pose of the current camera instance, whereas
:meth:`move` clones the camera instance with a new pose.
:seealso: :meth:`move`
"""
return self._pose
@pose.setter
def pose(self, newpose: SE3) -> None:
"""
Set camera pose
:param newpose: pose of camera frame
:type newpose: :class:`~spatialmath..pose3d.SE3` or ndarray(4,4)
:seealso: :meth:`move`
"""
self._pose = SE3(newpose)
@property
def noise(self) -> float | None:
"""
Set/Get projection noise (base method)
:return: standard deviation of noise added to projected image plane points
:rtype: float
The noise parameter is set by the object constructor.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default();
>>> camera.project_point([0, 0, 3])
>>> camera.noise = 2
>>> camera.project_point([0, 0, 2])
>>> camera.project_point([0, 0, 2])
:seealso: :meth:`project_point`
"""
return self._noise
@noise.setter
def noise(self, noise: float | None) -> None:
self._noise = noise
@property
def distortion(self) -> np.ndarray | None:
r"""
Set/Get distortion model (base method)
:return: distortion model
:rtype: array_like()
The distortion model is a vector of various lengths that can be read or written.
It is passed directly to OpenCV functions which define how it is used:
- 4-element vector, :math:`[k_1, k_2, p_1, p_2]`
- 5-element vector, :math:`[k_1, k_2, p_1, p_2, k_3]`
- 8-element vector, :math:`[k_1, k_2, p_1, p_2, k_3, k_4, k_5, k_6]`
- 12-element vector, :math:`[k_1, k_2, p_1, p_2, k_3, k_4, k_5, k_6, s_1, s_2, s_3, s_4]`,
- 14-element vector, :math:`[k_1, k_2, p_1, p_2, k_3, k_4, k_5, k_6, s_1, s_2, s_3, s_4, \tau_x, \tau_y]`
The elements :math:`k_i` are radial distortion coefficients, :math:`p_i` are
tangential distortion coefficients, :math:`s_i` are thin prism
distortion coefficients, and :math:`\tau_i` apply when the camera's image plane
is tilted with respect to the lens.
If the vector is ``None``, then zero distortion (all coefficients are zero) is
assumed.
:seealso: :meth:`~CentralCamera.project_point` `OpenCV distortion model <https://docs.opencv.org/4.x/d9/d0c/group__calib3d.html>`_
"""
return self._distortion
@distortion.setter
def distortion(self, distortion: np.ndarray | None) -> None:
self._distortion = distortion
def move(
self, T: SE3, name: str | None = None, relative: bool = False
) -> CameraBase:
"""
Move camera (base method)
:param T: pose of camera frame
:type T: :class:`~spatialmath..pose3d.SE3`
:param relative: move relative to pose of original camera, defaults to ``False``
:type relative: bool, optional
:return: new camera object
:rtype: :class:`CameraBase` subclass
Returns a copy of the camera object with pose set to ``T``.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> from spatialmath import SE3
>>> camera = CentralCamera();
>>> camera.move(SE3.Trans(0.1, 0.2, 0.3))
>>> camera
.. note:: The ``plot`` method of this cloned camera will create a new
window.
:seealso: :meth:`pose`
"""
newcamera = copy.copy(self)
if name is not None:
newcamera.name = name
else:
newcamera.name = self.name + "-moved"
newcamera._ax = None
if relative:
newcamera.pose = self.pose * T
else:
newcamera.pose = T
return newcamera
# ----------------------- plotting ----------------------------------- #
def _new_imageplane(
self, fig: Figure | None = None, ax: Axes | None = None
) -> bool:
"""
Create a new virtual image plane if required
:param fig: Matplotlib figure number, defaults to ``None``
:type fig: int, optional
:param ax: Matplotlob axes, defaults to ``None``
:type ax: :class:`matplotlib.axes`, optional
:return: whether virtual image plane exists
:rtype: bool
If this camera already has a virtual image plane, return ``True``.
Otherwise, create an axes, and optionally a figure, and return ``False``.
"""
if _isnotebook():
ax = plt.gca() # return current or create new
if self._ax is None or self._ax is not ax:
self._ax = ax
self._fig = plt.gcf()
return False
else:
return True
else:
# if not Jupyter, reuse an existing imageplane plot
if self._ax is not None:
return True
if (fig is None) and (ax is None):
# create our own handle for the figure/plot
# print('creating new figure and axes for camera')
fig, ax = plt.subplots() # TODO *args, **kwargs?
# TODO elif ax is a plot handle, else raise ValueError
self._ax = ax
self._fig = fig
return False
def _init_imageplane(
self, fig: Figure | None = None, ax: Axes | None = None
) -> Axes:
"""
Create plot window for camera image plane
:param fig: figure to plot into, defaults to ``None``
:type fig: figure handle, optional
:param ax: axis to plot into, defaults to ``None``
:type ax: 2D axis handle, optional
:return: figure and axis
:rtype: (fig, axis)
Creates a 2D axis that represents the image plane of the virtual
camera.
:seealso: :meth:`plot` :meth:`mesh`
"""
if self._new_imageplane(fig, ax):
assert self._ax is not None
return self._ax
ax = self._ax
assert ax is not None
if self._image is not None:
# if camera has an image, display said image
idisp(self._image, fig=fig, ax=ax, title=self._name, drawonly=True)
else:
if self.limits is None:
ax.set_xlim(0, self.nu)
ax.set_ylim(0, self.nv)
else:
ax.set_xlim(self.limits[0], self.limits[1])
ax.set_ylim(self.limits[2], self.limits[3])
ax.autoscale(False)
ax.set_aspect("equal")
ax.invert_yaxis()
ax.grid(True)
if self.labels is None:
ax.set_xlabel("u (pixels)")
ax.set_ylabel("v (pixels)")
else:
ax.set_xlabel(self.labels[0])
ax.set_ylabel(self.labels[1])
ax.set_title(self.name)
ax.set_facecolor("lightyellow")
try:
ax.figure.canvas.manager.set_window_title( # type: ignore[union-attr]
"Machine Vision Toolbox for Python"
)
except AttributeError:
# can happen during unit test without GUI
pass
# TODO figure out axes ticks, etc
return ax # likely this return is not necessary
def clf(self) -> None:
"""
Clear the virtual image plane (base method)
Every camera object has a virtual image plane drawn using Matplotlib.
Remove all points and lines from the image plane.
:seealso: :meth:`plot_point` :meth:`plot_line` :meth:`disp`
"""
if self._ax is not None:
for artist in self._ax.get_children():
try:
artist.remove()
except:
pass
def plot_point(
self,
P: np.ndarray | ArrayLike,
*fmt,
return_artist: bool = False,
objpose: SE3 | None = None,
pose: SE3 | None = None,
ax: Axes | None = None,
**kwargs,
) -> np.ndarray:
"""
Plot points on virtual image plane (base method)
:param P: 3D world point or points, or 2D image plane point or points
:type P: ndarray(3,), ndarray(3,N), or ndarray(2,), ndarray(2,N)
:param objpose: transformation for the 3D points, defaults to ``None``
:type objpose: :class:`~spatialmath..pose3d.SE3`, optional
:param pose: pose of the camera, defaults to ``None``
:type pose: :class:`~spatialmath..pose3d.SE3`, optional
:param ax: axes to plot into
:type ax: :class:`matplotlib.axes`
:param kwargs: additional arguments passed to :obj:`~matplotlib.pyplot.plot`
:return: Matplotlib line objects
:rtype: list of :class:`~matplotlib.lines.Line2d`
3D world points are first projected to the image plane and then
plotted on the camera's virtual image plane.
Points are organized as columns of the arrays.
Example:
.. code-block:: python
from machinevisiontoolbox import CentralCamera
from spatialmath import SE3
camera = CentralCamera.Default()
camera.plot_point([0.2, 0.3, 2])
camera.plot_point([0.2, 0.3, 2], 'r*')
camera.plot_point([0.2, 0.3, 2], pose=SE3(0.1, 0, 0))
.. plot::
from machinevisiontoolbox import CentralCamera
from spatialmath import SE3
camera = CentralCamera.Default()
camera.plot_point([0.2, 0.3, 2])
camera.plot_point([0.2, 0.3, 2], 'r*')
camera.plot_point([0.2, 0.3, 2], pose=SE3(0.1, 0, 0))
.. note::
- Successive calls add items to the virtual image plane.
- This method is common to all ``CameraBase`` subclasses, but it
invokes a camera-specific projection method.
:seealso: :meth:`plot_line2` :meth:`plot_line3` :meth:`plot_wireframe` :meth:`clf`
"""
self._init_imageplane(ax)
if not isinstance(P, np.ndarray):
P = smb.getvector(P)
if P.shape[0] == 3:
# plot world points
p = self.project_point(P, pose=pose, objpose=objpose)
else:
# plot image plane points
p = P
assert isinstance(p, np.ndarray)
if p.shape[0] != 2:
raise ValueError("p must have be (2,), (3,), (2,n), (3,n)")
defaults = dict(markersize=6, color="k")
if len(fmt) == 0:
fmt = ["o"]
kwargs = {**defaults, **kwargs}
assert self._ax is not None
artist = self._ax.plot(p[0, :], p[1, :], *fmt, **kwargs)
if plt.isinteractive():
plt.show(block=False)
if return_artist:
return p, artist[0]
else:
return p
def plot_line2(self, l: ArrayLike, *args: Any, **kwargs: Any) -> None:
r"""
Plot 2D line on virtual image plane (base method)
:param l: homogeneous line
:type l: array_like(3)
:param kwargs: arguments passed to ``plot``
Plot the homogeneous line on the camera's virtual image plane. The line
is expressed in the form
.. math:: \ell_0 u + \ell_1 v + \ell_2 = 0
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default()
>>> camera.plot_line2([1, 0.2, -500])
.. note::
- Successive calls add items to the virtual image plane.
- This method is common to all ``CameraBase`` subclasses, but it
invokes a camera-specific projection method.
:seealso: :meth:`plot_point` :meth:`plot_line3` :meth:`clf`
"""
# get handle for this camera image plane
self._init_imageplane()
plt.autoscale(False)
assert self._ax is not None
smb.plot_homline(l, *args, ax=self._ax, **kwargs)
# def plot_line3(self, L, nsteps=21, **kwargs):
# """
# Plot 3D line on virtual image plane (base method)
# :param L: 3D line or lines in Plucker coordinates
# :type L: :class:`~spatialmath..geom3d.Line3`
# :param kwargs: arguments passed to ``plot``
# The Plucker lines are projected to the camera's virtual image plane and
# plotted. Each line is approximated by ``nsteps`` points, each of which
# is projected, allowing straight lines to appear curved after projection.
# Example:
# .. runblock:: pycon
# >>> from machinevisiontoolbox import CentralCamera, mkcube
# >>> from spatialmath import Line3
# >>> camera = CentralCamera()
# >>> line = Line3.Join((-1, -2, -3), (4, 5, 6))
# >>> camera.plot_line3(line, 'k--')
# .. note::
# - Successive calls add items to the virtual image plane.
# - This method is common to all ``CameraBase`` subclasses, but it
# invokes a camera-specific projection method.
# :seealso: :meth:`plot_point` :meth:`plot_line2` :meth:`plot_wireframe` :meth:`clf`
# """
# # draw 3D line segments
# s = np.linspace(0, 1, nsteps)
# this is actually pretty tricky
# - how to determine which part of the 3D line is visible
# - if highly curved it may be in two or more segments
# for line in L:
# l = self.project_line(line)
# # straight world lines are not straight, plot them piecewise
# P = (1 - s) * P0[:, np.newaxis] + s * P2[:, np.newaxis]
# uv = self.project_point(P, pose=pose)
def plot_wireframe(
self,
X: np.ndarray,
Y: np.ndarray,
Z: np.ndarray,
*fmt: Any,
objpose: SE3 | None = None,
pose: SE3 | None = None,
nsteps: int = 21,
**kwargs: Any,
) -> None:
"""
Plot 3D wireframe in virtual image plane (base method)
:param X: world X coordinates
:type X: ndarray(N,M)
:param Y: world Y coordinates
:type Y: ndarray(N,M)
:param Z: world Z coordinates
:type Z: ndarray(N,M)
:param objpose: transformation for the wireframe points, defaults to ``None``
:type objpose: :class:`~spatialmath..pose3d.SE3`, optional
:param pose: pose of the camera, defaults to ``None``
:type pose: :class:`~spatialmath..pose3d.SE3`, optional
:param nsteps: number of points for each wireframe segment, defaults to 21
:type nsteps: int, optional
:param kwargs: arguments passed to ``plot``
The 3D wireframe is projected to the camera's virtual image plane. Each
wire link in the wireframe is approximated by ``nsteps`` points, each of
which is projected, allowing straight edges to appear curved.
Example:
.. code-block:: python
from machinevisiontoolbox import CentralCamera, mkcube
from spatialmath import SE3
camera = CentralCamera.Default()
X, Y, Z = mkcube(0.2, pose=SE3(0, 0, 1), edge=True)
camera.plot_wireframe(X, Y, Z, 'k--')
.. plot::
from machinevisiontoolbox import CentralCamera, mkcube
from spatialmath import SE3
camera = CentralCamera.Default()
X, Y, Z = mkcube(0.2, pose=SE3(0, 0, 1), edge=True)
camera.plot_wireframe(X, Y, Z, 'k--')
:seealso: :func:`mkcube` :obj:`spatialmath.base.cylinder` :obj:`spatialmath.base.sphere` :obj:`spatialmath.base.cuboid`
"""
# self._ax.plot_surface(X, Y, Z)
# plt.show()
# check that mesh matrices conform
if X.shape != Y.shape or X.shape != Z.shape:
raise ValueError("matrices must be the same shape")
if pose is None:
pose = self.pose
if objpose is not None:
pose = objpose.inv() * pose
# get handle for this camera image plane
self._init_imageplane()
plt.autoscale(False)
assert self._ax is not None
ax = self._ax
# draw 3D line segments
s = np.linspace(0, 1, nsteps)
# c.clf
# holdon = c.hold(1);
for i in range(X.shape[0] - 1): # i=1:numrows(X)-1
for j in range(X.shape[1] - 1): # j=1:numcols(X)-1
P0 = np.r_[X[i, j], Y[i, j], Z[i, j]]
P1 = np.r_[X[i + 1, j], Y[i + 1, j], Z[i + 1, j]]
P2 = np.r_[X[i, j + 1], Y[i, j + 1], Z[i, j + 1]]
if self.camtype == "perspective":
# straight world lines are straight on the image plane
uv = self.project_point(np.c_[P0, P1], pose=pose)
else:
# straight world lines are not straight, plot them piecewise
P = (1 - s) * P0[:, np.newaxis] + s * P1[:, np.newaxis]
uv = self.project_point(P, pose=pose)
ax.plot(uv[0, :], uv[1, :], *fmt, **kwargs)
if self.camtype == "perspective":
# straight world lines are straight on the image plane
uv = self.project_point(np.c_[P0, P2], pose=pose)
else:
# straight world lines are not straight, plot them piecewise
P = (1 - s) * P0[:, np.newaxis] + s * P2[:, np.newaxis]
uv = self.project_point(P, pose=pose)
ax.plot(uv[0, :], uv[1, :], *fmt, **kwargs)
for j in range(X.shape[1] - 1): # j=1:numcols(X)-1
P0 = [X[-1, j], Y[-1, j], Z[-1, j]]
P1 = [X[-1, j + 1], Y[-1, j + 1], Z[-1, j + 1]]
# if c.perspective
# straight world lines are straight on the image plane
uv = self.project_point(np.c_[P0, P1], pose=pose)
# else
# # straight world lines are not straight, plot them piecewise
# P = bsxfun(@times, (1-s), P0) + bsxfun(@times, s, P1);
# uv = c.project(P, 'setopt', opt);
ax.plot(uv[0, :], uv[1, :], *fmt, **kwargs)
# c.hold(holdon); # turn hold off if it was initially off
plt.draw()
def disp(self, im: Image, **kwargs: Any) -> None:
"""
Display image on virtual image plane (base method)
:param im: image to display
:type im: :class:`Image` instance
:param kwargs: options to :func:`~machinevisiontoolbox.base.imageio.idisp()`
An image is displayed on camera's the virtual image plane.
.. note: The dimensions of the image plane should match the dimensions of the image.
:seealso: :func:`machinevisiontoolbox.base.idisp()`
"""
self.imagesize = (im.shape[1], im.shape[0])
self._init_imageplane()
im.disp(ax=self._ax, title=False, **kwargs)
plt.autoscale(False)
def plot(
self=None,
pose: SE3 | None = None,
scale: float = 1,
shape: str = "camera",
label: bool = True,
alpha: float = 1,
solid: bool = False,
color: str = "r",
projection: str = "ortho",
frame: bool = False,
ax: Axes | None = None,
) -> Axes:
"""
Plot 3D camera icon in world view (base method)
:param pose: camera pose
:type pose: :class:`~spatialmath..pose3d.SE3`
:param scale: scale factor, defaults to 1
:type scale: float
:param shape: icon shape: 'frustum' [default], 'camera'
:type shape: str, optional
:param label: show camera name, defaults to ``True``
:type label: bool, optional
:param alpha: transparency of icon, defaults to 1
:type alpha: float, optional
:param solid: icon comprises solid faces, defaults to ``False``
:type solid: bool, optional
:param color: icon color, defaults to 'r'
:type color: str, optional
:param projection: projection model for new axes, defaults to 'ortho'
:type projection: str, optional
:param ax: axes to draw in, defaults to current 3D axes
:type ax: :class:`~matplotlib.Axes3D`, optional
:return: axes drawn into
:rtype: :class:`~matplotlib.Axes3D`
Plot a 3D icon representing the pose of a camera into a 3D Matplotlib
plot. Two icons are supported: the traditional frustum, and a
simplistic camera comprising a box and cylinder.
.. note:: If ``pose`` is not given it defaults to the pose of the
instance.
"""
# if (fig is None) and (ax is None):
# # create our own handle for the figure/plot
# print('creating new figure and axes for camera')
# fig = plt.figure()
# ax = fig.gca(projection='3d')
# # ax.set_aspect('equal')
"""[summary]
face order -x, +y, +x, -y
"""
# get axes to draw in
ax = smb.axes_logic(ax, 3, projection=projection)
if pose is None:
pose = self.pose
# Matplotlib >=3.10 can raise while autoscaling ragged 3D line
# collections (eg. from cuboid/cylinder helper primitives). Disable
# autoscale only for these collection additions.
_orig_add_collection3d = ax.add_collection3d
def _add_collection3d_compat(collection, *args, **kwargs):
kwargs.setdefault("autolim", False)
try:
return _orig_add_collection3d(collection, *args, **kwargs)
except TypeError:
# Older Matplotlib versions may not accept autolim.
kwargs.pop("autolim", None)
return _orig_add_collection3d(collection, *args, **kwargs)
ax.add_collection3d = _add_collection3d_compat
try:
# draw camera-like object:
if shape == "frustum":
# TODO make this kwargs or optional inputs
# side colors:
# +x red
# -y red
# +y green
# -y yellow
length = scale
widthb = scale / 10
widtht = scale
widthb /= 2
widtht /= 2
b0 = np.array([-widthb, -widthb, 0, 1])
b1 = np.array([-widthb, widthb, 0, 1])
b2 = np.array([widthb, widthb, 0, 1])
b3 = np.array([widthb, -widthb, 0, 1])
t0 = np.array([-widtht, -widtht, length, 1])
t1 = np.array([-widtht, widtht, length, 1])
t2 = np.array([widtht, widtht, length, 1])
t3 = np.array([widtht, -widtht, length, 1])
# bottom/narrow end
T = pose.A
b0 = (T @ b0)[:-1]
b1 = (T @ b1)[:-1]
b2 = (T @ b2)[:-1]
b3 = (T @ b3)[:-1]
# wide/top end
t0 = (T @ t0)[:-1]
t1 = (T @ t1)[:-1]
t2 = (T @ t2)[:-1]
t3 = (T @ t3)[:-1]
points = [
np.array([b0, b1, t1, t0]), # -x face
np.array([b1, b2, t2, t1]), # +y face
np.array([b2, b3, t3, t2]), # +x face
np.array([b3, b0, t0, t3]), # -y face
]
poly = Poly3DCollection(
points, facecolors=["r", "g", "r", "y"], alpha=alpha
)
ax.add_collection3d(poly)
elif shape == "camera":
# the box is centred at the origin and its centerline parallel to the
# z-axis. Its z-extent is -bh/2 to bh/2.
W = 0.5 # width & height of the box
L = 1.2 # length of the box
cr = 0.2 # cylinder radius
ch = 0.4 # cylinder height
cn = 12 # number of facets of cylinder
a = 3 # length of axis line segments
# draw the box part of the camera
smb.plot_cuboid(
sides=np.r_[W, W, L] * scale,
pose=pose, # type: ignore[arg-type]
filled=solid,
color=color,
alpha=0.5 * alpha if solid else alpha,
ax=ax,
)
# draw the lens
smb.plot_cylinder(
radius=cr * scale,
height=np.r_[L / 2, L / 2 + ch] * scale,
resolution=cn,
pose=pose, # type: ignore[arg-type]
filled=solid,
color=color,
alpha=0.5 * alpha,
ax=ax,
)
if label:
ax.set_xlabel("X")
ax.set_ylabel("Y")
ax.set_zlabel("Z")
if frame is True:
self.pose.plot(
length=scale * 1.5,
style="line",
color=color,
flo=(0.07, 0, -0.01),
)
elif frame is not False:
self.pose.plot(**frame)
finally:
ax.add_collection3d = _orig_add_collection3d
return ax
def _add_noise_distortion(self, uv: np.ndarray) -> np.ndarray:
"""
Add noise to pixel coordinates
:param uv: image plane point coordinates
:type uv: ndarray(2,N)
:return: point coordinates with additive noise
:rtype: ndarray(2,N)
Model noise in the image process by adding zero-mean Gaussian noise
to the coordinates of projected world points. The noise has a
standard deviation specified by the camera constructor.
:seealso: :meth:`noise`
"""
# distort the pixels
# add Gaussian noise with specified standard deviation
if self.noise is not None:
uv += self._random.normal(0.0, self.noise, size=uv.shape)
return uv
[docs]
class CentralCamera(CameraBase):
"""
Create central camera projection model
.. inheritance-diagram:: machinevisiontoolbox.Camera.CentralCamera
:top-classes: machinevisiontoolbox.Camera.Camera
:parts: 1
:param f: focal length, defaults to 8mm
:type f: float, optional
:param distortion: camera distortion parameters, defaults to ``None``
:type distortion: array_like(5), optional
:param kwargs: arguments passed to :class:`CameraBase` constructor
A camera object contains methods for projecting 3D points and lines
to the image plane, as well as supporting a virtual image plane onto
which 3D points and lines can be drawn.
:references:
- |RVC3|, Section 13.1.
:seealso: :class:`CameraBase` :class:`FishEyeCamera` :class:`SphericalCamera`
"""
_fu: float
_fv: float
_distortion: np.ndarray | None
def __init__(
self, f: ArrayLike = 1, distortion: np.ndarray | None = None, **kwargs
) -> None:
super().__init__(camtype="perspective", **kwargs)
# TODO some of this logic to f and pp setters
self.f = f
self._distortion = distortion
[docs]
@classmethod
def Default(cls, **kwargs: Any) -> CentralCamera:
r"""
Set default central camera parameters
:return: central camera model
:rtype: :class:`CentralCamera` instance
Initialize a central camera with: focal length of 8mm, :math:`10\mu\mbox{m}` pixels,
image size of :math:`1000 \times 1000` with principal point at (500, 500).
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default(name='camera1')
>>> camera
:references:
- |RVC3|, Section 13.1.
:seealso: :class:`CentralCamera`
"""
default = {
"f": 0.008,
"rho": 10e-6,
"imagesize": 1000,
"pp": (500, 500),
"name": "default perspective camera",
}
return CentralCamera(**{**default, **kwargs})
def __str__(self) -> str:
"""
String representation of central camera parameters
:return: string representation
:rtype: str
Multi-line string representation of camera intrinsic and extrinsic
parameters.
"""
s = super().__str__()
s += self.fmt.format("principal pt", self.pp)
s += self.fmt.format("focal length", self.f)
return s
[docs]
def project_point(
self,
P: np.ndarray | ArrayLike,
pose: SE3 | None = None,
objpose: SE3 | None = None,
behind: bool = True,
visibility: bool = False,
retinal: bool = False,
**kwargs,
) -> np.ndarray:
r"""
Project 3D points to image plane
:param P: 3D world point or points in Euclidean or homogeneous form
:type P: array_like(3), array_like(3,n), array_like(4), array_like(4,n)
:param pose: camera pose with respect to the world frame, defaults to
camera's ``pose`` attribute
:type pose: :class:`~spatialmath..pose3d.SE3`, optional
:param objpose: 3D point reference frame, defaults to world frame
:type objpose: :class:`~spatialmath..pose3d.SE3`, optional
:param behind: points behind the camera indicated by NaN, defaults to ``True``
:type behind: bool, optional
:param visibility: return visibility array, defaults to ``False``
:type visibility: bool
:param retinal: transform to retinal coordinates, defaults to ``False``
:type retinal: bool, optional
:return: image plane points, optional visibility vector
:rtype: ndarray(2,n), ndarray(n)
Project a 3D point to the image plane
.. math::
\hvec{p} = \mat{C} \hvec{P}
where :math:`\mat{C}` is the camera calibration matrix and
:math:`\hvec{p}` and :math:`\hvec{P}` are the image plane and world
frame coordinates respectively, in homogeneous form.
World points are given as a 1D array or the columns of a 2D array of
Euclidean or homogeneous coordinates. The computed image plane
coordinates are Euclidean or homogeneous and given as a 1D array or the
corresponding columns of a 2D array.
If ``pose`` is specified it is used for the camera frame pose, otherwise
the attribute ``pose`` is used. The object's ``pose`` attribute is not
updated if ``pose`` is specified.
A single point can be specified as a 3-vector, multiple points as an
array with three rows and each column is the 3D point coordinate (X, Y,
Z).
The points ``P`` are by default with respect to the world frame, but
they can be transformed by specifying ``objpose``.
If world points are behind the camera and ``behind`` is ``True`` then the
image plane coordinates are set to NaN.
If ``visibility`` is ``True`` then each projected point is checked to ensure
it lies in the bounds of the image plane. In this case there are two
return values: the image plane coordinates and an array of booleans
indicating if the corresponding point is visible.
If ``retinal`` is ``True`` then project points in retinal coordinates,
in units of metres with respect to the principal point.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default()
>>> camera.project_point((0.3, 0.4, 2))
:references:
- |RVC3|, Section 13.1.
:seealso: :meth:`C` :meth:`project_point` :meth:`project_line` :meth:`project_quadric`
"""
if pose is None:
pose = self.pose
C = self.C(pose, retinal=retinal)
if isinstance(P, np.ndarray):
if P.ndim == 1:
P = P.reshape((-1, 1)) # make it a column
else:
P = smb.getvector(P, out="col")
if P.shape[0] == 3:
P = smb.e2h(P) # make it homogeneous
euclidean = True
else:
euclidean = False
# project 3D points
if objpose is not None:
P = objpose.A @ P
x = C @ P
if behind:
x[2, x[2, :] < 0] = np.nan # points behind the camera are set to NaN
if euclidean:
# Euclidean points given, return Euclidean points
x = smb.h2e(x)
# add Gaussian noise and distortion
if self._distortion:
x = self.distort(x)
x = self._add_noise_distortion(x)
# do visibility check if required
if visibility:
visible = (
~np.isnan(x[0, :])
& (x[0, :] >= 0)
& (x[1, :] >= 0)
& (x[0, :] < self.nu)
& (x[1, :] < self.nv)
)
return x, visible
else:
return x
else:
# homogeneous points given, return homogeneous points
return x
[docs]
def project_line(self, lines: Line3) -> np.ndarray:
r"""
Project 3D lines to image plane
:param lines: Plucker line or lines
:type lines: :class:`~spatialmath..geom3d.Line3` instance with N values
:return: 2D homogeneous lines, one per column
:rtype: ndarray(3,N)
The :class:`~spatialmath..geom3d.Line3` object can contain multiple lines. The result array has one
column per line, and each column is a vector describing the image plane
line in homogeneous form :math:`\ell_0 u + \ell_1 v + \ell_2 = 0`.
The projection is
.. math::
\ell = \vex{\mat{C} \sk{\vec{L}} \mat{C}^{\top}}
where :math:`\mat{C}` is the camera calibration matrix and :math:`\sk{\vec{L}}`
is the skew matrix representation of the Plucker line.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> from spatialmath import Line3
>>> line = Line3.Join((-3, -4, 5), (5, 2, 6))
>>> line
>>> camera = CentralCamera()
>>> camera.project_line(line)
:references:
- |RVC3|, Section 13.7.1.
:seealso: :meth:`C` :class:`~spatialmath..geom3d.Line3` :meth:`project_point` :meth:`project_quadric`
"""
if not isinstance(lines, Line3):
raise ValueError("expecting Line3 lines")
# project Plucker lines
lines2d = []
C = self.C()
for line in lines:
l = smb.vex(C @ line.skew() @ C.T)
x = l / np.max(np.abs(l)) # normalize by largest element
lines2d.append(x)
return np.column_stack(lines2d)
[docs]
def project_quadric(self, Q: np.ndarray) -> np.ndarray:
r"""
Project 3D quadric to image plane
:param Q: quadric matrix
:type Q: ndarray(4,4)
:return: image plane conic
:rtype: ndarray(3,3)
Quadrics, short for quadratic surfaces, are a rich family of
3-dimensional surfaces. There are 17 standard types including spheres,
ellipsoids, hyper- boloids, paraboloids, cylinders and cones all
described by points :math:`\vec{x} \in \mathbb{P}^3` such that
.. math::
\hvec{x}^{\top} \mat{Q} \hvec{x} = 0
The outline of the quadric is projected to a conic section on the image
plane
.. math::
c^* = \mat{C} \mat{Q}^* \mat{C}^{\top}
where :math:`(\mat{X})^* = det(\mat{X}) \mat{X}^{-1}` is the adjugate
operator.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> from spatialmath import SE3
>>> T_cam = SE3.Trans(0.2, 0.1, -5) * SE3.Rx(0.2)
>>> Q = np.diag([1, 1, 1, -1]) # unit sphere at origin
>>> camera = CentralCamera.Default(f=0.015, pose=T_cam);
>>> camera.project_quadric(Q)
:references:
- |RVC3|, Section 13.7.1.
:seealso: :meth:`C` :meth:`project_point` :meth:`project_line`
"""
if not smb.ismatrix(Q, (4, 4)):
raise ValueError("expecting 4x4 conic matrix")
return self.C() @ Q @ self.C().T
[docs]
def epiline(self, p: np.ndarray, camera2: CentralCamera) -> np.ndarray:
r"""
Compute epipolar line
:param p: image plane point or points
:type p: array_like(2) or ndarray(2,N)
:param camera2: second camera
:type camera2: :class:`CentralCamera` instance
:return: epipolar line or lines in homogeneous form
:rtype: ndarray(3), ndarray(3,N)
Compute the epipolar line in ``camera2`` induced by the image plane
points ``p`` in the current camera. Each line is given by
.. math::
\ell = \mat{F} {}^1 \hvec{p}
which is in homogeneous form :math:`\ell_0 u + \ell_1 v + \ell_2 = 0`
and the conjugate point :math:`{}^2 \vec{p}` lies on this line.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> from spatialmath import SE3
>>> camera1 = CentralCamera.Default(name='camera1')
>>> camera2 = CentralCamera.Default(pose=SE3(0.1, 0.05, 0), name='camera2')
>>> P = [-0.2, 0.3, 5] # world point
>>> p1 = camera1.project_point(P) # project to first camera
>>> camera1.epiline(p1, camera2) # epipolar line in second camera
:references:
- |RVC3|, Section 14.2.1.
:seealso: :meth:`plot_epiline` :meth:`CentralCamera.F`
"""
# p is 3 x N, result is 3 x N
return self.F(camera2) @ smb.e2h(p)
[docs]
def plot_epiline(
self, F: np.ndarray, p: np.ndarray, *fmt: Any, **kwargs: Any
) -> None:
r"""
Plot epipolar line
:param F: fundamental matrix
:type F: ndarray(3,3)
:param p: image plane point or points
:type p: array_like(2) or ndarray(2,N)
:param fmt: line style argument passed to ``plot``
:param kwargs: additional line style arguments passed to ``plot``
Plot the epipolar line induced by the image plane points ``p`` in the
camera's virtual image plane. Each line is given by
.. math::
\ell = \mat{F} {}^1 \hvec{p}
which is in homogeneous form :math:`\ell_0 u + \ell_1 v + \ell_2 = 0`
and the conjugate point :math:`{}^2 \vec{p}` lies on this line.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> from spatialmath import SE3
>>> camera1 = CentralCamera.Default(name='camera1')
>>> camera2 = CentralCamera.Default(pose=SE3(0.1, 0.05, 0), name='camera2')
>>> P = [-0.2, 0.3, 5] # world point
>>> p1 = camera1.project_point(P) # project to first camera
>>> camera2.plot_point(P, 'kd') # project and display in second camera
>>> camera2.plot_epiline(camera1.F(camera2), p1) # plot epipolar line in second camera
:references:
- |RVC3|, Section 14.2.1.
:seealso: :meth:`plot_point` :meth:`epiline` :meth:`CentralCamera.F`
"""
# p is 3 x N, result is 3 x N
self.plot_line2(F @ smb.e2h(p), *fmt, **kwargs)
[docs]
def plot_line3(self, L: Line3, **kwargs: Any) -> None:
"""
Plot 3D line on virtual image plane (base method)
:param L: 3D line in Plucker coordinates
:type L: :class:`~spatialmath..geom3d.Line3`
:param kwargs: arguments passed to ``plot``
The Plucker line is projected to the camera's virtual image plane and
plotted.
.. note::
- Successive calls add items to the virtual image plane.
- This method is common to all ``CameraBase`` subclasses, but it
invokes a camera-specific projection method.
:seealso: :meth:`plot_point` :meth:`plot_line2` :meth:`plot_wireframe` :meth:`clf`
"""
l = self.project_line(L)
for hl in l.T:
self.plot_line2(hl, **kwargs)
[docs]
def ray(self, points: np.ndarray | ArrayLike, pose: SE3 | None = None) -> Line3:
"""
Project image plane points to a ray
:param points: image plane points
:type points: ndarray(2,N)
:param pose: camera pose, defaults to ``None``
:type pose: :class:`~spatialmath..pose3d.SE3`, optional
:return: corresponding Plucker lines
:rtype: :class:`~spatialmath..geom3d.Line3`
For each image plane point compute the equation of a Plucker line
that represents the 3D ray from the camera origin through the image
plane point.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default()
>>> line = camera.ray((100, 200))
>>> line
:references:
- "Multiview Geometry", Hartley & Zisserman, p.162
- |RVC3|, Section 14.3.
:seealso: :class:`~spatialmath..geom3d.Line3`
"""
# define Plucker line in terms of point (centre of camera) and direction
C = self.C(pose=pose)
Mi = np.linalg.inv(C[:3, :3])
v = C[:, 3]
lines = []
for point in smb.getmatrix(points, (2, None)).T:
lines.append(Line3.PointDir(-Mi @ v, Mi @ smb.e2h(point)))
return Line3(lines)
@property
def centre(self) -> np.ndarray:
"""
Position of camera frame
:return: Euclidean coordinate of the camera frame's origin
:rtype: ndarray(3)
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> from spatialmath import SE3
>>> camera1 = CentralCamera.Default(name='camera1', pose=SE3.Trans(1,2,3))
>>> camera1
>>> camera1.centre
"""
return self.pose.t
@property
def center(self) -> np.ndarray:
"""
Position of camera frame
:return: Euclidean coordinate of the camera frame's origin
:rtype: ndarray(3)
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> from spatialmath import SE3
>>> camera1 = CentralCamera.Default(name='camera1', pose=SE3.Trans(1,2,3))
>>> camera1
>>> camera1.center
"""
return self.pose.t
[docs]
def fov(self) -> np.ndarray:
"""
Camera field-of-view angles
:return: field of view angles in radians
:rtype: ndarray(2)
Computes the field of view angles (2x1) in radians for the camera
horizontal and vertical directions.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera1 = CentralCamera.Default(name='camera1')
>>> camera1.fov()
:references:
- |RVC3|, Section 13.1.4.
"""
try:
return 2 * np.arctan(np.r_[self.imagesize] / 2 * np.r_[self.rho] / self.f)
except:
raise ValueError("imagesize or rho properties not set")
[docs]
def distort(self, points: np.ndarray) -> np.ndarray:
"""
Compute distorted coordinate
:param points: image plane points
:type points: ndarray(2,n)
:returns: distorted image plane coordinates
:rtype: ndarray(2,n)
Compute the image plane coordinates after lens distortion has been
applied. The lens distortion model is initialized at constructor time.
"""
if self._distortion is None:
return points
# convert to normalized image coordinates
X = np.linalg.inv(self.K) * smb.e2h(points)
# unpack coordinates
u = X[0, :]
v = X[1, :]
# unpack distortion vector
k = self._distortion[:3]
p = self._distortion[3:]
r = np.sqrt(u**2 + v**2) # distance from principal point
# compute the shift due to distortion
delta_u = (
u * (k[0] * r**2 + k[1] * r**4 + k[2] * r**6)
+ 2 * p[0] * u * v
+ p[1] * (r**2 + 2 * u**2)
)
delta_v = (
v * (k[0] * r**2 + k[1] * r**4 + k[2] * r**6)
+ p[0] * (r**2 + 2 * v**2)
+ 2 * p[1] * u * v
)
# distorted coordinates
ud = u + delta_u
vd = v + delta_v
return self.K * smb.e2h(np.r_[ud, vd]) # convert to pixel coords
@property
def fu(self) -> float:
"""
Get focal length in horizontal direction
:return: focal length in horizontal direction
:rtype: float
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default(name='camera1')
>>> camera.fu
:seealso: :meth:`fv` :meth:`f`
"""
return self._fu
@property
def fv(self) -> float:
"""
Get focal length in vertical direction
:return: focal length in vertical direction
:rtype: float
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default(name='camera1')
>>> camera.fv
:seealso: :meth:`fu` :meth:`f`
"""
return self._fv
@property
def f(self) -> np.ndarray:
"""
Set/get focal length
:return: focal length in horizontal and vertical directions
:rtype: ndarray(2)
Return focal length in horizontal and vertical directions.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default(name='camera1')
>>> camera.f
>>> camera.f = 0.015
>>> camera.f
>>> camera.f = [0.015, 0.020]
>>> camera.f
.. note:: These are normally identical but will differ if the sensor
has non-square pixels or the frame grabber is changing the aspect
ratio of the image.
:seealso: :meth:`fu` :meth:`fv`
"""
return np.r_[self._fu, self._fv]
@f.setter
def f(self, f: ArrayLike) -> None:
"""[summary]
:param f: focal length
:type f: scalar or array_like(2)
:raises ValueError: incorrect length of ``f``
"""
f = smb.getvector(f)
if len(f) == 1:
self._fu = f[0]
self._fv = f[0]
elif len(f) == 2:
self._fu = f[0]
self._fv = f[1]
else:
raise ValueError(f, "f must be a 1- or 2-element vector")
@property
def fpix(self) -> np.ndarray:
"""
Get focal length in pixels
:return: focal length in horizontal and vertical directions in pixels
:rtype: ndarray(2)
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default(name='camera1')
>>> camera.fpix
:seealso: :meth:`f`
"""
return np.r_[self._fu / self._rhou, self._fv / self._rhov]
@property
def K(self) -> np.ndarray:
"""
Intrinsic matrix of camera
:return: intrinsic matrix
:rtype: ndarray(3,3)
Return the camera intrinsic matrix.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default(name='camera1')
>>> camera.K
:references:
- |RVC3|, Section 13.1.
:seealso: :meth:`C` :meth:`pp` :meth:`rho`
"""
# fmt: off
K = np.array([[self.fu / self.rhou, 0, self.u0],
[ 0, self.fv / self.rhov, self.v0],
[ 0, 0, 1]
], dtype=np.float64)
# fmt: on
return K
# =================== camera calibration =============================== #
[docs]
def C(self, pose: SE3 | None = None, retinal: bool = False) -> np.ndarray:
"""
Camera projection matrix
:param T: camera pose with respect to world frame, defaults to pose from camera object
:type T: :class:`~spatialmath..pose3d.SE3`, optional
:param retinal: transform to retinal coordinates, default False
:type retinal: bool, optional
:return: camera projection/calibration matrix
:rtype: ndarray(3,4)
Return the camera matrix which projects 3D points to the image plane.
It is a function of the camera's intrinsic and extrinsic parameters.
If ``retinal`` is ``True`` then project points in retinal coordinates,
in units of metres with respect to the principal point.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default(name='camera1')
>>> camera.C()
>>> camera.C(SE3.Trans(0.1, 0, 0))
>>> camera.move(SE3(0.1, 0, 0)).C()
:references:
- |RVC3|, Section 13.1.
:seealso: :meth:`project_point` :meth:`K` :meth:`decomposeC`
"""
P0 = np.eye(3, 4)
if pose is None:
pose = self.pose
if retinal:
K = np.diag([self.fu, self.fv, 1])
else:
K = self.K
return K @ P0 @ pose.inv().A
[docs]
@staticmethod
def points2C(P: np.ndarray, p: np.ndarray) -> tuple[np.ndarray, float]:
r"""
Estimate camera matrix from data points
:param P: calibration points in world coordinate frame
:type P: ndarray(3,N)
:param p: calibration points in image plane
:type p: ndarray(2,N)
:return: camera calibration matrix and residual
:rtype: ndarray(3,4), float
Estimate the camera matrix :math:`\mat{C}` determined by least
squares from corresponding world ``P`` and image-plane ``p`` points.
Corresponding points are represented by corresponding columns of ``P``
and ``p``. Also returns the residual which is:
.. math::
\max | \mat{C}\mat{P} - \mat{p} |
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera, mkcube
>>> P = mkcube(0.2)
>>> camera_unknown = CentralCamera(f=0.015, rho=10e-6, imagesize=[1280, 1024], noise=0.05, seed=0)
>>> T_unknown = SE3.Trans(0.1, 0.2, 1.5) * SE3.RPY(0.1, 0.2, 0.3)
>>> p = camera_unknown.project_point(P, objpose=T_unknown)
>>> C, resid = CentralCamera.points2C(P, p)
>>> C
>>> camera_unknown.C()
>>> resid
.. note:: This method assumes no lens distortion affecting the image plane
coordinates.
:references:
- |RVC3|, Section 13.2.1.
:seealso: :meth:`C` :meth:`images2C` :meth:`decomposeC`
"""
z4 = np.zeros((4,))
A = np.empty(shape=(0, 11))
b = np.empty(shape=(0,))
for uv, X in zip(p.T, P.T):
u, v = uv
# fmt: off
row = np.array([
np.r_[ X, 1, z4, -u * X],
np.r_[z4, X, 1, -v * X]
])
# fmt: on
A = np.vstack((A, row))
b = np.r_[b, uv]
# solve Ax = b where c is 11 elements of camera matrix
c, *_ = scipy.linalg.lstsq(A, b)
# compute and print the residual
r = np.max(np.abs((A @ c - b)))
c = np.r_[c, 1] # append a 1
C = c.reshape((3, 4)) # make a 3x4 matrix
return C, r
[docs]
@classmethod
def images2C(
cls, images, gridshape: tuple[int, int] = (7, 6), squaresize: float = 0.025
) -> tuple[np.ndarray, np.ndarray, list] | None:
"""
Calibrate camera from checkerboard images
:param images: an iterator that returns :class:`~machinevisiontoolbox.ImageCore.Image` objects
:type images: :class:`~machinevisiontoolbox.Sources.ImageSource`
:param gridshape: number of grid squares in each dimension, defaults to (7,6)
:type gridshape: tuple, optional
:param squaresize: size of the grid squares in units of length, defaults to 0.025
:type squaresize: float, optional
:return: camera calibration matrix, distortion parameters, image frames
:rtype: ndarray(3,4), ndarray(5), list of named tuples
The distortion coefficients are in the order :math:`(k_1, k_2, p_1, p_2, k_3)`
where :math:`k_i` are radial distortion coefficients and :math:`p_i` are
tangential distortion coefficients.
Image frames that were successfully processed are returned as a list of
named tuples ``CalibrationFrame`` with elements:
======= ===================== ===========================================================
element type description
======= ===================== ===========================================================
image :class:`Image` calibration image with overlaid annotation
pose :class:`SE3` instance pose of the camera with respect to the origin of this image
id int sequence number of this image in ``images``
======= ===================== ===========================================================
.. note:: The units used for ``squaresize`` must match the units used
for defining 3D points in space.
:references:
- |RVC3|, Section 13.7.
:seealso: :meth:`C` :meth:`points2C` :meth:`decomposeC` :class:`~spatialmath..pose3d.SE3`
"""
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
# create set of feature points, like (0,0,0), (1,0,0), (2,0,0) ....,(6,5,0)
# these all have Z=0 since they are relative to the calibration target frame
objp = np.zeros((gridshape[0] * gridshape[1], 3), np.float32)
objp[:, :2] = (
np.mgrid[0 : gridshape[0], 0 : gridshape[1]].T.reshape(-1, 2) * squaresize
)
# lists to store object points and image points from all the images
objpoints = [] # 3d point in real world space
imgpoints = [] # 2d points in image plane.
corner_images = []
valid = []
for i, image in enumerate(images):
gray = image.mono()._A
# Find the chess board corners
ret, corners = cv2.findChessboardCorners(
image=gray, patternSize=gridshape, corners=None
)
# If found, add object points, image points (after refining them)
if ret:
objpoints.append(objp)
corners2 = cv2.cornerSubPix(
image=gray,
corners=corners,
winSize=(11, 11),
zeroZone=(-1, -1),
criteria=criteria,
)
imgpoints.append(corners)
# Draw the corners
image = Image(image, copy=True)
if not image.iscolor:
image = image.colorize()
corner_images.append(
cv2.drawChessboardCorners(
image=image._A,
patternSize=gridshape,
corners=corners2,
patternWasFound=ret,
)
)
valid.append(i)
ret, C, distortion, rvecs, tvecs = cv2.calibrateCamera( # type: ignore[call-overload]
objectPoints=objpoints,
imagePoints=imgpoints,
imageSize=gray.shape[::-1],
cameraMatrix=None, # type: ignore[arg-type]
distCoeffs=None, # type: ignore[arg-type]
)
CalibrationFrame = namedtuple("CalibrationFrame", "image pose id")
if ret:
frames = []
for rvec, tvec, corner_image, id in zip(rvecs, tvecs, corner_images, valid):
frame = CalibrationFrame(
Image(corner_image, colororder="BGR"),
SE3(SE3(tvec) * SE3.EulerVec(rvec.flatten())).inv(), # type: ignore[arg-type]
id,
)
frames.append(frame)
return C, distortion[0], frames
else:
return None
[docs]
@classmethod
def decomposeC(cls, C: np.ndarray) -> CentralCamera:
r"""
Decompose camera calibration matrix
:param C: camera calibration matrix
:type C: ndarray(3,4)
:return: camera model parameters
:rtype: :class:`CentralCamera`
Decompose a :math:`3\times 4` camera calibration matrix ``C`` to
determine feasible intrinsic and extrinsic parameters. The result is a
``CentralCamera`` instance with the following parameters set:
================ ====================================
Parameter Meaning
================ ====================================
``f`` focal length in pixels
``sx``, ``sy`` pixel size where ``sx`` =1
(``u0``, ``v0``) principal point
``pose`` pose of the camera frame wrt world
================ ====================================
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> from spatialmath import SE3
>>> camera = CentralCamera(name='camera1')
>>> C = camera.C(SE3(0.1, 0, 0))
>>> CentralCamera.decomposeC(C)
.. note:: Since only :math:`f s_x` and :math:`f s_y` can be estimated we
set :math:`s_x = 1`.
:references:
- Multiple View Geometry, Hartley&Zisserman, p 163-164
- |RVC3|, Section 13.2.3.
:seealso: :meth:`C` :meth:`points2C`
"""
def rq(S: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
# from vgg_rq.m
# [R,Q] = vgg_rq(S) Just like qr but the other way around.
# If [R,Q] = vgg_rq(X), then R is upper-triangular, Q is orthogonal, and X==R*Q.
# Moreover, if S is a real matrix, then det(Q)>0.
# By awf
S = S.T
Q, U = np.linalg.qr(S[::-1, ::-1])
Q = Q.T
Q = Q[::-1, ::-1]
U = U.T
U = U[::-1, ::-1]
if np.linalg.det(Q) < 0:
U[:, 0] = -U[:, 0]
Q[0, :] = -Q[0, :]
return U, Q
if not C.shape == (3, 4):
raise ValueError("argument is not a 3x4 matrix")
u, s, v = np.linalg.svd(C)
v = v.T
# determine camera position
t = v[:, 3] # last column
t = t / t[3]
t = t[:3]
# determine camera orientation
M = C[:3, :3]
# K, R = rq(M)
K, R = scipy.linalg.rq(M)
# deal with K having negative elements on the diagonal
# make a matrix to fix this, K*C has positive diagonal
C = np.diag(np.sign(np.diag(K)))
# now K*R = (K*C) * (inv(C)*R), so we need to check C is a proper rotation
# matrix. If isn't then the situation is unfixable
if not np.isclose(np.linalg.det(C), 1):
raise RuntimeError("cannot correct signs in the intrinsic matrix")
# all good, let's fix it
K = K @ C
R = C.T @ R
# normalize K so that lower left is 1
K = K / K[2, 2]
# pull out focal length and scale factors
f = K[0, 0]
s = np.r_[1, K[1, 1] / K[0, 0]]
# build an equivalent camera model
return cls(name="invC", f=f, pp=K[:2, 2], rho=s, pose=SE3.Rt(R.T, t))
# https://docs.opencv.org/3.1.0/d9/d0c/group__calib3d.html#gaaae5a7899faa1ffdf268cd9088940248
# ======================= homography =============================== #
[docs]
def H(self, T: SE3, n: ArrayLike, d: float) -> np.ndarray:
"""
Compute homography from plane and camera pose
:param T: relative camera motion
:type T: :class:`~spatialmath..pose3d.SE3`
:param n: plane normal with respect to world frame
:type n: array_like(3)
:param d: plane offset from world frame origin
:type d: float
:return: homography matrix
:rtype: ndarray(3,3)
Computes the homography matrix for the camera observing points on a
plane from two viewpoints. The first view is from the current camera
pose (``self.pose``), and the second is after a relative motion
represented by the rigid-body motion ``T``. The plane has normal ``n``
and at distance ``d`` with respect to the world frame.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> from spatialmath import SE3
>>> camera = CentralCamera.Default(name='camera1') # looking along z-axis
>>> plane = [0, 1, 1]
>>> H = camera.H(SE3.Tx(0.2), plane, 5)
>>> H
:seealso: :meth:`points2H` :meth:`decomposeH`
"""
if d < 0:
raise ValueError(d, "plane distance d must be > 0")
n = smb.getvector(n)
if n[2] < 0:
raise ValueError(n, "normal must be away from camera (n[2] >= 0)")
# T transform view 1 to view 2
T = SE3(T).inv()
HH = T.R + 1.0 / d * T.t @ n # need to ensure column then row = 3x3
# apply camera intrinsics
HH = self.K @ HH @ np.linalg.inv(self.K)
return HH / HH[2, 2] # normalised
[docs]
@staticmethod
def points2H(
p1: np.ndarray,
p2: np.ndarray,
method: str = "leastsquares",
seed: int | None = None,
**kwargs,
) -> tuple[np.ndarray, float] | tuple[np.ndarray, float, np.ndarray]:
"""
Estimate homography from corresponding points
:param p1: image plane points from first camera
:type p1: ndarray(2,N)
:param p2: image plane points from second camera
:type p2: ndarray(2,N)
:param method: algorithm: 'leastsquares' [default], 'ransac', 'lmeds', 'prosac'
:type method: str
:param kwargs: optional arguments as required for ransac' and 'lmeds'
methods
:return: homography, residual and optional inliers
:rtype: ndarray(3,3), float, ndarray(N,bool)
Compute a homography from two sets of corresponding image plane points
whose world points lie on a plane.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera, mkgrid
>>> from spatialmath import SE3
>>> camera1 = CentralCamera(name="camera 1", f=0.002, imagesize=1000, rho=10e-6, pose=SE3.Tx(-0.1)*SE3.Ry(0.4))
>>> camera2 = CentralCamera(name="camera 2", f=0.002, imagesize=1000, rho=10e-6, pose=SE3.Tx(0.1)*SE3.Ry(-0.4))
>>> T_grid = SE3.Tz(1) * SE3.Rx(0.1) * SE3.Ry(0.2)
>>> P = mkgrid(3, 1.0, pose=T_grid)
>>> p1 = camera1.project_point(P)
>>> p2 = camera2.project_point(P);
>>> H, resid = CentralCamera.points2H(p1, p2)
>>> H
>>> resid
.. note:: If the method is 'ransac' or 'lmeds' then a boolean array
of inliers is also returned, True means the corresponding input
point pair is an inlier.
:references:
- |RVC3|, Section 14.2.4.
:seealso: :meth:`H` :meth:`decomposeH`
`opencv.findHomography <https://docs.opencv.org/4.x/d9/d0c/group__calib3d.html#ga4abc2ece9fab9398f2e560d53c8c9780>`_
"""
points2H_dict = {
"leastsquares": 0,
"ransac": cv2.RANSAC,
"lmeds": cv2.LMEDS,
"prosac": cv2.RHO,
}
if seed is not None:
cv2.setRNGSeed(seed)
H, mask = cv2.findHomography(
srcPoints=p1.T, dstPoints=p2.T, method=points2H_dict[method], **kwargs
)
mask = mask.ravel().astype(bool)
e = smb.homtrans(H, p1[:, mask]) - p2[:, mask]
resid = np.linalg.norm(e)
if method in ("ransac", "lmeds"):
return H, resid, mask
else:
return H, resid
# https://docs.opencv.org/3.1.0/d9/d0c/group__calib3d.html#ga7f60bdff78833d1e3fd6d9d0fd538d92
[docs]
def decomposeH(
self, H: np.ndarray, K: np.ndarray | None = None
) -> tuple[SE3, list[np.ndarray]]:
"""
Decompose homography matrix
:param H: homography matrix
:type H: ndarray(3,3)
:param K: camera intrinsics, defaults to parameters from object
:type K: ndarray(3,3), optional
:return: camera poses, plane normals
:rtype: :class:`~spatialmath..pose3d.SE3`, list of ndarray(3,1)
Decomposes the homography matrix into the camera motion and the normal
to the plane. In practice, there are multiple solutions. The translation
not to scale.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera, mkgrid
>>> from spatialmath import SE3
>>> camera1 = CentralCamera(name="camera 1", f=0.002, imagesize=1000, rho=10e-6, pose=SE3.Tx(-0.1)*SE3.Ry(0.4))
>>> camera2 = CentralCamera(name="camera 2", f=0.002, imagesize=1000, rho=10e-6, pose=SE3.Tx(0.1)*SE3.Ry(-0.4))
>>> T_grid = SE3.Tz(1) * SE3.Rx(0.1) * SE3.Ry(0.2)
>>> P = mkgrid(3, 1.0, pose=T_grid)
>>> p1 = camera1.project_point(P)
>>> p2 = camera2.project_point(P);
>>> H, resid = CentralCamera.points2H(p1, p2)
>>> T, normals = camera1.decomposeH(H)
>>> T.printline(orient="camera")
>>> normals
:references:
- |RVC3|, Section 14.2.4.
:seealso: :meth:`points2H` :meth:`H`
`opencv.decomposeHomographyMat <https://docs.opencv.org/4.x/d9/d0c/group__calib3d.html#ga7f60bdff78833d1e3fd6d9d0fd538d92>`_
"""
retval, rotations, translations, normals = cv2.decomposeHomographyMat(
H=H, K=self.K
)
T = SE3.Empty()
for R, t in zip(rotations, translations):
# we normalize the rotation matrix, those returned by openCV can
# not quite proper SO(3) values
pose = SE3.Rt(smb.trnorm(R), t).inv()
T.append(pose)
return T, normals
# if K is None:
# K = np.identity(3)
# # also have K = self.K
# H = np.linalg.inv(K) @ H @ K
# # normalise so that the second singular value is one
# U, S, V = np.linalg.svd(H, compute_uv=True)
# H = H / S[1]
# # compute the SVD of the symmetric matrix H'*H = VSV'
# U, S, V = np.linalg.svd(np.transpose(H) @ H)
# # ensure V is right-handed
# if np.linalg.det(V) < 0:
# print('det(V) was < 0')
# V = -V
# # get squared singular values
# s0 = S[0]
# s2 = S[2]
# # v0 = V[0:, 0]
# # v1 = V[0:, 1]
# # v2 = V[0:, 2]
# # pure rotation - where all singular values == 1
# if np.abs(s0 - s2) < (100 * np.spacing(1)):
# print('Warning: Homography due to pure rotation')
# if np.linalg.det(H) < 0:
# H = -H
# # sol = namedtuple('T', T, ''
# # TODO finish from invhomog.m
# print('Unfinished')
# return False
# =================== fundamental matrix =============================== #
[docs]
def F(self, other: CentralCamera | SE3) -> np.ndarray:
"""
Fundamental matrix
:param other: second camera view
:type other: :class:`CentralCamera`, :class:`~spatialmath..pose3d.SE3`
:return: fundamental matrix
:rtype: numpy(3,3)
Compute the fundamental matrix relating two camera views. The
first view is defined by the instance. The second
is defined by:
* another :class:`CentralCamera` instance
* an SE3 pose describing the pose of the second view with respect to
the first, assuming the same camera intrinsic parameters.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> from spatialmath import SE3
>>> camera1 = CentralCamera(name="camera 1", f=0.002, imagesize=1000, rho=10e-6, pose=SE3.Tx(-0.1)*SE3.Ry(0.4))
>>> camera2 = CentralCamera(name="camera 2", f=0.002, imagesize=1000, rho=10e-6, pose=SE3.Tx(0.1)*SE3.Ry(-0.4))
>>> F = camera1.F(camera2)
>>> F
>>> F = camera1.F(SE3.Tx(0.2))
>>> F
:references:
- Y.Ma, J.Kosecka, S.Soatto, S.Sastry, "An invitation to 3D",
Springer, 2003. p.177
- |RVC3|, Section 14.2.1.
:seealso: :meth:`points2F` :meth:`E`
"""
if isinstance(other, SE3):
E = self.E(other)
K = self.K
return np.linalg.inv(K).T @ E @ np.linalg.inv(K)
elif isinstance(other, CentralCamera):
# use relative pose and camera parameters of
E = self.E(other)
K1 = self.K
K2 = other.K
return np.linalg.inv(K2).T @ E @ np.linalg.inv(K1)
else:
raise ValueError("bad type")
[docs]
@staticmethod
def points2F(
p1: np.ndarray,
p2: np.ndarray,
method: str = "8p",
residual: bool = True,
seed: int | None = None,
**kwargs,
) -> list:
"""
Estimate fundamental matrix from corresponding points
:param p1: image plane points from first camera
:type p1: ndarray(2,N)
:param p2: image plane points from second camera
:type p2: ndarray(2,N)
:param method: algorithm '7p', '8p' [default], 'ransac', 'lmeds'
:type method: str, optional
:param kwargs: optional arguments as required for ransac', 'lmeds'
methods
:return: fundamental matrix and residual
:rtype: ndarray(3,3), float
Computes the fundamental matrix from two sets of corresponding
image-plane points. Corresponding points are given by corresponding
columns of ``p1`` and ``p2``.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera, mkgrid
>>> from spatialmath import SE3
>>> camera1 = CentralCamera(name="camera 1", f=0.002, imagesize=1000, rho=10e-6, pose=SE3.Tx(-0.1)*SE3.Ry(0.4))
>>> camera2 = CentralCamera(name="camera 2", f=0.002, imagesize=1000, rho=10e-6, pose=SE3.Tx(0.1)*SE3.Ry(-0.4))
>>> T_grid = SE3.Tz(1) * SE3.Rx(0.1) * SE3.Ry(0.2)
>>> P = mkgrid(3, 1.0, pose=T_grid)
>>> p1 = camera1.project_point(P)
>>> p2 = camera2.project_point(P);
>>> F, resid = CentralCamera.points2F(p1, p2)
>>> F
>>> resid
:seealso: :meth:`F` :meth:`E`
`opencv.findFundamentalMat <https://docs.opencv.org/4.x/d9/d0c/group__calib3d.html#ga59b0d57f46f8677fb5904294a23d404a>`_
"""
points2F_dict = {
"7p": cv2.FM_7POINT,
"8p": cv2.FM_8POINT,
"ransac": cv2.FM_RANSAC,
"lmeds": cv2.FM_LMEDS,
}
if seed is not None:
cv2.setRNGSeed(seed)
F, mask = cv2.findFundamentalMat(
points1=p1.T, points2=p2.T, method=points2F_dict[method], **kwargs
)
mask = mask.ravel().astype(bool)
# add various return values
retval = [F]
if residual:
e = smb.e2h(p2[:, mask]).T @ F @ smb.e2h(p1[:, mask])
resid = np.linalg.norm(np.diagonal(e))
retval.append(resid)
if method in ("ransac", "lmeds"):
retval.append(mask)
return retval
# elines = smb.e2h(p2).T @ F # homog lines, one per row
# p1h = smb.e2h(p1)
# residuals = []
# for i, line in enumerate(elines):
# d = line @ p1h[:, i] / np.sqrt(line[0] ** 2 + line[1] ** 2)
# residuals.append(d)
# resid = np.array(residuals).mean()
[docs]
@staticmethod
def epidist(F: np.ndarray, p1: np.ndarray, p2: np.ndarray) -> np.ndarray:
"""
Epipolar distance
:param F: fundamental matrix
:type F: ndarray(3,3)
:param p1: image plane point or points from first camera
:type p1: ndarray(2) or ndarray(2,N)
:param p2: image plane point or points from second camera
:type p2: ndarray(2) or ndarray(2,M)
:return: distance matrix
:rtype: ndarray(N,M)
Computes the distance of the points ``p2`` from the
epipolar lines induced by points ``p1``. Element [i,j] of the return
value is the distance of point j in camera 2 from the epipolar line
induced by point i in camera 1.
"""
if p1.ndim == 1:
p1 = np.c_[p1]
if p2.ndim == 1:
p2 = np.c_[p2]
D = np.empty((p1.shape[1], p2.shape[1]))
# compute epipolar lines corresponding to p1
l = F @ smb.e2h(p1)
for i in range(p1.shape[1]):
for j in range(p2.shape[1]):
D[i, j] = np.abs(
l[0, i] * p2[0, j] + l[1, i] * p2[1, j] + l[2, i]
) / np.sqrt(l[0, i] ** 2 + l[1, i] ** 2)
return D
# ===================== essential matrix =============================== #
[docs]
def E(self, other: CentralCamera | SE3 | np.ndarray) -> np.ndarray:
"""
Essential matrix from two camera views
:param other: second camera view, camera pose or fundamental matrix
:type other: :class:`CentralCamera`, :class:`~spatialmath..pose3d.SE3`, ndarray(3,3)
:return: essential matrix
:rtype: ndarray(3,3)
Compute the essential matrix relating two camera views. The first view
is defined by the instance, and second view is specified by:
* a camera instance represented by a :class:`CentralCamera`. Assumes the
cameras have the same intrinsics.
* a relative motion represented by a :class:`~spatialmath..pose3d.SE3`
* a fundamental matrix
:references:
- Y.Ma, J.Kosecka, S.Soatto, S.Sastry, "An invitation to 3D",
Springer, 2003. p.177
:seealso: :meth:`F` :meth:`decomposeE` :meth:`points2E`
"""
if isinstance(other, np.ndarray) and other.shape == (3, 3):
# essential matrix from F matrix and intrinsics
return self.K.T @ other @ self.K
elif isinstance(other, CentralCamera):
# camera relative pose
T21 = SE3(other.pose.inv() * self.pose) # type: ignore[arg-type]
elif isinstance(other, SE3):
# relative pose given explicitly
T21 = SE3(other.inv()) # type: ignore[arg-type]
else:
raise ValueError("bad type")
return smb.skew(T21.t) @ T21.R
[docs]
def points2E(
self,
p1: np.ndarray,
p2: np.ndarray,
method: str | None = None,
K: np.ndarray | None = None,
**kwargs,
) -> np.ndarray | tuple[np.ndarray, np.ndarray]:
"""
Essential matrix from points
:param P1: image plane points
:type P1: ndarray(2,N)
:param P2: image plane points
:type P2: ndarray(2,N)
:param method: method, can be 'ransac' or 'lmeds'
:type method: str
:param K: camera intrinsic matrix, defaults to that of camera object
:type K: ndarray(3,3), optional
:param kwargs: additional arguments required for 'ransac' or 'lmeds'
options
:return: essential matrix and optional inlier vevtor
:rtype: ndarray(3,3), ndarray(N, bool)
Compute the essential matrix from two sets of corresponding points.
Each set of points is represented by the columns of the array ``p1``
or ``p2``.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera, mkgrid
>>> from spatialmath import SE3
>>> camera1 = CentralCamera(name="camera 1", f=0.002, imagesize=1000, rho=10e-6, pose=SE3.Tx(-0.1)*SE3.Ry(0.4))
>>> camera2 = CentralCamera(name="camera 2", f=0.002, imagesize=1000, rho=10e-6, pose=SE3.Tx(0.1)*SE3.Ry(-0.4))
>>> T_grid = SE3.Tz(1) * SE3.Rx(0.1) * SE3.Ry(0.2)
>>> P = mkgrid(3, 1.0, pose=T_grid)
>>> p1 = camera1.project_point(P)
>>> p2 = camera2.project_point(P);
>>> E, inliers = camera1.points2E(p1, p2)
>>> E
>>> inliers
.. note:: If the method is 'ransac' or 'lmeds' then a boolean array
of inliers is also returned, True means the corresponding input
point pair is an inlier.
:seealso: :meth:`E` :meth:`decomposeE`
`opencv.findEssentialMat <https://docs.opencv.org/4.x/d9/d0c/group__calib3d.html#gad245d60e64d0c1270dbfd0520847bb87>`_
"""
if K is None:
K = self.K
_points2E_dict = {"ransac": cv2.RANSAC, "lmeds": cv2.LMEDS}
if method is not None:
method_int = _points2E_dict[method]
else:
method_int = None
E, mask = cv2.findEssentialMat(
points1=p1.T, points2=p2.T, cameraMatrix=K, method=method_int, **kwargs # type: ignore[arg-type]
)
if mask is not None:
mask = mask.flatten().astype(bool)
return E, mask
else:
return E
[docs]
def decomposeE(
self, E: np.ndarray, P: np.ndarray | FeatureMatch | None = None
) -> SE3:
"""
Decompose essential matrix
:param E: essential matrix
:type E: ndarray(3,3)
:param P: world point or feature match object to resolve ambiguity
:type P: array_like(3), :class:`~machinevisiontoolbox.ImagePointFeatures.FeatureMatch`
:return: camera relative pose
:rtype: :class:`~spatialmath..pose3d.SE3`
Determines relative pose from essential matrix. This operation has
multiple solutions which is resolved by passing in:
- a single 3D world point in front of the camera
- a :class:`~machinevisiontoolbox.ImagePointFeatures.FeatureMatch` object
:references:
- |RVC3|, Section 14.2.2.
:seealso: :meth:`E` :meth:`points2E`
:class:`~machinevisiontoolbox.ImagePointFeatures.FeatureMatch`
`opencv.decomposeEssentialMat <https://docs.opencv.org/4.x/d9/d0c/group__calib3d.html#ga54a2f5b3f8aeaf6c76d4a31dece85d5d>`_
`opencv.recoverPose <https://docs.opencv.org/4.x/d9/d0c/group__calib3d.html#gadb7d2dfcc184c1d2f496d8639f4371c0>`_
"""
if isinstance(P, FeatureMatch):
# passed a Match object
match = P
retval, R, t, mask = cv2.recoverPose(
E=E,
points1=match.p1.T,
points2=match.p2.T,
cameraMatrix=self.C()[:3, :3],
)
# not explicitly stated, but seems that this returns (R, t) from
# camera to world
return SE3.Rt(R, t).inv()
else:
R1, R2, t = cv2.decomposeEssentialMat(E=E)
# not explicitly stated, but seems that this returns (R, t) from
# camera to world
possibles = [(R1, t), (R1, -t), (R2, t), (R2, -t)]
if smb.isvector(P, 3):
for Rt in possibles:
pose = SE3.Rt(Rt[0], Rt[1]).inv()
p = self.project_point(P, pose=pose, behind=True)
# check if point is projected behind the camera, indicated
# by nan values
if not np.isnan(p[0]):
# return the first good one
return pose
else:
T = SE3.Empty()
for Rt in possibles:
pose = SE3.Rt(Rt[0], Rt[1]).inv()
T.append(pose)
return T
# ===================== image plane motion ============================= #
[docs]
def visjac_p(
self, uv: np.ndarray | ArrayLike, depth: float | np.ndarray
) -> np.ndarray:
r"""
Visual Jacobian for point features
:param p: image plane point or points
:type p: array_like(2), ndarray(2,N)
:param depth: point depth
:type depth: float, array_like(N)
:return: visual Jacobian matrix
:rtype: ndarray(2,6), ndarray(2N,6)
Compute the image Jacobian :math:`\mat{J}` which maps
.. math::
\dvec{p} = \mat{J}(\vec{p}, z) \vec{\nu}
camera spatial velocity :math:`\vec{\nu}` to the image plane velocity
:math:`\dvec{p}` of the point.
If ``p`` describes multiple points then return a stack of these
:math:`2\times 6` matrices, one per point.
Depth is the z-component of the point's coordinate in the camera frame.
If ``depth`` is a scalar then it is the depth for all points.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> from spatialmath import SE3
>>> camera = CentralCamera.Default()
>>> camera.visjac_p((200, 300), 2)
:references:
- A tutorial on Visual Servo Control, Hutchinson, Hager & Corke,
IEEE Trans. R&A, Vol 12(5), Oct, 1996, pp 651-670.
- |RVC3|, Section 15.2.1.
:seealso: :meth:`flowfield` :meth:`visjac_p_polar` :meth:`visjac_l` :meth:`visjac_e`
"""
uv = smb.getmatrix(uv, (2, None))
Z = depth
Z = smb.getvector(Z)
if len(Z) == 1:
Z = np.repeat(Z, uv.shape[1])
elif len(Z) != uv.shape[1]:
raise ValueError("Z must be a scalar or have same number of columns as uv")
L = np.empty((0, 6)) # empty matrix
K = self.K
Kinv = np.linalg.inv(K)
for z, p in zip(Z, uv.T): # iterate over each column (point)
# convert to normalized image-plane coordinates
xy = Kinv @ smb.e2h(p)
x = xy[0, 0]
y = xy[1, 0]
# 2x6 Jacobian for this point
# fmt: off
Lp = K[:2,:2] @ np.array(
[ [-1/z, 0, x/z, x * y, -(1 + x**2), y],
[ 0, -1/z, y/z, (1 + y**2), -x*y, -x] ])
# fmt: on
# stack them vertically
L = np.vstack([L, Lp])
return L
[docs]
def visjac_p_polar(
self, p: np.ndarray | ArrayLike, Z: float | np.ndarray
) -> np.ndarray:
r"""
Visual Jacobian for point features in polar coordinates
:param p: image plane point or points
:type p: array_like(2), ndarray(2,N)
:param depth: point depth
:type depth: float, array_like(N)
:return: visual Jacobian matrix in polar coordinates
:rtype: ndarray(2,6), ndarray(2N,6)
Compute the image Jacobian :math:`\mat{J}` which maps
.. math::
\begin{pmatrix} \dot{\phi} \\ \dot{r} \end{pmatrix} = \mat{J}(\vec{p}, z) \vec{\nu}
camera spatial velocity :math:`\vec{\nu}` to the image plane velocity
of the point expressed in polar coordinate form :math:`(\phi, r)`.
If ``p`` describes multiple points then return a stack of these
:math:`2\times 6` matrices, one per point.
Depth is the z-component of the point's coordinate in the camera frame.
If ``depth`` is a scalar then it is the depth for all points.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> from spatialmath import SE3
>>> camera = CentralCamera.Default()
>>> camera.visjac_p_polar((200, 300), 2)
:references:
- Combining Cartesian and polar coordinates in IBVS.
Corke PI, Spindler F, Chaumette F
IROS 2009, pp 5962–5967
- |RVC3|, Section 16.2.
:seealso: :meth:`visjac_p` :meth:`visjac_l` :meth:`visjac_e`
"""
J = []
p = smb.getmatrix(p, (2, None))
f = self.f[0]
if smb.isscalar(Z):
Z = [Z] * p.shape[1]
for (phi, r), Zk in zip(p.T, Z):
# k = (f**2 + r**2) / f
# k2 = f / (r * Zk)
c = np.cos(phi)
s = np.sin(phi)
r = max(r, 0.05)
Jk = np.array(
[
[-s / r / Zk, c / r / Zk, 0, -c / r, -s / r, 1],
[c / Zk, s / Zk, -r / Zk, -(1 + r**2) * s, (1 + r**2) * c, 0],
]
)
# Jk = np.array([
# [ k2 * sth, -k2 * cth, 0, f / r * cth, f / r * sth , -1],
# [-f / Zk * cth, -f / Zk * sth, r / Zk, k * sth, -k * cth, 0],
# ])
# Jk = np.array([
# [cth/Zk , sth / Zk, -r / Zk, -(1+r**2) * sth, -k * cth, 0],
# [ k2 * sth, -k2 * cth, 0, f / r * cth, f / r * sth , -1]])
J.append(Jk)
return np.vstack(J)
# if 0
# r = rt(1); theta = rt(2);
# % compute the mapping from uv-dot to r-theta dot
# M = 1/r * [r*cos(theta) r*sin(theta); -sin(theta) cos(theta)];
# % convert r-theta form to uv form
# u = r * cos(theta); v = r * sin(theta);
# % compute the Jacobian
# J = M * cam.visjac_p([u; v], Z);
[docs]
def visjac_l(self, lines: np.ndarray | ArrayLike, plane: ArrayLike) -> np.ndarray:
r"""
Visual Jacobian for line features
:param lines: image plane line parameters
:type p: array_like(2), ndarray(2,N)
:param plane: plane containing the line
:type plane: array_like(4)
:return: visual Jacobian matrix for line feature
:rtype: ndarray(2,6), ndarray(2N,6)
Compute the Jacobian which gives the rates of change of the line
parameters in terms of camera spatial velocity.
For image planes lines
.. math:: u \cos \theta + v \sin \theta = \rho
the image Jacobian :math:`\mat{J}` maps
.. math::
\begin{pmatrix} \dot{\theta} \\ \dot{\rho} \end{pmatrix} = \mat{J}(\vec{p}, z) \vec{\nu}
camera spatial velocity :math:`\vec{\nu}` to the image plane velocity
of the line parameters :math:`(\theta, \rho)`.
The world plane containing the line is also required, and is provided
as a vector :math:`(a,b,c,d)` such that
.. math:: aX + bY + cZ + d = 0
If ``lines`` describes multiple points then return a stack of these
:math:`2\times 6` matrices, one per point.
Depth is the z-component of the point's coordinate in the camera frame.
If ``depth`` is a scalar then it is the depth for all points.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> from spatialmath import SE3
>>> camera = CentralCamera.Default()
>>> camera.visjac_l((0.2, 500), (0, 0, 1, -3))
:references:
- A New Approach to Visual Servoing in Robotics,
B. Espiau, F. Chaumette, and P. Rives,
IEEE Transactions on Robotics and Automation,
vol. 8, pp. 313-326, June 1992.
- Visual servo control 2: Advanced approaches
Chaumette F, Hutchinson S,
IEEE Robot Autom Mag 14(1):109–118 (2007)
- |RVC3|, Section 15.3.1.
:seealso: :meth:`visjac_p` :meth:`visjac_p_polar` :meth:`visjac_e`
"""
a, b, c, d = plane
lines = smb.getmatrix(lines, (2, None))
jac = []
for theta, rho in lines.T:
sth = np.sin(theta)
cth = np.cos(theta)
lam_th = (a * sth - b * cth) / d
lam_rho = (a * rho * cth + b * rho * sth + c) / d
L = np.array(
[
[
lam_th * cth,
lam_th * sth,
-lam_th * rho,
-rho * cth,
-rho * sth,
-1,
],
[
lam_rho * cth,
lam_rho * sth,
-lam_rho * rho,
(1 + rho**2) * sth,
-(1 + rho**2) * cth,
0,
],
]
)
jac.append(L)
return np.vstack(jac)
[docs]
def visjac_e(self, E: ArrayLike, plane: ArrayLike) -> np.ndarray:
r"""
Visual Jacobian for ellipse features
:param E: image plane ellipse parameters
:type E: array_like(5), ndarray(5,N)
:param plane: plane containing the ellipse
:type plane: array_like(4)
:return: visual Jacobian matrix for ellipse feature
:rtype: ndarray(2,6), ndarray(2N,6)
Compute the Jacobian gives the rates of change of the ellipse parameters
in terms of camera spatial velocity.
For image plane ellipses
.. math:: u^2 + E_0 v^2 -2 E_1 u v + 2 E_2 u + 2 E_3 v + E_4 = 0
the image Jacobian :math:`\mat{J}` maps
.. math::
\begin{pmatrix} \dot{E_0} \\ \vdots \\ \dot{E_4} \end{pmatrix} = \mat{J}(\vec{p}, z) \vec{\nu}
camera spatial velocity :math:`\vec{\nu}` to the velocity
of the ellipse parameters :math:`(E_0 \ldots E_4)`.
The world plane containing the ellipse is also required, and is provided
as a vector :math:`(a,b,c,d)` such that
.. math:: aX + bY + cZ + d = 0
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> from spatialmath import SE3
>>> camera = CentralCamera.Default()
>>> camera.visjac_e(((0.5, 0, -1000, -500, 374900)), (0, 0, 1, -1)
:references:
- A New Approach to Visual Servoing in Robotics,
B. Espiau, F. Chaumette, and P. Rives,
IEEE Transactions on Robotics and Automation,
vol. 8, pp. 313-326, June 1992.
- Visual servo control 2: Advanced approaches
Chaumette F, Hutchinson S,
IEEE Robot Autom Mag 14(1):109–118 (2007)
- |RVC3|, Section 15.3.2.
:seealso: :meth:`visjac_p` :meth:`visjac_p_polar` :meth:`visjac_l`
"""
a = -plane[0] / plane[3]
b = -plane[1] / plane[3]
c = -plane[2] / plane[3]
L = np.array(
[
[
2 * b * E[1] - 2 * a * E[0],
2 * E[0] * (b - a * E[1]),
2 * b * E[3] - 2 * a * E[0] * E[2],
2 * E[3],
2 * E[0] * E[2],
-2 * E[1] * (E[0] + 1),
],
[
b - a * E[1],
b * E[1] - a * (2 * E[1] ** 2 - E[0]),
a * (E[3] - 2 * E[1] * E[2]) + b * E[2],
-E[2],
-(2 * E[1] * E[2] - E[3]),
E[0] - 2 * E[1] ** 2 - 1,
],
[
c - a * E[2],
a * (E[3] - 2 * E[1] * E[2]) + c * E[1],
c * E[2] - a * (2 * E[2] ** 2 - E[4]),
-E[1],
1 + 2 * E[2] ** 2 - E[4],
E[3] - 2 * E[1] * E[2],
],
[
E[2] * b + E[1] * c - 2 * a * E[3],
E[3] * b + E[0] * c - 2 * a * E[1] * E[3],
b * E[4] + c * E[3] - 2 * a * E[2] * E[3],
E[4] - E[0],
2 * E[2] * E[3] + E[1],
-2 * E[1] * E[3] - E[2],
],
[
2 * c * E[2] - 2 * a * E[4],
2 * c * E[3] - 2 * a * E[1] * E[4],
2 * c * E[4] - 2 * a * E[2] * E[4],
-2 * E[3],
2 * E[2] * E[4] + 2 * E[2],
-2 * E[1] * E[4],
],
]
)
L = L @ np.diag([0.5, 0.5, 0.5, 1, 1, 1]) # not sure why...
return L
[docs]
def flowfield(self, vel: ArrayLike, Z: float = 2) -> None:
"""
Display optical flow field
:param vel: camera spatial velocity
:type vel: array_like(6)
:param Z: _description_, defaults to 2
:type Z: scalar, optional
Display the optical flow field using Matplotlib, for a grid of points at
distance ``Z`` for a camera velocity of ``vel``.
Example:
.. runblock:: pycon
>>> from machinevisiontoolbox import CentralCamera
>>> camera = CentralCamera.Default()
>>> camera.flowfield([0, 0, 0, 0, 1, 0])
:seealso: :meth:`visjac_p`
"""
vel = smb.getvector(vel, 6)
u = np.arange(0, self.nu, 50)
v = np.arange(0, self.nv, 50)
[U, V] = np.meshgrid(u, v, indexing="ij")
du = np.empty(shape=U.shape)
dv = np.empty(shape=U.shape)
for r in range(U.shape[0]):
for c in range(U.shape[1]):
J = self.visjac_p((U[r, c], V[r, c]), Z)
ud, vd = J @ vel
du[r, c] = ud
dv[r, c] = -vd
self.clf()
ax = self._init_imageplane()
ax.quiver(U, V, du, dv, 0.4, zorder=20)
[docs]
def derivatives(
self, x: ArrayLike, P: ArrayLike
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
r"""
Compute projection and derivatives for bundle adjustment
:param x: camera pose as translation and quaternion vector part
:type x: array_like(6)
:param P: 3D world point
:type P: array_like(3)
:return: p, A, B
:rtype: ndarray(2), ndarray(2,6), ndarray(2,3)
For a world point :math:`\vec{x}` compute the image plane projection and the
sensitivity to camera and point change
.. math:: \mat{A} = \frac{\partial \vec{f}(\vec{x})}{\partial \pose}, \mat{B} = \frac{\partial \vec{f}(\vec{x})}{\partial \vec{P}}
where :math:`\vec{f}(\vec{x})` is the perspective projection function.
:seealso: :meth:`project_point`
"""
# compute Jacobians and projection
from machinevisiontoolbox.camera_derivatives import cameraModel
Kp = [self.f[0], self.rhou, self.rhov, self.u0, self.v0]
return cameraModel(*x, *P, *Kp)
[docs]
def estpose(
self,
P: np.ndarray,
p: np.ndarray,
method: str = "iterative",
frame: str = "world",
) -> SE3 | None:
"""
Estimate object pose
:param P: A set of 3D points defining the object with respect to its own frame
:type P: ndarray(3, N)
:param p: Image plane projection of the object points
:type p: ndarray(2, N)
:param method: pose estimation algorithm, see OpenCV solvePnP, defaults to 'iterative'
:type method: str, optional
:param frame: estimate pose with respect to frame "world" [default] or "camera"
:type frame: str, optional
:return: pose of target frame relative to the world frame
:rtype: :class:`~spatialmath..pose3d.SE3`
Using a set of points defining some object with respect to its own frame {B}, and
a set of image-plane projections, estimate the pose of {B} with respect to the world
or camera frame.
To estimate the camera's pose with respect to the world frame the camera's pose
``self.pose`` is used.
.. note::
* All of the OpenCV estimation algorithms are supported.
* Algorithm ``"ippe-square"`` requires exactly four points at the corners of a
square and in the order: (-x, y), (x, y), (x, -y), (-x, -y).
:seealso: :meth:`project_point`
`opencv.solvePnP <https://docs.opencv.org/4.x/d9/d0c/group__calib3d.html#ga549c2075fac14829ff4a58bc931c033d>`_
"""
method_dict = {
"iterative": cv2.SOLVEPNP_ITERATIVE,
"epnp": cv2.SOLVEPNP_EPNP,
"p3p": cv2.SOLVEPNP_P3P,
"ap3p": cv2.SOLVEPNP_AP3P,
"ippe": cv2.SOLVEPNP_IPPE,
"ippe-square": cv2.SOLVEPNP_IPPE_SQUARE,
}
# as per the Note on solvePnP page
# we need to ensure that the image point data is contiguous nx1x2 array
n = p.shape[1]
p = np.ascontiguousarray(p[:2, :].T).reshape((n, 1, 2))
# do the pose estimation
sol = cv2.solvePnP(
objectPoints=P.T,
imagePoints=p,
cameraMatrix=self.K,
distCoeffs=self._distortion,
flags=method_dict[method],
)
if sol[0]:
# pose of target with respect to camera
pose_C_T = SE3(sol[2]) * SE3.EulerVec(sol[1])
# pose with respect to world frame
if frame == "camera":
return pose_C_T
elif frame == "world":
return self.pose * pose_C_T
else:
raise ValueError(f"bad frame value {frame}")
else:
return None
# ------------------------------------------------------------------------ #
[docs]
class FishEyeCamera(CameraBase):
r"""
Create fisheye camera projection model
.. inheritance-diagram:: machinevisiontoolbox.Camera.FishEyeCamera
:top-classes: machinevisiontoolbox.Camera.Camera
:parts: 1
:param k: scale factor
:type k: float, optional
:param projection: projection model: ``'equiangular'`` [default], ``'sine'``, ``'equisolid'`` or ``'stereographic'``
:type projection: str, optional
:param kwargs: arguments passed to :class:`CameraBase` constructor
A fisheye camera contains a wide angle lens, and the angle of the
incoming ray is mapped to a radius with respect to the principal point.
The mapping from elevation angle :math:`\theta` to image plane radius is
given by:
============= =======================================
Projection :math:`r(\theta)`
============= =======================================
equiangular :math:`r = k \theta`
sine :math:`r = k \sin \theta`
equisolid :math:`r = k \sin \frac{\theta}{2}`
stereographic :math:`r = k \tan \frac{\theta}{2}`
============= =======================================
.. note::
- If ``K`` is not specified it is computed such that the circular
imaging region maximally fills the square image plane.
- This camera model assumes central projection, that is, the focal point
is at z=0 and the image plane is at z=f. The image is not inverted.
:references:
- |RVC3|, Section 13.3.1.
:seealso: :class:`CameraBase` :class:`CentralCamera` :class:`CatadioptricCamera`
:class:`SphericalCamera`
"""
k: float
rfunc: Callable
projection: str
def __init__(
self, k: float | None = None, projection: str = "equiangular", **kwargs
) -> None:
super().__init__(camtype="fisheye", **kwargs)
self.projection = projection
if k is None:
assert self._imagesize is not None and self._pp is not None
r = np.min((self._imagesize - self._pp) * self.rho)
if self.projection == "equiangular":
if k is None:
k = r / (pi / 2)
rfunc = lambda theta: k * theta
elif self.projection == "sine":
if k is None:
k = r
rfunc = lambda theta: k * np.sin(theta)
elif self.projection == "equisolid":
if k is None:
k = r / sin(pi / 4)
rfunc = lambda theta: k * np.sin(theta / 2)
elif self.projection == "stereographic":
if k is None:
k = r / tan(pi / 4)
rfunc = lambda theta: k * np.tan(theta / 2)
else:
raise ValueError("unknown projection model")
self.k = k
self.rfunc = rfunc
def __str__(self) -> str:
s = super().__str__()
s += self.fmt.format("model", self.projection, fmt="{}")
s += self.fmt.format("k", self.k, fmt="{:.4g}")
return s
[docs]
def project_point(
self,
P: np.ndarray | ArrayLike,
pose: SE3 | None = None,
objpose: SE3 | None = None,
) -> np.ndarray:
r"""
Project 3D points to image plane
:param P: 3D world point or points
:type P: array_like(3), array_like(3,n)
:param pose: camera pose with respect to the world frame, defaults to
camera's ``pose`` attribute
:type pose: :class:`~spatialmath.pose3d.SE3`, optional
:param objpose: 3D point reference frame, defaults to world frame
:type objpose: :class:`~spatialmath..pose3d.SE3`, optional
:param visibility: test if points are visible, default False
:type visibility: bool
:raises ValueError: [description]
:return: image plane points
:rtype: ndarray(2,n)
Project world points to the fisheye camera image plane.
The elevation angle range is from :math:`-pi/2` (below the mirror) to
maxangle above the horizontal plane. The mapping from elevation angle
:math:`\theta` to image plane radius is given by:
============= =======================================
Projection :math:`r(\theta)`
============= =======================================
equiangular :math:`r = k \theta`
sine :math:`r = k \sin \theta`
equisolid :math:`r = k \sin \frac{\theta}{2}`
stereographic :math:`r = k \tan \frac{\theta}{2}`
============= =======================================
World points are given as a 1D array or the columns of a 2D array of
Euclidean coordinates. The computed image plane coordinates are
Euclidean and given as a 1D array or the corresponding columns of a 2D
array.
If ``pose`` is specified it is used for the camera pose instead of the
attribute ``pose``. The object's attribute is not updated.
The points ``P`` are by default with respect to the world frame, but
they can be transformed by specifying ``objpose``.
:seealso: :meth:`plot_point`
"""
P = smb.getmatrix(P, (3, None))
if pose is not None:
T = self.pose.inv()
else:
T = SE3()
if objpose is not None:
T *= objpose
R = np.sqrt(np.sum(P**2, axis=0))
phi = np.arctan2(-P[1, :], P[0, :])
theta = np.arccos(P[2, :] / R)
r = self.rfunc(theta)
x = r * np.cos(phi)
y = r * np.sin(phi)
uv = np.array([x / self.rhou + self.u0, y / self.rhov + self.v0])
return self._add_noise_distortion(uv)
# ------------------------------------------------------------------------ #
[docs]
class CatadioptricCamera(CameraBase):
r"""
Create catadioptric camera projection model
.. inheritance-diagram:: machinevisiontoolbox.Camera.CatadioptricCamera
:top-classes: machinevisiontoolbox.Camera.Camera
:parts: 1
:param k: scale factor
:type k: float, optional
:param projection: projection model: ``'equiangular'`` [default], ``'sine'``, ``'equisolid'`` or ``'stereographic'``
:type projection: str, optional
:param kwargs: arguments passed to :class:`CameraBase` constructor
A catadioptric camera comprises a perspective camera pointed at a
convex mirror, typically paraboloidal or conical.
The elevation angle range is from :math:`-\pi/2` (below the mirror) to
maxangle above the horizontal plane. The mapping from elevation angle
:math:`\theta` to image plane radius is given by:
============= =======================================
Projection :math:`r(\theta)`
============= =======================================
equiangular :math:`r = k \theta`
sine :math:`r = k \sin \theta`
equisolid :math:`r = k \sin \frac{\theta}{2}`
stereographic :math:`r = k \tan \frac{\theta}{2}`
============= =======================================
.. note::
- If ``K`` is not specified it is computed such that the circular
imaging region maximally fills the image plane.
- This camera model assumes central projection, that is, the focal point
is at :math:`z=0` and the image plane is at :math:`z=f`. The image is not inverted.
:references:
- |RVC3|, Section 13.3.2.
:seealso: :class:`CameraBase` :class:`CentralCamera` :class:`FishEyeCamera`
:class:`SphericalCamera`
"""
k: float
rfunc: Callable
projection: str
def __init__(
self,
k: float | None = None,
projection: str = "equiangular",
maxangle: float | None = None,
**kwargs,
) -> None:
super().__init__(camtype="catadioptric", **kwargs)
self.projection = projection
if k is None:
assert self._imagesize is not None and self._pp is not None
r = np.min((self._imagesize - self._pp) * self.rho)
# compute k if not specified, so that hemisphere fits into
# image plane, requires maxangle being set
if self.projection == "equiangular":
if k is None:
if maxangle is not None:
k = r / (pi / 2 + maxangle)
self.maxangle = maxangle
else:
raise ValueError("must specify either k or maxangle")
rfunc = lambda theta: k * theta
elif self.projection == "sine":
if k is None:
k = r
rfunc = lambda theta: k * np.sin(theta)
elif self.projection == "equisolid":
if k is None:
k = r / sin(pi / 4)
rfunc = lambda theta: k * np.sin(theta / 2)
elif self.projection == "stereographic":
if k is None:
k = r / tan(pi / 4)
rfunc = lambda theta: k * np.tan(theta / 2)
else:
raise ValueError("unknown projection model")
self.k = k
self.rfunc = rfunc
def __str__(self) -> str:
s = super().__str__()
s += self.fmt.format("model", self.projection, fmt="{}")
s += self.fmt.format("k", self.k, fmt="{:.4g}")
return s
[docs]
def project_point(
self,
P: np.ndarray | ArrayLike,
pose: SE3 | None = None,
objpose: SE3 | None = None,
) -> np.ndarray:
"""
Project 3D points to image plane
:param P: 3D world point or points
:type P: array_like(3), array_like(3,n)
:param pose: camera pose with respect to the world frame, defaults to
camera's ``pose`` attribute
:type pose: :class:`~spatialmath..pose3d.SE3`, optional
:param objpose: 3D point reference frame, defaults to world frame
:type objpose: :class:`~spatialmath..pose3d.SE3`, optional
:param visibility: test if points are visible, default False
:type visibility: bool
:raises ValueError: [description]
:return: image plane points
:rtype: ndarray(2,n)
Project world points to the catadioptric camera image plane.
World points are given as a 1D array or the columns of a 2D array of
Euclidean coordinates. The computed image plane coordinates are
Euclidean and given as a 1D array or the corresponding columns of a 2D
array.
If ``pose`` is specified it is used for the camera pose instead of the
attribute ``pose``. The object's attribute is not updated.
The points ``P`` are by default with respect to the world frame, but
they can be transformed by specifying ``objpose``.
:seealso: :meth:`plot_point`
"""
P = smb.getmatrix(P, (3, None))
if pose is not None:
T = self.pose.inv()
else:
T = SE3()
if objpose is not None:
T *= objpose
P = T * P # transform points to camera frame
P_arr: np.ndarray = np.asarray(P)
# project to the image plane
phi = np.arctan2(P_arr[1, :], P_arr[0, :])
Rxy = np.sqrt(np.sum(P_arr[:2, :] ** 2, axis=0)) # sqrt(X^2 + Y^2)
theta = np.arctan(P_arr[2, :] / Rxy)
r = self.rfunc(theta) # depends on projection model
x = r * np.cos(phi)
y = r * np.sin(phi)
uv = np.array([x / self.rhou + self.u0, y / self.rhov + self.v0])
return self._add_noise_distortion(uv)
# ------------------------------------------------------------------------ #
[docs]
class SphericalCamera(CameraBase):
"""
Create spherical camera projection model
.. inheritance-diagram:: machinevisiontoolbox.Camera.SphericalCamera
:top-classes: machinevisiontoolbox.Camera.Camera
:parts: 1
:param kwargs: arguments passed to :class:`CameraBase` constructor
The spherical camera is an idealization with a complete field of view
that can be used to generalize all camera projection models.
:references:
- |RVC3|, Section 13.3.3.
:seealso: :class:`CameraBase` :class:`CentralCamera` :class:`CatadioptricCamera`
:class:`FishEyeCamera`
"""
def __init__(self, **kwargs: Any) -> None:
# invoke the superclass constructor
super().__init__(
camtype="spherical",
limits=[-pi, pi, 0, pi],
labels=["Longitude φ (rad)", "Colatitude θ (rad)"],
**kwargs,
)
# return field-of-view angle for x and y direction (rad)
[docs]
def fov(self) -> list[float]:
"""
Camera field-of-view angles
:return: field of view angles in radians
:rtype: ndarray(2)
Computes the field of view angles (2x1) in radians for the camera
horizontal and vertical directions.
"""
return [2 * pi, 2 * pi]
[docs]
def project_point(
self,
P: np.ndarray | ArrayLike,
pose: SE3 | None = None,
objpose: SE3 | None = None,
) -> np.ndarray:
r"""
Project 3D points to image plane
:param P: 3D world point or points
:type P: array_like(3), array_like(3,n)
:param pose: camera pose with respect to the world frame, defaults to
camera's ``pose`` attribute
:type pose: :class:`~spatialmath..pose3d.SE3`, optional
:param objpose: 3D point reference frame, defaults to world frame
:type objpose: :class:`~spatialmath..pose3d.SE3`, optional
:return: image plane points
:rtype: ndarray(2,n)
Project world points to the spherical camera image plane.
World points are given as a 1D array or the columns of a 2D array of
Euclidean coordinates. The computed image plane coordinates are
in polar form :math:`(\phi, \theta)` (longitude, colatitude),
and given as a 1D array or the corresponding columns of a 2D
array.
If ``pose`` is specified it is used for the camera pose instead of the
attribute ``pose``. The object's attribute is not updated.
The points ``P`` are by default with respect to the world frame, but
they can be transformed by specifying ``objpose``.
:seealso: :meth:`plot_point`
"""
P = smb.getmatrix(P, (3, None))
if pose is None:
pose = self.pose
pose = pose.inv()
if objpose is not None:
pose *= objpose
P = pose * P # transform points to camera frame
P_arr: np.ndarray = np.asarray(P)
R = np.linalg.norm(P_arr, axis=0)
x = P_arr[0, :] / R
y = P_arr[1, :] / R
z = P_arr[2, :] / R
phi = np.arctan2(y, x)
r = np.sqrt(x**2 + y**2)
theta = np.arcsin(r)
return np.array([phi, theta])
[docs]
def visjac_p(
self, p: np.ndarray | ArrayLike, depth: float | np.ndarray | None = None
) -> np.ndarray:
r"""
Visual Jacobian for point features
:param p: image plane points
:type p: array_like(2) or ndarray(2,N)
:param depth: point depth, defaults to ``None``
:type depth: float or array_like(N), optional
:return: visual Jacobian
:rtype: ndarray(2,6) or ndarray(2N,6)
Compute the image Jacobian :math:`\mat{J}` which maps
.. math::
\dvec{p} = \mat{J}(\vec{p}, z) \vec{\nu}
camera spatial velocity :math:`\vec{\nu}` to the image plane velocity
:math:`\dvec{p}` of the point where :math:`\vec{p}=(\phi, \theta)`
If ``p`` describes multiple points then return a stack of these
:math:`2\times 6` matrices, one per point.
Depth is the z-component of the point's coordinate in the camera frame.
If ``depth`` is a scalar then it is the depth for all points.
"""
J = []
if smb.isscalar(depth):
depth = [depth] * p.shape[1]
assert depth is not None
for (phi, theta), R in zip(p.T, depth):
sp = np.sin(phi)
cp = np.cos(phi)
st = np.sin(theta)
ct = np.cos(theta)
Jk = np.array(
[
[sp / R / st, -cp / R / st, 0, cp * ct / st, sp * ct / st, -1],
[-cp * ct / R, -sp * ct / R, st / R, sp, -cp, 0],
]
)
J.append(Jk)
return np.vstack(J)
[docs]
def plot(self, frame: bool = False, **kwargs: Any) -> None:
smb.plot_sphere(
radius=1,
filled=True,
color="lightyellow",
alpha=0.3,
resolution=20,
centre=self.pose.t,
)
self.pose.plot(style="arrow", axislabel=True, length=1.4)
# class CentralCamera_polar(Camera):
# """
# Central projection camera class
# """
# def __init__(self,
# f=1,
# distortion=None,
# **kwargs):
# """
# Create central camera projection model in polar coordinates
# :param f: focal length, defaults to 8*1e-3
# :type f: float, optional
# :param distortion: camera distortion parameters, defaults to None
# :type distortion: array_like(5), optional
# :seealso: :meth:`distort`
# """
# super().__init__(type='perspective', **kwargs)
# # TODO some of this logic to f and pp setters
# self.f = f
# self._distortion = distortion
# @classmethod
# def Default(cls, **kwargs):
# default = {
# 'f': 0.008,
# 'rho': 10e-6,
# 'imagesize': 1000,
# 'pp': (500,500),
# 'name': 'default perspective camera'
# }
# return CentralCamera_polar(**{**default, **kwargs})
# def __str__(self):
# s = super().__str__()
# s += self.fmt.format('principal pt', self.pp)
# s += self.fmt.format('focal length', self.f)
# return s
# def project_point(self, P, pose=None, objpose=None, **kwargs):
# r"""
# Project 3D points to image plane
# :param P: 3D points to project into camera image plane
# :type P: array_like(3), array_like(3,n)
# :param pose: camera pose with respect to the world frame, defaults to
# camera's ``pose`` attribute
# :type pose: SE3, optional
# :param objpose: 3D point reference frame, defaults to world frame
# :type objpose: SE3, optional
# :param visibility: test if points are visible, default False
# :type visibility: bool
# :param retinal: transform to retinal coordinates, default False
# :type retinal: bool, optional
# :return: image plane points
# :rtype: ndarray(2,n)
# Project a 3D point to the image plane
# .. math::
# \hvec{p} = \mat{C} \hvec{P}
# where :math:`\mat{C}` is the camera calibration matrix and :math:`\hvec{p}` and :math:`\hvec{P}`
# are the image plane and world frame coordinates respectively.
# Example:
# .. runblock:: pycon
# >>> from machinevisiontoolbox import CentralCamera
# >>> camera = CentralCamera()
# >>> camera.project_point((0.3, 0.4, 2))
# If ``pose`` is specified it is used for the camera frame pose, otherwise
# the attribute ``pose``. The object's ``pose`` attribute is not updated
# if ``pose`` is specified.
# A single point can be specified as a 3-vector, multiple points as an
# array with three rows and one column (x, y, z) per point.
# The points ``P`` are by default with respect to the world frame, but
# they can be transformed by specifying ``objpose``.
# If world points are behind the camera, the image plane points are set to
# NaN.
# if ``visibility`` is True then each projected point is checked to ensure
# it lies in the bounds of the image plane. In this case there are two
# return values: the image plane coordinates and an array of booleans
# indicating if the corresponding point is visible.
# """
# if pose is None:
# pose = self.pose
# C = self.C(pose, retinal=retinal)
# if isinstance(P, np.ndarray):
# if P.ndim == 1:
# P = P.reshape((-1, 1)) # make it a column
# else:
# P = smb.getvector(P, out='col')
# # make it homogeneous if not already
# if P.shape[0] == 3:
# P = smb.e2h(P)
# # project 3D points
# if objpose is not None:
# P = objpose.A @ P
# x = C @ P
# if behind:
# x[2, x[2, :] < 0] = np.nan # points behind the camera are set to NaN
# x = smb.h2e(x)
# # add Gaussian noise and distortion
# x = self.add_noise_distortion(x)
# # do visibility check if required
# if visibility:
# visible = ~np.isnan(x[0,:]) \
# & (x[0, :] >= 0) \
# & (x[1, :] >= 0) \
# & (x[0, :] < self.nu) \
# & (x[1, :] < self.nv)
# return x, visible
# else:
# return x
# def plot_point(self, Pp
# ax = _newplot(self, fig, ax)
# if self._image is not None:
# # if camera has an image, display said image
# idisp(self._image,
# fig=fig,
# ax=ax,
# title=self._name,
# drawonly=True)
# else:
# if self.limits is None:
# ax.set_xlim(0, self.nu)
# ax.set_ylim(0, self.nv)
# else:
# ax.set_xlim(self.limits[0], self.limits[1])
# ax.set_ylim(self.limits[2], self.limits[3])
# ax.autoscale(False)
# ax.set_aspect('equal')
# ax.invert_yaxis()
# ax.grid(True)
# if self.labels is None:
# ax.set_xlabel('u (pixels)')
# ax.set_ylabel('v (pixels)')
# else:
# ax.set_xlabel(self.labels[0])
# ax.set_ylabel(self.labels[1])
# ax.set_title(self.name)
# ax.set_facecolor('lightyellow')
# ax.figure.canvas.set_window_title('Machine Vision Toolbox for Python')
# # TODO figure out axes ticks, etc
# return ax # likely this return is not necessary
if __name__ == "__main__":
from pathlib import Path
import pytest
pytest.main(
[str(Path(__file__).parent.parent.parent / "tests" / "test_camera.py"), "-v"]
)