Skip to content

Fix manual contour label positions on sparse contours #1865

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

Merged
merged 8 commits into from
Apr 2, 2013
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions doc/users/whats_new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,16 @@ Andrew Dawson added the ability to add axes titles flush with the left and
right sides of the top of the axes using a new keyword argument `loc` to
:func:`~matplotlib.pyplot.title`.

Improved manual contour plot label positioning
----------------------------------------------

Brian Mattern modified the manual contour plot label positioning code to
interpolate along line segments and find the actual closest point on a
contour to the requested position. Previously, the closest path vertex was
used, which, in the case of straight contours was sometimes quite distant
from the requested location. Much more precise label positioning is now
possible.

.. _whats-new-1-2:

new in matplotlib-1.2
Expand Down
189 changes: 130 additions & 59 deletions lib/matplotlib/contour.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def get_rotation(self):


class ContourLabeler:
'''Mixin to provide labelling capability to ContourSet'''
"""Mixin to provide labelling capability to ContourSet"""

def clabel(self, *args, **kwargs):
"""
Expand Down Expand Up @@ -572,6 +572,18 @@ def add_label_near(self, x, y, inline=True, inline_spacing=5,
conmin, segmin, imin, xmin, ymin = self.find_nearest_contour(
x, y, self.labelIndiceList)[:5]

# The calc_label_rot_and_inline routine requires that (xmin,ymin)
# be a vertex in the path. So, if it isn't, add a vertex here
paths = self.collections[conmin].get_paths()
lc = paths[segmin].vertices
if transform:
xcmin = transform.inverted().transform([xmin, ymin])
else:
xcmin = np.array([xmin, ymin])
if not np.allclose(xcmin, lc[imin]):
lc = np.r_[lc[:imin], np.array(xcmin)[None, :], lc[imin:]]
paths[segmin] = mpath.Path(lc)

# Get index of nearest level in subset of levels used for labeling
lmin = self.labelIndiceList.index(conmin)

Expand Down Expand Up @@ -608,7 +620,7 @@ def add_label_near(self, x, y, inline=True, inline_spacing=5,
paths.append(mpath.Path(n))

def pop_label(self, index=-1):
'''Defaults to removing last label, but any index can be supplied'''
"""Defaults to removing last label, but any index can be supplied"""
self.labelCValues.pop(index)
t = self.labelTexts.pop(index)
t.remove()
Expand All @@ -621,8 +633,8 @@ def labels(self, inline, inline_spacing):
add_label = self.add_label

for icon, lev, fsize, cvalue in zip(
self.labelIndiceList, self.labelLevelList, self.labelFontSizeList,
self.labelCValueList):
self.labelIndiceList, self.labelLevelList,
self.labelFontSizeList, self.labelCValueList):

con = self.collections[icon]
trans = con.get_transform()
Expand Down Expand Up @@ -674,6 +686,64 @@ def labels(self, inline, inline_spacing):
paths.extend(additions)


def _find_closest_point_on_leg(p1, p2, p0):
"""find closest point to p0 on line segment connecting p1 and p2"""

# handle degenerate case
if np.all(p2 == p1):
d = np.sum((p0 - p1)**2)
return d, p1

d21 = p2 - p1
d01 = p0 - p1

# project on to line segment to find closest point
proj = np.dot(d01, d21) / np.dot(d21, d21)
if proj < 0:
proj = 0
if proj > 1:
proj = 1
pc = p1 + proj * d21

# find squared distance
d = np.sum((pc-p0)**2)

return d, pc


def _find_closest_point_on_path(lc, point):
"""
lc: coordinates of vertices
point: coordinates of test point
"""

# find index of closest vertex for this segment
ds = np.sum((lc - point[None, :])**2, 1)
imin = np.argmin(ds)

dmin = np.inf
xcmin = None
legmin = (None, None)

closed = mlab.is_closed_polygon(lc)

# build list of legs before and after this vertex
legs = []
if imin > 0 or closed:
legs.append(((imin-1) % len(lc), imin))
if imin < len(lc) - 1 or closed:
legs.append((imin, (imin+1) % len(lc)))

for leg in legs:
d, xc = _find_closest_point_on_leg(lc[leg[0]], lc[leg[1]], point)
if d < dmin:
dmin = d
xcmin = xc
legmin = leg

return (dmin, xcmin, legmin)


class ContourSet(cm.ScalarMappable, ContourLabeler):
"""
Store a set of contour lines or filled regions.
Expand Down Expand Up @@ -832,12 +902,13 @@ def __init__(self, ax, *args, **kwargs):
paths = self._make_paths(segs, kinds)
# Default zorder taken from Collection
zorder = kwargs.get('zorder', 1)
col = mcoll.PathCollection(paths,
antialiaseds=(self.antialiased,),
edgecolors='none',
alpha=self.alpha,
transform=self.get_transform(),
zorder=zorder)
col = mcoll.PathCollection(
paths,
antialiaseds=(self.antialiased,),
edgecolors='none',
alpha=self.alpha,
transform=self.get_transform(),
zorder=zorder)
self.ax.add_collection(col)
self.collections.append(col)
else:
Expand All @@ -851,13 +922,14 @@ def __init__(self, ax, *args, **kwargs):
zip(self.levels, tlinewidths, tlinestyles, self.allsegs):
# Default zorder taken from LineCollection
zorder = kwargs.get('zorder', 2)
col = mcoll.LineCollection(segs,
antialiaseds=aa,
linewidths=width,
linestyle=[lstyle],
alpha=self.alpha,
transform=self.get_transform(),
zorder=zorder)
col = mcoll.LineCollection(
segs,
antialiaseds=aa,
linewidths=width,
linestyle=[lstyle],
alpha=self.alpha,
transform=self.get_transform(),
zorder=zorder)
col.set_label('_nolegend_')
self.ax.add_collection(col, False)
self.collections.append(col)
Expand Down Expand Up @@ -902,29 +974,27 @@ def legend_elements(self, variable_name='x', str_format=str):
n_levels = len(self.collections)

for i, (collection, lower, upper) in enumerate(
zip(self.collections,
lowers, uppers)):
patch = mpatches.Rectangle(
(0, 0), 1, 1,
facecolor=collection.get_facecolor()[0],
hatch=collection.get_hatch(),
alpha=collection.get_alpha(),
)
artists.append(patch)

lower = str_format(lower)
upper = str_format(upper)

if i == 0 and self.extend in ('min', 'both'):
labels.append(r'$%s \leq %s$' % (variable_name,
lower))
elif i == n_levels - 1 and self.extend in ('max', 'both'):
labels.append(r'$%s > %s$' % (variable_name,
upper))
else:
labels.append(r'$%s < %s \leq %s$' % (lower,
variable_name,
upper))
zip(self.collections, lowers, uppers)):
patch = mpatches.Rectangle(
(0, 0), 1, 1,
facecolor=collection.get_facecolor()[0],
hatch=collection.get_hatch(),
alpha=collection.get_alpha())
artists.append(patch)

lower = str_format(lower)
upper = str_format(upper)

if i == 0 and self.extend in ('min', 'both'):
labels.append(r'$%s \leq %s$' % (variable_name,
lower))
elif i == n_levels - 1 and self.extend in ('max', 'both'):
labels.append(r'$%s > %s$' % (variable_name,
upper))
else:
labels.append(r'$%s < %s \leq %s$' % (lower,
variable_name,
upper))
else:
for collection, level in zip(self.collections, self.levels):

Expand Down Expand Up @@ -963,7 +1033,7 @@ def _process_args(self, *args, **kwargs):

# Check length of allkinds.
if (self.allkinds is not None and
len(self.allkinds) != len(self.allsegs)):
len(self.allkinds) != len(self.allsegs)):
raise ValueError('allkinds has different length to allsegs')

# Determine x,y bounds and update axes data limits.
Expand Down Expand Up @@ -1032,7 +1102,7 @@ def changed(self):
cm.ScalarMappable.changed(self)

def _autolev(self, z, N):
'''
"""
Select contour levels to span the data.

We need two more levels for filled contours than for
Expand All @@ -1041,7 +1111,7 @@ def _autolev(self, z, N):
a single contour boundary, say at z = 0, requires only
one contour line, but two filled regions, and therefore
three levels to provide boundaries for both regions.
'''
"""
if self.locator is None:
if self.logscale:
self.locator = ticker.LogLocator()
Expand Down Expand Up @@ -1210,11 +1280,11 @@ def _process_linestyles(self):
return tlinestyles

def get_alpha(self):
'''returns alpha to be applied to all ContourSet artists'''
"""returns alpha to be applied to all ContourSet artists"""
return self.alpha

def set_alpha(self, alpha):
'''sets alpha for all ContourSet artists'''
"""sets alpha for all ContourSet artists"""
self.alpha = alpha
self.changed()

Expand Down Expand Up @@ -1256,32 +1326,33 @@ def find_nearest_contour(self, x, y, indices=None, pixel=True):
if indices is None:
indices = range(len(self.levels))

dmin = 1e10
dmin = np.inf
conmin = None
segmin = None
xmin = None
ymin = None

point = np.array([x, y])

for icon in indices:
con = self.collections[icon]
trans = con.get_transform()
paths = con.get_paths()

for segNum, linepath in enumerate(paths):
lc = linepath.vertices

# transfer all data points to screen coordinates if desired
if pixel:
lc = trans.transform(lc)

ds = (lc[:, 0] - x) ** 2 + (lc[:, 1] - y) ** 2
d = min(ds)
d, xc, leg = _find_closest_point_on_path(lc, point)
if d < dmin:
dmin = d
conmin = icon
segmin = segNum
imin = mpl.mlab.find(ds == d)[0]
xmin = lc[imin, 0]
ymin = lc[imin, 1]
imin = leg[1]
xmin = xc[0]
ymin = xc[1]

return (conmin, segmin, imin, xmin, ymin, dmin)

Expand Down Expand Up @@ -1340,7 +1411,7 @@ def _process_args(self, *args, **kwargs):
# if the transform is not trans data, and some part of it
# contains transData, transform the xs and ys to data coordinates
if (t != self.ax.transData and
any(t.contains_branch_seperately(self.ax.transData))):
any(t.contains_branch_seperately(self.ax.transData))):
trans_to_data = t - self.ax.transData
pts = (np.vstack([x.flat, y.flat]).T)
transformed_pts = trans_to_data.transform(pts)
Expand Down Expand Up @@ -1408,14 +1479,14 @@ def _contour_args(self, args, kwargs):
return (x, y, z)

def _check_xyz(self, args, kwargs):
'''
"""
For functions like contour, check that the dimensions
of the input arrays match; if x and y are 1D, convert
them to 2D using meshgrid.

Possible change: I think we should make and use an ArgumentError
Exception class (here and elsewhere).
'''
"""
x, y = args[:2]
self.ax._process_unit_info(xdata=x, ydata=y, kwargs=kwargs)
x = self.ax.convert_xunits(x)
Expand Down Expand Up @@ -1450,11 +1521,11 @@ def _check_xyz(self, args, kwargs):

if x.shape != z.shape:
raise TypeError("Shape of x does not match that of z: found "
"{0} instead of {1}.".format(x.shape, z.shape))
"{0} instead of {1}.".format(x.shape, z.shape))

if y.shape != z.shape:
raise TypeError("Shape of y does not match that of z: found "
"{0} instead of {1}.".format(y.shape, z.shape))
"{0} instead of {1}.".format(y.shape, z.shape))

else:

Expand All @@ -1463,7 +1534,7 @@ def _check_xyz(self, args, kwargs):
return x, y, z

def _initialize_x_y(self, z):
'''
"""
Return X, Y arrays such that contour(Z) will match imshow(Z)
if origin is not None.
The center of pixel Z[i,j] depends on origin:
Expand All @@ -1474,7 +1545,7 @@ def _initialize_x_y(self, z):
as in imshow.
If origin is None and extent is not None, then extent
will give the minimum and maximum values of x and y.
'''
"""
if z.ndim != 2:
raise TypeError("Input must be a 2D array.")
else:
Expand Down
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading