diff --git a/code/episode_7/poetry.lock b/code/episode_7/poetry.lock index e4a55833213a95cf75d54cd36e923e3351ace60f..29511c163524ebb6404d185ba32eedbb2b4e2709 100755 --- a/code/episode_7/poetry.lock +++ b/code/episode_7/poetry.lock @@ -1410,12 +1410,12 @@ tomli = ">=2.0.2,<3.0.0" [[package]] name = "nova-mvvm" -version = "0.8.0" +version = "0.9.0" description = "A Python Package for Model-View-ViewModel pattern" optional = false python-versions = "<4.0,>=3.10" files = [ - {file = "nova_mvvm-0.8.0-py3-none-any.whl", hash = "sha256:494e87915785dee46f01d06bc723dd29fba1724042fdf7fa102789a2e524d055"}, + {file = "nova_mvvm-0.9.0-py3-none-any.whl", hash = "sha256:60c70f8579b155e7081548e8aec9e77de497fa33ac569096015cffe27ef0f796"}, ] [package.dependencies] @@ -3003,4 +3003,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "f9931d7baca4d3cc5354430155449d43e5364af4fa95648bd39137bb8b05f375" +content-hash = "5cb22adddafd5b2416c7533855cfdaac1ca49f636272dcf41713f76fceb65da1" diff --git a/code/episode_7/pyproject.toml b/code/episode_7/pyproject.toml index 706779ab00aee83f84231203c193d5ac7f328d8b..4f452f8aefa570f6eaf84b70028d2c514cb4985b 100644 --- a/code/episode_7/pyproject.toml +++ b/code/episode_7/pyproject.toml @@ -15,7 +15,7 @@ packages = [ [tool.poetry.dependencies] python = "^3.10" nova-galaxy = "^0.4.0" -nova-mvvm = "0.8.0" +nova-mvvm = "0.9.0" nova-trame = "0.14.0" pandas = "^2.2.3" plotly = "^5.24.1" diff --git a/code/episode_7/src/nova_tutorial/main.py b/code/episode_7/src/nova_tutorial/main.py index cfe12fdc4e2e89a2f0ac9d9eddc170e73b10f180..68a0571cff37c6f2a9b57d66cb4d8d15557d7519 100644 --- a/code/episode_7/src/nova_tutorial/main.py +++ b/code/episode_7/src/nova_tutorial/main.py @@ -3,7 +3,7 @@ from nova_tutorial.views.visualization import VisualizationApp -def main(): +def main() -> None: app = VisualizationApp() app.server.start() diff --git a/code/episode_7/src/nova_tutorial/models/plotly.py b/code/episode_7/src/nova_tutorial/models/plotly.py index f76df4687e3d14f362550bd751a526af351546a2..d1450cf0302abf1937361da03a5d7422856a27a5 100644 --- a/code/episode_7/src/nova_tutorial/models/plotly.py +++ b/code/episode_7/src/nova_tutorial/models/plotly.py @@ -1,8 +1,7 @@ """Configuration for the Plotly example.""" -from typing import Any - -from plotly.data import iris # type: ignore +import plotly.graph_objects as go +from plotly.data import iris from pydantic import BaseModel, Field, computed_field IRIS_DATA = iris() @@ -11,35 +10,32 @@ IRIS_DATA = iris() class PlotlyConfig(BaseModel): """Configuration class for the Plotly example.""" - axis_options: list[str] = ["sepal_length", "sepal_width", "petal_length", "petal_width", "species", "species_id"] + axis_options: list[str] = ["sepal_length", "sepal_width", "petal_length", "petal_width"] x_axis: str = Field(default="sepal_length", title="X Axis") y_axis: str = Field(default="sepal_width", title="Y Axis") - z_axis: str = Field(default="petal_length", title="Z Axis") - plot_data: Any = None + z_axis: str = Field(default="petal_length", title="Color") plot_type: str = Field(default="scatter", title="Plot Type") plot_type_options: list[str] = ["heatmap", "scatter"] @computed_field # type: ignore @property - def is_scatter(self) -> bool: - return self.plot_type == "scatter" - - def update(self) -> None: - self.plot_data = { - "data": [ - { - "x": IRIS_DATA[self.x_axis].tolist(), - "y": IRIS_DATA[self.y_axis].tolist(), - "z": IRIS_DATA[self.z_axis].tolist(), - "type": self.plot_type, - "mode": "markers", - "colorbar": {"title": self.z_axis}, - "colorscale": "Viridis", - } - ], - "layout": { - "title": "Iris Flower Data Set", - "xaxis": {"fixedrange": True, "title": self.x_axis}, - "yaxis": {"fixedrange": True, "title": self.y_axis}, - }, - } + def is_not_heatmap(self) -> bool: + return self.plot_type != "heatmap" + + def get_figure(self) -> go.Figure: + match self.plot_type: + case "heatmap": + plot_data = go.Heatmap(x=IRIS_DATA[self.x_axis], y=IRIS_DATA[self.y_axis], z=IRIS_DATA[self.z_axis]) + case "scatter": + plot_data = go.Scatter(x=IRIS_DATA[self.x_axis], y=IRIS_DATA[self.y_axis], mode="markers") + case _: + raise ValueError(f"Invalid plot type: {self.plot_type}") + + figure = go.Figure(plot_data) + figure.update_layout( + title={"text": f"{self.plot_type}"}, + xaxis={"title": {"text": self.x_axis}}, + yaxis={"title": {"text": self.y_axis}}, + ) + + return figure diff --git a/code/episode_7/src/nova_tutorial/models/pyvista.py b/code/episode_7/src/nova_tutorial/models/pyvista.py index 4c9e1297f0f16d8d264ba8d980bc283bef7d0275..3e174c7b1b78559bc0a00381fcf61a7831e23ff6 100644 --- a/code/episode_7/src/nova_tutorial/models/pyvista.py +++ b/code/episode_7/src/nova_tutorial/models/pyvista.py @@ -1,7 +1,5 @@ """Configuration for the PyVista example.""" -from typing import Optional - from pydantic import BaseModel, Field from pyvista import Plotter, examples @@ -16,12 +14,11 @@ class PyVistaConfig(BaseModel): colormap: str = Field(default="viridis", title="Color Transfer Function") opacity: str = Field(default="linear", title="Opacity Transfer Function") - def update(self, plotter: Optional[Plotter]) -> None: - # TODO: This should not need to fully re-render each time. However, something about this setup is making - # view.update() not work. - if plotter: - plotter.clear() - plotter.add_volume(KNEE_DATA, cmap=self.colormap, opacity=self.opacity, show_scalar_bar=False) + def render(self, plotter: Plotter) -> None: + # If re-rendering the volume on changes isn't acceptable, then you may need to switch to using VTK directly due + # limitations of the PyVista volume rendering engine. + plotter.clear() + plotter.add_volume(KNEE_DATA, cmap=self.colormap, opacity=self.opacity, show_scalar_bar=False) - plotter.render() - plotter.view_isometric() # type: ignore + plotter.render() + plotter.view_isometric() diff --git a/code/episode_7/src/nova_tutorial/models/vtk.py b/code/episode_7/src/nova_tutorial/models/vtk.py index 36f3d4acd4541d9b0b7c801d57c6d5e49df8b111..c2b4bf049a0a981457d079548ad71189a0a2ba02 100644 --- a/code/episode_7/src/nova_tutorial/models/vtk.py +++ b/code/episode_7/src/nova_tutorial/models/vtk.py @@ -2,36 +2,37 @@ import numpy as np from pyvista import examples -from vtk import vtkSLCReader # type: ignore +from vtk import vtkSLCReader from vtkmodules.vtkCommonDataModel import vtkPiecewiseFunction from vtkmodules.vtkRenderingCore import vtkColorTransferFunction, vtkVolume, vtkVolumeProperty from vtkmodules.vtkRenderingVolume import vtkFixedPointVolumeRayCastMapper +KNEE_DATA = examples.download_knee_full() +KNEE_DATAFILE = examples.download_knee_full(load=False) + class VTKConfig: """Configuration class for the VTK example.""" - # The min/max here are somewhat arbitrary values that work well with this dataset. - max: float = 200.0 - min: float = 75.0 + max: float = KNEE_DATA.get_data_range()[1] + min: float = KNEE_DATA.get_data_range()[0] def __init__(self) -> None: - datafile = examples.download_knee_full(load=False) reader = vtkSLCReader() - reader.SetFileName(datafile) + reader.SetFileName(KNEE_DATAFILE) mapper = vtkFixedPointVolumeRayCastMapper() mapper.SetInputConnection(reader.GetOutputPort()) lut = self.init_lut() pwf = self.init_pwf() - volume_props = vtkVolumeProperty() # type: ignore + volume_props = vtkVolumeProperty() volume_props.SetColor(lut) volume_props.SetScalarOpacity(pwf) volume_props.SetShade(0) volume_props.SetInterpolationTypeToLinear() - self.volume = vtkVolume() # type: ignore + self.volume = vtkVolume() self.volume.SetMapper(mapper) self.volume.SetProperty(volume_props) self.volume.SetVisibility(1) @@ -89,7 +90,7 @@ class VTKConfig: ] ) - for arr in np.split(srgb, len(srgb) / 4): # type: ignore + for arr in np.split(srgb, len(srgb) / 4): lut.AddRGBPoint(arr[0], arr[1], arr[2], arr[3]) prev_min, prev_max = lut.GetRange() diff --git a/code/episode_7/src/nova_tutorial/view_models/visualization.py b/code/episode_7/src/nova_tutorial/view_models/visualization.py index ac993ee1c496d5045c0342c21826705dac4fc999..99d1c854b9b35a4a279d705fb070c28ffb2d86cc 100644 --- a/code/episode_7/src/nova_tutorial/view_models/visualization.py +++ b/code/episode_7/src/nova_tutorial/view_models/visualization.py @@ -2,7 +2,7 @@ from typing import Any, Optional -from nova.mvvm.interface import BindingInterface # type: ignore +from nova.mvvm.interface import BindingInterface from pydantic import BaseModel, Field from pyvista import Plotter @@ -14,7 +14,7 @@ from nova_tutorial.models.vtk import VTKConfig class Controls(BaseModel): """General controls for the GUI.""" - active_tab: int = Field(default=1) + active_tab: int = Field(default=0) class VisualizationViewModel: @@ -22,32 +22,29 @@ class VisualizationViewModel: def __init__(self, binding: BindingInterface): self.controls = Controls() - self.config_2d = PlotlyConfig() - self.config_pyvista = PyVistaConfig() - self.config_vtk = VTKConfig() - - self.controls_bind = binding.new_bind() - self.config_2d_bind = binding.new_bind(linked_object=self.config_2d, callback_after_update=self.update_2d_plot) - self.config_pyvista_bind = binding.new_bind( - linked_object=self.config_pyvista, callback_after_update=self.update_pyvista + self.plotly_config = PlotlyConfig() + self.pyvista_config = PyVistaConfig() + self.vtk_config = VTKConfig() + + self.controls_bind = binding.new_bind(self.controls) + self.plotly_config_bind = binding.new_bind( + linked_object=self.plotly_config, callback_after_update=self.update_plotly_figure ) - self.config_vtk_bind = binding.new_bind() + self.plotly_figure_bind = binding.new_bind() + self.pyvista_config_bind = binding.new_bind(linked_object=self.pyvista_config) + self.vtk_config_bind = binding.new_bind(linked_object=self.vtk_config) + self.render_vtk_bind = binding.new_bind() def init_view(self) -> None: - self.controls_bind.update_in_view(self.controls) - self.update_2d_plot() - self.update_pyvista() - self.update_vtk() - - def update_2d_plot(self, results: Optional[dict[str, Any]] = None) -> None: - self.config_2d.update() - self.config_2d_bind.update_in_view(self.config_2d) + self.update_plotly_figure() + self.init_vtk() - def update_pyvista(self, results: Optional[dict[str, Any]] = None) -> None: - self.config_pyvista_bind.update_in_view(self.config_pyvista) + def init_vtk(self) -> None: + self.render_vtk_bind.update_in_view(self.vtk_config.get_volume()) - def render_pyvista(self, plotter: Optional[Plotter]) -> None: - self.config_pyvista.update(plotter) + def render_pyvista(self, plotter: Plotter) -> None: + self.pyvista_config.render(plotter) - def update_vtk(self) -> None: - self.config_vtk_bind.update_in_view(self.config_vtk.get_volume()) + def update_plotly_figure(self, _: Optional[dict[str, Any]] = None) -> None: + self.plotly_config_bind.update_in_view(self.plotly_config) + self.plotly_figure_bind.update_in_view(self.plotly_config.get_figure()) diff --git a/code/episode_7/src/nova_tutorial/views/plotly.py b/code/episode_7/src/nova_tutorial/views/plotly.py new file mode 100644 index 0000000000000000000000000000000000000000..3be3b0e6ab543e6f81d7f7286f1c102dc25f94a3 --- /dev/null +++ b/code/episode_7/src/nova_tutorial/views/plotly.py @@ -0,0 +1,38 @@ +"""View for Plotly.""" + +import plotly.graph_objects as go +from nova.trame.view.components import InputField +from nova.trame.view.layouts import GridLayout, HBoxLayout +from trame.widgets import plotly + +from nova_tutorial.view_models.visualization import VisualizationViewModel + + +class PlotlyView: + """View class for Plotly.""" + + def __init__(self, view_model: VisualizationViewModel) -> None: + self.view_model = view_model + self.view_model.plotly_config_bind.connect("plotly_config") + self.view_model.plotly_figure_bind.connect(self.update_figure) + + self.create_ui() + + def create_ui(self) -> None: + with GridLayout(columns=4, classes="mb-2"): + InputField(v_model="plotly_config.plot_type", items="plotly_config.plot_type_options", type="select") + InputField(v_model="plotly_config.x_axis", items="plotly_config.axis_options", type="select") + InputField(v_model="plotly_config.y_axis", items="plotly_config.axis_options", type="select") + InputField( + v_model="plotly_config.z_axis", + disabled=("plotly_config.is_not_heatmap",), + items="plotly_config.axis_options", + type="select", + ) + + with HBoxLayout(halign="center", height="50vh"): + self.figure = plotly.Figure() + + def update_figure(self, figure: go.Figure) -> None: + self.figure.update(figure) + self.figure.state.flush() # This is necessary if you call update asynchronously. diff --git a/code/episode_7/src/nova_tutorial/views/plotly_2d.py b/code/episode_7/src/nova_tutorial/views/plotly_2d.py deleted file mode 100644 index 4fbb6ce49a1bb486ee509c0412837f25db701aae..0000000000000000000000000000000000000000 --- a/code/episode_7/src/nova_tutorial/views/plotly_2d.py +++ /dev/null @@ -1,34 +0,0 @@ -"""View for the 2d plot.""" - -from nova.trame.view.components import InputField # type: ignore -from nova.trame.view.layouts import GridLayout, HBoxLayout # type: ignore -from trame.widgets import plotly # type: ignore -from trame.widgets import vuetify3 as vuetify - -from nova_tutorial.view_models.visualization import VisualizationViewModel - - -class Plot2D: - """View class for the 2d plot.""" - - def __init__(self, view_model: VisualizationViewModel) -> None: - self.view_model = view_model - self.view_model.config_2d_bind.connect("config_2d") - - self.create_ui() - - def create_ui(self) -> None: - vuetify.VCardTitle("Plotly") - with GridLayout(columns=4, classes="mb-2"): - InputField(v_model="config_2d.plot_type", items="config_2d.plot_type_options", type="select") - InputField(v_model="config_2d.x_axis", items="config_2d.axis_options", type="select") - InputField(v_model="config_2d.y_axis", items="config_2d.axis_options", type="select") - InputField( - v_model="config_2d.z_axis", - disabled=("config_2d.is_scatter",), - items="config_2d.axis_options", - type="select", - ) - - with HBoxLayout(halign="center", height="50vh"): - plotly.Figure(v_if="config_2d.plot_data", state_variable_name="config_2d.plot_data") diff --git a/code/episode_7/src/nova_tutorial/views/pyvista_3d.py b/code/episode_7/src/nova_tutorial/views/pyvista.py similarity index 50% rename from code/episode_7/src/nova_tutorial/views/pyvista_3d.py rename to code/episode_7/src/nova_tutorial/views/pyvista.py index 5965f961f97d79d1bfc4355f0668cb239d7b3377..d7dea3a781764ac2e174ab9466bc5c6b0e20a139 100644 --- a/code/episode_7/src/nova_tutorial/views/pyvista_3d.py +++ b/code/episode_7/src/nova_tutorial/views/pyvista.py @@ -1,23 +1,22 @@ """View for the 3d plot using PyVista.""" -from functools import partial -from typing import Optional +from typing import Any, Optional -import pyvista as pv # type: ignore -from nova.trame.view.components import InputField # type: ignore -from nova.trame.view.layouts import GridLayout, HBoxLayout # type: ignore -from pyvista.trame.ui import plotter_ui # type: ignore -from trame.widgets import vuetify3 as vuetify # type: ignore +import pyvista as pv +from nova.trame.view.components import InputField +from nova.trame.view.layouts import GridLayout, HBoxLayout +from pyvista.trame.ui import plotter_ui +from trame.widgets import vuetify3 as vuetify from nova_tutorial.view_models.visualization import VisualizationViewModel -class PyVistaPlot: +class PyVistaView: """View class for the 3d plot using PyVista.""" def __init__(self, view_model: VisualizationViewModel) -> None: self.view_model = view_model - self.view_model.config_pyvista_bind.connect("config_pyvista") + self.view_model.pyvista_config_bind.connect("pyvista_config") self.plotter: Optional[pv.Plotter] = None @@ -31,12 +30,15 @@ class PyVistaPlot: vuetify.VCardTitle("PyVista") with GridLayout(columns=5, classes="mb-2", valign="center"): InputField( - v_model="config_pyvista.colormap", column_span=2, items="config_pyvista.colormap_options", type="select" + v_model="pyvista_config.colormap", column_span=2, items="pyvista_config.colormap_options", type="select" ) InputField( - v_model="config_pyvista.opacity", column_span=2, items="config_pyvista.opacity_options", type="select" + v_model="pyvista_config.opacity", column_span=2, items="pyvista_config.opacity_options", type="select" ) - vuetify.VBtn("Render", click=partial(self.view_model.render_pyvista, self.plotter)) - + vuetify.VBtn("Render", click=self.update) with HBoxLayout(halign="center", height="50vh"): plotter_ui(self.plotter) + + def update(self, _: Any = None) -> None: + if self.plotter: + self.view_model.render_pyvista(self.plotter) diff --git a/code/episode_7/src/nova_tutorial/views/visualization.py b/code/episode_7/src/nova_tutorial/views/visualization.py index 49c91464fbbc8ef59feeeecb186c95e619d6e77f..ca34ca0aeef676e059877cfe23a22b78d42d03a6 100644 --- a/code/episode_7/src/nova_tutorial/views/visualization.py +++ b/code/episode_7/src/nova_tutorial/views/visualization.py @@ -2,15 +2,15 @@ from typing import Any -from nova.mvvm.trame_binding import TrameBinding # type: ignore -from nova.trame import ThemedApp # type: ignore -from trame.app import get_server # type: ignore -from trame.widgets import vuetify3 as vuetify # type: ignore +from nova.mvvm.trame_binding import TrameBinding +from nova.trame import ThemedApp +from trame.app import get_server +from trame.widgets import vuetify3 as vuetify from nova_tutorial.view_models.visualization import VisualizationViewModel -from nova_tutorial.views.plotly_2d import Plot2D -from nova_tutorial.views.pyvista_3d import PyVistaPlot -from nova_tutorial.views.vtk_3d import VTKPlot +from nova_tutorial.views.plotly import PlotlyView +from nova_tutorial.views.pyvista import PyVistaView +from nova_tutorial.views.vtk import VTKView class VisualizationApp(ThemedApp): # Inherits from nova.trame.ThemedApp for consistent styling @@ -40,19 +40,19 @@ class VisualizationApp(ThemedApp): # Inherits from nova.trame.ThemedApp for con with vuetify.VTabs( v_model="controls.active_tab", classes="pl-4", update_modelValue="flushState('controls');" ): - vuetify.VTab("Plotly", value=1) - vuetify.VTab("PyVista", value=2) - vuetify.VTab("VTK", value=3) + vuetify.VTab("Plotly", value=0) + vuetify.VTab("PyVista", value=1) + vuetify.VTab("VTK", value=2) with layout.content: with vuetify.VCard(): with vuetify.VTabsWindow(v_model="controls.active_tab"): + with vuetify.VTabsWindowItem(value=0): + PlotlyView(self.view_model) with vuetify.VTabsWindowItem(value=1): - Plot2D(self.view_model) + PyVistaView(self.view_model) with vuetify.VTabsWindowItem(value=2): - PyVistaPlot(self.view_model) - with vuetify.VTabsWindowItem(value=3): - VTKPlot(self.view_model) + VTKView(self.view_model) layout.post_content.classes += "mb-4" diff --git a/code/episode_7/src/nova_tutorial/views/vtk_3d.py b/code/episode_7/src/nova_tutorial/views/vtk.py similarity index 79% rename from code/episode_7/src/nova_tutorial/views/vtk_3d.py rename to code/episode_7/src/nova_tutorial/views/vtk.py index e5c1bed536efe8fbf697e411fdfc5fb5155b3abd..ee19e11dec8d7be9e25fd3f42dd1df0d341e2d39 100644 --- a/code/episode_7/src/nova_tutorial/views/vtk_3d.py +++ b/code/episode_7/src/nova_tutorial/views/vtk.py @@ -1,26 +1,26 @@ """View for the 3d plot using PyVista.""" import vtkmodules.vtkRenderingVolumeOpenGL2 # noqa -from nova.trame.view.layouts import HBoxLayout # type: ignore -from trame.widgets import vtk as vtkw # type: ignore -from trame.widgets import vuetify3 as vuetify # type: ignore +from nova.trame.view.layouts import HBoxLayout +from trame.widgets import vtk as vtkw +from trame.widgets import vuetify3 as vuetify from vtkmodules.vtkRenderingCore import vtkRenderer, vtkRenderWindow, vtkRenderWindowInteractor, vtkVolume from nova_tutorial.view_models.visualization import VisualizationViewModel -class VTKPlot: +class VTKView: """View class for the 3d plot using PyVista.""" def __init__(self, view_model: VisualizationViewModel) -> None: self.view_model = view_model - self.view_model.config_vtk_bind.connect(self.render) + self.view_model.render_vtk_bind.connect(self.render) self.create_vtk() self.create_ui() def create_vtk(self) -> None: - self.renderer = vtkRenderer() # type: ignore + self.renderer = vtkRenderer() self.renderer.SetBackground(0.7, 0.7, 0.7) self.render_window = vtkRenderWindow() @@ -29,7 +29,7 @@ class VTKPlot: self.render_window_interactor = vtkRenderWindowInteractor() self.render_window_interactor.SetRenderWindow(self.render_window) - self.render_window_interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() # type: ignore + self.render_window_interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() self.render_window_interactor.Initialize() # Ensure interactor is initialized def create_ui(self) -> None: diff --git a/episodes/07-Advanced-Visualizations.md b/episodes/07-Advanced-Visualizations.md index eaaf49c91ca00dbc61e1e50e124c5289f5c507e4..a3fa26d197345efdb1ae0f9efca42e1249cf9a76 100755 --- a/episodes/07-Advanced-Visualizations.md +++ b/episodes/07-Advanced-Visualizations.md @@ -1,25 +1,378 @@ --- title: "Advanced Visualizations" teaching: 10 -exercises: 0 +exercises: 1 --- # Advanced Visualizations -Here we talk about advanced Trame visualizations! -## Plotly 2D Plot -Time resolve viz? +In this section, we will look at a selection of the libraries that integrate well with Trame for producing more sophisticated visualizations of your data. Specifically, we will look at Plotly for interactive 2D charts, PyVista for interactive 3D visualizations, and VTK for advanced 3D visualizations. -## Pyvista 3d visualizations +The complete code for this episode is available in the `code/episode_7` directory. This code defines a Trame application that presents three views (one each for Plotly, PyVista, and VTK) that the user can choose between with a tab widget. -## VTK? +## Plotly (2D) -Focus on how these connect in trame +Trame provides a library called [trame-plotly](https://github.com/Kitware/trame-plotly) for connecting Trame and [Plotly](https://plotly.com/python/). You can install it with: +```bash +poetry add plotly trame-plotly +``` + +Now, we can create a view that displays a Plotly figure. + +**1. `PlotlyView` View Class (`src/nova_tutorial/views/plotly.py`):** + +* **Imports**: Pay special attention to the plotly import. This module contains a Trame widget that will allow us to quickly add a Plotly chart to our view. + + ```python + """View for Plotly.""" + + import plotly.graph_objects as go + from nova.trame.view.components import InputField + from nova.trame.view.layouts import GridLayout, HBoxLayout + from trame.widgets import plotly + + from nova_tutorial.view_models.visualization import VisualizationViewModel + ``` + +* **Class Definition**: The view model connections allow us to connect the controls we will define in create_ui() to the server and update the Plotly chart after a control is changed. + + ```python + class PlotlyView: + """View class for Plotly.""" + + def __init__(self, view_model: VisualizationViewModel) -> None: + self.view_model = view_model + self.view_model.plotly_config_bind.connect("plotly_config") + self.view_model.plotly_figure_bind.connect(self.update_figure) + + self.create_ui() + ``` + +* **Controls**: These controls will dynamically update the Plotly chart. + + ```python + def create_ui(self) -> None: + with GridLayout(columns=4, classes="mb-2"): + InputField(v_model="plotly_config.plot_type", items="plotly_config.plot_type_options", type="select") + InputField(v_model="plotly_config.x_axis", items="plotly_config.axis_options", type="select") + InputField(v_model="plotly_config.y_axis", items="plotly_config.axis_options", type="select") + InputField( + v_model="plotly_config.z_axis", + disabled=("plotly_config.is_not_heatmap",), + items="plotly_config.axis_options", + type="select", + ) + ``` + +* **Chart Definition**: Here, we use the imported Trame widget for Plotly to define the chart. This widget includes an `update` method that allows us to change the content after the initial rendering. + + ```python + with HBoxLayout(halign="center", height="50vh"): + self.figure = plotly.Figure() + + def update_figure(self, figure: go.Figure) -> None: + self.figure.update(figure) + self.figure.state.flush() # This is necessary if you call update asynchronously. + ``` + +As with our previous examples, there is a corresponding model. + +**2. `PlotlyConfig` Model Class (src/nova_tutorial/models/plotly.py):** + +* **Imports**: The graph_objects module is how we will define the content for our chart. The iris module defines an example dataset. + + ```python + """Configuration for the Plotly example.""" + + import plotly.graph_objects as go + from plotly.data import iris + from pydantic import BaseModel, Field, computed_field + + IRIS_DATA = iris() + ``` + +* **Pydantic definition**: Here we define the controls for our view. + + ```python + class PlotlyConfig(BaseModel): + """Configuration class for the Plotly example.""" + + axis_options: list[str] = ["sepal_length", "sepal_width", "petal_length", "petal_width"] + x_axis: str = Field(default="sepal_length", title="X Axis") + y_axis: str = Field(default="sepal_width", title="Y Axis") + z_axis: str = Field(default="petal_length", title="Color") + plot_type: str = Field(default="scatter", title="Plot Type") + plot_type_options: list[str] = ["heatmap", "scatter"] + + @computed_field # type: ignore + @property + def is_not_heatmap(self) -> bool: + return self.plot_type != "heatmap" + ``` + +* **Plotly Figure Setup**: Finally, we define the Plotly figure based on the user's selection. go.Heatmap and go.Scatter define Plotly `traces`, which represent individual components of the figure. + + ```python + def get_figure(self) -> go.Figure: + match self.plot_type: + case "heatmap": + plot_data = go.Heatmap(x=IRIS_DATA[self.x_axis], y=IRIS_DATA[self.y_axis], z=IRIS_DATA[self.z_axis]) + case "scatter": + plot_data = go.Scatter(x=IRIS_DATA[self.x_axis], y=IRIS_DATA[self.y_axis], mode="markers") + case _: + raise ValueError(f"Invalid plot type: {self.plot_type}") + + figure = go.Figure(plot_data) + figure.update_layout( + title={"text": f"{self.plot_type}"}, + xaxis={"title": {"text": self.x_axis}}, + yaxis={"title": {"text": self.y_axis}}, + ) + + return figure + ``` + +As with our other examples, the view model connects these two classes together. You can review the view model code for this example in `src/nova_tutorial/view_models/visualization.py`. + +## PyVista (3D) + +One of Trame's core features is that it has direct integration with VTK for building 3D visualizations. Learning VTK from scratch is non-trivial, however, so we recommend that you work with PyVista. PyVista serves as a more developer-friendly wrapper around VTK, allowing you to build your visualizations with a simpler, more intuitive API. To get started, you will need to install the Python package. + +```bash +poetry add pyvista trame-vtk +``` + +PyVista contains built-in Trame support, but we still need to install the Trame widget for VTK that PyVista will use internally. + +Now we can set up our view. + +**3. `PyVistaView` View Class (`src/nova_tutorial/views/pyvista.py`):** + +* **Imports:** `plotter_ui` contains the Trame widget for PyVista. + + ```python + """View for the 3d plot using PyVista.""" + + from typing import Any, Optional + + import pyvista as pv + from nova.trame.view.components import InputField + from nova.trame.view.layouts import GridLayout, HBoxLayout + from pyvista.trame.ui import plotter_ui + from trame.widgets import vuetify3 as vuetify + + from nova_tutorial.view_models.visualization import VisualizationViewModel + ``` + +* **Class Definition:** The `Plotter` object is PyVista's main entry point. It will allow you to add meshes and volumes with the properties you've specified. + + ```python + class PyVistaView: + """View class for the 3d plot using PyVista.""" + + def __init__(self, view_model: VisualizationViewModel) -> None: + self.view_model = view_model + self.view_model.pyvista_config_bind.connect("pyvista_config") + + self.plotter: Optional[pv.Plotter] = None + + self.create_plotter() + self.create_ui() + + def create_plotter(self) -> None: + self.plotter = pv.Plotter(off_screen=True) + ``` + +* **View Definition:** Now, we can use `plotter_ui` to create a view into which our rendering will go. + + ```python + def create_ui(self) -> None: + vuetify.VCardTitle("PyVista") + with GridLayout(columns=5, classes="mb-2", valign="center"): + InputField( + v_model="pyvista_config.colormap", column_span=2, items="pyvista_config.colormap_options", type="select" + ) + InputField( + v_model="pyvista_config.opacity", column_span=2, items="pyvista_config.opacity_options", type="select" + ) + vuetify.VBtn("Render", click=self.update) + with HBoxLayout(halign="center", height="50vh"): + plotter_ui(self.plotter) + + def update(self, _: Any = None) -> None: + if self.plotter: + self.view_model.render_pyvista(self.plotter) + ``` + +**4. `PyVistaConfig` Model Class (`src/nova_tutorial/models/pyvista.py`):** + +* **Imports:** `download_knee_full` yields a 3D dataset that is suitable for volume rendering. You can find more datasets in PyVista's [Dataset Gallery](https://docs.pyvista.org/api/examples/dataset_gallery). + + ```python + """Configuration for the PyVista example.""" + + from pydantic import BaseModel, Field + from pyvista import Plotter, examples + + KNEE_DATA = examples.download_knee_full() + ``` + +* **Pydantic Configuration:** The `Fields` defined here will be passed to [`Plotter.add_volume`](https://docs.pyvista.org/api/plotting/_autosummary/pyvista.plotter.add_volume). + + ```python + class PyVistaConfig(BaseModel): + """Configuration class for the PyVista example.""" + + colormap_options: list[str] = ["viridis", "autumn", "coolwarm", "twilight", "jet"] + opacity_options: list[str] = ["linear", "sigmoid"] + colormap: str = Field(default="viridis", title="Color Transfer Function") + opacity: str = Field(default="linear", title="Opacity Transfer Function") + ``` + +* **Rendering:** `add_volume` will return an actor. In practice, you may get better performance by manipulating that actor instead of doing a full re-render. + + ```python + def render(self, plotter: Plotter) -> None: + # If re-rendering the volume on changes isn't acceptable, then you may need to switch to using VTK directly due + # limitations of the PyVista volume rendering engine. + plotter.clear() + plotter.add_volume(KNEE_DATA, cmap=self.colormap, opacity=self.opacity, show_scalar_bar=False) + + plotter.render() + plotter.view_isometric() + ``` + +## VTK (3D) + +If you have prior experience with VTK then you may prefer to work with it directly. You can get started with it by installing the Python VTK bindings and the Trame widget for VTK. + +```bash +poetry add trame-vtk vtk +``` + +Since we've seen plenty of examples of UI controls at this point, we've omitted them for this example so that we can focus on the VTK boilerplate needed to get started. + +**5. `VTKView` View Class (`src/nova_tutorial/views/vtk.py`):** + +* **Imports:** The `vtkRenderingVolumeOpenGL2` import is necessary despite being unreferenced. + + ```python + """View for the 3d plot using PyVista.""" + + import vtkmodules.vtkRenderingVolumeOpenGL2 # noqa + from nova.trame.view.layouts import HBoxLayout + from trame.widgets import vtk as vtkw + from trame.widgets import vuetify3 as vuetify + from vtkmodules.vtkRenderingCore import vtkRenderer, vtkRenderWindow, vtkRenderWindowInteractor, vtkVolume + + from nova_tutorial.view_models.visualization import VisualizationViewModel + ``` + +* **Initialization:** Here we define the boiler plate for the interactive VTK window. As with PyVista, setting off-screen rendering to on is necessary when working with Trame. + + ```python + class VTKView: + """View class for the 3d plot using PyVista.""" + + def __init__(self, view_model: VisualizationViewModel) -> None: + self.view_model = view_model + self.view_model.render_vtk_bind.connect(self.render) + + self.create_vtk() + self.create_ui() + + def create_vtk(self) -> None: + self.renderer = vtkRenderer() + self.renderer.SetBackground(0.7, 0.7, 0.7) + + self.render_window = vtkRenderWindow() + self.render_window.AddRenderer(self.renderer) + self.render_window.OffScreenRenderingOn() + + self.render_window_interactor = vtkRenderWindowInteractor() + self.render_window_interactor.SetRenderWindow(self.render_window) + self.render_window_interactor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() + self.render_window_interactor.Initialize() # Ensure interactor is initialized + ``` + +* **View Definition:** Now, we setup the VTK window and add our volume rendering to it. By using `VTKRemoteView`, we are instructing VTK to perform server-side rendering. + + ```python + def create_ui(self) -> None: + vuetify.VCardTitle("VTK") + + with HBoxLayout(halign="center", height="50vh"): + self.view = vtkw.VtkRemoteView(self.render_window, interactive_ratio=1) + + def render(self, volume: vtkVolume) -> None: + self.renderer.Clear() + self.renderer.AddVolume(volume) + self.render_window.Render() + ``` + +**6. `VTKConfig` Model Class (`src/nova_tutorial/models/vtk.py`):** + +* **Imports:** We are only using PyVista to get an example dataset. There are two references to it as we use `KNEE_DATA` to compute min/max bounds for the data and `KNEE_DATAFILE` to pass the data file into a VTK reader. The FixedPointVolumeRayCastMapper is CPU-based, but other mappers are available if you need GPU support. + + ```python + """Configuration for the VTK example.""" + + import numpy as np + from pyvista import examples + from vtk import vtkSLCReader + from vtkmodules.vtkCommonDataModel import vtkPiecewiseFunction + from vtkmodules.vtkRenderingCore import vtkColorTransferFunction, vtkVolume, vtkVolumeProperty + from vtkmodules.vtkRenderingVolume import vtkFixedPointVolumeRayCastMapper + + KNEE_DATA = examples.download_knee_full() + KNEE_DATAFILE = examples.download_knee_full(load=False) + ``` + +* **VTK Pipeline Setup:** The dataset is stored in .slc format, so we can use a built-in VTK reader to load it into a pipeline. From there, we setup the volume. A lookup table is used to define the color transfer function, and a piecewise function is used to define the opacity transfer function. + + ```python + class VTKConfig: + """Configuration class for the VTK example.""" + + max: float = KNEE_DATA.get_data_range()[1] + min: float = KNEE_DATA.get_data_range()[0] + + def __init__(self) -> None: + reader = vtkSLCReader() + reader.SetFileName(KNEE_DATAFILE) + + mapper = vtkFixedPointVolumeRayCastMapper() + mapper.SetInputConnection(reader.GetOutputPort()) + + lut = self.init_lut() + pwf = self.init_pwf() + volume_props = vtkVolumeProperty() + volume_props.SetColor(lut) + volume_props.SetScalarOpacity(pwf) + volume_props.SetShade(0) + volume_props.SetInterpolationTypeToLinear() + + self.volume = vtkVolume() + self.volume.SetMapper(mapper) + self.volume.SetProperty(volume_props) + self.volume.SetVisibility(1) + + def get_volume(self) -> vtkVolume: + return self.volume + ``` + +## Exercises + +1. **Plotly Box Plot:** Add a box plot to the available plot types. Hint: you shouldn't need to change anything in the view class to do this. +2. **PyVista clim Control:** Add control(s) to the UI to control the `clim` argument for the `add_volume` method. +3. **Investigate the lookup table and piecewise function:** We didn't look at `VTKConfig.init_lut` or `VTKConfig.init_pwf` during the tutorial. Read through these methods and then trys manipulating the opacity of the rendering. ## References -* **Nova Documentation**: https://nova-application-development.readthedocs.io/en/latest/ -* **nova-galaxy documentation**: https://nova-application-development.readthedocs.io/projects/nova-galaxy/en/latest/ -* **nova-trame documentation**: https://nova-application-development.readthedocs.io/projects/nova-trame/en/stable/ -* **nova-mvvm documentation**: https://nova-application-development.readthedocs.io/projects/mvvm-lib/en/latest/ \ No newline at end of file +* **Plotly Documentation**: https://plotly.com/python/ +* **Trame/Plotly Integration Repository**: https://github.com/Kitware/trame-plotly +* **PyVista Documentation**: https://docs.pyvista.org/ +* **Trame/PyVista Integration Tutorial**: https://tutorial.pyvista.org/tutorial/09_trame/index.html +* **VTK Python Documentation**: https://docs.vtk.org/en/latest/api/python.html +* **Trame Tutorial**: https://kitware.github.io/trame/guide/tutorial/