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 ef6dba0ad24..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) { - childNode.setAttribute(SvgConstants.SVG_STYLE_ATTRIBUTE, "image-rendering: optimizeSpeed; image-rendering: pixelated") + SvgUtils.ensureDefaultImageRendering( + childNode, + "image-rendering: optimizeSpeed; image-rendering: pixelated" + ) } @Suppress("USELESS_CAST") // Kotlin 1.9 fails to infer correctly here @@ -126,4 +129,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/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 00000000000..276a0480368 Binary files /dev/null and b/docs/f-26b/images/blackRedCross.png differ 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 00000000000..f2d6e5a5374 Binary files /dev/null and b/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_default_bounds.png differ diff --git a/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_inner_bounds.png b/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_inner_bounds.png new file mode 100644 index 00000000000..e1b287f3558 Binary files /dev/null and b/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_inner_bounds.png differ diff --git a/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_outside_bounds.png b/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_outside_bounds.png new file mode 100644 index 00000000000..5afe71cbe6a Binary files /dev/null and b/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_outside_bounds.png differ 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 00000000000..860d54fc3a8 Binary files /dev/null and b/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_over_points.png differ diff --git a/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_partial_default_bounds.png b/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_partial_default_bounds.png new file mode 100644 index 00000000000..9ede60f8d65 Binary files /dev/null and b/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_partial_default_bounds.png differ diff --git a/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_under_points.png b/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_under_points.png new file mode 100644 index 00000000000..c4af0213d94 Binary files /dev/null and b/platf-awt/src/test/resources/expected-images/visual-testing/plot/annotation_raster_under_points.png differ 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..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 @@ -24,7 +24,10 @@ class SvgNodeMapperFactory(private val myPeer: SvgDomPeer): MapperFactory throw IllegalStateException("Unsupported SvgNode ${source::class}") } -} \ No newline at end of file +} 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..a6f8932b6af --- /dev/null +++ b/plot-base/src/commonMain/kotlin/org/jetbrains/letsPlot/core/plot/base/geom/AnnotationRasterGeom.kt @@ -0,0 +1,77 @@ +/* + * 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.SvgConstants +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 + ) { + if (imageUrl.isNullOrEmpty()) return + 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( + SvgConstants.SVG_STYLE_ATTRIBUTE, + if (interpolate) "image-rendering: auto" else "image-rendering: pixelated;image-rendering: crisp-edges;" + ) + 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 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.LTRB(left, top, right, bottom) + } + + companion object { + const val HANDLES_GROUPS = false + + 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-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..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 @@ -382,6 +382,17 @@ internal object GeomProviderFactory { ) } + GeomKind.ANNOTATION_RASTER -> GeomProvider.annotationRaster { + AnnotationRasterGeom( + 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), + ) + } + GeomKind.PIE -> GeomProvider.pie { val geom = PieGeom() layerConfig.getDouble(Pie.HOLE)?.let { geom.holeSize = it } @@ -584,4 +595,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..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 @@ -455,6 +455,10 @@ object Option { val YMAX = Aes.YMAX.name } + object AnnotationRaster { + const val INTERPOLATE = "interpolate" + } + object Text { const val LABEL_FORMAT = "label_format" const val NA_TEXT = "na_text" @@ -1185,6 +1189,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 +1251,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 +1346,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-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 00000000000..b58b6b820c9 Binary files /dev/null and b/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_default_bounds.png differ 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 00000000000..229ff897478 Binary files /dev/null and b/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_inner_bounds.png differ diff --git a/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_outside_bounds.png b/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_outside_bounds.png new file mode 100644 index 00000000000..12e1fe95380 Binary files /dev/null and b/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_outside_bounds.png differ 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 00000000000..072bcc94670 Binary files /dev/null and b/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_over_points.png differ 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 00000000000..5ed2f5e412d Binary files /dev/null and b/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_partial_default_bounds.png differ 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 00000000000..af406e8d656 Binary files /dev/null and b/python-extension/src/nativeTest/resources/expected-images/visual-testing/plot/annotation_raster_under_points.png differ 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_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 new file mode 100644 index 00000000000..888d1a42b2c --- /dev/null +++ b/python-package/test/plot/test_annotation_raster.py @@ -0,0 +1,124 @@ +# +# 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 zlib +from io import BytesIO + +import pytest +from PIL import Image + +from lets_plot.plot.annotation_raster_ 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, + ).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 == { + 'data_meta': {}, + 'geom': 'annotation_raster', + '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(): + 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 + ) + + +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(): + 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') 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..4c42713e56e --- /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, +) : PlotTestSuitBase() { + + 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)) + } +}