diff --git a/docs/examples/_valid_examples.toml b/docs/examples/_valid_examples.toml index 2eb4d2a5a..3b6786301 100644 --- a/docs/examples/_valid_examples.toml +++ b/docs/examples/_valid_examples.toml @@ -58,7 +58,8 @@ files = [ "viz_pbr_interactive.py", "viz_emwave_animation.py", "viz_helical_motion.py", - "viz_play_video.py" + "viz_play_video.py", + "viz_kde_render.py" ] [ui] diff --git a/docs/examples/viz_kde_render.py b/docs/examples/viz_kde_render.py new file mode 100644 index 000000000..1d7d71514 --- /dev/null +++ b/docs/examples/viz_kde_render.py @@ -0,0 +1,113 @@ +""" +====================================================================== +Fury Kernel Density Estimation rendering Actor +====================================================================== +This example shows how to use the KDE effect. This is a feature in Fury +that uses a post-processing stage to render kernel density estimations of a +given set of points in real-time to the screen. For better understanding +on KDEs, check this `Wikipedia page `_ +about it. + +For this example, you will only need the modules below: +""" +import numpy as np + +from fury.effects import EffectManager, KDE +from fury.window import Scene, ShowManager, record + +##################################################################################### +# This function below will help us to relocate the points for a better visualization, +# but it is not required. + +def normalize(array : np.array, min : float = 0.0, max : float = 1.0, axis : int = 0): + """Convert an array to a given desired range. + + Parameters + ---------- + array : np.ndarray + Array to be normalized. + min : float + Bottom value of the interval of normalization. If no value is given, it is passed as 0.0. + max : float + Upper value of the interval of normalization. If no value is given, it is passed as 1.0. + + Returns + ------- + array : np.array + Array converted to the given desired range. + """ + if np.max(array) != np.min(array): + return ((array - np.min(array))/(np.max(array) - np.min(array)))*(max - min) + min + else: + raise ValueError( + "Can't normalize an array which maximum and minimum value are the same.") + +################################################################## +# First, we need to setup the screen we will render the points to. + +size = (1200, 1000) + +scene = Scene() +scene.set_camera(position=(-24, 20, -40), + focal_point=(0.0, + 0.0, + 0.0), + view_up=(0.0, 0.0, 1.0)) + +manager = ShowManager( + scene, + "KDE Render", + size) + + +#################################################################### +# ``numpy.random.rand`` will be used to generate random points, which +# will be then relocated with the function we declared below to the +# range of ``[-5.0, 5.0]``, so they get more space between them. In case +# offsetted points are wanted, it can be done just as below. + +n_points = 1000 +points = np.random.rand(n_points, 3) +points = normalize(points, -5, 5) +offset = np.array([0.0, 0.0, 0.0]) +points = points + np.tile(offset, points.shape[0]).reshape(points.shape) + +################################################################### +# For this KDE render, we will use a set of random bandwidths, +# generated with ``numpy.random.rand`` as well, which are also +# remapped to the range of ``[0.05, 0.2]``. + +bandwidths = normalize(np.random.rand(n_points, 1), 0.05, 0.2) + +################################################################### +# Now, for the KDE render, a special class is needed, the +# ``EffectManager``. This class is needed to manage the post-processing +# aspect of this kind of render, as it will need to first be +# rendered to an offscreen buffer, retrieved and then processed +# by the final actor that will render it to the screen, but don't +# worry, none of this will need to be setup by you! Just call the +# ``EffectManager`` like below, passing the manager to it: + +effects_m = EffectManager(manager) + +################################################################### +# After having the ``effects_m`` setup, we can add effects to it. +# Here, we will use the ``KDE`` effect, that comes from ``fury.effects.effects`` +# module. When we call it, we pass the points, bandwidth, and other optional options +# if wished, like the kernel to be used or the colormap desired. The available kernels +# can be found inside KDE's description. The colormaps are by default taken from *matplotlib*, +# but a custom one can be passed. After calling it, it returns the KDE class, that will +# then need to be added to the ``EffectManager`` with the ``add()`` method. + +kde_effect = KDE(points, bandwidths, kernel="exponential", colormap="inferno") +effects_m.add(kde_effect) + +#################################################################### +# Having that setup, just start the rendering process to see the results, and we are done here! + +interactive = False + +if interactive: + manager.start() + +record(scene, out_path="kde_points.png", size=(800, 800)) diff --git a/fury/effects/__init__.py b/fury/effects/__init__.py new file mode 100644 index 000000000..ad0aabfce --- /dev/null +++ b/fury/effects/__init__.py @@ -0,0 +1,2 @@ +from fury.effects.effect_manager import EffectManager +from fury.effects.effects import * diff --git a/fury/effects/effect_manager.py b/fury/effects/effect_manager.py new file mode 100644 index 000000000..6279d6576 --- /dev/null +++ b/fury/effects/effect_manager.py @@ -0,0 +1,66 @@ +from functools import partial +from fury.window import Scene, ShowManager + + +class EffectManager(): + """Class that manages the application of post-processing effects on actors. + + Parameters + ---------- + manager : ShowManager + Target manager that will render post processed actors.""" + + def __init__(self, manager : ShowManager): + manager.initialize() + self.scene = Scene() + cam_params = manager.scene.get_camera() + self.scene.set_camera(*cam_params) + self.on_manager = manager + self.off_manager = ShowManager(self.scene, + size=manager.size) + self.off_manager.window.SetOffScreenRendering(True) + self.off_manager.initialize() + self._n_active_effects = 0 + self._active_effects = {} + + def add(self, effect : callable): + """Add an effect to the EffectManager. The effect must have a callable property, + that will act as the callback for the interactor. Check the KDE effect for reference. + + Parameters + ---------- + effect : callable + Effect to be added to the `EffectManager`. + """ + callback = partial( + effect, + off_manager=self.off_manager, + on_manager=self.on_manager + ) + if hasattr(effect, "apply"): + effect.apply(self) + + callback() + callback_id = self.on_manager.add_iren_callback(callback, "RenderEvent") + + self._active_effects[effect] = (callback_id, effect._offscreen_actor) + self._n_active_effects += 1 + self.on_manager.scene.add(effect._onscreen_actor) + + def remove_effect(self, effect): + """ + Remove an existing effect from the effect manager. + + Parameters + ---------- + effect_actor : callable + Effect to be removed. + """ + if self._n_active_effects > 0: + self.on_manager.iren.RemoveObserver(self._active_effects[effect][0]) + self.off_manager.scene.RemoveActor(self._active_effects[effect][1]) + self.on_manager.scene.RemoveActor(effect._onscreen_actor) + self._active_effects.pop(effect) + self._n_active_effects -= 1 + else: + raise IndexError("Manager has no active effects.") diff --git a/fury/effects/effects.py b/fury/effects/effects.py new file mode 100644 index 000000000..05ffa29ea --- /dev/null +++ b/fury/effects/effects.py @@ -0,0 +1,431 @@ +import os +from typing import Any, Union as tUnion + +import numpy as np +from fury.actor import Actor, billboard +from fury.colormap import create_colormap +from fury.effects import EffectManager +from fury.io import load_image +from fury.lib import Texture, WindowToImageFilter +from fury.shaders import (attribute_to_actor, + compose_shader, + import_fury_shader, + shader_apply_effects, + shader_custom_uniforms) +from fury.utils import rgb_to_vtk +from fury.window import (gl_disable_depth, + gl_set_additive_blending, + RenderWindow, + ShowManager) + + +WRAP_MODE_DIC = {"clamptoedge" : Texture.ClampToEdge, + "repeat" : Texture.Repeat, + "mirroredrepeat" : Texture.MirroredRepeat, + "clamptoborder" : Texture.ClampToBorder} + +BLENDING_MODE_DIC = {"none" : 0, "replace" : 1, + "modulate" : 2, "add" : 3, + "addsigned" : 4, "interpolate" : 5, + "subtract" : 6} + + +def window_to_texture( + window : RenderWindow, + texture_name : str, + target_actor : Actor, + blending_mode : str = "None", + wrap_mode : str = "ClampToBorder", + border_color : tuple = ( + 0.0, + 0.0, + 0.0, + 1.0), + interpolate : bool = True, + d_type : str = "rgb"): + """Capture a rendered window and pass it as a texture to the given actor. + + Parameters + ---------- + window : window.RenderWindow + Window to be captured. + texture_name : str + Name of the texture to be passed to the actor. + target_actor : Actor + Target actor to receive the texture. + blending_mode : str, optional + Texture blending mode. The options are: + 1. None + 2. Replace + 3. Modulate + 4. Add + 5. AddSigned + 6. Interpolate + 7. Subtract + wrap_mode : str, optional + Texture wrapping mode. The options are: + 1. ClampToEdge + 2. Repeat + 3. MirroredRepeat + 4. ClampToBorder + border_color : tuple (4, ), optional + Texture RGBA border color. + interpolate : bool, optional + Texture interpolation. + d_type : str, optional + Texture pixel type, "rgb" or "rgba". Default is "rgb" + """ + + windowToImageFilter = WindowToImageFilter() + windowToImageFilter.SetInput(window) + type_dic = {"rgb" : windowToImageFilter.SetInputBufferTypeToRGB, + "rgba" : windowToImageFilter.SetInputBufferTypeToRGBA, + "zbuffer" : windowToImageFilter.SetInputBufferTypeToZBuffer} + type_dic[d_type.lower()]() + windowToImageFilter.Update() + + texture = Texture() + texture.SetMipmap(True) + texture.SetInputConnection(windowToImageFilter.GetOutputPort()) + texture.SetBorderColor(*border_color) + texture.SetWrap(WRAP_MODE_DIC[wrap_mode.lower()]) + texture.SetInterpolate(interpolate) + texture.SetBlendingMode(BLENDING_MODE_DIC[blending_mode.lower()]) + + target_actor.GetProperty().SetTexture(texture_name, texture) + + +def texture_to_actor( + path_to_texture : str, + texture_name : str, + target_actor : Actor, + blending_mode : str = "None", + wrap_mode : str = "ClampToBorder", + border_color : tuple = ( + 0.0, + 0.0, + 0.0, + 1.0), + interpolate : bool = True): + """Pass an imported texture to an actor. + + Parameters + ---------- + path_to_texture : str + Texture image path. + texture_name : str + Name of the texture to be passed to the actor. + target_actor : Actor + Target actor to receive the texture. + blending_mode : str, optional + Texture blending mode. The options are: + 1. None + 2. Replace + 3. Modulate + 4. Add + 5. AddSigned + 6. Interpolate + 7. Subtract + wrap_mode : str, optional + Texture wrapping mode. The options are: + 1. ClampToEdge + 2. Repeat + 3. MirroredRepeat + 4. ClampToBorder + border_color : tuple (4, ), optional + Texture RGBA border color. + interpolate : bool, optional + Texture interpolation.""" + + texture = Texture() + + textureArray = load_image(path_to_texture) + textureData = rgb_to_vtk(textureArray) + + texture.SetInputDataObject(textureData) + texture.SetBorderColor(*border_color) + texture.SetWrap(WRAP_MODE_DIC[wrap_mode.lower()]) + texture.SetInterpolate(interpolate) + texture.MipmapOn() + texture.SetBlendingMode(BLENDING_MODE_DIC[blending_mode.lower()]) + + target_actor.GetProperty().SetTexture(texture_name, texture) + + +def colormap_to_texture( + colormap : np.array, + texture_name : str, + target_actor : Actor, + interpolate : bool = True): + """Convert a colormap to a texture and pass it to an actor. + + Parameters + ---------- + colormap : np.array (N, 4) or (1, N, 4) + RGBA color map array. The array can be two dimensional, although a three dimensional one is preferred. + texture_name : str + Name of the color map texture to be passed to the actor. + target_actor : Actor + Target actor to receive the color map texture. + interpolate : bool, optional + Color map texture interpolation.""" + + if len(colormap.shape) == 2: + colormap = np.array([colormap]) + + texture = Texture() + + cmap = (255*colormap).astype(np.uint8) + cmap = rgb_to_vtk(cmap) + + texture.SetInputDataObject(cmap) + texture.SetWrap(Texture.ClampToEdge) + texture.SetInterpolate(interpolate) + texture.MipmapOn() + texture.SetBlendingMode(0) + + target_actor.GetProperty().SetTexture(texture_name, texture) + + +class KDE(): + """ + Class that implements the Kernel Density Estimation effect of a given set of points. + + Parameters + ---------- + points : np.ndarray (N, 3) + Array of points to be displayed. + bandwidths : float, np.ndarray (1, ) or (N, 1) + Array of bandwidths to be used in the KDE calculations. Must be one or one for each point. + kernel : str, optional + Kernel to be used for the distribution calculation. The available options are: + * "cosine" + * "epanechnikov" + * "exponential" + * "gaussian" + * "linear" + * "tophat" + opacity : float, optional + Opacity of the effect, defined between [0.0, 1.0]. + colormap : str, optional. + Colormap matplotlib name for the KDE rendering. Default is "viridis". + custom_colormap : np.ndarray (N, 4), optional + Custom colormap for the KDE rendering. Default is none which means no + custom colormap is desired. If passed, will overwrite matplotlib colormap + chosen in the previous parameter. + """ + + def __init__(self, + points : np.ndarray, + bandwidths : tUnion[float, np.ndarray], + kernel : str = "gaussian", + opacity : float = 1.0, + colormap : str = "viridis", + custom_colormap : np.array = None): + + # Effect required variables + self._offscreen_actor = None + self._onscreen_actor = None + self.res = None + + # Unmutable variables + self._points = points + + # Mutable variables + self.bandwidths = bandwidths + self.kernel = kernel + self.opacity = opacity + self.colormap = colormap + self.custom_colormap = custom_colormap + if not isinstance(bandwidths, np.ndarray): + bandwidths = np.array([bandwidths]) + if bandwidths.shape[0] != 1 and bandwidths.shape[0] != points.shape[0]: + raise IndexError("bandwidths size must be one or points size.") + elif bandwidths.shape[0] == 1: + bandwidths = np.repeat(bandwidths[0], points.shape[0]) + if np.min(bandwidths) <= 0: + raise ValueError("bandwidths can't have zero or negative values.") + + kde_vs_dec = """ + in float in_bandwidth; + varying float out_bandwidth; + + in float in_scale; + varying float out_scale; + """ + + kde_vs_impl = """ + out_bandwidth = in_bandwidth; + out_scale = in_scale; + """ + + varying_fs_dec = """ + varying float out_bandwidth; + varying float out_scale; + """ + + kde_fs_dec = import_fury_shader( + os.path.join("utils", f"{kernel.lower()}_distribution.glsl")) + + kde_fs_impl = """ + float current_kde = kde(normalizedVertexMCVSOutput*out_scale, out_bandwidth); + color = vec3(current_kde); + fragOutput0 = vec4(color, 1.0); + """ + + fs_dec = compose_shader([varying_fs_dec, kde_fs_dec]) + + """Scales parameter will be defined by the empirical rule: + 1*bandwidth radius = 68.27% of data inside the curve + 2*bandwidth radius = 95.45% of data inside the curve + 3*bandwidth radius = 99.73% of data inside the curve""" + scales = 2*3.0*np.copy(bandwidths) + + self.center_of_mass = np.average(points, axis=0) + self._offscreen_actor = billboard( + points, + (0.0, + 0.0, + 1.0), + scales=scales, + fs_dec=fs_dec, + fs_impl=kde_fs_impl, + vs_dec=kde_vs_dec, + vs_impl=kde_vs_impl) + + attribute_to_actor( + self._offscreen_actor, np.repeat( + bandwidths, 4), "in_bandwidth") + attribute_to_actor(self._offscreen_actor, np.repeat(scales, 4), "in_scale") + + def apply(self, effect_manager : EffectManager): + """ + Apply the KDE effect to the given EffectManager. + + Parameters + ---------- + effect_manager : EffectManager. + """ + bandwidths = self.bandwidths + opacity = self.opacity + colormap = self.colormap + custom_colormap = self.custom_colormap + bill = self._offscreen_actor + center_of_mass = self.center_of_mass + + off_window = effect_manager.off_manager.window + + # Blending setup + shader_apply_effects(off_window, bill, gl_disable_depth) + shader_apply_effects(off_window, bill, gl_set_additive_blending) + + # Important step to guarantee API handles multiple effects + if effect_manager._n_active_effects > 0: + effect_manager.off_manager.scene.GetActors().GetLastActor().SetVisibility(False) + effect_manager.off_manager.scene.add(bill) + + bill_bounds = bill.GetBounds() + max_bandwidth = 2*4.0*np.max(bandwidths) + + actor_scales = np.array([[bill_bounds[1] - bill_bounds[0] + + center_of_mass[0] + max_bandwidth, + bill_bounds[3] - bill_bounds[2] + + center_of_mass[1] + max_bandwidth, 0.0]]) + + scale = np.array([[actor_scales.max(), + actor_scales.max(), + 0.0]]) + + self.res = np.array(effect_manager.off_manager.size) + + # Shaders for the onscreen textured billboard. + tex_dec = import_fury_shader(os.path.join("effects", "color_mapping.glsl")) + + tex_impl = """ + // Turning screen coordinates to texture coordinates + vec2 tex_coords = gl_FragCoord.xy/res; + float intensity = texture(screenTexture, tex_coords).r; + + if(intensity<=0.0){ + discard; + }else{ + vec4 final_color = color_mapping(intensity, colormapTexture); + fragOutput0 = vec4(final_color.rgb, u_opacity*final_color.a); + } + """ + textured_billboard = billboard( + np.array([center_of_mass]), + scales=scale, + fs_dec=tex_dec, + fs_impl=tex_impl) + shader_custom_uniforms( + textured_billboard, + "fragment").SetUniform2f( + "res", + self.res) + shader_custom_uniforms( + textured_billboard, + "fragment").SetUniformf( + "u_opacity", + opacity) + self._onscreen_actor = textured_billboard + + # Disables the texture warnings + textured_billboard.GetProperty().GlobalWarningDisplayOff() + + if custom_colormap is None: + cmap = create_colormap(np.arange(0.0, 1.0, 1/256), colormap) + else: + cmap = custom_colormap + + colormap_to_texture(cmap, "colormapTexture", textured_billboard) + + self.res[0], self.res[1] = effect_manager.on_manager.window.GetSize() + + def __call__(self, obj=None, event=None, + off_manager: ShowManager = None, on_manager: ShowManager = None) -> Any: + """Callback of the KDE effect. + obj : Any + This parameter is passed by VTK. + event : str + This parameter is passed by VTK. + off_manager : ShowManager + Offscreen manager. + on_manager : Show Manager + Onscreen manager.""" + + on_window = on_manager.window + off_window = off_manager.window + + # 1. Updating offscreen renderer. + camera = on_manager.scene.camera() + camera.SetClippingRange(0.01, 1000.01) + off_manager.scene.SetActiveCamera(camera) + self.res[0], self.res[1] = on_window.GetSize() + off_window.SetSize(*self.res) + + # 2. Updating variables. + shader_custom_uniforms( + self._onscreen_actor, + "fragment").SetUniform2f( + "res", + self.res) + + # 3. Sets the visibility of the current actor to True + self._offscreen_actor.SetVisibility(True) + + # 4. Renders the offscreen scene. + off_manager.scene.Modified() + off_manager.render() + + # 5. Passes the offscreen as a texture to the post-processing actor + window_to_texture( + off_manager.window, + "screenTexture", + self._onscreen_actor, + blending_mode="Interpolate", + d_type="rgba") + + # 6. Sets visibility of the current actor to False + self._offscreen_actor.SetVisibility(False) + self._offscreen_actor.Modified() diff --git a/fury/shaders/__init__.py b/fury/shaders/__init__.py index 8a8f11764..fb03e15c0 100644 --- a/fury/shaders/__init__.py +++ b/fury/shaders/__init__.py @@ -8,6 +8,7 @@ replace_shader_in_actor, shader_apply_effects, shader_to_actor, + shader_custom_uniforms ) __all__ = [ @@ -20,4 +21,5 @@ 'replace_shader_in_actor', 'shader_apply_effects', 'shader_to_actor', + 'shader_custom_uniforms' ] diff --git a/fury/shaders/base.py b/fury/shaders/base.py index 6caec9f5c..101b20b53 100644 --- a/fury/shaders/base.py +++ b/fury/shaders/base.py @@ -412,3 +412,24 @@ def attribute_to_actor(actor, arr, attr_name, deep=True): mapper.MapDataArrayToVertexAttribute( attr_name, attr_name, DataObject.FIELD_ASSOCIATION_POINTS, -1 ) + +def shader_custom_uniforms(actor, shader_type): + """Ease the passing of uniform values to the shaders by returning ``actor.GetShaderProperty().GetVertexCustomUniforms()``, + that give access to the ``SetUniform`` methods. + Parameters + ---------- + actor : actor.Actor + Actor which the uniform values will be passed to. + shader_type : str + Shader type of the uniform values to be passed. It can be: + * "vertex" + * "fragment" + * "geometry" + """ + shader_functions = {"vertex" : actor.GetShaderProperty().GetVertexCustomUniforms(), + "fragment" : actor.GetShaderProperty().GetFragmentCustomUniforms(), + "geometry" : actor.GetShaderProperty().GetGeometryCustomUniforms()} + if shader_type.lower() not in shader_functions: + raise ValueError("Shader type should be of type 'vertex', 'fragment' or 'geometry'.") + + return shader_functions[shader_type.lower()] diff --git a/fury/shaders/effects/color_mapping.glsl b/fury/shaders/effects/color_mapping.glsl new file mode 100644 index 000000000..be9334ec6 --- /dev/null +++ b/fury/shaders/effects/color_mapping.glsl @@ -0,0 +1,3 @@ +vec4 color_mapping(float intensity, sampler2D colormapTexture){ + return texture(colormapTexture, vec2(intensity,0)); +} \ No newline at end of file diff --git a/fury/shaders/utils/cosine_distribution.glsl b/fury/shaders/utils/cosine_distribution.glsl new file mode 100644 index 000000000..54a0a8baf --- /dev/null +++ b/fury/shaders/utils/cosine_distribution.glsl @@ -0,0 +1,4 @@ +// This assumes the center of the normal distribution is the center of the screen +float kde(vec3 point, float bandwidth){ + return cos(PI*length(point)/(2*bandwidth))*int(length(point) < bandwidth); +} \ No newline at end of file diff --git a/fury/shaders/utils/epanechnikov_distribution.glsl b/fury/shaders/utils/epanechnikov_distribution.glsl new file mode 100644 index 000000000..b7369ca4d --- /dev/null +++ b/fury/shaders/utils/epanechnikov_distribution.glsl @@ -0,0 +1,4 @@ +// This assumes the center of the normal distribution is the center of the screen +float kde(vec3 point, float bandwidth){ + return (1.0 - (length(point)*length(point))/(bandwidth*bandwidth)); +} \ No newline at end of file diff --git a/fury/shaders/utils/exponential_distribution.glsl b/fury/shaders/utils/exponential_distribution.glsl new file mode 100644 index 000000000..e894ecd2f --- /dev/null +++ b/fury/shaders/utils/exponential_distribution.glsl @@ -0,0 +1,4 @@ +// This assumes the center of the normal distribution is the center of the screen +float kde(vec3 point, float bandwidth){ + return exp(-1.0*length(point)/bandwidth); +} \ No newline at end of file diff --git a/fury/shaders/utils/gaussian_distribution.glsl b/fury/shaders/utils/gaussian_distribution.glsl new file mode 100644 index 000000000..fd2be2275 --- /dev/null +++ b/fury/shaders/utils/gaussian_distribution.glsl @@ -0,0 +1,4 @@ +// This assumes the center of the normal distribution is the center of the screen +float kde(vec3 point, float bandwidth){ + return exp(-1.0*pow(length(point), 2.0)/(2.0*bandwidth*bandwidth) ); +} \ No newline at end of file diff --git a/fury/shaders/utils/linear_distribution.glsl b/fury/shaders/utils/linear_distribution.glsl new file mode 100644 index 000000000..0b42114e0 --- /dev/null +++ b/fury/shaders/utils/linear_distribution.glsl @@ -0,0 +1,4 @@ +// This assumes the center of the normal distribution is the center of the screen +float kde(vec3 point, float bandwidth){ + return (1.0 - length(point)/bandwidth)*int(length(point) < bandwidth); +} \ No newline at end of file diff --git a/fury/shaders/utils/tophat_distribution.glsl b/fury/shaders/utils/tophat_distribution.glsl new file mode 100644 index 000000000..76e44f88a --- /dev/null +++ b/fury/shaders/utils/tophat_distribution.glsl @@ -0,0 +1,4 @@ +// This assumes the center of the normal distribution is the center of the screen +float kde(vec3 point, float bandwidth){ + return int(length(point) < bandwidth); +} \ No newline at end of file diff --git a/fury/tests/test_effects.py b/fury/tests/test_effects.py new file mode 100644 index 000000000..53f8d3ce1 --- /dev/null +++ b/fury/tests/test_effects.py @@ -0,0 +1,423 @@ +import numpy as np +import numpy.testing as npt +import os + +from fury.actor import (billboard, + cube) +from fury.effects import (colormap_to_texture, + EffectManager, + KDE, + texture_to_actor, + window_to_texture) +from fury.colormap import create_colormap +from fury.lib import Texture +from fury.shaders import (shader_custom_uniforms, + import_fury_shader) +from fury.window import (Scene, + ShowManager, + record) +from fury import window + +size = (400, 400) + +WRAP_MODE_DIC = {"clamptoedge" : Texture.ClampToEdge, + "repeat" : Texture.Repeat, + "mirroredrepeat" : Texture.MirroredRepeat, + "clamptoborder" : Texture.ClampToBorder} + +BLENDING_MODE_DIC = {"none" : 0, "replace" : 1, + "modulate" : 2, "add" : 3, + "addsigned" : 4, "interpolate" : 5, + "subtract" : 6} + +points = 5.0*np.array([[0.36600749, 0.65827962, 0.53083986], + [0.97657922, 0.78730041, 0.13946709], + [0.7441061, 0.26322696, 0.8683115], + [0.14606987, 0.05490296, 0.98723486], + [0.71673873, 0.29188497, 0.02825102], + [0.90364963, 0.06387054, 0.91557011], + [0.11106939, 0.73972495, 0.49771819], + [0.63509055, 0.26659524, 0.4790886], + [0.20590893, 0.56012136, 0.78304187], + [0.30247726, 0.28023438, 0.6883304], + [0.58971475, 0.67312749, 0.47656539], + [0.26257592, 0.23964672, 0.64210249], + [0.26631165, 0.35701288, 0.88390072], + [0.01108113, 0.87276217, 0.99321825], + [0.68792169, 0.42725589, 0.92290326], + [0.09702907, 0.69950028, 0.97210289], + [0.86744636, 0.29614399, 0.2729772], + [0.77511449, 0.6912353, 0.97596621], + [0.5919642, 0.25713794, 0.0692452], + [0.47674521, 0.94254354, 0.71231971], + [0.50177591, 0.19320157, 0.91493713], + [0.27073903, 0.58171665, 0.79582017], + [0.76282237, 0.35119548, 0.80971555], + [0.43065933, 0.87678895, 0.57491155], + [0.34213045, 0.70619672, 0.43970999], + [0.38793158, 0.33048163, 0.91679507], + [0.68375111, 0.47934201, 0.86197378], + [0.67829585, 0.80616031, 0.76974334], + [0.01784785, 0.24857252, 0.89913317], + [0.8458996, 0.51551657, 0.69597985]]) + +bandwidths = np.array([[0.56193862], [0.1275334], [0.91069059], + [0.01177131], [0.67799239], [0.95772282], + [0.55834784], [0.60151661], [0.25946789], + [0.88343075], [0.24011991], [0.05879632], + [0.6370561], [0.23859789], [0.18654873], + [0.70008281], [0.02968318], [0.01304724], + [0.08251756], [0.625351], [0.89982588], + [0.62378987], [0.8661594], [0.05583442], + [0.60157791], [0.84737657], [0.36433019], + [0.13263502], [0.30937519], [0.88979053]]) + + +def test_window_to_texture(interactive=False): + fs_impl = """ + vec2 res_factor = vec2(res.y/res.x, 1.0); + vec2 renorm_tex = res_factor*normalizedVertexMCVSOutput.xy*0.5 + 0.5; + vec4 tex = texture(screenTexture, renorm_tex); + fragOutput0 = vec4(tex); + """ + + on_scene = window.Scene() + on_scene.set_camera(position=(-2, 1, -2), + focal_point=(0.0, + 0.0, + 0.0), + view_up=(0.0, 0.0, 0.0)) + + on_manager = window.ShowManager( + on_scene, + "on_manager", + size) + + off_scene = window.Scene() + off_scene.set_camera(position=(-4, 2, -4), + focal_point=(0.0, + 0.0, + 0.0), + view_up=(0.0, 0.0, 0.0)) + + off_manager = window.ShowManager( + off_scene, + "off_manager", + size) + + off_manager.window.SetOffScreenRendering(True) + off_manager.initialize() + + scale = np.array([[size[0]/size[1], 1.0, 0.0]]) + c = cube(np.array([[0.0, 0.0, 0.0]]), colors=(1.0, 1.0, 0.0)) + bill = billboard(np.array([[0.0, 0.0, 0.0]]), scales=scale, fs_impl=fs_impl) + shader_custom_uniforms(bill, "fragment").SetUniform2f("res", off_manager.size) + + off_manager.scene.add(c) + on_manager.scene.add(bill) + + tex_name = "screenTexture" + blending_mode = "Interpolate" + dtype = "RGBA" + wrap_mode = "ClampToBorder" + border_color = (0.0, 0.0, 0.0, 1.0) + interpolate = True + + off_manager.render() + window_to_texture(off_manager.window, + tex_name, + bill, + blending_mode=blending_mode, + wrap_mode=wrap_mode, + border_color=border_color, + interpolate=interpolate, + d_type=dtype) + + if interactive: + window.show(on_manager.scene) + + n_textures = bill.GetProperty().GetNumberOfTextures() + texture = bill.GetProperty().GetTexture(tex_name) + + npt.assert_equal(1, n_textures) + npt.assert_equal(WRAP_MODE_DIC[wrap_mode.lower()], texture.GetWrap()) + npt.assert_equal( + BLENDING_MODE_DIC[blending_mode.lower()], texture.GetBlendingMode()) + npt.assert_array_almost_equal(list(border_color), texture.GetBorderColor()) + npt.assert_equal(interpolate, texture.GetInterpolate()) + npt.assert_equal(True, texture.GetMipmap()) + + +def test_texture_to_actor(interactive=False): + fs_impl = """ + vec2 res_factor = vec2(res.y/res.x, 1.0); + vec2 renorm_tex = res_factor*normalizedVertexMCVSOutput.xy*0.5 + 0.5; + vec4 tex = texture(screenTexture, renorm_tex); + fragOutput0 = vec4(tex); + """ + + on_scene = window.Scene() + on_scene.set_camera(position=(-2, 1, -2), + focal_point=(0.0, + 0.0, + 0.0), + view_up=(0.0, 0.0, 0.0)) + + on_manager = window.ShowManager( + on_scene, + "on_manager", + size) + + scale = np.array([[size[0]/size[1], 1.0, 0.0]]) + bill = billboard(np.array([[0.0, 0.0, 0.0]]), scales=scale, fs_impl=fs_impl) + shader_custom_uniforms(bill, "fragment").SetUniform2f("res", on_manager.size) + + on_manager.scene.add(bill) + + tex_name = "screenTexture" + blending_mode = "Interpolate" + wrap_mode = "ClampToBorder" + border_color = (0.0, 0.0, 0.0, 1.0) + interpolate = True + + texture_to_actor("test_surface.png", + tex_name, + bill, + blending_mode=blending_mode, + wrap_mode=wrap_mode, + border_color=border_color, + interpolate=interpolate) + + if interactive: + window.show(on_manager.scene) + + n_textures = bill.GetProperty().GetNumberOfTextures() + texture = bill.GetProperty().GetTexture(tex_name) + + npt.assert_equal(1, n_textures) + npt.assert_equal(WRAP_MODE_DIC[wrap_mode.lower()], texture.GetWrap()) + npt.assert_equal( + BLENDING_MODE_DIC[blending_mode.lower()], texture.GetBlendingMode()) + npt.assert_array_almost_equal(list(border_color), texture.GetBorderColor()) + npt.assert_equal(interpolate, texture.GetInterpolate()) + npt.assert_equal(True, texture.GetMipmap()) + + +def test_colormap_to_actor(interactive=False): + + fs_dec = import_fury_shader(os.path.join("effects", "color_mapping.glsl")) + + fs_impl = """ + vec2 res_factor = vec2(res.y/res.x, 1.0); + vec2 renorm_tex = res_factor*normalizedVertexMCVSOutput.xy*0.5 + 0.5; + float intensity = renorm_tex.x; + vec4 tex = color_mapping(intensity, colormapTexture); + fragOutput0 = vec4(tex); + """ + + on_scene = window.Scene() + on_scene.set_camera(position=(-2, 1, -2), + focal_point=(0.0, + 0.0, + 0.0), + view_up=(0.0, 0.0, 0.0)) + + on_manager = window.ShowManager( + on_scene, + "on_manager", + size) + + scale = 3.4*np.array([[size[0]/size[1], 1.0, 0.0]]) + bill = billboard(np.array([[0.0, 0.0, 0.0]]), + scales=scale, fs_impl=fs_impl, fs_dec=fs_dec) + shader_custom_uniforms(bill, "fragment").SetUniform2f("res", on_manager.size) + + on_manager.scene.add(bill) + + tex_name = "colormapTexture" + interpolate = True + + colormap_to_texture(create_colormap(np.arange(0.0, 1.0, 1/256), "viridis"), + tex_name, + bill, + interpolate) + + if interactive: + window.show(on_manager.scene) + + n_textures = bill.GetProperty().GetNumberOfTextures() + texture = bill.GetProperty().GetTexture(tex_name) + + npt.assert_equal(1, n_textures) + npt.assert_equal(WRAP_MODE_DIC["ClampToEdge".lower()], texture.GetWrap()) + npt.assert_equal(BLENDING_MODE_DIC["None".lower()], texture.GetBlendingMode()) + npt.assert_equal(interpolate, texture.GetInterpolate()) + npt.assert_equal(True, texture.GetMipmap()) + + +def test_effect_manager_setup(interactive=False): + + scene = Scene() + scene.set_camera(position=(-24, 20, -40), + focal_point=(0.0, + 0.0, + 0.0), + view_up=(0.0, 0.0, 0.0)) + + manager = ShowManager( + scene, + "Test EffectManager", + size) + + em = EffectManager(manager) + + npt.assert_equal(True, em.scene.get_camera() == manager.scene.get_camera()) + npt.assert_equal(True, manager == em.on_manager) + npt.assert_array_equal(manager.window.GetSize(), em.off_manager.window.GetSize()) + npt.assert_equal(True, em.scene == em.off_manager.scene) + npt.assert_equal(True, em.off_manager.window.GetOffScreenRendering()) + npt.assert_equal(0, em._n_active_effects) + npt.assert_equal({}, em._active_effects) + + +def test_kde_effect(): + kde_effect = KDE(points, bandwidths, colormap="inferno") + + npt.assert_equal(True, hasattr(kde_effect, "_offscreen_actor")) + npt.assert_equal(True, hasattr(kde_effect, "_onscreen_actor")) + npt.assert_equal(True, hasattr(kde_effect, "res")) + + npt.assert_array_almost_equal(np.average(points, axis=0), kde_effect.center_of_mass) + npt.assert_equal(True, kde_effect._offscreen_actor is not None) + + +def test_kde_apply(): + scene = Scene() + scene.set_camera(position=(-24, 20, -40), + focal_point=(0.0, + 0.0, + 0.0), + view_up=(0.0, 0.0, 0.0)) + + manager = ShowManager( + scene, + "Test KDE.apply()", + size) + + em = EffectManager(manager) + + kde_effect = KDE(points, bandwidths, colormap="inferno") + + offset = np.array([[5.0, 5.0, 5.0]]) + kde_effect_2 = KDE(points + offset, bandwidths, colormap="viridis") + + kde_effect.apply(em) + + npt.assert_equal(True, kde_effect._onscreen_actor is not None) + npt.assert_array_equal(size, kde_effect.res) + + +def test_call_kde(): + scene = Scene() + scene.set_camera(position=(-24, 20, -40), + focal_point=(0.0, + 0.0, + 0.0), + view_up=(0.0, 0.0, 0.0)) + + manager = ShowManager( + scene, + "Test KDE.apply()", + size) + + em = EffectManager(manager) + + kde_effect = KDE(points, bandwidths, colormap="inferno") + kde_effect.apply(em) + kde_effect(off_manager=em.off_manager, on_manager=em.on_manager) + + off_camera = em.off_manager.scene.camera() + off_clip_range = off_camera.GetClippingRange() + off_cam_params = em.off_manager.scene.get_camera() + off_size = em.off_manager.window.GetSize() + + on_camera = em.on_manager.scene.camera() + on_clip_range = on_camera.GetClippingRange() + on_cam_params = em.on_manager.scene.get_camera() + on_size = em.on_manager.window.GetSize() + + npt.assert_array_equal(on_clip_range, off_clip_range) + npt.assert_array_equal(off_cam_params[0], on_cam_params[0]) + npt.assert_array_equal(off_cam_params[1], on_cam_params[1]) + npt.assert_array_equal(off_cam_params[2], on_cam_params[2]) + npt.assert_array_equal(off_size, on_size) + + +def test_add(interactive=False): + scene = window.Scene() + scene.set_camera(position=(-24, 20, -40), + focal_point=(0.0, + 0.0, + 0.0), + view_up=(0.0, 0.0, 0.0)) + + manager = window.ShowManager( + scene, + "Test add", + size) + + em = EffectManager(manager) + + kde_effect = KDE(points, bandwidths, colormap="inferno") + + em.add(kde_effect) + + if interactive: + window.show(manager.scene) + + off_ascene = window.analyze_scene(em.off_manager.scene) + + on_ascene = window.analyze_scene(manager.scene) + + on_obs = em.on_manager.iren.HasObserver("RenderEvent") + + npt.assert_equal(True, em._n_active_effects > 0) + npt.assert_equal(1, off_ascene.actors) + npt.assert_equal(1, em._n_active_effects) + npt.assert_equal(1, len(em._active_effects)) + npt.assert_equal(1, on_obs) + npt.assert_equal(1, on_ascene.actors) + + +def test_remove_effect(interactive=False): + scene = window.Scene() + scene.set_camera(position=(-24, 20, -40), + focal_point=(0.0, + 0.0, + 0.0), + view_up=(0.0, 0.0, 0.0)) + + manager = window.ShowManager( + scene, + "Test remove_effect", + size) + + em = EffectManager(manager) + + kde_effect = KDE(points, bandwidths, colormap="inferno") + em.add(kde_effect) + + em.remove_effect(kde_effect) + + if interactive: + window.show(manager.scene) + + off_ascene = window.analyze_scene(em.off_manager.scene) + on_ascene = window.analyze_scene(manager.scene) + on_obs = em.on_manager.iren.HasObserver("RenderEvent") + + npt.assert_equal(0, on_obs) + npt.assert_equal(0, on_ascene.actors) + npt.assert_equal(0, off_ascene.actors) + npt.assert_equal({}, em._active_effects) + npt.assert_equal(0, em._n_active_effects) diff --git a/fury/window.py b/fury/window.py index 649a3bf07..b583bb690 100644 --- a/fury/window.py +++ b/fury/window.py @@ -740,8 +740,9 @@ def play_events_from_file(self, filename): def add_window_callback(self, win_callback, event=Command.ModifiedEvent): """Add window callbacks.""" - self.window.AddObserver(event, win_callback) + window_id = self.window.AddObserver(event, win_callback) self.window.Render() + return window_id def add_timer_callback(self, repeat, duration, timer_callback): if not self.iren.GetInitialized(): @@ -758,7 +759,8 @@ def add_timer_callback(self, repeat, duration, timer_callback): def add_iren_callback(self, iren_callback, event='MouseMoveEvent'): if not self.iren.GetInitialized(): self.initialize() - self.iren.AddObserver(event, iren_callback) + iren_id = self.iren.AddObserver(event, iren_callback) + return iren_id def destroy_timer(self, timer_id): self.iren.DestroyTimer(timer_id)