Loading pyproject.toml +1 −1 Original line number Diff line number Diff line [tool.poetry] name = "ctscan-viz" version = "0.2.4" version = "0.3.0" description = "Template application" authors = [] readme = "README.md" Loading src/ctscan_viz/models/main.py +7 −0 Original line number Diff line number Diff line Loading @@ -15,6 +15,7 @@ class MainModel: def __init__(self) -> None: self.data_directory = "" self.file_list: list[str] = [] self.needs_update = True self.volume: Optional[ImageData] = None def get_file_count(self) -> int: Loading @@ -28,6 +29,7 @@ class MainModel: def update_data_directory(self, value: str) -> None: self.data_directory = value self.needs_update = True self.update_file_list() def update_file_list(self) -> None: Loading @@ -43,6 +45,9 @@ class MainModel: pass def update_volume(self) -> None: if not self.needs_update: return # Read the slices and validate their dimensions start = time() x_range = 0 Loading Loading @@ -76,3 +81,5 @@ class MainModel: # Define the 3D volume's scalars for the color/opacity transfer functions self.volume["TIFF Scalars"] = scalars print(f"PyVista volume creation: {time() - start:.2f}s", flush=True) self.needs_update = False src/ctscan_viz/view_models/main.py +40 −0 Original line number Diff line number Diff line Loading @@ -17,17 +17,35 @@ class MainViewModel: self.model = model self.binding = binding self.clim_max: Optional[float] = None self.clim_min: Optional[float] = None self.loading = False self.opacity = "linear" self.clim_max_bind = self.binding.new_bind() self.clim_min_bind = self.binding.new_bind() self.data_directory_bind = self.binding.new_bind() self.file_count_bind = self.binding.new_bind() self.loading_bind = self.binding.new_bind() self.opacity_bind = self.binding.new_bind() self.render_bind = self.binding.new_bind() self.update_clim_bind = self.binding.new_bind() self.update_opacity_bind = self.binding.new_bind() def get_clim(self) -> Optional[list[float]]: if self.clim_max is None or self.clim_min is None: return None return [self.clim_min, self.clim_max] def get_opacity(self) -> str: return self.opacity def get_volume(self) -> Optional[ImageData]: return self.model.get_volume() async def monitor_loading(self) -> None: await sleep(0.1) while self.loading: await sleep(0.1) Loading @@ -47,10 +65,32 @@ class MainViewModel: self.loading_thread.start() create_task(self.monitor_loading()) def update_clim_max(self, value: str) -> None: try: self.clim_max = float(value) except ValueError: self.clim_max = None def update_clim_min(self, value: str) -> None: try: self.clim_min = float(value) except ValueError: self.clim_min = None def update_data_directory(self, value: str) -> None: self.model.update_data_directory(value) self.update_view() def update_loading(self, value: bool) -> None: self.loading = value self.update_view() def update_opacity(self, value: str) -> None: self.opacity = value def update_view(self) -> None: self.clim_max_bind.update_in_view(self.clim_max) self.clim_min_bind.update_in_view(self.clim_min) self.file_count_bind.update_in_view(self.model.get_file_count()) self.loading_bind.update_in_view(self.loading) self.opacity_bind.update_in_view(self.opacity) src/ctscan_viz/views/data_selector.py +45 −3 Original line number Diff line number Diff line Loading @@ -2,7 +2,10 @@ from typing import Any from trame_facade.view.components import RemoteFileInput from trame.widgets import html from trame.widgets import vuetify3 as vuetify from trame_facade.view.components import InputField, RemoteFileInput from trame_facade.view.layouts import GridLayout from trame_server.core import Server from ctscan_viz.view_models.main import MainViewModel Loading @@ -12,13 +15,18 @@ class DataSelector: """Selection widget for folder containing CT scans.""" def __init__(self, server: Server, vm: MainViewModel) -> None: self.server = server self.ctrl = server.controller self.vm = vm self.vm.clim_max_bind.connect("clim_max") self.vm.clim_min_bind.connect("clim_min") self.vm.data_directory_bind.connect("data_directory") self.vm.opacity_bind.connect("opacity") # TODO: why is this needed? self.last_dir = "" @server.change("data_directory") @self.server.change("data_directory") def _on_data_directory_change(*args: Any, **kwargs: Any) -> None: if server.state.data_directory != self.last_dir: self.last_dir = server.state.data_directory Loading @@ -27,11 +35,45 @@ class DataSelector: self.create_ui() def create_ui(self) -> None: @self.ctrl.trigger("update_clim_max") def _update_clim_max(value: str) -> None: self.vm.update_clim_max(value) @self.ctrl.trigger("update_clim_min") def _update_clim_min(value: str) -> None: self.vm.update_clim_min(value) @self.ctrl.trigger("update_opacity") def _update_opacity(value: str) -> None: self.vm.update_opacity(value) RemoteFileInput( v_model="data_directory", allow_files=False, allow_folders=True, base_paths=["/HFIR", "/SNS"], input_props={"classes": "mb-4"}, input_props={"classes": "mb-2"}, label="Select Data Directory", ) with GridLayout(classes="mb-2", columns=4, valign="center"): InputField( v_model="clim_min", label="Minimum Value", update_modelValue="trigger('update_clim_min', [clim_min])", ) InputField( v_model="clim_max", label="Maximum Value", update_modelValue="trigger('update_clim_max', [clim_max])", ) InputField( v_model="opacity", items=("['linear', 'geom', 'sigmoid']",), label="Opacity", type="select", update_modelValue="trigger('update_opacity', [opacity])", ) with vuetify.VBtn(disabled=("file_count < 1 || loading",), click="trigger('start_render');"): vuetify.VProgressCircular(indeterminate=True, v_if="loading", size=24) html.Span("Render {{ file_count }} files", v_else=True) src/ctscan_viz/views/visualization_panel.py +8 −12 Original line number Diff line number Diff line Loading @@ -6,9 +6,8 @@ from typing import Any, Optional from pyvista import Plotter, start_xvfb, themes from pyvista.trame.ui import get_viewer from trame.widgets import html from trame.widgets import vuetify3 as vuetify from trame_server.core import Server from trame_server.state import State from ctscan_viz.view_models.main import MainViewModel Loading @@ -20,6 +19,7 @@ class VisualizationPanel: def __init__(self, server: Server, vm: MainViewModel) -> None: self.server = server self.ctrl = server.controller self.vm = vm self.vm.file_count_bind.connect("file_count") self.vm.loading_bind.connect("loading") Loading @@ -28,6 +28,10 @@ class VisualizationPanel: self.plotter = self.create_plotter() self.create_ui() @property def state(self) -> State: return self.server.state def create_plotter(self) -> Plotter: plotter = Plotter(off_screen=True) plotter.background_color = "black" Loading @@ -36,25 +40,17 @@ class VisualizationPanel: return plotter def create_ui(self) -> None: @self.server.controller.trigger("start_render") @self.ctrl.trigger("start_render") def _start_render() -> None: self.plotter.clear() self.vm.render_files() view = get_viewer(self.plotter) with vuetify.VBtn( classes="mb-2", disabled=("file_count < 1 || loading",), click="trigger('start_render');", ): vuetify.VProgressCircular(indeterminate=True, v_if="loading", size=24) html.Span("Render {{ file_count }} files", v_else=True) view.ui(mode="server", style="height: 66vh;") async def render_in_background(self) -> None: start = time() self.plotter.add_volume(self.vm.get_volume(), opacity="sigmoid") self.plotter.add_volume(self.vm.get_volume(), clim=self.vm.get_clim(), opacity=self.vm.get_opacity()) self.plotter.view_isometric(self.plotter) print(f"PyVista volume rendering: {time() - start:.2f}s", flush=True) Loading Loading
pyproject.toml +1 −1 Original line number Diff line number Diff line [tool.poetry] name = "ctscan-viz" version = "0.2.4" version = "0.3.0" description = "Template application" authors = [] readme = "README.md" Loading
src/ctscan_viz/models/main.py +7 −0 Original line number Diff line number Diff line Loading @@ -15,6 +15,7 @@ class MainModel: def __init__(self) -> None: self.data_directory = "" self.file_list: list[str] = [] self.needs_update = True self.volume: Optional[ImageData] = None def get_file_count(self) -> int: Loading @@ -28,6 +29,7 @@ class MainModel: def update_data_directory(self, value: str) -> None: self.data_directory = value self.needs_update = True self.update_file_list() def update_file_list(self) -> None: Loading @@ -43,6 +45,9 @@ class MainModel: pass def update_volume(self) -> None: if not self.needs_update: return # Read the slices and validate their dimensions start = time() x_range = 0 Loading Loading @@ -76,3 +81,5 @@ class MainModel: # Define the 3D volume's scalars for the color/opacity transfer functions self.volume["TIFF Scalars"] = scalars print(f"PyVista volume creation: {time() - start:.2f}s", flush=True) self.needs_update = False
src/ctscan_viz/view_models/main.py +40 −0 Original line number Diff line number Diff line Loading @@ -17,17 +17,35 @@ class MainViewModel: self.model = model self.binding = binding self.clim_max: Optional[float] = None self.clim_min: Optional[float] = None self.loading = False self.opacity = "linear" self.clim_max_bind = self.binding.new_bind() self.clim_min_bind = self.binding.new_bind() self.data_directory_bind = self.binding.new_bind() self.file_count_bind = self.binding.new_bind() self.loading_bind = self.binding.new_bind() self.opacity_bind = self.binding.new_bind() self.render_bind = self.binding.new_bind() self.update_clim_bind = self.binding.new_bind() self.update_opacity_bind = self.binding.new_bind() def get_clim(self) -> Optional[list[float]]: if self.clim_max is None or self.clim_min is None: return None return [self.clim_min, self.clim_max] def get_opacity(self) -> str: return self.opacity def get_volume(self) -> Optional[ImageData]: return self.model.get_volume() async def monitor_loading(self) -> None: await sleep(0.1) while self.loading: await sleep(0.1) Loading @@ -47,10 +65,32 @@ class MainViewModel: self.loading_thread.start() create_task(self.monitor_loading()) def update_clim_max(self, value: str) -> None: try: self.clim_max = float(value) except ValueError: self.clim_max = None def update_clim_min(self, value: str) -> None: try: self.clim_min = float(value) except ValueError: self.clim_min = None def update_data_directory(self, value: str) -> None: self.model.update_data_directory(value) self.update_view() def update_loading(self, value: bool) -> None: self.loading = value self.update_view() def update_opacity(self, value: str) -> None: self.opacity = value def update_view(self) -> None: self.clim_max_bind.update_in_view(self.clim_max) self.clim_min_bind.update_in_view(self.clim_min) self.file_count_bind.update_in_view(self.model.get_file_count()) self.loading_bind.update_in_view(self.loading) self.opacity_bind.update_in_view(self.opacity)
src/ctscan_viz/views/data_selector.py +45 −3 Original line number Diff line number Diff line Loading @@ -2,7 +2,10 @@ from typing import Any from trame_facade.view.components import RemoteFileInput from trame.widgets import html from trame.widgets import vuetify3 as vuetify from trame_facade.view.components import InputField, RemoteFileInput from trame_facade.view.layouts import GridLayout from trame_server.core import Server from ctscan_viz.view_models.main import MainViewModel Loading @@ -12,13 +15,18 @@ class DataSelector: """Selection widget for folder containing CT scans.""" def __init__(self, server: Server, vm: MainViewModel) -> None: self.server = server self.ctrl = server.controller self.vm = vm self.vm.clim_max_bind.connect("clim_max") self.vm.clim_min_bind.connect("clim_min") self.vm.data_directory_bind.connect("data_directory") self.vm.opacity_bind.connect("opacity") # TODO: why is this needed? self.last_dir = "" @server.change("data_directory") @self.server.change("data_directory") def _on_data_directory_change(*args: Any, **kwargs: Any) -> None: if server.state.data_directory != self.last_dir: self.last_dir = server.state.data_directory Loading @@ -27,11 +35,45 @@ class DataSelector: self.create_ui() def create_ui(self) -> None: @self.ctrl.trigger("update_clim_max") def _update_clim_max(value: str) -> None: self.vm.update_clim_max(value) @self.ctrl.trigger("update_clim_min") def _update_clim_min(value: str) -> None: self.vm.update_clim_min(value) @self.ctrl.trigger("update_opacity") def _update_opacity(value: str) -> None: self.vm.update_opacity(value) RemoteFileInput( v_model="data_directory", allow_files=False, allow_folders=True, base_paths=["/HFIR", "/SNS"], input_props={"classes": "mb-4"}, input_props={"classes": "mb-2"}, label="Select Data Directory", ) with GridLayout(classes="mb-2", columns=4, valign="center"): InputField( v_model="clim_min", label="Minimum Value", update_modelValue="trigger('update_clim_min', [clim_min])", ) InputField( v_model="clim_max", label="Maximum Value", update_modelValue="trigger('update_clim_max', [clim_max])", ) InputField( v_model="opacity", items=("['linear', 'geom', 'sigmoid']",), label="Opacity", type="select", update_modelValue="trigger('update_opacity', [opacity])", ) with vuetify.VBtn(disabled=("file_count < 1 || loading",), click="trigger('start_render');"): vuetify.VProgressCircular(indeterminate=True, v_if="loading", size=24) html.Span("Render {{ file_count }} files", v_else=True)
src/ctscan_viz/views/visualization_panel.py +8 −12 Original line number Diff line number Diff line Loading @@ -6,9 +6,8 @@ from typing import Any, Optional from pyvista import Plotter, start_xvfb, themes from pyvista.trame.ui import get_viewer from trame.widgets import html from trame.widgets import vuetify3 as vuetify from trame_server.core import Server from trame_server.state import State from ctscan_viz.view_models.main import MainViewModel Loading @@ -20,6 +19,7 @@ class VisualizationPanel: def __init__(self, server: Server, vm: MainViewModel) -> None: self.server = server self.ctrl = server.controller self.vm = vm self.vm.file_count_bind.connect("file_count") self.vm.loading_bind.connect("loading") Loading @@ -28,6 +28,10 @@ class VisualizationPanel: self.plotter = self.create_plotter() self.create_ui() @property def state(self) -> State: return self.server.state def create_plotter(self) -> Plotter: plotter = Plotter(off_screen=True) plotter.background_color = "black" Loading @@ -36,25 +40,17 @@ class VisualizationPanel: return plotter def create_ui(self) -> None: @self.server.controller.trigger("start_render") @self.ctrl.trigger("start_render") def _start_render() -> None: self.plotter.clear() self.vm.render_files() view = get_viewer(self.plotter) with vuetify.VBtn( classes="mb-2", disabled=("file_count < 1 || loading",), click="trigger('start_render');", ): vuetify.VProgressCircular(indeterminate=True, v_if="loading", size=24) html.Span("Render {{ file_count }} files", v_else=True) view.ui(mode="server", style="height: 66vh;") async def render_in_background(self) -> None: start = time() self.plotter.add_volume(self.vm.get_volume(), opacity="sigmoid") self.plotter.add_volume(self.vm.get_volume(), clim=self.vm.get_clim(), opacity=self.vm.get_opacity()) self.plotter.view_isometric(self.plotter) print(f"PyVista volume rendering: {time() - start:.2f}s", flush=True) Loading