Repository: scikit-image/scikit-image

Issue:
Possibility to have ellipses in random shapes
## Description

It could be interesting to have the possibility to draw random ellipses when using the [`random_shapes` function](https://scikit-image.org/docs/dev/auto_examples/edges/plot_random_shapes.html).
Random ellipses are often used as a toy example for different research papers, e.g.:
- https://arxiv.org/abs/2001.01258
- https://arxiv.org/abs/1707.06474

I can definitely try and submit a PR for this.



Discussion:

Resolving patch:
diff --git a/doc/examples/edges/plot_random_shapes.py b/doc/examples/edges/plot_random_shapes.py
--- a/doc/examples/edges/plot_random_shapes.py
+++ b/doc/examples/edges/plot_random_shapes.py
@@ -13,7 +13,7 @@
 # Let's start simple and generate a 128x128 image
 # with a single grayscale rectangle.
 result = random_shapes((128, 128), max_shapes=1, shape='rectangle',
-                       multichannel=False)
+                       multichannel=False, random_seed=0)
 
 # We get back a tuple consisting of (1) the image with the generated shapes
 # and (2) a list of label tuples with the kind of shape (e.g. circle,
diff --git a/skimage/draw/_random_shapes.py b/skimage/draw/_random_shapes.py
--- a/skimage/draw/_random_shapes.py
+++ b/skimage/draw/_random_shapes.py
@@ -1,6 +1,8 @@
+import math
 import numpy as np
 
-from . import polygon as draw_polygon, circle as draw_circle
+from . import (polygon as draw_polygon, circle as draw_circle,
+    ellipse as draw_ellipse)
 from .._shared.utils import warn
 
 
@@ -163,11 +165,82 @@ def _generate_triangle_mask(point, image, shape, random):
     return triangle, label
 
 
+def _generate_ellipse_mask(point, image, shape, random):
+    """Generate a mask for a filled ellipse shape.
+
+    The rotation, major and minor semi-axes of the ellipse are generated
+    randomly.
+
+    Parameters
+    ----------
+    point : tuple
+        The row and column of the top left corner of the rectangle.
+    image : tuple
+        The height, width and depth of the image into which the shape is
+        placed.
+    shape : tuple
+        The minimum and maximum size and color of the shape to fit.
+    random : np.random.RandomState
+        The random state to use for random sampling.
+
+    Raises
+    ------
+    ArithmeticError
+        When a shape cannot be fit into the image with the given starting
+        coordinates. This usually means the image dimensions are too small or
+        shape dimensions too large.
+
+    Returns
+    -------
+    label : tuple
+        A (category, ((r0, r1), (c0, c1))) tuple specifying the category and
+        bounding box coordinates of the shape.
+    indices : 2-D array
+        A mask of indices that the shape fills.
+    """
+    if shape[0] == 1 or shape[1] == 1:
+        raise ValueError('size must be > 1 for ellipses')
+    min_radius = shape[0] / 2.0
+    max_radius = shape[1] / 2.0
+    left = point[1]
+    right = image[1] - point[1]
+    top = point[0]
+    bottom = image[0] - point[0]
+    available_radius = min(left, right, top, bottom, max_radius)
+    if available_radius < min_radius:
+        raise ArithmeticError('cannot fit shape to image')
+    # NOTE: very conservative because we could take into account the fact that
+    # we have 2 different radii, but this is a good first approximation.
+    # Also, we can afford to have a uniform sampling because the ellipse will
+    # be rotated.
+    r_radius = random.uniform(min_radius, available_radius + 1)
+    c_radius = random.uniform(min_radius, available_radius + 1)
+    rotation = random.uniform(-np.pi, np.pi)
+    ellipse = draw_ellipse(
+        point[0],
+        point[1],
+        r_radius,
+        c_radius,
+        shape=image[:2],
+        rotation=rotation,
+    )
+    max_radius = math.ceil(max(r_radius, c_radius))
+    min_x = np.min(ellipse[0])
+    max_x = np.max(ellipse[0]) + 1
+    min_y = np.min(ellipse[1])
+    max_y = np.max(ellipse[1]) + 1
+    label = ('ellipse', ((min_x, max_x), (min_y, max_y)))
+
+    return ellipse, label
+
+
+
 # Allows lookup by key as well as random selection.
 SHAPE_GENERATORS = dict(
     rectangle=_generate_rectangle_mask,
     circle=_generate_circle_mask,
-    triangle=_generate_triangle_mask)
+    triangle=_generate_triangle_mask,
+    ellipse=_generate_ellipse_mask)
 SHAPE_CHOICES = list(SHAPE_GENERATORS.values())
 
 
@@ -252,7 +325,7 @@ def random_shapes(image_shape,
         Number of channels in the generated image. If 1, generate monochrome
         images, else color images with multiple channels. Ignored if
         ``multichannel`` is set to False.
-    shape : {rectangle, circle, triangle, None} str, optional
+    shape : {rectangle, circle, triangle, ellipse, None} str, optional
         The name of the shape to generate or `None` to pick random ones.
     intensity_range : {tuple of tuples of uint8, tuple of uint8}, optional
         The range of values to sample pixel values from. For grayscale images
@@ -265,7 +338,7 @@ def random_shapes(image_shape,
         If `True`, allow shapes to overlap.
     num_trials : int, optional
         How often to attempt to fit a shape into the image before skipping it.
-    seed : int, optional
+    random_seed : int, optional
         Seed to initialize the random number generator.
         If `None`, a random seed from the operating system is used.
 


Test patch:
diff --git a/skimage/draw/tests/test_random_shapes.py b/skimage/draw/tests/test_random_shapes.py
--- a/skimage/draw/tests/test_random_shapes.py
+++ b/skimage/draw/tests/test_random_shapes.py
@@ -79,12 +79,40 @@ def test_generates_correct_bounding_boxes_for_circles():
     assert (image == 255).all()
 
 
+def test_generates_correct_bounding_boxes_for_ellipses():
+    image, labels = random_shapes(
+        (43, 44),
+        max_shapes=1,
+        min_size=20,
+        max_size=20,
+        shape='ellipse',
+        random_seed=42)
+    assert len(labels) == 1
+    label, bbox = labels[0]
+    assert label == 'ellipse', label
+
+    crop = image[bbox[0][0]:bbox[0][1], bbox[1][0]:bbox[1][1]]
+
+    # The crop is filled.
+    assert (crop >= 0).any() and (crop < 255).any()
+
+    # The crop is complete.
+    image[bbox[0][0]:bbox[0][1], bbox[1][0]:bbox[1][1]] = 255
+    assert (image == 255).all()
+
+
 def test_generate_circle_throws_when_size_too_small():
     with testing.raises(ValueError):
         random_shapes(
             (64, 128), max_shapes=1, min_size=1, max_size=1, shape='circle')
 
 
+def test_generate_ellipse_throws_when_size_too_small():
+    with testing.raises(ValueError):
+        random_shapes(
+            (64, 128), max_shapes=1, min_size=1, max_size=1, shape='ellipse')
+
+
 def test_generate_triangle_throws_when_size_too_small():
     with testing.raises(ValueError):
         random_shapes(