Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions src/surface_morphometrics_gui/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,11 @@ def main():
# Setup responsive layout
setup_responsive_layout(viewer)

# Create mesh viewer widget (before RefinementWidget, which reuses it to
# preview refined iterations in napari before accepting one).
mesh_viewer = MeshViewer(viewer)

# Create widgets
experiment_manager = ExperimentManager(viewer)
# Create mesh viewer widget (before RefinementWidget, which reuses it to
# preview refined iterations in napari before accepting one).
mesh_viewer = MeshViewer(viewer, experiment_manager=experiment_manager)
mesh_widget = MeshGenerationWidget(experiment_manager)
pycurv_widget = PyCurvWidget(experiment_manager=experiment_manager)
refinement_widget = RefinementWidget(experiment_manager, mesh_viewer=mesh_viewer)
Expand All @@ -98,8 +97,6 @@ def main():
# (Mesh completion connection set after dock widgets are created below)
# Create tomoslice plugin
tomoslice = TomoslicePlugin(viewer, experiment_manager)


# Setup and add dock widgets with proper sizing
dw1 = viewer.window.add_dock_widget(experiment_manager, name='Experiment Manager', area='right')
dw2 = viewer.window.add_dock_widget(mesh_widget, name='Surface Mesh', area='right')
Expand Down
216 changes: 212 additions & 4 deletions src/surface_morphometrics_gui/plugins/mesh_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@
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 qtpy.QtCore import Qt, QTimer, Signal
from magicgui.widgets import FloatRangeSlider, FloatSpinBox
from qtpy.QtWidgets import QWidget, QVBoxLayout, QPushButton, QSizePolicy, QScrollArea, QFileDialog
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QWidget, QVBoxLayout, QPushButton, QSizePolicy, QScrollArea, QFileDialog, QMessageBox,
)

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,
Expand All @@ -23,10 +29,50 @@
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):
# 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
self.experiment_manager = experiment_manager

# Initialize automatic lighting and ambient occlusion
self._initialize_automatic_lighting_ao()
Expand Down Expand Up @@ -123,6 +169,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
Expand All @@ -146,6 +202,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)
Expand Down Expand Up @@ -177,6 +237,7 @@ def __init__(self, viewer, *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."""
Expand Down Expand Up @@ -356,6 +417,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."""
Expand Down Expand Up @@ -541,6 +603,149 @@ 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))}", flush=True)
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(), flush=True)
if result.stderr:
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 = 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()
self._export_finished.emit(False, detail, obj_path, mtl_path, "")
return

print("Export done.", flush=True)
self._export_finished.emit(True, "", obj_path, mtl_path, png_path)
except Exception as exc:
self._export_finished.emit(False, str(exc), "", "", "")

def _finish_export_obj(self, success, detail, obj_path, mtl_path, png_path=""):
layer = self._find_active_surface_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"
f"Texture: {png_path}\n\n"
"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."""
Expand Down Expand Up @@ -796,6 +1001,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
Expand All @@ -809,6 +1015,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
Expand All @@ -822,6 +1029,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."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions src/surface_morphometrics_gui/utils/script_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
45 changes: 45 additions & 0 deletions tests/test_mesh_viewer_export.py
Original file line number Diff line number Diff line change
@@ -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()
Loading