Repository: kornia/kornia

Issue:
Add `Hyperplane` api
## 🚀 Feature

Api to integrate `Eigen::Hyperplane` re-written in pure pytorch.
source: https://gitlab.com/libeigen/eigen/-/blob/master/Eigen/src/Geometry/Hyperplane.h

## Pitch

Part of the effort to support more `Eigen` primitives as part of the `kornia.geometry`.

______________________________________________________________________

#### Consider also to contribute to Kornia universe projects :)

<sub>

- [**Tutorials**](https://github.com/kornia/tutorials): our repository containing the tutorials.

</sub>


Discussion:

Patch:
diff --git a/docs/source/geometry.linalg.rst b/docs/source/geometry.linalg.rst
index 6e548ca4..47aee485 100644
--- a/docs/source/geometry.linalg.rst
+++ b/docs/source/geometry.linalg.rst
@@ -10,3 +10,4 @@ kornia.geometry.linalg
 .. autofunction:: transform_points
 .. autofunction:: point_line_distance
 .. autofunction:: squared_norm
+.. autofunction:: batched_dot_product
diff --git a/kornia/geometry/linalg.py b/kornia/geometry/linalg.py
index c70c8ea1..7133d734 100644
--- a/kornia/geometry/linalg.py
+++ b/kornia/geometry/linalg.py
@@ -1,7 +1,7 @@
 import torch
 from torch import Tensor
 
-from kornia.testing import KORNIA_CHECK_IS_TENSOR, check_is_tensor
+from kornia.testing import KORNIA_CHECK_IS_TENSOR, KORNIA_CHECK_SHAPE, check_is_tensor
 
 from .conversions import convert_points_from_homogeneous, convert_points_to_homogeneous
 
@@ -12,6 +12,7 @@ __all__ = [
     "transform_points",
     "point_line_distance",
     "squared_norm",
+    "batched_dot_product",
 ]
 
 
@@ -225,10 +226,20 @@ def point_line_distance(point: Tensor, line: Tensor, eps: float = 1e-9) -> Tenso
     return numerator / (denominator + eps)
 
 
-def squared_norm(x: Tensor) -> Tensor:
+def squared_norm(x: Tensor, keepdim: bool = False) -> Tensor:
     """Return the squared norm of a vector."""
-    return x.pow(2).sum(-1)
+    return (x * x).sum(-1, keepdim)
 
 
+def batched_dot_product(x: Tensor, y: Tensor, keepdim: bool = False) -> Tensor:
+    """Return a batched version of .dot()"""
+    KORNIA_CHECK_SHAPE(x, ["B", "N"])
+    KORNIA_CHECK_SHAPE(y, ["B", "N"])
+    return (x * y).sum(-1, keepdim)
+
+
+# aliases
+batched_squared_norm = squared_norm
+
 # TODO:
 # - project_points: from opencv
diff --git a/kornia/geometry/line.py b/kornia/geometry/line.py
index 14899818..05547174 100644
--- a/kornia/geometry/line.py
+++ b/kornia/geometry/line.py
@@ -4,21 +4,15 @@ from typing import Optional, Union
 
 import torch
 
-from kornia.core import Module, Parameter, Tensor, normalize
-from kornia.geometry.linalg import squared_norm
+from kornia.core import Module, Parameter, Tensor, normalize, where
+from kornia.geometry.linalg import batched_dot_product, squared_norm
+from kornia.geometry.plane import Hyperplane
 from kornia.testing import KORNIA_CHECK, KORNIA_CHECK_IS_TENSOR, KORNIA_CHECK_SHAPE
 from kornia.utils.helpers import _torch_svd_cast
 
 __all__ = ["ParametrizedLine", "fit_line"]
 
 
-# TODO: implement me: https://gitlab.com/libeigen/eigen/-/blob/master/Eigen/src/Geometry/Hyperplane.h
-class Hyperplane:
-    """Not implemented yet: https://gitlab.com/libeigen/eigen/-/blob/master/Eigen/src/Geometry/Hyperplane.h."""
-
-    pass
-
-
 class ParametrizedLine(Module):
     """Class that describes a parametrize line.
 
@@ -87,10 +81,6 @@ class ParametrizedLine(Module):
         """
         return ParametrizedLine(p0, normalize((p1 - p0), p=2, dim=-1))
 
-    @classmethod
-    def from_hyperplane(cls, plane: Hyperplane) -> "ParametrizedLine":
-        raise NotImplementedError(f"Plane not implemented yet {plane}.")
-
     def point_at(self, t: Union[float, Tensor]) -> Tensor:
         """The point at :math:`t` along this line.
 
@@ -140,6 +130,30 @@ class ParametrizedLine(Module):
     # - intersection_parameter
     # - intersection_point
 
+    # TODO: add tests, and possibly return a mask
+    def intersect(self, plane: Hyperplane, eps: float = 1e-6) -> Tensor:
+        """Return the intersection point between the line and a given plane.
+
+        Args:
+            plane: the plane to compute the intersection point.
+
+        Return:
+            - the lambda value used to compute the look at point.
+            - the intersected point.
+        """
+        dot_prod = batched_dot_product(plane.normal, self.direction)
+        dot_prod_mask = dot_prod.abs() >= eps
+
+        # TODO: add check for dot product
+        res_lambda = where(
+            dot_prod_mask,
+            -(plane.offset + batched_dot_product(plane.normal, self.origin)) / dot_prod,
+            torch.empty_like(dot_prod),
+        )
+
+        res_point = self.point_at(res_lambda)
+        return res_lambda, res_point
+
 
 def fit_line(points: Tensor, weights: Optional[Tensor] = None) -> ParametrizedLine:
     """Fit a line from a set of points.
diff --git a/kornia/geometry/plane.py b/kornia/geometry/plane.py
new file mode 100644
index 00000000..941567d5
--- /dev/null
+++ b/kornia/geometry/plane.py
@@ -0,0 +1,106 @@
+# kornia.geometry.plane module inspired by Eigen::geometry::Hyperplane
+# https://gitlab.com/libeigen/eigen/-/blob/master/Eigen/src/Geometry/Hyperplane.h
+
+from typing import Optional
+
+from kornia.core import Module, Tensor, normalize
+from kornia.geometry.linalg import batched_dot_product
+from kornia.testing import KORNIA_CHECK_SHAPE
+from kornia.utils.helpers import _torch_svd_cast
+
+__all__ = ["Hyperplane", "fit_plane"]
+
+
+# NOTE: in the near future the constructor will change
+# class Hyperplane(Module):
+#     def __init__(self, n: _VectorType, d: _VectorType) -> None:
+#         super().__init__()
+#         self._n = n
+#         self._d = d
+
+
+class Hyperplane(Module):
+    def __init__(self, n: Tensor, d: Tensor) -> None:
+        super().__init__()
+        KORNIA_CHECK_SHAPE(n, ["B", "*"])
+        KORNIA_CHECK_SHAPE(d, ["B"])
+        self._n = n
+        self._d = d
+
+    def __str__(self) -> str:
+        return f"Normal: {self.normal}\nOffset: {self.offset}"
+
+    def __repr__(self) -> str:
+        return str(self)
+
+    def __getitem__(self, idx) -> Tensor:
+        return self.normal if idx == 0 else self.offset
+
+    def __iter__(self):
+        yield from (self.normal, self.offset)
+
+    @property
+    def normal(self) -> Tensor:
+        return self._n
+
+    @property
+    def offset(self) -> Tensor:
+        return self._d
+
+    @classmethod
+    def from_vector(self, n: Tensor, e: Tensor) -> "Hyperplane":
+        normal = n
+        offset = -batched_dot_product(normal, e)
+        return Hyperplane(normal, offset)
+
+    @classmethod
+    def through(cls, p0: Tensor, p1: Tensor, p2: Optional[Tensor] = None) -> "Hyperplane":
+        # 2d case
+        if p2 is None:
+            KORNIA_CHECK_SHAPE(p0, ["B", "2"])
+            KORNIA_CHECK_SHAPE(p1, ["B", "2"])
+            # TODO: implement `.unitOrthonormal`
+            normal = normalize((p1 - p0), p=2, dim=-1)
+            offset = -batched_dot_product(p0, normal)
+            return Hyperplane(normal, offset)
+        # 3d case
+        KORNIA_CHECK_SHAPE(p0, ["B", "3"])
+        KORNIA_CHECK_SHAPE(p1, ["B", "3"])
+        KORNIA_CHECK_SHAPE(p2, ["B", "3"])
+        v0, v1 = (p2 - p1), (p1 - p0)
+        normal = v0.cross(v1)
+        norm = normal.norm(-1)
+        # TODO: use where
+        # https://gitlab.com/libeigen/eigen/-/blob/master/Eigen/src/Geometry/Hyperplane.h#L108
+        # general case
+        normal = normal / (norm + 1e-6)
+        offset = -batched_dot_product(p0, normal)
+        return Hyperplane(normal, offset)
+
+
+# TODO: factor to avoid duplicated from line.py
+# https://github.com/strasdat/Sophus/blob/23.04-beta/cpp/sophus/geometry/fit_plane.h
+def fit_plane(points: Tensor) -> Hyperplane:
+    """Fit a plane from a set of points using SVD.
+
+    Args:
+        points: tensor containing a batch of sets of n-dimensional points. The  expected
+            shape of the tensor is :math:`(B, N, D)`.
+
+    Return:
+        The computed hyperplane object.
+    """
+    KORNIA_CHECK_SHAPE(points, ["B", "N", "D"])
+
+    mean = points.mean(-2, True)
+    points_centered = points - mean
+
+    # NOTE: not optimal for 2d points, but for now works for other dimensions
+    _, _, V = _torch_svd_cast(points_centered)
+    V = V.transpose(-2, -1)
+
+    # the first left eigenvector is the direction on the fited line
+    direction = V[..., 0, :]  # BxD
+    origin = mean[..., 0, :]  # BxD
+
+    return Hyperplane.from_vector(direction, origin)
diff --git a/kornia/geometry/ray.py b/kornia/geometry/ray.py
new file mode 100644
index 00000000..9fd0637f
--- /dev/null
+++ b/kornia/geometry/ray.py
@@ -0,0 +1,5 @@
+from kornia.geometry.line import ParametrizedLine
+
+# NOTE: for now lets make an alias
+# TODO: add more functionality and semantics for graphics stuff
+Ray = ParametrizedLine
diff --git a/kornia/geometry/vector.py b/kornia/geometry/vector.py
new file mode 100644
index 00000000..04126eb8
--- /dev/null
+++ b/kornia/geometry/vector.py
@@ -0,0 +1,78 @@
+from typing import List
+
+from kornia.core import Module, Parameter, Tensor, normalize, rand
+from kornia.geometry.linalg import batched_dot_product
+from kornia.geometry.linalg import batched_squared_norm as _squared_norm
+from kornia.testing import KORNIA_CHECK_SHAPE
+
+__all__ = ["UnitVector"]
+
+
+# TODO: model _Scalar
+class _ScalarType:
+    pass
+
+
+# NOTE: not very clear if this is a module or custom Tensor
+# tensor subclass doesn't like to override methods
+class _VectorType(Module):
+    def __init__(self, vector: Tensor) -> None:
+        super().__init__()
+        KORNIA_CHECK_SHAPE(vector, ["B", "N"])
+        self._vector = Parameter(vector)
+
+    @property
+    def data(self) -> Tensor:
+        return self._vector
+
+    @property
+    def device(self):
+        return self.data.device
+
+    @property
+    def dtype(self):
+        return self.data.dtype
+
+    @property
+    def shape(self):
+        return self.data.shape
+
+    def __repr__(self) -> str:
+        return f"{self.data}"
+
+    @classmethod
+    def random(cls, shape: List[int], device=None, dtype=None) -> "_VectorType":
+        return cls(rand(shape, device=device, dtype=dtype))
+
+    def normalized(self) -> "_VectorType":
+        vec_norm = normalize(self._vector, p=2, dim=-1)
+        return _VectorType(vec_norm)
+
+    def squared_norm(self) -> "_VectorType":
+        return _VectorType(_squared_norm(self.data))
+
+    def dot(self, right: "_VectorType") -> "_VectorType":
+        if len(right.shape) == 1:
+            return _VectorType(self.data.dot(right.data))
+        return _VectorType(batched_dot_product(self.data, right.data))
+
+
+class UnitVector(Module):
+    def __init__(self, vector: Tensor) -> None:
+        super().__init__()
+        KORNIA_CHECK_SHAPE(vector, ["B", "N"])
+        self._vector = Parameter(vector)
+
+    @property
+    def vector(self) -> Tensor:
+        return self._vector
+
+    @classmethod
+    def from_unit_vector(cls, v: Tensor) -> "UnitVector":
+        # TODO: add checks https://github.com/strasdat/Sophus/blob/23.04-beta/cpp/sophus/geometry/ray.h#L59
+        return UnitVector(_VectorType(v))
+
+    @classmethod
+    def from_vector(cls, v: Tensor) -> "UnitVector":
+        """From a vector and normalize."""
+        return UnitVector(_VectorType(v).normalized())


Test patch:
diff --git a/kornia/testing/__init__.py b/kornia/testing/__init__.py
index eb6a328e..4499d538 100644
--- a/kornia/testing/__init__.py
+++ b/kornia/testing/__init__.py
@@ -5,10 +5,9 @@ import math
 from abc import ABC, abstractmethod
 from copy import deepcopy
 from itertools import product
-from typing import Any, Dict, List, Optional, cast
+from typing import Any, Dict, List, Optional, Tuple, cast
 
 import torch
-from torch.nn import Parameter
 
 from kornia.core import Tensor
 
@@ -146,9 +145,9 @@ class BaseTester(ABC):
                 This parameter allows to reduce tolerance. Half the decimal places.
                 Example, 1e-4 -> 1e-2 or 1e-6 -> 1e-3
         """
-        if isinstance(actual, Parameter):
+        if hasattr(actual, "data"):
             actual = actual.data
-        if isinstance(expected, Parameter):
+        if hasattr(expected, "data"):
             expected = expected.data
 
         if 'xla' in actual.device.type or 'xla' in expected.device.type:
@@ -314,6 +313,7 @@ def KORNIA_CHECK_SHAPE(x, shape: List[str]) -> None:
     # Desired shape here is list and not tuple, because torch.jit
     # does not like variable-length tuples
     KORNIA_CHECK_IS_TENSOR(x)
+
     if '*' == shape[0]:
         start_idx: int = 1
         x_shape_to_check = x.shape[-len(shape) + 1 :]
@@ -332,7 +332,7 @@ def KORNIA_CHECK_SHAPE(x, shape: List[str]) -> None:
             continue
         dim = int(dim_)
         if x_shape_to_check[i] != dim:
-            raise TypeError(f"{x} shape should be must be [{shape}]. Got {x.shape}")
+            raise TypeError(f"{x} shape must be [{shape}]. Got {x.shape}")
 
 
 def KORNIA_CHECK(condition: bool, msg: Optional[str] = None) -> None:
diff --git a/test/geometry/test_line.py b/test/geometry/test_line.py
index 43b4ce90..e7d7c1c1 100644
--- a/test/geometry/test_line.py
+++ b/test/geometry/test_line.py
@@ -3,6 +3,7 @@ import torch
 from torch.autograd import gradcheck
 
 from kornia.geometry.line import ParametrizedLine, fit_line
+from kornia.geometry.plane import Hyperplane
 from kornia.testing import BaseTester
 
 
@@ -73,6 +74,24 @@ class TestParametrizedLine(BaseTester):
         distance_expected = torch.tensor(16.0, device=device, dtype=dtype)
         self.assert_close(l1.squared_distance(point), distance_expected)
 
+    def test_instersect_plane(self, device, dtype):
+        p0 = torch.tensor([0.0, 0.0, 0.0], device=device, dtype=dtype)
+        p1 = torch.tensor([1.0, 0.0, 0.0], device=device, dtype=dtype)
+        l1 = ParametrizedLine.through(p0, p1)
+
+        v0 = torch.tensor([3.0, 0.0, 1.0], device=device, dtype=dtype)
+        v1 = torch.tensor([3.0, 1.0, 0.0], device=device, dtype=dtype)
+        v2 = torch.tensor([3.0, 0.0, -1.0], device=device, dtype=dtype)
+        pl0 = Hyperplane.through(v0, v1, v2)
+
+        lmbda, point = l1.intersect(pl0)
+
+        expected_point = torch.tensor([3.0, 0.0, 0.0], device=device, dtype=dtype)
+        expected_lambda = torch.tensor(3.0, device=device, dtype=dtype)
+
+        self.assert_close(lmbda, expected_lambda)
+        self.assert_close(point, expected_point)
+
     @pytest.mark.skip(reason="not implemented yet")
     def test_cardinality(self, device, dtype):
         pass
diff --git a/test/geometry/test_plane.py b/test/geometry/test_plane.py
new file mode 100644
index 00000000..1285b0b4
--- /dev/null
+++ b/test/geometry/test_plane.py
@@ -0,0 +1,120 @@
+import pytest
+import torch
+
+from kornia.geometry.plane import Hyperplane, fit_plane
+from kornia.geometry.vector import _VectorType
+from kornia.testing import BaseTester
+
+
+# TODO: implement the rest of methods
+class TestFitPlane(BaseTester):
+    @pytest.mark.parametrize("B", (1, 2))
+    @pytest.mark.parametrize("D", (2, 3, 4))
+    def test_smoke(self, device, dtype, B, D):
+        N: int = 10  # num points
+        points = torch.ones(B, N, D, device=device, dtype=dtype)
+        plane = fit_plane(points)
+        assert isinstance(plane, Hyperplane)
+        assert plane.offset.shape == (B,)
+        assert plane.normal.shape == (B, D)
+
+        assert (plane.normal == plane[0]).all()
+        assert (plane.offset == plane[1]).all()
+
+        normal, offset = fit_plane(points)
+        assert (plane.normal == normal).all()
+        assert (plane.offset == offset).all()
+
+    @pytest.mark.skip(reason="not implemented yet")
+    def test_cardinality(self, device, dtype):
+        pass
+
+    @pytest.mark.skip(reason="not implemented yet")
+    def test_jit(self, device, dtype):
+        pass
+
+    @pytest.mark.skip(reason="not implemented yet")
+    def test_exception(self, device, dtype):
+        pass
+
+    @pytest.mark.skip(reason="not implemented yet")
+    def test_module(self, device, dtype):
+        pass
+
+    @pytest.mark.skip(reason="not implemented yet")
+    def test_gradcheck(self, device):
+        pass
+
+
+# TODO: implement the rest of methods
+class TestHyperplane(BaseTester):
+    @pytest.mark.parametrize("batch_size", [1, 2])
+    @pytest.mark.parametrize("dim", (2, 3))
+    def test_smoke(self, device, dtype, batch_size, dim):
+        p0 = _VectorType.random((batch_size, dim), device, dtype)
+        n0 = _VectorType.random((batch_size, dim), device, dtype).normalized()
+        pl0 = Hyperplane.from_vector(n0.data, p0.data)
+        # TODO: improve api so that we can accept Vector too
+        assert pl0.offset.shape == (batch_size,)
+        assert pl0.normal.shape == (batch_size, dim)
+
+    def test_dot_unbatched(self, device, dtype):
+        p0 = _VectorType.random((3,), device, dtype)
+        n0 = _VectorType.random((3,), device, dtype).normalized()
+        assert p0.dot(n0).shape == ()
+
+    @pytest.mark.parametrize("batch_size", [1, 2])
+    @pytest.mark.parametrize("dim", (2, 3))
+    def test_dot_batched(self, device, dtype, batch_size, dim):
+        p0 = _VectorType.random((batch_size, dim), device, dtype)
+        n0 = _VectorType.random((batch_size, dim), device, dtype).normalized()
+        assert p0.dot(n0).shape == (batch_size,)
+
+    def test_sqn_unbatched(self, device, dtype):
+        p0 = _VectorType.random((3,), device, dtype).normalized()
+        assert p0.squared_norm().shape == ()
+
+    @pytest.mark.parametrize("batch_size", [1, 2])
+    @pytest.mark.parametrize("dim", (2, 3))
+    def test_sqn_batched(self, device, dtype, batch_size, dim):
+        n0 = _VectorType.random((batch_size, dim), device, dtype).normalized()
+        assert n0.squared_norm().shape == (batch_size,)
+
+    @pytest.mark.parametrize("batch_size", [1, 2])
+    def test_through_two(self, device, dtype, batch_size):
+        v0 = _VectorType.random((batch_size, 2), device, dtype)
+        v1 = _VectorType.random((batch_size, 2), device, dtype)
+        # TODO: improve api so that we can accept Vector too
+        p0 = Hyperplane.through(v0.data, v1.data)
+        assert p0.offset.shape == (batch_size,)
+        assert p0.normal.shape == (batch_size, 2)
+
+    @pytest.mark.parametrize("batch_size", [1, 2])
+    def test_through_three(self, device, dtype, batch_size):
+        v0 = _VectorType.random((batch_size, 3), device, dtype)
+        v1 = _VectorType.random((batch_size, 3), device, dtype)
+        v2 = _VectorType.random((batch_size, 3), device, dtype)
+        # TODO: improve api so that we can accept Vector too
+        p0 = Hyperplane.through(v0.data, v1.data, v2.data)
+        assert p0.offset.shape == (batch_size,)
+        assert p0.normal.shape == (batch_size, 3)
+
+    @pytest.mark.skip(reason="not implemented yet")
+    def test_cardinality(self, device, dtype):
+        pass
+
+    @pytest.mark.skip(reason="not implemented yet")
+    def test_jit(self, device, dtype):
+        pass
+
+    @pytest.mark.skip(reason="not implemented yet")
+    def test_exception(self, device, dtype):
+        pass
+
+    @pytest.mark.skip(reason="not implemented yet")
+    def test_module(self, device, dtype):
+        pass
+
+    @pytest.mark.skip(reason="not implemented yet")
+    def test_gradcheck(self, device):
+        pass