Repository: scikit-image/scikit-image

Issue:
Wrong matrix calculation for shear angle in `AffineTransform`
## Description

The convenience method of constructing an affine transform by providing a shear angle creates a wrong internal transform matrix - the diagonal elements should be 1.0 and in the second column it isn't. The -tan(angle) value is also wrong (apparently mis-scaled the same way as the diagola element.

## Way to reproduce

```python
import numpy as np
from skimage import transform
import math as m

angle = 0.2

shear_transform = transform.AffineTransform(shear=angle)
print("shear-convenience-constructor:")
print(shear_transform.params)
print()

matrix = np.array([[1.0, -m.tan(angle), 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])
affine_transform = transform.AffineTransform(matrix=matrix)
print("correct matrix constructor:")
print(affine_transform.params)
```


Discussion:
Wolfram would say you are right:
http://mathworld.wolfram.com/Shear.html

This line of code and the documentation say it is explicit @ahojnnes 
https://github.com/scikit-image/scikit-image/blob/d3b8b6c296bd40ec15c23f38894156133745c6d9/skimage/transform/_geometric.py#L717-L724
https://github.com/scikit-image/scikit-image/blob/d3b8b6c296bd40ec15c23f38894156133745c6d9/skimage/transform/_geometric.py#L777-L780

Shear should not be a 1D parameter should it? I feel like you should have shear_x shear_y? or shear `(y, x)` (well `(x, y)` because the transform module is anti-python)

For reference, opencv doesn't provide a shortcut for shear
https://docs.opencv.org/3.0-beta/doc/py_tutorials/py_imgproc/py_geometric_transformations/py_geometric_transformations.html
I've written a notebook ([HTML](https://thomasaarholt.github.io/affine_transforms/affine.html#skimage.transform.warp) [IPYNB](https://thomasaarholt.github.io/affine_transforms/affine.ipynb) that demonstrates how the shear transform is incorrect (as well as a bunch of other peculiarities with how Affine Transforms are applied in python).

The relevant discussion is [here](https://thomasaarholt.github.io/affine_transforms/affine.html#skimage.transform.warp) (scroll up one paragraph for some discussion). The following image shows the problem, scikit-image implementation at the bottom:

![image](https://user-images.githubusercontent.com/2721423/103412216-4e7c7180-4b74-11eb-8e7c-b3ff056b6c8d.png)

The `matplotlib.transforms.Affine2D()` function works very well as a substitute. Since skimage has matplotlib as a dependency, maybe one should just use that library instead? As documented in my notebook above, with the exception of the erroneous `shear` function, the implementation is identical (and supports chaining methods, which is nice).

Resolving patch:
diff --git a/skimage/transform/_geometric.py b/skimage/transform/_geometric.py
--- a/skimage/transform/_geometric.py
+++ b/skimage/transform/_geometric.py
@@ -850,13 +850,21 @@ class AffineTransform(ProjectiveTransform):
     Has the following form::
 
         X = a0*x + a1*y + a2 =
-          = sx*x*cos(rotation) - sy*y*sin(rotation + shear) + a2
+          = sx*x*cos(rotation) - sy*y*(tan(shear) * cos(rotation) + sin(rotation)) + translation_x
 
         Y = b0*x + b1*y + b2 =
-          = sx*x*sin(rotation) + sy*y*cos(rotation + shear) + b2
+          = sx*x*sin(rotation) - sy*y*(tan(shear) * sin(rotation) - cos(rotation)) + translation_y
 
-    where ``sx`` and ``sy`` are scale factors in the x and y directions,
-    and the homogeneous transformation matrix is::
+    where ``sx`` and ``sy`` are scale factors in the x and y directions.
+
+    This is equivalent to applying the operations in the following order:
+
+    1. Scale
+    2. Shear(x-dimension)
+    3. Rotate
+    4. Translate
+
+    The homogeneous transformation matrix is::
 
         [[a0  a1  a2]
          [b0  b1  b2]
@@ -884,11 +892,10 @@ class AffineTransform(ProjectiveTransform):
         .. versionadded:: 0.17
            Added support for supplying a single scalar value.
     rotation : float, optional
-        Rotation angle in counter-clockwise direction as radians. Only
-        available for 2D.
+        Rotation angle, clockwise, as radians. Only available for 2D.
     shear : float, optional
-        Shear angle in counter-clockwise direction as radians. Only available
-        for 2D.
+        The shear angle, clockwise, by which the y-axis is
+        rotated around the origin [2]. Only available in 2D.
     translation : (tx, ty) as array, list or tuple, optional
         Translation parameters. Only available for 2D.
     dimensionality : int, optional
@@ -930,6 +937,12 @@ class AffineTransform(ProjectiveTransform):
 
     >>> warped = ski.transform.warp(img, inverse_map=tform.inverse)
 
+    References
+    ----------
+    .. [1] Wikipedia, "Affine transformation",
+           https://en.wikipedia.org/wiki/Affine_transformation#Image_transformation
+    .. [2] Wikipedia, "Shear mapping",
+           https://en.wikipedia.org/wiki/Shear_mapping
     """
 
     def __init__(self, matrix=None, scale=None, rotation=None, shear=None,
@@ -969,19 +982,29 @@ def __init__(self, matrix=None, scale=None, rotation=None, shear=None,
             else:
                 sx, sy = scale
 
-            self.params = np.array([
-                [sx * math.cos(rotation), -sy * math.sin(rotation + shear), 0],
-                [sx * math.sin(rotation),  sy * math.cos(rotation + shear), 0],
-                [                      0,                                0, 1]
-            ])
-            self.params[0:2, 2] = translation
+            a0 = sx * math.cos(rotation)
+            a1 = -sy * (
+                math.tan(shear) * math.cos(rotation) + math.sin(rotation)
+            )
+            a2 = translation[0]
+            b0 = sx * math.sin(rotation)
+            b1 = -sy * (
+                math.tan(shear) * math.sin(rotation) - math.cos(rotation)
+            )
+            b2 = translation[1]
+            self.params = np.array([[a0, a1, a2], [b0, b1, b2], [0, 0, 1]])
         else:
             # default to an identity transform
             self.params = np.eye(dimensionality + 1)
 
     @property
     def scale(self):
-        return np.sqrt(np.sum(self.params ** 2, axis=0))[:self.dimensionality]
+        if self.dimensionality != 2:
+            return np.sqrt(np.sum(self.params ** 2, axis=0))[:self.dimensionality]
+        else:
+            ss = np.sum(self.params ** 2, axis=0)
+            ss[1] = ss[1] / (math.tan(self.shear)**2 + 1)
+            return np.sqrt(ss)[:self.dimensionality]
 
     @property
     def rotation(self):
@@ -1228,7 +1251,7 @@ class EuclideanTransform(ProjectiveTransform):
     matrix : (D+1, D+1) array_like, optional
         Homogeneous transformation matrix.
     rotation : float or sequence of float, optional
-        Rotation angle in counter-clockwise direction as radians. If given as
+        Rotation angle, clockwise, as radians. If given as
         a vector, it is interpreted as Euler rotation angles [1]_. Only 2D
         (single rotation) and 3D (Euler rotations) values are supported. For
         higher dimensions, you must provide or estimate the transformation
@@ -1368,7 +1391,7 @@ class SimilarityTransform(EuclideanTransform):
     scale : float, optional
         Scale factor. Implemented only for 2D and 3D.
     rotation : float, optional
-        Rotation angle in counter-clockwise direction as radians.
+        Rotation angle, clockwise, as radians.
         Implemented only for 2D and 3D. For 3D, this is given in ZYX Euler
         angles.
     translation : (dim,) array_like, optional


Test patch:
diff --git a/skimage/transform/tests/test_geometric.py b/skimage/transform/tests/test_geometric.py
--- a/skimage/transform/tests/test_geometric.py
+++ b/skimage/transform/tests/test_geometric.py
@@ -261,6 +261,19 @@ def test_affine_init():
                         AffineTransform(scale=(0.5, 0.5)).scale)
 
 
+def test_affine_shear():
+    shear = 0.1
+    # expected horizontal shear transform
+    expected = np.array([
+        [1, -np.tan(shear), 0],
+        [0,              1, 0],
+        [0,              0, 1]
+    ])
+
+    tform = AffineTransform(shear=shear)
+    assert_almost_equal(tform.params, expected)
+
+
 def test_piecewise_affine():
     tform = PiecewiseAffineTransform()
     assert tform.estimate(SRC, DST)