diff --git a/doc/source/modules/gui/plot3d/items.rst b/doc/source/modules/gui/plot3d/items.rst index 5c4884f7a6..ba393368db 100644 --- a/doc/source/modules/gui/plot3d/items.rst +++ b/doc/source/modules/gui/plot3d/items.rst @@ -112,6 +112,29 @@ The following classes allows to configure :class:`ScalarField3D` visualization: getParameters, setParameters, getDisplayValuesBelowMin, setDisplayValuesBelowMin +Height map +---------- + +.. currentmodule:: silx.gui.plot3d.items.image + +:class:`HeightMapData` +++++++++++++++++++++++ + +:class:`HeightMapData` inherits from :class:`.DataItem3D` and also provides its API. + +.. autoclass:: HeightMapData + :members: getData, setData, + getColormappedData, setColormappedData + +:class:`HeightMapRGBA` +++++++++++++++++++++++ + +:class:`HeightMapRGBA` inherits from :class:`.DataItem3D` and also provides its API. + +.. autoclass:: HeightMapRGBA + :members: getData, setData, + getColorData, setColorData + Clipping plane -------------- diff --git a/silx/gui/plot3d/items/__init__.py b/silx/gui/plot3d/items/__init__.py index 5810618179..e7c4af14ef 100644 --- a/silx/gui/plot3d/items/__init__.py +++ b/silx/gui/plot3d/items/__init__.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2019 European Synchrotron Radiation Facility +# Copyright (c) 2017-2021 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -37,7 +37,7 @@ from .mixins import (ColormapMixIn, ComplexMixIn, InterpolationMixIn, # noqa PlaneMixIn, SymbolMixIn) # noqa from .clipplane import ClipPlane # noqa -from .image import ImageData, ImageRgba # noqa +from .image import ImageData, ImageRgba, HeightMapData, HeightMapRGBA # noqa from .mesh import Mesh, ColormapMesh, Box, Cylinder, Hexagon # noqa from .scatter import Scatter2D, Scatter3D # noqa from .volume import ComplexField3D, ScalarField3D # noqa diff --git a/silx/gui/plot3d/items/image.py b/silx/gui/plot3d/items/image.py index cfd1188260..7aeee31c91 100644 --- a/silx/gui/plot3d/items/image.py +++ b/silx/gui/plot3d/items/image.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2017-2020 European Synchrotron Radiation Facility +# Copyright (c) 2017-2021 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -174,3 +174,252 @@ def getData(self, copy=True): :return: The image data """ return self._image.getData(copy=copy) + + +class _HeightMap(DataItem3D): + """Base class for 2D data array displayed as a height field. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + DataItem3D.__init__(self, parent=parent) + self.__data = numpy.zeros((0, 0), dtype=numpy.float32) + + def _pickFull(self, context, threshold=0., sort='depth'): + """Perform picking in this item at given widget position. + + :param PickContext context: Current picking context + :param float threshold: Picking threshold in pixel. + Perform picking in a square of size threshold x threshold. + :param str sort: How returned indices are sorted: + + - 'index' (default): sort by the value of the indices + - 'depth': Sort by the depth of the points from the current + camera point of view. + :return: Object holding the results or None + :rtype: Union[None,PickingResult] + """ + assert sort in ('index', 'depth') + + rayNdc = context.getPickingSegment(frame='ndc') + if rayNdc is None: # No picking outside viewport + return None + + # TODO no colormapped or color data + # Project data to NDC + heightData = self.getData(copy=False) + if heightData.size == 0: + return # Nothing displayed + + height, width = heightData.shape + z = numpy.ravel(heightData) + y, x = numpy.mgrid[0:height, 0:width] + dataPoints = numpy.transpose((numpy.ravel(x), + numpy.ravel(y), + z, + numpy.ones_like(z))) + + primitive = self._getScenePrimitive() + + pointsNdc = primitive.objectToNDCTransform.transformPoints( + dataPoints, perspectiveDivide=True) + + # Perform picking + distancesNdc = numpy.abs(pointsNdc[:, :2] - rayNdc[0, :2]) + # TODO issue with symbol size: using pixel instead of points + threshold += 1. # symbol size + thresholdNdc = 2. * threshold / numpy.array(primitive.viewport.size) + picked = numpy.where(numpy.logical_and( + numpy.all(distancesNdc < thresholdNdc, axis=1), + numpy.logical_and(rayNdc[0, 2] <= pointsNdc[:, 2], + pointsNdc[:, 2] <= rayNdc[1, 2])))[0] + + if sort == 'depth': + # Sort picked points from front to back + picked = picked[numpy.argsort(pointsNdc[picked, 2])] + + if picked.size > 0: + # Convert indices from 1D to 2D + return PickingResult(self, + positions=dataPoints[picked, :3], + indices=(picked // height, picked % height), + fetchdata=self.getData) + else: + return None + + def setData(self, data, copy: bool=True): + """Set the height field data. + + :param data: + :param copy: True (default) to copy the data, + False to use as is (do not modify!). + """ + data = numpy.array(data, copy=copy) + assert data.ndim == 2 + + self.__data = data + self._updated(ItemChangedType.DATA) + + def getData(self, copy: bool=True) -> numpy.ndarray: + """Get the height field 2D data. + + :param bool copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + """ + return numpy.array(self.__data, copy=copy) + + +class HeightMapData(_HeightMap, ColormapMixIn): + """Description of a 2D height field associated to a colormapped dataset. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + _HeightMap.__init__(self, parent=parent) + ColormapMixIn.__init__(self) + + self.__data = numpy.zeros((0, 0), dtype=numpy.float32) + + def _updated(self, event=None): + if event == ItemChangedType.DATA: + self.__updateScene() + super()._updated(event=event) + + def __updateScene(self): + """Update display primitive to use""" + self._getScenePrimitive().children = [] # Remove previous primitives + ColormapMixIn._setSceneColormap(self, None) + + if not self.isVisible(): + return # Update when visible + + data = self.getColormappedData(copy=False) + heightData = self.getData(copy=False) + + if data.size == 0 or heightData.size == 0: + return # Nothing to display + + # Display as a set of points + height, width = heightData.shape + # Generates coordinates + y, x = numpy.mgrid[0:height, 0:width] + + if data.shape != heightData.shape: # data and height size miss-match + # Colormapped data is interpolated (nearest-neighbour) to match the height field + data = data[numpy.floor(y * data.shape[0] / height).astype(numpy.int), + numpy.floor(x * data.shape[1] / height).astype(numpy.int)] + + x = numpy.ravel(x) + y = numpy.ravel(y) + + primitive = primitives.Points( + x=x, + y=y, + z=numpy.ravel(heightData), + value=numpy.ravel(data), + size=1) + primitive.marker = 's' + ColormapMixIn._setSceneColormap(self, primitive.colormap) + self._getScenePrimitive().children = [primitive] + + def setColormappedData(self, data, copy: bool=True): + """Set the 2D data used to compute colors. + + :param data: 2D array of data + :param copy: True (default) to copy the data, + False to use as is (do not modify!). + """ + data = numpy.array(data, copy=copy) + assert data.ndim == 2 + + self.__data = data + self._updated(ItemChangedType.DATA) + + def getColormappedData(self, copy: bool=True) -> numpy.ndarray: + """Returns the 2D data used to compute colors. + + :param copy: + True (default) to get a copy, + False to get internal representation (do not modify!). + """ + return numpy.array(self.__data, copy=copy) + + +class HeightMapRGBA(_HeightMap): + """Description of a 2D height field associated to a RGB(A) image. + + :param parent: The View widget this item belongs to. + """ + + def __init__(self, parent=None): + _HeightMap.__init__(self, parent=parent) + + self.__rgba = numpy.zeros((0, 0, 3), dtype=numpy.float32) + + def _updated(self, event=None): + if event == ItemChangedType.DATA: + self.__updateScene() + super()._updated(event=event) + + def __updateScene(self): + """Update display primitive to use""" + self._getScenePrimitive().children = [] # Remove previous primitives + + if not self.isVisible(): + return # Update when visible + + rgba = self.getColorData(copy=False) + heightData = self.getData(copy=False) + if rgba.size == 0 or heightData.size == 0: + return # Nothing to display + + # Display as a set of points + height, width = heightData.shape + # Generates coordinates + y, x = numpy.mgrid[0:height, 0:width] + + if rgba.shape[:2] != heightData.shape: # image and height size miss-match + # RGBA data is interpolated (nearest-neighbour) to match the height field + rgba = rgba[numpy.floor(y * rgba.shape[0] / height).astype(numpy.int), + numpy.floor(x * rgba.shape[1] / height).astype(numpy.int)] + + x = numpy.ravel(x) + y = numpy.ravel(y) + + primitive = primitives.ColorPoints( + x=x, + y=y, + z=numpy.ravel(heightData), + color=rgba.reshape(-1, rgba.shape[-1]), + size=1) + primitive.marker = 's' + self._getScenePrimitive().children = [primitive] + + def setColorData(self, data, copy: bool=True): + """Set the RGB(A) image to use. + + Supported array format: float32 in [0, 1], uint8. + + :param data: + The RGBA image data as an array of shape (H, W, Channels) + :param copy: True (default) to copy the data, + False to use as is (do not modify!). + """ + data = numpy.array(data, copy=copy) + assert data.ndim == 3 + assert data.shape[-1] in (3, 4) + # TODO check type + + self.__rgba = data + self._updated(ItemChangedType.DATA) + + def getColorData(self, copy: bool=True) -> numpy.ndarray: + """Get the RGB(A) image data. + + :param copy: True (default) to get a copy, + False to get internal representation (do not modify!). + """ + return numpy.array(self.__rgba, copy=copy) diff --git a/silx/gui/plot3d/test/testSceneWindow.py b/silx/gui/plot3d/test/testSceneWindow.py index b2e6ea02a3..8cf6b81a7b 100644 --- a/silx/gui/plot3d/test/testSceneWindow.py +++ b/silx/gui/plot3d/test/testSceneWindow.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2019 European Synchrotron Radiation Facility +# Copyright (c) 2019-2021 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -37,7 +37,7 @@ from silx.gui import qt from silx.gui.plot3d.SceneWindow import SceneWindow - +from silx.gui.plot3d.items import HeightMapData, HeightMapRGBA class TestSceneWindow(TestCaseQt, ParametricTestCase): """Tests SceneWidget picking feature""" @@ -114,6 +114,42 @@ def testAdd(self): sceneWidget.resetZoom('front') self.qapp.processEvents() + def testHeightMap(self): + """Test height map items""" + sceneWidget = self.window.getSceneWidget() + + height = numpy.arange(10000).reshape(100, 100) /100. + + for shape in ((100, 100), (4, 5), (150, 20), (110, 110)): + with self.subTest(shape=shape): + items = [] + + # Colormapped data height map + data = numpy.arange(numpy.prod(shape)).astype(numpy.float32).reshape(shape) + + heightmap = HeightMapData() + heightmap.setData(height) + heightmap.setColormappedData(data) + heightmap.getColormap().setName('viridis') + items.append(heightmap) + sceneWidget.addItem(heightmap) + + # RGBA height map + colors = numpy.zeros(shape + (3,), dtype=numpy.float32) + colors[:, :, 1] = numpy.random.random(shape) + + heightmap = HeightMapRGBA() + heightmap.setData(height) + heightmap.setColorData(colors) + heightmap.setTranslation(100., 0., 0.) + items.append(heightmap) + sceneWidget.addItem(heightmap) + + self.assertEqual(sceneWidget.getItems(), tuple(items)) + sceneWidget.resetZoom('front') + self.qapp.processEvents() + sceneWidget.clearItems() + def testChangeContent(self): """Test add/remove/clear items""" sceneWidget = self.window.getSceneWidget() diff --git a/silx/gui/plot3d/tools/PositionInfoWidget.py b/silx/gui/plot3d/tools/PositionInfoWidget.py index fc86a7f0e6..78f2959647 100644 --- a/silx/gui/plot3d/tools/PositionInfoWidget.py +++ b/silx/gui/plot3d/tools/PositionInfoWidget.py @@ -1,7 +1,7 @@ # coding: utf-8 # /*########################################################################## # -# Copyright (c) 2018-2019 European Synchrotron Radiation Facility +# Copyright (c) 2018-2021 European Synchrotron Radiation Facility # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -144,6 +144,8 @@ def clear(self): items.Scatter2D, items.ImageData, items.ImageRgba, + items.HeightMapData, + items.HeightMapRGBA, items.Mesh, items.Box, items.Cylinder,