Skip to content

Render matplotlib path collections (violin plots, pcolor, stems, etc.) in plotly - #5702

Open
robertoffmoura wants to merge 1 commit into
plotly:mainfrom
robertoffmoura:rm/fix-violin-plot
Open

Render matplotlib path collections (violin plots, pcolor, stems, etc.) in plotly#5702
robertoffmoura wants to merge 1 commit into
plotly:mainfrom
robertoffmoura:rm/fix-violin-plot

Conversation

@robertoffmoura

Copy link
Copy Markdown
Contributor

mpl_to_plotly silently drops several common plot types. Violin plots, pcolor, event plots, stack plots, fill_between and stem plots all produce PolyCollection/LineCollection artists in data coordinates, which the renderer skipped with "Dang! That path collection is out of this world...". The converted figure ends up with zero traces (except for stem, which gets partially drawn).

Fix: data-coordinate path collections are now drawn:

  • collections with face colors (violin bodies, pcolor cells, stacked areas, fills) → filled polygons (fill="toself")
  • collections without face colors (contour lines, stem lines) → plain line traces
  • per-path colors/widths handled for collections that mix them; empty color arrays and degenerate paths skipped

Before: plots converts to 0 traces (empty figures).
After: plots render correctly.

Snippet to reproduce:

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import plotly.tools as tls

x = np.linspace(-3, 3, 30)
X, Y = np.meshgrid(x, x)
t = np.linspace(0, 2 * np.pi, 50)

cases = {
    "violinplot":   lambda: plt.violinplot(np.random.randn(100, 3)),
    "pcolor":       lambda: plt.pcolor(X, Y, np.sin(X) * np.cos(Y)),
    "eventplot":    lambda: plt.eventplot([np.random.randn(20) for _ in range(5)]),
    "stackplot":    lambda: plt.stackplot(np.arange(10), np.random.rand(10), np.random.rand(10), np.random.rand(10)),
    "fill_between": lambda: plt.fill_between(t, np.sin(t), np.cos(t)),
    "stem":         lambda: plt.stem(np.linspace(0, 2 * np.pi, 20), np.sin(np.linspace(0, 2 * np.pi, 20))),
}

for name, build in cases.items():
    fig, ax = plt.subplots()
    build()
    fig.savefig(f"{name}_mpl.png")

    p = tls.mpl_to_plotly(fig)   # 0 traces for all of these before the fix (except stem, which had some traces)
    p.write_image(f"{name}_plotly.png")

    print(f"{name:13s} -> {len(p.data)} traces")
Plot type Converted plotly figure
violinplot (12 traces) violinplot_plotly
pcolor (900 traces) pcolor_plotly
eventplot (100 traces) eventplot_plotly
stackplot (3 traces) stackplot_plotly
fill_between (1 trace) fill_between_plotly
stem (22 traces) stem_plotly

@camdecoster camdecoster left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the PR! Overall this is in good shape but needs a couple of changes. Could you also please add a changelog entry?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could you run the ruff formatter on this file?

facecolors = mpltools.convert_rgba_array(props["styles"]["facecolor"])
edgecolors = mpltools.convert_rgba_array(props["styles"]["edgecolor"])
linewidths = mpltools.convert_linewidth_array(props["styles"]["linewidth"])
alpha = props["styles"]["alpha"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This adds the opacity twice. convert_rgba_array already adds the alpha value. We don't handle this consistently right now, but I think this should be removed to avoid making the plot too transparent.

isinstance(c, str) for c in color
):
return [_export_color(c) for c in color]
bgcolor = export_color(color)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should this be _export_color? If this should be export_color, you'll need to import it from plotly/matplotlylib/mplexporter/utils.py.

n = len(colors)
except TypeError:
return colors
return colors[min(i, n - 1)] if n else default

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If i is greater than or equal to n, you'll get the same color for every call to per_path. To match the existing matplotlib behavior for the case of path length being greater than colors length, you could switch this logic a bit. This likely wouldn't happen much in practice, but this would cover us if it did come up.

Suggested change
return colors[min(i, n - 1)] if n else default
return colors[i % n] if n else default

linewidth = per_path(linewidths, i, 0)
self.plotly_fig.add_trace(
go.Scatter(
x=[v[0] for v in verts],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This line needs to handle the case where x values are dates. You can see how this is handled for the same situation on line 456. This is actually done in a few places. If you want to extract a helper function, this could be used in all those locations.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants