Skip to content

Commit

Permalink
Ellipse parameter now supports major/minor axis lengths (#1509)
Browse files Browse the repository at this point in the history
* Fixed typo in docstring

* Ellipse parameter now supports major/minor axis lengths

* Simplified Ellipse to support a straightforward width parameter

* Fixed incorrect docstring

* Updated docstring and restricted aspect to the height-only spec

* Fixed clone method for BaseShapes

* Further fixes and improvements to Ellipse

* Made the Box element's API consistent with Ellipse

* Fixed outdated docstrings

* Updated Ellipse element notebooks

* Updated Box and Ellipse element notebooks

* Added seven unit tests of Ellipse and Box

* Made Box element notebooks Python 3 compatible
  • Loading branch information
jlstevens authored and philippjfr committed Jun 4, 2017
1 parent e6ded88 commit e0358d4
Show file tree
Hide file tree
Showing 6 changed files with 219 additions and 33 deletions.
17 changes: 11 additions & 6 deletions examples/elements/bokeh/Box.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"A ``Box`` is an annotation that takes a center x-position, a center y-position and a width:"
"A ``Box`` is an annotation that takes a center x-position, a center y-position and a size:"
]
},
{
Expand All @@ -41,17 +41,20 @@
},
"outputs": [],
"source": [
"%%opts Box (line_width=5 color='purple') Image (cmap='gray')\n",
"%%opts Box (line_width=5 color='red') Image (cmap='gray')\n",
"data = np.sin(np.mgrid[0:100,0:100][1]/10.0)\n",
"data[range(30, 70), range(30, 70)] = -3\n",
"hv.Image(data) * hv.Box(-0, 0, 0.5 )"
"data[np.arange(40, 60), np.arange(20, 40)] = -1\n",
"data[np.arange(40, 50), np.arange(70, 80)] = -3 \n",
"hv.Image(data) * hv.Box(-0.2, 0, 0.25 ) * hv.Box(-0, 0, (0.4,0.9) )"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"In addition to these arguments, it supports an optional ``aspect ratio``:"
"In addition to these arguments, it supports an optional ``aspect ratio``:\n",
"\n",
"By default, the size argument results in a square such as the small square shown above. Alternatively, the size can be given as the tuple ``(width, height)`` resulting in a rectangle. If you only supply a size value, you can still specify a rectangle by specifying an optional aspect value. In addition, you can also set the orientation (in radians, rotating anticlockwise):"
]
},
{
Expand All @@ -63,7 +66,9 @@
"outputs": [],
"source": [
"%%opts Box (line_width=5 color='purple') Image (cmap='gray')\n",
"hv.Image(data) * hv.Box(-0, 0, 0.25, aspect=3)"
"data = np.sin(np.mgrid[0:100,0:100][1]/10.0)\n",
"data[np.arange(30, 70), np.arange(30, 70)] = -3\n",
"hv.Image(data) * hv.Box(-0, 0, 0.25, aspect=3, orientation=-np.pi/4)"
]
}
],
Expand Down
24 changes: 22 additions & 2 deletions examples/elements/bokeh/Ellipse.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"An ``Ellipse`` is an annotation that takes a center x-position, a center y-position, a width and an optional aspect ratio:"
"An ``Ellipse`` is an annotation that takes a center x-position, a center y-position, a size:"
]
},
{
Expand All @@ -49,7 +49,27 @@
"c3 = np.random.normal(loc=0, scale=1.5, size=(400,400))\n",
"# Create an overlay of points and ellipses\n",
"clusters = hv.Points(c1) * hv.Points((c2x, c2y)) * hv.Points(c3)\n",
"clusters * hv.Ellipse(-2,-2, 0.2*12, aspect=1.5) * hv.Ellipse(2,2, 0.6*4)"
"clusters * hv.Ellipse(2,2, 2) * hv.Ellipse(-2,-2, (4,2)) "
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"By default, the size is just a diameter, resulting in a circle such as the blue circle above. Alternatively, the size can be given as the tuple ``(width, height)`` as shown for the red ellipse above. If you only supply a diameter, you can still specify an ellipse by specifying an optional aspect value. In addition, you can also set the orientation (in radians, rotating anticlockwise):"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"collapsed": false
},
"outputs": [],
"source": [
"%%opts Ellipse (line_width=6)\n",
"clusters = hv.Points(c1) * hv.Points((c2x, c2y)) * hv.Points(c3)\n",
"clusters * hv.Ellipse(0,0, 4, orientation=np.pi/5, aspect=2) "
]
}
],
Expand Down
17 changes: 11 additions & 6 deletions examples/elements/matplotlib/Box.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"A ``Box`` is an annotation that takes a center x-position, a center y-position and a width:"
"A ``Box`` is an annotation that takes a center x-position, a center y-position and a size:"
]
},
{
Expand All @@ -41,17 +41,20 @@
},
"outputs": [],
"source": [
"%%opts Box (linewidth=5 color='purple') Image (cmap='gray')\n",
"%%opts Box (linewidth=5 color='red') Image (cmap='gray')\n",
"data = np.sin(np.mgrid[0:100,0:100][1]/10.0)\n",
"data[range(30, 70), range(30, 70)] = -3\n",
"hv.Image(data) * hv.Box(-0, 0, 0.5 )"
"data[np.arange(40, 60), np.arange(20, 40)] = -1\n",
"data[np.arange(40, 50), np.arange(70, 80)] = -3 \n",
"hv.Image(data) * hv.Box(-0.2, 0, 0.25 ) * hv.Box(-0, 0, (0.4,0.9) )"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"In addition to these arguments, it supports an optional ``aspect ratio``:"
"In addition to these arguments, it supports an optional ``aspect ratio``:\n",
"\n",
"By default, the size argument results in a square such as the small square shown above. Alternatively, the size can be given as the tuple ``(width, height)`` resulting in a rectangle. If you only supply a size value, you can still specify a rectangle by specifying an optional aspect value. In addition, you can also set the orientation (in radians, rotating anticlockwise):"
]
},
{
Expand All @@ -63,7 +66,9 @@
"outputs": [],
"source": [
"%%opts Box (linewidth=5 color='purple') Image (cmap='gray')\n",
"hv.Image(data) * hv.Box(-0, 0, 0.25, aspect=3)"
"data = np.sin(np.mgrid[0:100,0:100][1]/10.0)\n",
"data[np.arange(30, 70), np.arange(30, 70)] = -3\n",
"hv.Image(data) * hv.Box(-0, 0, 0.25, aspect=3, orientation=-np.pi/4)"
]
}
],
Expand Down
24 changes: 22 additions & 2 deletions examples/elements/matplotlib/Ellipse.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"An ``Ellipse`` is an annotation that takes a center x-position, a center y-position, a width and an optional aspect ratio:"
"An ``Ellipse`` is an annotation that takes a center x-position, a center y-position, a size:"
]
},
{
Expand All @@ -49,7 +49,27 @@
"c3 = np.random.normal(loc=0, scale=1.5, size=(400,400))\n",
"# Create an overlay of points and ellipses\n",
"clusters = hv.Points(c1) * hv.Points((c2x, c2y)) * hv.Points(c3)\n",
"clusters * hv.Ellipse(-2,-2, 0.2*12, aspect=1.5) * hv.Ellipse(2,2, 0.6*4)"
"clusters * hv.Ellipse(2,2, 2) * hv.Ellipse(-2,-2, (4,2)) "
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"By default, the size is just a diameter, resulting in a circle such as the blue circle above. Alternatively, the size can be given as the tuple ``(width, height)`` as shown for the red ellipse above. If you only supply a diameter, you can still specify an ellipse by specifying an optional aspect value. In addition, you can also set the orientation (in radians, rotating anticlockwise):"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"collapsed": false
},
"outputs": [],
"source": [
"%%opts Ellipse (linewidth=6)\n",
"clusters = hv.Points(c1) * hv.Points((c2x, c2y)) * hv.Points(c3)\n",
"clusters * hv.Ellipse(0,0, 4, orientation=np.pi/5, aspect=2) "
]
}
],
Expand Down
100 changes: 83 additions & 17 deletions holoviews/element/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ class BaseShape(Path):

__abstract = True


def clone(self, *args, **overrides):
"""
Returns a clone of the object with matching parameter values
Expand All @@ -148,7 +149,11 @@ def clone(self, *args, **overrides):
settings = dict(self.get_param_values(), **overrides)
if not args:
settings['plot_id'] = self._plot_id
return self.__class__(*args, **settings)

pos_args = getattr(self, '_' + type(self).__name__ + '__pos_params', [])
return self.__class__(*(settings[n] for n in pos_args),
**{k:v for k,v in settings.items()
if k not in pos_args})



Expand All @@ -162,26 +167,64 @@ class Box(BaseShape):

y = param.Number(default=0, doc="The y-position of the box center.")

width = param.Number(default=1, doc="The width of the box.")

height = param.Number(default=1, doc="The height of the box.")

aspect= param.Number(default=1, doc=""""
The aspect ratio of the box if supplied, otherwise an aspect
of 1.0 is used.""")
orientation = param.Number(default=0, doc="""
Orientation in the Cartesian coordinate system, the
counterclockwise angle in radians between the first axis and the
horizontal.""")

aspect= param.Number(default=1.0, doc="""
Optional multiplier applied to the box size to compute the
width in cases where only the length value is set.""")

group = param.String(default='Box', constant=True, doc="The assigned group name.")

def __init__(self, x, y, height, **params):
super(Box, self).__init__([], x=x,y =y, height=height, **params)
width = height * self.aspect
(l,b,r,t) = (x-width/2.0, y-height/2, x+width/2.0, y+height/2)
self.data = [np.array([(l, b), (l, t), (r, t), (r, b),(l, b)])]
__pos_params = ['x','y', 'height']

def __init__(self, x, y, spec, **params):

if isinstance(spec, tuple):
if 'aspect' in params:
raise ValueError('Aspect parameter not supported when supplying '
'(width, height) specification.')
(height, width) = spec
else:
width, height = params.get('width', spec), spec

params['width']=params.get('width',width)
super(Box, self).__init__([], x=x, y=y, height=height, **params)

half_width = (self.width * self.aspect)/ 2.0
half_height = self.height / 2.0
(l,b,r,t) = (x-half_width, y-half_height, x+half_width, y+half_height)

box = np.array([(l, b), (l, t), (r, t), (r, b),(l, b)])
rot = np.array([[np.cos(self.orientation), -np.sin(self.orientation)],
[np.sin(self.orientation), np.cos(self.orientation)]])

self.data = [np.tensordot(rot, box.T, axes=[1,0]).T]


class Ellipse(BaseShape):
"""
Draw an axis-aligned ellipse at the specified x,y position with
the given width, aspect ratio and orientation. By default
draws a circle (aspect=1).
the given orientation.
The simplest (default) Ellipse is a circle, specified using:
Ellipse(x,y, diameter)
A circle is a degenerate ellipse where the width and height are
equal. To specify these explicitly, you can use:
Ellipse(x,y, (width, height))
There is also an apect parameter allowing you to generate an ellipse
by specifying a multiplicating factor that will be applied to the
height only.
Note that as a subclass of Path, internally an Ellipse is a
sequency of (x,y) sample positions. Ellipse could also be
Expand All @@ -191,24 +234,45 @@ class Ellipse(BaseShape):

y = param.Number(default=0, doc="The y-position of the ellipse center.")

width = param.Number(default=1, doc="The width of the ellipse.")

height = param.Number(default=1, doc="The height of the ellipse.")

aspect= param.Number(default=1.0, doc="The aspect ratio of the ellipse.")
orientation = param.Number(default=0, doc="""
Orientation in the Cartesian coordinate system, the
counterclockwise angle in radians between the first axis and the
horizontal.""")

orientation = param.Number(default=0, doc="Orientation in the Cartesian coordinate system, the counterclockwise angle in radian between the first axis and the horizontal.")
aspect= param.Number(default=1.0, doc="""
Optional multiplier applied to the diameter to compute the width
in cases where only the diameter value is set.""")

samples = param.Number(default=100, doc="The sample count used to draw the ellipse.")

group = param.String(default='Ellipse', constant=True, doc="The assigned group name.")

def __init__(self, x, y, height, **params):
__pos_params = ['x','y', 'height']

def __init__(self, x, y, spec, **params):

if isinstance(spec, tuple):
if 'aspect' in params:
raise ValueError('Aspect parameter not supported when supplying '
'(width, height) specification.')
(width, height) = spec
else:
width, height = params.get('width', spec), spec

params['width']=params.get('width',width)
super(Ellipse, self).__init__([], x=x, y=y, height=height, **params)

angles = np.linspace(0, 2*np.pi, self.samples)
radius = height / 2.0
half_width = (self.width * self.aspect)/ 2.0
half_height = self.height / 2.0
#create points
ellipse = np.array(
list(zip(radius*self.aspect*np.sin(angles),
radius*np.cos(angles))))
list(zip(half_width*np.sin(angles),
half_height*np.cos(angles))))
#rotate ellipse and add offset
rot = np.array([[np.cos(self.orientation), -np.sin(self.orientation)],
[np.sin(self.orientation), np.cos(self.orientation)]])
Expand All @@ -230,6 +294,8 @@ class Bounds(BaseShape):

group = param.String(default='Bounds', constant=True, doc="The assigned group name.")


__pos_params = ['lbrt']
def __init__(self, lbrt, **params):
if not isinstance(lbrt, tuple):
lbrt = (-lbrt, -lbrt, lbrt, lbrt)
Expand Down
70 changes: 70 additions & 0 deletions tests/testpaths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""
Unit tests of Path types.
"""
import numpy as np
from holoviews import Ellipse, Box, Bounds
from holoviews.element.comparison import ComparisonTestCase


class EllipseTests(ComparisonTestCase):

def setUp(self):
self.pentagon = np.array([[ 0.00000000e+00, 5.00000000e-01],
[ 4.75528258e-01, 1.54508497e-01],
[ 2.93892626e-01, -4.04508497e-01],
[ -2.93892626e-01, -4.04508497e-01],
[ -4.75528258e-01, 1.54508497e-01],
[ -1.22464680e-16, 5.00000000e-01]])

self.squashed = np.array([[ 0.00000000e+00, 1.00000000e+00],
[ 4.75528258e-01, 3.09016994e-01],
[ 2.93892626e-01, -8.09016994e-01],
[ -2.93892626e-01, -8.09016994e-01],
[ -4.75528258e-01, 3.09016994e-01],
[ -1.22464680e-16, 1.00000000e+00]])


def test_ellipse_simple_constructor(self):
ellipse = Ellipse(0,0,1, samples=100)
self.assertEqual(len(ellipse.data[0]), 100)

def test_ellipse_simple_constructor_pentagon(self):
ellipse = Ellipse(0,0,1, samples=6)
self.assertEqual(np.allclose(ellipse.data[0], self.pentagon), True)

def test_ellipse_tuple_constructor_squashed(self):
ellipse = Ellipse(0,0,(1,2), samples=6)
self.assertEqual(np.allclose(ellipse.data[0], self.squashed), True)

def test_ellipse_simple_constructor_squashed_aspect(self):
ellipse = Ellipse(0,0,2, aspect=0.5, samples=6)
self.assertEqual(np.allclose(ellipse.data[0], self.squashed), True)


class BoxTests(ComparisonTestCase):

def setUp(self):
self.rotated_square = np.array([[-0.27059805, -0.65328148],
[-0.65328148, 0.27059805],
[ 0.27059805, 0.65328148],
[ 0.65328148, -0.27059805],
[-0.27059805, -0.65328148]])

self.rotated_rect = np.array([[-0.73253782, -0.8446232 ],
[-1.11522125, 0.07925633],
[ 0.73253782, 0.8446232 ],
[ 1.11522125, -0.07925633],
[-0.73253782, -0.8446232 ]])

def test_box_simple_constructor_rotated(self):
box = Box(0,0,1, orientation=np.pi/8)
self.assertEqual(np.allclose(box.data[0], self.rotated_square), True)


def test_box_tuple_constructor_rotated(self):
box = Box(0,0,(1,2), orientation=np.pi/8)
self.assertEqual(np.allclose(box.data[0], self.rotated_rect), True)

def test_box_aspect_constructor_rotated(self):
box = Box(0,0,1, aspect=2, orientation=np.pi/8)
self.assertEqual(np.allclose(box.data[0], self.rotated_rect), True)

0 comments on commit e0358d4

Please sign in to comment.