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

[ENH]: Color along line by a value #28242

Open
eytanadler opened this issue May 17, 2024 · 11 comments
Open

[ENH]: Color along line by a value #28242

eytanadler opened this issue May 17, 2024 · 11 comments

Comments

@eytanadler
Copy link

Problem

I'd like to plot a path and assign a color or gradient along that path by some third value via a colormap. Current solutions seem to be hacking line collections (1) or using lots of scatter points to replicate a line (2, 3). The line collection approach appears to be the most common but can get ugly in regions with lots of curvature (see image). The limitation seems to be that lines and points in matplotlib can have only a single color. As far as I can tell, "continuous" gradients are supported only with imshow, which discretizes them. How challenging would it be to implement this feature properly? If the answer is "very challenging," is there a better workaround?

image

Proposed solution

No response

@jklymak
Copy link
Member

jklymak commented May 17, 2024

The work around is to over interpolate your data, which is essentially what would have to happen to make a continuous line anyway.

Whether marplotlib could try and do a better job with this is a reasonable question. We have long relied on the color line example, but it's not an unreasonable ask to make this its own api.

@timhoffm
Copy link
Member

No low-level backend natively supports multicolored lines. Thus, every Implenetation has to be made up of separate single-colored segments; i.e. you cannot get fundamentally better than what LineCollection offers.

But you may be able to use it more intelligently. There are two issues with a naive approach:

  • You define a line by it's nodes, but the colors need to apply to the connections, i.e. if you do a colored_line(x, y, c), x, y are length N, but c is length N-1. This is a technical requirement, but often as a user you have data at the points x, y that you want to encode in color.
  • if you draw isolated lines between the points, the connections are genreally not smooth (see your LineCollection example above). You can choose how the lines end with capstyles but with that you are not able in general to replicate joinstyles - well the best you could do is using a rounded cap, resulting in a rounded join, but you'll have one rounding on top of the other, which for dense points will look like a sequence of overlapping circles, similar to a dense scatter plot.

Taking these two together, it may be an option to center the colors around the points; i.e. calculate middle points in between your actual data points and make 3-point segments (mid_n-1, point_n, mid_n) since there is no angle change at the mid points, you can smoothly connect them using butt caps. The angle change at the point_n is a regular join and you can use whatever joinstyle you like. The start and end segments are only two-point segments. this solves the connection problem and uses as many colors as points.

image

@jklymak
Copy link
Member

jklymak commented May 17, 2024

@timhoffm this all seems correct to me and would avoid needing to overinterpolate.

@eytanadler
Copy link
Author

eytanadler commented May 17, 2024

Thanks for the suggestion @timhoffm! I tried all the approaches, and yours turned out great (and has the bonus of requiring no interpolation). I suppose the question now is whether this is functionality you'd be open to add to the matplotlib interface (I'd be happy to help with this), rather than every user having to do it manually. It seems like quite a few people are looking for similar functionality.

Source code to produce these figures
import numpy as np
from scipy.interpolate import splprep, splev
import matplotlib.pyplot as plt
from matplotlib.collections import LineCollection


# ==============================================================================
# Utilities to make setup and plotting easier
# ==============================================================================
# -------------- Line x, y, and color value sampler --------------
x = [0.0, 0.8, 0.75, 0.7, 1.5]
y = [0.0, 0.3, 1.0, 0.3, 0.0]
c = [0.0, 0.1, 0.5, 0.9, 1.0]
u = np.linspace(0, 1, len(x))

# Interpolate x, y, and c
spl, _ = splprep([x, y, c], u=u, s=0)

# Function to get x, y, and c values out at a parametric coordinate
line = lambda u_sample: splev(u_sample, spl)

# Suppose this is our "raw" data we'd like to plot
u_raw = np.linspace(0, 1, 100)
x_raw, y_raw, c_raw = line(u_raw)

# Use this colormap
cmap = "plasma"

# -------------- Function to set up axes and plot --------------
def plot_results(plotting_callback, outfile):
    fig, ax = plt.subplots()

    # Inset axes around peak
    width = 0.1
    height = 0.1
    y_shift = -0.025
    x1, x2 = x[2] - width / 2, x[2] + width / 2
    y1, y2 = y[2] - height / 2 + y_shift, y[2] + height / 2 + y_shift
    ax_zoom = ax.inset_axes([0.65, 0.55, 0.4, 0.4 * height / width])
    ax_zoom.set_xlim(x1, x2)
    ax_zoom.set_ylim(y1, y2)
    ax_zoom.set_xticks([])
    ax_zoom.set_yticks([])
    _, connectors = ax.indicate_inset_zoom(ax_zoom, edgecolor="k", alpha=1.0)
    for connector in connectors:
        connector.set_visible(False)

    for ax_cur, zoomed in zip([ax, ax_zoom], [False, True]):
        # Approximate the zoomed in scale by changing line width/scatter point size
        plotting_callback(ax_cur, zoomed)

    ax.set_aspect("equal")
    ax_zoom.set_aspect("equal")
    ax.axis("off")
    ax.set_xlim(-0.1, 1.6)
    ax.set_ylim(-0.2, 1.1)
    fig.savefig(outfile, bbox_inches="tight", dpi=400)
    plt.close(fig)


# ==============================================================================
# Line collection-based approaches
# ==============================================================================
# -------------- Original LineCollection approach with straight segment for each point --------------
def line_collection_orig(ax, zoomed):
    """
    Adapted from the top answer on https://stackoverflow.com/questions/8500700/how-to-plot-a-gradient-color-line
    """
    scale = 25 if zoomed else 4
    points = np.array([x_raw, y_raw]).T.reshape(-1, 1, 2)
    segments = np.concatenate([points[:-1, :], points[1:, :]], axis=1)
    lc = LineCollection(segments, array=c_raw, cmap=cmap, linewidth=2.0 * scale)

    ax.add_collection(lc)


plot_results(line_collection_orig, "colorline_line_collection_orig.jpeg")

# -------------- New LineCollection approach with 3-point segments to midpoints --------------
def line_collection_new(ax, zoomed):
    scale = 25 if zoomed else 4

    # Compute the midpoints of the line segments. Include the first and last points
    # twice so we don't need any special syntax to handle them.
    x_midpoints = np.hstack((x_raw[0], 0.5 * (x_raw[1:] + x_raw[:-1]), x_raw[-1]))
    y_midpoints = np.hstack((y_raw[0], 0.5 * (y_raw[1:] + y_raw[:-1]), y_raw[-1]))

    # Interlace the midpoints with the raw data
    new_size = x_raw.size * 2 + 1
    x_new = np.zeros(new_size)
    y_new = np.zeros(new_size)
    x_new[::2] = x_midpoints
    x_new[1::2] = x_raw
    y_new[::2] = y_midpoints
    y_new[1::2] = y_raw

    # Similar approach here to line_collection_orig, but use three points for each line segment
    points = np.array([x_new, y_new]).T.reshape(-1, 1, 2)
    segments = np.concatenate([points[:-1:2, :], points[1::2, :], points[2::2, :]], axis=1)
    lc = LineCollection(segments, array=c_raw, cmap=cmap, linewidth=2.0 * scale, joinstyle="bevel", capstyle="butt")

    ax.add_collection(lc)


plot_results(line_collection_new, "colorline_line_collection_new.jpeg")


# ==============================================================================
# Scatter-based approaches
# ==============================================================================
# -------------- Scatter the raw points --------------
def scatter(ax, zoomed):
    scale = 30 if zoomed else 1
    ax.scatter(x_raw, y_raw, 100 * scale, c=c_raw, cmap=cmap)


plot_results(scatter, "colorline_scatter.jpeg")

# -------------- Interpolate x, y, and color values and scatter them --------------
def scatter_interp(ax, zoomed):
    # Interpolate very finely
    u = np.linspace(0, 1, 2000)
    x, y, c = line(u)
    scale = 30 if zoomed else 1
    ax.scatter(x, y, 100 * scale, c=c, cmap=cmap)


plot_results(scatter_interp, "colorline_scatter_interp.jpeg")

Original LineCollection method I linked

This has the ugly gaps in regions of high curvature I mentioned in the original issue.
colorline_line_collection_orig

Suggested LineCollection method

This goes from midpoint to vertex to midpoint and looks very nice. It still has a few drawbacks, though minor compared to the gaps in the original:

  • Some of the segments can overlap each other on the inner side of a tight curve (see the bottom side of the peak in the zoomed in region)
  • I see white lines between the segments. I'm not sure if this is caused by the known problem with white lines between contourf levels, but I suspect not since it appears also when saved as a raster image. It could probably be resolved by slightly extrapolating each segment into the neighboring ones.
    colorline_line_collection_new

Interpolate and scatter

This approach looks nicest of all to me, but admittedly has quite a few limitations. The biggest of all is that you need to interpolate data to build it. This likely makes it a nonstarter for many cases. Another is that, when saved as a vector graphic, it is about 10x larger than the others because of the number of objects required.
colorline_scatter_interp

@timhoffm
Copy link
Member

@eytanadler Do you want to write up an example? I think this should replace https://matplotlib.org/devdocs/gallery/color/color_by_yvalue.html because it's a far superior solution.

@eytanadler
Copy link
Author

Sure, I can do that!

Do you think it's too much to include this functionality directly in the matplotlib API? It seems like a common request and a bit silly for people to manually implement this solution every time. I do get that it's a bit specialized, so I could see an argument for either way.

@timhoffm
Copy link
Member

Let's start with an example. Making this a core function requires careful API design. If we get something wrong, it's very hard to change later due to our back-compatibility policy. Also, the LineCollection method has the "white lines" issue, OTOH, scatter requires strong upsampling. So there's not yet the one optimal solution providing the high quality we aspire for core functionality.

@rcomer
Copy link
Member

rcomer commented May 22, 2024

Should this also replace this example?
https://matplotlib.org/stable/gallery/lines_bars_and_markers/multicolored_line.html

@QuLogic
Copy link
Member

QuLogic commented May 29, 2024

Coincidentally, I just came across #19286, which appears at first blush to implement things similarly? However, the contributor does seem to have disappeared. I didn't review any of the code, so I don't know what the state of the PR is right now.

@timhoffm
Copy link
Member

#19286 is only very loosely coupled. It creates a new GradientLineCollection subclass, that accepts gradient parameters. It then subsamples given line segments to allow gradual coloring. It's quite a special case and does not give new insights for this PR.

@ufukty
Copy link

ufukty commented May 30, 2024

I'm the contributor of #19286

My implementation was only intended to work for straight lines, without curvatures, and was based on implementations in the StackOverflow page that is shared in the issue. Since there are better suggestions like using scatter plot for smoother curve and color change, the PR is safe to ignore.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants