Skip to content

Commit 893e977

Browse files
authored
Merge pull request #5700 from robertoffmoura/rm/fix-boxplot
Fix boxplot conversion by mapping 'none' colors to transparent rgba
2 parents d3d7e4d + bbf39a8 commit 893e977

4 files changed

Lines changed: 143 additions & 38 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
1010
- Fix `mpl_to_plotly` crashing on touching bars (such as `plt.hist`) due to floating-point noise producing negative `bargap` values by clamping `bargap` to `[0, 1]` [[#5696](https://github.com/plotly/plotly.py/pull/5696)], with thanks to @robertoffmoura for the contribution!
1111
- Fix `mpl_to_plotly` drawing step plots with diagonal lines instead of vertical/horizontal step segments by mapping matplotlib step drawstyles (`steps-pre`, `steps-post`, `steps-mid`) to plotly line shapes [[#5697](https://github.com/plotly/plotly.py/pull/5697)], with thanks to @robertoffmoura for the contribution!
1212
- Fix `mpl_to_plotly` showing unwanted legends with placeholder or internal label entries (`_nolegend_`, `_childN`) by defaulting `layout.showlegend=False` unless the matplotlib figure explicitly defines a legend [[#5699](https://github.com/plotly/plotly.py/pull/5699)], with thanks to @robertoffmoura for the contribution!
13+
- Fix `mpl_to_plotly` crashing on figures with fully transparent or alpha-carrying colors, such as boxplot outlier markers (`facecolor="none"`), rgba line colors, and `color="none"` text and axis labels; all matplotlib colors are now exported through a single helper that maps `"none"` to transparent rgba and keeps or overrides alpha as matplotlib does [[#5700](https://github.com/plotly/plotly.py/pull/5700)], with thanks to @robertoffmoura for the contribution!
1314
- Export tick label colors to the plotly tickfont in `mpl_to_plotly` [[#5716](https://github.com/plotly/plotly.py/pull/5716)], with thanks to @robertoffmoura for the contribution!
1415
- Fix `px.sunburst`, `px.treemap` and `px.icicle` listing sectors in a different order on every run when `path` is used with a Polars DataFrame; sectors now follow their order of first appearance for all dataframe backends [[#5766](https://github.com/plotly/plotly.py/pull/5766)], with thanks to @Irahan2 for the contribution!
1516

‎plotly/matplotlylib/mpltools.py‎

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
from _plotly_utils.colors import hex_to_rgb
1414

15+
from plotly.matplotlylib.mplexporter.utils import export_color
16+
1517

1618
def check_bar_match(old_bar, new_bar):
1719
"""Check if two bars belong in the same collection (bar chart).
@@ -106,26 +108,6 @@ def convert_symbol(mpl_symbol):
106108
return "circle" # default
107109

108110

109-
def merge_color_and_opacity(color, opacity):
110-
"""
111-
Merge hex color with an alpha (opacity) to get an rgba tuple.
112-
113-
:param (str|unicode) color: A hex color string.
114-
:param (float|int) opacity: A value [0, 1] for the 'a' in 'rgba'.
115-
:return: (int, int, int, float) The rgba color and alpha tuple.
116-
117-
"""
118-
if color is None: # None can be used as a placeholder, just bail.
119-
return None
120-
121-
rgb_tup = hex_to_rgb(color)
122-
if opacity is None:
123-
return "rgb{}".format(rgb_tup)
124-
125-
rgba_tup = rgb_tup + (opacity,)
126-
return "rgba{}".format(rgba_tup)
127-
128-
129111
def convert_va(mpl_va):
130112
"""Convert mpl vertical alignment word to equivalent HTML word.
131113
@@ -530,19 +512,29 @@ def prep_ticks(ax, index, ax_type, props):
530512
return axis_dict
531513

532514

533-
def _export_color(color):
515+
def _export_color(color, opacity=None):
534516
"""Export a matplotlib color for use as a plotly color.
535517
536518
matplotlib uses "none" for fully transparent colors, which plotly does not
537-
accept, so transparent colors are exported as transparent black.
538-
Colors already exported by the mplexporter (hex or rgba strings) are
539-
passed through unchanged.
519+
accept, so transparent colors are exported as transparent black. Colors
520+
already exported by the mplexporter (hex or rgba strings) keep the alpha
521+
they carry unless an explicit opacity overrides it.
540522
"""
541523
if color is None:
542524
return None
543-
if isinstance(color, str):
544-
return color if color != "none" else "rgba(0,0,0,0)"
545-
return [_export_color(c) for c in color]
525+
if isinstance(color, (list, tuple)) and all(isinstance(c, str) for c in color):
526+
return [_export_color(c, opacity) for c in color]
527+
if not isinstance(color, str):
528+
color = export_color(color)
529+
if color == "none":
530+
return "rgba(0,0,0,0)"
531+
if color.startswith("#") and opacity is not None:
532+
return "rgba{0}".format(hex_to_rgb(color) + (opacity,))
533+
if color.startswith("rgb") and opacity not in (None, 1):
534+
rgb = color[color.index("(") + 1 : color.rindex(")")].split(",")
535+
r, g, b = (int(round(float(component))) for component in rgb[:3])
536+
return "rgba({0}, {1}, {2}, {3})".format(r, g, b, opacity)
537+
return color
546538

547539

548540
def prep_xy_axis(ax, props, x_bounds, y_bounds):

‎plotly/matplotlylib/renderer.py‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ def draw_bar(self, coll):
384384
yaxis="y{0}".format(self.axis_ct),
385385
opacity=trace[0]["alpha"], # TODO: get all alphas if array?
386386
marker=go.bar.Marker(
387-
color=trace[0]["facecolor"], # TODO: get all
387+
color=_export_color(trace[0]["facecolor"]), # TODO: get all
388388
line=dict(width=trace[0]["edgewidth"]),
389389
),
390390
) # TODO ditto
@@ -446,7 +446,7 @@ def draw_marked_line(self, **props):
446446
self.msg += "... with just markers\n"
447447
mode = "markers"
448448
if props["linestyle"]:
449-
color = mpltools.merge_color_and_opacity(
449+
color = _export_color(
450450
props["linestyle"]["color"], props["linestyle"]["alpha"]
451451
)
452452

@@ -469,22 +469,22 @@ def draw_marked_line(self, **props):
469469
if props["coordinates"] == "data":
470470
marker = go.scatter.Marker(
471471
opacity=props["markerstyle"]["alpha"],
472-
color=props["markerstyle"]["facecolor"],
472+
color=_export_color(props["markerstyle"]["facecolor"]),
473473
symbol=mpltools.convert_symbol(props["markerstyle"]["marker"]),
474474
size=props["markerstyle"]["markersize"],
475475
line=dict(
476-
color=props["markerstyle"]["edgecolor"],
476+
color=_export_color(props["markerstyle"]["edgecolor"]),
477477
width=props["markerstyle"]["edgewidth"],
478478
),
479479
)
480480
else:
481481
shape = dict(
482482
opacity=props["markerstyle"]["alpha"],
483-
fillcolor=props["markerstyle"]["facecolor"],
483+
fillcolor=_export_color(props["markerstyle"]["facecolor"]),
484484
symbol=mpltools.convert_symbol(props["markerstyle"]["marker"]),
485485
size=props["markerstyle"]["markersize"],
486486
line=dict(
487-
color=props["markerstyle"]["edgecolor"],
487+
color=_export_color(props["markerstyle"]["edgecolor"]),
488488
width=props["markerstyle"]["edgewidth"],
489489
),
490490
)
@@ -782,7 +782,8 @@ def draw_text(self, **props):
782782
yanchor=yanchor,
783783
showarrow=False, # change this later?
784784
font=go.layout.annotation.Font(
785-
color=props["style"]["color"], size=props["style"]["fontsize"]
785+
color=_export_color(props["style"]["color"]),
786+
size=props["style"]["fontsize"],
786787
),
787788
)
788789
self.plotly_fig["layout"]["annotations"] += (annotation,)
@@ -822,7 +823,8 @@ def draw_title(self, **props):
822823
annotation = go.layout.Annotation(
823824
text=props["text"],
824825
font=go.layout.annotation.Font(
825-
color=props["style"]["color"], size=props["style"]["fontsize"]
826+
color=_export_color(props["style"]["color"]),
827+
size=props["style"]["fontsize"],
826828
),
827829
xref="paper",
828830
yref="paper",
@@ -837,7 +839,8 @@ def draw_title(self, **props):
837839
self.msg += " Only one subplot found, adding as a plotly title\n"
838840
self.plotly_fig["layout"]["title"] = props["text"]
839841
title_font = dict(
840-
size=props["style"]["fontsize"], color=props["style"]["color"]
842+
size=props["style"]["fontsize"],
843+
color=_export_color(props["style"]["color"]),
841844
)
842845
self.plotly_fig["layout"]["title_font"] = title_font
843846

@@ -868,7 +871,8 @@ def draw_xlabel(self, **props):
868871
axis_key = "xaxis{0}".format(self.axis_ct)
869872
self.plotly_fig["layout"][axis_key]["title"] = str(props["text"])
870873
title_font = dict(
871-
size=props["style"]["fontsize"], color=props["style"]["color"]
874+
size=props["style"]["fontsize"],
875+
color=_export_color(props["style"]["color"]),
872876
)
873877
self.plotly_fig["layout"][axis_key]["title_font"] = title_font
874878

@@ -899,7 +903,8 @@ def draw_ylabel(self, **props):
899903
axis_key = "yaxis{0}".format(self.axis_ct)
900904
self.plotly_fig["layout"][axis_key]["title"] = props["text"]
901905
title_font = dict(
902-
size=props["style"]["fontsize"], color=props["style"]["color"]
906+
size=props["style"]["fontsize"],
907+
color=_export_color(props["style"]["color"]),
903908
)
904909
self.plotly_fig["layout"][axis_key]["title_font"] = title_font
905910

‎plotly/matplotlylib/tests/test_renderer.py‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,113 @@ def test_pcolor_rectangles_render():
322322
assert all(len(t.x) >= 4 for t in plotly_fig.data)
323323

324324

325+
def test_boxplot_converts_with_none_marker_facecolor():
326+
"""Boxplot outlier markers use facecolor 'none', which plotly rejects."""
327+
fig, ax = plt.subplots()
328+
ax.boxplot(np.random.randn(100, 4))
329+
330+
plotly_fig = tls.mpl_to_plotly(fig)
331+
332+
assert len(plotly_fig.data) > 0
333+
334+
335+
def test_line_with_none_color_converts():
336+
"""Lines with color='none' use the string 'none' for the line color,
337+
which plotly rejects; it must be exported as a transparent line."""
338+
fig, ax = plt.subplots()
339+
ax.plot([0, 1], [0, 1], color="none")
340+
341+
plotly_fig = tls.mpl_to_plotly(fig)
342+
343+
assert len(plotly_fig.data) == 1
344+
assert plotly_fig.data[0].line.color == "rgba(0,0,0,0)"
345+
346+
347+
def test_line_with_rgba_color_converts():
348+
"""Line colors that carry their own alpha (rgba tuple or 8-digit hex)
349+
export as rgba strings."""
350+
fig, ax = plt.subplots()
351+
ax.plot([0, 1], [0, 1], color=(1.0, 0.0, 0.0, 0.5))
352+
ax.plot([0, 1], [1, 0], color="#0000FF80")
353+
354+
plotly_fig = tls.mpl_to_plotly(fig)
355+
356+
assert plotly_fig.data[0].line.color == "rgba(255, 0, 0, 0.5)"
357+
assert plotly_fig.data[1].line.color == "rgba(0, 0, 255, 0.5019607843137255)"
358+
359+
360+
def test_line_rgba_color_with_separate_alpha_converts():
361+
"""An explicit alpha overrides the alpha carried by the line color."""
362+
fig, ax = plt.subplots()
363+
ax.plot([0, 1], [0, 1], color=(1.0, 0.0, 0.0, 0.2), alpha=0.5)
364+
365+
plotly_fig = tls.mpl_to_plotly(fig)
366+
367+
assert plotly_fig.data[0].line.color == "rgba(255, 0, 0, 0.5)"
368+
369+
370+
def test_transparent_text_colors_export():
371+
"""Text, title, and axis labels with color 'none' export transparent
372+
fonts."""
373+
fig, ax = plt.subplots()
374+
ax.text(0.5, 0.5, "text", color="none")
375+
ax.set_title("title", color="none")
376+
ax.set_xlabel("xlabel", color="none")
377+
ax.set_ylabel("ylabel", color="none")
378+
379+
plotly_fig = tls.mpl_to_plotly(fig)
380+
381+
assert plotly_fig.layout.annotations[0].font.color == "rgba(0,0,0,0)"
382+
assert plotly_fig.layout.title.font.color == "rgba(0,0,0,0)"
383+
assert plotly_fig.layout.xaxis.title.font.color == "rgba(0,0,0,0)"
384+
assert plotly_fig.layout.yaxis.title.font.color == "rgba(0,0,0,0)"
385+
386+
387+
def test_export_color_maps_colors():
388+
"""_export_color maps matplotlib color strings to plotly colors, keeping
389+
or overriding the alpha as requested."""
390+
from plotly.matplotlylib.mpltools import _export_color
391+
392+
expected_mappings = {
393+
(None, None): None,
394+
("none", None): "rgba(0,0,0,0)",
395+
("#FF0000", None): "#FF0000",
396+
("#FF0000", 1): "rgba(255, 0, 0, 1)",
397+
("#FF0000", 0.5): "rgba(255, 0, 0, 0.5)",
398+
("rgba(255, 0, 0, 0.2)", None): "rgba(255, 0, 0, 0.2)",
399+
("rgba(255, 0, 0, 0.2)", 1): "rgba(255, 0, 0, 0.2)",
400+
("rgba(255, 0, 0, 0.2)", 0.5): "rgba(255, 0, 0, 0.5)",
401+
("rgb(255, 0, 0)", None): "rgb(255, 0, 0)",
402+
("rgb(255, 0, 0)", 0.5): "rgba(255, 0, 0, 0.5)",
403+
((1.0, 0.0, 0.0, 0.5), None): "rgba(255, 0, 0, 0.5)",
404+
((0.0, 1.0, 0.0, 1.0), None): "#00FF00",
405+
}
406+
for (color, opacity), expected in expected_mappings.items():
407+
result = _export_color(color, opacity)
408+
assert result == expected, (
409+
f"Input {color!r} with opacity {opacity!r} produced {result!r}, "
410+
f"expected {expected!r}"
411+
)
412+
413+
assert _export_color(["#FF0000", "none"]) == ["#FF0000", "rgba(0,0,0,0)"]
414+
assert _export_color(["rgba(255, 0, 0, 0.2)"], 0.5) == ["rgba(255, 0, 0, 0.5)"]
415+
416+
417+
def test_scatter_with_multiple_colors_converts():
418+
"""Scatter markers with per-point colors export a list of marker colors."""
419+
fig, ax = plt.subplots()
420+
ax.scatter([0, 1, 2], [0, 1, 2], c=["red", "green", "blue"])
421+
422+
plotly_fig = tls.mpl_to_plotly(fig)
423+
424+
assert plotly_fig.data[0].mode == "markers"
425+
assert plotly_fig.data[0].marker.color == (
426+
"rgba(255,0,0,1.0)",
427+
"rgba(0,128,0,1.0)",
428+
"rgba(0,0,255,1.0)",
429+
)
430+
431+
325432
def test_eventplot_segments_render():
326433
fig, ax = plt.subplots()
327434
ax.eventplot([np.random.randn(20) for _ in range(5)])

0 commit comments

Comments
 (0)