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

Skip to content

Commit 9349727

Browse files
committed
Fix shape rectangle when using rotation and the axes aspect ratio != 1
1 parent 928e662 commit 9349727

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
@@ -8094,3 +8094,10 @@ def violin(self, vpstats, positions=None, vert=True, widths=0.5,
80948094
tricontourf = mtri.tricontourf
80958095
tripcolor = mtri.tripcolor
80968096
triplot = mtri.triplot
8097+
8098+
def _get_aspect_ratio(self):
8099+
6D40 """Convenience method to calculate aspect ratio of the axes."""
8100+
figure_size = self.get_figure().get_size_inches()
8101+
ll, ur = self.get_position() * figure_size
8102+
width, height = ur - ll
8103+
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
@@ -737,6 +737,8 @@ def __init__(self, xy, width, height, angle=0.0,
737737
self._height = height
738738
self.angle = float(angle)
739739
self.rotate_around_center = rotate_around_center
740+
# Required for RectangleSelector with axes aspect ratio != 1
741+
self._aspect_ratio_correction = 1.0
740742
self._convert_units() # Validate the inputs.
741743

742744
def get_path(self):
@@ -762,9 +764,13 @@ def get_patch_transform(self):
762764
rotation_point = bbox.x0 + width / 2., bbox.y0 + height / 2.
763765
else:
764766
rotation_point = bbox.x0, bbox.y0
765-
return (transforms.BboxTransformTo(bbox)
766-
+ transforms.Affine2D().rotate_deg_around(
767-
*rotation_point, self.angle))
767+
return transforms.BboxTransformTo(bbox) \
768+
+ transforms.Affine2D() \
769+
.translate(-rotation_point[0], -rotation_point[1]) \
770+
.scale(1, self._aspect_ratio_correction) \
771+
.rotate_deg(self.angle) \
772+
.scale(1, 1 / self._aspect_ratio_correction) \
773+
.translate(*rotation_point)
768774

769775
def get_x(self):
770776
"""Return the left coordinate of the rectangle."""
@@ -1523,6 +1529,8 @@ def __init__(self, xy, width, height, angle=0, **kwargs):
15231529
self._width, self._height = width, height
15241530
self._angle = angle
15251531
self._path = Path.unit_circle()
1532+
# Required for EllipseSelector with axes aspect ratio != 1
1533+
self._aspect_ratio_correction = 1.0
15261534
# Note: This cannot be calculated until this is added to an Axes
15271535
self._patch_transform = transforms.IdentityTransform()
15281536

@@ -1540,8 +1548,9 @@ def _recompute_transform(self):
15401548
width = self.convert_xunits(self._width)
15411549
height = self.convert_yunits(self._height)
15421550
self._patch_transform = transforms.Affine2D() \
1543-
.scale(width * 0.5, height * 0.5) \
1551+
.scale(width * 0.5, height * 0.5 * self._aspect_ratio_correction) \
15441552
.rotate_deg(self.angle) \
1553+
.scale(1, 1 / self._aspect_ratio_correction) \
15451554
.translate(*center)
15461555

15471556
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._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
@@ -1947,9 +1947,8 @@ def press(self, event):
19471947
key = event.key or ''
19481948
key = key.replace('ctrl', 'control')
19491949
# move state is locked in on a button press
1950-
for state in ['move']:
1951-
if key == self._state_modifier_keys[state]:
1952-
self._state.add(state)
1950+
if key == self._state_modifier_keys['move']:
1951+
self._state.add('move')
19531952
self._press(event)
19541953
return True
19551954
return False
@@ -2000,14 +1999,10 @@ def on_key_press(self, event):
20001999
self.clear()
20012000
return
20022001
for (state, modifier) in self._state_modifier_keys.items():
2003-
if modifier in key.split('+'):
2004-
# rotate and data_coordinates are enable/disable
2005-
# on key press
2006-
if (state in ['rotate', 'data_coordinates'] and
2007-
state in self._state):
2008-
self._state.discard(state)
2009-
else:
2010-
self._state.add(state)
2002+
# 'rotate' and 'data_coordinates' are added in _default_state
2003+
if (modifier in key.split('+') and
2004+
state not in ['rotate', 'data_coordinates']):
2005+
self._state.add(state)
20112006
self._on_key_press(event)
20122007

20132008
def _on_key_press(self, event):
@@ -2017,9 +2012,9 @@ def on_key_release(self, event):
20172012
"""Key release event handler and validator."""
20182013
if self.active:
20192014
key = event.key or ''
2015+
key = key.replace('ctrl', 'control')
20202016
for (state, modifier) in self._state_modifier_keys.items():
2021-
if (modifier in key.split('+') and
2022-
state not in ['rotate', 'data_coordinates']):
2017+
if modifier in key.split('+'):
20232018
self._state.discard(state)
20242019
self._on_key_release(event)
20252020

@@ -2858,7 +2853,8 @@ def __init__(self, ax, onselect, drawtype='box',
28582853
self._interactive = interactive
28592854
self.drag_from_anywhere = drag_from_anywhere
28602855
self.ignore_event_outside = ignore_event_outside
2861-
self._rotation = 0
2856+
self._rotation = 0.0
2857+
self._aspect_ratio_correction = 1.0
28622858

28632859
if drawtype == 'none': # draw a line but make it invisible
28642860
_api.warn_deprecated(
@@ -2892,6 +2888,7 @@ def __init__(self, ax, onselect, drawtype='box',
28922888
self.ax.add_line(to_draw)
28932889

28942890
self._selection_artist = to_draw
2891+
self._set_aspect_ratio_correction()
28952892

28962893
self.minspanx = minspanx
28972894
self.minspany = minspany
@@ -2975,6 +2972,8 @@ def _press(self, event):
29752972
self.set_visible(True)
29762973

29772974
self._extents_on_press = self.extents
2975+
self._rotation_on_press = self._rotation
2976+
self._set_aspect_ratio_correction()
29782977

29792978
return False
29802979

@@ -3050,13 +3049,8 @@ def _onmove(self, event):
30503049
dy = event.ydata - eventpress.ydata
30513050
refmax = None
30523051
if 'data_coordinates' in state:
3053-
aspect_ratio = 1
30543052
refx, refy = dx, dy
30553053
else:
3056-
figure_size = self.ax.get_figure().get_size_inches()
3057-
ll, ur = self.ax.get_position() * figure_size
3058-
width, height = ur - ll
3059-
aspect_ratio = height / width * self.ax.get_data_ratio()
30603054
refx = event.xdata / (eventpress.xdata + 1e-6)
30613055
refy = event.ydata / (eventpress.ydata + 1e-6)
30623056

@@ -3067,8 +3061,9 @@ def _onmove(self, event):
30673061
a = np.array([eventpress.xdata, eventpress.ydata])
30683062
b = np.array(self.center)
30693063
c = np.array([event.xdata, event.ydata])
3070-
self._rotation = (np.arctan2(c[1]-b[1], c[0]-b[0]) -
3071-
np.arctan2(a[1]-b[1], a[0]-b[0]))
3064+
angle = (np.arctan2(c[1]-b[1], c[0]-b[0]) -
3065+
np.arctan2(a[1]-b[1], a[0]-b[0]))
3066+
self.rotation = np.rad2deg(self._rotation_on_press + angle)
30723067

30733068
# resize an existing shape
30743069
elif self._active_handle and self._active_handle != 'C':
@@ -3083,10 +3078,10 @@ def _onmove(self, event):
30833078
refmax = max(refx, refy, key=abs)
30843079
if self._active_handle in ['E', 'W'] or refmax == refx:
30853080
hw = event.xdata - center[0]
3086-
hh = hw / aspect_ratio
3081+
hh = hw / self._aspect_ratio_correction
30873082
else:
30883083
hh = event.ydata - center[1]
3089-
hw = hh * aspect_ratio
3084+
hw = hh * self._aspect_ratio_correction
30903085
else:
30913086
hw = size_on_press[0] / 2
30923087
hh = size_on_press[1] / 2
@@ -3119,10 +3114,12 @@ def _onmove(self, event):
31193114
refmax = max(refx, refy, key=abs)
31203115
if self._active_handle in ['E', 'W'] or refmax == refx:
31213116
sign = np.sign(event.ydata - y0)
3122-
y1 = y0 + sign * abs(x1 - x0) / aspect_ratio
3117+
y1 = y0 + sign * abs(x1 - x0) / \
3118+
self._aspect_ratio_correction
31233119
else:
31243120
sign = np.sign(event.xdata - x0)
3125-
x1 = x0 + sign * abs(y1 - y0) * aspect_ratio
3121+
x1 = x0 + sign * abs(y1 - y0) * \
3122+
self._aspect_ratio_correction
31263123

31273124
# move existing shape
31283125
elif self._active_handle == 'C':
@@ -3149,9 +3146,9 @@ def _onmove(self, event):
31493146
if 'square' in state:
31503147
refmax = max(refx, refy, key=abs)
31513148
if refmax == refx:
3152-
dy = dx / aspect_ratio
3149+
dy = np.sign(dy) * abs(dx) / self._aspect_ratio_correction
31533150
else:
3154-
dx = dy * aspect_ratio
3151+
dx = np.sign(dx) * abs(dy) * self._aspect_ratio_correction
31553152

31563153
# from center
31573154
if 'center' in state:
@@ -3168,6 +3165,18 @@ def _onmove(self, event):
31683165

31693166
self.extents = x0, x1, y0, y1
31703167

3168+
def _on_key_press(self, event):
3169+
key = event.key or ''
3170+
key = key.replace('ctrl', 'control')
3171+
for (state, modifier) in self._state_modifier_keys.items():
3172+
if modifier in key.split('+'):
3173+
if state in ['rotate', 'data_coordinates']:
3174+
if state in self._default_state:
3175+
self._default_state.discard(state)
3176+
else:
3177+
self._default_state.add(state)
3178+
self._set_aspect_ratio_correction()
3179+
31713180
@property
31723181
def _rect_bbox(self):
31733182
if self._drawtype == 'box':
@@ -3178,8 +3187,27 @@ def _rect_bbox(self):
31783187
y0, y1 = min(y 10000 ), max(y)
31793188
return x0, y0, x1 - x0, y1 - y0
31803189

3190+
def _set_aspect_ratio_correction(self):
3191+
aspect_ratio = self.ax._get_aspect_ratio()
3192+
if not hasattr(self._selection_artist, '_aspect_ratio_correction'):
3193+
# Aspect ratio correction is not supported with deprecated
3194+
# drawtype='line'. Remove this block in matplotlib 3.7
3195+
self._aspect_ratio_correction = 1
3196+
return
3197+
3198+
self._selection_artist._aspect_ratio_correction = aspect_ratio
3199+
if 'data_coordinates' in self._state | self._default_state:
3200+
self._aspect_ratio_correction = 1
3201+
else:
3202+
self._aspect_ratio_correction = aspect_ratio
3203+
31813204
def _get_rotation_transform(self):
3182-
return Affine2D().rotate_around(*self.center, self._rotation)
3205+
aspect_ratio = self.ax._get_aspect_ratio()
3206+
return Affine2D().translate(-self.center[0], -self.center[1]) \
3207+
.scale(1, aspect_ratio) \
3208+
.rotate(self._rotation) \
3209+
.scale(1, 1 / aspect_ratio) \
3210+
.translate(*self.center)
31833211

31843212
@property
31853213
def corners(self):
@@ -3234,14 +3262,17 @@ def extents(self, extents):
32343262

32353263
@property
32363264
def rotation(self):
3237-
"""Rotation in degree."""
3265+
"""Rotation in degree in interval [0, 45]."""
32383266
return np.rad2deg(self._rotation)
32393267

32403268
@rotation.setter
32413269
def rotation(self, value):
3242-
self._rotation = np.deg2rad(value)
3243-
# call extents setter to draw shape and update handles positions
3244-
self.extents = self.extents
3270+
# Restrict to a limited range of rotation [0, 45] to avoid changing
3271+
# order of handles
3272+
if 0 <= value and value <= 45:
3273+
self._rotation = np.deg2rad(value)
3274+
# call extents setter to draw shape and update handles positions
3275+
self.extents = self.extents
32453276

32463277
draw_shape = _api.deprecate_privatize_attribute('3.5')
32473278

@@ -3352,7 +3383,7 @@ def _draw_shape(self, extents):
33523383
self._selection_artist.center = center
33533384
self._selection_artist.width = 2 * a
33543385
self._selection_artist.height = 2 * b
3355-
self._selection_artist.set_angle(self.rotation)
3386+
self._selection_artist.angle = self.rotation
33563387
else:
33573388
rad = np.deg2rad(np.arange(31) * 12)
33583389
x = a * np.cos(rad) + center[0]

0 commit comments

Comments
 (0)
0