From a1f12f70b3047fe9ccb492ab78ee5db555760cf2 Mon Sep 17 00:00:00 2001
From: "Olivier J.N. Bertrand" <olivier.bertrand@uni-bielefeld.de>
Date: Tue, 19 Jun 2018 17:44:17 +0200
Subject: [PATCH] Merger tra3dpy -> navipy Add plots of frame Add marker
 functionality to decompose 3 markers into x,y,z yaw pitch roll

---
 navipy/markers/__init__.py        |   1 +
 navipy/markers/test_markers.py    | 132 +++++++++++++
 navipy/markers/test_triangle.py   | 107 ++++++++++
 navipy/markers/tools.py           |  41 ++++
 navipy/markers/transformations.py | 319 ++++++++++++++++++++++++++++++
 navipy/markers/triangle.py        | 179 +++++++++++++++++
 navipy/plots/__init__.py          |  46 +++++
 7 files changed, 825 insertions(+)
 create mode 100644 navipy/markers/__init__.py
 create mode 100644 navipy/markers/test_markers.py
 create mode 100644 navipy/markers/test_triangle.py
 create mode 100644 navipy/markers/tools.py
 create mode 100644 navipy/markers/transformations.py
 create mode 100644 navipy/markers/triangle.py
 create mode 100644 navipy/plots/__init__.py

diff --git a/navipy/markers/__init__.py b/navipy/markers/__init__.py
new file mode 100644
index 0000000..5bb534f
--- /dev/null
+++ b/navipy/markers/__init__.py
@@ -0,0 +1 @@
+# package
diff --git a/navipy/markers/test_markers.py b/navipy/markers/test_markers.py
new file mode 100644
index 0000000..6ccc2a3
--- /dev/null
+++ b/navipy/markers/test_markers.py
@@ -0,0 +1,132 @@
+import unittest
+import numpy as np
+import pandas as pd
+from tra3dpy.maths.homogeneous_transformations import compose_matrix
+import tra3dpy.markers.transformations as mtf
+from tra3dpy.markers.triangle import Triangle
+
+
+class TestMarkersTransform(unittest.TestCase):
+    def setUp(self):
+        # Build an equilateral triangle
+        self.markers = pd.Series(data=0,
+                                 index=pd.MultiIndex.from_product(
+                                     [[0, 1, 2], ['x', 'y', 'z']]))
+        self.markers.loc[(0, 'x')] = -1
+        self.markers.loc[(2, 'y')] = np.sin(np.pi / 3)
+        self.markers.loc[(1, 'y')] = -np.sin(np.pi / 3)
+        self.markers.loc[(1, 'x')] = np.cos(np.pi / 3)
+        self.markers.loc[(2, 'x')] = np.cos(np.pi / 3)
+        self.equilateral = Triangle(self.markers.loc[0],
+                                    self.markers.loc[1],
+                                    self.markers.loc[2])
+
+    def random_apex(self):
+        return pd.Series(data=np.random.rand(3),
+                         index=['x', 'y', 'z'])
+
+    def test_normalise_vec(self):
+        vec = np.random.rand(10)
+        vec = mtf.normalise_vec(vec)
+        self.assertAlmostEqual(np.linalg.norm(vec), 1)
+
+    def test_triangle2bodyaxis(self):
+        for cmode in ['x-axis=median-from-0',
+                      'y-axis=1-2']:
+            origin, x_axis, y_axis, z_axis = \
+                mtf.triangle2bodyaxis(self.equilateral,
+                                      mode=cmode)
+            np.testing.assert_allclose(origin, [0, 0, 0],
+                                       atol=1e-07,
+                                       err_msg='origin&mode '+cmode)
+            np.testing.assert_allclose(x_axis, [1, 0, 0],
+                                       atol=1e-07,
+                                       err_msg='x-axis&mode '+cmode)
+            np.testing.assert_allclose(y_axis, [0, 1, 0],
+                                       atol=1e-07,
+                                       err_msg='y-axis&mode '+cmode)
+            np.testing.assert_allclose(z_axis, [0, 0, 1],
+                                       atol=1e-07,
+                                       err_msg='z-axis&mode '+cmode)
+
+    def test_triangle2bodyaxis_wrongmode(self):
+        """Certain mode are not supported"""
+        apex0 = self.random_apex()
+        apex1 = self.random_apex()
+        apex2 = self.random_apex()
+        triangle = Triangle(apex0, apex1, apex2)
+        mode = 'Thisisafakemodeandshouldfail'
+        with self.assertRaises(KeyError):
+            mtf.triangle2bodyaxis(triangle, mode)
+
+    def test_triangle2bodyaxis_cross(self):
+        """The cross product of x-axis and y-axis should \
+        be the same than the z-axis"""
+        apex0 = self.random_apex()
+        apex1 = self.random_apex()
+        apex2 = self.random_apex()
+        triangle = Triangle(apex0, apex1, apex2)
+        for mode in mtf._modes:
+            _, x_axis, y_axis, z_axis = \
+                mtf.triangle2bodyaxis(triangle, mode)
+            np.testing.assert_almost_equal(np.cross(x_axis, y_axis), z_axis)
+
+    def test_triangle2bodyaxis_ortho(self):
+        """Determinant of frame should be 1, it is a rotation matrix"""
+        apex0 = self.random_apex()
+        apex1 = self.random_apex()
+        apex2 = self.random_apex()
+        triangle = Triangle(apex0, apex1, apex2)
+        for mode in mtf._modes:
+            _, x_axis, y_axis, z_axis = \
+                mtf.triangle2bodyaxis(triangle, mode)
+            frame = mtf.bodyaxistransformations(
+                x_axis, y_axis, z_axis)
+            np.testing.assert_almost_equal(np.linalg.det(frame), 1)
+
+    def test_markers2decompose(self):
+        apex0 = self.random_apex()
+        apex1 = self.random_apex()
+        apex2 = self.random_apex()
+        euler_axes = 'rxyz'
+        for triangle_mode in mtf._modes:
+            scale, shear, angles, translate, perspective =\
+                mtf.markers2decompose(
+                    apex0, apex1, apex2, triangle_mode, euler_axes)
+            np.testing.assert_almost_equal(scale, [1, 1, 1])
+            np.testing.assert_almost_equal(shear, [0, 0, 0])
+            np.testing.assert_almost_equal(perspective, [0, 0, 0, 1])
+
+    def test_twomarker2euler(self):
+        angles = np.pi*(np.random.rand(3) - 0.5)
+        angles[0] *= 2
+        # angle[1] is not multipliy because in range [-pi/2,pi/2]
+        angles[2] *= 2
+        mark0 = pd.Series(data=0, index=['x', 'y', 'z'])
+        for axis_alignement, euler_axes, known_angle in zip(
+                ['x-axis', 'z-axis', 'y-axis'],
+                ['rzyx', 'ryxz', 'rzxy'],
+                [angles[2], angles[2], angles[2]]):
+
+            triangle_mode = 'x-axis=median-from-0'
+            transform = compose_matrix(angles=angles,
+                                       axes=euler_axes)
+            equilateral = Triangle(self.markers.loc[0],
+                                   self.markers.loc[1],
+                                   self.markers.loc[2])
+            equilateral.transform(transform)
+            origin, x_axis, y_axis, z_axis = mtf.triangle2bodyaxis(
+                equilateral, triangle_mode)
+            if axis_alignement == 'x-axis':
+                mark1 = pd.Series(x_axis, index=['x', 'y', 'z'])
+            elif axis_alignement == 'y-axis':
+                mark1 = pd.Series(y_axis, index=['x', 'y', 'z'])
+            elif axis_alignement == 'z-axis':
+                mark1 = pd.Series(z_axis, index=['x', 'y', 'z'])
+            angles_estimate = mtf.twomarkers2euler(
+                mark0, mark1, axis_alignement, known_angle, euler_axes)
+            np.testing.assert_almost_equal(angles, angles_estimate)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/navipy/markers/test_triangle.py b/navipy/markers/test_triangle.py
new file mode 100644
index 0000000..76a06b9
--- /dev/null
+++ b/navipy/markers/test_triangle.py
@@ -0,0 +1,107 @@
+"""
+"""
+import unittest
+import numpy as np
+import pandas as pd
+from tra3dpy.markers.triangle import Triangle
+
+
+class TestTriangle(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        cls.apex0 = pd.Series(data=np.random.rand(3),
+                              index=['x', 'y', 'z'],
+                              dtype=float)
+        cls.apex1 = pd.Series(data=np.random.rand(3),
+                              index=['x', 'y', 'z'],
+                              dtype=float)
+        cls.apex2 = pd.Series(data=np.random.rand(3),
+                              index=['x', 'y', 'z'],
+                              dtype=float)
+
+    def test_init_notpandas_first(self):
+
+        with self.assertRaises(TypeError):
+            Triangle(np.random.rand(3), self.apex1, self.apex2)
+
+    def test_init_notpandas_second(self):
+        with self.assertRaises(TypeError):
+            Triangle(self.apex0, np.random.rand(3), self.apex2)
+
+    def test_init_notarray_third(self):
+        with self.assertRaises(TypeError):
+            Triangle(self.apex0, self.apex1, np.random.rand(3))
+
+    def test_init_not3el_first(self):
+        apex_false = pd.Series(index=['x', 'y'])
+        with self.assertRaises(IOError):
+            Triangle(apex_false, self.apex1, self.apex2)
+
+    def test_init_not3el_second(self):
+        apex_false = pd.Series(index=['x', 'y'])
+        with self.assertRaises(IOError):
+            Triangle(self.apex0, apex_false, self.apex2)
+
+    def test_init_not3el_third(self):
+        apex_false = pd.Series(index=['x', 'y'])
+        with self.assertRaises(IOError):
+            Triangle(self.apex0, self.apex1, apex_false)
+
+    def test_init_notfloat_first(self):
+        apex_false = pd.Series(index=['x', 'y', 'z'],
+                               dtype=object)
+        with self.assertRaises(IOError):
+            Triangle(apex_false, self.apex1, self.apex2)
+
+    def test_init_notfloat_second(self):
+        apex_false = pd.Series(index=['x', 'y', 'z'],
+                               dtype=object)
+        with self.assertRaises(IOError):
+            Triangle(self.apex0, apex_false, self.apex2)
+
+    def test_init_notfloat_third(self):
+        apex_false = pd.Series(index=['x', 'y', 'z'],
+                               dtype=object)
+        with self.assertRaises(IOError):
+            Triangle(self.apex0, self.apex1, apex_false)
+
+    def test_init(self):
+        triangle = Triangle(self.apex0, self.apex1, self.apex2)
+        condition = np.allclose(triangle.apexes.apex0, self.apex0)
+        condition &= np.allclose(triangle.apexes.apex1, self.apex1)
+        condition &= np.allclose(triangle.apexes.apex2, self.apex2)
+        self.assertTrue(condition)
+
+    def test_center_of_mass(self):
+        triangle = Triangle(self.apex0, self.apex1, self.apex2)
+        cm = triangle.center_of_mass()
+        cm_test = (self.apex0 + self.apex1 + self.apex2) / 3
+        self.assertTrue(np.allclose(cm, cm_test))
+
+    def test_apexes2vectors(self):
+        """The vectors are from one apex to the other in a directed \
+manner. apex0 -> apex1 -> apex2. So the sum of all vector \
+should be zero
+        """
+        triangle = Triangle(self.apex0, self.apex1, self.apex2)
+        vec = triangle.apexes2vectors()
+        self.assertTrue(np.allclose(vec.sum(axis=1).data, [0, 0, 0]))
+
+    def test_apexes2edges_norm(self):
+        triangle = Triangle(self.apex0, self.apex1, self.apex2)
+        en = triangle.apexes2edges_norm()
+        test_norm = np.sqrt(np.sum((self.apex0 - self.apex1)**2))
+        self.assertAlmostEqual(en.loc[(0, 1)], test_norm)
+
+    def test_medians(self):
+        """The median intersect should be at the middle of an edge
+        """
+        triangle = Triangle(self.apex0, self.apex1, self.apex2)
+        md = triangle.medians()
+        test_apex1 = np.sqrt(np.sum((md.loc[:, 0] - self.apex1)**2))
+        test_apex2 = np.sqrt(np.sum((md.loc[:, 0] - self.apex2)**2))
+        self.assertAlmostEqual(test_apex1, test_apex2)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/navipy/markers/tools.py b/navipy/markers/tools.py
new file mode 100644
index 0000000..396b94d
--- /dev/null
+++ b/navipy/markers/tools.py
@@ -0,0 +1,41 @@
+"""
+   Tools for markers
+"""
+from scipy import signal
+from scipy.interpolate import interp1d
+
+
+def interpolate_markers(markers, kind='cubic'):
+    """
+       Interpolate marker position where Nan are
+    """
+    columns = markers.columns
+    markers_inter = markers.copy()
+    for col in columns:
+        y = markers_inter.loc[:, col]
+        valid_y = y.dropna()
+        valid_t = valid_y.index
+        nan_y = y.isnull().nonzero()[0]
+
+        func = interp1d(valid_t, valid_y, kind='cubic')
+        markers_inter.loc[nan_y, col] = func(nan_y)
+    return markers_inter
+
+
+def filter_markers(markers, cutfreq, order):
+    if cutfreq < order:
+        raise ValueError(
+            'cutoffrequency {} can not be lower than order {}'.format(
+                cutfreq, order))
+    nyquist = markers.index.name / 2
+    if cutfreq > nyquist:
+        raise ValueError(
+            'cutoffrequency {} can not be higher than Nyquist freq {}'.format(
+                cutfreq, nyquist))
+    b, a = signal.butter(order, cutfreq/nyquist)
+
+    columns = markers.columns
+    markers_filt = markers.copy()
+    for col in columns:
+        markers_filt.loc[:, col] = signal.filtfilt(b, a, markers.loc[:, col])
+    return markers_filt
diff --git a/navipy/markers/transformations.py b/navipy/markers/transformations.py
new file mode 100644
index 0000000..4b61734
--- /dev/null
+++ b/navipy/markers/transformations.py
@@ -0,0 +1,319 @@
+"""
+"""
+import numpy as np
+from tra3dpy.markers.triangle import Triangle
+import tra3dpy.maths.homogeneous_transformations as ht
+from scipy.optimize import minimize
+
+_modes = []
+for axel in ['x-axis', 'y-axis', 'z-axis']:
+    for marker in range(3):
+        _modes.append(axel+'=median-from-{}'.format(marker))
+
+for axel in ['x-axis', 'y-axis', 'z-axis']:
+    for marki in range(3):
+        for markj in range(3):
+            if marki == markj:
+                continue
+            _modes.append(axel+'={}-{}'.format(marki, markj))
+
+
+def determine_mode(mode):
+    cmode = mode.split('=')
+    if len(cmode) != 2:
+        raise KeyError('mode should contain one and only one sign =')
+    axes = ['x-axis', 'y-axis', 'z-axis']
+    if cmode[0] not in axes:
+        raise KeyError(
+            'left side of sign = should be {}, {}, or {}'.format(
+                axes[0], axes[1], axes[2]))
+    axel = cmode[0]
+    cmode = cmode[1].split('-')
+    if len(cmode) == 2:
+        apexes = (int(cmode[0]), int(cmode[1]))
+        method = 'aligned-with'
+    elif len(cmode) == 3:
+        apexes = int(cmode[-1])
+        method = cmode[0]+'-'+cmode[1]
+    return axel, apexes, method
+
+
+def normalise_vec(vec):
+    """Normalise vector when its norm is >0"""
+    # assert np.linalg.norm(vec) > 0, 'vec axis has a null norm'
+    if np.linalg.norm(vec) > 0:
+        vec = vec / np.linalg.norm(vec)
+    else:
+        vec = vec*np.nan
+    return vec
+
+
+def median_from(triangle, axel, marker):
+    """One axel of the body is assumed to be aligned with the median \
+       from an apex of the triangle.
+    """
+    # find next marker
+    next_marker = marker + 1
+    if next_marker > 2:
+        next_marker = 0
+    medians = triangle.medians()
+    vectors = triangle.apexes2vectors()
+    x_axis = medians.loc[:, marker] - triangle.apexes.apex0
+    x_axis = x_axis.loc[['x', 'y', 'z']].values
+    if ((marker, next_marker) == (0, 1)) or \
+       ((marker, next_marker) == (1, 2)) or \
+       ((marker, next_marker) == (2, 0)):
+        vector = vectors.loc[['x', 'y', 'z'], (marker, next_marker)]
+    elif ((next_marker, marker) == (0, 1)) or \
+         ((next_marker, marker) == (1, 2)) or \
+         ((next_marker, marker) == (2, 0)):
+        vector = -vectors.loc[['x', 'y', 'z'], (next_marker, marker)]
+    else:
+        raise KeyError(
+            'Can not compute median from with apexes {}'.format(marker))
+    z_axis = np.cross(vector.values,
+                      x_axis)
+    y_axis = np.cross(z_axis, x_axis)
+    x_axis = normalise_vec(x_axis)
+    y_axis = normalise_vec(y_axis)
+    z_axis = normalise_vec(z_axis)
+    if axel == 'x-axis':
+        return x_axis, y_axis, z_axis
+    elif axel == 'y-axis':
+        return y_axis, z_axis, x_axis
+    elif axel == 'z-axis':
+        return z_axis, x_axis, y_axis
+    else:
+        raise KeyError('{} axis is not supported'.format(axel))
+
+    return x_axis, y_axis, z_axis
+
+
+def aligned_with(triangle, axel, markers):
+    """One axel of the body is assumed to be aligned with one edge of the triangle.
+    """
+    # find third marker
+    third_marker = None
+    for mark in [0, 1, 2]:
+        if mark not in markers:
+            third_marker = mark
+            break
+
+    vectors = triangle.apexes2vectors()
+    if (markers == (0, 1)) or \
+       (markers == (1, 2)) or \
+       (markers == (2, 0)):
+        y_axis = vectors.loc[:, (markers[0], markers[1])]
+    elif (markers[::-1] == (0, 1)) or \
+         (markers[::-1] == (1, 2)) or \
+         (markers[::-1] == (2, 0)):
+        y_axis = - vectors.loc[['x', 'y', 'z'],
+                               (markers[1], markers[0])]
+    else:
+        raise KeyError(
+            'Can not compute aligned-with with apexes {}, {}'.format(
+                markers[0], markers[1]))
+
+    y_axis = y_axis.loc[['x', 'y', 'z']].values
+    if ((markers[0], third_marker) == (0, 1)) or \
+       ((markers[0], third_marker) == (1, 2)) or \
+       ((markers[0], third_marker) == (2, 0)):
+        vector = - vectors.loc[['x', 'y', 'z'],
+                               (markers[0], third_marker)]
+    elif ((third_marker, markers[0]) == (0, 1)) or \
+        ((third_marker, markers[0]) == (1, 2)) or \
+            ((third_marker, markers[0]) == (2, 0)):
+        vector = vectors.loc[['x', 'y', 'z'],
+                             (third_marker, markers[0])]
+    else:
+        raise KeyError(
+            'Can not compute aligned-with with apexes {}, {}'.format(
+                markers[0], markers[1]))
+
+    z_axis = np.cross(vector, y_axis)
+    x_axis = np.cross(y_axis, z_axis)
+
+    x_axis = normalise_vec(x_axis)
+    y_axis = normalise_vec(y_axis)
+    z_axis = normalise_vec(z_axis)
+
+    if axel == 'y-axis':
+        return x_axis, y_axis, z_axis
+    elif axel == 'x-axis':
+        return y_axis, z_axis, x_axis
+    elif axel == 'z-axis':
+        return z_axis, x_axis, y_axis
+    else:
+        raise KeyError('{} axis is not supported'.format(axel))
+
+
+def triangle2bodyaxis(triangle, mode):
+    """
+    The center of mass of the triangle is the center of all axis of the body.
+    The triangle may not be always placed in the same relative to \
+the body axis. Therefore several methods can be used to compute \
+the body axis from a three points placed on a body. Those methods \
+are accesible via the mode.
+
+    modes:
+    - 'x-axis=median-from-0'
+    - 'y-axis=1-2'
+    """
+    axel, markers, method = determine_mode(mode)
+    origin = triangle.center_of_mass()
+    if method == 'median-from':
+        x_axis, y_axis, z_axis = median_from(triangle,
+                                             axel, markers)
+    elif method == 'aligned-with':
+        x_axis, y_axis, z_axis = aligned_with(triangle,
+                                              axel, markers)
+    else:
+        print('supported modes')
+        print('---------------')
+        for m in _modes:
+            print(m)
+        raise ValueError('mode {} is not supported.'.format(mode))
+    return origin, x_axis, y_axis, z_axis
+
+
+def bodyaxistransformations(x_axis, y_axis, z_axis):
+    frame = np.zeros((4, 4))
+    frame[:3, 0] = x_axis
+    frame[:3, 1] = y_axis
+    frame[:3, 2] = z_axis
+    frame[3, 3] = 1
+    return frame
+
+
+def triangle2homogeous_transform(triangle, triangle_mode):
+    origin, x_axis, y_axis, z_axis = triangle2bodyaxis(triangle, triangle_mode)
+    transform = bodyaxistransformations(x_axis, y_axis, z_axis)
+    transform[:3, 3] = origin
+    return transform
+
+
+def markers2decompose(mark0, mark1, mark2, triangle_mode, euler_axes):
+    triangle = Triangle(mark0, mark1, mark2)
+    transform = triangle2homogeous_transform(triangle, triangle_mode)
+    return ht.decompose_matrix(transform, axes=euler_axes)
+
+
+def markers2euler(mark0, mark1, mark2, triangle_mode, euler_axes):
+    _, angles = markers2posorient(mark0, mark1, mark2,
+                                  triangle_mode, euler_axes)
+    return angles
+
+
+def markers2translate(mark0, mark1, mark2, triangle_mode, euler_axes):
+    translate, _ = markers2posorient(mark0, mark1, mark2,
+                                     triangle_mode, euler_axes)
+    return translate
+
+
+def markers2posorient(mark0, mark1, mark2, triangle_mode, euler_axes):
+    _, _, angle, translate, _ = markers2decompose(mark0, mark1, mark2,
+                                                  triangle_mode, euler_axes)
+    return translate, angle
+
+
+def twomarkers2euler_easy_euleraxes(mark0, mark1, axis_alignement,
+                                    known_angle):
+    """ Determine euler angles from two markers and a known angle
+
+    The functions require a known angle express in the temporary \
+rotation convention.
+
+    The known angle is:
+
+    * rotation around x, for axis alignment x-axis
+    * rotation around y, for axis alignment y-axis
+    * rotation around z, for axis alignment z-axis
+
+    The temporary rotation convention used two determine the euler angles are:
+
+    * rzyx, for axis alignment x-axis
+    * rzxy, for axis alignment y-axis
+    * ryxz, for axis alignment z-axis
+
+    Note: If you know the angle in your rotation convention, then you \
+can run this function through a minimisation procedure with free parameter \
+known angle until you get the desired orientation (see twomarkers2euler)
+
+    """
+    vector = mark1-mark0
+    vector = normalise_vec(vector)
+    if axis_alignement == 'x-axis':
+        axes_convention = 'rzyx'
+        beta = np.arcsin(-vector.z)
+        alpha = np.arctan2(vector.y/np.cos(beta),
+                           vector.x/np.cos(beta))
+        gamma = known_angle
+        angles = [alpha, beta, gamma]
+    elif axis_alignement == 'y-axis':
+        axes_convention = 'rzxy'
+        gamma = np.arcsin(vector.z)
+        alpha = np.arctan2(-vector.x/np.cos(gamma),
+                           vector.y/np.cos(gamma))
+        beta = known_angle
+        angles = [alpha, gamma, beta]
+    elif axis_alignement == 'z-axis':
+        axes_convention = 'ryxz'
+        gamma = np.arcsin(-vector.y)
+        beta = np.arctan2(vector.x/np.cos(gamma),
+                          vector.z/np.cos(gamma))
+        alpha = known_angle
+        angles = [beta, gamma, alpha]
+    else:
+        raise KeyError(
+            'Axis aligment {} is not supported'.format(
+                axis_alignement))
+    return angles, axes_convention
+
+
+def twomarkers2euler_score(x, mark0, mark1,
+                           axis_alignement, known_angles, euler_axes):
+    """ A root mean square score between wished angles and obtained \
+        angles from two markers
+
+    This function is used by twomarkers2euler within the minimisation of scipy
+    """
+    angles, axes_convention = twomarkers2euler_easy_euleraxes(
+        mark0, mark1, axis_alignement, x)
+    matrix = ht.compose_matrix(
+        angles=angles, axes=axes_convention)
+    _, _, angles, _, _ = ht.decompose_matrix(matrix, axes=euler_axes)
+
+    return np.nanmean((angles - known_angles)**2)
+
+
+def twomarkers2euler(mark0, mark1, axis_alignement,
+                     known_angle, euler_axes,
+                     method='Nelder-Mead', tol=1e-6):
+    """ Determine euler angles from two markers and a known angle.
+
+    The known angle is assumed to be the one around the axis aligment.
+    If euler_axes contains twice the same axis the first rotation axis is used.
+
+    """
+    if axis_alignement not in ['x-axis', 'y-axis', 'z-axis']:
+        raise KeyError(
+            'Axis aligment {} is not supported'.format(
+                axis_alignement))
+    known_angles = np.nan*np.zeros(3)
+    # Find the first rotation axis
+    index = euler_axes.find(axis_alignement[0])
+    if index < 1:
+        raise KeyError(
+            'Axis aligment {} can not work with euler_axes {}'.format(
+                axis_alignement, euler_axes))
+    known_angles[index-1] = known_angle
+    args = (mark0, mark1, axis_alignement, known_angles, euler_axes)
+    res = minimize(twomarkers2euler_score, x0=known_angle, args=args,
+                   method=method, tol=tol)
+    angles, axes_convention = twomarkers2euler_easy_euleraxes(
+        mark0, mark1, axis_alignement, res.x)
+    # Build rotation matrix and decompse
+    matrix = ht.compose_matrix(
+        angles=angles, axes=axes_convention)
+    _, _, angles, _, _ = ht.decompose_matrix(matrix, axes=euler_axes)
+    return angles
diff --git a/navipy/markers/triangle.py b/navipy/markers/triangle.py
new file mode 100644
index 0000000..737cb9f
--- /dev/null
+++ b/navipy/markers/triangle.py
@@ -0,0 +1,179 @@
+"""
+"""
+import numpy as np
+import pandas as pd
+import matplotlib.pyplot as plt
+import mpl_toolkits.mplot3d as a3
+
+
+class Triangle():
+    """
+    A Triangle is defined by three apexes
+    - apex 0
+    - apex 1
+    - apex 2
+
+    This class provides methods to calculate triangle properties when the \
+    apexes of a triangle are known
+    """
+
+    def __init__(self, apex0, apex1, apex2):
+        msg = 'should be a pandas Series with x,y,z as index'
+        if not isinstance(apex0, pd.Series):
+            raise TypeError('apex0 ' + msg)
+        if not isinstance(apex1, pd.Series):
+            raise TypeError('apex1 ' + msg)
+        if not isinstance(apex2, pd.Series):
+            raise TypeError('apex2 ' + msg)
+        if apex0.shape[0] != 3:
+            raise IOError('apex0 ' + msg)
+        if apex1.shape[0] != 3:
+            raise IOError('apex1 ' + msg)
+        if apex2.shape[0] != 3:
+            raise IOError('apex2 ' + msg)
+        if ('x' not in apex0.index) or \
+           ('y' not in apex0.index) or \
+           ('z' not in apex0.index):
+            raise IOError('apex0 ' + msg)
+        if ('x' not in apex1.index) or \
+           ('y' not in apex1.index) or \
+           ('z' not in apex1.index):
+            raise IOError('apex1 ' + msg)
+        if ('x' not in apex2.index) or \
+           ('y' not in apex2.index) or \
+           ('z' not in apex2.index):
+            raise IOError('apex2 ' + msg)
+        if apex0.dtype != float:
+            raise IOError('apex0 does not contains float')
+        if apex1.dtype != float:
+            raise IOError('apex1 does not contains float')
+        if apex2.dtype != float:
+            raise IOError('apex2 does not contains float')
+
+        # The apexes are stored in a pandas dataframe
+        self.apexes = pd.DataFrame(index=['x', 'y', 'z'],
+                                   columns=['apex0', 'apex1', 'apex2'],
+                                   dtype=float)
+        self.apexes.apex0 = apex0
+        self.apexes.apex1 = apex1
+        self.apexes.apex2 = apex2
+
+    def __convert_list2array(self, clist):
+        if isinstance(clist, list):
+            clist = np.array(clist)
+        return clist
+
+    def center_of_mass(self):
+        """center of mass
+
+        :returns: center of mass of the triangle
+        :rtype: pandas series
+
+        """
+        cm = pd.Series(index=['x', 'y', 'z'], name='center_of_mass')
+        cm.x = self.apexes.loc['x', :].mean()
+        cm.y = self.apexes.loc['y', :].mean()
+        cm.z = self.apexes.loc['z', :].mean()
+        return cm
+
+    def apexes2vectors(self):
+        """apexes2vectors return a vector between each apexes
+        the vector originating from apex a and going to apex b, \
+can be access as (a,b), here a and b are the apex index.
+        for example for the vector between apex0 and apex1, \
+the tuple is (0,1)
+
+        :returns: return the vectors between edges
+        :rtype: pandas multiindexed DataFrame
+
+        """
+        vec = pd.DataFrame(data=0,
+                           index=['x', 'y', 'z'],
+                           columns=pd.MultiIndex.from_tuples([(0, 1),
+                                                              (1, 2),
+                                                              (2, 0)]))
+        vec.name = 'vectors'
+        vec.loc[:, (0, 1)] = self.apexes.apex1 - self.apexes.apex0
+        vec.loc[:, (1, 2)] = self.apexes.apex2 - self.apexes.apex1
+        vec.loc[:, (2, 0)] = self.apexes.apex0 - self.apexes.apex2
+        return vec
+
+    def apexes2edges_norm(self):
+        """apexes2edges_norm return the edges norm.
+
+        the edges are accessed by a tuple (a,b), here a and b \
+are the apex indeces.
+        for example for the edge between apex0 and apex1, the \
+tuple is (0,1)
+
+        :returns: return edges norm
+        :rtype: pandas multindexed Series
+
+        """
+        en = self.apexes2vectors()
+        en = np.sqrt((en**2).sum(axis=0))
+        en.name = 'edges_norm'
+        return en
+
+    def medians(self):
+        """medians
+        :returns: the point on the facing edge of an apex at which the median \
+        emanating from this apex cross the facing edge.
+        :rtype: pd.dataframe
+        """
+        md = pd.DataFrame(index=['x', 'y', 'z'],
+                          columns=[0, 1, 2])
+        vec = self.apexes2vectors()
+        md.loc[:, 0] = (vec.loc[:, (1, 2)] / 2) + self.apexes.apex1
+        md.loc[:, 1] = (vec.loc[:, (2, 0)] / 2) + self.apexes.apex2
+        md.loc[:, 2] = (vec.loc[:, (0, 1)] / 2) + self.apexes.apex0
+        return md
+
+    def transform(self, matrix):
+        if not isinstance(matrix, (np.ndarray, np.generic)):
+            raise TypeError('frame should be a numpy array')
+        if len(matrix.shape) != 2:
+            raise TypeError('frame should have 2 dimensions')
+        if not np.all(matrix.shape == [4, 4]):
+            matrix = matrix[:3, :]
+        if not np.all(matrix.shape != [3, 4]):
+            raise TypeError('frame should be a 3x4 or 4x4 matrix')
+        for apexname, row in self.apexes.transpose().iterrows():
+            self.apexes.loc[['x', 'y', 'z'], apexname] = matrix.dot(
+                [row.x, row.y, row.z, 1])[:3]
+
+    def plot(self, ax=plt.gca(),
+             facecolor='k',
+             edgecolor='k',
+             alpha=1,
+             apex_marker=None):
+        vtx = np.zeros((3, 3))
+        vtx[:, 0] = self.apexes.apex0.values
+        vtx[:, 1] = self.apexes.apex1.values
+        vtx[:, 2] = self.apexes.apex2.values
+        tri = a3.art3d.Poly3DCollection([vtx.transpose()])
+        tri.set_facecolor(facecolor)
+        tri.set_edgecolor(edgecolor)
+        tri.set_alpha(alpha)
+        ax.add_collection3d(tri)
+        if apex_marker is not None:
+            for _, row in self.apexes.transpose().iterrows():
+                plt.plot([row.x], [row.y], [row.z], apex_marker)
+        xlim = ax.get_xlim()
+        ylim = ax.get_ylim()
+        zlim = ax.get_zlim()
+        if min(xlim) < min(vtx[0, :]):
+            xlim[0] = min(vtx[0, :])
+        if max(xlim) > max(vtx[0, :]):
+            xlim[1] = max(vtx[0, :])
+        if min(ylim) < min(vtx[1, :]):
+            ylim[0] = min(vtx[1, :])
+        if max(ylim) > max(vtx[1, :]):
+            ylim[1] = max(vtx[1, :])
+        if min(zlim) < min(vtx[2, :]):
+            zlim[0] = min(vtx[2, :])
+        if max(xlim) > max(vtx[2, :]):
+            zlim[1] = max(vtx[2, :])
+        ax.set_xlim(xlim)
+        ax.set_ylim(ylim)
+        ax.set_zlim(zlim)
diff --git a/navipy/plots/__init__.py b/navipy/plots/__init__.py
new file mode 100644
index 0000000..67c0226
--- /dev/null
+++ b/navipy/plots/__init__.py
@@ -0,0 +1,46 @@
+"""
+Ploting tools
+"""
+from matplotlib import pyplot as plt
+from matplotlib.patches import FancyArrowPatch
+from mpl_toolkits.mplot3d import proj3d
+import numpy as np
+
+
+class Arrow3D(FancyArrowPatch):
+    def __init__(self, xs, ys, zs, *args, **kwargs):
+        FancyArrowPatch.__init__(self, (0, 0), (0, 0), *args, **kwargs)
+        self._verts3d = xs, ys, zs
+
+    def draw(self, renderer):
+        xs3d, ys3d, zs3d = self._verts3d
+        xs, ys, zs = proj3d.proj_transform(xs3d, ys3d, zs3d, renderer.M)
+        self.set_positions((xs[0], ys[0]), (xs[1], ys[1]))
+        FancyArrowPatch.draw(self, renderer)
+
+
+def draw_frame(frame,
+               ax=plt.gca(),
+               mutation_scale=20, lw=3,
+               arrowstyle="-|>", colors=['r', 'g', 'b']):
+    if not isinstance(frame, (np.ndarray, np.generic)):
+        raise TypeError('frame should be a numpy array')
+    if len(frame.shape) != 2:
+        raise TypeError('frame should have 2 dimensions')
+    if not np.all(frame.shape == [4, 4]):
+        frame = frame[:3, :]
+    if not np.all(frame.shape != [3, 4]):
+        raise TypeError('frame should be a 3x4 or 4x4 matrix')
+
+    origin = frame[:, 3]
+    for (i, color) in enumerate(colors):
+        v = frame[:, i]
+        xs = [origin[0], origin[0]+v[0]]
+        ys = [origin[1], origin[1]+v[1]]
+        zs = [origin[2], origin[2]+v[2]]
+        a = Arrow3D(xs, ys, zs,
+                    mutation_scale=mutation_scale,
+                    lw=lw,
+                    arrowstyle=arrowstyle,
+                    color=color)
+        ax.add_artist(a)
-- 
GitLab