Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ellipse parameter now supports major/minor axis lengths #1509

Merged
merged 13 commits into from Jun 4, 2017
Merged
17 changes: 11 additions & 6 deletions examples/elements/bokeh/Box.ipynb
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
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
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
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
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', [])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessary? Why would self.__pos_params work?

Copy link
Contributor Author

@jlstevens jlstevens Jun 2, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why wouldn't it work? Name mangling due to the double underscore.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, instead of getattr? In this case it might work...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope just checked, name mangling is still an issue.

Copy link
Member

@philippjfr philippjfr Jun 2, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might not work actually, for that reason a single underscore may have been cleaner. There's a bunch of them now though, so up to you.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe single underscore would have messed up Layouts and AttrTrees. It was done like this for a reason but I can't quite remember all the details.

Copy link
Contributor Author

@jlstevens jlstevens Jun 2, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I partially remember - clone has to use getattr as not all elements need the positional parameters declared (most don't).

Calling getattr will create a path on AttrTrees resulting in an annoying entry which doesn't happen for double underscored entries. You have to be careful with getattr when AttrTrees are involved...

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
@@ -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)