Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

silx.gui.plot3d: Added HeightMapData and HeightMapRGBA items #3386

Merged
merged 7 commits into from
Mar 2, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions doc/source/modules/gui/plot3d/items.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------

Expand Down
4 changes: 2 additions & 2 deletions silx/gui/plot3d/items/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
251 changes: 250 additions & 1 deletion silx/gui/plot3d/items/image.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
40 changes: 38 additions & 2 deletions silx/gui/plot3d/test/testSceneWindow.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion silx/gui/plot3d/tools/PositionInfoWidget.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -144,6 +144,8 @@ def clear(self):
items.Scatter2D,
items.ImageData,
items.ImageRgba,
items.HeightMapData,
items.HeightMapRGBA,
items.Mesh,
items.Box,
items.Cylinder,
Expand Down