8000 Fix shape rectangle when using rotation and the axes aspect ratio != 1 · matplotlib/matplotlib@75d48f3 · GitHub
[go: up one dir, main page]

Skip to content

Commit 75d48f3

Browse files
committed
Fix shape rectangle when using rotation and the axes aspect ratio != 1
1 parent 2036ee3 commit 75d48f3

File tree

4 files changed

+112
-55
lines changed

4 files changed

+112
-55
lines changed

lib/matplotlib/axes/_axes.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8149,3 +8149,10 @@ def violin(self, vpstats, positions=None, vert=True, widths=0.5,
81498149
tricontourf = mtri.tricontourf
81508150
tripcolor = mtri.tripcolor
81518151
triplot = mtri.triplot
8152+
8153+
def _get_aspect_ratio(self):
8154+
"""Convenience method to calculate aspect ratio of the axes."""
8155+
figure_size = self.get_figure().get_size_inches()
8156+
ll, ur = self.get_position() * figure_size
8157+
width, height = ur - ll
8158+
return height / width * self.get_data_ratio()

lib/matplotlib/patches.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,8 @@ def __init__(self, xy, width, height, angle=0.0,
740740
self._height = height
741741
self.angle = float(angle)
742742
self.rotate_around_center = rotate_around_center
743+
# Required for RectangleSelector with axes aspect ratio != 1
744+
self._aspect_ratio_correction = 1.0
743745
self._convert_units() # Validate the inputs.
744746

745747
def get_path(self):
@@ -765,9 +767,13 @@ def get_patch_transform(self):
765767
rotation_point = bbox.x0 + width / 2., bbox.y0 + height / 2.
766768
else:
767769
rotation_point = bbox.x0, bbox.y0
768-
return (transforms.BboxTransformTo(bbox)
769-
+ transforms.Affine2D().rotate_deg_around(
770-
*rotation_point, self.angle))
770+
return transforms.BboxTransformTo(bbox) \
771+
+ transforms.Affine2D() \
772+
.translate(-rotation_point[0], -rotation_point[1]) \
773+
.scale(1, self._aspect_ratio_correction) \
774+
.rotate_deg(self.angle) \
775+
.scale(1, 1 / self._aspect_ratio_correction) \
776+
.translate(*rotation_point)
771777

772778
def get_x(self):
773779
"""Return the left coordinate of the rectangle."""
@@ -1526,6 +1532,8 @@ def __init__(self, xy, width, height, angle=0, **kwargs):
15261532
self._width, self._height = width, height
15271533
self._angle = angle
15281534
self._path = Path.unit_circle()
1535+
# Required for EllipseSelector with axes aspect ratio != 1
1536+
self._aspect_ratio_correction = 1.0
15291537
# Note: This cannot be calculated until this is added to an Axes
15301538
self._patch_transform = transforms.IdentityTransform()
15311539

@@ -1543,8 +1551,9 @@ def _recompute_transform(self):
15431551
width = self.convert_xunits(self._width)
15441552
height = self.convert_yunits(self._height)
15451553
self._patch_transform = transforms.Affine2D() \
1546-
.scale(width * 0.5, height * 0.5) \
1554+
.scale(width * 0.5, height * 0.5 * self._aspect_ratio_correction) \
15471555
.rotate_deg(self.angle) \
1556+
.scale(1, 1 / self._aspect_ratio_correction) \
15481557
.translate(*center)
15491558

15501559
def get_path(self):

lib/matplotlib/tests/test_widgets.py

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -350,61 +350,61 @@ def onselect(epress, erelease):
350350
_resize_rectangle(tool, 70, 65, 120, 115)
351351
tool.add_default_state('square')
352352
tool.add_default_state('center')
353-
assert tool.extents == (70.0, 120.0, 65.0, 115.0)
353+
assert_allclose(tool.extents, (70.0, 120.0, 65.0, 115.0))
354354

355355
# resize NE handle
356356
extents = tool.extents
357357
xdata, ydata = extents[1], extents[3]
358358
xdiff, ydiff = 10, 5
359359
xdata_new, ydata_new = xdata + xdiff, ydata + ydiff
360360
_resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new)
361-
assert tool.extents == (extents[0] - xdiff, xdata_new,
362-
extents[2] - xdiff, extents[3] + xdiff)
361+
assert_allclose(tool.extents, (extents[0] - xdiff, xdata_new,
362+
extents[2] - xdiff, extents[3] + xdiff))
363363

364364
# resize E handle
365365
extents = tool.extents
366366
xdata, ydata = extents[1], extents[2] + (extents[3] - extents[2]) / 2
367367
xdiff = 10
368368
xdata_new, ydata_new = xdata + xdiff, ydata
369369
_resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new)
370-
assert tool.extents == (extents[0] - xdiff, xdata_new,
371-
extents[2] - xdiff, extents[3] + xdiff)
370+
assert_allclose(tool.extents, (extents[0] - xdiff, xdata_new,
371+
extents[2] - xdiff, extents[3] + xdiff))
372372

373373
# resize E handle negative diff
374374
extents = tool.extents
375375
xdata, ydata = extents[1], extents[2] + (extents[3] - extents[2]) / 2
376376
xdiff = -20
377377
xdata_new, ydata_new = xdata + xdiff, ydata
378378
_resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new)
379-
assert tool.extents == (extents[0] - xdiff, xdata_new,
380-
extents[2] - xdiff, extents[3] + xdiff)
379+
assert_allclose(tool.extents, (extents[0] - xdiff, xdata_new,
380+
extents[2] - xdiff, extents[3] + xdiff))
381381

382382
# resize W handle
383383
extents = tool.extents
384384
xdata, ydata = extents[0], extents[2] + (extents[3] - extents[2]) / 2
385385
xdiff = 5
386386
xdata_new, ydata_new = xdata + xdiff, ydata
387387
_resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new)
388-
assert tool.extents == (xdata_new, extents[1] - xdiff,
389-
extents[2] + xdiff, extents[3] - xdiff)
388+
assert_allclose(tool.extents, (xdata_new, extents[1] - xdiff,
389+
extents[2] + xdiff, extents[3] - xdiff))
390390

391391
# resize W handle negative diff
392392
extents = tool.extents
393393
xdata, ydata = extents[0], extents[2] + (extents[3] - extents[2]) / 2
394394
xdiff = -25
395395
xdata_new, ydata_new = xdata + xdiff, ydata
396396
_resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new)
397-
assert tool.extents == (xdata_new, extents[1] - xdiff,
398-
extents[2] + xdiff, extents[3] - xdiff)
397+
assert_allclose(tool.extents, (xdata_new, extents[1] - xdiff,
398+
extents[2] + xdiff, extents[3] - xdiff))
399399

400400
# resize SW handle
401401
extents = tool.extents
402402
xdata, ydata = extents[0], extents[2]
403403
xdiff, ydiff = 20, 25
404404
xdata_new, ydata_new = xdata + xdiff, ydata + ydiff
405405
_resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new)
406-
assert tool.extents == (extents[0] + ydiff, extents[1] - ydiff,
407-
ydata_new, extents[3] - ydiff)
406+
assert_allclose(tool.extents, (extents[0] + ydiff, extents[1] - ydiff,
407+
ydata_new, extents[3] - ydiff))
408408

409409

410410
@pytest.mark.parametrize('selector_class',
@@ -421,26 +421,35 @@ def onselect(epress, erelease):
421421
do_event(tool, 'onmove', xdata=130, ydata=140)
422422
do_event(tool, 'release', xdata=130, ydata=140)
423423
assert tool.extents == (100, 130, 100, 140)
424+
assert len(tool._default_state) == 0
425+
assert len(tool._state) == 0
424426

425427
# Rotate anticlockwise using top-right corner
426428
do_event(tool, 'on_key_press', key='r')
429+
assert tool._default_state == set(['rotate'])
430+
assert len(tool._ F438 state) == 0
427431
do_event(tool, 'press', xdata=130, ydata=140)
428-
do_event(tool, 'onmove', xdata=110, ydata=145)
429-
do_event(tool, 'release', xdata=110, ydata=145)
432+
do_event(tool, 'onmove', xdata=120, ydata=145)
433+
do_event(tool, 'release', xdata=120, ydata=145)
430434
do_event(tool, 'on_key_press', key='r')
435+
assert len(tool._default_state) == 0
436+
assert len(tool._state) == 0
431437
# Extents shouldn't change (as shape of rectangle hasn't changed)
432438
assert tool.extents == (100, 130, 100, 140)
439+
assert_allclose(tool.rotation, 25.56, atol=0.01)
440+
tool.rotation = 45
441+
assert tool.rotation == 45
433442
# Corners should move
434443
# The third corner is at (100, 145)
435444
assert_allclose(tool.corners,
436-
np.array([[119.9, 139.9, 110.1, 90.1],
437-
[95.4, 117.8, 144.5, 122.2]]), atol=0.1)
445+
np.array([[118.53, 139.75, 111.46, 90.25],
446+
[95.25, 116.46, 144.75, 123.54]]), atol=0.01)
438447

439448
# Scale using top-right corner
440449
do_event(tool, 'press', xdata=110, ydata=145)
441450
do_event(tool, 'onmove', xdata=110, ydata=160)
442451
do_event(tool, 'release', xdata=110, ydata=160)
443-
assert_allclose(tool.extents, (100, 141.5, 100, 150.4), atol=0.1)
452+
assert_allclose(tool.extents, (100, 139.75, 100, 151.82), atol=0.01)
444453

445454

446455
def test_rectangle_resize_square_center_aspect():
@@ -462,6 +471,7 @@ def onselect(epress, erelease):
462471
xdata, ydata = extents[1], extents[3]
463472
xdiff = 10
464473
xdata_new, ydata_new = xdata + xdiff, ydata
474+
ychange = xdiff * 1 / tool._aspect_ratio_correction
465475
_resize_rectangle(tool, xdata, ydata, xdata_new, ydata_new)
466476
assert_allclose(tool.extents, [extents[0] - xdiff, xdata_new,
467477
46.25, 133.75])

lib/matplotlib/widgets.py

Lines changed: 64 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1934,9 +1934,8 @@ def press(self, event):
19341934
key = event.key or ''
19351935
key = key.replace('ctrl', 'control')
19361936
# move state is locked in on a button press
1937-
for state in ['move']:
1938-
if key == self._state_modifier_keys[state]:
1939-
self._state.add(state)
1937+
if key == self._state_modifier_keys['move']:
1938+
self._state.add('move')
19401939
self._press(event)
19411940
return True
19421941
return False
@@ -1989,14 +1988,10 @@ def on_key_press(self, event):
19891988
self.update()
19901989
return
19911990
for (state, modifier) in self._state_modifier_keys.items():
1992-
if modifier in key.split('+'):
1993-
# rotate and data_coordinates are enable/disable
1994-
# on key press
1995-
if (state in ['rotate', 'data_coordinates'] and
1996-
state in self._state):
1997-
self._state.discard(state)
1998-
else:
1999-
self._state.add(state)
1991+
# 'rotate' and 'data_coordinates' are added in _default_state
1992+
if (modifier in key.split('+') and
1993+
state not in ['rotate', 'data_coordinates']):
1994+
sel 10000 f._state.add(state)
20001995
self._on_key_press(event)
20011996

20021997
def _on_key_press(self, event):
@@ -2006,9 +2001,9 @@ def on_key_release(self, event):
20062001
"""Key release event handler and validator."""
20072002
if self.active:
20082003
key = event.key or ''
2004+
key = key.replace('ctrl', 'control')
20092005
for (state, modifier) in self._state_modifier_keys.items():
2010-
if (modifier in key.split('+') and
2011-
state not in ['rotate', 'data_coordinates']):
2006+
if modifier in key.split('+'):
20122007
self._state.discard(state)
20132008
self._on_key_release(event)
20142009

@@ -2835,7 +2830,8 @@ def __init__(self, ax, onselect, drawtype='box',
28352830
self._interactive = interactive
28362831
self.drag_from_anywhere = drag_from_anywhere
28372832
self.ignore_event_outside = ignore_event_outside
2838-
self._rotation = 0
2833+
self._rotation = 0.0
2834+
self._aspect_ratio_correction = 1.0
28392835

28402836
if drawtype == 'none': # draw a line but make it invisible
28412837
_api.warn_deprecated(
@@ -2869,6 +2865,7 @@ def __init__(self, ax, onselect, drawtype='box',
28692865
self.ax.add_line(to_draw)
28702866

28712867
self._selection_artist = to_draw
2868+
self._set_aspect_ratio_correction()
28722869

28732870
self.minspanx = minspanx
28742871
self.minspany = minspany
@@ -2952,6 +2949,8 @@ def _press(self, event):
29522949
self.set_visible(True)
29532950

29542951
self._extents_on_press = self.extents
2952+
self._rotation_on_press = self._rotation
2953+
self._set_aspect_ratio_correction()
29552954

29562955
return False
29572956

@@ -3027,13 +3026,8 @@ def _onmove(self, event):
30273026
dy = event.ydata - eventpress.ydata
30283027
refmax = None
30293028
if 'data_coordinates' in state:
3030-
aspect_ratio = 1
30313029
refx, refy = dx, dy
30323030
else:
3033-
figure_size = self.ax.get_figure().get_size_inches()
3034-
ll, ur = self.ax.get_position() * figure_size
3035-
width, height = ur - ll
3036-
aspect_ratio = height / width * self.ax.get_data_ratio()
30373031
refx = event.xdata / (eventpress.xdata + 1e-6)
30383032
refy = event.ydata / (eventpress.ydata + 1e-6)
30393033

@@ -3044,8 +3038,9 @@ def _onmove(self, event):
30443038
a = np.array([eventpress.xdata, eventpress.ydata])
30453039
b = np.array(self.center)
30463040
c = np.array([event.xdata, event.ydata])
3047-
self._rotation = (np.arctan2(c[1]-b[1], c[0]-b[0]) -
3048-
np.arctan2(a[1]-b[1], a[0]-b[0]))
3041+
angle = (np.arctan2(c[1]-b[1], c[0]-b[0]) -
3042+
np.arctan2(a[1]-b[1], a[0]-b[0]))
3043+
self.rotation = np.rad2deg(self._rotation_on_press + angle)
30493044

30503045
# resize an existing shape
30513046
elif self._active_handle and self._active_handle != 'C':
@@ -3060,10 +3055,10 @@ def _onmove(self, event):
30603055
refmax = max(refx, refy, key=abs)
30613056
if self._active_handle in ['E', 'W'] or refmax == refx:
30623057
dw = event.xdata - center[0]
3063-
dh = dw / aspect_ratio
3058+
dh = dw / self._aspect_ratio_correction
30643059
else:
30653060
dh = event.ydata - center[1]
3066-
dw = dh * aspect_ratio
3061+
dw = dh * self._aspect_ratio_correction
30673062
else:
30683063
dw = sizepress[0] / 2
30693064
dh = sizepress[1] / 2
@@ -3096,10 +3091,12 @@ def _onmove(self, event):
30963091
refmax = max(refx, refy, key=abs)
30973092
if self._active_handle in ['E', 'W'] or refmax == refx:
30983093
sign = np.sign(event.ydata - y0)
3099-
y1 = y0 + sign * abs(x1 - x0) / aspect_ratio
3094+
y1 = y0 + sign * abs(x1 - x0) / \
3095+
self._aspect_ratio_correction
31003096
else:
31013097
sign = np.sign(event.xdata - x0)
3102-
x1 = x0 + sign * abs(y1 - y0) * aspect_ratio
3098+
x1 = x0 + sign * abs(y1 - y0) * \
3099+
self._aspect_ratio_correction
31033100

31043101
# move existing shape
31053102
elif self._active_handle == 'C':
@@ -3126,9 +3123,9 @@ def _onmove(self, event):
31263123
if 'square' in state:
31273124
refmax = max(refx, refy, key=abs)
31283125
if refmax == refx:
3129-
dy = dx / aspect_ratio
3126+
dy = np.sign(dy) * abs(dx) / self._aspect_ratio_correction
31303127
else:
3131-
dx = dy * aspect_ratio
3128+
dx = np.sign(dx) * abs(dy) * self._aspect_ratio_correction
31323129

31333130
# from center
31343131
if 'center' in state:
@@ -3145,6 +3142,18 @@ def _onmove(self, event):
31453142

31463143
self.extents = x0, x1, y0, y1
31473144

3145+
def _on_key_press(self, event):
3146+
key = event.key or ''
3147+
key = key.replace('ctrl', 'control')
3148+
for (state, modifier) in self._state_modifier_keys.items():
3149+
if modifier in key.split('+'):
3150+
if state in ['rotate', 'data_coordinates']:
3151+
if state in self._default_state:
3152+
self._default_state.discard(state)
3153+
else:
3154+
self._default_state.add(state)
3155+
self._set_aspect_ratio_correction()
3156+
31483157
@property
31493158
def _rect_bbox(self):
31503159
if self._drawtype == 'box':
@@ -3155,8 +3164,27 @@ def _rect_bbox(self):
31553164
y0, y1 = min(y), max(y)
31563165
return x0, y0, x1 - x0, y1 - y0
31573166

3167+
def _set_aspect_ratio_correction(self):
3168+
aspect_ratio = self.ax._get_aspect_ratio()
3169+
if not hasattr(self._selection_artist, '_aspect_ratio_correction'):
3170+
# Aspect ratio correction is not supported with deprecated
3171+
# drawtype='line'. Remove this block in matplotlib 3.7
3172+
self._aspect_ratio_correction = 1
3173+
return
3174+
3175+
self._selection_artist._aspect_ratio_correction = aspect_ratio
3176+
if 'data_coordinates' in self._state | self._default_state:
3177+
self._aspect_ratio_correction = 1
3178+
else:
3179+
self._aspect_ratio_correction = aspect_ratio
3180+
31583181
def _get_rotation_transform(self):
3159-
return Affine2D().rotate_around(*self.center, self._rotation)
3182+
aspect_ratio = self.ax._get_aspect_ratio()
3183+
return Affine2D().translate(-self.center[0], -self.center[1]) \
3184+
.scale(1, aspect_ratio) \
3185+
.rotate(self._rotation) \
3186+
.scale(1, 1 / aspect_ratio) \
3187+
.translate(*self.center)
31603188

31613189
@property
31623190
def corners(self):
@@ -3211,14 +3239,17 @@ def extents(self, extents):
32113239

32123240
@property
32133241
def rotation(self):
3214-
"""Rotation in degree."""
3242+
"""Rotation in degree in interval [0, 45]."""
32153243
return np.rad2deg(self._rotation)
32163244

32173245
@rotation.setter
32183246
def rotation(self, value):
3219-
self._rotation = np.deg2rad(value)
3220-
# call extents setter to draw shape and update handles positions
3221-
self.extents = self.extents
3247+
# Restrict to a limited range of rotation [0, 45] to avoid changing
3248+
# order of handles
3249+
if 0 <= value and value <= 45:
3250+
self._rotation = np.deg2rad(value)
3251+
# call extents setter to draw shape and update handles positions
3252+
self.extents = self.extents
32223253

32233254
draw_shape = _api.deprecate_privatize_attribute('3.5')
32243255

@@ -3329,7 +3360,7 @@ def _draw_shape(self, extents):
33293360
self._selection_artist.center = center
33303361
self._selection_artist.width = 2 * a
33313362
self._selection_artist.height = 2 * b
3332-
self._selection_artist.set_angle(self.rotation)
3363+
self._selection_artist.angle = self.rotation
33333364
else:
33343365
rad = np.deg2rad(np.arange(31) * 12)
33353366
x = a * np.cos(rad) + center[0]

0 commit comments

Comments
 (0)
0