Skip to content

ds_msp.ops

Model-agnostic services built on top of any CameraModel: undistortion (Undistorter) and pose estimation (solve_pnp, solve_pnp_ransac). See Undistort images and Solve PnP on a fisheye for task recipes.

ds_msp.ops

Model-agnostic services: undistortion, pose estimation, and chart reprojection.

Chart

Base class: a chart is an output grid plus a bijection with unit rays.

Source code in ds_msp/ops/reproject.py
class Chart:
    """Base class: a chart is an output grid plus a bijection with unit rays."""

    width: int
    height: int

    @property
    def shape(self) -> Tuple[int, int]:
        """``(height, width)`` of the chart's output grid, pixels."""
        return self.height, self.width

    def pixel_to_ray(self, u: np.ndarray, v: np.ndarray) -> np.ndarray:
        """Map chart pixel coordinates to unit bearing rays.

        Parameters
        ----------
        u, v : ndarray
            Chart pixel coordinates, any common broadcastable shape ``(...,)``.

        Returns
        -------
        ndarray
            Unit-norm rays, shape ``(..., 3)``, in the library's ``x`` right,
            ``y`` down, ``z`` forward convention.

        Raises
        ------
        NotImplementedError
            Always, on the base class. Concrete charts override this.
        """
        raise NotImplementedError

    def ray_to_pixel(self, rays: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
        """Map unit bearing rays to chart pixel coordinates.

        Parameters
        ----------
        rays : ndarray
            Rays, shape ``(..., 3)``, need not be unit-norm.

        Returns
        -------
        uv : ndarray
            Chart pixel coordinates, shape ``(..., 2)``.
        valid : ndarray
            Boolean mask, shape ``(...,)``, ``True`` where the ray falls inside
            this chart's coverage.

        Raises
        ------
        NotImplementedError
            Always, on the base class. Concrete charts override this.
        """
        raise NotImplementedError

shape property

shape: Tuple[int, int]

(height, width) of the chart's output grid, pixels.

pixel_to_ray

pixel_to_ray(u: ndarray, v: ndarray) -> np.ndarray

Map chart pixel coordinates to unit bearing rays.

Parameters:

Name Type Description Default
u ndarray

Chart pixel coordinates, any common broadcastable shape (...,).

required
v ndarray

Chart pixel coordinates, any common broadcastable shape (...,).

required

Returns:

Type Description
ndarray

Unit-norm rays, shape (..., 3), in the library's x right, y down, z forward convention.

Raises:

Type Description
NotImplementedError

Always, on the base class. Concrete charts override this.

Source code in ds_msp/ops/reproject.py
def pixel_to_ray(self, u: np.ndarray, v: np.ndarray) -> np.ndarray:
    """Map chart pixel coordinates to unit bearing rays.

    Parameters
    ----------
    u, v : ndarray
        Chart pixel coordinates, any common broadcastable shape ``(...,)``.

    Returns
    -------
    ndarray
        Unit-norm rays, shape ``(..., 3)``, in the library's ``x`` right,
        ``y`` down, ``z`` forward convention.

    Raises
    ------
    NotImplementedError
        Always, on the base class. Concrete charts override this.
    """
    raise NotImplementedError

ray_to_pixel

ray_to_pixel(rays: ndarray) -> Tuple[np.ndarray, np.ndarray]

Map unit bearing rays to chart pixel coordinates.

Parameters:

Name Type Description Default
rays ndarray

Rays, shape (..., 3), need not be unit-norm.

required

Returns:

Name Type Description
uv ndarray

Chart pixel coordinates, shape (..., 2).

valid ndarray

Boolean mask, shape (...,), True where the ray falls inside this chart's coverage.

Raises:

Type Description
NotImplementedError

Always, on the base class. Concrete charts override this.

Source code in ds_msp/ops/reproject.py
def ray_to_pixel(self, rays: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
    """Map unit bearing rays to chart pixel coordinates.

    Parameters
    ----------
    rays : ndarray
        Rays, shape ``(..., 3)``, need not be unit-norm.

    Returns
    -------
    uv : ndarray
        Chart pixel coordinates, shape ``(..., 2)``.
    valid : ndarray
        Boolean mask, shape ``(...,)``, ``True`` where the ray falls inside
        this chart's coverage.

    Raises
    ------
    NotImplementedError
        Always, on the base class. Concrete charts override this.
    """
    raise NotImplementedError

Cylindrical

Bases: Chart

Cylinder chart: same azimuth column as the sphere, row linear in tan(elevation).

Source code in ds_msp/ops/reproject.py
class Cylindrical(Chart):
    """Cylinder chart: same azimuth column as the sphere, row linear in tan(elevation)."""

    def __init__(self, width: int, height: int, hfov_deg: float = 360.0):
        self.width, self.height = int(width), int(height)
        self.f = width / np.radians(hfov_deg)
        self.cx, self.cy = width / 2.0, height / 2.0

    def pixel_to_ray(self, u, v):
        """See :meth:`Chart.pixel_to_ray`. Row ``v`` maps through ``tan(elevation)``."""
        lam = (u - self.cx) / self.f
        h = (self.cy - v) / self.f                       # = tan(elevation)
        return _unit(np.stack([np.sin(lam), -h, np.cos(lam)], axis=-1))

    def ray_to_pixel(self, rays):
        """See :meth:`Chart.ray_to_pixel`. ``valid`` requires ``z > 0`` (front
        hemisphere of the azimuth branch; the cylinder projection is undefined
        past ±90° from the forward axis)."""
        x, y, z = rays[..., 0], rays[..., 1], rays[..., 2]
        lam = np.arctan2(x, z)
        h = -y / np.hypot(x, z)
        uv = np.stack([self.cx + self.f * lam, self.cy - self.f * h], axis=-1)
        valid = z > 0                                    # cylinder covers |azimuth| < 90° per branch
        return uv, valid

pixel_to_ray

pixel_to_ray(u, v)

See :meth:Chart.pixel_to_ray. Row v maps through tan(elevation).

Source code in ds_msp/ops/reproject.py
def pixel_to_ray(self, u, v):
    """See :meth:`Chart.pixel_to_ray`. Row ``v`` maps through ``tan(elevation)``."""
    lam = (u - self.cx) / self.f
    h = (self.cy - v) / self.f                       # = tan(elevation)
    return _unit(np.stack([np.sin(lam), -h, np.cos(lam)], axis=-1))

ray_to_pixel

ray_to_pixel(rays)

See :meth:Chart.ray_to_pixel. valid requires z > 0 (front hemisphere of the azimuth branch; the cylinder projection is undefined past ±90° from the forward axis).

Source code in ds_msp/ops/reproject.py
def ray_to_pixel(self, rays):
    """See :meth:`Chart.ray_to_pixel`. ``valid`` requires ``z > 0`` (front
    hemisphere of the azimuth branch; the cylinder projection is undefined
    past ±90° from the forward axis)."""
    x, y, z = rays[..., 0], rays[..., 1], rays[..., 2]
    lam = np.arctan2(x, z)
    h = -y / np.hypot(x, z)
    uv = np.stack([self.cx + self.f * lam, self.cy - self.f * h], axis=-1)
    valid = z > 0                                    # cylinder covers |azimuth| < 90° per branch
    return uv, valid

Equirectangular

Bases: Chart

Unit-sphere chart: column linear in azimuth, row linear in elevation (full sphere).

Source code in ds_msp/ops/reproject.py
class Equirectangular(Chart):
    """Unit-sphere chart: column linear in azimuth, row linear in elevation (full sphere)."""

    def __init__(self, width: int, height: int, hfov_deg: float = 360.0):
        self.width, self.height = int(width), int(height)
        self.f = width / np.radians(hfov_deg)            # px / rad
        self.cx, self.cy = width / 2.0, height / 2.0

    def pixel_to_ray(self, u, v):
        """See :meth:`Chart.pixel_to_ray`. Covers the full sphere; never invalid."""
        lam = (u - self.cx) / self.f
        psi = (self.cy - v) / self.f
        c = np.cos(psi)
        return np.stack([c * np.sin(lam), -np.sin(psi), c * np.cos(lam)], axis=-1)

    def ray_to_pixel(self, rays):
        """See :meth:`Chart.ray_to_pixel`. ``valid`` is always all-``True``."""
        x, y, z = rays[..., 0], rays[..., 1], rays[..., 2]
        lam = np.arctan2(x, z)
        psi = np.arctan2(-y, np.hypot(x, z))
        uv = np.stack([self.cx + self.f * lam, self.cy - self.f * psi], axis=-1)
        return uv, np.ones(rays.shape[:-1], dtype=bool)

pixel_to_ray

pixel_to_ray(u, v)

See :meth:Chart.pixel_to_ray. Covers the full sphere; never invalid.

Source code in ds_msp/ops/reproject.py
def pixel_to_ray(self, u, v):
    """See :meth:`Chart.pixel_to_ray`. Covers the full sphere; never invalid."""
    lam = (u - self.cx) / self.f
    psi = (self.cy - v) / self.f
    c = np.cos(psi)
    return np.stack([c * np.sin(lam), -np.sin(psi), c * np.cos(lam)], axis=-1)

ray_to_pixel

ray_to_pixel(rays)

See :meth:Chart.ray_to_pixel. valid is always all-True.

Source code in ds_msp/ops/reproject.py
def ray_to_pixel(self, rays):
    """See :meth:`Chart.ray_to_pixel`. ``valid`` is always all-``True``."""
    x, y, z = rays[..., 0], rays[..., 1], rays[..., 2]
    lam = np.arctan2(x, z)
    psi = np.arctan2(-y, np.hypot(x, z))
    uv = np.stack([self.cx + self.f * lam, self.cy - self.f * psi], axis=-1)
    return uv, np.ones(rays.shape[:-1], dtype=bool)

Pinhole

Bases: Chart

Rectilinear (gnomonic) chart — a virtual pinhole, valid only for the front hemisphere.

Parameters:

Name Type Description Default
width int

Output chart size, pixels.

required
height int

Output chart size, pixels.

required
hfov_deg float

Horizontal field of view, degrees. Must be < 180.0tan diverges at a 90° half-angle, so a wider pinhole cannot exist.

90.0
R ndarray of shape (3, 3)

Rotation from the chart's local look direction into the camera frame (ray_camera = R @ ray_chart). Defaults to identity (chart looks along the camera's own +z).

None

Raises:

Type Description
ValueError

If hfov_deg >= 180.0.

Source code in ds_msp/ops/reproject.py
class Pinhole(Chart):
    """Rectilinear (gnomonic) chart — a virtual pinhole, valid only for the front hemisphere.

    Parameters
    ----------
    width, height : int
        Output chart size, pixels.
    hfov_deg : float, default=90.0
        Horizontal field of view, degrees. Must be ``< 180.0`` — ``tan`` diverges
        at a 90° half-angle, so a wider pinhole cannot exist.
    R : ndarray of shape (3, 3), optional
        Rotation from the chart's local look direction into the camera frame
        (``ray_camera = R @ ray_chart``). Defaults to identity (chart looks
        along the camera's own +z).

    Raises
    ------
    ValueError
        If ``hfov_deg >= 180.0``.
    """

    def __init__(self, width: int, height: int, hfov_deg: float = 90.0,
                 R: np.ndarray | None = None):
        if hfov_deg >= 180.0:
            raise ValueError("pinhole hfov must be < 180° (tan diverges at 90°)")
        self.width, self.height = int(width), int(height)
        self.fx = (width / 2.0) / np.tan(np.radians(hfov_deg) / 2.0)
        self.fy = self.fx
        self.cx, self.cy = width / 2.0, height / 2.0
        self.R = np.eye(3) if R is None else np.asarray(R, float)   # chart→camera rotation

    def pixel_to_ray(self, u, v):
        """See :meth:`Chart.pixel_to_ray`. Never invalid (every pixel has a ray)."""
        x = (u - self.cx) / self.fx
        y = (v - self.cy) / self.fy
        local = _unit(np.stack([x, y, np.ones_like(x)], axis=-1))
        return local @ self.R.T

    def ray_to_pixel(self, rays):
        """See :meth:`Chart.ray_to_pixel`. ``valid`` requires the ray to lie in
        front of the chart plane (``z > 1e-9`` in the chart's rotated frame)."""
        d = rays @ self.R                                # into chart frame
        x, y, z = d[..., 0], d[..., 1], d[..., 2]
        valid = z > 1e-9
        zc = np.where(valid, z, 1.0)
        uv = np.stack([self.cx + self.fx * x / zc, self.cy + self.fy * y / zc], axis=-1)
        return uv, valid

pixel_to_ray

pixel_to_ray(u, v)

See :meth:Chart.pixel_to_ray. Never invalid (every pixel has a ray).

Source code in ds_msp/ops/reproject.py
def pixel_to_ray(self, u, v):
    """See :meth:`Chart.pixel_to_ray`. Never invalid (every pixel has a ray)."""
    x = (u - self.cx) / self.fx
    y = (v - self.cy) / self.fy
    local = _unit(np.stack([x, y, np.ones_like(x)], axis=-1))
    return local @ self.R.T

ray_to_pixel

ray_to_pixel(rays)

See :meth:Chart.ray_to_pixel. valid requires the ray to lie in front of the chart plane (z > 1e-9 in the chart's rotated frame).

Source code in ds_msp/ops/reproject.py
def ray_to_pixel(self, rays):
    """See :meth:`Chart.ray_to_pixel`. ``valid`` requires the ray to lie in
    front of the chart plane (``z > 1e-9`` in the chart's rotated frame)."""
    d = rays @ self.R                                # into chart frame
    x, y, z = d[..., 0], d[..., 1], d[..., 2]
    valid = z > 1e-9
    zc = np.where(valid, z, 1.0)
    uv = np.stack([self.cx + self.fx * x / zc, self.cy + self.fy * y / zc], axis=-1)
    return uv, valid

TangentImage

Bases: Chart

A gnomonic patch tangent to the sphere at center (a unit ray) — a low-distortion perspective view of one region. Tile these (cubemap / icosahedron) to cover a wide FOV with near-frontal, straight-edged content. Equivalent to a Pinhole oriented at center.

Parameters:

Name Type Description Default
center ndarray of shape (3,)

Look direction of the patch, in camera-frame coordinates. Need not be unit-norm; it is normalized internally.

required
fov_deg float

Horizontal field of view of the (square) patch, degrees. Must be < 180.0 (see :class:Pinhole).

required
size int

Output patch width and height, pixels (square).

required

Raises:

Type Description
ValueError

If fov_deg >= 180.0.

Source code in ds_msp/ops/reproject.py
class TangentImage(Chart):
    """A gnomonic patch tangent to the sphere at ``center`` (a unit ray) — a low-distortion
    perspective view of one region. Tile these (cubemap / icosahedron) to cover a wide FOV with
    near-frontal, straight-edged content. Equivalent to a `Pinhole` oriented at ``center``.

    Parameters
    ----------
    center : ndarray of shape (3,)
        Look direction of the patch, in camera-frame coordinates. Need not be
        unit-norm; it is normalized internally.
    fov_deg : float
        Horizontal field of view of the (square) patch, degrees. Must be
        ``< 180.0`` (see :class:`Pinhole`).
    size : int
        Output patch width and height, pixels (square).

    Raises
    ------
    ValueError
        If ``fov_deg >= 180.0``.
    """

    def __init__(self, center: np.ndarray, fov_deg: float, size: int):
        n = _unit(np.asarray(center, float))
        up = np.array([0.0, -1.0, 0.0])                  # world "up" is -y
        if abs(n @ up) > 0.999:                          # looking near a pole → pick another up
            up = np.array([0.0, 0.0, 1.0])
        right = _unit(np.cross(up, n))
        true_up = np.cross(n, right)
        self.R = np.stack([right, true_up, n], axis=1)   # columns: chart axes in camera frame
        self._pin = Pinhole(size, size, hfov_deg=fov_deg, R=self.R)
        self.width = self.height = int(size)
        self.center = n

    def pixel_to_ray(self, u, v):
        """See :meth:`Chart.pixel_to_ray`. Delegates to the underlying `Pinhole`."""
        return self._pin.pixel_to_ray(u, v)

    def ray_to_pixel(self, rays):
        """See :meth:`Chart.ray_to_pixel`. Delegates to the underlying `Pinhole`."""
        return self._pin.ray_to_pixel(rays)

pixel_to_ray

pixel_to_ray(u, v)

See :meth:Chart.pixel_to_ray. Delegates to the underlying Pinhole.

Source code in ds_msp/ops/reproject.py
def pixel_to_ray(self, u, v):
    """See :meth:`Chart.pixel_to_ray`. Delegates to the underlying `Pinhole`."""
    return self._pin.pixel_to_ray(u, v)

ray_to_pixel

ray_to_pixel(rays)

See :meth:Chart.ray_to_pixel. Delegates to the underlying Pinhole.

Source code in ds_msp/ops/reproject.py
def ray_to_pixel(self, rays):
    """See :meth:`Chart.ray_to_pixel`. Delegates to the underlying `Pinhole`."""
    return self._pin.ray_to_pixel(rays)

Undistorter

Undistort images/points from any model into a pinhole view.

Model-agnostic counterpart to :meth:ds_msp.model.DoubleSphereCamera.undistort_image: works with any :class:~ds_msp.core.contracts.CameraModel. Caches the resampling map by output K_new (recomputed only when a different K_new is requested), so repeated calls with the same target intrinsics reuse the cached (mapx, mapy).

Parameters:

Name Type Description Default
model CameraModel

The calibrated camera to undistort from.

required
width int

Output (rectified) image size, pixels.

required
height int

Output (rectified) image size, pixels.

required

Examples:

>>> import numpy as np
>>> from ds_msp.models import DoubleSphereModel
>>> model = DoubleSphereModel.sample()
>>> img = np.zeros((1080, 1920, 3), dtype=np.uint8)
>>> und = Undistorter(model, 1920, 1080)
>>> img_rect, K_new = und.undistort_image(img)        # works with any CameraModel
>>> img_rect.shape, K_new.shape
((1080, 1920, 3), (3, 3))
Source code in ds_msp/ops/undistort.py
class Undistorter:
    """Undistort images/points from any model into a pinhole view.

    Model-agnostic counterpart to :meth:`ds_msp.model.DoubleSphereCamera.undistort_image`:
    works with any :class:`~ds_msp.core.contracts.CameraModel`. Caches the resampling map by
    output ``K_new`` (recomputed only when a different ``K_new`` is requested), so repeated
    calls with the same target intrinsics reuse the cached ``(mapx, mapy)``.

    Parameters
    ----------
    model : CameraModel
        The calibrated camera to undistort from.
    width, height : int
        Output (rectified) image size, pixels.

    Examples
    --------
    >>> import numpy as np
    >>> from ds_msp.models import DoubleSphereModel
    >>> model = DoubleSphereModel.sample()
    >>> img = np.zeros((1080, 1920, 3), dtype=np.uint8)
    >>> und = Undistorter(model, 1920, 1080)
    >>> img_rect, K_new = und.undistort_image(img)        # works with any CameraModel
    >>> img_rect.shape, K_new.shape
    ((1080, 1920, 3), (3, 3))
    """

    def __init__(self, model: CameraModel, width: int, height: int) -> None:
        self.model = model
        self.width = int(width)
        self.height = int(height)
        self._mapx = None
        self._mapy = None
        self._K_new = None

    def new_K(self, balance: float = 0.5) -> np.ndarray:
        """Build a balanced pinhole intrinsic matrix for the output image.

        Parameters
        ----------
        balance : float, default=0.5
            Field-of-view/border trade-off in ``[0, 1]``: ``0.0`` keeps the
            widest field of view (more black border), ``1.0`` crops tightest
            (least border). See :func:`ds_msp.core.pinhole.balanced_pinhole_K`.

        Returns
        -------
        ndarray of shape (3, 3)
            The new pinhole ``K``, principal point at the output image center.
        """
        K = self.model.K
        return balanced_pinhole_K(K[0, 0], K[1, 1], self.width, self.height, balance)

    def maps(self, K_new: Optional[np.ndarray] = None
             ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
        """Build (or return the cached) ``cv2.remap`` lookup for a target ``K_new``.

        Parameters
        ----------
        K_new : ndarray of shape (3, 3), optional
            Target pinhole intrinsics for the rectified output. Defaults to
            :meth:`new_K` at ``balance=0.5`` when not given.

        Returns
        -------
        mapx : ndarray of shape (height, width), float32
            Source-image ``u`` for each output pixel, ``-1`` where the model
            has no valid projection.
        mapy : ndarray of shape (height, width), float32
            Source-image ``v`` for each output pixel, ``-1`` where invalid.
        K_new : ndarray of shape (3, 3)
            The intrinsics the maps were built for (echoes the input, or the
            computed default).
        """
        if K_new is None:
            K_new = self.new_K()
        if self._mapx is not None and self._K_new is not None \
                and np.array_equal(K_new, self._K_new):
            return self._mapx, self._mapy, self._K_new

        fx_n, fy_n = K_new[0, 0], K_new[1, 1]
        cx_n, cy_n = K_new[0, 2], K_new[1, 2]
        xg, yg = np.meshgrid(np.arange(self.width, dtype=np.float64),
                             np.arange(self.height, dtype=np.float64), indexing="xy")
        rays = np.stack([(xg - cx_n) / fx_n, (yg - cy_n) / fy_n, np.ones_like(xg)], axis=-1)
        uv, valid = self.model.project(rays)
        mapx = uv[..., 0].astype(np.float32)
        mapy = uv[..., 1].astype(np.float32)
        mapx[~valid] = -1
        mapy[~valid] = -1
        self._mapx, self._mapy, self._K_new = mapx, mapy, K_new
        return mapx, mapy, K_new

    def undistort_image(self, img: np.ndarray, K_new: Optional[np.ndarray] = None
                        ) -> Tuple[np.ndarray, np.ndarray]:
        """Resample a distorted ``img`` into a rectified pinhole view.

        Parameters
        ----------
        img : ndarray of shape (H_src, W_src, ...)
            Source (distorted) image, any ``cv2.remap``-compatible dtype.
        K_new : ndarray of shape (3, 3), optional
            Target pinhole intrinsics; see :meth:`maps`. Defaults to
            :meth:`new_K` at ``balance=0.5``.

        Returns
        -------
        out : ndarray of shape (height, width, ...)
            The rectified image, sized ``(height, width)`` from the
            constructor. Pixels with no valid source ray are zero
            (``cv2.BORDER_CONSTANT``).
        K_new : ndarray of shape (3, 3)
            The intrinsics actually used (echoes the input, or the computed
            default).
        """
        mapx, mapy, K_new = self.maps(K_new)
        out = cv2.remap(img, mapx, mapy, cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT)
        return out, K_new

    def undistort_points(self, points: np.ndarray, K_new: Optional[np.ndarray] = None
                         ) -> Tuple[np.ndarray, np.ndarray]:
        """Map distorted pixels to rectified pinhole pixels (in the ``K_new`` frame).

        Closed-form counterpart to :meth:`undistort_image`: unprojects each point
        through ``self.model`` and reprojects the resulting ray with ``K_new``.
        Exact wherever the ray is recoverable — unlike inverting a displacement
        mesh, this has no periphery error (see :mod:`ds_msp.ldc` for the mesh
        alternative and its accuracy trade-off).

        Parameters
        ----------
        points : ndarray of shape (N, 2)
            Distorted pixels in the original (source) image.
        K_new : ndarray of shape (3, 3), optional
            Target pinhole intrinsics; see :meth:`maps`. Defaults to
            :meth:`new_K` at ``balance=0.5``.

        Returns
        -------
        uv : ndarray of shape (N, 2)
            Rectified pixel coordinates in the ``K_new`` frame.
        valid : ndarray of shape (N,), bool
            ``True`` where the model successfully unprojected the input pixel.
        """
        if K_new is None:
            K_new = self.new_K()
        rays, valid = self.model.unproject(np.asarray(points, dtype=np.float64))
        rays_n = rays / (rays[:, 2:3] + 1e-12)
        u = K_new[0, 0] * rays_n[:, 0] + K_new[0, 2]
        v = K_new[1, 1] * rays_n[:, 1] + K_new[1, 2]
        return np.stack([u, v], axis=-1), valid

    def distort_points(self, points: np.ndarray, K_new: Optional[np.ndarray] = None
                       ) -> Tuple[np.ndarray, np.ndarray]:
        """Inverse of :meth:`undistort_points`: map rectified pixels back to distorted ones.

        Parameters
        ----------
        points : ndarray of shape (N, 2)
            Rectified pinhole pixels, in the ``K_new`` frame.
        K_new : ndarray of shape (3, 3), optional
            The intrinsics ``points`` are expressed in; see :meth:`maps`.
            Defaults to :meth:`new_K` at ``balance=0.5``.

        Returns
        -------
        uv : ndarray of shape (N, 2)
            Distorted pixels in the original (source) image.
        valid : ndarray of shape (N,), bool
            ``True`` where ``self.model.project`` produced a valid pixel for
            the corresponding ray.
        """
        if K_new is None:
            K_new = self.new_K()
        pts = np.asarray(points, dtype=np.float64)
        mx = (pts[:, 0] - K_new[0, 2]) / K_new[0, 0]
        my = (pts[:, 1] - K_new[1, 2]) / K_new[1, 1]
        rays = np.stack([mx, my, np.ones_like(mx)], axis=-1)
        rays = rays / np.linalg.norm(rays, axis=-1, keepdims=True)
        return self.model.project(rays)

distort_points

distort_points(points: ndarray, K_new: Optional[ndarray] = None) -> Tuple[np.ndarray, np.ndarray]

Inverse of :meth:undistort_points: map rectified pixels back to distorted ones.

Parameters:

Name Type Description Default
points ndarray of shape (N, 2)

Rectified pinhole pixels, in the K_new frame.

required
K_new ndarray of shape (3, 3)

The intrinsics points are expressed in; see :meth:maps. Defaults to :meth:new_K at balance=0.5.

None

Returns:

Name Type Description
uv ndarray of shape (N, 2)

Distorted pixels in the original (source) image.

valid ndarray of shape (N,), bool

True where self.model.project produced a valid pixel for the corresponding ray.

Source code in ds_msp/ops/undistort.py
def distort_points(self, points: np.ndarray, K_new: Optional[np.ndarray] = None
                   ) -> Tuple[np.ndarray, np.ndarray]:
    """Inverse of :meth:`undistort_points`: map rectified pixels back to distorted ones.

    Parameters
    ----------
    points : ndarray of shape (N, 2)
        Rectified pinhole pixels, in the ``K_new`` frame.
    K_new : ndarray of shape (3, 3), optional
        The intrinsics ``points`` are expressed in; see :meth:`maps`.
        Defaults to :meth:`new_K` at ``balance=0.5``.

    Returns
    -------
    uv : ndarray of shape (N, 2)
        Distorted pixels in the original (source) image.
    valid : ndarray of shape (N,), bool
        ``True`` where ``self.model.project`` produced a valid pixel for
        the corresponding ray.
    """
    if K_new is None:
        K_new = self.new_K()
    pts = np.asarray(points, dtype=np.float64)
    mx = (pts[:, 0] - K_new[0, 2]) / K_new[0, 0]
    my = (pts[:, 1] - K_new[1, 2]) / K_new[1, 1]
    rays = np.stack([mx, my, np.ones_like(mx)], axis=-1)
    rays = rays / np.linalg.norm(rays, axis=-1, keepdims=True)
    return self.model.project(rays)

maps

maps(K_new: Optional[ndarray] = None) -> Tuple[np.ndarray, np.ndarray, np.ndarray]

Build (or return the cached) cv2.remap lookup for a target K_new.

Parameters:

Name Type Description Default
K_new ndarray of shape (3, 3)

Target pinhole intrinsics for the rectified output. Defaults to :meth:new_K at balance=0.5 when not given.

None

Returns:

Name Type Description
mapx ndarray of shape (height, width), float32

Source-image u for each output pixel, -1 where the model has no valid projection.

mapy ndarray of shape (height, width), float32

Source-image v for each output pixel, -1 where invalid.

K_new ndarray of shape (3, 3)

The intrinsics the maps were built for (echoes the input, or the computed default).

Source code in ds_msp/ops/undistort.py
def maps(self, K_new: Optional[np.ndarray] = None
         ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Build (or return the cached) ``cv2.remap`` lookup for a target ``K_new``.

    Parameters
    ----------
    K_new : ndarray of shape (3, 3), optional
        Target pinhole intrinsics for the rectified output. Defaults to
        :meth:`new_K` at ``balance=0.5`` when not given.

    Returns
    -------
    mapx : ndarray of shape (height, width), float32
        Source-image ``u`` for each output pixel, ``-1`` where the model
        has no valid projection.
    mapy : ndarray of shape (height, width), float32
        Source-image ``v`` for each output pixel, ``-1`` where invalid.
    K_new : ndarray of shape (3, 3)
        The intrinsics the maps were built for (echoes the input, or the
        computed default).
    """
    if K_new is None:
        K_new = self.new_K()
    if self._mapx is not None and self._K_new is not None \
            and np.array_equal(K_new, self._K_new):
        return self._mapx, self._mapy, self._K_new

    fx_n, fy_n = K_new[0, 0], K_new[1, 1]
    cx_n, cy_n = K_new[0, 2], K_new[1, 2]
    xg, yg = np.meshgrid(np.arange(self.width, dtype=np.float64),
                         np.arange(self.height, dtype=np.float64), indexing="xy")
    rays = np.stack([(xg - cx_n) / fx_n, (yg - cy_n) / fy_n, np.ones_like(xg)], axis=-1)
    uv, valid = self.model.project(rays)
    mapx = uv[..., 0].astype(np.float32)
    mapy = uv[..., 1].astype(np.float32)
    mapx[~valid] = -1
    mapy[~valid] = -1
    self._mapx, self._mapy, self._K_new = mapx, mapy, K_new
    return mapx, mapy, K_new

new_K

new_K(balance: float = 0.5) -> np.ndarray

Build a balanced pinhole intrinsic matrix for the output image.

Parameters:

Name Type Description Default
balance float

Field-of-view/border trade-off in [0, 1]: 0.0 keeps the widest field of view (more black border), 1.0 crops tightest (least border). See :func:ds_msp.core.pinhole.balanced_pinhole_K.

0.5

Returns:

Type Description
ndarray of shape (3, 3)

The new pinhole K, principal point at the output image center.

Source code in ds_msp/ops/undistort.py
def new_K(self, balance: float = 0.5) -> np.ndarray:
    """Build a balanced pinhole intrinsic matrix for the output image.

    Parameters
    ----------
    balance : float, default=0.5
        Field-of-view/border trade-off in ``[0, 1]``: ``0.0`` keeps the
        widest field of view (more black border), ``1.0`` crops tightest
        (least border). See :func:`ds_msp.core.pinhole.balanced_pinhole_K`.

    Returns
    -------
    ndarray of shape (3, 3)
        The new pinhole ``K``, principal point at the output image center.
    """
    K = self.model.K
    return balanced_pinhole_K(K[0, 0], K[1, 1], self.width, self.height, balance)

undistort_image

undistort_image(img: ndarray, K_new: Optional[ndarray] = None) -> Tuple[np.ndarray, np.ndarray]

Resample a distorted img into a rectified pinhole view.

Parameters:

Name Type Description Default
img ndarray of shape (H_src, W_src, ...)

Source (distorted) image, any cv2.remap-compatible dtype.

required
K_new ndarray of shape (3, 3)

Target pinhole intrinsics; see :meth:maps. Defaults to :meth:new_K at balance=0.5.

None

Returns:

Name Type Description
out ndarray of shape (height, width, ...)

The rectified image, sized (height, width) from the constructor. Pixels with no valid source ray are zero (cv2.BORDER_CONSTANT).

K_new ndarray of shape (3, 3)

The intrinsics actually used (echoes the input, or the computed default).

Source code in ds_msp/ops/undistort.py
def undistort_image(self, img: np.ndarray, K_new: Optional[np.ndarray] = None
                    ) -> Tuple[np.ndarray, np.ndarray]:
    """Resample a distorted ``img`` into a rectified pinhole view.

    Parameters
    ----------
    img : ndarray of shape (H_src, W_src, ...)
        Source (distorted) image, any ``cv2.remap``-compatible dtype.
    K_new : ndarray of shape (3, 3), optional
        Target pinhole intrinsics; see :meth:`maps`. Defaults to
        :meth:`new_K` at ``balance=0.5``.

    Returns
    -------
    out : ndarray of shape (height, width, ...)
        The rectified image, sized ``(height, width)`` from the
        constructor. Pixels with no valid source ray are zero
        (``cv2.BORDER_CONSTANT``).
    K_new : ndarray of shape (3, 3)
        The intrinsics actually used (echoes the input, or the computed
        default).
    """
    mapx, mapy, K_new = self.maps(K_new)
    out = cv2.remap(img, mapx, mapy, cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT)
    return out, K_new

undistort_points

undistort_points(points: ndarray, K_new: Optional[ndarray] = None) -> Tuple[np.ndarray, np.ndarray]

Map distorted pixels to rectified pinhole pixels (in the K_new frame).

Closed-form counterpart to :meth:undistort_image: unprojects each point through self.model and reprojects the resulting ray with K_new. Exact wherever the ray is recoverable — unlike inverting a displacement mesh, this has no periphery error (see :mod:ds_msp.ldc for the mesh alternative and its accuracy trade-off).

Parameters:

Name Type Description Default
points ndarray of shape (N, 2)

Distorted pixels in the original (source) image.

required
K_new ndarray of shape (3, 3)

Target pinhole intrinsics; see :meth:maps. Defaults to :meth:new_K at balance=0.5.

None

Returns:

Name Type Description
uv ndarray of shape (N, 2)

Rectified pixel coordinates in the K_new frame.

valid ndarray of shape (N,), bool

True where the model successfully unprojected the input pixel.

Source code in ds_msp/ops/undistort.py
def undistort_points(self, points: np.ndarray, K_new: Optional[np.ndarray] = None
                     ) -> Tuple[np.ndarray, np.ndarray]:
    """Map distorted pixels to rectified pinhole pixels (in the ``K_new`` frame).

    Closed-form counterpart to :meth:`undistort_image`: unprojects each point
    through ``self.model`` and reprojects the resulting ray with ``K_new``.
    Exact wherever the ray is recoverable — unlike inverting a displacement
    mesh, this has no periphery error (see :mod:`ds_msp.ldc` for the mesh
    alternative and its accuracy trade-off).

    Parameters
    ----------
    points : ndarray of shape (N, 2)
        Distorted pixels in the original (source) image.
    K_new : ndarray of shape (3, 3), optional
        Target pinhole intrinsics; see :meth:`maps`. Defaults to
        :meth:`new_K` at ``balance=0.5``.

    Returns
    -------
    uv : ndarray of shape (N, 2)
        Rectified pixel coordinates in the ``K_new`` frame.
    valid : ndarray of shape (N,), bool
        ``True`` where the model successfully unprojected the input pixel.
    """
    if K_new is None:
        K_new = self.new_K()
    rays, valid = self.model.unproject(np.asarray(points, dtype=np.float64))
    rays_n = rays / (rays[:, 2:3] + 1e-12)
    u = K_new[0, 0] * rays_n[:, 0] + K_new[0, 2]
    v = K_new[1, 1] * rays_n[:, 1] + K_new[1, 2]
    return np.stack([u, v], axis=-1), valid

cubemap_charts

cubemap_charts(face_size: int, overlap_deg: float = 1.0) -> List[TangentImage]

Build the six cube faces (+/-X, +/-Y, +/-Z) as tangent images.

Less polar distortion than equirectangular, and each face is a plain perspective view. A bare 90° face leaves a measure-zero gap at the cube corners (and a half-pixel sliver from the principal-point convention), so each face is widened by overlap_deg on every side, giving a small guard band that tiles the sphere seamlessly — the standard cubemap practice.

Parameters:

Name Type Description Default
face_size int

Width and height of each square face, pixels.

required
overlap_deg float

Extra field of view added on every side of the nominal 90° face, degrees, so adjacent faces overlap slightly instead of leaving gaps.

1.0

Returns:

Type Description
list of TangentImage

Six charts, one per cube face, in the fixed order +X, -X, +Y, -Y, +Z, -Z.

Source code in ds_msp/ops/reproject.py
def cubemap_charts(face_size: int, overlap_deg: float = 1.0) -> List[TangentImage]:
    """Build the six cube faces (+/-X, +/-Y, +/-Z) as tangent images.

    Less polar distortion than equirectangular, and each face is a plain perspective view. A
    bare 90° face leaves a measure-zero gap at the cube corners (and a half-pixel sliver from
    the principal-point convention), so each face is widened by ``overlap_deg`` on every side,
    giving a small guard band that tiles the sphere seamlessly — the standard cubemap practice.

    Parameters
    ----------
    face_size : int
        Width and height of each square face, pixels.
    overlap_deg : float, default=1.0
        Extra field of view added on every side of the nominal 90° face,
        degrees, so adjacent faces overlap slightly instead of leaving gaps.

    Returns
    -------
    list of TangentImage
        Six charts, one per cube face, in the fixed order ``+X, -X, +Y, -Y,
        +Z, -Z``.
    """
    fov = 90.0 + 2.0 * overlap_deg
    return [TangentImage(np.array(d, float), fov, face_size) for d in _CUBE_DIRS]

reproject_image

reproject_image(cam, img: ndarray, chart: Chart, interpolation: int = cv2.INTER_LINEAR) -> Tuple[np.ndarray, np.ndarray]

Resample a fisheye image into a chart (e.g. equirectangular, cubemap face).

Builds the lookup with :func:reproject_maps and applies it with cv2.remap.

Parameters:

Name Type Description Default
cam CameraModel

The calibrated model that captured img.

required
img ndarray of shape (H_src, W_src, ...)

Source image, any cv2.remap-compatible dtype/channel count.

required
chart Chart

The target chart; determines the output size (chart.shape).

required
interpolation int

OpenCV interpolation flag passed to cv2.remap.

cv2.INTER_LINEAR

Returns:

Name Type Description
out ndarray of shape (H, W, ...)

The resampled image, chart.shape in size. Pixels outside both the chart's and the camera's valid coverage are filled with cv2.BORDER_CONSTANT (zero).

valid ndarray of shape (H, W), bool

Per-pixel mask, True where out holds a real sample (see :func:reproject_maps).

Source code in ds_msp/ops/reproject.py
def reproject_image(cam, img: np.ndarray, chart: Chart,
                    interpolation: int = cv2.INTER_LINEAR
                    ) -> Tuple[np.ndarray, np.ndarray]:
    """Resample a fisheye image into a chart (e.g. equirectangular, cubemap face).

    Builds the lookup with :func:`reproject_maps` and applies it with ``cv2.remap``.

    Parameters
    ----------
    cam : CameraModel
        The calibrated model that captured ``img``.
    img : ndarray of shape (H_src, W_src, ...)
        Source image, any ``cv2.remap``-compatible dtype/channel count.
    chart : Chart
        The target chart; determines the output size (``chart.shape``).
    interpolation : int, default=cv2.INTER_LINEAR
        OpenCV interpolation flag passed to ``cv2.remap``.

    Returns
    -------
    out : ndarray of shape (H, W, ...)
        The resampled image, ``chart.shape`` in size. Pixels outside both the
        chart's and the camera's valid coverage are filled with
        ``cv2.BORDER_CONSTANT`` (zero).
    valid : ndarray of shape (H, W), bool
        Per-pixel mask, ``True`` where ``out`` holds a real sample (see
        :func:`reproject_maps`).
    """
    mapx, mapy, valid = reproject_maps(cam, chart)
    out = cv2.remap(img, mapx, mapy, interpolation, borderMode=cv2.BORDER_CONSTANT)
    return out, valid

reproject_maps

reproject_maps(cam, chart: Chart) -> Tuple[np.ndarray, np.ndarray, np.ndarray]

Build (mapx, mapy, valid) resampling lookups for a chart from a calibrated camera.

For every output pixel: chart pixel_to_ray -> cam.project -> source pixel. valid marks output pixels whose ray both exists in the chart and projects inside the camera's model-valid cone; their map entries are set to -1 so cv2.remap leaves them blank.

Parameters:

Name Type Description Default
cam CameraModel

Any calibrated model implementing project (see :class:ds_msp.core.contracts.CameraModel).

required
chart Chart

The output chart (e.g. :class:Equirectangular, :class:Pinhole) whose grid is being resampled from cam's image.

required

Returns:

Name Type Description
mapx ndarray of shape (H, W), float32

Source-image u coordinate for each output pixel, -1 where invalid.

mapy ndarray of shape (H, W), float32

Source-image v coordinate for each output pixel, -1 where invalid.

valid ndarray of shape (H, W), bool

True where the output pixel has a usable source sample.

Source code in ds_msp/ops/reproject.py
def reproject_maps(cam, chart: Chart) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Build ``(mapx, mapy, valid)`` resampling lookups for a chart from a calibrated camera.

    For every output pixel: chart ``pixel_to_ray`` -> ``cam.project`` -> source pixel. ``valid``
    marks output pixels whose ray both exists in the chart and projects inside the camera's
    model-valid cone; their map entries are set to ``-1`` so ``cv2.remap`` leaves them blank.

    Parameters
    ----------
    cam : CameraModel
        Any calibrated model implementing ``project`` (see
        :class:`ds_msp.core.contracts.CameraModel`).
    chart : Chart
        The output chart (e.g. :class:`Equirectangular`, :class:`Pinhole`) whose
        grid is being resampled from ``cam``'s image.

    Returns
    -------
    mapx : ndarray of shape (H, W), float32
        Source-image ``u`` coordinate for each output pixel, ``-1`` where invalid.
    mapy : ndarray of shape (H, W), float32
        Source-image ``v`` coordinate for each output pixel, ``-1`` where invalid.
    valid : ndarray of shape (H, W), bool
        ``True`` where the output pixel has a usable source sample.
    """
    h, w = chart.shape
    u, v = np.meshgrid(np.arange(w, dtype=np.float64), np.arange(h, dtype=np.float64))
    rays = chart.pixel_to_ray(u, v).reshape(-1, 3)
    pts, ok = cam.project(rays)
    valid = ok.reshape(h, w)
    mapx = pts[:, 0].reshape(h, w).astype(np.float32)
    mapy = pts[:, 1].reshape(h, w).astype(np.float32)
    mapx[~valid] = -1
    mapy[~valid] = -1
    return mapx, mapy, valid

solve_pnp

solve_pnp(model: CameraModel, object_points: ndarray, image_points: ndarray, method: int = cv2.SOLVEPNP_ITERATIVE) -> Tuple[bool, Optional[np.ndarray], Optional[np.ndarray]]

Estimate pose from 3D-2D correspondences for any fisheye/omni model.

Parameters:

Name Type Description Default
model CameraModel

Any model implementing the contract.

required
object_points (N, 3) world points.
required
image_points (N, 2) distorted pixels.
required

Returns:

Type Description
tuple

(success, rvec, tvec) with squeezed (3,) vectors, or (False, None, None) if fewer than 4 points survive the front-facing filter or the solve fails.

Source code in ds_msp/ops/pose.py
def solve_pnp(model: CameraModel, object_points: np.ndarray, image_points: np.ndarray,
              method: int = cv2.SOLVEPNP_ITERATIVE
              ) -> Tuple[bool, Optional[np.ndarray], Optional[np.ndarray]]:
    """Estimate pose from 3D-2D correspondences for any fisheye/omni model.

    Parameters
    ----------
    model : CameraModel
        Any model implementing the contract.
    object_points : (N, 3) world points.
    image_points : (N, 2) distorted pixels.

    Returns
    -------
    tuple
        ``(success, rvec, tvec)`` with squeezed ``(3,)`` vectors, or ``(False, None, None)``
        if fewer than 4 points survive the front-facing filter or the solve fails.
    """
    object_points = np.asarray(object_points, dtype=np.float64)
    image_points = np.asarray(image_points, dtype=np.float64)

    rays, valid = model.unproject(image_points)
    usable = valid & (rays[:, 2] > 1e-6)
    if not usable.all():
        object_points = object_points[usable]
        rays = rays[usable]
        if len(object_points) < 4:
            return False, None, None

    pts_norm = rays[:, :2] / rays[:, 2:3]
    success, rvec, tvec = cv2.solvePnP(
        object_points, pts_norm.astype(np.float64),
        np.eye(3, dtype=np.float64), np.zeros(5, dtype=np.float64), flags=method)
    if not success:
        return False, None, None
    return True, rvec.squeeze(), tvec.squeeze()

solve_pnp_ransac

solve_pnp_ransac(model: CameraModel, object_points: ndarray, image_points: ndarray, *, thresh_px: float = 3.0, max_iters: int = 300, confidence: float = 0.999, seed: int = 0, refine: bool = True) -> Tuple[bool, Optional[np.ndarray], Optional[np.ndarray], np.ndarray]

Outlier-robust PnP for any camera model.

Unlike :func:solve_pnp (a single non-robust solve), this rejects gross-outlier correspondences with RANSAC before estimating the pose, so a handful of mis-detected / mismatched points don't drag the result. The pixels are unprojected through the model (the same step :func:solve_pnp uses), then a RANSAC pose is fit on the normalized plane (:func:ds_msp.geometry.resection.ransac_pnp_normalized — a plane homography for a coplanar board, a 3x4 DLT otherwise); with refine=True the pose is polished on the consensus set.

Parameters:

Name Type Description Default
object_points (N, 3) world points.
required
image_points (N, 2) distorted pixels.
required
thresh_px inlier reprojection gate in pixels.
3.0

Returns:

Type Description
tuple

(success, rvec, tvec, inliers) where inliers is an (N,) boolean mask over image_points (False for points dropped as outliers or as non-front-facing rays). Like any plane-based PnP it uses front-facing (z > 0) correspondences only.

Source code in ds_msp/ops/pose.py
def solve_pnp_ransac(model: CameraModel, object_points: np.ndarray, image_points: np.ndarray,
                     *, thresh_px: float = 3.0, max_iters: int = 300,
                     confidence: float = 0.999, seed: int = 0, refine: bool = True
                     ) -> Tuple[bool, Optional[np.ndarray], Optional[np.ndarray], np.ndarray]:
    """Outlier-robust PnP for any camera model.

    Unlike :func:`solve_pnp` (a single non-robust solve), this rejects gross-outlier
    correspondences with RANSAC before estimating the pose, so a handful of mis-detected /
    mismatched points don't drag the result. The pixels are unprojected through the model
    (the same step :func:`solve_pnp` uses), then a RANSAC pose is fit on the normalized plane
    (:func:`ds_msp.geometry.resection.ransac_pnp_normalized` — a plane homography for a coplanar
    board, a 3x4 DLT otherwise); with ``refine=True`` the pose is polished on the consensus set.

    Parameters
    ----------
    object_points : (N, 3) world points.
    image_points : (N, 2) distorted pixels.
    thresh_px : inlier reprojection gate in pixels.

    Returns
    -------
    tuple
        ``(success, rvec, tvec, inliers)`` where ``inliers`` is an ``(N,)`` boolean mask over
        ``image_points`` (``False`` for points dropped as outliers or as non-front-facing
        rays). Like any plane-based PnP it uses front-facing (``z > 0``) correspondences only.
    """
    from ..geometry.resection import ransac_pnp_normalized          # NC robust engine

    object_points = np.asarray(object_points, dtype=np.float64)
    image_points = np.asarray(image_points, dtype=np.float64)
    n = len(object_points)
    rays, valid = model.unproject(image_points)
    usable = np.asarray(valid).ravel().astype(bool) & (rays[:, 2] > 1e-6)
    idx = np.flatnonzero(usable)
    if idx.size < 4:
        return False, None, None, np.zeros(n, bool)

    X = object_points[usable]
    pn = (rays[usable, :2] / rays[usable, 2:3]).astype(np.float64)
    focal = float(np.asarray(model.K, float)[0, 0])
    T, inl = ransac_pnp_normalized(X, pn, focal=focal, thresh_px=thresh_px,
                                   max_iters=max_iters, confidence=confidence, seed=seed)
    if T is None:
        return False, None, None, np.zeros(n, bool)

    rvec = cv2.Rodrigues(T[:3, :3])[0]
    tvec = T[:3, 3].reshape(3, 1)
    if refine and inl.sum() >= 4:
        ok, rv, tv = cv2.solvePnP(X[inl], pn[inl], np.eye(3, dtype=np.float64),
                                  np.zeros(5, dtype=np.float64), rvec.copy(), tvec.copy(),
                                  useExtrinsicGuess=True, flags=cv2.SOLVEPNP_ITERATIVE)
        if ok:
            rvec, tvec = rv, tv

    mask = np.zeros(n, bool)
    mask[idx[inl]] = True
    return True, rvec.squeeze(), tvec.squeeze(), mask