From 13555e8235cc579b1f2754bf8c4954cfab84a9da Mon Sep 17 00:00:00 2001 From: Hemanth Kapa Date: Tue, 7 Jul 2026 11:51:22 -0700 Subject: [PATCH 1/2] button to export to obj --- src/surface_morphometrics_gui/main.py | 2 +- .../plugins/mesh_viewer.py | 204 +++++++++++++++++- .../plugins/mesh_viewer_plugin.py | 2 +- .../utils/script_resolver.py | 1 + tests/test_mesh_viewer_export.py | 45 ++++ 5 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 tests/test_mesh_viewer_export.py diff --git a/src/surface_morphometrics_gui/main.py b/src/surface_morphometrics_gui/main.py index 949bb37..a7e686f 100644 --- a/src/surface_morphometrics_gui/main.py +++ b/src/surface_morphometrics_gui/main.py @@ -97,7 +97,7 @@ def main(): # Create mesh viewer widget - mesh_viewer = MeshViewer(viewer) + mesh_viewer = MeshViewer(viewer, experiment_manager=experiment_manager) # Setup and add dock widgets with proper sizing diff --git a/src/surface_morphometrics_gui/plugins/mesh_viewer.py b/src/surface_morphometrics_gui/plugins/mesh_viewer.py index 8ce78a6..ba5f2d9 100644 --- a/src/surface_morphometrics_gui/plugins/mesh_viewer.py +++ b/src/surface_morphometrics_gui/plugins/mesh_viewer.py @@ -4,12 +4,19 @@ from napari.layers import Surface import os import glob +import subprocess +import threading +from pathlib import Path from magicgui import widgets from qtpy.QtCore import QTimer from magicgui.widgets import FloatRangeSlider, FloatSpinBox -from qtpy.QtWidgets import QWidget, QVBoxLayout, QPushButton, QSizePolicy, QScrollArea, QFileDialog +from qtpy.QtWidgets import ( + QWidget, QVBoxLayout, QPushButton, QSizePolicy, QScrollArea, QFileDialog, QMessageBox, +) from qtpy.QtCore import Qt +from ..utils.script_resolver import CLI_MISSING_MESSAGE, EXPORT_OBJ, resolve_cli_runner + # We implement our own camera-following lighting directly on vispy's # ShadingFilter rather than using napari-threedee's LightingControl, # which has compatibility issues with shading_filter initialization. @@ -23,10 +30,47 @@ print("Warning: libigl not available for ambient occlusion") +def export_feature_name(active_property): + """Map GUI property names (Cell_/Point_ prefixes) to export_obj feature names.""" + if active_property.startswith('Cell_'): + return active_property[5:] + if active_property.startswith('Point_'): + return active_property[6:] + return active_property + + +def resolve_config_path(vtp_path, experiment_manager=None): + """Find the experiment config.yml for a loaded VTP file.""" + if experiment_manager is not None: + work_dir = getattr(experiment_manager.work_dir, 'value', None) + exp_name = experiment_manager.experiment_name.currentText().strip() + if work_dir and exp_name: + exp_dir = Path(work_dir) / exp_name + preferred = exp_dir / f"{exp_name}_config.yml" + if preferred.is_file(): + return preferred + fallback = exp_dir / "config.yml" + if fallback.is_file(): + return fallback + + vtp_path = Path(vtp_path).resolve() + for directory in (vtp_path.parent, vtp_path.parent.parent): + if not directory.is_dir(): + continue + named = sorted(directory.glob("*_config.yml")) + if named: + return named[0] + fallback = directory / "config.yml" + if fallback.is_file(): + return fallback + return None + + class MeshViewer(QWidget): - def __init__(self, viewer, *args, **kwargs): + def __init__(self, viewer, experiment_manager=None, *args, **kwargs): super().__init__(*args, **kwargs) self.viewer = viewer + self.experiment_manager = experiment_manager # Initialize automatic lighting and ambient occlusion self._initialize_automatic_lighting_ao() @@ -123,6 +167,16 @@ def __init__(self, viewer, *args, **kwargs): self.contrast_container.append(self.contrast_minmax_row) self.contrast_container.visible = False + self.export_obj_button = QPushButton("Export to OBJ") + self.export_obj_button.setMinimumWidth(100) + self.export_obj_button.setFixedHeight(28) + self.export_obj_button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.export_obj_button.setEnabled(False) + self.export_obj_button.setToolTip( + "Export the current surface to a colormapped OBJ + MTL for Blender, ChimeraX, etc." + ) + self.export_obj_button.clicked.connect(self._on_export_obj_clicked) + self.stats_label = widgets.Label(value="No data loaded") # Build load mesh row @@ -146,6 +200,10 @@ def __init__(self, viewer, *args, **kwargs): self.contrast_container, ]) + export_row = widgets.Container(layout='horizontal') + export_row.native.layout().addWidget(self.export_obj_button) + self.controls_container.append(export_row) + # Compact child widget internal margins while keeping outer spacing for i in range(self.controls_container.native.layout().count()): item = self.controls_container.native.layout().itemAt(i) @@ -311,6 +369,7 @@ def _disable_controls(self): self.property_selector.enabled = False self.colormap_selector.enabled = False self.auto_apply.enabled = False + self.export_obj_button.setEnabled(False) def _initialize_vtp_layer(self, layer): """Read VTP file once, cache data, and apply initial colormap.""" @@ -496,6 +555,144 @@ def _update_ui_from_layer(self, layer): self._update_statistics(layer) self._update_contrast_slider_state(layer) + self._update_export_button_state(layer) + + def _can_export_obj(self, layer): + """Return True when the active layer can be exported via morphometrics export_obj.""" + if not layer or not self._is_vtp_surface_layer(layer): + return False + active_property = layer.metadata.get('active_property') + if not active_property or active_property == 'solid_color': + return False + vtp_path = layer.metadata.get('vtp_path') or layer.metadata.get('source_vtp_path') + if not vtp_path or not os.path.exists(vtp_path): + return False + scalar_data = layer.metadata.get('vtp_scalar_data', {}) + data = scalar_data.get(active_property) + if data is None or (hasattr(data, 'ndim') and data.ndim > 1): + return False + return True + + def _update_export_button_state(self, layer=None): + if layer is None: + layer = self._find_active_surface_layer() + self.export_obj_button.setEnabled(self._can_export_obj(layer)) + + def _on_export_obj_clicked(self): + layer = self._find_active_surface_layer() + if not self._can_export_obj(layer): + QMessageBox.warning( + self, + "Cannot export", + "Load a quantified VTP surface, select a scalar property, and adjust the " + "colormap/contrast before exporting.", + ) + return + + runner = resolve_cli_runner() + if runner is None: + QMessageBox.critical(self, "morphometrics CLI not found", CLI_MISSING_MESSAGE) + return + + vtp_path = Path(layer.metadata.get('vtp_path') or layer.metadata['source_vtp_path']).resolve() + config_path = resolve_config_path(vtp_path, self.experiment_manager) + if config_path is None: + QMessageBox.critical( + self, + "Config not found", + f"Could not find a config.yml for:\n{vtp_path}\n\n" + "Open the experiment in Experiment Manager or place config.yml next to the VTP.", + ) + return + + active_property = layer.metadata['active_property'] + feature = export_feature_name(active_property) + cmap = self.colormap_selector.value + vmin, vmax = layer.contrast_limits + + self.export_obj_button.setEnabled(False) + self.export_obj_button.setText("Exporting…") + job_data = { + 'runner': runner, + 'config_path': config_path, + 'vtp_path': vtp_path, + 'feature': feature, + 'cmap': cmap, + 'vmin': vmin, + 'vmax': vmax, + } + threading.Thread(target=self._run_export_obj_worker, args=(job_data,), daemon=True).start() + + def _run_export_obj_worker(self, job_data): + try: + cmd = job_data['runner'] + [ + EXPORT_OBJ, + str(job_data['config_path']), + str(job_data['vtp_path']), + '--feature', job_data['feature'], + '--cmap', job_data['cmap'], + '--vmin', str(job_data['vmin']), + '--vmax', str(job_data['vmax']), + ] + print(f"Running: {' '.join(map(str, cmd))}") + result = subprocess.run( + cmd, + cwd=str(job_data['vtp_path'].parent), + capture_output=True, + text=True, + check=False, + ) + if result.stdout: + print(result.stdout.rstrip()) + if result.stderr: + print(result.stderr.rstrip()) + + base = job_data['vtp_path'].stem + feature = job_data['feature'] + out_dir = job_data['vtp_path'].parent + obj_path = out_dir / f"{base}_{feature}.obj" + mtl_path = out_dir / f"{base}_{feature}.mtl" + png_path = out_dir / f"{base}_{feature}.png" + + if result.returncode != 0: + detail = (result.stderr or result.stdout or "Unknown error").strip() + QTimer.singleShot( + 0, + lambda: self._finish_export_obj(False, detail, obj_path, mtl_path), + ) + return + + QTimer.singleShot( + 0, + lambda: self._finish_export_obj(True, "", obj_path, mtl_path, png_path), + ) + except Exception as exc: + QTimer.singleShot( + 0, + lambda: self._finish_export_obj(False, str(exc), None, None), + ) + + def _finish_export_obj(self, success, detail, obj_path, mtl_path, png_path=None): + self.export_obj_button.setText("Export to OBJ") + layer = self._find_active_surface_layer() + self._update_export_button_state(layer) + + if success: + QMessageBox.information( + self, + "Export complete", + "Colormapped surface exported:\n\n" + f"OBJ: {obj_path}\n" + f"MTL: {mtl_path}\n" + f"Texture: {png_path}\n\n" + "The OBJ references the MTL; the MTL references the colormap PNG.", + ) + else: + QMessageBox.critical( + self, + "Export failed", + f"Could not export surface to OBJ.\n\n{detail}", + ) def _find_active_surface_layer(self): """Find the active surface layer, falling back to any VTP surface layer.""" @@ -751,6 +948,7 @@ def _on_contrast_slider_changed(self, value): max_val = max(min(value[1], self.contrast_max.max), self.contrast_max.min) self.contrast_min.value = min_val self.contrast_max.value = max_val + self._update_export_button_state(layer) def _on_contrast_min_changed(self, value): layer = self.viewer.layers.selection.active @@ -764,6 +962,7 @@ def _on_contrast_min_changed(self, value): clamped_min = max(min(min_val, self.contrast_slider.max), self.contrast_slider.min) clamped_max = max(min(max_val, self.contrast_slider.max), self.contrast_slider.min) self.contrast_slider.value = (clamped_min, clamped_max) + self._update_export_button_state(layer) def _on_contrast_max_changed(self, value): layer = self.viewer.layers.selection.active @@ -777,6 +976,7 @@ def _on_contrast_max_changed(self, value): clamped_min = max(min(min_val, self.contrast_slider.max), self.contrast_slider.min) clamped_max = max(min(max_val, self.contrast_slider.max), self.contrast_slider.min) self.contrast_slider.value = (clamped_min, clamped_max) + self._update_export_button_state(layer) def _initialize_automatic_lighting_ao(self): """Initialize ambient occlusion and smooth shading.""" diff --git a/src/surface_morphometrics_gui/plugins/mesh_viewer_plugin.py b/src/surface_morphometrics_gui/plugins/mesh_viewer_plugin.py index 17a4185..91de626 100644 --- a/src/surface_morphometrics_gui/plugins/mesh_viewer_plugin.py +++ b/src/surface_morphometrics_gui/plugins/mesh_viewer_plugin.py @@ -5,7 +5,7 @@ class MeshViewerPlugin: def __init__(self, viewer, experiment_manager): self.viewer = viewer self.experiment_manager = experiment_manager - self.widget = MeshViewer(viewer) + self.widget = MeshViewer(viewer, experiment_manager=experiment_manager) @property def native(self): diff --git a/src/surface_morphometrics_gui/utils/script_resolver.py b/src/surface_morphometrics_gui/utils/script_resolver.py index 9f459a3..5e9d918 100644 --- a/src/surface_morphometrics_gui/utils/script_resolver.py +++ b/src/surface_morphometrics_gui/utils/script_resolver.py @@ -22,6 +22,7 @@ # refine_mesh iterates, then accept_refinement commits a chosen iteration. REFINE_MESH = "refine_mesh" ACCEPT_REFINEMENT = "accept_refinement" +EXPORT_OBJ = "export_obj" def results_dir(work_dir_field, exp_name): diff --git a/tests/test_mesh_viewer_export.py b/tests/test_mesh_viewer_export.py new file mode 100644 index 0000000..daa2cf1 --- /dev/null +++ b/tests/test_mesh_viewer_export.py @@ -0,0 +1,45 @@ +from pathlib import Path + +from surface_morphometrics_gui.plugins.mesh_viewer import export_feature_name, resolve_config_path + + +def test_export_feature_name_strips_prefixes(): + assert export_feature_name("Cell_curvedness_VV") == "curvedness_VV" + assert export_feature_name("Point_thickness") == "thickness" + assert export_feature_name("gauss_curvature") == "gauss_curvature" + + +def test_resolve_config_path_prefers_named_config(tmp_path): + vtp = tmp_path / "results" / "surface.AVV_rh9.vtp" + vtp.parent.mkdir(parents=True) + vtp.write_text("stub") + cfg = tmp_path / "myexp_config.yml" + cfg.write_text("work_dir: results/\n") + + assert resolve_config_path(vtp).resolve() == cfg.resolve() + + +def test_resolve_config_path_falls_back_to_config_yml(tmp_path): + vtp = tmp_path / "surface.vtp" + vtp.write_text("stub") + cfg = tmp_path / "config.yml" + cfg.write_text("work_dir: ./\n") + + assert resolve_config_path(vtp).resolve() == cfg.resolve() + + +def test_resolve_config_path_uses_experiment_manager(tmp_path): + vtp = tmp_path / "elsewhere" / "surface.vtp" + vtp.parent.mkdir(parents=True) + vtp.write_text("stub") + + exp_dir = tmp_path / "exp1" + exp_dir.mkdir() + cfg = exp_dir / "exp1_config.yml" + cfg.write_text("work_dir: results/\n") + + class EM: + work_dir = type("W", (), {"value": str(tmp_path)})() + experiment_name = type("N", (), {"currentText": lambda self: "exp1"})() + + assert resolve_config_path(vtp, EM()).resolve() == cfg.resolve() From bb3ccd65f9540a03448531224b351cab4654e668 Mon Sep 17 00:00:00 2001 From: Hemanth Kapa Date: Tue, 7 Jul 2026 12:26:08 -0700 Subject: [PATCH 2/2] fix the suprocess getting stuck Co-authored-by: Cursor --- .../plugins/mesh_viewer.py | 54 +++++++++++-------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/src/surface_morphometrics_gui/plugins/mesh_viewer.py b/src/surface_morphometrics_gui/plugins/mesh_viewer.py index ba5f2d9..d0ec19e 100644 --- a/src/surface_morphometrics_gui/plugins/mesh_viewer.py +++ b/src/surface_morphometrics_gui/plugins/mesh_viewer.py @@ -8,12 +8,11 @@ import threading from pathlib import Path from magicgui import widgets -from qtpy.QtCore import QTimer +from qtpy.QtCore import Qt, QTimer, Signal from magicgui.widgets import FloatRangeSlider, FloatSpinBox from qtpy.QtWidgets import ( QWidget, QVBoxLayout, QPushButton, QSizePolicy, QScrollArea, QFileDialog, QMessageBox, ) -from qtpy.QtCore import Qt from ..utils.script_resolver import CLI_MISSING_MESSAGE, EXPORT_OBJ, resolve_cli_runner @@ -67,6 +66,9 @@ def resolve_config_path(vtp_path, experiment_manager=None): class MeshViewer(QWidget): + # Marshals export results from the worker thread back to the Qt main thread. + _export_finished = Signal(bool, str, str, str, str) + def __init__(self, viewer, experiment_manager=None, *args, **kwargs): super().__init__(*args, **kwargs) self.viewer = viewer @@ -235,6 +237,7 @@ def __init__(self, viewer, experiment_manager=None, *args, **kwargs): self.contrast_max.changed.connect(self._on_contrast_max_changed) self.ao_enabled.changed.connect(self._on_ao_toggled) self.shading_selector.changed.connect(self._on_shading_changed) + self._export_finished.connect(self._finish_export_obj) def _on_load_mesh_clicked(self): """Open a file dialog and load the selected mesh file.""" @@ -634,7 +637,7 @@ def _run_export_obj_worker(self, job_data): '--vmin', str(job_data['vmin']), '--vmax', str(job_data['vmax']), ] - print(f"Running: {' '.join(map(str, cmd))}") + print(f"Running: {' '.join(map(str, cmd))}", flush=True) result = subprocess.run( cmd, cwd=str(job_data['vtp_path'].parent), @@ -643,44 +646,44 @@ def _run_export_obj_worker(self, job_data): check=False, ) if result.stdout: - print(result.stdout.rstrip()) + print(result.stdout.rstrip(), flush=True) if result.stderr: - print(result.stderr.rstrip()) + print(result.stderr.rstrip(), flush=True) base = job_data['vtp_path'].stem feature = job_data['feature'] out_dir = job_data['vtp_path'].parent - obj_path = out_dir / f"{base}_{feature}.obj" - mtl_path = out_dir / f"{base}_{feature}.mtl" - png_path = out_dir / f"{base}_{feature}.png" + obj_path = str(out_dir / f"{base}_{feature}.obj") + mtl_path = str(out_dir / f"{base}_{feature}.mtl") + png_path = str(out_dir / f"{base}_{feature}.png") if result.returncode != 0: detail = (result.stderr or result.stdout or "Unknown error").strip() - QTimer.singleShot( - 0, - lambda: self._finish_export_obj(False, detail, obj_path, mtl_path), - ) + self._export_finished.emit(False, detail, obj_path, mtl_path, "") return - QTimer.singleShot( - 0, - lambda: self._finish_export_obj(True, "", obj_path, mtl_path, png_path), - ) + print("Export done.", flush=True) + self._export_finished.emit(True, "", obj_path, mtl_path, png_path) except Exception as exc: - QTimer.singleShot( - 0, - lambda: self._finish_export_obj(False, str(exc), None, None), - ) + self._export_finished.emit(False, str(exc), "", "", "") - def _finish_export_obj(self, success, detail, obj_path, mtl_path, png_path=None): - self.export_obj_button.setText("Export to OBJ") + def _finish_export_obj(self, success, detail, obj_path, mtl_path, png_path=""): layer = self._find_active_surface_layer() - self._update_export_button_state(layer) if success: + self.export_obj_button.setText("Done") + self.export_obj_button.setEnabled(True) + self.stats_label.value = ( + f"Export done.\n\n" + f"OBJ: {obj_path}\n" + f"MTL: {mtl_path}\n" + f"Texture: {png_path}" + ) + QTimer.singleShot(2000, lambda: self.export_obj_button.setText("Export to OBJ")) QMessageBox.information( self, "Export complete", + "Done.\n\n" "Colormapped surface exported:\n\n" f"OBJ: {obj_path}\n" f"MTL: {mtl_path}\n" @@ -688,11 +691,16 @@ def _finish_export_obj(self, success, detail, obj_path, mtl_path, png_path=None) "The OBJ references the MTL; the MTL references the colormap PNG.", ) else: + self.export_obj_button.setText("Export to OBJ") + self._update_export_button_state(layer) QMessageBox.critical( self, "Export failed", f"Could not export surface to OBJ.\n\n{detail}", ) + return + + self._update_export_button_state(layer) def _find_active_surface_layer(self): """Find the active surface layer, falling back to any VTP surface layer."""