diff --git a/CHANGELOG.md b/CHANGELOG.md index 8378fcf347..8830033e99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Fix `mpl_to_plotly` conversion of matplotlib contour lines and line collections: close contour rings ending with `Z` codes, support dash styles, convert date x-axes, separate disjoint subpaths with `None` separators, hide line collection traces from the legend by default, and group consecutive same-style lines into single traces [[#5770](https://github.com/plotly/plotly.py/pull/5770)], with thanks to @robertoffmoura for the contribution! - Fix `plotly.io.write_images` ignoring `layout.width`/`layout.height` (and the template's) and exporting every figure at the default 700x500 size; it now uses the same width/height priority as `write_image` and `to_image` [[#5784](https://github.com/plotly/plotly.py/pull/5784)], with thanks to @breken-ai for the contribution! - Fix color utilities mutating the list of colors passed to them [[#5836](https://github.com/plotly/plotly.py/pull/5836)], with thanks to @Enesuygurs for the contribution! +- Fix `go.Image` rejecting 3-element `zmin`/`zmax` values for 3-component colormodels such as `rgb` and `hsl`; the image trace's `zmin`/`zmax` now accept up to 4 elements so their length can match the colormodel [[#1973](https://github.com/plotly/plotly.py/issues/1973)] ## [7.1.0] - 2026-09-15 diff --git a/codegen/__init__.py b/codegen/__init__.py index b299fa3604..0d5234d6d8 100644 --- a/codegen/__init__.py +++ b/codegen/__init__.py @@ -86,6 +86,19 @@ def preprocess_schema(plotly_schema): if "concentrationscales" in items: items["colorscale"] = items.pop("concentrationscales") + # The number of color components in the image trace's zmin/zmax + # depends on the colormodel (3 for rgb/hsl, 4 for rgba/rgba256/hsla), + # as described in the attributes' own descriptions. Validators are + # stateless and cannot see the colormodel of the trace being + # validated, so the fixed 4-element length from the plotly.js schema + # rejects valid 3-element values. Mark both properties as freeLength + # (accept up to 4 elements), as the schema already does for e.g. + # updatemenu button args and parcoords constraintrange. + # See https://github.com/plotly/plotly.py/issues/1973 + image_attributes = plotly_schema["traces"]["image"]["attributes"] + for prop in ("zmin", "zmax"): + image_attributes[prop]["freeLength"] = True + def make_paths(outdir): """Make various paths needed for formatting and linting.""" diff --git a/plotly/graph_objs/_image.py b/plotly/graph_objs/_image.py index b859fda1c6..2e428fa3e0 100644 --- a/plotly/graph_objs/_image.py +++ b/plotly/graph_objs/_image.py @@ -650,7 +650,7 @@ def zmax(self): The 'zmax' property is an info array that may be specified as: - * a list or tuple of 4 elements where: + * a list or tuple of up to 4 elements where: (0) The 'zmax[0]' property is a number and may be specified as: - An int or float (1) The 'zmax[1]' property is a number and may be specified as: @@ -682,7 +682,7 @@ def zmin(self): The 'zmin' property is an info array that may be specified as: - * a list or tuple of 4 elements where: + * a list or tuple of up to 4 elements where: (0) The 'zmin[0]' property is a number and may be specified as: - An int or float (1) The 'zmin[1]' property is a number and may be specified as: diff --git a/plotly/validators/_validators.json b/plotly/validators/_validators.json index c923bc67e6..846ca20d2a 100644 --- a/plotly/validators/_validators.json +++ b/plotly/validators/_validators.json @@ -77687,6 +77687,7 @@ "plotly_name": "zmin", "parent_name": "image", "edit_type": "calc", + "free_length": true, "items": [ { "editType": "calc", @@ -77713,6 +77714,7 @@ "plotly_name": "zmax", "parent_name": "image", "edit_type": "calc", + "free_length": true, "items": [ { "editType": "calc", diff --git a/tests/test_core/test_graph_objs/test_image.py b/tests/test_core/test_graph_objs/test_image.py new file mode 100644 index 0000000000..e03b447ec5 --- /dev/null +++ b/tests/test_core/test_graph_objs/test_image.py @@ -0,0 +1,71 @@ +import numpy as np +import pytest + +import plotly.graph_objects as go + +Z3 = np.random.randint(256, size=(10, 10, 3), dtype=np.uint8) +Z4 = np.random.randint(256, size=(10, 10, 4), dtype=np.uint8) + + +# Regression tests for https://github.com/plotly/plotly.py/issues/1973 +# The number of components of zmin/zmax depends on the colormodel +# (3 for rgb/hsl, 4 for rgba/rgba256/hsla), so both lengths must validate. +@pytest.mark.parametrize( + "colormodel,z,zmin,zmax", + [ + ("rgb", Z3, [0, 0, 0], [255, 255, 255]), + ("hsl", Z3, [0, 0, 0], [360, 100, 100]), + ("rgba", Z4, [0, 0, 0, 0], [255, 255, 255, 1]), + ("rgba256", Z4, [0, 0, 0, 0], [255, 255, 255, 255]), + ("hsla", Z4, [0, 0, 0, 0], [360, 100, 100, 1]), + # A 4-element value must keep working for 3-component models too, + # as it did before this fix (it was the only length accepted). + ("rgb", Z3, [0, 0, 0, 0], [255, 255, 255, 255]), + ], +) +def test_image_zmin_zmax_matching_colormodel(colormodel, z, zmin, zmax): + img = go.Image(z=z, colormodel=colormodel, zmin=zmin, zmax=zmax) + assert img.zmin == tuple(zmin) + assert img.zmax == tuple(zmax) + + +def test_image_zmin_zmax_issue_example(): + # The exact example from #1973 + im = np.random.randint(256, size=(50, 50, 3), dtype=np.uint8) + fig = go.Figure( + go.Image(z=im, colormodel="rgb", zmin=[10, 10, 10], zmax=[100, 100, 100]) + ) + assert fig.data[0].zmin == (10, 10, 10) + assert fig.data[0].zmax == (100, 100, 100) + + +def test_image_zmin_zmax_default_colormodel(): + # colormodel defaults to "rgb", so 3 elements are valid without it. + img = go.Image(z=Z3, zmin=[0, 0, 0], zmax=[255, 255, 255]) + assert img.zmax == (255, 255, 255) + + +def test_image_zmin_zmax_assignment_order(): + # Validation must not depend on the order properties are set in. + img = go.Image(z=Z3, zmin=[0, 0, 0, 0], zmax=[255, 255, 255, 255]) + img.colormodel = "rgb" + img.zmin = [0, 0, 0] + img.zmax = [255, 255, 255] + assert img.zmin == (0, 0, 0) + + +@pytest.mark.parametrize("colormodel", ["rgb", "rgba", "rgba256", "hsl", "hsla"]) +def test_image_zmin_zmax_five_elements_rejected(colormodel): + with pytest.raises(ValueError): + go.Image(z=Z4, colormodel=colormodel, zmax=[1, 2, 3, 4, 5]) + + +def test_image_zmin_zmax_non_numeric_rejected(): + with pytest.raises(ValueError): + go.Image(z=Z3, colormodel="rgb", zmax=["a", "b", "c"]) + + +def test_heatmap_zmin_zmax_scalar_unaffected(): + hm = go.Heatmap(z=[[1, 2], [3, 4]], zmin=0, zmax=5) + assert hm.zmin == 0 + assert hm.zmax == 5