From e16f1177b88882d84f9a2a10d031c866c8bede36 Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Thu, 26 Sep 2024 16:56:53 -0700 Subject: [PATCH 1/2] fix (refactor slice explorer): Addressing Seb review Making necessary code maintainance and readability changes Adding presets for diverging color maps Expand and collapse drawer to expand render area Adding support for varied datetime objects for time dimension Adding documentation --- examples/slice_explorer.py | 2 + pan3d/__init__.py | 3 +- pan3d/dataset_builder.py | 40 +- pan3d/explorers/Presets.json | 295 ++++++++++++++ pan3d/explorers/slice_explorer.py | 625 ++++++++++++++++++------------ pan3d/explorers/utilities.py | 78 ++++ 6 files changed, 776 insertions(+), 267 deletions(-) create mode 100644 pan3d/explorers/Presets.json create mode 100644 pan3d/explorers/utilities.py diff --git a/examples/slice_explorer.py b/examples/slice_explorer.py index 3f8dd27..d27f49d 100644 --- a/examples/slice_explorer.py +++ b/examples/slice_explorer.py @@ -3,6 +3,7 @@ from pan3d import DatasetBuilder from pan3d import SliceExplorer + def serve(): parser = ArgumentParser( prog="Pan3D", @@ -20,5 +21,6 @@ def serve(): viewer = SliceExplorer(builder) viewer.start() + if __name__ == "__main__": serve() diff --git a/pan3d/__init__.py b/pan3d/__init__.py index 4905f3b..49cee75 100644 --- a/pan3d/__init__.py +++ b/pan3d/__init__.py @@ -10,7 +10,8 @@ try: from .dataset_viewer import DatasetViewer from .explorers.slice_explorer import SliceExplorer + __all__ = [DatasetBuilder, DatasetViewer, SliceExplorer] except Exception: # Trame is not installed, DatasetViewer will not be accessible - __all__ = [DatasetBuilder] \ No newline at end of file + __all__ = [DatasetBuilder] diff --git a/pan3d/dataset_builder.py b/pan3d/dataset_builder.py index b934c2b..1f0683d 100644 --- a/pan3d/dataset_builder.py +++ b/pan3d/dataset_builder.py @@ -320,35 +320,33 @@ def t_index(self, t_index: int) -> None: self._viewer._mesh_changed() @property - def t_num(self) -> int: + def t_size(self) -> int: + """Returns the number of time slices available""" if not self.t: - raise ValueError( - "Cannot set time index > 0 without setting t array first." - ) + raise ValueError("Cannot set time index > 0 without setting t array first.") t_coords = self.dataset[self.data_array_name].coords[self.t] return t_coords.size - + @property def t_range(self) -> Tuple[Any]: + """Returns the min and max values for the temporal coordinate""" if not self.t: - raise ValueError( - "Cannot set time index > 0 without setting t array first." - ) + raise ValueError("Cannot set time index > 0 without setting t array first.") t_coords = self.dataset[self.data_array_name].coords[self.t].to_numpy().tolist() return (t_coords[0], t_coords[-1]) - + @property def t_values(self) -> List: + """Returns the values for the temporal dimension""" if not self.t: - raise ValueError( - "Cannot set time index > 0 without setting t array first." - ) + raise ValueError("Cannot set time index > 0 without setting t array first.") t_coords = self.dataset[self.data_array_name].coords[self.t] return t_coords.to_numpy().tolist() @property def var_ranges(self) -> map: - range_map = {} + """Returns a map with variable names as keys and their ranges as values""" + range_map = {} for var in self.dataset.data_vars: arr = self.dataset[var].to_numpy() range_map[var] = (arr.min(), arr.max()) @@ -362,9 +360,13 @@ def slicing(self) -> Dict[str, List]: integers or floats representing start value, stop value, and step. """ return self._algorithm.slicing - + @property def extents(self) -> map: + """ + Returns a map with dimension name as keys and their range (extents) + as values + """ extents = {} dims = [self.x, self.y, self.z] for i, dim in enumerate(dims): @@ -514,11 +516,11 @@ def _auto_select_slicing( k: [ v[0], v[1], - math.ceil((v[1] - v[0]) / self._resolution) - if self._resolution > 1 and v[1] - v[0] > 0 and k != self.t - else steps.get(k, 1) - if steps is not None and k != self.t - else 1, + ( + math.ceil((v[1] - v[0]) / self._resolution) + if self._resolution > 1 and v[1] - v[0] > 0 and k != self.t + else steps.get(k, 1) if steps is not None and k != self.t else 1 + ), ] for k, v in bounds.items() } diff --git a/pan3d/explorers/Presets.json b/pan3d/explorers/Presets.json new file mode 100644 index 0000000..a002d2d --- /dev/null +++ b/pan3d/explorers/Presets.json @@ -0,0 +1,295 @@ +[ + { + "ColorSpace" : "Diverging", + "Name" : "Cool to Warm", + "NanColor" : [ 1, 1, 0 ], + "RGBPoints" : [ + 0, + 0.23137254902, + 0.298039215686, + 0.752941176471, + 0.5, + 0.865, + 0.865, + 0.865, + 1, + 0.705882352941, + 0.0156862745098, + 0.149019607843 + ] + }, + { + "ColorSpace" : "RGB", + "Name" : "Rainbow Desaturated", + "NanColor" : [ 1, 1, 0 ], + "RGBPoints" : [ + 0, + 0.278431372549, + 0.278431372549, + 0.858823529412, + 0.143, + 0, + 0, + 0.360784313725, + 0.285, + 0, + 1, + 1, + 0.429, + 0, + 0.501960784314, + 0, + 0.571, + 1, + 1, + 0, + 0.714, + 1, + 0.380392156863, + 0, + 0.857, + 0.419607843137, + 0, + 0, + 1, + 0.8784313725489999, + 0.301960784314, + 0.301960784314 + ] + }, + { + "ColorSpace" : "RGB", + "Name" : "Cold and Hot", + "NanColor" : [ 1, 1, 0 ], + "RGBPoints" : [ + 0, + 0, + 1, + 1, + 0.45, + 0, + 0, + 1, + 0.5, + 0, + 0, + 0.501960784314, + 0.55, + 1, + 0, + 0, + 1, + 1, + 1, + 0 + ] + }, + { + "ColorSpace" : "RGB", + "Name" : "Black-Body Radiation", + "NanColor" : [ 0, 0.498039215686, 1 ], + "RGBPoints" : [ + 0, + 0, + 0, + 0, + 0.4, + 0.901960784314, + 0, + 0, + 0.8, + 0.901960784314, + 0.901960784314, + 0, + 1, + 1, + 1, + 1 + ] + }, + { + "ColorSpace" : "RGB", + "Name" : "Black, Blue and White", + "NanColor" : [ 1, 1, 0 ], + "RGBPoints" : [ + 0, + 0, + 0, + 0, + 0.333, + 0, + 0, + 0.501960784314, + 0.666, + 0, + 0.501960784314, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "ColorSpace" : "RGB", + "Name" : "Black, Orange and White", + "NanColor" : [ 1, 1, 0 ], + "RGBPoints" : [ + 0, + 0, + 0, + 0, + 0.333, + 0.501960784314, + 0, + 0, + 0.666, + 1, + 0.501960784314, + 0, + 1, + 1, + 1, + 1 + ] + },{ + "ColorSpace" : "Lab", + "Name" : "erdc_rainbow_bright", + "RGBPoints" : [ + -1, + 0.32549, + 0.14902, + 0.960784, + -0.866221, + 0.297047, + 0.375586, + 0.963836, + -0.732441, + 0.180302, + 0.536818, + 0.964627, + -0.598662, + 0.1302, + 0.649207, + 0.929647, + -0.464883, + 0.0445143, + 0.749654, + 0.855998, + -0.331104, + 0.0271325, + 0.830713, + 0.721527, + -0.197324, + 0.259504, + 0.8661450000000001, + 0.543555, + -0.0635452, + 0.428364, + 0.890725, + 0.329819, + 0.07023409999999999, + 0.568503, + 0.898508, + 0.187623, + 0.204013, + 0.738259, + 0.890317, + 0.0825461, + 0.337793, + 0.84546, + 0.86136, + 0.0147555, + 0.471572, + 0.912191, + 0.808018, + 0, + 0.605351, + 0.962848, + 0.710445, + 0, + 0.73913, + 0.9994690000000001, + 0.600258, + 0.0176284, + 0.87291, + 0.994156, + 0.445975, + 0.193912, + 1, + 0.980407, + 0.247105, + 0.262699 + ] + }, + { + "ColorSpace" : "Lab", + "Name" : "erdc_rainbow_dark", + "RGBPoints" : [ + -1, + 0, + 0, + 0.423499, + -0.866221, + 0, + 0.119346, + 0.529237, + -0.732441, + 0, + 0.238691, + 0.634976, + -0.598662, + 0, + 0.346852, + 0.68788, + -0.464883, + 0, + 0.45022, + 0.718141, + -0.331104, + 0, + 0.553554, + 0.664839, + -0.197324, + 0, + 0.651082, + 0.519303, + -0.0635452, + 0.115841, + 0.72479, + 0.352857, + 0.07023409999999999, + 0.326771, + 0.781195, + 0.140187, + 0.204013, + 0.522765, + 0.798524, + 0.0284624, + 0.337793, + 0.703162, + 0.788685, + 0.00885756, + 0.471572, + 0.845118, + 0.7511330000000001, + 0, + 0.605351, + 0.955734, + 0.690825, + 0, + 0.73913, + 0.995402, + 0.567916, + 0.0618524, + 0.87291, + 0.987712, + 0.403398, + 0.164851, + 1, + 0.980407, + 0.247105, + 0.262699 + ] + } +] \ No newline at end of file diff --git a/pan3d/explorers/slice_explorer.py b/pan3d/explorers/slice_explorer.py index c282170..1879634 100644 --- a/pan3d/explorers/slice_explorer.py +++ b/pan3d/explorers/slice_explorer.py @@ -3,72 +3,88 @@ from trame.ui.vuetify3 import SinglePageWithDrawerLayout from trame.widgets import vuetify3 as v3, vtk as vtkw, html from pan3d.dataset_builder import DatasetBuilder -import vtk, numpy as np - -colors = [ - "Rainbow", - "Inv Rainbow", - "Greyscale", - "Inv Greyscale", -] - -class LookupTable: - Rainbow = 0 - Inverted_Rainbow = 1 - Greyscale = 2 - Inverted_Greyscale = 3 - -# Color Map Callbacks -def use_preset(sactor : vtk.vtkActor, dactor : vtk.vtkActor, preset : str) -> None: - s_lut = sactor.GetMapper().GetLookupTable() - d_lut = dactor.GetMapper().GetLookupTable() - if preset == "Rainbow": - s_lut.SetHueRange(0.666, 0.0) - s_lut.SetSaturationRange(1.0, 1.0) - s_lut.SetValueRange(1.0, 1.0) - d_lut.SetHueRange(0.666, 0.0) - d_lut.SetSaturationRange(1.0, 1.0) - d_lut.SetValueRange(1.0, 1.0) - elif preset == "Inv Rainbow": - s_lut.SetHueRange(0.0, 0.666) - s_lut.SetSaturationRange(1.0, 1.0) - s_lut.SetValueRange(1.0, 1.0) - d_lut.SetHueRange(0.0, 0.666) - d_lut.SetSaturationRange(1.0, 1.0) - d_lut.SetValueRange(1.0, 1.0) - elif preset == "Greyscale": - s_lut.SetHueRange(0.0, 0.0) - s_lut.SetSaturationRange(0.0, 0.0) - s_lut.SetValueRange(0.0, 1.0) - d_lut.SetHueRange(0.0, 0.0) - d_lut.SetSaturationRange(0.0, 0.0) - d_lut.SetValueRange(0.0, 1.0) - elif preset == "Inv Greyscale": - s_lut.SetHueRange(0.0, 0.666) - s_lut.SetSaturationRange(0.0, 0.0) - s_lut.SetValueRange(1.0, 0.0) - d_lut.SetHueRange(0.0, 0.666) - d_lut.SetSaturationRange(0.0, 0.0) - d_lut.SetValueRange(1.0, 0.0) - s_lut.Build() - d_lut.Build() - -def update_preset(actor: vtk.vtkActor, logcale : bool) -> None: +import vtk +import numpy as np + +from pan3d.explorers.utilities import apply_preset +from pan3d.explorers.utilities import hsv_colors, rgb_colors + +colors = [] +colors.extend(list(hsv_colors.keys())) +colors.extend(list(rgb_colors.keys())) + + +def use_preset( + sactor: vtk.vtkActor, dactor: vtk.vtkActor, sbar: vtk.vtkActor, preset: str +) -> None: + """ + Given the slice, data, and scalar bar actor, applies the provided preset + and updates the actors and the scalar bar + """ + srange = sactor.GetMapper().GetScalarRange() + drange = dactor.GetMapper().GetScalarRange() + actors = [sactor, dactor] + ranges = [srange, drange] + for actor, range in zip(actors, ranges): + apply_preset(actor, range, preset) + sactor.GetMapper().SetScalarRange(srange[0], srange[1]) + dactor.GetMapper().SetScalarRange(drange[0], drange[1]) + sbar.SetLookupTable(sactor.GetMapper().GetLookupTable()) + + +def update_preset(actor: vtk.vtkActor, sbar: vtk.vtkActor, logcale: bool) -> None: + """ + Given an actor, scalar bar, and the option for whether to use log scale, + make changes to the lookup table for the actor, and update the scalar bar + """ lut = actor.GetMapper().GetLookupTable() if logcale: - print("Setting log scale") lut.SetScaleToLog10() else: - print("Setting linear scale") lut.SetScaleToLinear() lut.Build() + sbar.SetLookupTable(lut) + @TrameApp() class SliceExplorer: - def __init__(self, builder : DatasetBuilder, server=None): + """ + A Trame based pan3D explorer to visualize 3D using slices along different dimensions + + This explorer uses the pan3D DatasetBuilder and it's operability with xarray to fetch + relevant data and allows users to specify a specific slice of interest and visualize it + using VTK while interacting with the slice in 2D or 3D. + """ + + def __init__(self, builder: DatasetBuilder = None, server=None): self.server = get_server(server) - self.builder = builder - self.dims = dict([(x, y) for (x, y) in zip([builder.x, builder.y, builder.z], ['x', 'y','z']) if not x is None]) + + parser = self.server.cli + parser.add_argument("--config_path") + args, _ = self.server.cli.parse_known_args() + + if builder is None: + import os + + if args.config_path is None or not os.path.exists(args.config_path): + raise AttributeError( + "Need an instance of DatasetBuilder or a valid config path build one" + ) + exit(1) + else: + builder = DatasetBuilder() + builder.import_config(args.config_path) + self.builder = builder + else: + self.builder = builder + + self.dims = dict( + [ + (x, y) + for (x, y) in zip([builder.x, builder.y, builder.z], ["x", "y", "z"]) + if x is not None + ] + ) self.extents = builder.extents self._ui = None @@ -77,255 +93,342 @@ def __init__(self, builder : DatasetBuilder, server=None): self.state.dimval = float(ext[0] + (ext[1] - ext[0]) / 2) self.state.dimmin = float(ext[0]) self.state.dimmax = float(ext[1]) - self.state.varmin = 0. - self.state.varmax = 0. + self.state.varmin = 0.0 + self.state.varmax = 0.0 self.t_cache = {} self.vars = list(builder.dataset.data_vars.keys()) self.var_ranges = builder.var_ranges - self.render_window = vtk.vtkRenderWindow() - self.renderer = vtk.vtkRenderer() - self.interactor = vtk.vtkRenderWindowInteractor() - extents = list(self.extents.values()) self.origin = [ - float(extents[0][0] + (extents[0][1] - extents[0][0]) / 2), - float(extents[1][0] + (extents[1][1] - extents[1][0]) / 2), - float(extents[2][0] + (extents[2][1] - extents[2][0]) / 2) - ] + float(extents[0][0] + (extents[0][1] - extents[0][0]) / 2), + float(extents[1][0] + (extents[1][1] - extents[1][0]) / 2), + float(extents[2][0] + (extents[2][1] - extents[2][0]) / 2), + ] self.normal = [1, 0, 0] self.lengths = [ float(extents[0][1] - extents[0][0]), float(extents[1][1] - extents[1][0]), float(extents[2][1] - extents[2][0]), ] + self._generate_vtk_pipeline() + self.ui = self._build_ui() - self.plane = vtk.vtkPlane() - self.plane.SetOrigin(self.origin) - self.plane.SetNormal(self.normal) - - self.cutter = vtk.vtkCutter() - self.cutter.SetCutFunction(self.plane) - self.cutter.SetInputData(self.builder.mesh) + def _generate_vtk_pipeline(self): + self._renderer = vtk.vtkRenderer() + self._interactor = vtk.vtkRenderWindowInteractor() + self._render_window = vtk.vtkRenderWindow() var_range = self.var_ranges[self.vars[0]] - self.slice_actor = vtk.vtkActor() - self.slice_mapper = vtk.vtkDataSetMapper() - self.slice_mapper.SetInputConnection(self.cutter.GetOutputPort()) - self.slice_mapper.SetScalarRange(float(var_range[0]), float(var_range[1])) - self.slice_actor.SetMapper(self.slice_mapper) - - self.outline = vtk.vtkOutlineFilter() - self.tubify = vtk.vtkTubeFilter() - self.outline_actor = vtk.vtkActor() - self.outline_mapper = vtk.vtkPolyDataMapper() - self.outline.SetInputData(builder.mesh) - self.tubify.SetInputConnection(self.outline.GetOutputPort()) - self.outline_mapper.SetInputConnection(self.tubify.GetOutputPort()) - self.outline_actor.SetMapper(self.outline_mapper) - self.outline_actor.GetProperty().SetColor(0.5, 0.5, 0.5) - - self.data_actor = vtk.vtkActor() - self.data_mapper = vtk.vtkDataSetMapper() - self.data_mapper.SetInputData(builder.mesh) - self.data_mapper.SetScalarRange(float(var_range[0]), float(var_range[1])) - self.data_actor.SetMapper(self.data_mapper) - self.data_actor.GetProperty().SetOpacity(0.1) - - self.sbar_actor = vtk.vtkScalarBarActor() - self.sbar_actor.SetLookupTable(self.slice_mapper.GetLookupTable()) - self.sbar_actor.SetMaximumHeightInPixels(600) - self.sbar_actor.SetMaximumWidthInPixels(150) - self.sbar_actor.SetTitleRatio(0.2) - lprop : vtk.vtkTextProperty = self.sbar_actor.GetLabelTextProperty() + + plane = vtk.vtkPlane() + plane.SetOrigin(self.origin) + plane.SetNormal(self.normal) + cutter = vtk.vtkCutter() + cutter.SetCutFunction(plane) + cutter.SetInputData(self.builder.mesh) + slice_actor = vtk.vtkActor() + slice_mapper = vtk.vtkDataSetMapper() + slice_mapper.SetInputConnection(cutter.GetOutputPort()) + slice_mapper.SetScalarRange(float(var_range[0]), float(var_range[1])) + slice_actor.SetMapper(slice_mapper) + self._plane = plane + self._cutter = cutter + self._slice_actor = slice_actor + self._slice_mapper = slice_mapper + + outline = vtk.vtkOutlineFilter() + outline_actor = vtk.vtkActor() + outline_mapper = vtk.vtkPolyDataMapper() + outline.SetInputData(self.builder.mesh) + tubify = vtk.vtkTubeFilter() + tubify.SetInputConnection(outline.GetOutputPort()) + outline_mapper.SetInputConnection(tubify.GetOutputPort()) + outline_actor.SetMapper(outline_mapper) + outline_actor.GetProperty().SetColor(0.5, 0.5, 0.5) + self._outline = outline + self._tubify = tubify + self._outline_actor = outline_actor + self._outline_mapper = outline_mapper + + data_actor = vtk.vtkActor() + data_mapper = vtk.vtkDataSetMapper() + data_mapper.SetInputData(self.builder.mesh) + data_mapper.SetScalarRange(float(var_range[0]), float(var_range[1])) + data_actor.SetMapper(data_mapper) + data_actor.GetProperty().SetOpacity(0.1) + data_actor.SetVisibility(False) + self._data_actor = data_actor + self._data_mapper = data_mapper + + sbar_actor = vtk.vtkScalarBarActor() + sbar_actor.SetLookupTable(self._slice_mapper.GetLookupTable()) + sbar_actor.SetMaximumHeightInPixels(600) + sbar_actor.SetMaximumWidthInPixels(150) + sbar_actor.SetTitleRatio(0.2) + lprop: vtk.vtkTextProperty = sbar_actor.GetLabelTextProperty() lprop.SetColor(0.5, 0.5, 0.5) - tprop : vtk.vtkTextProperty = self.sbar_actor.GetTitleTextProperty() + tprop: vtk.vtkTextProperty = sbar_actor.GetTitleTextProperty() tprop.SetColor(0.5, 0.5, 0.5) + self._sbar_actor = sbar_actor - self.renderer.SetBackground(1., 1., 1.) - self.render_window.OffScreenRenderingOn() - self.render_window.AddRenderer(self.renderer) - self.interactor.SetRenderWindow(self.render_window) - self.interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() - - self.renderer.AddActor(self.outline_actor) - self.renderer.AddActor(self.slice_actor) - self.renderer.AddActor(self.sbar_actor) + self._renderer.SetBackground(1.0, 1.0, 1.0) + self._render_window.OffScreenRenderingOn() + self._render_window.AddRenderer(self._renderer) + self._interactor.SetRenderWindow(self._render_window) + self._interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() - self.renderer.ResetCamera() + self._renderer.AddActor(self._outline_actor) + self._renderer.AddActor(self._data_actor) + self._renderer.AddActor(self._slice_actor) + self._renderer.AddActor(self._sbar_actor) + self._renderer.ResetCamera() @property def ctrl(self): return self.server.controller - + @property def state(self): return self.server.state @property def t_slice(self): + """ + Property representing the current time slice for the dataset from the + DatasetBuilder, and is extracted by setting the current active time value + as a xarray selection for the time slice + """ datavar = self.state.data_var - m = self.state.time_active - mesh = self.t_cache.get((m, datavar)) + m = self.state.time_active + mesh = self.t_cache.get((m, datavar)) + builder = self.builder + ttype = builder.dataset.coords[builder.t].dtype if mesh is None: criteria = {} - criteria[self.builder.t] = m - da = self.builder.dataset[datavar].sel(**criteria) + if ttype.kind in ["O", "M"]: + print(np.datetime64(m, "ns")) + criteria[builder.t] = np.datetime64(m, "ns") + else: + criteria[builder.t] = m + da = builder.dataset[datavar].sel(**criteria, method="nearest") da.load() - mesh = da.pyvista.mesh(x=self.builder.x, y=self.builder.y, z=self.builder.z) + mesh = da.pyvista.mesh(x=builder.x, y=builder.y, z=builder.z) self.t_cache[(m, datavar)] = mesh return mesh - + @property def slice_dimension(self): + """ + Returns the active dimension along with the slice is performed + """ return self.state.slice_dim @slice_dimension.setter - def slice_dimension(self, dim : str) -> None: - print(f"Setting slice dimension to {dim}") - self.state.slice_dim = dim - self.UpdateSlicer(dim) - self.on_data_change() + def slice_dimension(self, dim: str) -> None: + """ + Sets the active dimension along which the slice is performed + """ + with self.state: + self.state.slice_dim = dim + self.update_slicer_dimension(dim) + self.on_data_change() @property def slice_value(self): + """ + Returns the value(origin) for the dimension along which the slice + is performed + """ return self.state.dimval @slice_value.setter - def slice_value(self, value : float) -> None: - print(f"Updating slice value tp {value}") - self.state.dimval = value - self.on_data_change() + def slice_value(self, value: float) -> None: + """ + Sets the value(origin) for the dimension along which the slice + is performed + """ + with self.state: + self.state.dimval = value + self.on_data_change() @property def view_mode(self): + """ + Returns the interaction mode (2D/3D) for the slice + """ return self.state.view_mode - + @view_mode.setter def view_mode(self, mode): - self.state.view_mode = mode - self.on_view_mode_change("2D") + """ + Sets the interaction mode (2D/3D) for the slice, + and updates camera accordingly. Uses isometric view for 3D + """ + with self.state: + self.state.view_mode = mode + self.on_view_mode_change(mode) @property def color_map(self): + """ + Returns the color map currently used for visualization + """ return self.state.cmap - + @color_map.setter def color_map(self, cmap): - self.state.cmap = cmap - self.on_colormap_change(cmap) + """ + Sets the color map used for visualization + """ + with self.state: + self.state.cmap = cmap + self.on_colormap_change(cmap) @change("cmap") def on_colormap_change(self, cmap, **_): - use_preset(self.slice_actor, self.data_actor, cmap) - self.ctrl.view_update() + """ + Performs all the steps necessary to visualize correct data when the + color map is updated + """ + use_preset(self._slice_actor, self._data_actor, self._sbar_actor, cmap) + self.ctrl.view_update() @change("logscale") def on_log_scale_change(self, logscale, **_): - update_preset(self.slice_actor, logscale) - self.ctrl.view_update() + """ + Performs all the steps necessary when user toggles log scale for color map + """ + update_preset(self._slice_actor, self._sbar_actor, logscale) + self.ctrl.view_update() + + def _set_view_2D(self, origin, dimension): + camera = self._renderer.GetActiveCamera() + position = camera.GetPosition() + norm = np.linalg.norm(np.array(origin) - np.array(position)) + position = origin[:] + self._outline_actor.SetVisibility(False) + self._data_actor.SetVisibility(False) + position[dimension] = norm + view_up = [0, 0.0, 1.0] if dimension == 1 else [0, 1.0, 0.0] + camera.SetPosition(position) + camera.SetViewUp(view_up) + camera.SetFocalPoint(origin) + camera.OrthogonalizeViewUp() + self._renderer.SetActiveCamera(camera) + self._renderer.ResetCamera() + self.ctrl.view_update() + + def _set_view_3D(self, origin): + camera = self._renderer.GetActiveCamera() + position = camera.GetPosition() + norm = np.linalg.norm(np.array(origin) - np.array(position)) + camera.SetPosition(norm, norm, norm) + camera.SetViewUp(0.0, 1.0, 0.0) + if self.state.outline: + self._outline_actor.SetVisibility(True) + if self.state.tdata: + self._data_actor.SetVisibility(True) + self._renderer.SetActiveCamera(camera) + self._renderer.ResetCamera() + self.ctrl.view_update() @change("view_mode") def on_view_mode_change(self, view_mode, **_): + """ + Performs all the steps necessary when user toggles the view mode + """ slice_dim = self.state.slice_dim - slice_i = 0 if self.dims[slice_dim] == 'x' else 1 if self.dims[slice_dim] == 'y' else 2 - + slice_i = ( + 0 + if self.dims[slice_dim] == "x" + else 1 if self.dims[slice_dim] == "y" else 2 + ) extents = list(self.extents.values()) origin = [ - float(extents[0][0] + (extents[0][1] - extents[0][0]) / 2), - float(extents[1][0] + (extents[1][1] - extents[1][0]) / 2), - float(extents[2][0] + (extents[2][1] - extents[2][0]) / 2) + float(extents[0][0] + (extents[0][1] - extents[0][0]) / 2), + float(extents[1][0] + (extents[1][1] - extents[1][0]) / 2), + float(extents[2][0] + (extents[2][1] - extents[2][0]) / 2), ] - - camera = self.renderer.GetActiveCamera() - pos = camera.GetPosition() - norm = np.linalg.norm(np.array(origin) - np.array(pos)) - print(f"Origin and norm : {origin, norm}") - if self.state.view_mode == '3D': - camera.SetPosition(norm, norm, norm) - camera.SetViewUp(0., 1., 0.) - elif self.state.view_mode == '2D': - self.renderer.RemoveActor(self.outline_actor) - self.renderer.RemoveActor(self.data_actor) - if slice_i == 0: - camera.SetPosition(norm, origin[1], origin[2]) - camera.SetViewUp(0., 1., 0.) - elif slice_i == 1: - camera.SetPosition(origin[0], norm, origin[2]) - camera.SetViewUp(0., 0., 1.) - elif slice_i == 2: - camera.SetPosition(origin[0], origin[1], norm) - camera.SetViewUp(0., 1., 0.) - camera.SetFocalPoint(origin[0], origin[1], origin[2]) - camera.OrthogonalizeViewUp() - self.renderer.SetActiveCamera(camera) - self.renderer.ResetCamera() - self.ctrl.view_update() + if view_mode == "3D": + self._set_view_3D(origin) + elif view_mode == "2D": + self._set_view_2D(origin, slice_i) @change("data_var", "time_active", "dimval") def on_data_change(self, **_): - dimval = self.state.dimval + """ + Performs all the steps necessary when the user updates any properties + that requires a new data update. E.g. changing the data variable for + visualization, or changing active time, or changing slice value. + """ + dimval = self.state.dimval slice_dim = self.state.slice_dim - normal = [0, 0, 0] - slice_i = 0 if self.dims[slice_dim] == 'x' else 1 if self.dims[slice_dim] == 'y' else 2 + normal = [0, 0, 0] + slice_i = ( + 0 + if self.dims[slice_dim] == "x" + else 1 if self.dims[slice_dim] == "y" else 2 + ) normal[slice_i] = 1 - + extents = list(self.extents.values()) origin = [ - float(extents[0][0] + (extents[0][1] - extents[0][0]) / 2), - float(extents[1][0] + (extents[1][1] - extents[1][0]) / 2), - float(extents[2][0] + (extents[2][1] - extents[2][0]) / 2) - ] + float(extents[0][0] + (extents[0][1] - extents[0][0]) / 2), + float(extents[1][0] + (extents[1][1] - extents[1][0]) / 2), + float(extents[2][0] + (extents[2][1] - extents[2][0]) / 2), + ] origin[slice_i] = dimval - - self.plane.SetOrigin(origin) - self.plane.SetNormal(normal) - - self.cutter.SetInputData(self.t_slice) - self.cutter.SetCutFunction(self.plane) - self.cutter.Update() - output = self.cutter.GetOutput() + + self._plane.SetOrigin(origin) + self._plane.SetNormal(normal) + + self._cutter.SetInputData(self.t_slice) + self._cutter.Update() + output = self._cutter.GetOutput() vrange = output.GetPointData().GetArray(self.state.data_var).GetRange() - self.slice_mapper.SetScalarRange(float(vrange[0]), float(vrange[1])) + self._slice_mapper.SetScalarRange(float(vrange[0]), float(vrange[1])) self.state.varmin = float(vrange[0]) self.state.varmax = float(vrange[1]) - self.sbar_actor.SetLookupTable(self.slice_mapper.GetLookupTable()) + self._sbar_actor.SetLookupTable(self._slice_mapper.GetLookupTable()) if self.state.view_mode == "2D": self.on_view_mode_change("2D") - self.renderer.ResetCamera() + self._renderer.ResetCamera() self.ctrl.view_update() @change("outline", "tdata") def on_rep_change(self, outline, tdata, **_): - if outline: - self.renderer.AddActor(self.outline_actor) - else: - self.renderer.RemoveActor(self.outline_actor) - if tdata: - self.renderer.AddActor(self.data_actor) - else: - self.renderer.RemoveActor(self.data_actor) + """ + Performs all the steps necessary when user specifies 3D interaction options + """ + self._outline_actor.SetVisibility(outline) + self._data_actor.SetVisibility(tdata) self.ctrl.view_update() @change("varmin", "varmax") def on_scalar_change(self, varmin, varmax, **_): - self.slice_mapper.SetScalarRange(float(varmin), float(varmax)) - self.sbar_actor.SetLookupTable(self.slice_mapper.GetLookupTable()) + """ + Performs all the steps necessary when user specifies values for scalar range explicitly + """ + self._slice_mapper.SetScalarRange(float(varmin), float(varmax)) + self._sbar_actor.SetLookupTable(self._slice_mapper.GetLookupTable()) self.ctrl.view_update() - - @property - def levcoords(self): - return self.builder.dataset.coords["level"].to_numpy() @property - def timecoords(self): - return self.builder.dataset.coords["month"].to_numpy() - - def UpdateSlicer(self, dim): + def coords_time(self): + """ + Returns the values for time coordinates for user to select + """ + return self.builder.dataset.coords[self.builder.t].to_numpy() + + def update_slicer_dimension(self, dim): + """ + Update values for min/max and current slice values + """ ext = self.extents[dim] self.state.dimval = float(ext[0] + (ext[1] - ext[0]) / 2) self.state.dimmin = float(ext[0]) @@ -334,67 +437,82 @@ def UpdateSlicer(self, dim): def start(self, **kwargs): self.ui.server.start(**kwargs) - @property - def ui(self): - if self._ui: - return self._ui - + def _build_ui(self): style = dict(density="compact", hide_details=True) - with SinglePageWithDrawerLayout(self.server, full_height=True) as layout: self._ui = layout layout.title.set_text("Slice Explorer") - + with layout.toolbar.clear(): - v3.VBtn(icon="mdi-crop-free", click=self.ctrl.view_reset_camera) + with v3.VBtn( + size="x-large", + classes="pa-0 ma-0", + style="min-width: 60px", + click="main_drawer = !main_drawer", + ): + v3.VIcon("mdi-database-cog-outline") + v3.VIcon( + "{{ main_drawer ? 'mdi-chevron-left' : 'mdi-chevron-right' }}" + ) with v3.VBtnToggle( - v_model=("view_mode", "3D"), - mandatory=True, + v_model=("view_mode", "3D"), + mandatory=True, variant="outlined", classes="mx-4", **style, ): v3.VBtn(icon="mdi-video-2d", value="2D"), v3.VBtn(icon="mdi-video-3d", value="3D"), - - #with v3.VItemGroup(v_model=("rep3d", [True, False])): - html.Div("3D Representation", v_show="view_mode === '3D'", **style) - v3.VCheckbox(v_model=("outline", True), v_show="view_mode === '3D'", label="Outline", **style) - v3.VCheckbox(v_model=("tdata", False), v_show="view_mode === '3D'", label="Semi-Transparent Data", **style) + html.Div("3D Representation", v_show="view_mode === '3D'", **style) + v3.VCheckbox( + v_model=("outline", True), + v_show="view_mode === '3D'", + label="Outline", + **style, + ) + v3.VCheckbox( + v_model=("tdata", False), + v_show="view_mode === '3D'", + label="Semi-Transparent Data", + **style, + ) v3.VSpacer() - v3.VSwitch( - label="Scroll", + label="Disable Scroll", color="primary", v_model=("vscroll", False), **style, ) with layout.drawer as drawer: - drawer.width = 400 - with v3.VCard() : + drawer.width = 400 + with v3.VCard(): with v3.VCardTitle(): - html.Div( - "Slice Setting" - ) + html.Div("Slice Setting") with v3.VCardText(): v3.VSelect( label="Time", - v_model=("time_active", next(iter(self.timecoords.tolist()))), - items=("times_available", self.timecoords.tolist()), + v_model=( + "time_active", + next(iter(self.coords_time.tolist())), + ), + items=("times_available", self.coords_time.tolist()), ) - + v3.VSelect( label="Slice Dimension", v_model=("slice_dim",), items=("slice_dims", list(self.dims.keys())), - update_modelValue=(self.UpdateSlicer, "[$event]"), + update_modelValue=( + self.update_slicer_dimension, + "[$event]", + ), ) v3.VSlider( - thumb_size=16, + thumb_size=16, thumb_label=True, v_model=("dimval",), min=("dimmin",), @@ -409,9 +527,7 @@ def ui(self): with v3.VCard(): with v3.VCardTitle(): - html.Div( - "Color Setting" - ) + html.Div("Color Setting") with v3.VCardText(): v3.VSelect( v_model=("cmap", next(iter(colors))), @@ -421,21 +537,36 @@ def ui(self): hide_details=True, classes="pt-1", ) - v3.VCheckbox( - label="Use log scale", - v_model=("logscale", False) - ) + v3.VCheckbox(label="Use log scale", v_model=("logscale", False)) with html.Div("Scalar Range"): - v3.VTextField(v_model=("varmin",), label="min", outlined=True, style="height=50px") - v3.VTextField(v_model=("varmax",), label="max", outlined=True, style="height=50px") - with v3.VBtn(icon=True, outlined=True, style="height: 40px; width: 40px", ): + v3.VTextField( + v_model=("varmin",), + label="min", + outlined=True, + style="height=50px", + ) + v3.VTextField( + v_model=("varmax",), + label="max", + outlined=True, + style="height=50px", + ) + with v3.VBtn( + icon=True, + outlined=True, + style="height: 40px; width: 40px", + ): v3.VIcon("mdi-restore") with layout.content: - with vtkw.VtkRemoteView(self.render_window, interactive_ratio=1) as view: + with vtkw.VtkRemoteView( + self._render_window, interactive_ratio=1 + ) as view: self.ctrl.view_update = view.update self.ctrl.view_reset_camera = view.reset_camera - html.Div(v_show="vscroll", - style="position:absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1;") + html.Div( + v_show="vscroll", + style="position:absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1;", + ) - return layout \ No newline at end of file + return layout diff --git a/pan3d/explorers/utilities.py b/pan3d/explorers/utilities.py new file mode 100644 index 0000000..ef5cb54 --- /dev/null +++ b/pan3d/explorers/utilities.py @@ -0,0 +1,78 @@ +import json +import vtk +import os +import numpy as np + +hsv_colors = { + "Rainbow": { + "Hue": (0.666, 0.0), + "Saturation": (1.0, 1.0), + "Range": (1.0, 1.0), + }, + "Inv Rainbow": { + "Hue": (0.0, 0.666), + "Saturation": (1.0, 1.0), + "Range": (1.0, 1.0), + }, + "Greyscale": { + "Hue": (0.0, 0.0), + "Saturation": (0.0, 0.0), + "Range": (0.0, 1.0), + }, + "Inv Greyscale": { + "Hue": (0.0, 0.666), + "Saturation": (0.0, 0.0), + "Range": (1.0, 0.0), + }, +} + +rgb_colors = {} +try: + with open(os.path.dirname(__file__) + os.sep + "Presets.json", "r") as file: + data = json.load(file) + for cmap in data: + name = cmap["Name"] + srgb = np.array(cmap["RGBPoints"]) + tfunc = vtk.vtkColorTransferFunction() + for arr in np.split(srgb, len(srgb) / 4): + tfunc.AddRGBPoint(arr[0], arr[1], arr[2], arr[3]) + info = {"TF": tfunc, "Range": (srgb[0], srgb[-4])} + rgb_colors[name] = info +except Exception as e: + print("Error loading diverging color maps : ", e) + + +def convert_tfunc_to_lut(tfunc: vtk.vtkColorTransferFunction, tfrange): + lut = vtk.vtkLookupTable() + lut.SetNumberOfTableValues(256) + tflen = tfrange[1] - tfrange[0] + for i in range(256): + t = tfrange[0] + tflen * i / 255 + rgb = list(tfunc.GetColor(t)) + lut.SetTableValue(i, rgb[0], rgb[1], rgb[2]) + lut.Build() + return lut + + +def apply_preset(actor: vtk.vtkActor, srange, preset: str) -> None: + if preset in list(hsv_colors.keys()): + lut = vtk.vtkLookupTable() + lut.SetNumberOfTableValues(256) + mapper = actor.GetMapper() + mapper.SetLookupTable(lut) + preset = hsv_colors[preset] + hue = preset["Hue"] + sat = preset["Saturation"] + rng = preset["Range"] + lut.SetHueRange(hue[0], hue[1]) + lut.SetSaturationRange(sat[0], sat[1]) + lut.SetValueRange(rng[0], rng[1]) + lut.Build() + elif preset in list(rgb_colors.keys()): + info = rgb_colors[preset] + tfunc = info["TF"] + tfrange = info["Range"] + lut = convert_tfunc_to_lut(tfunc, tfrange) + mapper = actor.GetMapper() + mapper.SetLookupTable(lut) + mapper.SetScalarRange(srange[0], srange[1]) From ea0eef802e4092ba12e88b5f5e31e7b0384505b8 Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Wed, 16 Oct 2024 14:08:37 -0700 Subject: [PATCH 2/2] fix (slice explorer) : Adding time and slice sliders when drawer closed --- pan3d/dataset_builder.py | 12 +-- pan3d/explorers/slice_explorer.py | 151 +++++++++++++++++++++++++----- 2 files changed, 135 insertions(+), 28 deletions(-) diff --git a/pan3d/dataset_builder.py b/pan3d/dataset_builder.py index 1f0683d..969be7c 100644 --- a/pan3d/dataset_builder.py +++ b/pan3d/dataset_builder.py @@ -341,7 +341,7 @@ def t_values(self) -> List: if not self.t: raise ValueError("Cannot set time index > 0 without setting t array first.") t_coords = self.dataset[self.data_array_name].coords[self.t] - return t_coords.to_numpy().tolist() + return list(t_coords.values) @property def var_ranges(self) -> map: @@ -516,11 +516,11 @@ def _auto_select_slicing( k: [ v[0], v[1], - ( - math.ceil((v[1] - v[0]) / self._resolution) - if self._resolution > 1 and v[1] - v[0] > 0 and k != self.t - else steps.get(k, 1) if steps is not None and k != self.t else 1 - ), + math.ceil((v[1] - v[0]) / self._resolution) + if self._resolution > 1 and v[1] - v[0] > 0 and k != self.t + else steps.get(k, 1) + if steps is not None and k != self.t + else 1, ] for k, v in bounds.items() } diff --git a/pan3d/explorers/slice_explorer.py b/pan3d/explorers/slice_explorer.py index 1879634..b4a834f 100644 --- a/pan3d/explorers/slice_explorer.py +++ b/pan3d/explorers/slice_explorer.py @@ -1,10 +1,12 @@ +import vtk +import numpy as np +import pandas as pd + from trame.app import get_server from trame.decorators import TrameApp, change from trame.ui.vuetify3 import SinglePageWithDrawerLayout -from trame.widgets import vuetify3 as v3, vtk as vtkw, html +from trame.widgets import vuetify3 as v3, vtk as vtkw, html, client from pan3d.dataset_builder import DatasetBuilder -import vtk -import numpy as np from pan3d.explorers.utilities import apply_preset from pan3d.explorers.utilities import hsv_colors, rgb_colors @@ -46,6 +48,14 @@ def update_preset(actor: vtk.vtkActor, sbar: vtk.vtkActor, logcale: bool) -> Non sbar.SetLookupTable(lut) +def get_time_labels(times): + labels = [] + for time in times: + labels.append(pd.to_datetime(time).strftime("%Y-%m-%d %H:%M:%S")) + print("Labels : ", labels) + return labels + + @TrameApp() class SliceExplorer: """ @@ -96,6 +106,8 @@ def __init__(self, builder: DatasetBuilder = None, server=None): self.state.varmin = 0.0 self.state.varmax = 0.0 + self.state.t_labels = get_time_labels(self.builder.t_values) + self.t_cache = {} self.vars = list(builder.dataset.data_vars.keys()) self.var_ranges = builder.var_ranges @@ -202,7 +214,8 @@ def t_slice(self): as a xarray selection for the time slice """ datavar = self.state.data_var - m = self.state.time_active + # m = self.state.time_active + m = self.builder.t_values[self.state.time_active] mesh = self.t_cache.get((m, datavar)) builder = self.builder ttype = builder.dataset.coords[builder.t].dtype @@ -344,7 +357,9 @@ def on_view_mode_change(self, view_mode, **_): slice_i = ( 0 if self.dims[slice_dim] == "x" - else 1 if self.dims[slice_dim] == "y" else 2 + else 1 + if self.dims[slice_dim] == "y" + else 2 ) extents = list(self.extents.values()) origin = [ @@ -364,13 +379,16 @@ def on_data_change(self, **_): that requires a new data update. E.g. changing the data variable for visualization, or changing active time, or changing slice value. """ + dimval = self.state.dimval slice_dim = self.state.slice_dim normal = [0, 0, 0] slice_i = ( 0 if self.dims[slice_dim] == "x" - else 1 if self.dims[slice_dim] == "y" else 2 + else 1 + if self.dims[slice_dim] == "y" + else 2 ) normal[slice_i] = 1 @@ -442,7 +460,7 @@ def _build_ui(self): with SinglePageWithDrawerLayout(self.server, full_height=True) as layout: self._ui = layout layout.title.set_text("Slice Explorer") - + client.Style("html, body { overflow: hidden; }") with layout.toolbar.clear(): with v3.VBtn( size="x-large", @@ -483,6 +501,7 @@ def _build_ui(self): label="Disable Scroll", color="primary", v_model=("vscroll", False), + classes="mx-4", **style, ) @@ -492,13 +511,27 @@ def _build_ui(self): with v3.VCardTitle(): html.Div("Slice Setting") with v3.VCardText(): - v3.VSelect( - label="Time", - v_model=( - "time_active", - next(iter(self.coords_time.tolist())), - ), - items=("times_available", self.coords_time.tolist()), + html.Div( + "Time", classes="text-h6 text-center font-weight-medium" + ) + with v3.VCol(classes="text-center text-subtitle-1"): + html.Div("selected : {{t_labels[time_active]}}") + with v3.VRow(): + with v3.VCol(): + html.Div( + "{{t_labels[0]}}", classes="font-weight-medium" + ) + with v3.VCol(classes="text-right"): + html.Div( + "{{t_labels[t_labels.length - 1]}}", + classes="font-weight-medium", + ) + v3.VSlider( + classes="mx-2", + min=0, + max=self.builder.t_size - 1, + v_model=("time_active", 0), + step=1, ) v3.VSelect( @@ -511,9 +544,24 @@ def _build_ui(self): ), ) + html.Div( + "{{slice_dim}}", + classes="text-h6 text-center font-weight-medium", + ) + with v3.VCol(classes="text-center text-subtitle-1"): + html.Div("selected : {{parseFloat(dimval).toFixed(2)}}") + with v3.VRow(): + with v3.VCol(): + html.Div( + "{{parseFloat(dimmin).toFixed(2)}}", + classes="font-weight-medium", + ) + with v3.VCol(classes="text-right"): + html.Div( + "{{parseFloat(dimmax).toFixed(2)}}", + classes="font-weight-medium", + ) v3.VSlider( - thumb_size=16, - thumb_label=True, v_model=("dimval",), min=("dimmin",), max=("dimmax",), @@ -559,14 +607,73 @@ def _build_ui(self): v3.VIcon("mdi-restore") with layout.content: - with vtkw.VtkRemoteView( - self._render_window, interactive_ratio=1 - ) as view: - self.ctrl.view_update = view.update - self.ctrl.view_reset_camera = view.reset_camera + with html.Div( + style="position:absolute; top: 0; left: 0; width: 100%; height: 100%;", + ): + with vtkw.VtkRemoteView( + self._render_window, interactive_ratio=1 + ) as view: + self.ctrl.view_update = view.update + self.ctrl.view_reset_camera = view.reset_camera html.Div( v_show="vscroll", style="position:absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 1;", ) - + with html.Div( + v_show="!main_drawer", + classes="d-flex align-center flex-column", + style="position: absolute; left: 0; top: var(--v-layout-top); bottom: var(--v-layout-bottom); z-index: 2; pointer-events: none;", + ): + html.Div( + "{{slice_dim}}", + classes="text-subtitle-1 pa-2", + ) + html.Div( + "{{parseFloat(dimmin).toFixed(2)}}", + classes="text-subtitle-1 pa-2", + ) + v3.VSlider( + thumb_label="always", + thumb_size=16, + style="pointer-events: auto;", + hide_details=True, + classes="flex-fill", + direction="vertical", + v_model=("dimval",), + min=("dimmin",), + max=("dimmax",), + ) + html.Div( + "{{parseFloat(dimmax).toFixed(2)}}", + classes=" text-subtitle-1 pa-2", + ) + with html.Div( + v_show="!main_drawer", + classes="align-center flex-column", + style="position: absolute; bottom: var(--v-layout-bottom); left: 50%; transform: translateX(-50%); width: 80%;", + ): + with v3.VRow(): + with v3.VCol(classes="text-left"): + html.Div( + "{{t_labels[0]}}", + classes=" text-subtitle-1 pa-2 font-weight-medium", + ) + with v3.VCol(classes="text-center"): + html.Div( + "Time (selected : {{t_labels[time_active]}})", + classes=" text-subtitle-1 pa-2", + ) + with v3.VCol(classes="text-right"): + html.Div( + "{{t_labels[t_labels.length - 1]}}", + classes=" text-subtitle-1 pa-2", + ) + v3.VSlider( + style="pointer-events: auto;", + hide_details=True, + min=0, + max=self.builder.t_size - 1, + v_model=("time_active", 0), + step=1, + ) return layout