From e3fe279bfff2425b647099328828b509038c6928 Mon Sep 17 00:00:00 2001 From: Ivan Seleznev Date: Wed, 6 May 2026 10:50:32 +0200 Subject: [PATCH 01/11] Add annotation raster functionality with support for image annotations --- .../letsPlot/core/plot/base/GeomKind.kt | 2 +- .../letsPlot/core/plot/base/GeomMeta.kt | 4 +- .../core/plot/base/aes/AestheticsDefaults.kt | 1 + .../plot/base/geom/AnnotationRasterGeom.kt | 91 +++++++++++++++++++ .../builder/assemble/geom/GeomProvider.kt | 8 ++ .../jetbrains/letsPlot/core/spec/GeomProto.kt | 2 + .../letsPlot/core/spec/GeomProviderFactory.kt | 26 +++++- .../jetbrains/letsPlot/core/spec/Option.kt | 14 ++- python-package/lets_plot/plot/annotation.py | 71 ++++++++++++++- .../test/plot/test_annotation_raster.py | 64 +++++++++++++ 10 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt create mode 100644 python-package/test/plot/test_annotation_raster.py diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/GeomKind.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/GeomKind.kt index d05ca929d96..c632b8d2eaa 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/GeomKind.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/GeomKind.kt @@ -57,10 +57,10 @@ enum class GeomKind { LABEL_REPEL, RASTER, IMAGE, + ANNOTATION_RASTER, PIE, LOLLIPOP, BRACKET, BRACKET_DODGE, BLANK, } - diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/GeomMeta.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/GeomMeta.kt index 3dab965b1fd..9c0eb5db1bb 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/GeomMeta.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/GeomMeta.kt @@ -499,6 +499,8 @@ object GeomMeta { Aes.COLOR // not rendered but necessary for color legend to appear. ) + GeomKind.ANNOTATION_RASTER -> emptyList() + GeomKind.PIE -> listOf( Aes.X, Aes.Y, @@ -566,4 +568,4 @@ object GeomMeta { ) } } -} \ No newline at end of file +} diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/aes/AestheticsDefaults.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/aes/AestheticsDefaults.kt index 6c59f84a1e8..926c0afdd7f 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/aes/AestheticsDefaults.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/aes/AestheticsDefaults.kt @@ -203,6 +203,7 @@ class AestheticsDefaults private constructor( GeomKind.STEP, GeomKind.RASTER, GeomKind.IMAGE, + GeomKind.ANNOTATION_RASTER, GeomKind.BRACKET, GeomKind.BRACKET_DODGE, GeomKind.LIVE_MAP -> base(geomTheme) diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt new file mode 100644 index 00000000000..db1bda501e3 --- /dev/null +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026. JetBrains s.r.o. + * Use of this source code is governed by the MIT license that can be found in the LICENSE file. + */ + +package org.jetbrains.letsPlot.core.plot.base.geom + +import org.jetbrains.letsPlot.commons.geometry.DoubleRectangle +import org.jetbrains.letsPlot.commons.geometry.DoubleVector +import org.jetbrains.letsPlot.core.plot.base.Aesthetics +import org.jetbrains.letsPlot.core.plot.base.CoordinateSystem +import org.jetbrains.letsPlot.core.plot.base.GeomContext +import org.jetbrains.letsPlot.core.plot.base.PositionAdjustment +import org.jetbrains.letsPlot.core.plot.base.render.SvgRoot +import org.jetbrains.letsPlot.datamodel.svg.dom.SvgImageElement +import kotlin.math.max +import kotlin.math.min + +class AnnotationRasterGeom( + private val imageUrl: String, + private val xmin: Double?, + private val xmax: Double?, + private val ymin: Double?, + private val ymax: Double?, + private val interpolate: Boolean, +) : GeomBase() { + + override fun buildIntern( + root: SvgRoot, + aesthetics: Aesthetics, + pos: PositionAdjustment, + coord: CoordinateSystem, + ctx: GeomContext + ) { + val bbox = dataBounds(coord, ctx) ?: return + val boundsClient = coord.toClient(bbox) ?: return + + val svgImageElement = SvgImageElement( + boundsClient.origin.x, boundsClient.origin.y, + boundsClient.dimension.x, boundsClient.dimension.y + ) + svgImageElement.href().set(imageUrl) + svgImageElement.setAttribute( + SvgImageElement.IMAGE_RENDERING, + if (interpolate) "optimizeQuality" else "optimizeSpeed" + ) + root.add(svgImageElement) + } + + private fun dataBounds(coord: CoordinateSystem, ctx: GeomContext): DoubleRectangle? { + val contentOrigin = DoubleVector.ZERO + val contentCorner = ctx.getContentBounds().dimension + val dataOrigin = coord.fromClient(contentOrigin) + val dataCorner = coord.fromClient(contentCorner) + + val x0 = when { + xmin == null -> minNotNull(dataOrigin?.x, dataCorner?.x) ?: return null + xmin.isFinite() -> xmin + else -> return null + } + val x1 = when { + xmax == null -> maxNotNull(dataOrigin?.x, dataCorner?.x) ?: return null + xmax.isFinite() -> xmax + else -> return null + } + val y0 = when { + ymin == null -> minNotNull(dataOrigin?.y, dataCorner?.y) ?: return null + ymin.isFinite() -> ymin + else -> return null + } + val y1 = when { + ymax == null -> maxNotNull(dataOrigin?.y, dataCorner?.y) ?: return null + ymax.isFinite() -> ymax + else -> return null + } + + return DoubleRectangle.span(DoubleVector(x0, y0), DoubleVector(x1, y1)) + } + + companion object { + const val HANDLES_GROUPS = false + + private fun minNotNull(a: Double?, b: Double?): Double? { + return if (a == null || b == null) null else min(a, b) + } + + private fun maxNotNull(a: Double?, b: Double?): Double? { + return if (a == null || b == null) null else max(a, b) + } + } +} diff --git a/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/assemble/geom/GeomProvider.kt b/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/assemble/geom/GeomProvider.kt index a74a847b622..8a60e818b5d 100644 --- a/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/assemble/geom/GeomProvider.kt +++ b/plot-builder/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/builder/assemble/geom/GeomProvider.kt @@ -409,6 +409,14 @@ class GeomProvider internal constructor( ) } + fun annotationRaster(supplier: (Context) -> Geom): GeomProvider { + return GeomProvider( + GeomKind.ANNOTATION_RASTER, + AnnotationRasterGeom.HANDLES_GROUPS, + supplier + ) + } + fun pie(supplier: (Context) -> Geom): GeomProvider { return GeomProvider( GeomKind.PIE, diff --git a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProto.kt b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProto.kt index 8e02523f17f..888e325a9b9 100644 --- a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProto.kt +++ b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProto.kt @@ -56,6 +56,7 @@ class GeomProto(val geomKind: GeomKind) { // DENSITY2D, // DENSITY2DF, RASTER, + ANNOTATION_RASTER, IMAGE -> CoordProviders.fixed(1.0) MAP -> CoordProviders.map(projection = identity().takeIf { layerConfig.has(Layer.USE_CRS) } ?: mercator()) @@ -118,6 +119,7 @@ class GeomProto(val geomKind: GeomKind) { LIVE_MAP, RASTER, IMAGE, + ANNOTATION_RASTER, BLANK -> Samplings.NONE } } diff --git a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProviderFactory.kt b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProviderFactory.kt index 5f605b01d67..fe985fa36a1 100644 --- a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProviderFactory.kt +++ b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProviderFactory.kt @@ -382,6 +382,20 @@ internal object GeomProviderFactory { ) } + GeomKind.ANNOTATION_RASTER -> GeomProvider.annotationRaster { + require(layerConfig.hasOwn(Option.Geom.AnnotationRaster.HREF)) { + "Raster image reference URL (href) is not specified." + } + AnnotationRasterGeom( + imageUrl = layerConfig.getString(Option.Geom.AnnotationRaster.HREF)!!, + xmin = annotationRasterBound(layerConfig, Option.Geom.AnnotationRaster.XMIN), + xmax = annotationRasterBound(layerConfig, Option.Geom.AnnotationRaster.XMAX), + ymin = annotationRasterBound(layerConfig, Option.Geom.AnnotationRaster.YMIN), + ymax = annotationRasterBound(layerConfig, Option.Geom.AnnotationRaster.YMAX), + interpolate = layerConfig.getBoolean(Option.Geom.AnnotationRaster.INTERPOLATE), + ) + } + GeomKind.PIE -> GeomProvider.pie { val geom = PieGeom() layerConfig.getDouble(Pie.HOLE)?.let { geom.holeSize = it } @@ -584,4 +598,14 @@ internal object GeomProviderFactory { ) } } -} \ No newline at end of file + + private fun annotationRasterBound(layerConfig: LayerConfig, option: String): Double? { + return when (val value = layerConfig[option]) { + null -> null + is Number -> value.toDouble() + else -> throw IllegalArgumentException( + "Parameter '$option' expected to be a Number, but was ${value::class.simpleName}" + ) + } + } +} diff --git a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/Option.kt b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/Option.kt index 8246cc18738..06ee25f9cb5 100644 --- a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/Option.kt +++ b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/Option.kt @@ -455,6 +455,16 @@ object Option { val YMAX = Aes.YMAX.name } + object AnnotationRaster { + const val HREF = "href" + const val INTERPOLATE = "interpolate" + + const val XMIN = "xmin" + const val XMAX = "xmax" + const val YMIN = "ymin" + const val YMAX = "ymax" + } + object Text { const val LABEL_FORMAT = "label_format" const val NA_TEXT = "na_text" @@ -1185,6 +1195,7 @@ object Option { private const val LABEL_REPEL = "label_repel" private const val RASTER = "raster" const val IMAGE = "image" + private const val ANNOTATION_RASTER = "annotation_raster" const val PIE = "pie" const val LOLLIPOP = "lollipop" const val BRACKET = "bracket" @@ -1246,6 +1257,7 @@ object Option { map[LABEL_REPEL] = GeomKind.LABEL_REPEL map[RASTER] = GeomKind.RASTER map[IMAGE] = GeomKind.IMAGE + map[ANNOTATION_RASTER] = GeomKind.ANNOTATION_RASTER map[PIE] = GeomKind.PIE map[LOLLIPOP] = GeomKind.LOLLIPOP map[BRACKET] = GeomKind.BRACKET @@ -1340,4 +1352,4 @@ object Option { const val COORD_XLIM_TRANSFORMED = FigureModelOptions.COORD_XLIM_TRANSFORMED // array of two nullable numbers const val COORD_YLIM_TRANSFORMED = FigureModelOptions.COORD_YLIM_TRANSFORMED } -} \ No newline at end of file +} diff --git a/python-package/lets_plot/plot/annotation.py b/python-package/lets_plot/plot/annotation.py index 17a8674328a..717a8b8340e 100644 --- a/python-package/lets_plot/plot/annotation.py +++ b/python-package/lets_plot/plot/annotation.py @@ -1,6 +1,7 @@ # Copyright (c) 2022. JetBrains s.r.o. # Use of this source code is governed by the MIT license that can be found in the LICENSE file. +import base64 from typing import List from lets_plot.plot.core import FeatureSpec, _filter_none @@ -9,9 +10,77 @@ # Annotations # -__all__ = ['layer_labels', 'smooth_labels'] +__all__ = ['annotation_raster', 'layer_labels', 'smooth_labels'] +def annotation_raster(raster, xmin=None, xmax=None, ymin=None, ymax=None, interpolate=False): + """ + Add a raster image annotation layer. + + Parameters + ---------- + raster : bytes, bytearray or memoryview + Encoded raster image bytes. Supported formats: PNG, JPEG, GIF and WebP. + xmin, xmax, ymin, ymax : number + Image bounds in data coordinates. None values are interpreted as panel bounds. + interpolate : bool, default=False + If True, interpolate pixels when scaling the image. + + Returns + ------- + ``LayerSpec`` + Geom object specification. + """ + from .geom import _geom + + image_bytes = _as_image_bytes(raster) + mime_type = _detect_image_mime_type(image_bytes) + href = 'data:{};base64,{}'.format( + mime_type, + str(base64.standard_b64encode(image_bytes), 'utf-8') + ) + + return _geom( + 'annotation_raster', + href=href, + xmin=xmin, + xmax=xmax, + ymin=ymin, + ymax=ymax, + interpolate=interpolate, + show_legend=False, + inherit_aes=False, + ) + + +def _as_image_bytes(raster): + if isinstance(raster, bytes): + image_bytes = raster + elif isinstance(raster, bytearray): + image_bytes = bytes(raster) + elif isinstance(raster, memoryview): + image_bytes = raster.tobytes() + else: + raise ValueError("Unsupported raster value: expected bytes, bytearray or memoryview") + + if len(image_bytes) == 0: + raise ValueError("Raster image data is empty") + + return image_bytes + + +def _detect_image_mime_type(image_bytes): + if image_bytes.startswith(b'\x89PNG\r\n\x1a\n'): + return 'image/png' + if image_bytes.startswith(b'\xff\xd8\xff'): + return 'image/jpeg' + if image_bytes.startswith(b'GIF87a') or image_bytes.startswith(b'GIF89a'): + return 'image/gif' + if len(image_bytes) >= 12 and image_bytes[:4] == b'RIFF' and image_bytes[8:12] == b'WEBP': + return 'image/webp' + + raise ValueError("Unsupported raster image format: expected PNG, JPEG, GIF or WebP") + class layer_labels(FeatureSpec): """ Configure annotations for geometry layers. diff --git a/python-package/test/plot/test_annotation_raster.py b/python-package/test/plot/test_annotation_raster.py new file mode 100644 index 00000000000..a5dfa77b93b --- /dev/null +++ b/python-package/test/plot/test_annotation_raster.py @@ -0,0 +1,64 @@ +# +# Copyright (c) 2026. JetBrains s.r.o. +# Use of this source code is governed by the MIT license that can be found in the LICENSE file. +# + +import base64 + +import pytest + +from lets_plot.plot.annotation import annotation_raster + +PNG_1X1_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAADklEQVR4nGNgYGD4DwIADvoE/EWwHYsAAAAASUVORK5CYII=' +PNG_1X1_BYTES = base64.b64decode(PNG_1X1_BASE64) + + +def test_annotation_raster_spec(): + spec = annotation_raster( + PNG_1X1_BYTES, + xmin=1, + xmax=2, + ymin=3, + ymax=4, + interpolate=True, + ) + + assert spec.as_dict() == { + 'data_meta': {}, + 'geom': 'annotation_raster', + 'href': 'data:image/png;base64,' + PNG_1X1_BASE64, + 'mapping': {}, + 'xmin': 1, + 'xmax': 2, + 'ymin': 3, + 'ymax': 4, + 'interpolate': True, + 'show_legend': False, + 'inherit_aes': False, + } + + +def test_annotation_raster_default_bounds_are_omitted(): + spec = annotation_raster(PNG_1X1_BYTES).as_dict() + + assert 'xmin' not in spec + assert 'xmax' not in spec + assert 'ymin' not in spec + assert 'ymax' not in spec + + +def test_annotation_raster_accepts_bytes_like_values(): + expected_href = 'data:image/png;base64,' + PNG_1X1_BASE64 + + assert annotation_raster(bytearray(PNG_1X1_BYTES)).as_dict()['href'] == expected_href + assert annotation_raster(memoryview(PNG_1X1_BYTES)).as_dict()['href'] == expected_href + + +def test_annotation_raster_rejects_empty_bytes(): + with pytest.raises(ValueError, match="Raster image data is empty"): + annotation_raster(b'') + + +def test_annotation_raster_rejects_unknown_image_format(): + with pytest.raises(ValueError, match="Unsupported raster image format"): + annotation_raster(b'not an image') From 95068b1f71e2eff0301638bb1f98221b69ba4625 Mon Sep 17 00:00:00 2001 From: Ivan Seleznev Date: Thu, 7 May 2026 11:30:05 +0200 Subject: [PATCH 02/11] Enhance annotation raster functionality to support conversion of RGB and JPEG images to RGBA PNG format --- .../datamodel/svg/util/SvgToString.kt | 17 ++++- .../datamodel/svg/util/SvgToStringTest.kt | 34 +++++++++ .../w3c/mapping/svg/SvgNodeMapperFactory.kt | 17 ++++- .../plot/base/geom/AnnotationRasterGeom.kt | 5 +- python-package/lets_plot/plot/annotation.py | 34 ++++++++- .../test/plot/test_annotation_raster.py | 72 +++++++++++++++++-- 6 files changed, 164 insertions(+), 15 deletions(-) diff --git a/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/util/SvgToString.kt b/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/util/SvgToString.kt index ef6dba0ad24..fddd22f9846 100644 --- a/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/util/SvgToString.kt +++ b/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/util/SvgToString.kt @@ -77,7 +77,7 @@ object SvgToString { } if (childNode is SvgImageElement) { - childNode.setAttribute(SvgConstants.SVG_STYLE_ATTRIBUTE, "image-rendering: optimizeSpeed; image-rendering: pixelated") + ensureDefaultImageRendering(childNode) } @Suppress("USELESS_CAST") // Kotlin 1.9 fails to infer correctly here @@ -92,6 +92,19 @@ object SvgToString { buffer.append("') } + private fun ensureDefaultImageRendering(imageElement: SvgImageElement) { + if (imageElement.getAttribute(SvgImageElement.IMAGE_RENDERING).get() != null) { + return + } + if (imageElement.getAttribute(SvgConstants.SVG_STYLE_ATTRIBUTE).get() != null) { + return + } + imageElement.setAttribute( + SvgConstants.SVG_STYLE_ATTRIBUTE, + "image-rendering: optimizeSpeed; image-rendering: pixelated" + ) + } + private fun renderTextNode(node: SvgTextNode, buffer: StringBuilder) { buffer.append(htmlEscape(node.textContent().get())) } @@ -126,4 +139,4 @@ object SvgToString { buffer.append(' ') } } -} \ No newline at end of file +} diff --git a/datamodel/src/commonTest/kotlin/org/jetbrains/letsPlot/datamodel/svg/util/SvgToStringTest.kt b/datamodel/src/commonTest/kotlin/org/jetbrains/letsPlot/datamodel/svg/util/SvgToStringTest.kt index 5216f169c06..fbc8627bba1 100644 --- a/datamodel/src/commonTest/kotlin/org/jetbrains/letsPlot/datamodel/svg/util/SvgToStringTest.kt +++ b/datamodel/src/commonTest/kotlin/org/jetbrains/letsPlot/datamodel/svg/util/SvgToStringTest.kt @@ -82,4 +82,38 @@ class SvgToStringTest { // There should be no spaces between elements assertTrue(svgString.contains("123")) } + + @Test + fun imageRenderingAttributeShouldNotBeOverridden() { + val svg = SvgSvgElement().apply { + children().add( + SvgImageElement(0.0, 0.0, 10.0, 10.0).apply { + href().set("data:image/png;base64,abc") + setAttribute(SvgImageElement.IMAGE_RENDERING, "optimizeQuality") + } + ) + } + + val svgString = SvgToString.render(svg) + + assertTrue(svgString.contains("image-rendering=\"optimizeQuality\"")) + assertTrue(!svgString.contains("image-rendering: pixelated")) + } + + @Test + fun imageRenderingStyleShouldNotBeOverridden() { + val svg = SvgSvgElement().apply { + children().add( + SvgImageElement(0.0, 0.0, 10.0, 10.0).apply { + href().set("data:image/png;base64,abc") + setAttribute(SvgConstants.SVG_STYLE_ATTRIBUTE, "image-rendering: auto") + } + ) + } + + val svgString = SvgToString.render(svg) + + assertTrue(svgString.contains("style=\"image-rendering: auto\"")) + assertTrue(!svgString.contains("image-rendering: pixelated")) + } } diff --git a/platf-w3c/src/webMain/kotlin/org/jetbrains/letsPlot/platf/w3c/mapping/svg/SvgNodeMapperFactory.kt b/platf-w3c/src/webMain/kotlin/org/jetbrains/letsPlot/platf/w3c/mapping/svg/SvgNodeMapperFactory.kt index e7ac5dc39e7..dcac1233ec0 100644 --- a/platf-w3c/src/webMain/kotlin/org/jetbrains/letsPlot/platf/w3c/mapping/svg/SvgNodeMapperFactory.kt +++ b/platf-w3c/src/webMain/kotlin/org/jetbrains/letsPlot/platf/w3c/mapping/svg/SvgNodeMapperFactory.kt @@ -24,7 +24,7 @@ class SvgNodeMapperFactory(private val myPeer: SvgDomPeer): MapperFactory throw IllegalStateException("Unsupported SvgNode ${source::class}") } -} \ No newline at end of file + + private fun ensureDefaultImageRendering(imageElement: SvgImageElement) { + if (imageElement.getAttribute(SvgImageElement.IMAGE_RENDERING).get() != null) { + return + } + if (imageElement.getAttribute(SvgConstants.SVG_STYLE_ATTRIBUTE).get() != null) { + return + } + imageElement.setAttribute( + SvgConstants.SVG_STYLE_ATTRIBUTE, + "image-rendering: pixelated;image-rendering: crisp-edges;" + ) + } +} diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt index db1bda501e3..66706fe67f9 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt @@ -12,6 +12,7 @@ import org.jetbrains.letsPlot.core.plot.base.CoordinateSystem import org.jetbrains.letsPlot.core.plot.base.GeomContext import org.jetbrains.letsPlot.core.plot.base.PositionAdjustment import org.jetbrains.letsPlot.core.plot.base.render.SvgRoot +import org.jetbrains.letsPlot.datamodel.svg.dom.SvgConstants import org.jetbrains.letsPlot.datamodel.svg.dom.SvgImageElement import kotlin.math.max import kotlin.math.min @@ -41,8 +42,8 @@ class AnnotationRasterGeom( ) svgImageElement.href().set(imageUrl) svgImageElement.setAttribute( - SvgImageElement.IMAGE_RENDERING, - if (interpolate) "optimizeQuality" else "optimizeSpeed" + SvgConstants.SVG_STYLE_ATTRIBUTE, + if (interpolate) "image-rendering: auto" else "image-rendering: pixelated;image-rendering: crisp-edges;" ) root.add(svgImageElement) } diff --git a/python-package/lets_plot/plot/annotation.py b/python-package/lets_plot/plot/annotation.py index 717a8b8340e..71ba5bd2949 100644 --- a/python-package/lets_plot/plot/annotation.py +++ b/python-package/lets_plot/plot/annotation.py @@ -2,6 +2,7 @@ # Use of this source code is governed by the MIT license that can be found in the LICENSE file. import base64 +from io import BytesIO from typing import List from lets_plot.plot.core import FeatureSpec, _filter_none @@ -33,10 +34,9 @@ def annotation_raster(raster, xmin=None, xmax=None, ymin=None, ymax=None, interp """ from .geom import _geom - image_bytes = _as_image_bytes(raster) - mime_type = _detect_image_mime_type(image_bytes) + image_bytes = _to_supported_png_bytes(_as_image_bytes(raster)) href = 'data:{};base64,{}'.format( - mime_type, + 'image/png', str(base64.standard_b64encode(image_bytes), 'utf-8') ) @@ -81,6 +81,34 @@ def _detect_image_mime_type(image_bytes): raise ValueError("Unsupported raster image format: expected PNG, JPEG, GIF or WebP") + +def _to_supported_png_bytes(image_bytes): + _detect_image_mime_type(image_bytes) + + try: + import png + from PIL import Image + + image = Image.open(BytesIO(image_bytes)).convert('RGBA') + width, height = image.size + row_stride = width * 4 + rgba_bytes = image.tobytes() + rows = [ + rgba_bytes[y * row_stride:(y + 1) * row_stride] + for y in range(height) + ] + out = BytesIO() + png.Writer( + width=width, + height=height, + greyscale=False, + alpha=True, + bitdepth=8, + ).write(out, rows) + return out.getvalue() + except Exception as e: + raise ValueError("Unsupported raster image format: expected PNG, JPEG, GIF or WebP") from e + class layer_labels(FeatureSpec): """ Configure annotations for geometry layers. diff --git a/python-package/test/plot/test_annotation_raster.py b/python-package/test/plot/test_annotation_raster.py index a5dfa77b93b..66e822a60f1 100644 --- a/python-package/test/plot/test_annotation_raster.py +++ b/python-package/test/plot/test_annotation_raster.py @@ -4,8 +4,11 @@ # import base64 +import zlib +from io import BytesIO import pytest +from PIL import Image from lets_plot.plot.annotation import annotation_raster @@ -21,12 +24,15 @@ def test_annotation_raster_spec(): ymin=3, ymax=4, interpolate=True, - ) + ).as_dict() + + href = spec.pop('href') + assert href.startswith('data:image/png;base64,') + assert _is_rgba_png_without_filter(base64.b64decode(href.removeprefix('data:image/png;base64,'))) - assert spec.as_dict() == { + assert spec == { 'data_meta': {}, 'geom': 'annotation_raster', - 'href': 'data:image/png;base64,' + PNG_1X1_BASE64, 'mapping': {}, 'xmin': 1, 'xmax': 2, @@ -48,10 +54,64 @@ def test_annotation_raster_default_bounds_are_omitted(): def test_annotation_raster_accepts_bytes_like_values(): - expected_href = 'data:image/png;base64,' + PNG_1X1_BASE64 + href_from_bytearray = annotation_raster(bytearray(PNG_1X1_BYTES)).as_dict()['href'] + href_from_memoryview = annotation_raster(memoryview(PNG_1X1_BYTES)).as_dict()['href'] + + assert _is_rgba_png_without_filter(base64.b64decode(href_from_bytearray.removeprefix('data:image/png;base64,'))) + assert _is_rgba_png_without_filter(base64.b64decode(href_from_memoryview.removeprefix('data:image/png;base64,'))) + + +def test_annotation_raster_converts_rgb_png_to_rgba_png(): + image = Image.new('RGB', (2, 1)) + image.putpixel((0, 0), (255, 0, 0)) + image.putpixel((1, 0), (0, 255, 0)) + + rgb_png = BytesIO() + image.save(rgb_png, format='PNG') + + href = annotation_raster(rgb_png.getvalue()).as_dict()['href'] + png_bytes = base64.b64decode(href.removeprefix('data:image/png;base64,')) + + assert _is_rgba_png_without_filter(png_bytes) + + +def test_annotation_raster_converts_jpeg_to_rgba_png(): + image = Image.new('RGB', (2, 1)) + image.putpixel((0, 0), (255, 0, 0)) + image.putpixel((1, 0), (0, 255, 0)) + + jpeg = BytesIO() + image.save(jpeg, format='JPEG') + + href = annotation_raster(jpeg.getvalue()).as_dict()['href'] + png_bytes = base64.b64decode(href.removeprefix('data:image/png;base64,')) + + assert _is_rgba_png_without_filter(png_bytes) + + +def _is_rgba_png_without_filter(png_bytes): + return ( + png_bytes.startswith(b'\x89PNG\r\n\x1a\n') and + png_bytes[24] == 8 and + png_bytes[25] == 6 and + _png_first_idat_scanline_filter(png_bytes) == 0 + ) + - assert annotation_raster(bytearray(PNG_1X1_BYTES)).as_dict()['href'] == expected_href - assert annotation_raster(memoryview(PNG_1X1_BYTES)).as_dict()['href'] == expected_href +def _png_first_idat_scanline_filter(png_bytes): + offset = 8 + idat = bytearray() + while offset < len(png_bytes): + chunk_length = int.from_bytes(png_bytes[offset:offset + 4], 'big') + chunk_type = png_bytes[offset + 4:offset + 8] + chunk_data = png_bytes[offset + 8:offset + 8 + chunk_length] + if chunk_type == b'IDAT': + idat.extend(chunk_data) + if chunk_type == b'IEND': + break + offset += 12 + chunk_length + + return zlib.decompress(bytes(idat))[0] def test_annotation_raster_rejects_empty_bytes(): From 77b3a4f27a0b8354dc3ca4853f76438ed5646a34 Mon Sep 17 00:00:00 2001 From: Ivan Seleznev Date: Thu, 7 May 2026 11:43:42 +0200 Subject: [PATCH 03/11] Refactor AnnotationRasterGeom to use camelCase for bounding box parameters and streamline boundary resolution logic --- .../plot/base/geom/AnnotationRasterGeom.kt | 46 ++++++------------- .../letsPlot/core/spec/GeomProviderFactory.kt | 8 ++-- 2 files changed, 19 insertions(+), 35 deletions(-) diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt index 66706fe67f9..7138e474e0b 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt @@ -19,10 +19,10 @@ import kotlin.math.min class AnnotationRasterGeom( private val imageUrl: String, - private val xmin: Double?, - private val xmax: Double?, - private val ymin: Double?, - private val ymax: Double?, + private val xMin: Double?, + private val xMax: Double?, + private val yMin: Double?, + private val yMax: Double?, private val interpolate: Boolean, ) : GeomBase() { @@ -54,39 +54,23 @@ class AnnotationRasterGeom( val dataOrigin = coord.fromClient(contentOrigin) val dataCorner = coord.fromClient(contentCorner) - val x0 = when { - xmin == null -> minNotNull(dataOrigin?.x, dataCorner?.x) ?: return null - xmin.isFinite() -> xmin - else -> return null - } - val x1 = when { - xmax == null -> maxNotNull(dataOrigin?.x, dataCorner?.x) ?: return null - xmax.isFinite() -> xmax - else -> return null - } - val y0 = when { - ymin == null -> minNotNull(dataOrigin?.y, dataCorner?.y) ?: return null - ymin.isFinite() -> ymin - else -> return null - } - val y1 = when { - ymax == null -> maxNotNull(dataOrigin?.y, dataCorner?.y) ?: return null - ymax.isFinite() -> ymax - else -> return null - } + val left = resolveBound(xMin, dataOrigin?.x, dataCorner?.x, ::min) ?: return null + val right = resolveBound(xMax, dataOrigin?.x, dataCorner?.x, ::max) ?: return null + val top = resolveBound(yMin, dataOrigin?.y, dataCorner?.y, ::min) ?: return null + val bottom = resolveBound(yMax, dataOrigin?.y, dataCorner?.y, ::max) ?: return null - return DoubleRectangle.span(DoubleVector(x0, y0), DoubleVector(x1, y1)) + return DoubleRectangle.LTRB(left, top, right, bottom) } companion object { const val HANDLES_GROUPS = false - private fun minNotNull(a: Double?, b: Double?): Double? { - return if (a == null || b == null) null else min(a, b) - } - - private fun maxNotNull(a: Double?, b: Double?): Double? { - return if (a == null || b == null) null else max(a, b) + private fun resolveBound(bound: Double?, panelStart: Double?, panelEnd: Double?, edge: (Double, Double) -> Double): Double? { + return when { + bound == null && panelStart != null && panelEnd != null -> edge(panelStart, panelEnd) + bound != null && bound.isFinite() -> bound + else -> null + } } } } diff --git a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProviderFactory.kt b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProviderFactory.kt index fe985fa36a1..af420cdcf08 100644 --- a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProviderFactory.kt +++ b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProviderFactory.kt @@ -388,10 +388,10 @@ internal object GeomProviderFactory { } AnnotationRasterGeom( imageUrl = layerConfig.getString(Option.Geom.AnnotationRaster.HREF)!!, - xmin = annotationRasterBound(layerConfig, Option.Geom.AnnotationRaster.XMIN), - xmax = annotationRasterBound(layerConfig, Option.Geom.AnnotationRaster.XMAX), - ymin = annotationRasterBound(layerConfig, Option.Geom.AnnotationRaster.YMIN), - ymax = annotationRasterBound(layerConfig, Option.Geom.AnnotationRaster.YMAX), + xMin = annotationRasterBound(layerConfig, Option.Geom.AnnotationRaster.XMIN), + xMax = annotationRasterBound(layerConfig, Option.Geom.AnnotationRaster.XMAX), + yMin = annotationRasterBound(layerConfig, Option.Geom.AnnotationRaster.YMIN), + yMax = annotationRasterBound(layerConfig, Option.Geom.AnnotationRaster.YMAX), interpolate = layerConfig.getBoolean(Option.Geom.AnnotationRaster.INTERPOLATE), ) } From fb264045ec80e6c9c21f0e6342d79b6e7427f6b2 Mon Sep 17 00:00:00 2001 From: Ivan Seleznev Date: Thu, 7 May 2026 12:02:22 +0200 Subject: [PATCH 04/11] Refactor image rendering logic to use SvgUtils.ensureDefaultImageRendering with customizable styles --- .../letsPlot/datamodel/svg/dom/SvgUtils.kt | 10 ++++++++++ .../letsPlot/datamodel/svg/util/SvgToString.kt | 18 ++++-------------- .../w3c/mapping/svg/SvgNodeMapperFactory.kt | 17 ++++------------- 3 files changed, 18 insertions(+), 27 deletions(-) diff --git a/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/SvgUtils.kt b/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/SvgUtils.kt index 8688ce4ac54..e87f3676b83 100644 --- a/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/SvgUtils.kt +++ b/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/dom/SvgUtils.kt @@ -108,6 +108,16 @@ object SvgUtils { } } + fun ensureDefaultImageRendering(imageElement: SvgImageElement, defaultStyle: String) { + if (imageElement.getAttribute(SvgImageElement.IMAGE_RENDERING).get() != null) { + return + } + if (imageElement.getAttribute(SvgConstants.SVG_STYLE_ATTRIBUTE).get() != null) { + return + } + imageElement.setAttribute(SvgConstants.SVG_STYLE_ATTRIBUTE, defaultStyle) + } + fun pngDataURI(base64EncodedPngImage: String): String { return StringBuilder("data:image/png;base64,") .append(base64EncodedPngImage) diff --git a/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/util/SvgToString.kt b/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/util/SvgToString.kt index fddd22f9846..af5da28bf4c 100644 --- a/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/util/SvgToString.kt +++ b/datamodel/src/commonMain/kotlin/org/jetbrains/letsPlot/datamodel/svg/util/SvgToString.kt @@ -77,7 +77,10 @@ object SvgToString { } if (childNode is SvgImageElement) { - ensureDefaultImageRendering(childNode) + SvgUtils.ensureDefaultImageRendering( + childNode, + "image-rendering: optimizeSpeed; image-rendering: pixelated" + ) } @Suppress("USELESS_CAST") // Kotlin 1.9 fails to infer correctly here @@ -92,19 +95,6 @@ object SvgToString { buffer.append("') } - private fun ensureDefaultImageRendering(imageElement: SvgImageElement) { - if (imageElement.getAttribute(SvgImageElement.IMAGE_RENDERING).get() != null) { - return - } - if (imageElement.getAttribute(SvgConstants.SVG_STYLE_ATTRIBUTE).get() != null) { - return - } - imageElement.setAttribute( - SvgConstants.SVG_STYLE_ATTRIBUTE, - "image-rendering: optimizeSpeed; image-rendering: pixelated" - ) - } - private fun renderTextNode(node: SvgTextNode, buffer: StringBuilder) { buffer.append(htmlEscape(node.textContent().get())) } diff --git a/platf-w3c/src/webMain/kotlin/org/jetbrains/letsPlot/platf/w3c/mapping/svg/SvgNodeMapperFactory.kt b/platf-w3c/src/webMain/kotlin/org/jetbrains/letsPlot/platf/w3c/mapping/svg/SvgNodeMapperFactory.kt index dcac1233ec0..9dbb6d75659 100644 --- a/platf-w3c/src/webMain/kotlin/org/jetbrains/letsPlot/platf/w3c/mapping/svg/SvgNodeMapperFactory.kt +++ b/platf-w3c/src/webMain/kotlin/org/jetbrains/letsPlot/platf/w3c/mapping/svg/SvgNodeMapperFactory.kt @@ -24,7 +24,10 @@ class SvgNodeMapperFactory(private val myPeer: SvgDomPeer): MapperFactory throw IllegalStateException("Unsupported SvgNode ${source::class}") } - private fun ensureDefaultImageRendering(imageElement: SvgImageElement) { - if (imageElement.getAttribute(SvgImageElement.IMAGE_RENDERING).get() != null) { - return - } - if (imageElement.getAttribute(SvgConstants.SVG_STYLE_ATTRIBUTE).get() != null) { - return - } - imageElement.setAttribute( - SvgConstants.SVG_STYLE_ATTRIBUTE, - "image-rendering: pixelated;image-rendering: crisp-edges;" - ) - } } From c31a11a0378395ad1cdcefdd1f17e9c9f767c6d9 Mon Sep 17 00:00:00 2001 From: Ivan Seleznev Date: Thu, 7 May 2026 12:18:22 +0200 Subject: [PATCH 05/11] Refactor annotation raster option keys to use Image constants for consistency --- .../platf/w3c/mapping/svg/SvgNodeMapperFactory.kt | 1 - .../letsPlot/core/spec/GeomProviderFactory.kt | 12 ++++++------ .../org/jetbrains/letsPlot/core/spec/Option.kt | 6 ------ 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/platf-w3c/src/webMain/kotlin/org/jetbrains/letsPlot/platf/w3c/mapping/svg/SvgNodeMapperFactory.kt b/platf-w3c/src/webMain/kotlin/org/jetbrains/letsPlot/platf/w3c/mapping/svg/SvgNodeMapperFactory.kt index 9dbb6d75659..753f20f513e 100644 --- a/platf-w3c/src/webMain/kotlin/org/jetbrains/letsPlot/platf/w3c/mapping/svg/SvgNodeMapperFactory.kt +++ b/platf-w3c/src/webMain/kotlin/org/jetbrains/letsPlot/platf/w3c/mapping/svg/SvgNodeMapperFactory.kt @@ -53,5 +53,4 @@ class SvgNodeMapperFactory(private val myPeer: SvgDomPeer): MapperFactory throw IllegalStateException("Unsupported SvgNode ${source::class}") } - } diff --git a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProviderFactory.kt b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProviderFactory.kt index af420cdcf08..2d2d4b5ebe3 100644 --- a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProviderFactory.kt +++ b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProviderFactory.kt @@ -383,15 +383,15 @@ internal object GeomProviderFactory { } GeomKind.ANNOTATION_RASTER -> GeomProvider.annotationRaster { - require(layerConfig.hasOwn(Option.Geom.AnnotationRaster.HREF)) { + require(layerConfig.hasOwn(Option.Geom.Image.HREF)) { "Raster image reference URL (href) is not specified." } AnnotationRasterGeom( - imageUrl = layerConfig.getString(Option.Geom.AnnotationRaster.HREF)!!, - xMin = annotationRasterBound(layerConfig, Option.Geom.AnnotationRaster.XMIN), - xMax = annotationRasterBound(layerConfig, Option.Geom.AnnotationRaster.XMAX), - yMin = annotationRasterBound(layerConfig, Option.Geom.AnnotationRaster.YMIN), - yMax = annotationRasterBound(layerConfig, Option.Geom.AnnotationRaster.YMAX), + imageUrl = layerConfig.getString(Option.Geom.Image.HREF)!!, + xMin = annotationRasterBound(layerConfig, Option.Geom.Image.XMIN), + xMax = annotationRasterBound(layerConfig, Option.Geom.Image.XMAX), + yMin = annotationRasterBound(layerConfig, Option.Geom.Image.YMIN), + yMax = annotationRasterBound(layerConfig, Option.Geom.Image.YMAX), interpolate = layerConfig.getBoolean(Option.Geom.AnnotationRaster.INTERPOLATE), ) } diff --git a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/Option.kt b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/Option.kt index 06ee25f9cb5..bd0f960b21f 100644 --- a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/Option.kt +++ b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/Option.kt @@ -456,13 +456,7 @@ object Option { } object AnnotationRaster { - const val HREF = "href" const val INTERPOLATE = "interpolate" - - const val XMIN = "xmin" - const val XMAX = "xmax" - const val YMIN = "ymin" - const val YMAX = "ymax" } object Text { From ff34783efb00650ae5aeeb9c32f963d486a3a2a7 Mon Sep 17 00:00:00 2001 From: Ivan Seleznev Date: Thu, 7 May 2026 13:29:46 +0200 Subject: [PATCH 06/11] Refactor annotation_raster import --- python-package/lets_plot/plot/__init__.py | 2 + python-package/lets_plot/plot/annotation.py | 99 +---------------- .../lets_plot/plot/annotation_raster_.py | 105 ++++++++++++++++++ .../test/plot/test_annotation_raster.py | 2 +- 4 files changed, 109 insertions(+), 99 deletions(-) create mode 100644 python-package/lets_plot/plot/annotation_raster_.py diff --git a/python-package/lets_plot/plot/__init__.py b/python-package/lets_plot/plot/__init__.py index 032a132530b..d437775790e 100644 --- a/python-package/lets_plot/plot/__init__.py +++ b/python-package/lets_plot/plot/__init__.py @@ -3,6 +3,7 @@ # Use of this source code is governed by the MIT license that can be found in the LICENSE file. # from .annotation import * +from .annotation_raster_ import * from .coord import * from .core import * from .expand_limits_ import * @@ -56,6 +57,7 @@ theme_set.__all__ + tooltip.__all__ + annotation.__all__ + + annotation_raster_.__all__ + marginal_layer.__all__ + font_features.__all__ + ggbunch_.__all__ + diff --git a/python-package/lets_plot/plot/annotation.py b/python-package/lets_plot/plot/annotation.py index 71ba5bd2949..17a8674328a 100644 --- a/python-package/lets_plot/plot/annotation.py +++ b/python-package/lets_plot/plot/annotation.py @@ -1,8 +1,6 @@ # Copyright (c) 2022. JetBrains s.r.o. # Use of this source code is governed by the MIT license that can be found in the LICENSE file. -import base64 -from io import BytesIO from typing import List from lets_plot.plot.core import FeatureSpec, _filter_none @@ -11,104 +9,9 @@ # Annotations # -__all__ = ['annotation_raster', 'layer_labels', 'smooth_labels'] +__all__ = ['layer_labels', 'smooth_labels'] -def annotation_raster(raster, xmin=None, xmax=None, ymin=None, ymax=None, interpolate=False): - """ - Add a raster image annotation layer. - - Parameters - ---------- - raster : bytes, bytearray or memoryview - Encoded raster image bytes. Supported formats: PNG, JPEG, GIF and WebP. - xmin, xmax, ymin, ymax : number - Image bounds in data coordinates. None values are interpreted as panel bounds. - interpolate : bool, default=False - If True, interpolate pixels when scaling the image. - - Returns - ------- - ``LayerSpec`` - Geom object specification. - """ - from .geom import _geom - - image_bytes = _to_supported_png_bytes(_as_image_bytes(raster)) - href = 'data:{};base64,{}'.format( - 'image/png', - str(base64.standard_b64encode(image_bytes), 'utf-8') - ) - - return _geom( - 'annotation_raster', - href=href, - xmin=xmin, - xmax=xmax, - ymin=ymin, - ymax=ymax, - interpolate=interpolate, - show_legend=False, - inherit_aes=False, - ) - - -def _as_image_bytes(raster): - if isinstance(raster, bytes): - image_bytes = raster - elif isinstance(raster, bytearray): - image_bytes = bytes(raster) - elif isinstance(raster, memoryview): - image_bytes = raster.tobytes() - else: - raise ValueError("Unsupported raster value: expected bytes, bytearray or memoryview") - - if len(image_bytes) == 0: - raise ValueError("Raster image data is empty") - - return image_bytes - - -def _detect_image_mime_type(image_bytes): - if image_bytes.startswith(b'\x89PNG\r\n\x1a\n'): - return 'image/png' - if image_bytes.startswith(b'\xff\xd8\xff'): - return 'image/jpeg' - if image_bytes.startswith(b'GIF87a') or image_bytes.startswith(b'GIF89a'): - return 'image/gif' - if len(image_bytes) >= 12 and image_bytes[:4] == b'RIFF' and image_bytes[8:12] == b'WEBP': - return 'image/webp' - - raise ValueError("Unsupported raster image format: expected PNG, JPEG, GIF or WebP") - - -def _to_supported_png_bytes(image_bytes): - _detect_image_mime_type(image_bytes) - - try: - import png - from PIL import Image - - image = Image.open(BytesIO(image_bytes)).convert('RGBA') - width, height = image.size - row_stride = width * 4 - rgba_bytes = image.tobytes() - rows = [ - rgba_bytes[y * row_stride:(y + 1) * row_stride] - for y in range(height) - ] - out = BytesIO() - png.Writer( - width=width, - height=height, - greyscale=False, - alpha=True, - bitdepth=8, - ).write(out, rows) - return out.getvalue() - except Exception as e: - raise ValueError("Unsupported raster image format: expected PNG, JPEG, GIF or WebP") from e - class layer_labels(FeatureSpec): """ Configure annotations for geometry layers. diff --git a/python-package/lets_plot/plot/annotation_raster_.py b/python-package/lets_plot/plot/annotation_raster_.py new file mode 100644 index 00000000000..d79addb436c --- /dev/null +++ b/python-package/lets_plot/plot/annotation_raster_.py @@ -0,0 +1,105 @@ +# +# Copyright (c) 2026. JetBrains s.r.o. +# Use of this source code is governed by the MIT license that can be found in the LICENSE file. +# + +import base64 +from io import BytesIO + +__all__ = ['annotation_raster'] + + +def annotation_raster(raster, xmin=None, xmax=None, ymin=None, ymax=None, interpolate=False): + """ + Add a raster image annotation layer. + + Parameters + ---------- + raster : bytes, bytearray or memoryview + Encoded raster image bytes. Supported formats: PNG, JPEG, GIF and WebP. + xmin, xmax, ymin, ymax : number + Image bounds in data coordinates. None values are interpreted as panel bounds. + interpolate : bool, default=False + If True, interpolate pixels when scaling the image. + + Returns + ------- + ``LayerSpec`` + Geom object specification. + """ + from .geom import _geom + + image_bytes = _to_supported_png_bytes(_as_image_bytes(raster)) + href = 'data:{};base64,{}'.format( + 'image/png', + str(base64.standard_b64encode(image_bytes), 'utf-8') + ) + + return _geom( + 'annotation_raster', + href=href, + xmin=xmin, + xmax=xmax, + ymin=ymin, + ymax=ymax, + interpolate=interpolate, + show_legend=False, + inherit_aes=False, + ) + + +def _as_image_bytes(raster): + if isinstance(raster, bytes): + image_bytes = raster + elif isinstance(raster, bytearray): + image_bytes = bytes(raster) + elif isinstance(raster, memoryview): + image_bytes = raster.tobytes() + else: + raise ValueError("Unsupported raster value: expected bytes, bytearray or memoryview") + + if len(image_bytes) == 0: + raise ValueError("Raster image data is empty") + + return image_bytes + + +def _detect_image_mime_type(image_bytes): + if image_bytes.startswith(b'\x89PNG\r\n\x1a\n'): + return 'image/png' + if image_bytes.startswith(b'\xff\xd8\xff'): + return 'image/jpeg' + if image_bytes.startswith(b'GIF87a') or image_bytes.startswith(b'GIF89a'): + return 'image/gif' + if len(image_bytes) >= 12 and image_bytes[:4] == b'RIFF' and image_bytes[8:12] == b'WEBP': + return 'image/webp' + + raise ValueError("Unsupported raster image format: expected PNG, JPEG, GIF or WebP") + + +def _to_supported_png_bytes(image_bytes): + _detect_image_mime_type(image_bytes) + + try: + import png + from PIL import Image + + image = Image.open(BytesIO(image_bytes)).convert('RGBA') + width, height = image.size + row_stride = width * 4 + rgba_bytes = image.tobytes() + rows = [ + rgba_bytes[y * row_stride:(y + 1) * row_stride] + for y in range(height) + ] + out = BytesIO() + png.Writer( + width=width, + height=height, + greyscale=False, + alpha=True, + bitdepth=8, + ).write(out, rows) + return out.getvalue() + except Exception as e: + raise ValueError("Unsupported raster image format: expected PNG, JPEG, GIF or WebP") from e diff --git a/python-package/test/plot/test_annotation_raster.py b/python-package/test/plot/test_annotation_raster.py index 66e822a60f1..888d1a42b2c 100644 --- a/python-package/test/plot/test_annotation_raster.py +++ b/python-package/test/plot/test_annotation_raster.py @@ -10,7 +10,7 @@ import pytest from PIL import Image -from lets_plot.plot.annotation import annotation_raster +from lets_plot.plot.annotation_raster_ import annotation_raster PNG_1X1_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAADklEQVR4nGNgYGD4DwIADvoE/EWwHYsAAAAASUVORK5CYII=' PNG_1X1_BYTES = base64.b64decode(PNG_1X1_BASE64) From 3aedd52ddd852a279e9184361cf5c9e2d133d3e2 Mon Sep 17 00:00:00 2001 From: Ivan Seleznev Date: Thu, 7 May 2026 16:23:25 +0200 Subject: [PATCH 07/11] Add PlotGenericTest to enhance annotation raster testing coverage --- .../plot/annotation_raster_default_bounds.png | Bin 0 -> 4635 bytes .../plot/annotation_raster_inner_bounds.png | Bin 0 -> 4431 bytes .../plot/annotation_raster_outside_bounds.png | Bin 0 -> 4711 bytes .../plot/annotation_raster_over_points.png | Bin 0 -> 4610 bytes ...notation_raster_partial_default_bounds.png | Bin 0 -> 4594 bytes .../plot/annotation_raster_under_points.png | Bin 0 -> 4968 bytes .../visualtesting/plot/AllPlotTests.kt | 3 +- .../visualtesting/plot/PlotGenericTest.kt | 227 ++++++++++++++++++ 8 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_default_bounds.png create mode 100644 platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_inner_bounds.png create mode 100644 platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_outside_bounds.png create mode 100644 platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_over_points.png create mode 100644 platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_partial_default_bounds.png create mode 100644 platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_under_points.png create mode 100644 visual-testing/src/commonMain/kotlin/org/jetbrains/letsPlot/visualtesting/plot/PlotGenericTest.kt diff --git a/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_default_bounds.png b/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_default_bounds.png new file mode 100644 index 0000000000000000000000000000000000000000..f2d6e5a5374be071e8898d5c665b8c43b314f73a GIT binary patch literal 4635 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 XR+-gfMq&Z@fCQNuSs3ORI9~b}bzOb_ literal 0 HcmV?d00001 diff --git a/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_over_points.png b/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_over_points.png new file mode 100644 index 0000000000000000000000000000000000000000..860d54fc3a8b9ceff064445c1329c1689eac82ed GIT binary patch literal 4610 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 literal 0 HcmV?d00001 diff --git a/visual-testing/src/commonMain/kotlin/org/jetbrains/letsPlot/visualtesting/plot/AllPlotTests.kt b/visual-testing/src/commonMain/kotlin/org/jetbrains/letsPlot/visualtesting/plot/AllPlotTests.kt index ac9870f75cb..88b24372768 100644 --- a/visual-testing/src/commonMain/kotlin/org/jetbrains/letsPlot/visualtesting/plot/AllPlotTests.kt +++ b/visual-testing/src/commonMain/kotlin/org/jetbrains/letsPlot/visualtesting/plot/AllPlotTests.kt @@ -13,6 +13,7 @@ object AllPlotTests { var failedTestsCount = 0 failedTestsCount += PlotCompositeTest(canvasPeer, imageComparer).runTests() + failedTestsCount += PlotGenericTest(canvasPeer, imageComparer).runTests() failedTestsCount += PlotInteractivityTest(canvasPeer, imageComparer).runTests() failedTestsCount += PlotThemeTest(canvasPeer, imageComparer).runTests() //failedTestsCount += PlotAxisTest().runTests() @@ -23,4 +24,4 @@ object AllPlotTests { error("$failedTestsCount tests failed!") } } -} \ No newline at end of file +} diff --git a/visual-testing/src/commonMain/kotlin/org/jetbrains/letsPlot/visualtesting/plot/PlotGenericTest.kt b/visual-testing/src/commonMain/kotlin/org/jetbrains/letsPlot/visualtesting/plot/PlotGenericTest.kt new file mode 100644 index 00000000000..b2a07b31d5b --- /dev/null +++ b/visual-testing/src/commonMain/kotlin/org/jetbrains/letsPlot/visualtesting/plot/PlotGenericTest.kt @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026. JetBrains s.r.o. + * Use of this source code is governed by the MIT license that can be found in the LICENSE file. + */ + +@file:Suppress("FunctionName") + +package org.jetbrains.letsPlot.visualtesting.plot + +import org.jetbrains.letsPlot.commons.intern.json.JsonSupport.parseJson +import org.jetbrains.letsPlot.commons.values.Bitmap +import org.jetbrains.letsPlot.core.canvas.CanvasPeer +import org.jetbrains.letsPlot.visualtesting.ImageComparer + +class PlotGenericTest( + override val canvasPeer: CanvasPeer, + override val imageComparer: ImageComparer, +) : PlotTestBase() { + + init { + registerTest(::annotation_raster_under_points) + registerTest(::annotation_raster_over_points) + registerTest(::annotation_raster_default_bounds) + registerTest(::annotation_raster_partial_default_bounds) + registerTest(::annotation_raster_inner_bounds) + registerTest(::annotation_raster_outside_bounds) + } + + fun annotation_raster_under_points(): Bitmap { + val spec = """ + |{ + | "kind": "plot", + | "ggsize": { "width": 320.0, "height": 260.0 }, + | "data": { + | "x": [ 1.0, 1.5, 2.0 ], + | "y": [ 1.0, 1.5, 2.0 ] + | }, + | "mapping": { "x": "x", "y": "y" }, + | "coord": { + | "name": "cartesian", + | "xlim": [ 1.0, 2.0 ], + | "ylim": [ 1.0, 2.0 ] + | }, + | "layers": [ + | { + | "geom": "annotation_raster", + | "href": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAANElEQVR4nGN4757/HxsW7NqEFTMMVw0MDAxgjFUD27UF/9ExTMP/i2kYGKsGEMameLhoAABGcXfsngqr0AAAAABJRU5ErkJggg==", + | "interpolate": false, + | "inherit_aes": false, + | "show_legend": false + | }, + | { + | "geom": "point", + | "size": 12.0, + | "color": "black" + | } + | ], + | "theme": { + | "panel_grid": { "blank": true } + | } + |} + """.trimMargin() + + return paint(parseJson(spec)) + } + + fun annotation_raster_over_points(): Bitmap { + val spec = """ + |{ + | "kind": "plot", + | "ggsize": { "width": 320.0, "height": 260.0 }, + | "data": { + | "x": [ 1.0, 1.5, 2.0 ], + | "y": [ 1.0, 1.5, 2.0 ] + | }, + | "mapping": { "x": "x", "y": "y" }, + | "coord": { + | "name": "cartesian", + | "xlim": [ 1.0, 2.0 ], + | "ylim": [ 1.0, 2.0 ] + | }, + | "layers": [ + | { + | "geom": "point", + | "size": 12.0, + | "color": "black" + | }, + | { + | "geom": "annotation_raster", + | "href": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAANElEQVR4nGN4757/HxsW7NqEFTMMVw0MDAxgjFUD27UF/9ExTMP/i2kYGKsGEMameLhoAABGcXfsngqr0AAAAABJRU5ErkJggg==", + | "interpolate": false, + | "inherit_aes": false, + | "show_legend": false + | } + | ], + | "theme": { + | "panel_grid": { "blank": true } + | } + |} + """.trimMargin() + + return paint(parseJson(spec)) + } + + fun annotation_raster_default_bounds(): Bitmap { + val spec = """ + |{ + | "kind": "plot", + | "ggsize": { "width": 320.0, "height": 260.0 }, + | "coord": { + | "name": "cartesian", + | "xlim": [ 0.0, 10.0 ], + | "ylim": [ 0.0, 10.0 ] + | }, + | "layers": [ + | { + | "geom": "annotation_raster", + | "href": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAANElEQVR4nGN4757/HxsW7NqEFTMMVw0MDAxgjFUD27UF/9ExTMP/i2kYGKsGEMameLhoAABGcXfsngqr0AAAAABJRU5ErkJggg==", + | "interpolate": false, + | "inherit_aes": false, + | "show_legend": false + | } + | ], + | "theme": { + | "panel_grid": { "blank": true } + | } + |} + """.trimMargin() + + return paint(parseJson(spec)) + } + + fun annotation_raster_partial_default_bounds(): Bitmap { + val spec = """ + |{ + | "kind": "plot", + | "ggsize": { "width": 320.0, "height": 260.0 }, + | "coord": { + | "name": "cartesian", + | "xlim": [ 0.0, 10.0 ], + | "ylim": [ 0.0, 10.0 ] + | }, + | "layers": [ + | { + | "geom": "annotation_raster", + | "href": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAANElEQVR4nGN4757/HxsW7NqEFTMMVw0MDAxgjFUD27UF/9ExTMP/i2kYGKsGEMameLhoAABGcXfsngqr0AAAAABJRU5ErkJggg==", + | "interpolate": false, + | "inherit_aes": false, + | "show_legend": false, + | "xmin": 2.0, + | "xmax": 8.0, + | "ymax": 7.0 + | } + | ], + | "theme": { + | "panel_grid": { "blank": true } + | } + |} + """.trimMargin() + + return paint(parseJson(spec)) + } + + fun annotation_raster_inner_bounds(): Bitmap { + val spec = """ + |{ + | "kind": "plot", + | "ggsize": { "width": 320.0, "height": 260.0 }, + | "coord": { + | "name": "cartesian", + | "xlim": [ 0.0, 10.0 ], + | "ylim": [ 0.0, 10.0 ] + | }, + | "layers": [ + | { + | "geom": "annotation_raster", + | "href": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAANElEQVR4nGN4757/HxsW7NqEFTMMVw0MDAxgjFUD27UF/9ExTMP/i2kYGKsGEMameLhoAABGcXfsngqr0AAAAABJRU5ErkJggg==", + | "interpolate": false, + | "inherit_aes": false, + | "show_legend": false, + | "xmin": 2.0, + | "xmax": 8.0, + | "ymin": 2.0, + | "ymax": 8.0 + | } + | ], + | "theme": { + | "panel_grid": { "blank": true } + | } + |} + """.trimMargin() + + return paint(parseJson(spec)) + } + + fun annotation_raster_outside_bounds(): Bitmap { + val spec = """ + |{ + | "kind": "plot", + | "ggsize": { "width": 320.0, "height": 260.0 }, + | "coord": { + | "name": "cartesian", + | "xlim": [ 0.0, 10.0 ], + | "ylim": [ 0.0, 10.0 ] + | }, + | "layers": [ + | { + | "geom": "annotation_raster", + | "href": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAANElEQVR4nGN4757/HxsW7NqEFTMMVw0MDAxgjFUD27UF/9ExTMP/i2kYGKsGEMameLhoAABGcXfsngqr0AAAAABJRU5ErkJggg==", + | "interpolate": false, + | "inherit_aes": false, + | "show_legend": false, + | "xmin": -2.0, + | "xmax": 12.0, + | "ymin": -2.0, + | "ymax": 12.0 + | } + | ], + | "theme": { + | "panel_grid": { "blank": true } + | } + |} + """.trimMargin() + + return paint(parseJson(spec)) + } +} From c65fd1b560f21a31140ef1237b30008c71f4e075 Mon Sep 17 00:00:00 2001 From: Ivan Seleznev Date: Fri, 8 May 2026 14:35:30 +0200 Subject: [PATCH 08/11] Update AnnotationRasterGeom to handle nullable imageUrl and improve error handling in GeomProviderFactory --- .../letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt | 3 ++- .../org/jetbrains/letsPlot/core/spec/GeomProviderFactory.kt | 5 +---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt index 7138e474e0b..a6f8932b6af 100644 --- a/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt @@ -18,7 +18,7 @@ import kotlin.math.max import kotlin.math.min class AnnotationRasterGeom( - private val imageUrl: String, + private val imageUrl: String?, private val xMin: Double?, private val xMax: Double?, private val yMin: Double?, @@ -33,6 +33,7 @@ class AnnotationRasterGeom( coord: CoordinateSystem, ctx: GeomContext ) { + if (imageUrl.isNullOrEmpty()) return val bbox = dataBounds(coord, ctx) ?: return val boundsClient = coord.toClient(bbox) ?: return diff --git a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProviderFactory.kt b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProviderFactory.kt index 2d2d4b5ebe3..63165fbaf72 100644 --- a/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProviderFactory.kt +++ b/plot-stem/src/commonMain/kotlin/org/jetbrains/letsPlot/core/spec/GeomProviderFactory.kt @@ -383,11 +383,8 @@ internal object GeomProviderFactory { } GeomKind.ANNOTATION_RASTER -> GeomProvider.annotationRaster { - require(layerConfig.hasOwn(Option.Geom.Image.HREF)) { - "Raster image reference URL (href) is not specified." - } AnnotationRasterGeom( - imageUrl = layerConfig.getString(Option.Geom.Image.HREF)!!, + imageUrl = layerConfig.getString(Option.Geom.Image.HREF), xMin = annotationRasterBound(layerConfig, Option.Geom.Image.XMIN), xMax = annotationRasterBound(layerConfig, Option.Geom.Image.XMAX), yMin = annotationRasterBound(layerConfig, Option.Geom.Image.YMIN), From d6562fbe5af0753f927b6980712586b52af66f9c Mon Sep 17 00:00:00 2001 From: Ivan Seleznev Date: Fri, 8 May 2026 14:49:13 +0200 Subject: [PATCH 09/11] Add expected images for annotation_raster tests --- .../plot/annotation_raster_default_bounds.png | Bin 0 -> 4119 bytes .../plot/annotation_raster_inner_bounds.png | Bin 0 -> 3936 bytes .../plot/annotation_raster_outside_bounds.png | Bin 0 -> 4266 bytes .../plot/annotation_raster_over_points.png | Bin 0 -> 4287 bytes .../annotation_raster_partial_default_bounds.png | Bin 0 -> 4334 bytes .../plot/annotation_raster_under_points.png | Bin 0 -> 4482 bytes 6 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_default_bounds.png create mode 100644 python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_inner_bounds.png create mode 100644 python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_outside_bounds.png create mode 100644 python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_over_points.png create mode 100644 python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_partial_default_bounds.png create mode 100644 python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_under_points.png diff --git a/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_default_bounds.png b/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_default_bounds.png new file mode 100644 index 0000000000000000000000000000000000000000..b58b6b820c97ef49ccab1c971a3cb52e7340ede2 GIT binary patch literal 4119 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 literal 0 HcmV?d00001 diff --git a/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_inner_bounds.png b/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_inner_bounds.png new file mode 100644 index 0000000000000000000000000000000000000000..229ff897478ea9a89bffa4573d943043805ad0eb GIT binary patch literal 3936 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 U%+vEH_yz&#YMwunrf%i?FXo|Yp8x;= literal 0 HcmV?d00001 diff --git a/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_over_points.png b/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_over_points.png new file mode 100644 index 0000000000000000000000000000000000000000..072bcc946702eef7f8db8aa1deb8364d0ee94ee9 GIT binary patch literal 4287 zcmds*cTiLL7RPTw5QL?zC81}fi3Wq9A_4&gjRAyR5CuU(NdW1hC`CeSSO{gMiQuv{ zkrlxpMQK4g29YWtB$36?d*6hFmmtsQ?7VsV$D23vX79|sfBeq;?(g?I=X=ii+JIx8n?tSd1huWNv)3} z5SLfcD%r;~(=*?A2H0m^JlH518Rhe4*GVJ##7IieuK5NXhhE(gHW!6$R$5x~xb)~+ z@LOG>Dm6=(D{s zE-uH!6;X)X(|gMF_4N^gf`VEH4#YFj4X}k*sUp9YJbENEHa6D0T{(n%$)V)bRCCdvDzrDJSO{uX^rxhyQH*?id{`)mqzqU%vLsdHlhp|z z{RiR;4<(}ib)Y6A3aAm}S$Hc!h2e;g!f*%p-_od&2{O;NvO0fWgLg&J-TnQ4^z`(^ zQ4=++&Yjy+P{sPKrM2~mpBk}E(#W<5k04f7R$84oqZ$bz1G&&WBpqOydJK<#K+3kc6V4< z*n;oo&X}ixS}($2RE)NJt4R_PTZKqSNYEFIxW|hnU(MrP>V1`=D-{zpvhdL5NeotX z^F#*3OP4Nnbaid#tWvYhlCoaCvPG@0EsU1fm-;q~A}ioLos6-!8F}z!P^eHn+vMR4o5KDX1QRb#DCWvuDlB%&1{AJ^lTA z^{!6GH!6lZ9P;_7j160|Vf`~>8^ExPGhgJhBKCd-o?lew>h^;W;VU5#2CPbJ)a>7{f=0;d58i3^;C0)=KfK@F_OiaW@ zL`3Wk;T5L1QhJD4K9-_DBaC%qj~Yk*63+djTZ6WXojK@QINTYImJ1`8SCujw?Ib=1 zOs(B9gi3R#MMsYv{N21SOaO)|A0js@#ND_74rV5t&3Hf6?Z_5Y^r#|mSl#NW`@t|* zh~!0)Pt&w=X5GDQTKo53WbD43L$NgEEEZsk@g$%D1~N$nKa5l&?&k;T?4gY*5P$st zZs6xG;E9!bZhn5W1sSiA8n8DtTytQZE>#U+Mq{>Yo^Sf@kea5!2=j_i?_mv;mB)t|{^2JmV=_^t|-y=eIPQ-R~! zv=QSM4)VZfRlIbVSeuB`VGh^Y(m-Y8eOgisigg!oX%>c?zYW+!L`TcrLtWTM0$g-A zMpTIc0s`&otF`V@cchFELZDk1yH<$*I+xQvBIegus8V>L^U0|7XieG-chY&IXkhHl z2*3yf*)pTcjBCi9=JJ>Iu|#(jGi`Fk1UzM&9x| zSq>tpivQ9@G}XWGNET^>N8+?Lc*Oke29Nw5jfih~WPGaBkCE8gYLWZ&X>3i+F?9`% zg#7&c;FTZv&HG{epKxRoO66{Ac?SjAiin8dJUn)XDQ*U&0o==AATu*FJKEdH9zbz- zSJz2Ct!RO6MF2Hfh@k?|J~g7NtE<)N)5=OpN@Ein%}5phCsf@)0iI5@h(8d7#N}d# zhuxA>Qc|wUZoX#(eAvK1LrhGpRCN2|D+8HoQ9I<2dFX_&+(aq?$` zKGWdZs{djVzIS%^#rEyno7&piD%ka^#192cWSnC{WoJsOcsykvvP9W z>4D^}JajPH=_W5UsBVeD+}jR?ngs_3FJAWW_1$+fCYISzV;Q}D$ByQvrlyLCt?)=@ z-jLI^YYtCxaxzE5=9|^#>$X`UR{PUbT(z~et*}_+)YKH;|A<8U>5H=svVJChq!&1i zx|!KN6;peB3C#w-@AaMi=^3Dj|>+v47i=*V(tIiS_h>lwv+ey;~^f|*SU!(9<%B_fGj z7yX|GUCI|m-cwmi6Pe-kzP^|4_}U1u0A6QDptN=-@%%Y)yVJpkR%YW%uaJJcsFk{c zOkIm7XOn${SNp0tsZXi7rES!s0D6vDUVaLG<9|!tcW?XHu>^>hGNuKL-E>&2mj*OY(vGn X^=}%tOUm+oOaPeU7AE<}oNoLBqp2uY literal 0 HcmV?d00001 diff --git a/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_partial_default_bounds.png b/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_partial_default_bounds.png new file mode 100644 index 0000000000000000000000000000000000000000..5ed2f5e412d2c0bdbadee7e6450b77fd0bcc3c7c GIT binary patch literal 4334 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 literal 0 HcmV?d00001 diff --git a/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_under_points.png b/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_under_points.png new file mode 100644 index 0000000000000000000000000000000000000000..af406e8d656bc7849c01dca1856b2b3c55ae58d2 GIT binary patch literal 4482 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 Date: Fri, 8 May 2026 16:46:00 +0200 Subject: [PATCH 10/11] Fix PlotGenericTest --- .../jetbrains/letsPlot/visualtesting/plot/PlotGenericTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/visual-testing/src/commonMain/kotlin/org/jetbrains/letsPlot/visualtesting/plot/PlotGenericTest.kt b/visual-testing/src/commonMain/kotlin/org/jetbrains/letsPlot/visualtesting/plot/PlotGenericTest.kt index b2a07b31d5b..4c42713e56e 100644 --- a/visual-testing/src/commonMain/kotlin/org/jetbrains/letsPlot/visualtesting/plot/PlotGenericTest.kt +++ b/visual-testing/src/commonMain/kotlin/org/jetbrains/letsPlot/visualtesting/plot/PlotGenericTest.kt @@ -15,7 +15,7 @@ import org.jetbrains.letsPlot.visualtesting.ImageComparer class PlotGenericTest( override val canvasPeer: CanvasPeer, override val imageComparer: ImageComparer, -) : PlotTestBase() { +) : PlotTestSuitBase() { init { registerTest(::annotation_raster_under_points) From f0e7dadf9b1ba402822df44802072cf172fe785a Mon Sep 17 00:00:00 2001 From: Ivan Seleznev Date: Mon, 11 May 2026 16:41:43 +0200 Subject: [PATCH 11/11] Add example notebook --- docs/f-26b/annotation_raster.ipynb | 953 ++++++++++++++++++++++++++++ docs/f-26b/images/blackRedCross.png | Bin 0 -> 783 bytes 2 files changed, 953 insertions(+) create mode 100644 docs/f-26b/annotation_raster.ipynb create mode 100644 docs/f-26b/images/blackRedCross.png diff --git a/docs/f-26b/annotation_raster.ipynb b/docs/f-26b/annotation_raster.ipynb new file mode 100644 index 00000000000..2bc98a6669d --- /dev/null +++ b/docs/f-26b/annotation_raster.ipynb @@ -0,0 +1,953 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "annotation-raster-title", + "metadata": {}, + "source": [ + "# `annotation_raster()`\n", + "\n", + "`annotation_raster()` adds a raster image as a regular plot layer. It does not use aesthetics, but it participates in the layer order: layers added later are drawn on top of earlier layers.\n", + "\n", + "The image is passed as encoded image bytes. Missing bounds (`None`) mean that the corresponding image edge is aligned with the plot panel edge." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "34a547d0-8a53-4ef3-885b-0d9a4739f128", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " \n", + " " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "import requests\n", + "from lets_plot import *\n", + "\n", + "LetsPlot.setup_html()" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "df431e49-3cd7-43e9-a38b-786bfbc53758", + "metadata": {}, + "outputs": [], + "source": [ + "with open(\"./images/blackRedCross.png\", \"rb\") as f:\n", + " image_bytes = f.read()" + ] + }, + { + "cell_type": "markdown", + "id": "annotation-raster-default-bounds", + "metadata": {}, + "source": [ + "## Default Bounds\n", + "\n", + "When no bounds are specified, the raster fills the whole plot panel. In this example the raster layer is added before the point layer, so the points are drawn on top of the image." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "62cd6914-17c3-4450-912b-cbfd992f48a6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "

\n", + "
\n", + "
\n", + " \n", + "
\n", + " \n", + "
\n", + "
\n", + " \n", + " \n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ggplot({\"x\": [1, 2], \"y\": [2, 1]}, aes(\"x\", \"y\")) + \\\n", + " annotation_raster(image_bytes) + \\\n", + " geom_point(size=8) " + ] + }, + { + "cell_type": "markdown", + "id": "annotation-raster-partial-bounds", + "metadata": {}, + "source": [ + "## Partially Specified Bounds\n", + "\n", + "Each bound can be controlled independently. Here `xmin` and `ymin` are fixed in data coordinates, while `xmax=None` and `ymax=None` extend the image to the corresponding panel edges.\n", + "\n", + "The raster layer is added after the points, so it is drawn above them." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "38c3864c-9e73-466b-8e77-027389111be6", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "
\n", + "
\n", + "
\n", + " \n", + "
\n", + " \n", + "
\n", + "
\n", + " \n", + " \n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ggplot({\"x\": [1, 2], \"y\": [2, 1]}, aes(\"x\", \"y\")) + \\\n", + " geom_point(size=8) + \\\n", + " annotation_raster(\n", + " image_bytes,\n", + " xmin=1.2,\n", + " xmax=None,\n", + " ymin=1.2,\n", + " ymax=None,\n", + " interpolate=True\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "annotation-raster-outside-bounds", + "metadata": {}, + "source": [ + "## Bounds Outside the Plot Range\n", + "\n", + "Bounds can be outside the visible coordinate range. The image is positioned according to those data coordinates and only the visible part appears inside the panel." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "25fed76a-2284-4b09-8569-db7ddb6e2862", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "
\n", + "
\n", + "
\n", + " \n", + "
\n", + " \n", + "
\n", + "
\n", + " \n", + " \n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ggplot({\"x\": [1, 2], \"y\": [2, 1]}, aes(\"x\", \"y\")) + \\\n", + " geom_point(size=8) + \\\n", + " annotation_raster(\n", + " image_bytes,\n", + " xmin=0,\n", + " xmax=3,\n", + " ymin=0,\n", + " ymax=3\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "annotation-raster-interpolation", + "metadata": {}, + "source": [ + "## Pixel Interpolation\n", + "\n", + "The `interpolate` parameter controls how the image is scaled. With `interpolate=False`, pixels are kept sharp. With `interpolate=True`, the renderer can smooth the image while resizing it." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "58e8d9d7-e329-4edc-a534-101e8a5cf782", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "
\n", + "
\n", + "
\n", + " \n", + "
\n", + " \n", + "
\n", + "
\n", + " \n", + " \n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "data = {\"x\": [1, 2], \"y\": [2, 1]}\n", + "\n", + "p1 = ggplot(data, aes(\"x\", \"y\")) + \\\n", + " annotation_raster(image_bytes, interpolate=False) + \\\n", + " geom_point(size=8, color=\"red\")\n", + "\n", + "\n", + "p2 = ggplot(data, aes(\"x\", \"y\")) + \\\n", + " annotation_raster(image_bytes, interpolate=True) + \\\n", + " geom_point(size=8, color=\"red\")\n", + "\n", + "gggrid([p1 + ggtitle(\"interpolate=False\"),\n", + " p2 + ggtitle(\"interpolate=True\")])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8634dcbe-a67f-4c0f-b058-d4ab0f256f8d", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.20" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/f-26b/images/blackRedCross.png b/docs/f-26b/images/blackRedCross.png new file mode 100644 index 0000000000000000000000000000000000000000..276a048036830dc4575cdd449074ec4c4aa2e7ad GIT binary patch literal 783 zcmZvaSxggA7=>@Eg^_{_i$fa4Dpn{E0X4=L2o?)WXRN}NO`unxHaxh5L{T6vXdDu( zriQSoV2nD((nc(5jD!ZMs9`PyT%b@O0aqw4kqD?kaeVjTJLf;=e?1k+JCl68WL^LO z9}T63g75nWAr^GLOuRs_g#{|D3ILl)I+rIAvgj~OiWd!bMw}9YTu(tQ0Hx~yC_4+l zFQFg4ya7+T)I@YSb>9ToDRaUwkG6$2~NE(NkbQ&1@ znH<`~*vo9Ey^TDhp?!@V%q}_*k1%O;5PrdA(-Qm?Z=O1S$=WNH%BP+Mu8MR zBGqetmga3yCPkhPegB?SaXGC~cdG1UdCi6y$2hD(gxv6f4TPH!5o{P`Ddjxp(YsC__Yq;tM+fn-LMI7-zj9MARC^8#V_1MwFs&f;|9Z5f$3^iLHkR5f$Dy z%67pF`^6l0C3!2={y&_K<83{$t7fgrBG=wJIE~^SU5#5J`Z2E;HV8BcJJomMbI<$* D&i7E| literal 0 HcmV?d00001