Repository: kornia/kornia

Issue:
Round trip `torch.save` / `torch.load` not working for `Hyperplane`
### Describe the bug

Crashing when trying to save and reload a `Hyperplane` using `torch`:

```
Traceback (most recent call last):
  File "save_hyperplane.py", line 16, in <module>
    plane = torch.load("./saved_plane.pt")
  File "/home/kyle/venv/lib/python3.8/site-packages/torch/serialization.py", line 789, in load
    return _load(opened_zipfile, map_location, pickle_module, **pickle_load_args)
  File "/home/kyle/venv/lib/python3.8/site-packages/torch/serialization.py", line 1131, in _load
    result = unpickler.load()
  File "/home/kyle/venv/lib/python3.8/site-packages/kornia/core/tensor_wrapper.py", line 48, in __getattr__
    self.used_attrs.add(name)
  File "/home/kyle/venv/lib/python3.8/site-packages/kornia/core/tensor_wrapper.py", line 48, in __getattr__
    self.used_attrs.add(name)
  File "/home/kyle/venv/lib/python3.8/site-packages/kornia/core/tensor_wrapper.py", line 48, in __getattr__
    self.used_attrs.add(name)
  [Previous line repeated 993 more times]
RecursionError: maximum recursion depth exceeded
```

### Reproduction steps

```bash
$ python3 -m venv venv
$ source venv/bin/activate
(venv) $ pip install kornia
(venv) $ pip install numpy
(venv) $ python save_hyperplane.py
```

Content of save_hyperplane.py

```
from kornia.geometry.plane import Hyperplane
from kornia.geometry.vector import Vec3, Scalar
import torch

plane = Hyperplane(
    Vec3(torch.tensor([0,0,1])), Scalar(torch.tensor(0.5))
)

torch.save(plane, "./saved_plane.pt")
plane = torch.load("./saved_plane.pt")
```

### Expected behavior

Hyperplane is able to be saved and reloaded with `torch.save` & `torch.load` 

### Environment

```shell
Collecting environment information...
PyTorch version: 1.13.1+cu117
Is debug build: False
CUDA used to build PyTorch: 11.7
ROCM used to build PyTorch: N/A

OS: Ubuntu 20.04.5 LTS (x86_64)
GCC version: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0
Clang version: Could not collect
CMake version: version 3.16.3
Libc version: glibc-2.31

Python version: 3.8.10 (default, Nov 14 2022, 12:59:47)  [GCC 9.4.0] (64-bit runtime)
Python platform: Linux-5.15.0-56-generic-x86_64-with-glibc2.29
Is CUDA available: True
CUDA runtime version: Could not collect
CUDA_MODULE_LOADING set to: LAZY
GPU models and configuration: GPU 0: NVIDIA GeForce RTX 3080 Laptop GPU
Nvidia driver version: 470.141.03
cuDNN version: Could not collect
HIP runtime version: N/A
MIOpen runtime version: N/A
Is XNNPACK available: True

Versions of relevant libraries:
[pip3] numpy==1.24.1
[pip3] torch==1.13.1
[conda] Could not collect
```

### Additional context

_No response_


Discussion:
@Hackerman342 will take a look soon. This i guess is after introducing Vector3 and Scalar types

Resolving patch:
diff --git a/kornia/core/tensor_wrapper.py b/kornia/core/tensor_wrapper.py
--- a/kornia/core/tensor_wrapper.py
+++ b/kornia/core/tensor_wrapper.py
@@ -35,15 +35,22 @@ def __init__(self, data: Tensor) -> None:
         self.__dict__["used_attrs"] = set()
         self.__dict__["used_calls"] = set()
 
-    @property
-    def data(self) -> Tensor:
-        return self._data
+    def unwrap(self):
+        return unwrap(self)
+
+    def __getstate__(self):
+        return self.__dict__
+
+    def __setstate__(self, state):
+        self.__dict__.update(state)
 
     def __repr__(self) -> str:
-        return f"{self.data}"
+        return f"{self._data}"
 
     def __getattr__(self, name):
-        if name in self.__dict__:
+        if name == "data":
+            return self._data
+        elif name in self.__dict__:
             return self.__dict__[name]
         self.used_attrs.add(name)
 


Test patch:
diff --git a/test/core/test_tensor_wrapper.py b/test/core/test_tensor_wrapper.py
--- a/test/core/test_tensor_wrapper.py
+++ b/test/core/test_tensor_wrapper.py
@@ -15,20 +15,36 @@ def test_smoke(self, device, dtype):
         assert tensor.shape == (1, 2, 3, 4)
         assert tensor.device == device
         assert tensor.dtype == dtype
-        self.assert_close(data, tensor.data)
-        self.assert_close(data, unwrap(tensor))
+        self.assert_close(data, tensor.unwrap())
+
+    def test_serialization(self, device, dtype, tmp_path):
+        data = torch.rand(1, 2, 3, 4, device=device, dtype=dtype)
+        tensor: TensorWrapper = wrap(data, TensorWrapper)
+
+        file_path = tmp_path / "tensor.pt"
+        torch.save(tensor, file_path)
+        file_path.is_file()
+
+        loaded_tensor: TensorWrapper = torch.load(file_path)
+        assert isinstance(loaded_tensor, TensorWrapper)
+
+        self.assert_close(loaded_tensor.unwrap(), tensor.unwrap())
 
     def test_wrap_list(self, device, dtype):
         data_list = [torch.rand(2, device=device, dtype=dtype), torch.rand(3, device=device, dtype=dtype)]
         tensor_list = wrap(data_list, TensorWrapper)
         assert isinstance(tensor_list, list)
         assert len(tensor_list) == 2
+
         tensor_list_data = unwrap(tensor_list)
         assert len(tensor_list_data) == 2
+
         self.assert_close(tensor_list_data[0], data_list[0])
         self.assert_close(tensor_list_data[1], data_list[1])
 
-    # TODO: implement me
+        for i in range(len(tensor_list_data)):
+            self.assert_close(tensor_list[i].unwrap(), data_list[i])
+
     def test_accessors(self, device, dtype):
         data = torch.tensor([0.0, 1.0, 0.0], device=device, dtype=dtype)
         x = wrap(data, TensorWrapper)
diff --git a/test/geometry/test_plane.py b/test/geometry/test_plane.py
--- a/test/geometry/test_plane.py
+++ b/test/geometry/test_plane.py
@@ -49,6 +49,18 @@ def test_smoke(self, device, dtype, shape):
         assert pl0.normal.shape == shape or () + (3,)
         assert pl0.offset.shape == (shape + () if shape is not None else ())
 
+    def test_serialization(self, device, dtype, tmp_path):
+        p = Vector3.random((), device, dtype)
+        n = Vector3.random((), device, dtype).normalized()
+        plane = Hyperplane.from_vector(n, p)
+
+        file_path = tmp_path / "plane.pt"
+        torch.save(plane, file_path)
+        assert file_path.is_file()
+
+        loaded_plane = torch.load(file_path)
+        self.assert_close(plane.normal.unwrap(), loaded_plane.normal.unwrap())
+
     # TODO: implement `Vector2`
     # @pytest.mark.parametrize("batch_size", [1, 2])
     # def test_through_two(self, device, dtype, batch_size):