From 2706b2cb7e3261fa8ac009aa660d33ebfc9da252 Mon Sep 17 00:00:00 2001 From: maximsmol <1472826+maximsmol@users.noreply.github.com> Date: Fri, 11 Oct 2024 10:47:19 -0700 Subject: [PATCH 1/8] add webp support to px.imshow Signed-off-by: maximsmol <1472826+maximsmol@users.noreply.github.com> --- .../python/plotly/_plotly_utils/data_utils.py | 35 +++++++++++++++---- .../python/plotly/plotly/express/_imshow.py | 17 ++++++--- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/packages/python/plotly/_plotly_utils/data_utils.py b/packages/python/plotly/_plotly_utils/data_utils.py index 5fb05b03114..5c148c6428a 100644 --- a/packages/python/plotly/_plotly_utils/data_utils.py +++ b/packages/python/plotly/_plotly_utils/data_utils.py @@ -10,7 +10,9 @@ pil_imported = False -def image_array_to_data_uri(img, backend="pil", compression=4, ext="png"): +def image_array_to_data_uri( + img, backend="pil", compression=4, ext="webp", backend_kwargs=None +): """Converts a numpy array of uint8 into a base64 png or jpg string. Parameters @@ -22,8 +24,10 @@ def image_array_to_data_uri(img, backend="pil", compression=4, ext="png"): otherwise pypng. compression: int, between 0 and 9 compression level to be passed to the backend - ext: str, 'png' or 'jpg' + ext: str, 'webp', 'png', or 'jpg' compression format used to generate b64 string + backend_kwargs : dict or None + keyword arguments to be passed to the backend """ # PIL and pypng error messages are quite obscure so we catch invalid compression values if compression < 0 or compression > 9: @@ -41,7 +45,12 @@ def image_array_to_data_uri(img, backend="pil", compression=4, ext="png"): if backend == "auto": backend = "pil" if pil_imported else "pypng" if ext != "png" and backend != "pil": - raise ValueError("jpg binary strings are only available with PIL backend") + raise ValueError( + "webp and jpg binary strings are only available with PIL backend" + ) + + if backend_kwargs is None: + backend_kwargs = {} if backend == "pypng": ndim = img.ndim @@ -49,7 +58,12 @@ def image_array_to_data_uri(img, backend="pil", compression=4, ext="png"): if ndim == 3: img = img.reshape((sh[0], sh[1] * sh[2])) w = Writer( - sh[1], sh[0], greyscale=(ndim == 2), alpha=alpha, compression=compression + sh[1], + sh[0], + greyscale=(ndim == 2), + alpha=alpha, + compression=compression, + **backend_kwargs ) img_png = from_array(img, mode=mode) prefix = "data:image/png;base64," @@ -63,13 +77,22 @@ def image_array_to_data_uri(img, backend="pil", compression=4, ext="png"): "install pillow or use `backend='pypng'." ) pil_img = Image.fromarray(img) - if ext == "jpg" or ext == "jpeg": + if ext == "webp": + prefix = "data:image/webp;base64," + ext = "webp" + elif ext == "jpg" or ext == "jpeg": prefix = "data:image/jpeg;base64," ext = "jpeg" else: prefix = "data:image/png;base64," ext = "png" with BytesIO() as stream: - pil_img.save(stream, format=ext, compress_level=compression) + pil_img.save( + stream, + format=ext, + compress_level=compression, + lossless=True, + **backend_kwargs + ) base64_string = prefix + base64.b64encode(stream.getvalue()).decode("utf-8") return base64_string diff --git a/packages/python/plotly/plotly/express/_imshow.py b/packages/python/plotly/plotly/express/_imshow.py index de0e22284b4..1eb4e074915 100644 --- a/packages/python/plotly/plotly/express/_imshow.py +++ b/packages/python/plotly/plotly/express/_imshow.py @@ -78,7 +78,8 @@ def imshow( binary_string=None, binary_backend="auto", binary_compression_level=4, - binary_format="png", + binary_format="webp", + binary_backend_kwargs=None, text_auto=False, ) -> go.Figure: """ @@ -204,10 +205,15 @@ def imshow( test `len(fig.data[0].source)` and to time the execution of `imshow` to tune the level of compression. 0 means no compression (not recommended). - binary_format: str, 'png' (default) or 'jpg' - compression format used to generate b64 string. 'png' is recommended - since it uses lossless compression, but 'jpg' (lossy) compression can - result if smaller binary strings for natural images. + binary_format: str, 'webp' (default), 'png', or 'jpg' + compression format used to generate b64 string. 'webp' is recommended + since it supports both lossless and lossy compression with better quality + then 'png' or 'jpg' of similar sizes, but 'jpg' or 'png' can be used for + environments that do not support 'webp'. + + binary_backend_kwargs : dict or None + keyword arguments for the image backend. For Pillow, these are passed to `Image.save`. + For 'pypng', these are passed to `Writer.__init__` text_auto: bool or str (default `False`) If `True` or a string, single-channel `img` values will be displayed as text. @@ -502,6 +508,7 @@ def imshow( backend=binary_backend, compression=binary_compression_level, ext=binary_format, + binary_backend_kwargs=binary_backend_kwargs, ) for index_tup in itertools.product(*iterables) ] From 325057d0cd735ad77e94de6bc77807e8fc489fc3 Mon Sep 17 00:00:00 2001 From: maximsmol <1472826+maximsmol@users.noreply.github.com> Date: Fri, 11 Oct 2024 10:52:21 -0700 Subject: [PATCH 2/8] add doc example for imshow binary_backend_kwargs Signed-off-by: maximsmol <1472826+maximsmol@users.noreply.github.com> --- doc/python/imshow.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/doc/python/imshow.md b/doc/python/imshow.md index bec3b83de93..f1b03b7121a 100644 --- a/doc/python/imshow.md +++ b/doc/python/imshow.md @@ -74,6 +74,19 @@ fig = px.imshow(img, binary_format="jpeg", binary_compression_level=0) fig.show() ``` +```python +import plotly.express as px +from skimage import data +img = data.astronaut() +fig = px.imshow( + img, + # Pillow backend parameters are documented here: https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#webp + # Available parameters depend on the `binary_format` + binary_backend_kwargs={"lossless": False} +) +fig.show() +``` + ### Display single-channel 2D data as a heatmap For a 2D image, `px.imshow` uses a colorscale to map scalar data to colors. The default colorscale is the one of the active template (see [the tutorial on templates](/python/templates/)). From fc4b279778d7b365de5287470a449e02237c937d Mon Sep 17 00:00:00 2001 From: maximsmol <1472826+maximsmol@users.noreply.github.com> Date: Fri, 11 Oct 2024 10:55:14 -0700 Subject: [PATCH 3/8] update imshow test Signed-off-by: maximsmol <1472826+maximsmol@users.noreply.github.com> --- .../plotly/tests/test_optional/test_px/test_imshow.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/python/plotly/plotly/tests/test_optional/test_px/test_imshow.py b/packages/python/plotly/plotly/tests/test_optional/test_px/test_imshow.py index c2e863c846b..fea49c467d8 100644 --- a/packages/python/plotly/plotly/tests/test_optional/test_px/test_imshow.py +++ b/packages/python/plotly/plotly/tests/test_optional/test_px/test_imshow.py @@ -16,7 +16,9 @@ def decode_image_string(image_string): """ Converts image string to numpy array. """ - if "png" in image_string[:22]: + if "webp" in image_string[:23]: + return np.asarray(Image.open(BytesIO(base64.b64decode(image_string[23:])))) + elif "png" in image_string[:22]: return np.asarray(Image.open(BytesIO(base64.b64decode(image_string[22:])))) elif "jpeg" in image_string[:23]: return np.asarray(Image.open(BytesIO(base64.b64decode(image_string[23:])))) @@ -62,7 +64,7 @@ def test_automatic_zmax_from_dtype(): @pytest.mark.parametrize("binary_string", [False, True]) -@pytest.mark.parametrize("binary_format", ["png", "jpg"]) +@pytest.mark.parametrize("binary_format", ["webp", "png", "jpg"]) def test_origin(binary_string, binary_format): for i, img in enumerate([img_rgb, img_gray]): fig = px.imshow( @@ -76,7 +78,9 @@ def test_origin(binary_string, binary_format): # The equality below does not hold for jpeg compression since it's lossy assert np.all(img[::-1] == decode_image_string(fig.data[0].source)) if binary_string: - if binary_format == "jpg": + if binary_format == "webp": + assert fig.data[0].source[:15] == "data:image/webp" + elif binary_format == "jpg": assert fig.data[0].source[:15] == "data:image/jpeg" else: assert fig.data[0].source[:14] == "data:image/png" From 4dfccc875ad5dc612d619f43ae74baa5901608d4 Mon Sep 17 00:00:00 2001 From: maximsmol <1472826+maximsmol@users.noreply.github.com> Date: Fri, 11 Oct 2024 11:00:05 -0700 Subject: [PATCH 4/8] add doc note Signed-off-by: maximsmol <1472826+maximsmol@users.noreply.github.com> --- doc/python/imshow.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/python/imshow.md b/doc/python/imshow.md index f1b03b7121a..18e14ba95c9 100644 --- a/doc/python/imshow.md +++ b/doc/python/imshow.md @@ -74,6 +74,9 @@ fig = px.imshow(img, binary_format="jpeg", binary_compression_level=0) fig.show() ``` +Image data is encoded as a lossless base64-encoded WebP string by default. +The example below uses a lossy WebP instead by passing a setting to the underlying image library backend. + ```python import plotly.express as px from skimage import data From d55c09fdefe6316972f31abafdd1b6668d2733f9 Mon Sep 17 00:00:00 2001 From: maximsmol <1472826+maximsmol@users.noreply.github.com> Date: Fri, 11 Oct 2024 11:57:30 -0700 Subject: [PATCH 5/8] fix formatting issues not caught by pre-commit Signed-off-by: maximsmol <1472826+maximsmol@users.noreply.github.com> --- packages/python/plotly/_plotly_utils/data_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/python/plotly/_plotly_utils/data_utils.py b/packages/python/plotly/_plotly_utils/data_utils.py index 5c148c6428a..e28f412f4c8 100644 --- a/packages/python/plotly/_plotly_utils/data_utils.py +++ b/packages/python/plotly/_plotly_utils/data_utils.py @@ -63,7 +63,7 @@ def image_array_to_data_uri( greyscale=(ndim == 2), alpha=alpha, compression=compression, - **backend_kwargs + **backend_kwargs, ) img_png = from_array(img, mode=mode) prefix = "data:image/png;base64," @@ -92,7 +92,7 @@ def image_array_to_data_uri( format=ext, compress_level=compression, lossless=True, - **backend_kwargs + **backend_kwargs, ) base64_string = prefix + base64.b64encode(stream.getvalue()).decode("utf-8") return base64_string From 1dbb5d8e0be3c567c1c5fd007da2ca38e4f40d11 Mon Sep 17 00:00:00 2001 From: maximsmol <1472826+maximsmol@users.noreply.github.com> Date: Fri, 11 Oct 2024 12:03:16 -0700 Subject: [PATCH 6/8] fix typo Signed-off-by: maximsmol <1472826+maximsmol@users.noreply.github.com> --- packages/python/plotly/plotly/express/_imshow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/python/plotly/plotly/express/_imshow.py b/packages/python/plotly/plotly/express/_imshow.py index 1eb4e074915..00d8ee1e29f 100644 --- a/packages/python/plotly/plotly/express/_imshow.py +++ b/packages/python/plotly/plotly/express/_imshow.py @@ -508,7 +508,7 @@ def imshow( backend=binary_backend, compression=binary_compression_level, ext=binary_format, - binary_backend_kwargs=binary_backend_kwargs, + backend_kwargs=binary_backend_kwargs, ) for index_tup in itertools.product(*iterables) ] From 367d17f7dfb77a1fd9327fac802dcc5b828cbfbf Mon Sep 17 00:00:00 2001 From: maximsmol <1472826+maximsmol@users.noreply.github.com> Date: Fri, 11 Oct 2024 12:51:00 -0700 Subject: [PATCH 7/8] fix tests Signed-off-by: maximsmol <1472826+maximsmol@users.noreply.github.com> --- .../tests/test_optional/test_px/test_imshow.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/python/plotly/plotly/tests/test_optional/test_px/test_imshow.py b/packages/python/plotly/plotly/tests/test_optional/test_px/test_imshow.py index fea49c467d8..d34e2c33fb9 100644 --- a/packages/python/plotly/plotly/tests/test_optional/test_px/test_imshow.py +++ b/packages/python/plotly/plotly/tests/test_optional/test_px/test_imshow.py @@ -328,32 +328,28 @@ def test_imshow_dataframe(): def test_imshow_source_dtype_zmax(dtype, contrast_rescaling): img = np.arange(100, dtype=dtype).reshape((10, 10)) fig = px.imshow(img, binary_string=True, contrast_rescaling=contrast_rescaling) + + decoded = decode_image_string(fig.data[0].source)[:, :, 0] if contrast_rescaling == "minmax": assert ( np.max( np.abs( rescale_intensity(img, in_range="image", out_range=np.uint8) - - decode_image_string(fig.data[0].source) + - decoded ) ) < 1 ) else: if dtype in [np.uint8, np.float32, np.float64]: - assert np.all(img == decode_image_string(fig.data[0].source)) + assert np.all(img == decoded) else: - assert ( - np.abs( - np.max(decode_image_string(fig.data[0].source)) - - 255 * img.max() / np.iinfo(dtype).max - ) - < 1 - ) + assert np.abs(np.max(decoded) - 255 * img.max() / np.iinfo(dtype).max) < 1 @pytest.mark.parametrize("backend", ["auto", "pypng", "pil"]) def test_imshow_backend(backend): - fig = px.imshow(img_rgb, binary_backend=backend) + fig = px.imshow(img_rgb, binary_backend=backend, binary_format="png") decoded_img = decode_image_string(fig.data[0].source) assert np.all(decoded_img == img_rgb) @@ -365,6 +361,7 @@ def test_imshow_compression(level): fig = px.imshow( grid_img, binary_string=True, + binary_format="png", binary_compression_level=level, contrast_rescaling="infer", ) From ba98e7e260b1a70e6211dd82c0704a54e346013c Mon Sep 17 00:00:00 2001 From: maximsmol <1472826+maximsmol@users.noreply.github.com> Date: Fri, 11 Oct 2024 12:59:09 -0700 Subject: [PATCH 8/8] fix duplicate kwargs error + add test Signed-off-by: maximsmol <1472826+maximsmol@users.noreply.github.com> --- packages/python/plotly/_plotly_utils/data_utils.py | 11 ++++++++--- .../plotly/tests/test_optional/test_px/test_imshow.py | 9 +++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/python/plotly/_plotly_utils/data_utils.py b/packages/python/plotly/_plotly_utils/data_utils.py index e28f412f4c8..f24ace5aaed 100644 --- a/packages/python/plotly/_plotly_utils/data_utils.py +++ b/packages/python/plotly/_plotly_utils/data_utils.py @@ -53,6 +53,8 @@ def image_array_to_data_uri( backend_kwargs = {} if backend == "pypng": + backend_kwargs.setdefault("compression", compression) + ndim = img.ndim sh = img.shape if ndim == 3: @@ -62,7 +64,6 @@ def image_array_to_data_uri( sh[0], greyscale=(ndim == 2), alpha=alpha, - compression=compression, **backend_kwargs, ) img_png = from_array(img, mode=mode) @@ -71,6 +72,12 @@ def image_array_to_data_uri( w.write(stream, img_png.rows) base64_string = prefix + base64.b64encode(stream.getvalue()).decode("utf-8") else: # pil + if ext == "png": + backend_kwargs.setdefault("compress_level", compression) + + if ext == "webp": + backend_kwargs.setdefault("lossless", True) + if not pil_imported: raise ImportError( "pillow needs to be installed to use `backend='pil'. Please" @@ -90,8 +97,6 @@ def image_array_to_data_uri( pil_img.save( stream, format=ext, - compress_level=compression, - lossless=True, **backend_kwargs, ) base64_string = prefix + base64.b64encode(stream.getvalue()).decode("utf-8") diff --git a/packages/python/plotly/plotly/tests/test_optional/test_px/test_imshow.py b/packages/python/plotly/plotly/tests/test_optional/test_px/test_imshow.py index d34e2c33fb9..952f1128787 100644 --- a/packages/python/plotly/plotly/tests/test_optional/test_px/test_imshow.py +++ b/packages/python/plotly/plotly/tests/test_optional/test_px/test_imshow.py @@ -354,6 +354,15 @@ def test_imshow_backend(backend): assert np.all(decoded_img == img_rgb) +@pytest.mark.parametrize("lossless", [True, False]) +def test_imshow_backend_kwargs(lossless): + fig = px.imshow(img_rgb, binary_backend_kwargs={"lossless": lossless}) + decoded_img = decode_image_string(fig.data[0].source) + + if lossless: + assert np.all(decoded_img == img_rgb) + + @pytest.mark.parametrize("level", [0, 3, 6, 9]) def test_imshow_compression(level): _, grid_img = np.mgrid[0:10, 0:100]