diff --git a/tests/cxx/CMakeLists.txt b/tests/cxx/CMakeLists.txt index 0acbd9d0f..d88cb00cd 100644 --- a/tests/cxx/CMakeLists.txt +++ b/tests/cxx/CMakeLists.txt @@ -9,6 +9,10 @@ include_directories(SYSTEM ${PARAVIEW_INCLUDE_DIRS} ${GTEST_INCLUDE_DIRS}) include_directories(${PROJECT_SOURCE_DIR}/tomviz) +include_directories(${PROJECT_SOURCE_DIR}/tomviz/modules) +include_directories(${PROJECT_SOURCE_DIR}/tomviz/operators) +include_directories(${PROJECT_SOURCE_DIR}/tomviz/animations) +include_directories(${PROJECT_SOURCE_DIR}/tomviz/loguru) include_directories(${PROJECT_SOURCE_DIR}/tomviz/acquisition) include(CheckIncludeFileCXX) @@ -49,11 +53,16 @@ set(_pythonpath "${_pythonpath}${_separator}$ENV{PYTHONPATH}") # Add the test cases add_cxx_test(OperatorPython PYTHONPATH ${_pythonpath}) add_cxx_test(Variant) - +add_cxx_test(ScanID) +add_cxx_test(Utilities) +add_cxx_qtest(ModulePlot) +add_cxx_qtest(Tvh5Data) +add_cxx_qtest(InterfaceBuilder) +add_cxx_qtest(PipelineExecution PYTHONPATH ${_pythonpath}) add_cxx_qtest(DockerUtilities) add_cxx_qtest(AcquisitionClient PYTHONPATH "${CMAKE_SOURCE_DIR}/acquisition") -add_cxx_qtest(PtychoWorkflow) -add_cxx_qtest(PyXRFWorkflow) +add_cxx_qtest(PtychoWorkflow PYTHONPATH ${_pythonpath}) +add_cxx_qtest(PyXRFWorkflow PYTHONPATH ${_pythonpath}) # Generate the executable create_test_executable(tomvizTests) diff --git a/tests/cxx/CxxTests.cmake b/tests/cxx/CxxTests.cmake index e03285b9b..9a6b3ef12 100644 --- a/tests/cxx/CxxTests.cmake +++ b/tests/cxx/CxxTests.cmake @@ -15,9 +15,13 @@ macro(add_cxx_qtest name) set(_one_value_args PYTHONPATH) cmake_parse_arguments(fn "" "" "${_one_value_args}" "" ${ARGN}) + # Use a name-scoped variable to avoid clobbering the caller's _pythonpath. + # CMake macros do not have their own scope, so a bare _pythonpath would + # overwrite the identically-named variable in the calling CMakeLists.txt. + set(_qtest_${name}_pythonpath "") if(fn_PYTHONPATH) - set(_pythonpath "${fn_PYTHONPATH}") - message("PYTHONPATH for ${name}: ${_pythonpath})") + set(_qtest_${name}_pythonpath "${fn_PYTHONPATH}") + message("PYTHONPATH for ${name}: ${_qtest_${name}_pythonpath})") endif() set(_test_src ${name}Test.cxx) @@ -31,13 +35,13 @@ macro(add_cxx_qtest name) set_target_properties(${_executable_name} PROPERTIES ENABLE_EXPORTS TRUE) add_test(NAME "${name}" COMMAND ${_executable_name}) - if(_pythonpath) + if(_qtest_${name}_pythonpath) if (WIN32) - string(REPLACE "\\;" ";" "_pythonpath" "${_pythonpath}") - string(REPLACE ";" "\\;" "_pythonpath" "${_pythonpath}") + string(REPLACE "\\;" ";" "_qtest_${name}_pythonpath" "${_qtest_${name}_pythonpath}") + string(REPLACE ";" "\\;" "_qtest_${name}_pythonpath" "${_qtest_${name}_pythonpath}") endif() set_tests_properties(${name} - PROPERTIES ENVIRONMENT "PYTHONPATH=${_pythonpath}") + PROPERTIES ENVIRONMENT "PYTHONPATH=${_qtest_${name}_pythonpath};TOMVIZ_APPLICATION=1") endif() endmacro() diff --git a/tests/cxx/InterfaceBuilderTest.cxx b/tests/cxx/InterfaceBuilderTest.cxx new file mode 100644 index 000000000..b44016412 --- /dev/null +++ b/tests/cxx/InterfaceBuilderTest.cxx @@ -0,0 +1,199 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "InterfaceBuilder.h" + +using namespace tomviz; + +class InterfaceBuilderTest : public QObject +{ + Q_OBJECT + +private: + InterfaceBuilder* builder; + QWidget* parentWidget; + +private slots: + void init() + { + builder = new InterfaceBuilder(this); + parentWidget = new QWidget(); + } + + void cleanup() + { + delete parentWidget; + parentWidget = nullptr; + } + + void selectScalarsWidgetCreated() + { + QString desc = R"({ + "name": "TestOp", + "label": "Test Operator", + "parameters": [ + {"name": "selected_scalars", "type": "select_scalars", + "label": "Scalars"} + ] + })"; + + builder->setJSONDescription(desc); + QLayout* layout = builder->buildInterface(); + QVERIFY(layout != nullptr); + + parentWidget->setLayout(layout); + + auto* widget = parentWidget->findChild("selected_scalars"); + QVERIFY(widget != nullptr); + QCOMPARE(widget->property("type").toString(), QString("select_scalars")); + } + + void selectScalarsParameterValues() + { + QString desc = R"({ + "name": "TestOp", + "label": "Test Operator", + "parameters": [ + {"name": "selected_scalars", "type": "select_scalars", + "label": "Scalars"} + ] + })"; + + builder->setJSONDescription(desc); + QLayout* layout = builder->buildInterface(); + QVERIFY(layout != nullptr); + parentWidget->setLayout(layout); + + // The combo box is empty because we have no DataSource. + // Manually populate the model and check items to test parameterValues(). + auto* container = + parentWidget->findChild("selected_scalars"); + QVERIFY(container != nullptr); + + auto* applyAllCB = + container->findChild("selected_scalars_apply_all"); + QVERIFY(applyAllCB != nullptr); + + auto* combo = + container->findChild("selected_scalars_combo"); + QVERIFY(combo != nullptr); + + auto* model = qobject_cast(combo->model()); + QVERIFY(model != nullptr); + + // Add items and check them + auto* itemA = new QStandardItem("scalar_a"); + itemA->setFlags(Qt::ItemIsUserCheckable | Qt::ItemIsEnabled); + itemA->setData(Qt::Checked, Qt::CheckStateRole); + model->appendRow(itemA); + + auto* itemB = new QStandardItem("scalar_b"); + itemB->setFlags(Qt::ItemIsUserCheckable | Qt::ItemIsEnabled); + itemB->setData(Qt::Checked, Qt::CheckStateRole); + model->appendRow(itemB); + + // Uncheck "Apply to all" so individual selections are used + applyAllCB->setChecked(false); + + auto result = InterfaceBuilder::parameterValues(parentWidget); + QVERIFY(result.contains("selected_scalars")); + + auto resultScalars = result["selected_scalars"].toList(); + QCOMPARE(resultScalars.size(), 2); + QCOMPARE(resultScalars[0].toString(), QString("scalar_a")); + QCOMPARE(resultScalars[1].toString(), QString("scalar_b")); + } + + void basicParameterTypes() + { + QString desc = R"({ + "name": "TestOp", + "label": "Test Operator", + "parameters": [ + {"name": "int_param", "type": "int", "label": "Int", "default": 5}, + {"name": "double_param", "type": "double", "label": "Double", + "default": 1.5}, + {"name": "bool_param", "type": "bool", "label": "Bool", "default": true} + ] + })"; + + builder->setJSONDescription(desc); + QLayout* layout = builder->buildInterface(); + QVERIFY(layout != nullptr); + + parentWidget->setLayout(layout); + + auto result = InterfaceBuilder::parameterValues(parentWidget); + QVERIFY(result.contains("int_param")); + QVERIFY(result.contains("double_param")); + QVERIFY(result.contains("bool_param")); + + QCOMPARE(result["int_param"].toInt(), 5); + QCOMPARE(result["double_param"].toDouble(), 1.5); + QCOMPARE(result["bool_param"].toBool(), true); + } + + void enableIfVisibleIf() + { + QString desc = R"({ + "name": "TestOp", + "label": "Test Operator", + "parameters": [ + {"name": "toggle", "type": "bool", "label": "Toggle", "default": false}, + {"name": "enabled_dep", "type": "int", "label": "Enabled Dep", + "default": 0, "enable_if": "toggle == true"}, + {"name": "visible_dep", "type": "int", "label": "Visible Dep", + "default": 0, "visible_if": "toggle == true"} + ] + })"; + + builder->setJSONDescription(desc); + QLayout* layout = builder->buildInterface(); + QVERIFY(layout != nullptr); + + parentWidget->setLayout(layout); + // Must show the parent so isVisible() works on children -- + // Qt's isVisible() requires all ancestors to be visible. + parentWidget->show(); + + auto* toggleCheckBox = + parentWidget->findChild("toggle"); + QVERIFY(toggleCheckBox != nullptr); + + auto* enabledWidget = parentWidget->findChild("enabled_dep"); + QVERIFY(enabledWidget != nullptr); + + auto* visibleWidget = parentWidget->findChild("visible_dep"); + QVERIFY(visibleWidget != nullptr); + + // Toggle is false by default -- enabled_dep should be disabled, + // visible_dep should be hidden + QVERIFY(!enabledWidget->isEnabled()); + QVERIFY(!visibleWidget->isVisible()); + + // Toggle on -- both should become active + toggleCheckBox->setChecked(true); + QVERIFY(enabledWidget->isEnabled()); + QVERIFY(visibleWidget->isVisible()); + + // Toggle back off -- both should revert + toggleCheckBox->setChecked(false); + QVERIFY(!enabledWidget->isEnabled()); + QVERIFY(!visibleWidget->isVisible()); + } +}; + +QTEST_MAIN(InterfaceBuilderTest) +#include "InterfaceBuilderTest.moc" diff --git a/tests/cxx/ModulePlotTest.cxx b/tests/cxx/ModulePlotTest.cxx new file mode 100644 index 000000000..27f3a80c2 --- /dev/null +++ b/tests/cxx/ModulePlotTest.cxx @@ -0,0 +1,273 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "modules/ModulePlot.h" +#include "operators/OperatorResult.h" + +using namespace tomviz; + +class ModulePlotTest : public QObject +{ + Q_OBJECT + +private: + ModulePlot* modulePlot; + + // Create a simple vtkTable with x and y columns + vtkNew createTestTable() + { + vtkNew table; + + vtkNew xCol; + xCol->SetName("x"); + xCol->SetNumberOfTuples(5); + for (int i = 0; i < 5; ++i) { + xCol->SetValue(i, static_cast(i)); + } + + vtkNew yCol; + yCol->SetName("y"); + yCol->SetNumberOfTuples(5); + for (int i = 0; i < 5; ++i) { + yCol->SetValue(i, static_cast(i * i)); + } + + table->AddColumn(xCol); + table->AddColumn(yCol); + return table; + } + +private slots: + void init() + { + modulePlot = new ModulePlot(this); + } + + void cleanup() + { + delete modulePlot; + modulePlot = nullptr; + } + + void label() + { + QCOMPARE(modulePlot->label(), QString("Plot")); + } + + void visibilityDefault() + { + QVERIFY(modulePlot->visibility()); + } + + void setVisibilityToggle() + { + QVERIFY(modulePlot->setVisibility(false)); + QVERIFY(!modulePlot->visibility()); + + QVERIFY(modulePlot->setVisibility(true)); + QVERIFY(modulePlot->visibility()); + } + + void exportDataTypeString() + { + QCOMPARE(modulePlot->exportDataTypeString(), QString("")); + } + + void dataToExportReturnsNull() + { + QVERIFY(modulePlot->dataToExport() == nullptr); + } + + void initializeDataSourceReturnsFalse() + { + // Plot only works with OperatorResult, not DataSource + QVERIFY(!modulePlot->initialize(static_cast(nullptr), + nullptr)); + } + + void finalize() + { + QVERIFY(modulePlot->finalize()); + } + + void finalizeTwice() + { + QVERIFY(modulePlot->finalize()); + QVERIFY(modulePlot->finalize()); + } + + void deserializeVisibility() + { + QJsonObject props; + props["visibility"] = false; + QJsonObject json; + json["properties"] = props; + + QVERIFY(modulePlot->deserialize(json)); + QVERIFY(!modulePlot->visibility()); + } + + void deserializeRestoresVisibilityTrue() + { + modulePlot->setVisibility(false); + QVERIFY(!modulePlot->visibility()); + + QJsonObject props; + props["visibility"] = true; + QJsonObject json; + json["properties"] = props; + + QVERIFY(modulePlot->deserialize(json)); + QVERIFY(modulePlot->visibility()); + } + + void dataSourceMovedDoesNotCrash() + { + modulePlot->dataSourceMoved(1.0, 2.0, 3.0); + } + + void dataSourceRotatedDoesNotCrash() + { + modulePlot->dataSourceRotated(45.0, 90.0, 0.0); + } + + void initializeWithOperatorResult() + { + auto* objBuilder = pqApplicationCore::instance()->getObjectBuilder(); + auto* server = pqApplicationCore::instance()->getActiveServer(); + QVERIFY(server != nullptr); + + auto* pqView = objBuilder->createView("XYChartView", server); + QVERIFY(pqView != nullptr); + auto* viewProxy = pqView->getViewProxy(); + QVERIFY(viewProxy != nullptr); + + auto table = createTestTable(); + auto* result = new OperatorResult(this); + result->setName("test_result"); + result->setDataObject(table); + + QVERIFY(modulePlot->initialize(result, viewProxy)); + QVERIFY(modulePlot->visibility()); + + QVERIFY(modulePlot->finalize()); + objBuilder->destroy(pqView); + } + + void addToPanel() + { + auto* objBuilder = pqApplicationCore::instance()->getObjectBuilder(); + auto* server = pqApplicationCore::instance()->getActiveServer(); + auto* pqView = objBuilder->createView("XYChartView", server); + auto* viewProxy = pqView->getViewProxy(); + + auto table = createTestTable(); + auto* result = new OperatorResult(this); + result->setName("test_result"); + result->setDataObject(table); + + QVERIFY(modulePlot->initialize(result, viewProxy)); + + // addToPanel should create the label/log-scale controls + QWidget panel; + modulePlot->addToPanel(&panel); + + auto* layout = qobject_cast(panel.layout()); + QVERIFY(layout != nullptr); + + auto* xLogCheckBox = panel.findChild(); + QVERIFY(xLogCheckBox != nullptr); + + auto* xLabelEdit = panel.findChild(); + QVERIFY(xLabelEdit != nullptr); + + QVERIFY(modulePlot->finalize()); + objBuilder->destroy(pqView); + } + + void serializeAfterInit() + { + auto* objBuilder = pqApplicationCore::instance()->getObjectBuilder(); + auto* server = pqApplicationCore::instance()->getActiveServer(); + auto* pqView = objBuilder->createView("XYChartView", server); + auto* viewProxy = pqView->getViewProxy(); + + auto table = createTestTable(); + auto* result = new OperatorResult(this); + result->setName("test_result"); + result->setDataObject(table); + + QVERIFY(modulePlot->initialize(result, viewProxy)); + + auto json = modulePlot->serialize(); + + // Should contain properties with visibility + QVERIFY(json.contains("properties")); + auto props = json["properties"].toObject(); + QCOMPARE(props["visibility"].toBool(), true); + + // Should contain the operator result name + QCOMPARE(json["operatorResultName"].toString(), QString("test_result")); + + QVERIFY(modulePlot->finalize()); + objBuilder->destroy(pqView); + } + + void visibilityToggleWithChart() + { + auto* objBuilder = pqApplicationCore::instance()->getObjectBuilder(); + auto* server = pqApplicationCore::instance()->getActiveServer(); + auto* pqView = objBuilder->createView("XYChartView", server); + auto* viewProxy = pqView->getViewProxy(); + + auto table = createTestTable(); + auto* result = new OperatorResult(this); + result->setName("test_result"); + result->setDataObject(table); + + QVERIFY(modulePlot->initialize(result, viewProxy)); + + // Toggle visibility off and on with an actual chart backing it + QVERIFY(modulePlot->setVisibility(false)); + QVERIFY(!modulePlot->visibility()); + + QVERIFY(modulePlot->setVisibility(true)); + QVERIFY(modulePlot->visibility()); + + QVERIFY(modulePlot->finalize()); + objBuilder->destroy(pqView); + } +}; + +int main(int argc, char** argv) +{ + QApplication app(argc, argv); + pqPVApplicationCore appCore(argc, argv); + + // Create a builtin server connection so views and proxies can be created + auto* builder = pqApplicationCore::instance()->getObjectBuilder(); + builder->createServer(pqServerResource("builtin:")); + + ModulePlotTest tc; + return QTest::qExec(&tc, argc, argv); +} + +#include "ModulePlotTest.moc" diff --git a/tests/cxx/OperatorPythonTest.cxx b/tests/cxx/OperatorPythonTest.cxx index dfe652749..c8dcf7bd6 100644 --- a/tests/cxx/OperatorPythonTest.cxx +++ b/tests/cxx/OperatorPythonTest.cxx @@ -3,6 +3,7 @@ #include +#include #include #include @@ -13,6 +14,9 @@ #include #include #include +#include +#include +#include #include #include @@ -234,3 +238,240 @@ TEST_F(OperatorPythonTest, update_data) FAIL() << "Unable to load script."; } } + +// --- Breakpoint API tests --- + +TEST_F(OperatorPythonTest, breakpoint_default_false) +{ + ASSERT_FALSE(pythonOperator->hasBreakpoint()); +} + +TEST_F(OperatorPythonTest, breakpoint_set_emits_signal) +{ + QSignalSpy spy(pythonOperator, SIGNAL(breakpointChanged())); + pythonOperator->setBreakpoint(true); + ASSERT_TRUE(pythonOperator->hasBreakpoint()); + ASSERT_EQ(spy.count(), 1); +} + +TEST_F(OperatorPythonTest, breakpoint_no_signal_on_same_value) +{ + pythonOperator->setBreakpoint(true); + QSignalSpy spy(pythonOperator, SIGNAL(breakpointChanged())); + pythonOperator->setBreakpoint(true); + ASSERT_EQ(spy.count(), 0); +} + +TEST_F(OperatorPythonTest, breakpoint_toggle) +{ + QSignalSpy spy(pythonOperator, SIGNAL(breakpointChanged())); + pythonOperator->setBreakpoint(true); + ASSERT_TRUE(pythonOperator->hasBreakpoint()); + pythonOperator->setBreakpoint(false); + ASSERT_FALSE(pythonOperator->hasBreakpoint()); + ASSERT_EQ(spy.count(), 2); +} + +// --- Serialization tests --- + +TEST_F(OperatorPythonTest, serialize_breakpoint) +{ + pythonOperator->setLabel("test"); + pythonOperator->setBreakpoint(true); + auto json = pythonOperator->serialize(); + ASSERT_TRUE(json.contains("breakpoint")); + ASSERT_TRUE(json["breakpoint"].toBool()); +} + +TEST_F(OperatorPythonTest, serialize_no_breakpoint_by_default) +{ + pythonOperator->setLabel("test"); + auto json = pythonOperator->serialize(); + ASSERT_FALSE(json.contains("breakpoint")); +} + +TEST_F(OperatorPythonTest, deserialize_restores_label_and_script) +{ + QJsonObject json; + json["label"] = "My Label"; + json["script"] = "def transform(dataset): pass"; + json["description"] = ""; + pythonOperator->deserialize(json); + ASSERT_STREQ(pythonOperator->label().toLatin1().constData(), "My Label"); + ASSERT_STREQ(pythonOperator->script().toLatin1().constData(), + "def transform(dataset): pass"); +} + +// --- JSON description parsing tests --- + +TEST_F(OperatorPythonTest, json_description_parameters) +{ + QString desc = R"({ + "name": "TestOp", + "label": "Test Operator", + "parameters": [ + {"name": "param1", "type": "int", "default": 0}, + {"name": "param2", "type": "double", "default": 1.0}, + {"name": "param3", "type": "bool", "default": true} + ] + })"; + pythonOperator->setJSONDescription(desc); + ASSERT_EQ(pythonOperator->numberOfParameters(), 3); + ASSERT_STREQ(pythonOperator->label().toLatin1().constData(), + "Test Operator"); +} + +TEST_F(OperatorPythonTest, json_description_no_parameters) +{ + QString desc = R"({ + "name": "SimpleOp", + "label": "Simple Operator" + })"; + pythonOperator->setJSONDescription(desc); + ASSERT_EQ(pythonOperator->numberOfParameters(), 0); + ASSERT_STREQ(pythonOperator->label().toLatin1().constData(), + "Simple Operator"); +} + +TEST_F(OperatorPythonTest, json_description_with_results) +{ + QString desc = R"({ + "name": "ResultOp", + "label": "Result Operator", + "parameters": [ + {"name": "threshold", "type": "double", "default": 0.5} + ], + "results": [ + {"name": "output_image", "label": "Output Image"} + ] + })"; + pythonOperator->setJSONDescription(desc); + ASSERT_EQ(pythonOperator->numberOfParameters(), 1); + ASSERT_EQ(pythonOperator->numberOfResults(), 1); +} + +// --- Argument serialization round-trip tests --- + +TEST_F(OperatorPythonTest, serialize_deserialize_double_argument) +{ + QString desc = R"({ + "name": "TestOp", + "label": "Test Operator", + "parameters": [ + {"name": "rotation_center", "type": "double", "default": 0.0} + ] + })"; + pythonOperator->setJSONDescription(desc); + + QMap args; + args["rotation_center"] = 42.5; + pythonOperator->setArguments(args); + pythonOperator->setScript("def transform(dataset): pass"); + + auto json = pythonOperator->serialize(); + + // Deserialize onto a fresh operator + auto* newOp = new OperatorPython(nullptr); + ASSERT_TRUE(newOp->deserialize(json)); + + auto newArgs = newOp->arguments(); + ASSERT_DOUBLE_EQ(newArgs["rotation_center"].toDouble(), 42.5); + + newOp->deleteLater(); +} + +TEST_F(OperatorPythonTest, serialize_deserialize_enumeration_argument) +{ + QString desc = R"({ + "name": "TestOp", + "label": "Test Operator", + "parameters": [ + {"name": "transform_source", "type": "enumeration", "default": 0, + "options": [{"Manual": "manual"}, {"Load From File": "from_file"}]} + ] + })"; + pythonOperator->setJSONDescription(desc); + + QMap args; + args["transform_source"] = 1; + pythonOperator->setArguments(args); + pythonOperator->setScript("def transform(dataset): pass"); + + auto json = pythonOperator->serialize(); + + auto* newOp = new OperatorPython(nullptr); + ASSERT_TRUE(newOp->deserialize(json)); + + auto newArgs = newOp->arguments(); + ASSERT_EQ(newArgs["transform_source"].toInt(), 1); + + newOp->deleteLater(); +} + +TEST_F(OperatorPythonTest, deserialize_select_scalars) +{ + QString desc = R"({ + "name": "TestOp", + "label": "Test Operator", + "parameters": [ + {"name": "selected_scalars", "type": "select_scalars"} + ] + })"; + + QJsonObject json; + json["description"] = desc; + json["label"] = "Test Operator"; + json["script"] = ""; + + QJsonObject args; + QJsonArray scalarsArray; + scalarsArray.append("scalar_a"); + scalarsArray.append("scalar_b"); + args["selected_scalars"] = scalarsArray; + json["arguments"] = args; + + pythonOperator->deserialize(json); + + auto resultArgs = pythonOperator->arguments(); + auto scalars = resultArgs["selected_scalars"].toList(); + ASSERT_EQ(scalars.size(), 2); + ASSERT_STREQ(scalars[0].toString().toLatin1().constData(), "scalar_a"); + ASSERT_STREQ(scalars[1].toString().toLatin1().constData(), "scalar_b"); +} + +TEST_F(OperatorPythonTest, serialize_deserialize_roundtrip) +{ + QString desc = R"({ + "name": "TestOp", + "label": "Test Operator", + "parameters": [ + {"name": "value", "type": "double", "default": 0.0} + ] + })"; + pythonOperator->setJSONDescription(desc); + pythonOperator->setScript("def transform(dataset): pass"); + + QMap args; + args["value"] = 3.14; + pythonOperator->setArguments(args); + + auto json = pythonOperator->serialize(); + + // Verify the serialized JSON contains expected fields + ASSERT_TRUE(json.contains("description")); + ASSERT_TRUE(json.contains("label")); + ASSERT_TRUE(json.contains("script")); + ASSERT_TRUE(json.contains("arguments")); + ASSERT_STREQ(json["type"].toString().toLatin1().constData(), "Python"); + + auto* newOp = new OperatorPython(nullptr); + ASSERT_TRUE(newOp->deserialize(json)); + + ASSERT_DOUBLE_EQ(newOp->arguments()["value"].toDouble(), 3.14); + ASSERT_STREQ(newOp->label().toLatin1().constData(), "Test Operator"); + ASSERT_STREQ(newOp->script().toLatin1().constData(), + "def transform(dataset): pass"); + + newOp->deleteLater(); +} + diff --git a/tests/cxx/PipelineExecutionTest.cxx b/tests/cxx/PipelineExecutionTest.cxx new file mode 100644 index 000000000..eae10fcb8 --- /dev/null +++ b/tests/cxx/PipelineExecutionTest.cxx @@ -0,0 +1,256 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include + +#include "DataSource.h" +#include "Pipeline.h" +#include "PipelineProxy.h" +#include "PythonUtilities.h" +#include "TomvizTest.h" +#include "operators/OperatorProxy.h" +#include "operators/OperatorPython.h" + +using namespace tomviz; + +static QString loadFixture(const QString& name) +{ + QFile file(QString("%1/fixtures/%2").arg(SOURCE_DIR, name)); + if (!file.open(QIODevice::ReadOnly)) { + return QString(); + } + return QString(file.readAll()); +} + +static vtkSmartPointer createImageData(int dim, double fill) +{ + auto image = vtkSmartPointer::New(); + image->SetDimensions(dim, dim, dim); + image->AllocateScalars(VTK_DOUBLE, 1); + for (int z = 0; z < dim; ++z) { + for (int y = 0; y < dim; ++y) { + for (int x = 0; x < dim; ++x) { + image->SetScalarComponentFromDouble(x, y, z, 0, fill); + } + } + } + return image; +} + +class PipelineExecutionTest : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase() + { + OperatorProxyFactory::registerWithFactory(); + PipelineProxyFactory::registerWithFactory(); + } + + void pipelineStopsAtBreakpoint() + { + auto image = createImageData(2, 0.0); + auto* ds = new DataSource(image); + + QString script = loadFixture("increment_scalars.py"); + QVERIFY2(!script.isEmpty(), "Failed to load increment_scalars.py"); + + // Create a pipeline (paused so operators aren't auto-executed on add) + Pipeline pipeline(ds); + pipeline.pause(); + + // Add 3 increment operators + auto* op0 = new OperatorPython(ds); + op0->setLabel("increment_0"); + op0->setScript(script); + ds->addOperator(op0); + + auto* op1 = new OperatorPython(ds); + op1->setLabel("increment_1"); + op1->setScript(script); + ds->addOperator(op1); + + auto* op2 = new OperatorPython(ds); + op2->setLabel("increment_2"); + op2->setScript(script); + ds->addOperator(op2); + + // Set a breakpoint on the 3rd operator + op2->setBreakpoint(true); + + // Resume the pipeline and execute -- it should stop before op2 + pipeline.resume(); + QSignalSpy breakpointSpy(&pipeline, &Pipeline::breakpointReached); + QSignalSpy finishedSpy(&pipeline, &Pipeline::finished); + auto* future = pipeline.execute(ds, op0); + + // Wait for the pipeline to finish (up to 10 seconds) + QVERIFY(finishedSpy.wait(10000)); + + // breakpointReached should have been emitted with op2 + QCOMPARE(breakpointSpy.count(), 1); + auto* reachedOp = breakpointSpy.takeFirst().at(0).value(); + QCOMPARE(reachedOp, op2); + + // The first 2 operators should be Complete, the 3rd should be Queued + QCOMPARE(op0->state(), OperatorState::Complete); + QCOMPARE(op1->state(), OperatorState::Complete); + QCOMPARE(op2->state(), OperatorState::Queued); + + // Delete future before pipeline goes out of scope to avoid double-free + // (PipelineFutureInternal's QScopedPointer vs executor's QObject parent) + delete future; + } + + void breakpointSkipsRemainingOps() + { + auto image = createImageData(2, 0.0); + auto* ds = new DataSource(image); + + QString addOneScript = loadFixture("increment_scalars.py"); + QString addTenScript = loadFixture("add_ten.py"); + QVERIFY(!addOneScript.isEmpty()); + QVERIFY(!addTenScript.isEmpty()); + + Pipeline pipeline(ds); + pipeline.pause(); + + // Op0: add 1, Op1: add 10, Op2 (breakpoint): would add 1 again + auto* op0 = new OperatorPython(ds); + op0->setLabel("add_one"); + op0->setScript(addOneScript); + ds->addOperator(op0); + + auto* op1 = new OperatorPython(ds); + op1->setLabel("add_ten"); + op1->setScript(addTenScript); + ds->addOperator(op1); + + auto* op2 = new OperatorPython(ds); + op2->setLabel("add_one_again"); + op2->setScript(addOneScript); + op2->setBreakpoint(true); + ds->addOperator(op2); + + pipeline.resume(); + QSignalSpy finishedSpy(&pipeline, &Pipeline::finished); + auto* future = pipeline.execute(ds, op0); + + QVERIFY(finishedSpy.wait(10000)); + + // Result should be 11.0 (0 + 1 + 10), not 12.0 (if 3rd ran too) + auto result = future->result(); + QVERIFY(result != nullptr); + int dims[3]; + result->GetDimensions(dims); + for (int z = 0; z < dims[2]; ++z) { + for (int y = 0; y < dims[1]; ++y) { + for (int x = 0; x < dims[0]; ++x) { + QCOMPARE(result->GetScalarComponentAsDouble(x, y, z, 0), 11.0); + } + } + } + + delete future; + } + + void executionOrderMatters() + { + QString addTenScript = loadFixture("add_ten.py"); + QString multiplyTwoScript = loadFixture("multiply_two.py"); + QVERIFY(!addTenScript.isEmpty()); + QVERIFY(!multiplyTwoScript.isEmpty()); + + // Order 1: [add_ten, multiply_two] on data starting at 0 + // Expected: (0 + 10) * 2 = 20 + { + auto image = createImageData(2, 0.0); + auto* ds = new DataSource(image); + Pipeline pipeline(ds); + pipeline.pause(); + + auto* opAdd = new OperatorPython(ds); + opAdd->setLabel("add_ten"); + opAdd->setScript(addTenScript); + ds->addOperator(opAdd); + + auto* opMul = new OperatorPython(ds); + opMul->setLabel("multiply_two"); + opMul->setScript(multiplyTwoScript); + ds->addOperator(opMul); + + pipeline.resume(); + QSignalSpy finishedSpy(&pipeline, &Pipeline::finished); + auto* future = pipeline.execute(ds, opAdd); + + QVERIFY(finishedSpy.wait(10000)); + + auto result = future->result(); + QVERIFY(result != nullptr); + QCOMPARE(result->GetScalarComponentAsDouble(0, 0, 0, 0), 20.0); + + delete future; + } + + // Order 2: [multiply_two, add_ten] on data starting at 0 + // Expected: (0 * 2) + 10 = 10 + { + auto image = createImageData(2, 0.0); + auto* ds = new DataSource(image); + Pipeline pipeline(ds); + pipeline.pause(); + + auto* opMul = new OperatorPython(ds); + opMul->setLabel("multiply_two"); + opMul->setScript(multiplyTwoScript); + ds->addOperator(opMul); + + auto* opAdd = new OperatorPython(ds); + opAdd->setLabel("add_ten"); + opAdd->setScript(addTenScript); + ds->addOperator(opAdd); + + pipeline.resume(); + QSignalSpy finishedSpy(&pipeline, &Pipeline::finished); + auto* future = pipeline.execute(ds, opMul); + + QVERIFY(finishedSpy.wait(10000)); + + auto result = future->result(); + QVERIFY(result != nullptr); + QCOMPARE(result->GetScalarComponentAsDouble(0, 0, 0, 0), 10.0); + + delete future; + } + } +}; + +int main(int argc, char** argv) +{ + QApplication app(argc, argv); + pqPVApplicationCore appCore(argc, argv); + + // Create a builtin server connection so proxies can be created + auto* builder = pqApplicationCore::instance()->getObjectBuilder(); + builder->createServer(pqServerResource("builtin:")); + + Python::initialize(); + + PipelineExecutionTest tc; + return QTest::qExec(&tc, argc, argv); +} + +#include "PipelineExecutionTest.moc" diff --git a/tests/cxx/ScanIDTest.cxx b/tests/cxx/ScanIDTest.cxx new file mode 100644 index 000000000..c4554987e --- /dev/null +++ b/tests/cxx/ScanIDTest.cxx @@ -0,0 +1,75 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include + +#include +#include + +#include "DataSource.h" +#include "TomvizTest.h" + +using namespace tomviz; + +class ScanIDTest : public ::testing::Test +{ +protected: + void SetUp() override { dataObject = vtkSmartPointer::New(); } + + vtkSmartPointer dataObject; +}; + +TEST_F(ScanIDTest, scan_ids_not_present_by_default) +{ + ASSERT_FALSE(DataSource::hasScanIDs(dataObject)); +} + +TEST_F(ScanIDTest, set_and_get_scan_ids) +{ + QVector ids = { 1, 2, 3 }; + DataSource::setScanIDs(dataObject, ids); + + ASSERT_TRUE(DataSource::hasScanIDs(dataObject)); + + auto retrieved = DataSource::getScanIDs(dataObject); + ASSERT_EQ(retrieved.size(), 3); + ASSERT_EQ(retrieved[0], 1); + ASSERT_EQ(retrieved[1], 2); + ASSERT_EQ(retrieved[2], 3); +} + +TEST_F(ScanIDTest, clear_scan_ids) +{ + QVector ids = { 1, 2, 3 }; + DataSource::setScanIDs(dataObject, ids); + ASSERT_TRUE(DataSource::hasScanIDs(dataObject)); + + DataSource::clearScanIDs(dataObject); + ASSERT_FALSE(DataSource::hasScanIDs(dataObject)); +} + +TEST_F(ScanIDTest, empty_scan_ids) +{ + QVector ids; + DataSource::setScanIDs(dataObject, ids); + + ASSERT_TRUE(DataSource::hasScanIDs(dataObject)); + auto retrieved = DataSource::getScanIDs(dataObject); + ASSERT_EQ(retrieved.size(), 0); +} + +TEST_F(ScanIDTest, large_scan_id_set) +{ + QVector ids; + for (int i = 0; i < 200; ++i) { + ids.append(i * 10); + } + DataSource::setScanIDs(dataObject, ids); + + ASSERT_TRUE(DataSource::hasScanIDs(dataObject)); + auto retrieved = DataSource::getScanIDs(dataObject); + ASSERT_EQ(retrieved.size(), 200); + for (int i = 0; i < 200; ++i) { + ASSERT_EQ(retrieved[i], i * 10); + } +} diff --git a/tests/cxx/Tvh5DataTest.cxx b/tests/cxx/Tvh5DataTest.cxx new file mode 100644 index 000000000..243b09e01 --- /dev/null +++ b/tests/cxx/Tvh5DataTest.cxx @@ -0,0 +1,221 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "ActiveObjects.h" +#include "DataSource.h" +#include "EmdFormat.h" +#include "Pipeline.h" +#include "PipelineManager.h" +#include "Tvh5Format.h" +#include "modules/ModuleManager.h" + +using namespace tomviz; + +class Tvh5DataTest : public QObject +{ + Q_OBJECT + +private: + vtkSmartPointer createTestImage(int dim, double fillValue) + { + auto image = vtkSmartPointer::New(); + image->SetDimensions(dim, dim, dim); + image->AllocateScalars(VTK_DOUBLE, 1); + for (int z = 0; z < dim; ++z) { + for (int y = 0; y < dim; ++y) { + for (int x = 0; x < dim; ++x) { + image->SetScalarComponentFromDouble(x, y, z, 0, fillValue); + } + } + } + return image; + } + + // Get a temporary file path with .tvh5 extension. + QString tempFilePath() + { + QTemporaryFile tmpFile(QDir::tempPath() + "/tomviz_test_XXXXXX.tvh5"); + tmpFile.setAutoRemove(false); + tmpFile.open(); + auto path = tmpFile.fileName(); + tmpFile.close(); + m_tempFiles.push_back(path); + return path; + } + + // Set up a DataSource registered with the application singletons. + // Returns the DataSource (owned by the Pipeline). + DataSource* setupDataSource(vtkImageData* image) + { + auto* ds = new DataSource(image); + auto* pipeline = new Pipeline(ds); + PipelineManager::instance().addPipeline(pipeline); + ModuleManager::instance().addDataSource(ds); + ActiveObjects::instance().setActiveDataSource(ds); + return ds; + } + + QStringList m_tempFiles; + +private slots: + void cleanup() + { + // Reset application state between tests + ModuleManager::instance().reset(); + + for (const auto& f : m_tempFiles) { + QFile::remove(f); + } + m_tempFiles.clear(); + } + + void writeCreatesValidFile() + { + auto image = createTestImage(4, 42.0); + setupDataSource(image); + + auto fileName = tempFilePath(); + QVERIFY(Tvh5Format::write(fileName.toStdString())); + + // Verify the file can be read back as EMD data + auto readImage = vtkSmartPointer::New(); + QVERIFY(EmdFormat::read(fileName.toStdString(), readImage)); + + int dims[3]; + readImage->GetDimensions(dims); + QCOMPARE(dims[0], 4); + QCOMPARE(dims[1], 4); + QCOMPARE(dims[2], 4); + + for (int z = 0; z < 4; ++z) { + for (int y = 0; y < 4; ++y) { + for (int x = 0; x < 4; ++x) { + QCOMPARE(readImage->GetScalarComponentAsDouble(x, y, z, 0), 42.0); + } + } + } + } + + void writePreservesTiltAngles() + { + auto image = createTestImage(4, 1.0); + + vtkNew tiltAngles; + tiltAngles->SetName("tilt_angles"); + tiltAngles->SetNumberOfTuples(4); + tiltAngles->SetValue(0, -60.0); + tiltAngles->SetValue(1, -20.0); + tiltAngles->SetValue(2, 20.0); + tiltAngles->SetValue(3, 60.0); + image->GetFieldData()->AddArray(tiltAngles); + + setupDataSource(image); + + auto fileName = tempFilePath(); + QVERIFY(Tvh5Format::write(fileName.toStdString())); + + auto readImage = vtkSmartPointer::New(); + QVERIFY(EmdFormat::read(fileName.toStdString(), readImage)); + + auto* fd = readImage->GetFieldData(); + QVERIFY(fd->HasArray("tilt_angles")); + auto* readAngles = fd->GetArray("tilt_angles"); + QCOMPARE(readAngles->GetNumberOfTuples(), static_cast(4)); + QCOMPARE(readAngles->GetTuple1(0), -60.0); + QCOMPARE(readAngles->GetTuple1(1), -20.0); + QCOMPARE(readAngles->GetTuple1(2), 20.0); + QCOMPARE(readAngles->GetTuple1(3), 60.0); + } + + void writePreservesScanIDs() + { + auto image = createTestImage(4, 1.0); + + QVector scanIDs = { 10, 20, 30 }; + DataSource::setScanIDs(image, scanIDs); + + setupDataSource(image); + + auto fileName = tempFilePath(); + QVERIFY(Tvh5Format::write(fileName.toStdString())); + + auto readImage = vtkSmartPointer::New(); + QVERIFY(EmdFormat::read(fileName.toStdString(), readImage)); + + QVERIFY(DataSource::hasScanIDs(readImage)); + auto readIDs = DataSource::getScanIDs(readImage); + QCOMPARE(readIDs.size(), 3); + QCOMPARE(readIDs[0], 10); + QCOMPARE(readIDs[1], 20); + QCOMPARE(readIDs[2], 30); + } + + void roundtripPreservesData() + { + auto image = createTestImage(4, 99.0); + setupDataSource(image); + + auto fileName = tempFilePath(); + QVERIFY(Tvh5Format::write(fileName.toStdString())); + + // Clear application state + ModuleManager::instance().reset(); + + // Read back + QVERIFY(Tvh5Format::read(fileName.toStdString())); + + // Verify a data source was loaded + auto sources = ModuleManager::instance().allDataSources(); + QVERIFY(!sources.isEmpty()); + + auto* loadedDs = sources.first(); + QVERIFY(loadedDs != nullptr); + + auto* loadedImage = loadedDs->imageData(); + QVERIFY(loadedImage != nullptr); + + int dims[3]; + loadedImage->GetDimensions(dims); + QCOMPARE(dims[0], 4); + QCOMPARE(dims[1], 4); + QCOMPARE(dims[2], 4); + + for (int z = 0; z < 4; ++z) { + for (int y = 0; y < 4; ++y) { + for (int x = 0; x < 4; ++x) { + QCOMPARE(loadedImage->GetScalarComponentAsDouble(x, y, z, 0), 99.0); + } + } + } + } +}; + +int main(int argc, char** argv) +{ + QApplication app(argc, argv); + pqPVApplicationCore appCore(argc, argv); + + auto* builder = pqApplicationCore::instance()->getObjectBuilder(); + builder->createServer(pqServerResource("builtin:")); + + Tvh5DataTest tc; + return QTest::qExec(&tc, argc, argv); +} + +#include "Tvh5DataTest.moc" diff --git a/tests/cxx/UtilitiesTest.cxx b/tests/cxx/UtilitiesTest.cxx new file mode 100644 index 000000000..81f0b6267 --- /dev/null +++ b/tests/cxx/UtilitiesTest.cxx @@ -0,0 +1,120 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include + +#include +#include +#include + +#include "TomvizTest.h" +#include "Utilities.h" + +using namespace tomviz; + +class UtilitiesTest : public ::testing::Test +{ +}; + +TEST_F(UtilitiesTest, table_to_csv_basic) +{ + vtkNew table; + + vtkNew colX; + colX->SetName("x"); + colX->SetNumberOfTuples(3); + colX->SetValue(0, 1.0); + colX->SetValue(1, 2.0); + colX->SetValue(2, 3.0); + + vtkNew colY; + colY->SetName("y"); + colY->SetNumberOfTuples(3); + colY->SetValue(0, 4.0); + colY->SetValue(1, 5.0); + colY->SetValue(2, 6.0); + + table->AddColumn(colX); + table->AddColumn(colY); + + QString csv = tableToCsv(table); + QStringList lines = csv.split("\n"); + + ASSERT_EQ(lines.size(), 4); // header + 3 data rows + ASSERT_STREQ(lines[0].toLatin1().constData(), "x,y"); + ASSERT_STREQ(lines[1].toLatin1().constData(), "1,4"); + ASSERT_STREQ(lines[2].toLatin1().constData(), "2,5"); + ASSERT_STREQ(lines[3].toLatin1().constData(), "3,6"); +} + +TEST_F(UtilitiesTest, table_to_csv_multiple_columns) +{ + vtkNew table; + + const char* names[] = { "a", "b", "c", "d" }; + for (int col = 0; col < 4; ++col) { + vtkNew arr; + arr->SetName(names[col]); + arr->SetNumberOfTuples(2); + arr->SetValue(0, col + 1.0); + arr->SetValue(1, col + 10.0); + table->AddColumn(arr); + } + + QString csv = tableToCsv(table); + QStringList lines = csv.split("\n"); + + ASSERT_EQ(lines.size(), 3); // header + 2 data rows + ASSERT_STREQ(lines[0].toLatin1().constData(), "a,b,c,d"); + + // Verify comma separation in data rows + QStringList fields = lines[1].split(","); + ASSERT_EQ(fields.size(), 4); +} + +TEST_F(UtilitiesTest, table_to_csv_empty_table) +{ + vtkNew table; + + vtkNew colX; + colX->SetName("x"); + colX->SetNumberOfTuples(0); + + vtkNew colY; + colY->SetName("y"); + colY->SetNumberOfTuples(0); + + table->AddColumn(colX); + table->AddColumn(colY); + + QString csv = tableToCsv(table); + QStringList lines = csv.split("\n"); + + ASSERT_EQ(lines.size(), 1); // header only + ASSERT_STREQ(lines[0].toLatin1().constData(), "x,y"); +} + +TEST_F(UtilitiesTest, table_to_csv_single_row) +{ + vtkNew table; + + vtkNew colX; + colX->SetName("x"); + colX->SetNumberOfTuples(1); + colX->SetValue(0, 42.0); + + vtkNew colY; + colY->SetName("y"); + colY->SetNumberOfTuples(1); + colY->SetValue(0, 99.0); + + table->AddColumn(colX); + table->AddColumn(colY); + + QString csv = tableToCsv(table); + QStringList lines = csv.split("\n"); + + ASSERT_EQ(lines.size(), 2); // header + 1 data row + ASSERT_STREQ(lines[0].toLatin1().constData(), "x,y"); + ASSERT_STREQ(lines[1].toLatin1().constData(), "42,99"); +} diff --git a/tests/cxx/fixtures/add_ten.py b/tests/cxx/fixtures/add_ten.py new file mode 100644 index 000000000..113b398dd --- /dev/null +++ b/tests/cxx/fixtures/add_ten.py @@ -0,0 +1,2 @@ +def transform(dataset): + dataset.active_scalars += 10.0 diff --git a/tests/cxx/fixtures/increment_scalars.py b/tests/cxx/fixtures/increment_scalars.py new file mode 100644 index 000000000..04e1aedc8 --- /dev/null +++ b/tests/cxx/fixtures/increment_scalars.py @@ -0,0 +1,2 @@ +def transform(dataset): + dataset.active_scalars += 1.0 diff --git a/tests/cxx/fixtures/multiply_two.py b/tests/cxx/fixtures/multiply_two.py new file mode 100644 index 000000000..0367932ef --- /dev/null +++ b/tests/cxx/fixtures/multiply_two.py @@ -0,0 +1,2 @@ +def transform(dataset): + dataset.active_scalars *= 2.0 diff --git a/tests/python/CMakeLists.txt b/tests/python/CMakeLists.txt index 33cd12e5a..cea8bec22 100644 --- a/tests/python/CMakeLists.txt +++ b/tests/python/CMakeLists.txt @@ -7,3 +7,11 @@ add_python_test(multi_array) add_python_test(xcorr) add_python_test(tilt_axis_shift) add_python_test(constraint_dft) +add_python_test(shift_rotation_center) +add_python_test(remove_arrays) +add_python_test(tomopy_recon) +add_python_test(external_operator) +add_python_test(random_particles) +add_python_test(normalize) +add_python_test(psd_fsc) +add_python_test(deconvolution_denoise) diff --git a/tests/python/conftest.py b/tests/python/conftest.py index d8c1429f6..e3548905e 100644 --- a/tests/python/conftest.py +++ b/tests/python/conftest.py @@ -52,6 +52,32 @@ def hxn_xrf_example_dataset(hxn_xrf_example_output_dir: Path) -> Dataset: return dataset +@pytest.fixture +def chipset_test_data_dir(data_dir: Path) -> Path: + output_dir = data_dir / 'chipset_test_data' + if not output_dir.exists(): + # Download it + url = DATA_URL + '/69a5e30a90b2fab670f34788/download' + download_and_unzip_file(url, output_dir.parent) + + return output_dir + + +@pytest.fixture(scope='function') +def chipset_xrf_dataset(chipset_test_data_dir: Path) -> Dataset: + return load_dataset(chipset_test_data_dir / 'xrf_extracted_elements.emd') + + +@pytest.fixture(scope='function') +def chipset_ptycho_dataset(chipset_test_data_dir: Path) -> Dataset: + return load_dataset(chipset_test_data_dir / 'ptycho_object.emd') + + +@pytest.fixture(scope='function') +def chipset_probe_dataset(chipset_test_data_dir: Path) -> Dataset: + return load_dataset(chipset_test_data_dir / 'ptycho_probe.emd') + + @pytest.fixture def pystackreg_reference_output(data_dir: Path) -> dict[str, np.ndarray]: filepath = data_dir / 'test_pystackreg_reference_output.npz' diff --git a/tests/python/deconvolution_denoise_test.py b/tests/python/deconvolution_denoise_test.py new file mode 100644 index 000000000..ca443a076 --- /dev/null +++ b/tests/python/deconvolution_denoise_test.py @@ -0,0 +1,156 @@ +from unittest.mock import patch + +import numpy as np + +from utils import load_operator_class, load_operator_module + +from tomviz.external_dataset import Dataset + + +def deep_copy_dataset(dataset): + """Create a deep copy of a Dataset.""" + new_arrays = {name: array.copy() for name, array in dataset.arrays.items()} + new_dataset = Dataset(new_arrays, dataset.active_name) + if dataset.tilt_angles is not None: + new_dataset.tilt_angles = dataset.tilt_angles.copy() + if dataset.scan_ids is not None: + new_dataset.scan_ids = dataset.scan_ids.copy() + if dataset.tilt_axis is not None: + new_dataset.tilt_axis = dataset.tilt_axis + new_dataset.metadata = dataset.metadata.copy() if dataset.metadata else {} + return new_dataset + + +def test_deconvolution_denoise(chipset_xrf_dataset, chipset_probe_dataset): + xrf_dataset = chipset_xrf_dataset + probe_dataset = chipset_probe_dataset + + # Delete slices to keep only 3 (reduces runtime significantly) + delete_slices = load_operator_module('DeleteSlices') + + n_slices_xrf = xrf_dataset.active_scalars.shape[2] + if n_slices_xrf > 3: + delete_slices.transform(xrf_dataset, + firstSlice=3, + lastSlice=n_slices_xrf - 1, + axis=2) + + n_slices_probe = probe_dataset.active_scalars.shape[2] + if n_slices_probe > 3: + delete_slices.transform(probe_dataset, + firstSlice=3, + lastSlice=n_slices_probe - 1, + axis=2) + + # Set scan_ids to None for index-based matching (scan_ids are not + # updated by DeleteSlices, so use index-based matching instead) + xrf_dataset.scan_ids = None + probe_dataset.scan_ids = None + + # Save a copy of the original XRF dataset for reference + original_xrf = deep_copy_dataset(xrf_dataset) + + # Record original shapes for later verification + original_shapes = {name: array.shape + for name, array in xrf_dataset.arrays.items()} + + # Run deconvolution denoise with APG_TV method and 2x scaling + deconv_module = load_operator_module('DeconvolutionDenoise') + deconv_operator = load_operator_class(deconv_module) + + all_scalars = tuple(xrf_dataset.scalars_names) + + deconv_operator.transform( + xrf_dataset, + probe=probe_dataset, + selected_scalars=all_scalars, + method="APG_TV", + scale_x=2, + scale_y=2, + ) + + # Verify output is 2x larger in x and y, same in z + for name in xrf_dataset.scalars_names: + output_shape = xrf_dataset.scalars(name).shape + orig_shape = original_shapes[name] + assert output_shape[0] == orig_shape[0] * 2, \ + f"{name}: expected x={orig_shape[0] * 2}, got {output_shape[0]}" + assert output_shape[1] == orig_shape[1] * 2, \ + f"{name}: expected y={orig_shape[1] * 2}, got {output_shape[1]}" + assert output_shape[2] == orig_shape[2], \ + f"{name}: expected z={orig_shape[2]}, got {output_shape[2]}" + + # Run similarity metrics: deconvolution output vs original + sim_module = load_operator_module('SimilarityMetrics') + + # Mock make_spreadsheet since it requires VTK (internal mode only) + deconv_metrics = {} + + def capture_deconv_spreadsheet(column_names, table_data, *args, **kwargs): + deconv_metrics['column_names'] = list(column_names) + deconv_metrics['data'] = table_data.copy() + return deconv_metrics + + sim_operator = load_operator_class(sim_module) + with patch('tomviz.utils.make_spreadsheet', capture_deconv_spreadsheet): + sim_operator.transform( + xrf_dataset, + reference_dataset=original_xrf, + ) + + # Gaussian blur on a copy of the original + gaussian_xrf = deep_copy_dataset(original_xrf) + gaussian_module = load_operator_module('GaussianFilter') + gaussian_module.transform(gaussian_xrf, sigma=2.0) + + # Run similarity metrics: gaussian output vs original + gaussian_metrics = {} + + def capture_gaussian_spreadsheet(column_names, table_data, *args, **kwargs): + gaussian_metrics['column_names'] = list(column_names) + gaussian_metrics['data'] = table_data.copy() + return gaussian_metrics + + sim_operator2 = load_operator_class(sim_module) + with patch('tomviz.utils.make_spreadsheet', capture_gaussian_spreadsheet): + sim_operator2.transform( + gaussian_xrf, + reference_dataset=original_xrf, + ) + + # Compare metrics - deconvolution should be clearly better on average + # (lower MSE and higher SSIM) + deconv_data = deconv_metrics['data'] + gaussian_data = gaussian_metrics['data'] + deconv_columns = deconv_metrics['column_names'] + gaussian_columns = gaussian_metrics['column_names'] + + # Collect all MSE and SSIM values across scalars + deconv_mse_values = [] + gauss_mse_values = [] + deconv_ssim_values = [] + gauss_ssim_values = [] + + for i, col_name in enumerate(deconv_columns): + if 'MSE' in col_name: + deconv_mse_values.append(np.mean(deconv_data[:, i])) + gauss_idx = gaussian_columns.index(col_name) + gauss_mse_values.append(np.mean(gaussian_data[:, gauss_idx])) + + elif 'SSIM' in col_name: + deconv_ssim_values.append(np.mean(deconv_data[:, i])) + gauss_idx = gaussian_columns.index(col_name) + gauss_ssim_values.append(np.mean(gaussian_data[:, gauss_idx])) + + # Average across all scalars - deconv should be better overall + avg_deconv_mse = np.mean(deconv_mse_values) + avg_gauss_mse = np.mean(gauss_mse_values) + avg_deconv_ssim = np.mean(deconv_ssim_values) + avg_gauss_ssim = np.mean(gauss_ssim_values) + + assert avg_deconv_mse < avg_gauss_mse, \ + (f"Avg deconv MSE ({avg_deconv_mse:.6f}) should be lower than " + f"avg Gaussian MSE ({avg_gauss_mse:.6f})") + assert avg_deconv_ssim > avg_gauss_ssim, \ + (f"Avg deconv SSIM ({avg_deconv_ssim:.6f}) should be higher than " + f"avg Gaussian SSIM ({avg_gauss_ssim:.6f})") diff --git a/tests/python/external_operator_test.py b/tests/python/external_operator_test.py new file mode 100644 index 000000000..6dde9e8e7 --- /dev/null +++ b/tests/python/external_operator_test.py @@ -0,0 +1,92 @@ +import json +import sys + +import numpy as np +import pytest + + +def test_transform_method_wrapper_internal(): + """Verify transform_method_wrapper calls internal transform when no + external execution mode is specified.""" + from tomviz._internal import transform_method_wrapper + + called = {'count': 0} + + def dummy_transform(*args, **kwargs): + called['count'] += 1 + return True + + # Set apply_to_each_array to false so no dataset-expecting wrapper + # is added around our dummy function + operator_dict = { + 'label': 'Test', + 'script': '', + 'description': json.dumps({ + 'name': 'Test', + 'apply_to_each_array': False, + }), + } + operator_serialized = json.dumps(operator_dict) + + result = transform_method_wrapper(dummy_transform, operator_serialized) + assert called['count'] == 1 + assert result is True + + +def test_transform_single_external_operator(): + """Verify that an operator can be executed in a subprocess via + transform_single_external_operator.""" + from pathlib import Path + from tomviz._internal import transform_single_external_operator + from tomviz.external_dataset import Dataset + + # Find the environment that has tomviz-pipeline installed. + # Use sys.prefix for the current environment, but also check + # the conda env path since tests may run from a different prefix. + tomviz_pipeline_env = sys.prefix + exec_path = Path(tomviz_pipeline_env) / 'bin' / 'tomviz-pipeline' + if not exec_path.exists(): + # Try the conda env path + conda_prefix = sys.environ.get('CONDA_PREFIX') + if conda_prefix: + tomviz_pipeline_env = conda_prefix + exec_path = Path(tomviz_pipeline_env) / 'bin' / 'tomviz-pipeline' + + if not exec_path.exists(): + pytest.skip('tomviz-pipeline not found') + + # Create a simple 4x4x4 dataset with all values = 5.0 + data = np.full((4, 4, 4), 5.0, dtype=np.float64) + dataset = Dataset({'scalars': data}, active='scalars') + + # A simple operator script that adds 10 to all scalars + script = ( + "def transform(dataset):\n" + " dataset.active_scalars = dataset.active_scalars + 10.0\n" + ) + + operator_dict = { + 'type': 'python', + 'label': 'AddTen', + 'script': script, + 'description': json.dumps({ + 'name': 'AddTen', + 'apply_to_each_array': False, + 'tomviz_pipeline_env': tomviz_pipeline_env, + }), + } + operator_serialized = json.dumps(operator_dict) + + # The transform_method is not actually called in the external path + # (the subprocess loads the script from the operator dict), but + # we still need to pass one. + def dummy_transform(dataset): + pass + + transform_single_external_operator( + dummy_transform, operator_serialized, dataset) + + # The function modifies the input dataset in-place with the subprocess + # results. Verify that the scalars were updated from 5.0 to 15.0. + result = dataset.active_scalars + np.testing.assert_allclose(result, 15.0) diff --git a/tests/python/normalize_test.py b/tests/python/normalize_test.py new file mode 100644 index 000000000..5b0bc379c --- /dev/null +++ b/tests/python/normalize_test.py @@ -0,0 +1,89 @@ +import numpy as np +import pytest + +from utils import load_operator_module + +from tomviz.external_dataset import Dataset + + +def _make_dataset(data): + """Create a Dataset wrapping the given numpy array.""" + arrays = {'scalars': np.array(data, dtype=np.float32)} + ds = Dataset(arrays) + ds.spacing = [1.0, 1.0, 1.0] + return ds + + +def test_normalize_basic(): + """Test that normalization equalizes total intensity across slices.""" + module = load_operator_module('NormalizeTiltSeries') + + # Create tilt series with varying intensities per slice (axis 2) + shape = (10, 10, 5) + data = np.ones(shape, dtype=np.float32) + for i in range(shape[2]): + data[:, :, i] *= (i + 1) # Slice intensities: 1, 2, 3, 4, 5 + + dataset = _make_dataset(data) + module.transform(dataset) + + result = dataset.active_scalars + assert result.shape == shape + + # After normalization, all slices should have approximately equal + # total intensity + slice_sums = [np.sum(result[:, :, i]) for i in range(shape[2])] + mean_sum = np.mean(slice_sums) + for i, s in enumerate(slice_sums): + assert abs(s - mean_sum) / mean_sum < 0.01, \ + f"Slice {i} sum {s} differs from mean {mean_sum}" + + +def test_normalize_preserves_shape(): + """Test that output shape matches input shape.""" + module = load_operator_module('NormalizeTiltSeries') + + shape = (8, 12, 6) + data = np.random.rand(*shape).astype(np.float32) + 0.1 + dataset = _make_dataset(data) + module.transform(dataset) + + assert dataset.active_scalars.shape == shape + + +def test_normalize_zero_slice_no_error(): + """Test that normalization handles all-zero slices without crashing + (the divide-by-zero fix).""" + module = load_operator_module('NormalizeTiltSeries') + + shape = (10, 10, 5) + data = np.ones(shape, dtype=np.float32) + data[:, :, 2] = 0.0 # All-zero slice + + dataset = _make_dataset(data) + + # Should not raise an exception + module.transform(dataset) + + result = dataset.active_scalars + assert result.shape == shape + + # The zero slice should remain zero (no NaN/Inf from division) + assert np.allclose(result[:, :, 2], 0.0) + assert not np.any(np.isnan(result)) + assert not np.any(np.isinf(result)) + + +def test_normalize_already_uniform(): + """Test that uniform data remains approximately unchanged.""" + module = load_operator_module('NormalizeTiltSeries') + + shape = (10, 10, 5) + data = np.ones(shape, dtype=np.float32) * 3.0 + dataset = _make_dataset(data) + + original = dataset.active_scalars.copy() + module.transform(dataset) + + result = dataset.active_scalars + assert np.allclose(result, original, rtol=1e-5) diff --git a/tests/python/psd_fsc_test.py b/tests/python/psd_fsc_test.py new file mode 100644 index 000000000..e6a7a4944 --- /dev/null +++ b/tests/python/psd_fsc_test.py @@ -0,0 +1,172 @@ +import numpy as np +import pytest + +from utils import load_operator_module + + +# --- PSD tests --- + +def _load_psd_functions(): + module = load_operator_module('PowerSpectrumDensity') + return module.pad_to_cubic, module.psd3D + + +def test_pad_to_cubic(): + """Verify non-cubic array is padded to cubic shape.""" + pad_to_cubic, _ = _load_psd_functions() + + arr = np.random.rand(8, 12, 16) + result = pad_to_cubic(arr) + + assert result.shape == (16, 16, 16) + # Original data should be preserved in the unpadded region + assert np.allclose(result[:8, :12, :16], arr) + + +def test_pad_to_cubic_already_cubic(): + """Cubic array should be unchanged in shape.""" + pad_to_cubic, _ = _load_psd_functions() + + arr = np.random.rand(10, 10, 10) + result = pad_to_cubic(arr) + + assert result.shape == (10, 10, 10) + assert np.allclose(result, arr) + + +def test_psd3d_output_shape(): + """Verify PSD output has expected length.""" + _, psd3D = _load_psd_functions() + + arr = np.random.rand(16, 16, 16) + x, bins = psd3D(arr, pixel_size=1.0) + + # kbins = np.arange(0.5, npix//2+1, 1.) gives npix//2 bins, + # kvals has the same count as Abins + assert len(x) == 16 // 2 + assert len(bins) == len(x) + + +def test_psd3d_positive_values(): + """PSD values should be non-negative.""" + _, psd3D = _load_psd_functions() + + arr = np.random.rand(16, 16, 16) + x, bins = psd3D(arr, pixel_size=1.0) + + assert np.all(bins >= 0), "PSD values should be non-negative" + assert np.all(x >= 0), "Frequency values should be non-negative" + + +def test_psd3d_known_signal(): + """Create array with known frequency, verify PSD peak location.""" + _, psd3D = _load_psd_functions() + + n = 32 + # Create a signal with a known frequency + x_coord = np.arange(n) + freq = 4 # cycles across the array + signal_1d = np.sin(2 * np.pi * freq * x_coord / n) + arr = np.zeros((n, n, n)) + for i in range(n): + for j in range(n): + arr[:, i, j] = signal_1d + + x, bins = psd3D(arr, pixel_size=1.0) + + # The peak should be near the known frequency + peak_idx = np.argmax(bins) + # The frequency bins are kvals/(n*pixel_size), and our signal is at freq/n + # So the peak should be near index freq-1 (since kvals starts from 1) + assert abs(peak_idx - (freq - 1)) <= 2, \ + f"PSD peak at index {peak_idx}, expected near {freq - 1}" + + +# --- FSC tests --- + +def _load_fsc_functions(): + module = load_operator_module('FourierShellCorrelation') + return module.cal_dist, module.cal_fsc, module.checkerboard_split + + +def test_cal_dist_2d(): + """Verify 2D distance map shape and center value.""" + cal_dist, _, _ = _load_fsc_functions() + + shape = (16, 16) + dist_map = cal_dist(shape) + + assert dist_map.shape == shape + # Center value should be 0 (or close to it) + cx, cy = shape[0] // 2, shape[1] // 2 + assert dist_map[cx, cy] == 0.0 + + +def test_cal_dist_3d(): + """Verify 3D distance map shape and center value.""" + cal_dist, _, _ = _load_fsc_functions() + + shape = (8, 8, 8) + dist_map = cal_dist(shape) + + assert dist_map.shape == shape + cx, cy, cz = shape[0] // 2, shape[1] // 2, shape[2] // 2 + assert dist_map[cx, cy, cz] == 0.0 + + +def test_checkerboard_split_shape(): + """Verify checkerboard split output halves have correct shape.""" + _, _, checkerboard_split = _load_fsc_functions() + + arr = np.random.rand(16, 16, 16) + img1, img2 = checkerboard_split(arr) + + # Each half should have half the size in each dimension + assert img1.shape == (8, 8, 8) + assert img2.shape == (8, 8, 8) + + +def test_cal_fsc_identical_images(): + """FSC of an image with itself should be ~1.0 everywhere.""" + _, cal_fsc, _ = _load_fsc_functions() + + arr = np.random.rand(16, 16) + pixel_size = 1.0 + + x, fsc, noise_onebit, noise_halfbit = cal_fsc(arr, arr, pixel_size) + + # FSC of identical images should be 1.0 (or very close) + assert np.all(fsc > 0.99), \ + f"FSC of identical images should be ~1.0, got min={np.min(fsc)}" + + +def test_cal_fsc_output_lengths(): + """Verify x, fsc, noise_onebit, noise_halfbit have same length.""" + _, cal_fsc, _ = _load_fsc_functions() + + arr1 = np.random.rand(16, 16) + arr2 = np.random.rand(16, 16) + pixel_size = 1.0 + + x, fsc, noise_onebit, noise_halfbit = cal_fsc(arr1, arr2, pixel_size) + + assert len(x) == len(fsc) + assert len(x) == len(noise_onebit) + assert len(x) == len(noise_halfbit) + + +def test_cal_fsc_noise_curves_reasonable(): + """Noise curves should be between 0 and 1.""" + _, cal_fsc, _ = _load_fsc_functions() + + arr1 = np.random.rand(16, 16) + arr2 = np.random.rand(16, 16) + pixel_size = 1.0 + + x, fsc, noise_onebit, noise_halfbit = cal_fsc(arr1, arr2, pixel_size) + + # Skip first element which may be edge case + assert np.all(noise_onebit[1:] >= 0), "One-bit noise should be >= 0" + assert np.all(noise_onebit[1:] <= 1), "One-bit noise should be <= 1" + assert np.all(noise_halfbit[1:] >= 0), "Half-bit noise should be >= 0" + assert np.all(noise_halfbit[1:] <= 1), "Half-bit noise should be <= 1" diff --git a/tests/python/random_particles_test.py b/tests/python/random_particles_test.py new file mode 100644 index 000000000..6e6862577 --- /dev/null +++ b/tests/python/random_particles_test.py @@ -0,0 +1,61 @@ +import warnings + +import numpy as np +import pytest + +from utils import load_operator_module + + +def test_generate_dataset_basic(): + """Test that generate_dataset produces non-zero output.""" + module = load_operator_module('RandomParticles') + + array = np.zeros((16, 16, 16), dtype=np.float64) + module.generate_dataset(array) + + assert not np.allclose(array, 0), "Output should not be all zeros" + assert np.amax(array) == pytest.approx(1.0, abs=0.01), \ + "Max value should be approximately 1.0 after normalization" + + +def test_generate_dataset_shape_preserved(): + """Test that output shape matches input shape.""" + module = load_operator_module('RandomParticles') + + shape = (20, 24, 18) + array = np.zeros(shape, dtype=np.float64) + module.generate_dataset(array) + + assert array.shape == shape + + +def test_generate_dataset_sparsity(): + """Test that sparsity parameter controls fraction of zero voxels.""" + module = load_operator_module('RandomParticles') + + array = np.zeros((32, 32, 32), dtype=np.float64) + sparsity = 0.1 + module.generate_dataset(array, sparsity=sparsity) + + zero_fraction = np.count_nonzero(array == 0) / array.size + # With sparsity=0.1, approximately 90% of voxels should be zero + assert zero_fraction > 0.8, \ + f"Expected ~90% zeros with sparsity=0.1, got {zero_fraction*100:.1f}%" + + +def test_generate_dataset_uses_int64(): + """Test that no numpy deprecation warnings are raised (the fix changed + np.int to np.int64).""" + module = load_operator_module('RandomParticles') + + array = np.zeros((16, 16, 16), dtype=np.float64) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always", DeprecationWarning) + module.generate_dataset(array) + + numpy_deprecation_warnings = [ + x for x in w + if issubclass(x.category, DeprecationWarning) and 'numpy' in str(x.message).lower() + ] + assert len(numpy_deprecation_warnings) == 0, \ + f"Got numpy deprecation warnings: {[str(x.message) for x in numpy_deprecation_warnings]}" diff --git a/tests/python/remove_arrays_test.py b/tests/python/remove_arrays_test.py new file mode 100644 index 000000000..8bcd1066b --- /dev/null +++ b/tests/python/remove_arrays_test.py @@ -0,0 +1,85 @@ +import numpy as np + +from utils import load_operator_module + +from tomviz.external_dataset import Dataset + + +def _make_dataset(num_arrays=3, shape=(10, 10, 5)): + """Create a dataset with multiple named scalar arrays.""" + arrays = {} + for i in range(num_arrays): + arr = np.random.RandomState(i).rand(*shape).astype(np.float32) + arrays[f'array_{i}'] = arr + + dataset = Dataset(arrays) + dataset.spacing = [1.0, 1.0, 1.0] + dataset.tilt_angles = np.linspace(-70, 70, shape[2]) + return dataset + + +def test_remove_arrays_keep_one(): + """Test keeping a single array removes all others.""" + module = load_operator_module('RemoveArrays') + + dataset = _make_dataset(num_arrays=3) + assert len(dataset.scalars_names) == 3 + + module.transform(dataset, selected_scalars=('array_1',)) + + assert dataset.scalars_names == ['array_1'] + assert len(dataset.arrays) == 1 + + +def test_remove_arrays_keep_multiple(): + """Test keeping multiple arrays removes only unselected ones.""" + module = load_operator_module('RemoveArrays') + + dataset = _make_dataset(num_arrays=4) + assert len(dataset.scalars_names) == 4 + + module.transform(dataset, selected_scalars=('array_0', 'array_2')) + + assert sorted(dataset.scalars_names) == ['array_0', 'array_2'] + assert len(dataset.arrays) == 2 + + +def test_remove_arrays_keep_all(): + """Test that selecting all arrays removes nothing.""" + module = load_operator_module('RemoveArrays') + + dataset = _make_dataset(num_arrays=3) + originals = {name: arr.copy() for name, arr in dataset.arrays.items()} + + module.transform( + dataset, + selected_scalars=('array_0', 'array_1', 'array_2'), + ) + + assert sorted(dataset.scalars_names) == sorted(originals.keys()) + for name in dataset.scalars_names: + assert np.array_equal(dataset.arrays[name], originals[name]) + + +def test_remove_arrays_data_preserved(): + """Test that kept arrays retain their original data.""" + module = load_operator_module('RemoveArrays') + + dataset = _make_dataset(num_arrays=3) + kept_original = dataset.arrays['array_1'].copy() + + module.transform(dataset, selected_scalars=('array_1',)) + + assert np.array_equal(dataset.arrays['array_1'], kept_original) + + +def test_remove_arrays_default_keeps_active(): + """Test that passing None keeps only the active scalar array.""" + module = load_operator_module('RemoveArrays') + + dataset = _make_dataset(num_arrays=3) + active_name = dataset.active_name + + module.transform(dataset, selected_scalars=None) + + assert dataset.scalars_names == [active_name] diff --git a/tests/python/shift_rotation_center_test.py b/tests/python/shift_rotation_center_test.py new file mode 100644 index 000000000..c7993f866 --- /dev/null +++ b/tests/python/shift_rotation_center_test.py @@ -0,0 +1,320 @@ +import numpy as np +import os + +import pytest + +from utils import load_operator_module + +from tomviz.external_dataset import Dataset + +try: + import tomopy + HAS_TOMOPY = True +except ImportError: + HAS_TOMOPY = False + + +def _make_synthetic_dataset(shape=(20, 64, 30), num_arrays=1, spacing=None): + """Create a synthetic dataset with a bright column at the center of axis 1. + + This makes shift effects easy to detect: the column moves along axis 1. + """ + arrays = {} + for i in range(num_arrays): + arr = np.zeros(shape, dtype=np.float32) + mid_y = shape[1] // 2 + # Place a bright column at the center of Y + arr[:, mid_y - 1:mid_y + 2, :] = 1.0 + i + name = f'array_{i}' if num_arrays > 1 else 'intensity' + arrays[name] = arr + + dataset = Dataset(arrays) + if spacing is not None: + dataset.spacing = spacing + else: + dataset.spacing = [1.0, 1.0, 1.0] + dataset.tilt_angles = np.linspace(-70, 70, shape[2]) + return dataset + + +def test_shift_rotation_center_manual(): + """Test that a manual pixel shift moves data along axis 1.""" + module = load_operator_module('ShiftRotationCenter_tomopy') + + dataset = _make_synthetic_dataset() + original = dataset.active_scalars.copy() + + # A positive rotation_center means shift left (negative direction) + shift_px = 5 + module.transform(dataset, rotation_center=shift_px) + + result = dataset.active_scalars + + # Shape must be preserved + assert result.shape == original.shape + + # The data should have changed + assert not np.allclose(result, original) + + # The bright column was at center. After shifting by -5 pixels along + # axis 1, the column should move. Check that the peak position shifted. + orig_peak_y = np.argmax(original[0, :, 0]) + new_peak_y = np.argmax(result[0, :, 0]) + assert new_peak_y < orig_peak_y, "Peak should have moved left (negative)" + assert abs((orig_peak_y - new_peak_y) - shift_px) <= 1 + + +def test_shift_rotation_center_zero(): + """Test that a zero shift leaves the data unchanged.""" + module = load_operator_module('ShiftRotationCenter_tomopy') + + dataset = _make_synthetic_dataset() + original = dataset.active_scalars.copy() + + module.transform(dataset, rotation_center=0) + + assert np.allclose(dataset.active_scalars, original) + + +def test_shift_rotation_center_multi_array(): + """Test that the shift is applied to all scalar arrays.""" + module = load_operator_module('ShiftRotationCenter_tomopy') + + dataset = _make_synthetic_dataset(num_arrays=3) + originals = {name: arr.copy() for name, arr in dataset.arrays.items()} + + shift_px = 3 + module.transform(dataset, rotation_center=shift_px) + + # All arrays should have been modified + for name in dataset.scalars_names: + assert not np.allclose(dataset.arrays[name], originals[name]), \ + f"Array '{name}' was not shifted" + + # All arrays should have the same shift pattern (peak moved by same amount) + shifts = [] + for name in dataset.scalars_names: + orig_peak = np.argmax(originals[name][0, :, 0]) + new_peak = np.argmax(dataset.arrays[name][0, :, 0]) + shifts.append(orig_peak - new_peak) + + assert all(s == shifts[0] for s in shifts), \ + "All arrays should be shifted by the same amount" + + +def test_shift_rotation_center_npz_save(tmp_path): + """Test that NPZ file is saved with correct keys and values.""" + module = load_operator_module('ShiftRotationCenter_tomopy') + + dataset = _make_synthetic_dataset(spacing=[1.0, 2.5, 1.0]) + save_path = str(tmp_path / 'transforms.npz') + + shift_px = 7 + module.transform( + dataset, + rotation_center=shift_px, + transform_source='manual', + transforms_save_file=save_path, + ) + + assert os.path.exists(save_path) + + with np.load(save_path) as f: + assert 'rotation_center' in f + assert 'spacing' in f + assert float(f['rotation_center']) == shift_px + assert np.allclose(f['spacing'], [1.0, 2.5, 1.0]) + + +def test_shift_rotation_center_npz_load(tmp_path): + """Test loading a shift from an NPZ file, including spacing scaling.""" + module = load_operator_module('ShiftRotationCenter_tomopy') + + # Save with spacing [1.0, 2.0, 1.0] and a 10-pixel shift + save_path = str(tmp_path / 'transforms.npz') + np.savez(save_path, rotation_center=10, spacing=[1.0, 2.0, 1.0]) + + # Load onto a dataset with spacing [1.0, 1.0, 1.0] + # Expected scaled shift: 10 * 2.0 / 1.0 = 20 pixels + dataset = _make_synthetic_dataset( + shape=(20, 80, 30), + spacing=[1.0, 1.0, 1.0], + ) + original = dataset.active_scalars.copy() + + module.transform( + dataset, + transform_source='from_file', + transform_file=save_path, + ) + + result = dataset.active_scalars + assert not np.allclose(result, original) + + # Verify the shift magnitude is approximately 20 pixels + orig_peak = np.argmax(original[0, :, 0]) + new_peak = np.argmax(result[0, :, 0]) + actual_shift = orig_peak - new_peak + assert abs(actual_shift - 20) <= 1, \ + f"Expected ~20px shift from spacing scaling, got {actual_shift}" + + +def test_shift_rotation_center_npz_roundtrip(tmp_path): + """Test save then load produces equivalent results.""" + module = load_operator_module('ShiftRotationCenter_tomopy') + + spacing = [1.0, 1.5, 1.0] + shift_px = 4 + + # Apply manually and save + dataset_manual = _make_synthetic_dataset(spacing=spacing) + save_path = str(tmp_path / 'transforms.npz') + module.transform( + dataset_manual, + rotation_center=shift_px, + transform_source='manual', + transforms_save_file=save_path, + ) + + # Load from file on a fresh dataset with same spacing + dataset_loaded = _make_synthetic_dataset(spacing=spacing) + module.transform( + dataset_loaded, + transform_source='from_file', + transform_file=save_path, + ) + + assert np.allclose( + dataset_manual.active_scalars, + dataset_loaded.active_scalars, + ), "Round-trip save/load should produce identical results" + + +def test_shift_no_save_when_loading(tmp_path): + """Test that loading from file does not write a save file.""" + module = load_operator_module('ShiftRotationCenter_tomopy') + + # Create an NPZ to load from + load_path = str(tmp_path / 'input.npz') + np.savez(load_path, rotation_center=3, spacing=[1.0, 1.0, 1.0]) + + # Provide a save path, but since transform_source='from_file', + # the save should NOT happen + save_path = str(tmp_path / 'should_not_exist.npz') + dataset = _make_synthetic_dataset() + module.transform( + dataset, + transform_source='from_file', + transform_file=load_path, + transforms_save_file=save_path, + ) + + assert not os.path.exists(save_path) + + +# --- QiA and QN quality metric tests --- + +def test_qia_metric(): + """Test that Qia returns list of scores and best index.""" + module = load_operator_module('ShiftRotationCenter_tomopy') + + # Create synthetic reconstruction images + # One with all positive values, one with mixed values + images = np.zeros((3, 16, 16), dtype=np.float32) + images[0] = np.abs(np.random.rand(16, 16)) + 0.1 # All positive + images[1] = np.random.rand(16, 16) - 0.5 # Mixed + images[2] = np.abs(np.random.rand(16, 16)) + 0.5 # Larger positive + + qlist, best_idx = module.Qia(images) + + assert isinstance(qlist, list) + assert len(qlist) == 3 + assert isinstance(best_idx, int) + assert 0 <= best_idx < 3 + + +def test_qn_metric(): + """Test that Qn returns list of scores and best index.""" + module = load_operator_module('ShiftRotationCenter_tomopy') + + images = np.zeros((3, 16, 16), dtype=np.float32) + images[0] = np.random.rand(16, 16) - 0.3 # Some negative values + images[1] = np.random.rand(16, 16) - 0.5 # More negative values + images[2] = np.abs(np.random.rand(16, 16)) # All positive + + qlist, best_idx = module.Qn(images) + + assert isinstance(qlist, list) + assert len(qlist) == 3 + assert isinstance(best_idx, int) + assert 0 <= best_idx < 3 + + +def test_qia_uniform_gives_equal_scores(): + """Uniform images should have identical QiA scores.""" + module = load_operator_module('ShiftRotationCenter_tomopy') + + images = np.ones((5, 16, 16), dtype=np.float32) * 3.0 + + qlist, _ = module.Qia(images) + + # All scores should be equal for identical images + for i in range(1, len(qlist)): + assert abs(qlist[i] - qlist[0]) < 1e-6, \ + f"Score {i} ({qlist[i]}) differs from score 0 ({qlist[0]})" + + +def test_qn_all_positive_gives_zeros(): + """All-positive reconstruction should give near-zero QN values.""" + module = load_operator_module('ShiftRotationCenter_tomopy') + + images = np.abs(np.random.rand(5, 16, 16).astype(np.float32)) + 0.1 + + qlist, _ = module.Qn(images) + + # With all positive values, the negativity metric should be ~0 + for val in qlist: + assert abs(val) < 1e-6, \ + f"QN value {val} should be near zero for all-positive images" + + +@pytest.mark.skipif(not HAS_TOMOPY, reason="tomopy not installed") +def test_rotations_basic(): + """Test test_rotations returns correct structure.""" + module = load_operator_module('ShiftRotationCenter_tomopy') + + # Create a synthetic tilt series + num_angles = 20 + detector_width = 32 + num_slices = 1 + + arrays = {'intensity': np.random.rand( + num_angles, detector_width, num_slices + ).astype(np.float32) + 0.1} + + dataset = Dataset(arrays) + dataset.spacing = [1.0, 1.0, 1.0] + dataset.tilt_angles = np.linspace(-70, 70, num_angles) + dataset.tilt_axis = 0 + + steps = 5 + result = module.test_rotations( + dataset, + start=-3, + stop=3, + steps=steps, + sli=0, + algorithm='gridrec', + ) + + assert 'images' in result + assert 'centers' in result + assert 'qia' in result + assert 'qn' in result + + # Verify centers length matches steps + assert len(result['centers']) == steps + + # Verify QiA and QN are lists with correct length + assert len(result['qia']) == steps + assert len(result['qn']) == steps diff --git a/tests/python/tomopy_recon_test.py b/tests/python/tomopy_recon_test.py new file mode 100644 index 000000000..631f58900 --- /dev/null +++ b/tests/python/tomopy_recon_test.py @@ -0,0 +1,88 @@ +import numpy as np +import pytest + +from utils import load_operator_module + +try: + import tomopy + HAS_TOMOPY = True +except ImportError: + HAS_TOMOPY = False + + +@pytest.mark.skipif(not HAS_TOMOPY, reason="tomopy not installed") +def test_tomopy_reconstruction(): + """Test basic tomopy reconstruction via rotcen_test.""" + module = load_operator_module('ShiftRotationCenter_tomopy') + + # Create a simple synthetic sinogram + num_angles = 30 + detector_width = 32 + theta = np.linspace(0, np.pi, num_angles, endpoint=False) + + # Simple phantom: a circle + img_tomo = np.zeros((num_angles, 1, detector_width), dtype=np.float32) + for i in range(num_angles): + center = detector_width // 2 + for j in range(detector_width): + if abs(j - center) < detector_width // 4: + img_tomo[i, 0, j] = 1.0 + + recon_input = { + 'img_tomo': img_tomo, + 'angle': np.degrees(theta), + } + + images, centers = module.rotcen_test( + f=recon_input, + start=-3, + stop=3, + steps=5, + sli=0, + algorithm='gridrec', + ) + + # Verify output shape: steps images, each detector_width x detector_width + assert images.shape[0] == 5 + assert images.shape[1] == detector_width + assert images.shape[2] == detector_width + + # Verify reconstruction is not all zeros + assert not np.allclose(images, 0) + + # Verify centers length matches steps + assert len(centers) == 5 + + +@pytest.mark.skipif(not HAS_TOMOPY, reason="tomopy not installed") +def test_tomopy_reconstruction_different_algorithms(): + """Test reconstruction with gridrec and fbp algorithms.""" + module = load_operator_module('ShiftRotationCenter_tomopy') + + num_angles = 30 + detector_width = 32 + theta = np.linspace(0, np.pi, num_angles, endpoint=False) + + img_tomo = np.zeros((num_angles, 1, detector_width), dtype=np.float32) + for i in range(num_angles): + center = detector_width // 2 + for j in range(detector_width): + if abs(j - center) < detector_width // 4: + img_tomo[i, 0, j] = 1.0 + + recon_input = { + 'img_tomo': img_tomo, + 'angle': np.degrees(theta), + } + + for algorithm in ('gridrec', 'fbp'): + images, centers = module.rotcen_test( + f=recon_input, + start=-2, + stop=2, + steps=3, + sli=0, + algorithm=algorithm, + ) + assert images.shape[0] == 3, f"Failed for algorithm {algorithm}" + assert not np.allclose(images, 0), f"All zeros for algorithm {algorithm}" diff --git a/tests/python/utils.py b/tests/python/utils.py index b7ba753da..880d5e6e2 100644 --- a/tests/python/utils.py +++ b/tests/python/utils.py @@ -1,22 +1,42 @@ +from pathlib import Path +from types import ModuleType +from typing import Callable import importlib.util import inspect -from pathlib import Path import shutil -from types import ModuleType import urllib.request import zipfile from tomviz.executor import OperatorWrapper from tomviz.operators import Operator +from tomviz._internal import add_transform_decorators OPERATOR_PATH = Path(__file__).parent.parent.parent / 'tomviz/python' +def add_decorators(func: Callable, operator_name: str) -> Callable: + # Automatically add the decorators which would normally be + # automatically added by Tomviz. + json_path = OPERATOR_PATH / f'{operator_name}.json' + op_dict = {} + if json_path.exists(): + with open(json_path, 'rb') as rf: + op_dict['description'] = rf.read() + + func = add_transform_decorators(func, op_dict) + return func + + def load_operator_module(operator_name: str) -> ModuleType: module_path = OPERATOR_PATH / f'{operator_name}.py' spec = importlib.util.spec_from_file_location(operator_name, module_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) + + if hasattr(module, 'transform'): + # Add the decorators + module.transform = add_decorators(module.transform, operator_name) + return module @@ -24,9 +44,15 @@ def load_operator_class(operator_module: ModuleType) -> Operator | None: # Locate the operator class for v in operator_module.__dict__.values(): if inspect.isclass(v) and issubclass(v, Operator): + if hasattr(v, 'transform'): + # Decorate at the class level + name = operator_module.__name__ + v.transform = add_decorators(v.transform, name) + # Instantiate and set up wrapper operator = v() operator._operator_wrapper = OperatorWrapper() + return operator diff --git a/tomviz/ActiveObjects.cxx b/tomviz/ActiveObjects.cxx index ad2f4ee41..c309bcdd7 100644 --- a/tomviz/ActiveObjects.cxx +++ b/tomviz/ActiveObjects.cxx @@ -26,15 +26,14 @@ namespace tomviz { ActiveObjects::ActiveObjects() : QObject() { - connect(&pqActiveObjects::instance(), SIGNAL(viewChanged(pqView*)), - SLOT(viewChanged(pqView*))); - connect(&ModuleManager::instance(), SIGNAL(dataSourceRemoved(DataSource*)), - SLOT(dataSourceRemoved(DataSource*))); - connect(&ModuleManager::instance(), - SIGNAL(moleculeSourceRemoved(MoleculeSource*)), - SLOT(moleculeSourceRemoved(MoleculeSource*))); - connect(&ModuleManager::instance(), SIGNAL(moduleRemoved(Module*)), - SLOT(moduleRemoved(Module*))); + connect(&pqActiveObjects::instance(), &pqActiveObjects::viewChanged, this, + QOverload::of(&ActiveObjects::viewChanged)); + connect(&ModuleManager::instance(), &ModuleManager::dataSourceRemoved, this, + &ActiveObjects::dataSourceRemoved); + connect(&ModuleManager::instance(), &ModuleManager::moleculeSourceRemoved, + this, &ActiveObjects::moleculeSourceRemoved); + connect(&ModuleManager::instance(), &ModuleManager::moduleRemoved, this, + &ActiveObjects::moduleRemoved); } ActiveObjects::~ActiveObjects() = default; @@ -99,11 +98,14 @@ void ActiveObjects::setActiveDataSource(DataSource* source) } if (m_activeDataSource != source) { if (m_activeDataSource) { - disconnect(m_activeDataSource, SIGNAL(dataChanged()), this, - SLOT(dataSourceChanged())); + disconnect(m_activeDataSource, &DataSource::dataChanged, this, + static_cast( + &ActiveObjects::dataSourceChanged)); } if (source) { - connect(source, SIGNAL(dataChanged()), this, SLOT(dataSourceChanged())); + connect(source, &DataSource::dataChanged, this, + static_cast( + &ActiveObjects::dataSourceChanged)); m_activeDataSourceType = source->type(); } m_activeDataSource = source; diff --git a/tomviz/ActiveObjects.h b/tomviz/ActiveObjects.h index 56167ad4f..3e756679d 100644 --- a/tomviz/ActiveObjects.h +++ b/tomviz/ActiveObjects.h @@ -49,6 +49,9 @@ class ActiveObjects : public QObject /// Returns the selected data source, nullptr if no data source is selected. DataSource* selectedDataSource() const { return m_selectedDataSource; } + /// Returns the active operator. + Operator* activeOperator() const { return m_activeOperator; } + /// Returns the active data source. MoleculeSource* activeMoleculeSource() const { diff --git a/tomviz/AddAlignReaction.cxx b/tomviz/AddAlignReaction.cxx index 222a43ae0..95933803e 100644 --- a/tomviz/AddAlignReaction.cxx +++ b/tomviz/AddAlignReaction.cxx @@ -33,6 +33,6 @@ void AddAlignReaction::align(DataSource* source) dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->setWindowTitle("Manual Image Alignment"); dialog->show(); - connect(Op, SIGNAL(destroyed()), dialog, SLOT(reject())); + connect(Op, &QObject::destroyed, dialog, &QDialog::reject); } } // namespace tomviz diff --git a/tomviz/AddExpressionReaction.cxx b/tomviz/AddExpressionReaction.cxx index 89e06ff53..b7179ae01 100644 --- a/tomviz/AddExpressionReaction.cxx +++ b/tomviz/AddExpressionReaction.cxx @@ -36,7 +36,7 @@ OperatorPython* AddExpressionReaction::addExpression(DataSource* source) new EditOperatorDialog(opPython, source, true, tomviz::mainWidget()); dialog->setAttribute(Qt::WA_DeleteOnClose, true); dialog->show(); - connect(opPython, SIGNAL(destroyed()), dialog, SLOT(reject())); + connect(opPython, &QObject::destroyed, dialog, &QDialog::reject); return nullptr; } diff --git a/tomviz/AddPythonTransformReaction.cxx b/tomviz/AddPythonTransformReaction.cxx index 4e8fb887e..24f8f7f79 100644 --- a/tomviz/AddPythonTransformReaction.cxx +++ b/tomviz/AddPythonTransformReaction.cxx @@ -36,7 +36,6 @@ #include #include -#include namespace tomviz { @@ -47,8 +46,10 @@ AddPythonTransformReaction::AddPythonTransformReaction( interactive(false), requiresTiltSeries(rts), requiresVolume(rv), requiresFib(rf) { - connect(&ActiveObjects::instance(), SIGNAL(dataSourceChanged(DataSource*)), - SLOT(updateEnableState())); + connect(&ActiveObjects::instance(), + static_cast( + &ActiveObjects::dataSourceChanged), + this, &AddPythonTransformReaction::updateEnableState); connect(&PipelineManager::instance(), &PipelineManager::executionModeUpdated, this, &AddPythonTransformReaction::updateEnableState); @@ -153,6 +154,9 @@ OperatorPython* AddPythonTransformReaction::addExpression(DataSource* source) } else if (scriptLabel == "Shift Volume") { auto t = source->producer(); auto data = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); + if (!data) { + return nullptr; + } int* extent = data->GetExtent(); QDialog dialog(tomviz::mainWidget()); @@ -176,8 +180,8 @@ OperatorPython* AddPythonTransformReaction::addExpression(DataSource* source) QVBoxLayout* v = new QVBoxLayout; QDialogButtonBox* buttons = new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel, Qt::Horizontal, &dialog); - connect(buttons, SIGNAL(accepted()), &dialog, SLOT(accept())); - connect(buttons, SIGNAL(rejected()), &dialog, SLOT(reject())); + connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); v->addLayout(layout); v->addWidget(buttons); dialog.setLayout(v); @@ -211,8 +215,8 @@ OperatorPython* AddPythonTransformReaction::addExpression(DataSource* source) QVBoxLayout* v = new QVBoxLayout; QDialogButtonBox* buttons = new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel, Qt::Horizontal, &dialog); - connect(buttons, SIGNAL(accepted()), &dialog, SLOT(accept())); - connect(buttons, SIGNAL(rejected()), &dialog, SLOT(reject())); + connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); v->addLayout(layout); v->addWidget(buttons); dialog.setLayout(v); @@ -230,6 +234,9 @@ OperatorPython* AddPythonTransformReaction::addExpression(DataSource* source) } else if (scriptLabel == "Crop") { auto t = source->producer(); auto data = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); + if (!data) { + return nullptr; + } int* extent = data->GetExtent(); QDialog dialog(tomviz::mainWidget()); @@ -268,8 +275,8 @@ OperatorPython* AddPythonTransformReaction::addExpression(DataSource* source) QVBoxLayout* v = new QVBoxLayout; QDialogButtonBox* buttons = new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel, Qt::Horizontal, &dialog); - connect(buttons, SIGNAL(accepted()), &dialog, SLOT(accept())); - connect(buttons, SIGNAL(rejected()), &dialog, SLOT(reject())); + connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); v->addLayout(layout1); v->addLayout(layout2); v->addWidget(buttons); @@ -301,6 +308,9 @@ OperatorPython* AddPythonTransformReaction::addExpression(DataSource* source) int extent[6]; auto t = source->producer(); vtkImageData* image = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); + if (!image) { + return nullptr; + } image->GetOrigin(origin); image->GetSpacing(spacing); image->GetExtent(extent); @@ -312,14 +322,14 @@ OperatorPython* AddPythonTransformReaction::addExpression(DataSource* source) selectionWidget, &SelectVolumeWidget::dataMoved); QDialogButtonBox* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - connect(buttons, SIGNAL(accepted()), dialog, SLOT(accept())); - connect(buttons, SIGNAL(rejected()), dialog, SLOT(reject())); + connect(buttons, &QDialogButtonBox::accepted, dialog, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, dialog, &QDialog::reject); layout->addWidget(selectionWidget); layout->addWidget(buttons); dialog->setLayout(layout); - this->connect(dialog, SIGNAL(accepted()), - SLOT(addExpressionFromNonModalDialog())); + connect(dialog, &QDialog::accepted, this, + &AddPythonTransformReaction::addExpressionFromNonModalDialog); dialog->show(); dialog->layout()->setSizeConstraint( QLayout::SetFixedSize); // Make the UI non-resizeable @@ -335,6 +345,9 @@ OperatorPython* AddPythonTransformReaction::addExpression(DataSource* source) auto t = source->producer(); auto image = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); + if (!image) { + return nullptr; + } image->GetOrigin(origin); image->GetSpacing(spacing); image->GetExtent(extent); @@ -366,16 +379,16 @@ OperatorPython* AddPythonTransformReaction::addExpression(DataSource* source) selectionWidget, &SelectVolumeWidget::dataMoved); QDialogButtonBox* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - connect(buttons, SIGNAL(accepted()), dialog, SLOT(accept())); - connect(buttons, SIGNAL(rejected()), dialog, SLOT(reject())); + connect(buttons, &QDialogButtonBox::accepted, dialog, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, dialog, &QDialog::reject); layout->addWidget(selectionWidget); layout->addWidget(buttons); dialog->setLayout(layout); dialog->layout()->setSizeConstraint( QLayout::SetFixedSize); // Make the UI non-resizeable - this->connect(dialog, SIGNAL(accepted()), - SLOT(addExpressionFromNonModalDialog())); + connect(dialog, &QDialog::accepted, this, + &AddPythonTransformReaction::addExpressionFromNonModalDialog); dialog->show(); } else { OperatorPython* opPython = new OperatorPython(source); @@ -388,7 +401,7 @@ OperatorPython* AddPythonTransformReaction::addExpression(DataSource* source) new EditOperatorDialog(opPython, source, true, tomviz::mainWidget()); dialog->setAttribute(Qt::WA_DeleteOnClose, true); dialog->show(); - connect(opPython, SIGNAL(destroyed()), dialog, SIGNAL(reject())); + connect(opPython, &QObject::destroyed, dialog, &QDialog::reject); } else { source->addOperator(opPython); } @@ -429,13 +442,18 @@ void AddPythonTransformReaction::addExpressionFromNonModalDialog() } } - assert(volumeWidget); + if (!volumeWidget) { + return; + } int selection_extent[6]; volumeWidget->getExtentOfSelection(selection_extent); int image_extent[6]; auto t = source->producer(); auto image = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); + if (!image) { + return; + } image->GetExtent(image_extent); // The image extent is not necessarily zero-based. The numpy array is. @@ -466,13 +484,18 @@ void AddPythonTransformReaction::addExpressionFromNonModalDialog() } } - assert(volumeWidget); + if (!volumeWidget) { + return; + } int selection_extent[6]; volumeWidget->getExtentOfSelection(selection_extent); int image_extent[6]; auto t = source->producer(); auto image = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); + if (!image) { + return; + } image->GetExtent(image_extent); int indices[6]; indices[0] = selection_extent[0] - image_extent[0]; diff --git a/tomviz/AddRenderViewContextMenuBehavior.cxx b/tomviz/AddRenderViewContextMenuBehavior.cxx index d536cb26f..0485743a0 100644 --- a/tomviz/AddRenderViewContextMenuBehavior.cxx +++ b/tomviz/AddRenderViewContextMenuBehavior.cxx @@ -27,10 +27,12 @@ AddRenderViewContextMenuBehavior::AddRenderViewContextMenuBehavior(QObject* p) : QObject(p) { connect(pqApplicationCore::instance()->getServerManagerModel(), - SIGNAL(viewAdded(pqView*)), SLOT(onViewAdded(pqView*))); + &pqServerManagerModel::viewAdded, this, + &AddRenderViewContextMenuBehavior::onViewAdded); m_menu = new QMenu(); QAction* bgColorAction = m_menu->addAction("Set Background Color"); - connect(bgColorAction, SIGNAL(triggered()), SLOT(onSetBackgroundColor())); + connect(bgColorAction, &QAction::triggered, this, + &AddRenderViewContextMenuBehavior::onSetBackgroundColor); // Add separator m_menu->addSeparator(); @@ -78,7 +80,9 @@ void AddRenderViewContextMenuBehavior::onSetBackgroundColor() // Must set this to zero so that the render view will use its own // background color rather than the global palette. - vtkSMPropertyHelper(proxy, "UseColorPaletteForBackground").Set(0); + if (proxy->GetProperty("UseColorPaletteForBackground")) { + vtkSMPropertyHelper(proxy, "UseColorPaletteForBackground").Set(0); + } proxy->UpdateVTKObjects(); view->render(); diff --git a/tomviz/AddResampleReaction.cxx b/tomviz/AddResampleReaction.cxx index 0c00e2d7a..9314dda47 100644 --- a/tomviz/AddResampleReaction.cxx +++ b/tomviz/AddResampleReaction.cxx @@ -28,8 +28,10 @@ namespace tomviz { AddResampleReaction::AddResampleReaction(QAction* parentObject) : pqReaction(parentObject) { - connect(&ActiveObjects::instance(), SIGNAL(dataSourceChanged(DataSource*)), - SLOT(updateEnableState())); + connect(&ActiveObjects::instance(), + static_cast( + &ActiveObjects::dataSourceChanged), + this, &AddResampleReaction::updateEnableState); updateEnableState(); } @@ -85,8 +87,8 @@ void AddResampleReaction::resample(DataSource* source) QVBoxLayout* v = new QVBoxLayout; QDialogButtonBox* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - connect(buttons, SIGNAL(accepted()), &dialog, SLOT(accept())); - connect(buttons, SIGNAL(rejected()), &dialog, SLOT(reject())); + connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); v->addWidget(label0); v->addLayout(layout); v->addWidget(buttons); diff --git a/tomviz/AlignWidget.cxx b/tomviz/AlignWidget.cxx index f54e16712..4bca60ec1 100644 --- a/tomviz/AlignWidget.cxx +++ b/tomviz/AlignWidget.cxx @@ -402,11 +402,11 @@ AlignWidget::AlignWidget(TranslateAlignOperator* op, QHBoxLayout* viewControls = new QHBoxLayout; QPushButton* zoomToBox = new QPushButton( QIcon(":/pqWidgets/Icons/pqZoomToSelection.svg"), "Zoom to Selection"); - connect(zoomToBox, SIGNAL(pressed()), this, SLOT(zoomToSelectionStart())); + connect(zoomToBox, &QPushButton::pressed, this, &AlignWidget::zoomToSelectionStart); viewControls->addWidget(zoomToBox); QPushButton* resetCamera = new QPushButton(QIcon(":/pqWidgets/Icons/pqResetCamera.svg"), "Reset View"); - connect(resetCamera, SIGNAL(pressed()), this, SLOT(resetCamera())); + connect(resetCamera, &QPushButton::pressed, this, &AlignWidget::resetCamera); viewControls->addWidget(resetCamera); v->addLayout(viewControls); @@ -467,15 +467,15 @@ AlignWidget::AlignWidget(TranslateAlignOperator* op, m_modeSelect->addItem("Toggle Images"); m_modeSelect->addItem("Show Difference"); m_modeSelect->setCurrentIndex(0); - connect(m_modeSelect, SIGNAL(currentIndexChanged(int)), this, - SLOT(changeMode(int))); + connect(m_modeSelect, QOverload::of(&QComboBox::currentIndexChanged), this, + &AlignWidget::changeMode); optionsLayout->addWidget(m_modeSelect); QToolButton* presetSelectorButton = new QToolButton; presetSelectorButton->setIcon(QIcon(":/pqWidgets/Icons/pqFavorites.svg")); presetSelectorButton->setToolTip("Choose preset color map"); - connect(presetSelectorButton, SIGNAL(clicked()), this, - SLOT(onPresetClicked())); + connect(presetSelectorButton, &QToolButton::clicked, this, + &AlignWidget::onPresetClicked); optionsLayout->addWidget(presetSelectorButton); v->addLayout(optionsLayout); @@ -517,8 +517,8 @@ AlignWidget::AlignWidget(TranslateAlignOperator* op, m_currentSlice->setValue(startRef + 1); m_currentSlice->setRange(m_minSliceNum, m_maxSliceNum); m_currentSlice->installEventFilter(this); - connect(m_currentSlice, SIGNAL(editingFinished()), this, - SLOT(currentSliceEdited())); + connect(m_currentSlice, &SpinBox::editingFinished, this, + &AlignWidget::currentSliceEdited); grid->addWidget(m_currentSlice, gridrow, 1, 1, 1, Qt::AlignLeft); label = new QLabel("Shortcut: (A/S)"); grid->addWidget(label, gridrow, 2, 1, 2, Qt::AlignLeft); @@ -537,11 +537,11 @@ AlignWidget::AlignWidget(TranslateAlignOperator* op, m_refNum->setValue(startRef); m_refNum->setRange(m_minSliceNum, m_maxSliceNum); m_refNum->installEventFilter(this); - connect(m_refNum, SIGNAL(valueChanged(int)), SLOT(updateReference())); + connect(m_refNum, QOverload::of(&QSpinBox::valueChanged), this, [this](int) { updateReference(); }); grid->addWidget(m_refNum, gridrow, 1, 1, 1, Qt::AlignLeft); m_refNum->setEnabled(false); - connect(m_statButton, SIGNAL(toggled(bool)), m_refNum, - SLOT(setEnabled(bool))); + connect(m_statButton, &QRadioButton::toggled, m_refNum, + &QSpinBox::setEnabled); grid->addWidget(m_prevButton, gridrow, 2, 1, 1, Qt::AlignLeft); grid->addWidget(m_nextButton, gridrow, 3, 1, 1, Qt::AlignLeft); @@ -553,8 +553,8 @@ AlignWidget::AlignWidget(TranslateAlignOperator* op, m_referenceSliceMode->addButton(m_statButton); m_referenceSliceMode->setExclusive(true); m_prevButton->setChecked(true); - connect(m_referenceSliceMode, SIGNAL(buttonClicked(int)), - SLOT(updateReference())); + connect(m_referenceSliceMode, &QButtonGroup::idClicked, + this, [this](int) { updateReference(); }); ++gridrow; label = new QLabel("Frame rate (fps):"); @@ -563,7 +563,7 @@ AlignWidget::AlignWidget(TranslateAlignOperator* op, m_fpsSpin->setRange(0, 50); m_fpsSpin->setValue(5); m_fpsSpin->installEventFilter(this); - connect(m_fpsSpin, SIGNAL(valueChanged(int)), SLOT(setFrameRate(int))); + connect(m_fpsSpin, QOverload::of(&QSpinBox::valueChanged), this, &AlignWidget::setFrameRate); grid->addWidget(m_fpsSpin, gridrow, 1, 1, 1, Qt::AlignLeft); // Slice offsets @@ -576,11 +576,11 @@ AlignWidget::AlignWidget(TranslateAlignOperator* op, QHBoxLayout* buttonLayout = new QHBoxLayout; buttonLayout->addStretch(); m_startButton = new QPushButton("Start"); - connect(m_startButton, SIGNAL(clicked()), SLOT(startAlign())); + connect(m_startButton, &QPushButton::clicked, this, &AlignWidget::startAlign); buttonLayout->addWidget(m_startButton); m_startButton->setEnabled(false); m_stopButton = new QPushButton("Stop"); - connect(m_stopButton, SIGNAL(clicked()), SLOT(stopAlign())); + connect(m_stopButton, &QPushButton::clicked, this, &AlignWidget::stopAlign); buttonLayout->addWidget(m_stopButton); buttonLayout->addStretch(); v->addLayout(buttonLayout); @@ -654,9 +654,9 @@ AlignWidget::AlignWidget(TranslateAlignOperator* op, .arg(m_offsets[m_currentSlice->value()][0]) .arg(m_offsets[m_currentSlice->value()][1])); - connect(m_timer, SIGNAL(timeout()), SLOT(onTimeout())); - connect(m_offsetTable, SIGNAL(cellChanged(int, int)), - SLOT(sliceOffsetEdited(int, int))); + connect(m_timer, &QTimer::timeout, this, &AlignWidget::onTimeout); + connect(m_offsetTable, &QTableWidget::cellChanged, + this, &AlignWidget::sliceOffsetEdited); changeSlice(0); m_timer->start(200); } diff --git a/tomviz/ArrayWranglerReaction.cxx b/tomviz/ArrayWranglerReaction.cxx index 62389b012..430470182 100644 --- a/tomviz/ArrayWranglerReaction.cxx +++ b/tomviz/ArrayWranglerReaction.cxx @@ -32,6 +32,6 @@ void ArrayWranglerReaction::wrangleArray(DataSource* source) new EditOperatorDialog(Op, source, true, m_mainWindow); dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->show(); - connect(Op, SIGNAL(destroyed()), dialog, SLOT(reject())); + connect(Op, &QObject::destroyed, dialog, &QDialog::reject); } } // namespace tomviz diff --git a/tomviz/AxesReaction.cxx b/tomviz/AxesReaction.cxx index dde7e9387..04c3fce10 100644 --- a/tomviz/AxesReaction.cxx +++ b/tomviz/AxesReaction.cxx @@ -18,30 +18,34 @@ AxesReaction::AxesReaction(QAction* parentObject, AxesReaction::Mode mode) { m_reactionMode = mode; - QObject::connect(&ActiveObjects::instance(), - SIGNAL(viewChanged(vtkSMViewProxy*)), this, - SLOT(updateEnableState()), Qt::QueuedConnection); + QObject::connect( + &ActiveObjects::instance(), + QOverload::of(&ActiveObjects::viewChanged), this, + &AxesReaction::updateEnableState, Qt::QueuedConnection); QObject::connect(&ActiveObjects::instance(), - SIGNAL(dataSourceChanged(DataSource*)), this, - SLOT(updateEnableState())); + static_cast( + &ActiveObjects::dataSourceChanged), + this, &AxesReaction::updateEnableState); switch (m_reactionMode) { case SHOW_ORIENTATION_AXES: - QObject::connect(parentObject, SIGNAL(toggled(bool)), this, - SLOT(showOrientationAxes(bool))); + QObject::connect(parentObject, &QAction::toggled, this, + &AxesReaction::showOrientationAxes); break; case SHOW_CENTER_AXES: - QObject::connect(parentObject, SIGNAL(toggled(bool)), this, - SLOT(showCenterAxes(bool))); + QObject::connect(parentObject, &QAction::toggled, this, + &AxesReaction::showCenterAxes); break; case PICK_CENTER: { auto selectionReaction = new pqRenderViewSelectionReaction( parentObject, nullptr, pqRenderViewSelectionReaction::SELECT_CUSTOM_BOX); - QObject::connect(selectionReaction, - SIGNAL(selectedCustomBox(int, int, int, int)), this, - SLOT(pickCenterOfRotation(int, int))); + QObject::connect( + selectionReaction, + QOverload::of( + &pqRenderViewSelectionReaction::selectedCustomBox), + this, &AxesReaction::pickCenterOfRotation); } break; default: break; diff --git a/tomviz/Behaviors.cxx b/tomviz/Behaviors.cxx index a1f4c1be3..e0f7db090 100644 --- a/tomviz/Behaviors.cxx +++ b/tomviz/Behaviors.cxx @@ -5,7 +5,7 @@ #include "ActiveObjects.h" #include "AddRenderViewContextMenuBehavior.h" -#include "FxiWorkflowWidget.h" +#include "ShiftRotationCenterWidget.h" #include "ManualManipulationWidget.h" #include "MoveActiveObject.h" #include "OperatorPython.h" @@ -102,8 +102,8 @@ Behaviors::Behaviors(QMainWindow* mainWindow) : QObject(mainWindow) void Behaviors::registerCustomOperatorUIs() { - OperatorPython::registerCustomWidget("FxiWorkflowWidget", true, - FxiWorkflowWidget::New); + OperatorPython::registerCustomWidget("ShiftRotationCenterWidget", true, + ShiftRotationCenterWidget::New); OperatorPython::registerCustomWidget("RotationAlignWidget", true, RotateAlignWidget::New); OperatorPython::registerCustomWidget("ManualManipulationWidget", true, diff --git a/tomviz/CMakeLists.txt b/tomviz/CMakeLists.txt index 0ecd815d4..44a2ae7ff 100644 --- a/tomviz/CMakeLists.txt +++ b/tomviz/CMakeLists.txt @@ -98,8 +98,8 @@ set(SOURCES FileFormatManager.h FxiFormat.cxx FxiFormat.h - FxiWorkflowWidget.cxx - FxiWorkflowWidget.h + ShiftRotationCenterWidget.cxx + ShiftRotationCenterWidget.h GenericHDF5Format.cxx GenericHDF5Format.h GradientOpacityWidget.h @@ -308,6 +308,8 @@ list(APPEND SOURCES modules/ModuleMolecule.h modules/ModuleOutline.cxx modules/ModuleOutline.h + modules/ModulePlot.cxx + modules/ModulePlot.h modules/ModulePropertiesPanel.cxx modules/ModulePropertiesPanel.h modules/ModuleRuler.cxx @@ -446,6 +448,8 @@ set(python_files BinaryOpen.py BinaryClose.py DummyMolecule.py + DeconvolutionDenoise.py + SimilarityMetrics.py ElastixRegistration.py LabelObjectAttributes.py LabelObjectPrincipalAxes.py @@ -462,8 +466,8 @@ set(python_files Recon_ART.py Recon_SIRT.py Recon_TV_minimization.py - Recon_tomopy_gridrec.py - Recon_tomopy_fxi.py + Recon_tomopy.py + ShiftRotationCenter_tomopy.py FFT_AbsLog.py ManualManipulation.py Shift_Stack_Uniformly.py @@ -508,6 +512,9 @@ set(python_files TV_Filter.py PoreSizeDistribution.py Tortuosity.py + PowerSpectrumDensity.py + FourierShellCorrelation.py + RemoveArrays.py Recon_real_time_tomography.py ) @@ -515,6 +522,7 @@ set(json_files AddPoissonNoise.json AutoCenterOfMassTiltImageAlignment.json AutoCrossCorrelationTiltImageAlignment.json + AutoTiltAxisRotationAlignment.json AutoTiltAxisShiftAlignment.json PyStackRegImageAlignment.json BinaryThreshold.json @@ -527,6 +535,8 @@ set(json_files BinaryOpen.json BinaryClose.json DummyMolecule.json + DeconvolutionDenoise.json + SimilarityMetrics.json ElastixRegistration.json LabelObjectAttributes.json LabelObjectPrincipalAxes.json @@ -547,8 +557,8 @@ set(json_files Recon_DFT.json Recon_DFT_constraint.json Recon_TV_minimization.json - Recon_tomopy_gridrec.json - Recon_tomopy_fxi.json + Recon_tomopy.json + ShiftRotationCenter_tomopy.json Recon_SIRT.json Recon_WBP.json Shift3D.json @@ -564,6 +574,9 @@ set(json_files TV_Filter.json PoreSizeDistribution.json Tortuosity.json + PowerSpectrumDensity.json + FourierShellCorrelation.json + RemoveArrays.json Recon_real_time_tomography.json ) diff --git a/tomviz/CameraReaction.cxx b/tomviz/CameraReaction.cxx index b8a31a35d..7197358da 100644 --- a/tomviz/CameraReaction.cxx +++ b/tomviz/CameraReaction.cxx @@ -22,9 +22,10 @@ CameraReaction::CameraReaction(QAction* parentObject, CameraReaction::Mode mode) : pqReaction(parentObject) { m_reactionMode = mode; - QObject::connect(&ActiveObjects::instance(), - SIGNAL(viewChanged(vtkSMViewProxy*)), this, - SLOT(updateEnableState()), Qt::QueuedConnection); + QObject::connect( + &ActiveObjects::instance(), + QOverload::of(&ActiveObjects::viewChanged), this, + &CameraReaction::updateEnableState, Qt::QueuedConnection); updateEnableState(); } diff --git a/tomviz/CentralWidget.cxx b/tomviz/CentralWidget.cxx index ef7695d9a..d2ee1b2cd 100644 --- a/tomviz/CentralWidget.cxx +++ b/tomviz/CentralWidget.cxx @@ -27,6 +27,9 @@ #include "AbstractDataModel.h" #include "ActiveObjects.h" +#include "GradientOpacityWidget.h" +#include "Histogram2DWidget.h" +#include "HistogramWidget.h" #include "DataSource.h" #include "HistogramManager.h" #include "Module.h" @@ -130,15 +133,15 @@ CentralWidget::CentralWidget(QWidget* parentObject, Qt::WindowFlags wflags) } }); - connect(m_ui->histogramWidget, SIGNAL(colorMapUpdated()), - SLOT(onColorMapUpdated())); - connect(m_ui->histogramWidget, SIGNAL(colorLegendToggled(bool)), - SLOT(onColorLegendToggled(bool))); - connect(m_ui->gradientOpacityWidget, SIGNAL(mapUpdated()), - SLOT(onColorMapUpdated())); + connect(m_ui->histogramWidget, &HistogramWidget::colorMapUpdated, this, + &CentralWidget::onColorMapUpdated); + connect(m_ui->histogramWidget, &HistogramWidget::colorLegendToggled, this, + &CentralWidget::onColorLegendToggled); + connect(m_ui->gradientOpacityWidget, &GradientOpacityWidget::mapUpdated, this, + &CentralWidget::onColorMapUpdated); m_ui->gradientOpacityWidget->hide(); - connect(m_ui->histogramWidget, SIGNAL(opacityChanged()), - m_ui->histogram2DWidget, SLOT(updateTransfer2D())); + connect(m_ui->histogramWidget, &HistogramWidget::opacityChanged, + m_ui->histogram2DWidget, &Histogram2DWidget::updateTransfer2D); auto& histogramMgr = HistogramManager::instance(); connect(&histogramMgr, &HistogramManager::histogramReady, this, @@ -148,7 +151,7 @@ CentralWidget::CentralWidget(QWidget* parentObject, Qt::WindowFlags wflags) m_timer->setInterval(200); m_timer->setSingleShot(true); - connect(m_timer.data(), SIGNAL(timeout()), SLOT(refreshHistogram())); + connect(m_timer.data(), &QTimer::timeout, this, &CentralWidget::refreshHistogram); layout()->setContentsMargins(0, 0, 0, 0); layout()->setSpacing(0); @@ -186,11 +189,11 @@ void CentralWidget::setActiveModule(Module* module) } m_activeModule = module; if (m_activeModule) { - connect(m_activeModule, SIGNAL(colorMapChanged()), - SLOT(onColorMapDataSourceChanged())); + connect(m_activeModule, &Module::colorMapChanged, this, + &CentralWidget::onColorMapDataSourceChanged); setColorMapDataSource(module->colorMapDataSource()); - connect(m_activeModule, SIGNAL(transferModeChanged(const int)), this, - SLOT(onTransferModeChanged(const int))); + connect(m_activeModule, &Module::transferModeChanged, this, + &CentralWidget::onTransferModeChanged); onTransferModeChanged(static_cast(m_activeModule->getTransferMode())); } else { @@ -222,7 +225,7 @@ void CentralWidget::setColorMapDataSource(DataSource* source) m_activeColorMapDataSource = source; if (source) { - connect(source, SIGNAL(dataChanged()), SLOT(onColorMapDataSourceChanged())); + connect(source, &DataSource::dataChanged, this, &CentralWidget::onColorMapDataSourceChanged); } if (!source) { @@ -235,7 +238,7 @@ void CentralWidget::setColorMapDataSource(DataSource* source) // Get the actual data source, build a histogram out of it. auto image = vtkImageData::SafeDownCast(source->dataObject()); - if (image->GetPointData()->GetScalars() == nullptr) { + if (!image || image->GetPointData()->GetScalars() == nullptr) { return; } diff --git a/tomviz/ColorMap.cxx b/tomviz/ColorMap.cxx index 3dcb0bef6..3ca2cfc51 100644 --- a/tomviz/ColorMap.cxx +++ b/tomviz/ColorMap.cxx @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -189,7 +190,10 @@ void ColorMap::loadFromFile() #endif } - file.open(QIODevice::ReadOnly); + if (!file.open(QIODevice::ReadOnly)) { + qCritical() << "Unable to open color map file:" << file.fileName(); + return; + } QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); file.close(); QJsonArray objects = doc.array(); diff --git a/tomviz/CropReaction.cxx b/tomviz/CropReaction.cxx index 557d3f6d2..d7ef7853a 100644 --- a/tomviz/CropReaction.cxx +++ b/tomviz/CropReaction.cxx @@ -31,6 +31,6 @@ void CropReaction::crop(DataSource* source) new EditOperatorDialog(Op, source, true, m_mainWindow); dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->show(); - connect(Op, SIGNAL(destroyed()), dialog, SLOT(reject())); + connect(Op, &QObject::destroyed, dialog, &QDialog::reject); } } // namespace tomviz diff --git a/tomviz/DataPropertiesPanel.cxx b/tomviz/DataPropertiesPanel.cxx index 1bb91690e..c7c77c275 100644 --- a/tomviz/DataPropertiesPanel.cxx +++ b/tomviz/DataPropertiesPanel.cxx @@ -36,8 +36,12 @@ #include #include +#include +#include +#include #include #include +#include #include #include #include @@ -91,14 +95,24 @@ DataPropertiesPanel::DataPropertiesPanel(QWidget* parentObject) clear(); - connect(&ActiveObjects::instance(), SIGNAL(dataSourceChanged(DataSource*)), - SLOT(setDataSource(DataSource*))); - connect(&ActiveObjects::instance(), SIGNAL(dataSourceChanged(DataSource*)), - SLOT(updateAxesGridLabels())); - connect(&ActiveObjects::instance(), SIGNAL(viewChanged(vtkSMViewProxy*)), - SLOT(updateAxesGridLabels())); - connect(m_ui->SetTiltAnglesButton, SIGNAL(clicked()), SLOT(setTiltAngles())); - connect(m_ui->unitBox, SIGNAL(editingFinished()), SLOT(updateUnits())); + connect(&ActiveObjects::instance(), + static_cast( + &ActiveObjects::dataSourceChanged), + this, &DataPropertiesPanel::setDataSource); + connect(&ActiveObjects::instance(), + static_cast( + &ActiveObjects::dataSourceChanged), + this, &DataPropertiesPanel::updateAxesGridLabels); + connect(&ActiveObjects::instance(), + static_cast( + &ActiveObjects::viewChanged), + this, &DataPropertiesPanel::updateAxesGridLabels); + connect(m_ui->SetTiltAnglesButton, &QPushButton::clicked, this, + &DataPropertiesPanel::setTiltAngles); + connect(m_ui->saveTiltAngles, &QPushButton::clicked, this, + &DataPropertiesPanel::saveTiltAngles); + connect(m_ui->unitBox, &QLineEdit::editingFinished, this, + &DataPropertiesPanel::updateUnits); connect(m_ui->xLengthBox, &QLineEdit::editingFinished, [this]() { this->updateLength(m_ui->xLengthBox, 0); }); connect(m_ui->yLengthBox, &QLineEdit::editingFinished, @@ -179,8 +193,8 @@ void DataPropertiesPanel::setDataSource(DataSource* dsource) } m_currentDataSource = dsource; if (dsource) { - connect(dsource, SIGNAL(dataChanged()), SLOT(scheduleUpdate()), - Qt::UniqueConnection); + connect(dsource, &DataSource::dataChanged, this, + &DataPropertiesPanel::scheduleUpdate, Qt::UniqueConnection); connect(dsource, &DataSource::dataPropertiesChanged, this, &DataPropertiesPanel::onDataPropertiesChanged); connect(dsource, &DataSource::displayPositionChanged, this, @@ -371,8 +385,8 @@ void DataPropertiesPanel::updateData() return; } - disconnect(m_ui->TiltAnglesTable, SIGNAL(cellChanged(int, int)), this, - SLOT(onTiltAnglesModified(int, int))); + disconnect(m_ui->TiltAnglesTable, &QTableWidget::cellChanged, this, + &DataPropertiesPanel::onTiltAnglesModified); clear(); DataSource* dsource = m_currentDataSource; @@ -402,21 +416,41 @@ void DataPropertiesPanel::updateData() m_tiltAnglesSeparator->show(); m_ui->SetTiltAnglesButton->show(); m_ui->TiltAnglesTable->show(); + m_ui->saveTiltAngles->show(); QVector tiltAngles = dsource->getTiltAngles(); + QVector scanIDs = dsource->getScanIDs(); + m_hasScanIDs = scanIDs.size() == tiltAngles.size() && !scanIDs.isEmpty(); m_ui->TiltAnglesTable->setRowCount(tiltAngles.size()); - m_ui->TiltAnglesTable->setColumnCount(1); + int numCols = m_hasScanIDs ? 2 : 1; + m_ui->TiltAnglesTable->setColumnCount(numCols); + int tiltCol = m_hasScanIDs ? 1 : 0; for (int i = 0; i < tiltAngles.size(); ++i) { + if (m_hasScanIDs) { + QTableWidgetItem* scanItem = new QTableWidgetItem(); + scanItem->setData(Qt::DisplayRole, QString::number(scanIDs[i])); + scanItem->setFlags(scanItem->flags() & ~Qt::ItemIsEditable); + m_ui->TiltAnglesTable->setItem(i, 0, scanItem); + } QTableWidgetItem* item = new QTableWidgetItem(); item->setData(Qt::DisplayRole, QString::number(tiltAngles[i])); - m_ui->TiltAnglesTable->setItem(i, 0, item); + m_ui->TiltAnglesTable->setItem(i, tiltCol, item); } + // Set column headers + QStringList headers; + if (m_hasScanIDs) { + headers << "Scan ID"; + } + headers << "Tilt Angle"; + m_ui->TiltAnglesTable->setHorizontalHeaderLabels(headers); + m_ui->TiltAnglesTable->horizontalHeader()->setStretchLastSection(true); } else { m_tiltAnglesSeparator->hide(); m_ui->SetTiltAnglesButton->hide(); m_ui->TiltAnglesTable->hide(); + m_ui->saveTiltAngles->hide(); } - connect(m_ui->TiltAnglesTable, SIGNAL(cellChanged(int, int)), - SLOT(onTiltAnglesModified(int, int))); + connect(m_ui->TiltAnglesTable, &QTableWidget::cellChanged, this, + &DataPropertiesPanel::onTiltAnglesModified); updateTimeSeriesGroup(); updateComponentsCombo(); @@ -515,6 +549,11 @@ void DataPropertiesPanel::onTiltAnglesModified(int row, int column) // The table shouldn't be shown if this is not true, so this slot shouldn't be // called Q_ASSERT(dsource->type() == DataSource::TiltSeries); + // Tilt angles are in column 1 when scan IDs are present, column 0 otherwise + int tiltCol = m_hasScanIDs ? 1 : 0; + if (column != tiltCol) { + return; + } QTableWidgetItem* item = m_ui->TiltAnglesTable->item(row, column); auto ok = false; auto value = item->data(Qt::DisplayRole).toDouble(&ok); @@ -627,6 +666,54 @@ void DataPropertiesPanel::setTiltAngles() SetTiltAnglesReaction::showSetTiltAnglesUI(mainWindow, dsource); } +void DataPropertiesPanel::saveTiltAngles() +{ + DataSource* dsource = m_currentDataSource; + if (!dsource) { + return; + } + + // Prompt user to select a file for saving + QString fileName = QFileDialog::getSaveFileName( + nullptr, + "Save Tilt Angles", + QString(), // Default directory (or you can specify a path) + "TXT Files (*.txt);;All Files (*)" + ); + + // Check if user cancelled + if (fileName.isEmpty()) { + return; + } + + // Ensure the file has a .txt extension + if (!fileName.endsWith(".txt", Qt::CaseInsensitive)) { + fileName += ".txt"; + } + + auto tiltAngles = dsource->getTiltAngles(); + auto scanIDs = dsource->getScanIDs(); + + // Open file for writing + QFile file(fileName); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::warning(nullptr, "Error", + "Could not open file for writing: " + file.errorString()); + return; + } + + // Write scan IDs (if available) and tilt angles, one per line + QTextStream out(&file); + for (int i = 0; i < tiltAngles.size(); ++i) { + if (m_hasScanIDs) { + out << scanIDs[i] << " "; + } + out << tiltAngles[i] << "\n"; + } + + file.close(); +} + void DataPropertiesPanel::scheduleUpdate() { m_updateNeeded = true; @@ -811,6 +898,7 @@ void DataPropertiesPanel::clear() m_ui->TiltAnglesTable->clear(); m_ui->TiltAnglesTable->setRowCount(0); m_ui->TiltAnglesTable->hide(); + m_ui->saveTiltAngles->hide(); } void DataPropertiesPanel::updateSpacing(int axis, double newLength) diff --git a/tomviz/DataPropertiesPanel.h b/tomviz/DataPropertiesPanel.h index 2453da6d3..0fb4463f8 100644 --- a/tomviz/DataPropertiesPanel.h +++ b/tomviz/DataPropertiesPanel.h @@ -55,6 +55,7 @@ private slots: void setDataSource(DataSource*); void onTiltAnglesModified(int row, int column); void setTiltAngles(); + void saveTiltAngles(); void scheduleUpdate(); void onDataPropertiesChanged(); void onDataPositionChanged(double, double, double); @@ -86,6 +87,7 @@ private slots: // Hold the order (the indexes into the field data), so we can preserve // the order during a rename. QList m_scalarIndexes; + bool m_hasScanIDs = false; void clear(); void updateSpacing(int axis, double newLength); diff --git a/tomviz/DataPropertiesPanel.ui b/tomviz/DataPropertiesPanel.ui index 02e401d0a..b9c108ccd 100644 --- a/tomviz/DataPropertiesPanel.ui +++ b/tomviz/DataPropertiesPanel.ui @@ -7,7 +7,7 @@ 0 0 482 - 682 + 707 @@ -479,6 +479,16 @@ + + + + <html><head/><body><p>Save the tilt angles to an XY file.</p></body></html> + + + Save Tilt Angles + + + diff --git a/tomviz/DataSource.cxx b/tomviz/DataSource.cxx index c2fd8dcc5..3c12ac2dd 100644 --- a/tomviz/DataSource.cxx +++ b/tomviz/DataSource.cxx @@ -44,9 +44,15 @@ #include #include +#include +#include + +#include #include #include #include +#include +#include #include #include @@ -57,7 +63,11 @@ namespace { void createOrResizeTiltAnglesArray(vtkDataObject* data) { auto fd = data->GetFieldData(); - int* extent = vtkImageData::SafeDownCast(data)->GetExtent(); + auto* imageData = vtkImageData::SafeDownCast(data); + if (!imageData) { + return; + } + int* extent = imageData->GetExtent(); int numTiltAngles = extent[5] - extent[4] + 1; if (!fd->HasArray("tilt_angles")) { vtkNew array; @@ -625,8 +635,22 @@ bool DataSource::deserialize(const QJsonObject& state) viewProxy = ActiveObjects::instance().activeView(); } auto type = moduleObj["type"].toString(); + + // Plot modules require an OperatorResult, not a DataSource. They + // will be recreated when the operator pipeline is re-run and the + // user adds the Plot module again. + if (type == "Plot") { + qWarning() << "Skipping Plot module during state restore. Re-run" + << "the pipeline and add the Plot module to restore it."; + continue; + } + auto m = ModuleManager::instance().createAndAddModule(type, this, viewProxy); + if (!m) { + qWarning() << "Failed to create module of type:" << type; + continue; + } m->deserialize(moduleObj); } } @@ -641,7 +665,7 @@ bool DataSource::deserialize(const QJsonObject& state) op = OperatorFactory::instance().createOperator( operatorObj["type"].toString(), this); if (op && op->deserialize(operatorObj)) { - addOperator(op); + addOperator(op, /*append=*/true); } } @@ -688,6 +712,10 @@ DataSource* DataSource::clone() const newClone->setTiltAngles(getTiltAngles()); } + if (hasScanIDs(this->dataObject())) { + newClone->setScanIDs(getScanIDs()); + } + QList newTimeSteps; for (auto& timeStep : this->Internals->timeSeriesSteps) { newTimeSteps.append(timeStep.clone()); @@ -999,11 +1027,51 @@ void DataSource::setUnits(const QString& units, bool markModified) emit dataPropertiesChanged(); } -int DataSource::addOperator(Operator* op) +int DataSource::addOperator(Operator* op, bool append) { op->setParent(this); - int index = this->Internals->Operators.count(); - this->Internals->Operators.push_back(op); + int index = -1; + if (!append) { + auto activeOp = ActiveObjects::instance().activeOperator(); + if (activeOp && activeOp->dataSource() == this) { + index = this->Internals->Operators.indexOf(activeOp); + } + } + if (index >= 0) { + // About to insert (not append). Ask the user for confirmation unless + // they previously checked "Don't ask again". + auto settings = pqApplicationCore::instance()->settings(); + bool skipConfirm = + settings->value("OperatorInsertConfirm/DontAsk", false).toBool(); + if (!skipConfirm) { + QMessageBox msgBox; + msgBox.setWindowTitle("Insert Operator?"); + msgBox.setText( + "Insert this operator before the selected operator in the pipeline?"); + auto* insertBtn = msgBox.addButton("Insert", QMessageBox::AcceptRole); + msgBox.addButton("Append to End", QMessageBox::RejectRole); + msgBox.setDefaultButton(insertBtn); + QCheckBox dontAskAgain("Don't ask again (always insert)"); + msgBox.setCheckBox(&dontAskAgain); + + msgBox.exec(); + + if (dontAskAgain.isChecked()) { + settings->setValue("OperatorInsertConfirm/DontAsk", true); + } + + if (msgBox.clickedButton() != insertBtn) { + // Append to the end instead of inserting + index = -1; + } + } + } + if (index >= 0) { + this->Internals->Operators.insert(index, op); + } else { + index = this->Internals->Operators.count(); + this->Internals->Operators.push_back(op); + } emit operatorAdded(op); return index; @@ -1497,7 +1565,7 @@ void DataSource::init(vtkImageData* data, DataSourceType dataType, updateColorMap(); // Every time the data changes, we should update the color map. - connect(this, SIGNAL(dataChanged()), SLOT(updateColorMap())); + connect(this, &DataSource::dataChanged, this, &DataSource::updateColorMap); connect(this, &DataSource::dataPropertiesChanged, [this]() { this->proxy()->MarkModified(nullptr); }); @@ -1727,6 +1795,77 @@ void DataSource::clearTiltAngles(vtkDataObject* image) } } +bool DataSource::hasScanIDs(vtkDataObject* image) +{ + if (!image) + return false; + + return image->GetFieldData()->HasArray("scan_ids"); +} + +QVector DataSource::getScanIDs(vtkDataObject* image) +{ + QVector result; + if (!image) + return result; + + auto fd = image->GetFieldData(); + if (fd->HasArray("scan_ids")) { + auto scanIds = fd->GetArray("scan_ids"); + result.resize(scanIds->GetNumberOfTuples()); + for (int i = 0; i < result.size(); ++i) { + result[i] = static_cast(scanIds->GetTuple1(i)); + } + } + return result; +} + +void DataSource::setScanIDs(vtkDataObject* image, + const QVector& scanIDs) +{ + if (!image) + return; + + auto fd = image->GetFieldData(); + int numTuples = scanIDs.size(); + std::vector data(numTuples); + for (int i = 0; i < numTuples; ++i) { + data[i] = scanIDs[i]; + } + setFieldDataArray(fd, "scan_ids", numTuples, data.data()); +} + +void DataSource::clearScanIDs(vtkDataObject* image) +{ + if (!image) + return; + + auto fd = image->GetFieldData(); + if (fd->HasArray("scan_ids")) { + fd->RemoveArray("scan_ids"); + } +} + +bool DataSource::hasScanIDs() +{ + return hasScanIDs(dataObject()); +} + +QVector DataSource::getScanIDs() const +{ + return getScanIDs(dataObject()); +} + +void DataSource::setScanIDs(const QVector& scanIDs) +{ + setScanIDs(dataObject(), scanIDs); +} + +void DataSource::clearScanIDs() +{ + clearScanIDs(dataObject()); +} + bool DataSource::wasSubsampled(vtkDataObject* image) { bool ret = false; diff --git a/tomviz/DataSource.h b/tomviz/DataSource.h index fa426ac05..fef1f398f 100644 --- a/tomviz/DataSource.h +++ b/tomviz/DataSource.h @@ -112,7 +112,7 @@ class DataSource : public QObject const QList& operators() const; /// Add/remove operators. - int addOperator(Operator* op); + int addOperator(Operator* op, bool append = false); bool removeOperator(Operator* op); bool removeAllOperators(); @@ -271,6 +271,18 @@ class DataSource : public QObject /// Remove the tilt angles from the data source void clearTiltAngles(); + /// Returns true if the dataset has scan IDs + bool hasScanIDs(); + + /// Get scan IDs (if available - otherwise an empty vector is returned) + QVector getScanIDs() const; + + /// Set the scan IDs + void setScanIDs(const QVector& scanIDs); + + /// Remove scan IDs + void clearScanIDs(); + /// Moves the displayPosition of the DataSource by deltaPosition void translate(const double deltaPosition[3]); @@ -372,6 +384,11 @@ class DataSource : public QObject const QVector& angles); static void clearTiltAngles(vtkDataObject* image); + static bool hasScanIDs(vtkDataObject* image); + static QVector getScanIDs(vtkDataObject* image); + static void setScanIDs(vtkDataObject* image, const QVector& scanIDs); + static void clearScanIDs(vtkDataObject* image); + /// Check to see if the data was subsampled while reading static bool wasSubsampled(vtkDataObject* image); diff --git a/tomviz/DataTransformMenu.cxx b/tomviz/DataTransformMenu.cxx index ee8b7dc24..967cb14cd 100644 --- a/tomviz/DataTransformMenu.cxx +++ b/tomviz/DataTransformMenu.cxx @@ -42,6 +42,7 @@ void DataTransformMenu::buildTransforms() auto convertDataAction = menu->addAction("Convert to Float"); auto arrayWranglerAction = menu->addAction("Convert Type"); auto transposeDataAction = menu->addAction("Transpose Data"); + auto removeArraysAction = menu->addAction("Remove Arrays"); auto reinterpretSignedToUnignedAction = menu->addAction("Reinterpret Signed to Unsigned"); menu->addSeparator(); @@ -79,6 +80,11 @@ void DataTransformMenu::buildTransforms() auto tortuosityAction = menu->addAction("Tortuosity"); auto poreSizeAction = menu->addAction("Pore Size Distribution"); menu->addSeparator(); + auto psdAction = menu->addAction("Power Spectrum Density"); + auto fscAction = menu->addAction("Fourier Shell Correlation"); + auto deconvolutionDenoiseAction = menu->addAction("Deconvolution Denoise"); + auto similarityMetricsAction = menu->addAction("Similarity Metrics"); + menu->addSeparator(); auto cloneAction = menu->addAction("Clone"); auto deleteDataAction = menu->addAction( QIcon(":/QtWidgets/Icons/pqDelete.svg"), "Delete Data and Modules"); @@ -90,6 +96,10 @@ void DataTransformMenu::buildTransforms() new ConvertToFloatReaction(convertDataAction); new ArrayWranglerReaction(arrayWranglerAction, mainWindow); new TransposeDataReaction(transposeDataAction, mainWindow); + new AddPythonTransformReaction( + removeArraysAction, "Remove Arrays", + readInPythonScript("RemoveArrays"), false, false, false, + readInJSONDescription("RemoveArrays")); new AddPythonTransformReaction( reinterpretSignedToUnignedAction, "Reinterpret Signed to Unsigned", readInPythonScript("ReinterpretSignedToUnsigned")); @@ -183,6 +193,23 @@ void DataTransformMenu::buildTransforms() readInPythonScript("PoreSizeDistribution"), false, false, false, readInJSONDescription("PoreSizeDistribution")); + new AddPythonTransformReaction( + psdAction, "Power Spectrum Density", + readInPythonScript("PowerSpectrumDensity"), false, false, false, + readInJSONDescription("PowerSpectrumDensity")); + new AddPythonTransformReaction( + fscAction, "Fourier Shell Correlation", + readInPythonScript("FourierShellCorrelation"), false, false, false, + readInJSONDescription("FourierShellCorrelation")); + new AddPythonTransformReaction( + deconvolutionDenoiseAction, "Deconvolution Denoise", + readInPythonScript("DeconvolutionDenoise"), true, false, false, + readInJSONDescription("DeconvolutionDenoise")); + new AddPythonTransformReaction( + similarityMetricsAction, "Similarity Metrics", + readInPythonScript("SimilarityMetrics"), false, false, false, + readInJSONDescription("SimilarityMetrics")); + new CloneDataReaction(cloneAction); new DeleteDataReaction(deleteDataAction); diff --git a/tomviz/DeleteDataReaction.cxx b/tomviz/DeleteDataReaction.cxx index 5435f482e..8378132ea 100644 --- a/tomviz/DeleteDataReaction.cxx +++ b/tomviz/DeleteDataReaction.cxx @@ -12,8 +12,10 @@ namespace tomviz { DeleteDataReaction::DeleteDataReaction(QAction* parentObject) : pqReaction(parentObject) { - connect(&ActiveObjects::instance(), SIGNAL(dataSourceChanged(DataSource*)), - SLOT(activeDataSourceChanged())); + connect(&ActiveObjects::instance(), + static_cast( + &ActiveObjects::dataSourceChanged), + this, &DeleteDataReaction::activeDataSourceChanged); m_activeDataSource = ActiveObjects::instance().activeDataSource(); updateEnableState(); } diff --git a/tomviz/DoubleSliderWidget.cxx b/tomviz/DoubleSliderWidget.cxx index 25d0340a7..b4f86b1dd 100644 --- a/tomviz/DoubleSliderWidget.cxx +++ b/tomviz/DoubleSliderWidget.cxx @@ -41,15 +41,15 @@ DoubleSliderWidget::DoubleSliderWidget(bool showLineEdit, QWidget* p) this->LineEdit = nullptr; } - QObject::connect(this->Slider, SIGNAL(valueChanged(int)), this, - SLOT(sliderChanged(int))); - QObject::connect(this->Slider, SIGNAL(sliderReleased()), this, - SLOT(onSliderReleased())); + QObject::connect(this->Slider, &QSlider::valueChanged, this, + &DoubleSliderWidget::sliderChanged); + QObject::connect(this->Slider, &QSlider::sliderReleased, this, + &DoubleSliderWidget::onSliderReleased); if (showLineEdit) { - QObject::connect(this->LineEdit, SIGNAL(textChanged(const QString&)), this, - SLOT(textChanged(const QString&))); - QObject::connect(this->LineEdit, SIGNAL(textChangedAndEditingFinished()), - this, SLOT(editingFinished())); + QObject::connect(this->LineEdit, &pqLineEdit::textChanged, this, + &DoubleSliderWidget::textChanged); + QObject::connect(this->LineEdit, &pqLineEdit::textChangedAndEditingFinished, + this, &DoubleSliderWidget::editingFinished); } } @@ -169,6 +169,9 @@ bool DoubleSliderWidget::strictRange() const } const QDoubleValidator* dv = qobject_cast(this->LineEdit->validator()); + if (!dv) { + return false; + } return dv->bottom() == this->minimum() && dv->top() == this->maximum(); } diff --git a/tomviz/DoubleSpinBox.cxx b/tomviz/DoubleSpinBox.cxx index 0eba5e4e4..5ac8f7198 100644 --- a/tomviz/DoubleSpinBox.cxx +++ b/tomviz/DoubleSpinBox.cxx @@ -33,8 +33,8 @@ void DoubleSpinBox::mousePressEvent(QMouseEvent* event) this->pressInUp = this->pressInDown = false; } if (this->pressInUp || this->pressInDown) { - this->connect(this, SIGNAL(valueChanged(double)), this, - SIGNAL(editingFinished())); + this->connect(this, QOverload::of(&DoubleSpinBox::valueChanged), this, + &DoubleSpinBox::editingFinished); } } @@ -43,8 +43,8 @@ void DoubleSpinBox::mouseReleaseEvent(QMouseEvent* event) QDoubleSpinBox::mouseReleaseEvent(event); if (this->pressInUp || this->pressInDown) { - this->disconnect(this, SIGNAL(valueChanged(double)), this, - SIGNAL(editingFinished())); + this->disconnect(this, QOverload::of(&DoubleSpinBox::valueChanged), this, + &DoubleSpinBox::editingFinished); } QStyleOptionSpinBox opt; diff --git a/tomviz/EmdFormat.cxx b/tomviz/EmdFormat.cxx index 4b58d9fd3..8ffd22cab 100644 --- a/tomviz/EmdFormat.cxx +++ b/tomviz/EmdFormat.cxx @@ -159,6 +159,20 @@ bool EmdFormat::readNode(h5::H5ReadWrite& reader, const std::string& emdNode, DataSource::setType(image, DataSource::TiltSeries); } + // Read scan IDs if present + std::string scanIdsPath = emdNode + "/scan_ids"; + if (reader.isDataSet(scanIdsPath)) { + auto scanIdsData = reader.readData(scanIdsPath); + if (!scanIdsData.empty()) { + QVector scanIDs; + scanIDs.reserve(scanIdsData.size()); + for (auto& id : scanIdsData) { + scanIDs.push_back(id); + } + DataSource::setScanIDs(image, scanIDs); + } + } + return true; } @@ -270,6 +284,14 @@ bool EmdFormat::writeNode(h5::H5ReadWrite& writer, const std::string& path, // Write any extra scalars we might have writeExtraScalars(writer, path, permutedImage); + // Write scan IDs if present + if (DataSource::hasScanIDs(image)) { + auto scanIDs = DataSource::getScanIDs(image); + std::vector scanIdsVec(scanIDs.begin(), scanIDs.end()); + std::vector dims(1, static_cast(scanIdsVec.size())); + writer.writeData(path, "scan_ids", dims, scanIdsVec); + } + return true; } diff --git a/tomviz/ExportDataReaction.cxx b/tomviz/ExportDataReaction.cxx index 63a5a01d0..ef1979d0b 100644 --- a/tomviz/ExportDataReaction.cxx +++ b/tomviz/ExportDataReaction.cxx @@ -43,8 +43,8 @@ namespace tomviz { ExportDataReaction::ExportDataReaction(QAction* parentAction, Module* module) : pqReaction(parentAction), m_module(module) { - connect(&ActiveObjects::instance(), SIGNAL(moduleChanged(Module*)), - SLOT(updateEnableState())); + connect(&ActiveObjects::instance(), &ActiveObjects::moduleChanged, this, + &ExportDataReaction::updateEnableState); updateEnableState(); } diff --git a/tomviz/FxiWorkflowWidget.cxx b/tomviz/FxiWorkflowWidget.cxx deleted file mode 100644 index b72485947..000000000 --- a/tomviz/FxiWorkflowWidget.cxx +++ /dev/null @@ -1,900 +0,0 @@ -/* This source file is part of the Tomviz project, https://tomviz.org/. - It is released under the 3-Clause BSD License, see "LICENSE". */ - -#include "FxiWorkflowWidget.h" -#include "ui_FxiWorkflowWidget.h" - -#include "ActiveObjects.h" -#include "ColorMap.h" -#include "DataSource.h" -#include "InterfaceBuilder.h" -#include "InternalPythonHelper.h" -#include "OperatorPython.h" -#include "PresetDialog.h" -#include "Utilities.h" - -#include - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include - -namespace tomviz { - -class InternalProgressDialog : public QProgressDialog -{ -public: - InternalProgressDialog(QWidget* parent = nullptr) : QProgressDialog(parent) - { - setWindowTitle("Tomviz"); - setLabelText("Generating test images..."); - setMinimum(0); - setMaximum(0); - setWindowModality(Qt::WindowModal); - - // No cancel button - setCancelButton(nullptr); - - // No close button in the corner - setWindowFlags((windowFlags() | Qt::CustomizeWindowHint) & - ~Qt::WindowCloseButtonHint); - - reset(); - } - - void keyPressEvent(QKeyEvent* e) override - { - // Do not let the user close the dialog by pressing escape - if (e->key() == Qt::Key_Escape) { - return; - } - - QProgressDialog::keyPressEvent(e); - } -}; - -class InteractorStyle : public vtkInteractorStyleImage -{ - // Our customized 2D interactor style class -public: - static InteractorStyle* New(); - - InteractorStyle() { this->SetInteractionModeToImage2D(); } - - void OnLeftButtonDown() override - { - // Override this to not do window level events, and instead do panning. - int x = this->Interactor->GetEventPosition()[0]; - int y = this->Interactor->GetEventPosition()[1]; - - this->FindPokedRenderer(x, y); - if (this->CurrentRenderer == nullptr) { - return; - } - - this->GrabFocus(this->EventCallbackCommand); - if (!this->Interactor->GetShiftKey() && - !this->Interactor->GetControlKey()) { - this->StartPan(); - } else { - this->Superclass::OnLeftButtonDown(); - } - } -}; - -vtkStandardNewMacro(InteractorStyle) - - template - QMap unite(const QMap& map1, const QMap& map2) -{ - auto ret = map1; - for (const auto& k : map2.keys()) { - ret[k] = map2[k]; - } - return ret; -} - -class FxiWorkflowWidget::Internal : public QObject -{ - Q_OBJECT - -public: - Ui::FxiWorkflowWidget ui; - QPointer op; - vtkSmartPointer image; - vtkSmartPointer rotationImages; - vtkSmartPointer colorMap; - vtkSmartPointer lut; - QList rotations; - vtkNew slice; - vtkNew mapper; - vtkNew renderer; - vtkNew axesActor; - QString script; - InternalPythonHelper pythonHelper; - QPointer parent; - QPointer dataSource; - QPointer interfaceBuilder; - QVariantMap customReconSettings; - QVariantMap customTestRotationSettings; - int sliceNumber = 0; - QScopedPointer progressDialog; - QFutureWatcher futureWatcher; - bool testRotationsSuccess = false; - QString testRotationsErrorMessage; - - Internal(Operator* o, vtkSmartPointer img, FxiWorkflowWidget* p) - : op(o), image(img) - { - // Must call setupUi() before using p in any way - ui.setupUi(p); - setParent(p); - parent = p; - - readSettings(); - - // Keep the axes invisible until the data is displayed - axesActor->SetVisibility(false); - - mapper->SetOrientation(0); - slice->SetMapper(mapper); - renderer->AddViewProp(slice); - ui.sliceView->renderWindow()->AddRenderer(renderer); - - vtkNew interactorStyle; - ui.sliceView->interactor()->SetInteractorStyle(interactorStyle); - setRotationData(vtkImageData::New()); - - // Use a child data source if one is available so the color map will match - if (op->childDataSource()) { - dataSource = op->childDataSource(); - } else if (op->dataSource()) { - dataSource = op->dataSource(); - } else { - dataSource = ActiveObjects::instance().activeDataSource(); - } - - static unsigned int colorMapCounter = 0; - ++colorMapCounter; - - auto pxm = ActiveObjects::instance().proxyManager(); - vtkNew tfmgr; - colorMap = - tfmgr->GetColorTransferFunction(QString("FxiWorkflowWidgetColorMap%1") - .arg(colorMapCounter) - .toLatin1() - .data(), - pxm); - - setColorMapToGrayscale(); - - for (auto* w : inputWidgets()) { - w->installEventFilter(this); - } - - // This isn't always working in Qt designer, so set it here as well - ui.colorPresetButton->setIcon(QIcon(":/pqWidgets/Icons/pqFavorites.svg")); - - auto* dims = image->GetDimensions(); - ui.slice->setMaximum(dims[1] - 1); - ui.sliceStart->setMaximum(dims[1] - 1); - ui.sliceStop->setMaximum(dims[1]); - - // Get the slice start to default to 0, and the slice stop - // to default to dims[1], despite whatever settings they read in. - ui.sliceStart->setValue(0); - ui.sliceStop->setValue(dims[1]); - - // Set the default start and stop values around the predicted - // center of rotation. - auto center = dims[0] / 2.0; - auto delta = std::min(40.0, center); - ui.start->setValue(center - delta); - ui.stop->setValue(center + delta); - - // Indicate what the max is via a tooltip. - auto toolTip = "Max: " + QString::number(dims[1]); - ui.sliceStop->setToolTip(toolTip); - - // Hide the additional parameters label unless the user adds some - ui.reconExtraParamsLayoutWidget->hide(); - ui.testRotationsExtraParamsLayoutWidget->hide(); - - progressDialog.reset(new InternalProgressDialog(parent)); - - updateControls(); - setupConnections(); - } - - void setupConnections() - { - connect(ui.testRotations, &QPushButton::pressed, this, - &Internal::startGeneratingTestImages); - connect(ui.imageViewSlider, &IntSliderWidget::valueEdited, this, - &Internal::sliderEdited); - connect(&futureWatcher, &QFutureWatcher::finished, this, - &Internal::testImagesGenerated); - connect(&futureWatcher, &QFutureWatcher::finished, - progressDialog.data(), &QProgressDialog::accept); - connect(ui.colorPresetButton, &QToolButton::clicked, this, - &Internal::onColorPresetClicked); - connect(ui.previewMin, &DoubleSliderWidget::valueEdited, this, - &Internal::onPreviewRangeEdited); - connect(ui.previewMax, &DoubleSliderWidget::valueEdited, this, - &Internal::onPreviewRangeEdited); - } - - void setupUI(OperatorPython* pythonOp) - { - if (!pythonOp) { - return; - } - - // If the user added extra parameters, add them here - auto json = QJsonDocument::fromJson(pythonOp->JSONDescription().toLatin1()); - if (json.isNull() || !json.isObject()) { - return; - } - - DataSource* ds = nullptr; - if (pythonOp->hasChildDataSource()) { - ds = pythonOp->childDataSource(); - } else { - ds = qobject_cast(pythonOp->parent()); - } - - if (!ds) { - ds = ActiveObjects::instance().activeDataSource(); - } - - QJsonObject root = json.object(); - - // Get the parameters for the operator - QJsonValueRef parametersNode = root["parameters"]; - if (parametersNode.isUndefined() || !parametersNode.isArray()) { - return; - } - auto parameters = parametersNode.toArray(); - - // Set up the interface builder - if (interfaceBuilder) { - interfaceBuilder->deleteLater(); - interfaceBuilder = nullptr; - } - - interfaceBuilder = new InterfaceBuilder(this, ds); - interfaceBuilder->setParameterValues(pythonOp->arguments()); - - // Add any extra parameter widgets - addReconExtraParamWidgets(parameters); - addTestRotationExtraParamWidgets(parameters); - - // Modify the extra param widgets with any saved settings - setReconExtraParamValues(customReconSettings); - setTestRotationExtraParamValues(customTestRotationSettings); - } - - void addReconExtraParamWidgets(QJsonArray parameters) - { - // Here is the list of parameters for which we already have widgets - QStringList knownParameters = { - "denoise_flag", "denoise_level", "dark_scale", - "rotation_center", "slice_start", "slice_stop", - }; - - int i = 0; - while (i < parameters.size()) { - QJsonValueRef parameterNode = parameters[i]; - QJsonObject parameterObject = parameterNode.toObject(); - QJsonValueRef nameValue = parameterObject["name"]; - auto tagValue = parameterObject["tag"]; - if (knownParameters.contains(nameValue.toString())) { - // This parameter is already known. Remove it. - parameters.removeAt(i); - } else if (tagValue.toString("") != "") { - // Not the right tag. Remove it. - parameters.removeAt(i); - } else { - i += 1; - } - } - - if (parameters.isEmpty()) { - return; - } - - // If we get to this point, we have some extra parameters. - // Show the additional parameters label, and add the parameters. - ui.reconExtraParamsLayoutWidget->show(); - auto layout = ui.reconExtraParamsLayout; - interfaceBuilder->buildParameterInterface(layout, parameters); - } - - void addTestRotationExtraParamWidgets(QJsonArray parameters) - { - QString tag = "test_rotations"; - bool show = false; - - for (auto node : parameters) { - auto obj = node.toObject(); - if (obj["tag"].toString("") == tag) { - show = true; - break; - } - } - - if (!show) { - // Nothing to show - return; - } - - // If we get to this point, we have some extra parameters. - // Show the additional parameters label, and add the parameters. - ui.testRotationsExtraParamsLayoutWidget->show(); - auto layout = ui.testRotationsExtraParamsLayout; - interfaceBuilder->buildParameterInterface(layout, parameters, - "test_rotations"); - } - - void setExtraParamValues(QVariantMap values) - { - setReconExtraParamValues(values); - setTestRotationExtraParamValues(values); - } - - void setReconExtraParamValues(QVariantMap values) - { - if (!interfaceBuilder) { - return; - } - - auto parentWidget = ui.reconExtraParamsLayoutWidget; - interfaceBuilder->setParameterValues(values); - interfaceBuilder->updateWidgetValues(parentWidget); - } - - void setTestRotationExtraParamValues(QVariantMap values) - { - if (!interfaceBuilder) { - return; - } - - auto parentWidget = ui.testRotationsExtraParamsLayoutWidget; - interfaceBuilder->setParameterValues(values); - interfaceBuilder->updateWidgetValues(parentWidget); - } - - QVariantMap extraParamValues() - { - return unite(reconExtraParamValues(), testRotationsExtraParamValues()); - } - - QVariantMap reconExtraParamValues() - { - if (!interfaceBuilder) { - return QVariantMap(); - } - - auto parentWidget = ui.reconExtraParamsLayoutWidget; - return interfaceBuilder->parameterValues(parentWidget); - } - - QVariantMap testRotationsExtraParamValues() - { - if (!interfaceBuilder) { - return QVariantMap(); - } - - auto parentWidget = ui.testRotationsExtraParamsLayoutWidget; - return interfaceBuilder->parameterValues(parentWidget); - } - - void setupRenderer() { tomviz::setupRenderer(renderer, mapper, axesActor); } - - void render() { ui.sliceView->renderWindow()->Render(); } - - void readSettings() - { - readGeneralSettings(); - readReconSettings(); - readTestSettings(); - } - - void readGeneralSettings() - { - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("FxiWorkflowWidget"); - settings->beginGroup("General"); - setDenoiseFlag(settings->value("denoiseFlag", false).toBool()); - setDenoiseLevel(settings->value("denoiseLevel", 9).toInt()); - setDarkScale(settings->value("darkScale", 1).toDouble()); - settings->endGroup(); - settings->endGroup(); - } - - void readReconSettings() - { - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("FxiWorkflowWidget"); - settings->beginGroup("Recon"); - setRotationCenter(settings->value("rotationCenter", 600).toDouble()); - setSliceStart(settings->value("sliceStart", 0).toInt()); - setSliceStop(settings->value("sliceStop", 1).toInt()); - customReconSettings = settings->value("extraParams").toMap(); - settings->endGroup(); - settings->endGroup(); - } - - void readTestSettings() - { - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("FxiWorkflowWidget"); - settings->beginGroup("TestSettings"); - ui.steps->setValue(settings->value("steps", 26).toInt()); - ui.slice->setValue(settings->value("sli", 0).toInt()); - customTestRotationSettings = settings->value("extraParams").toMap(); - settings->endGroup(); - settings->endGroup(); - } - - void writeSettings() - { - writeGeneralSettings(); - writeReconSettings(); - writeTestSettings(); - } - - void writeGeneralSettings() - { - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("FxiWorkflowWidget"); - settings->beginGroup("General"); - settings->setValue("denoiseFlag", denoiseFlag()); - settings->setValue("denoiseLevel", denoiseLevel()); - settings->setValue("darkScale", darkScale()); - settings->endGroup(); - settings->endGroup(); - } - - void writeReconSettings() - { - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("FxiWorkflowWidget"); - settings->beginGroup("Recon"); - settings->setValue("rotationCenter", rotationCenter()); - settings->setValue("sliceStart", sliceStart()); - settings->setValue("sliceStop", sliceStop()); - settings->setValue("extraParams", reconExtraParamValues()); - settings->endGroup(); - settings->endGroup(); - } - - void writeTestSettings() - { - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("FxiWorkflowWidget"); - settings->beginGroup("TestSettings"); - settings->setValue("steps", ui.steps->value()); - settings->setValue("sli", ui.slice->value()); - settings->setValue("extraParams", testRotationsExtraParamValues()); - settings->endGroup(); - settings->endGroup(); - } - - QList inputWidgets() - { - return { ui.denoiseFlag, ui.denoiseLevel, ui.darkScale, ui.start, - ui.stop, ui.steps, ui.slice, ui.rotationCenter, - ui.sliceStart, ui.sliceStop }; - } - - void startGeneratingTestImages() - { - progressDialog->show(); - auto future = QtConcurrent::run(std::bind(&Internal::generateTestImages, this)); - futureWatcher.setFuture(future); - } - - void testImagesGenerated() - { - updateImageViewSlider(); - if (!testRotationsSuccess) { - auto msg = testRotationsErrorMessage; - qCritical() << msg; - QMessageBox::critical(parent, "Tomviz", msg); - return; - } - - if (rotationDataValid()) { - resetColorRange(); - render(); - } - } - - void generateTestImages() - { - testRotationsSuccess = false; - rotations.clear(); - - { - Python python; - auto module = pythonHelper.loadModule(script); - if (!module.isValid()) { - testRotationsErrorMessage = "Failed to load script"; - return; - } - - auto func = module.findFunction("test_rotations"); - if (!func.isValid()) { - testRotationsErrorMessage = - "Failed to find function \"test_rotations\""; - return; - } - - Python::Object data = Python::createDataset(image, *dataSource); - - Python::Dict kwargs; - kwargs.set("dataset", data); - kwargs.set("start", ui.start->value()); - kwargs.set("stop", ui.stop->value()); - kwargs.set("steps", ui.steps->value()); - kwargs.set("sli", ui.slice->value()); - kwargs.set("denoise_flag", denoiseFlag()); - kwargs.set("denoise_level", denoiseLevel()); - kwargs.set("dark_scale", darkScale()); - - // Add extra parameters - auto extraParams = testRotationsExtraParamValues(); - for (const auto& k : extraParams.keys()) { - kwargs.set(k, toVariant(extraParams[k])); - } - - auto ret = func.call(kwargs); - auto result = ret.toDict(); - if (!result.isValid()) { - testRotationsErrorMessage = "Failed to execute test_rotations()"; - return; - } - - auto pyImages = result["images"]; - auto* object = Python::VTK::convertToDataObject(pyImages); - if (!object) { - testRotationsErrorMessage = - "No image data was returned from test_rotations()"; - return; - } - - auto* imageData = vtkImageData::SafeDownCast(object); - if (!imageData) { - testRotationsErrorMessage = - "No image data was returned from test_rotations()"; - return; - } - - auto centers = result["centers"]; - auto pyRotations = centers.toList(); - if (!pyRotations.isValid() || pyRotations.length() <= 0) { - testRotationsErrorMessage = - "No rotations returned from test_rotations()"; - return; - } - - for (int i = 0; i < pyRotations.length(); ++i) { - rotations.append(pyRotations[i].toDouble()); - } - setRotationData(imageData); - } - - // If we made it this far, it was a success - // Make the axes visible. - axesActor->SetVisibility(true); - - // Save these settings in case the user wants to use them again... - writeTestSettings(); - testRotationsSuccess = true; - } - - void setRotationData(vtkImageData* data) - { - rotationImages = data; - mapper->SetInputData(rotationImages); - mapper->SetSliceNumber(0); - mapper->Update(); - setupRenderer(); - } - - void resetColorRange() - { - if (!rotationDataValid()) { - return; - } - - auto* range = rotationImages->GetScalarRange(); - - auto blocked1 = QSignalBlocker(ui.previewMin); - auto blocked2 = QSignalBlocker(ui.previewMax); - ui.previewMin->setMinimum(range[0]); - ui.previewMin->setMaximum(range[1]); - ui.previewMin->setValue(range[0]); - ui.previewMax->setMinimum(range[0]); - ui.previewMax->setMaximum(range[1]); - ui.previewMax->setValue(range[1]); - - rescaleColors(range); - } - - void rescaleColors(double* range) - { - // Always perform a deep copy of the original color map - // If we always modify the control points of the same LUT, - // the control points will often change and we will end up - // with a very different LUT than we had originally. - resetLut(); - if (!lut) { - return; - } - - auto* tf = vtkColorTransferFunction::SafeDownCast(lut); - if (!tf) { - return; - } - - rescaleLut(tf, range[0], range[1]); - } - - void onPreviewRangeEdited() - { - if (!rotationDataValid() || !lut) { - return; - } - - auto* maxRange = rotationImages->GetScalarRange(); - - double range[2]; - range[0] = ui.previewMin->value(); - range[1] = ui.previewMax->value(); - - auto minDiff = (maxRange[1] - maxRange[0]) / 1000; - if (range[1] - range[0] < minDiff) { - if (sender() == ui.previewMin) { - // Move the max - range[1] = range[0] + minDiff; - auto blocked = QSignalBlocker(ui.previewMax); - ui.previewMax->setValue(range[1]); - } else { - // Move the min - range[0] = range[1] - minDiff; - auto blocked = QSignalBlocker(ui.previewMin); - ui.previewMin->setValue(range[0]); - } - } - - rescaleColors(range); - render(); - } - - void updateControls() - { - std::vector blockers; - for (auto w : inputWidgets()) { - blockers.emplace_back(w); - } - - updateImageViewSlider(); - } - - bool rotationDataValid() - { - if (!rotationImages.GetPointer()) { - return false; - } - - if (rotations.isEmpty()) { - return false; - } - - return true; - } - - void updateImageViewSlider() - { - auto blocked = QSignalBlocker(ui.imageViewSlider); - - bool enable = rotationDataValid(); - ui.testRotationsSettingsGroup->setVisible(enable); - if (!enable) { - return; - } - - auto* dims = rotationImages->GetDimensions(); - ui.imageViewSlider->setMaximum(dims[0] - 1); - - sliceNumber = dims[0] / 2; - ui.imageViewSlider->setValue(sliceNumber); - - sliderEdited(); - } - - void sliderEdited() - { - sliceNumber = ui.imageViewSlider->value(); - if (sliceNumber < rotations.size()) { - ui.currentRotation->setValue(rotations[sliceNumber]); - - // For convenience, also set the rotation center for reconstruction - ui.rotationCenter->setValue(rotations[sliceNumber]); - } else { - qCritical() << sliceNumber - << "is greater than the rotations size:" << rotations.size(); - } - - mapper->SetSliceNumber(sliceNumber); - mapper->Update(); - render(); - } - - bool eventFilter(QObject* o, QEvent* e) override - { - if (inputWidgets().contains(qobject_cast(o))) { - if (e->type() == QEvent::KeyPress) { - QKeyEvent* keyEvent = static_cast(e); - if (keyEvent->key() == Qt::Key_Return || - keyEvent->key() == Qt::Key_Enter) { - e->accept(); - qobject_cast(o)->clearFocus(); - return true; - } - } - } - return QObject::eventFilter(o, e); - } - - void resetLut() - { - auto dsLut = - vtkScalarsToColors::SafeDownCast(colorMap->GetClientSideObject()); - if (!dsLut) { - return; - } - - // Make a deep copy to modify - lut = dsLut->NewInstance(); - lut->DeepCopy(dsLut); - slice->GetProperty()->SetLookupTable(lut); - } - - void setColorMapToGrayscale() - { - ColorMap::instance().applyPreset("Grayscale", colorMap); - } - - void onColorPresetClicked() - { - if (!colorMap) { - qCritical() << "No color map found!"; - return; - } - - PresetDialog dialog(tomviz::mainWidget()); - connect(&dialog, &PresetDialog::applyPreset, this, [this, &dialog]() { - ColorMap::instance().applyPreset(dialog.presetName(), colorMap); - // Keep the range the same - double range[2]; - range[0] = ui.previewMin->value(); - range[1] = ui.previewMax->value(); - rescaleColors(range); - render(); - }); - dialog.exec(); - } - - void setDenoiseFlag(bool b) { ui.denoiseFlag->setChecked(b); } - bool denoiseFlag() const { return ui.denoiseFlag->isChecked(); } - - void setDenoiseLevel(int i) { ui.denoiseLevel->setValue(i); } - int denoiseLevel() const { return ui.denoiseLevel->value(); } - - void setDarkScale(double x) { ui.darkScale->setValue(x); } - double darkScale() const { return ui.darkScale->value(); } - - void setRotationCenter(double center) { ui.rotationCenter->setValue(center); } - double rotationCenter() const { return ui.rotationCenter->value(); } - - void setSliceStart(int i) { ui.sliceStart->setValue(i); } - int sliceStart() const { return ui.sliceStart->value(); } - - void setSliceStop(int i) { ui.sliceStop->setValue(i); } - int sliceStop() const { return ui.sliceStop->value(); } -}; - -#include "FxiWorkflowWidget.moc" - -FxiWorkflowWidget::FxiWorkflowWidget(Operator* op, - vtkSmartPointer image, - QWidget* p) - : CustomPythonOperatorWidget(p) -{ - m_internal.reset(new Internal(op, image, this)); -} - -FxiWorkflowWidget::~FxiWorkflowWidget() = default; - -void FxiWorkflowWidget::getValues(QVariantMap& map) -{ - map.insert("denoise_flag", m_internal->denoiseFlag()); - map.insert("denoise_level", m_internal->denoiseLevel()); - map.insert("dark_scale", m_internal->darkScale()); - map.insert("rotation_center", m_internal->rotationCenter()); - map.insert("slice_start", m_internal->sliceStart()); - map.insert("slice_stop", m_internal->sliceStop()); - - map = unite(map, m_internal->reconExtraParamValues()); -} - -void FxiWorkflowWidget::setValues(const QVariantMap& map) -{ - if (map.contains("denoise_flag")) { - m_internal->setDenoiseFlag(map["denoise_flag"].toBool()); - } - if (map.contains("denoise_level")) { - m_internal->setDenoiseLevel(map["denoise_level"].toInt()); - } - if (map.contains("dark_scale")) { - m_internal->setDarkScale(map["dark_scale"].toDouble()); - } - if (map.contains("rotation_center")) { - m_internal->setRotationCenter(map["rotation_center"].toDouble()); - } - if (map.contains("slice_start")) { - m_internal->setSliceStart(map["slice_start"].toInt()); - } - if (map.contains("slice_stop")) { - m_internal->setSliceStop(map["slice_stop"].toInt()); - } - - m_internal->setReconExtraParamValues(map); -} - -void FxiWorkflowWidget::setScript(const QString& script) -{ - Superclass::setScript(script); - m_internal->script = script; -} - -void FxiWorkflowWidget::setupUI(OperatorPython* op) -{ - Superclass::setupUI(op); - m_internal->setupUI(op); -} - -void FxiWorkflowWidget::writeSettings() -{ - Superclass::writeSettings(); - m_internal->writeSettings(); -} - -} // namespace tomviz diff --git a/tomviz/HistogramManager.cxx b/tomviz/HistogramManager.cxx index f23c7cee5..6695f75bb 100644 --- a/tomviz/HistogramManager.cxx +++ b/tomviz/HistogramManager.cxx @@ -198,15 +198,10 @@ HistogramManager::HistogramManager() // histogram has been finished on the background thread. m_worker->start(); m_histogramGen->moveToThread(m_worker); - connect(m_histogramGen, SIGNAL(histogramDone(vtkSmartPointer, - vtkSmartPointer)), - SLOT(histogramReadyInternal(vtkSmartPointer, - vtkSmartPointer))); - connect(m_histogramGen, - SIGNAL(histogram2DDone(vtkSmartPointer, - vtkSmartPointer)), - SLOT(histogram2DReadyInternal(vtkSmartPointer, - vtkSmartPointer))); + connect(m_histogramGen, &HistogramMaker::histogramDone, this, + &HistogramManager::histogramReadyInternal); + connect(m_histogramGen, &HistogramMaker::histogram2DDone, this, + &HistogramManager::histogram2DReadyInternal); } HistogramManager::~HistogramManager() @@ -221,7 +216,7 @@ void HistogramManager::finalize() // disconnect all signals/slots disconnect(m_histogramGen, nullptr, nullptr, nullptr); // when the HistogramMaker is deleted, kill the background thread - connect(m_histogramGen, SIGNAL(destroyed()), m_worker, SLOT(quit())); + connect(m_histogramGen, &QObject::destroyed, m_worker, &QThread::quit); // I can't remember if deleteLater must be called on the owning thread // play it safe and let the owning thread call it. QMetaObject::invokeMethod(m_histogramGen, "deleteLater"); diff --git a/tomviz/HistogramWidget.cxx b/tomviz/HistogramWidget.cxx index 00e013d05..8aebfc38a 100644 --- a/tomviz/HistogramWidget.cxx +++ b/tomviz/HistogramWidget.cxx @@ -88,19 +88,19 @@ HistogramWidget::HistogramWidget(QWidget* parent) auto button = new QToolButton; button->setIcon(QIcon(":/pqWidgets/Icons/pqResetRange.svg")); button->setToolTip("Reset data range"); - connect(button, SIGNAL(clicked()), this, SLOT(onResetRangeClicked())); + connect(button, &QToolButton::clicked, this, &HistogramWidget::onResetRangeClicked); vLayout->addWidget(button); button = new QToolButton; button->setIcon(QIcon(":/icons/pqResetRangeCustom.png")); button->setToolTip("Specify data range"); - connect(button, SIGNAL(clicked()), this, SLOT(onCustomRangeClicked())); + connect(button, &QToolButton::clicked, this, &HistogramWidget::onCustomRangeClicked); vLayout->addWidget(button); button = new QToolButton; button->setIcon(QIcon(":/pqWidgets/Icons/pqInvert.svg")); button->setToolTip("Invert color map"); - connect(button, SIGNAL(clicked()), this, SLOT(onInvertClicked())); + connect(button, &QToolButton::clicked, this, &HistogramWidget::onInvertClicked); vLayout->addWidget(button); button = new QToolButton; @@ -114,7 +114,7 @@ HistogramWidget::HistogramWidget(QWidget* parent) button = new QToolButton; button->setIcon(QIcon(":/pqWidgets/Icons/pqFavorites.svg")); button->setToolTip("Choose preset color map"); - connect(button, SIGNAL(clicked()), this, SLOT(onPresetClicked())); + connect(button, &QToolButton::clicked, this, &HistogramWidget::onPresetClicked); vLayout->addWidget(button); button = new QToolButton; @@ -122,7 +122,7 @@ HistogramWidget::HistogramWidget(QWidget* parent) button->setIcon(QIcon(":/pqWidgets/Icons/pqSave.svg")); button->setToolTip("Save current color map as a preset"); button->setEnabled(false); - connect(button, SIGNAL(clicked()), this, SLOT(onSaveToPresetClicked())); + connect(button, &QToolButton::clicked, this, &HistogramWidget::onSaveToPresetClicked); vLayout->addWidget(button); button = new QToolButton; @@ -131,8 +131,8 @@ HistogramWidget::HistogramWidget(QWidget* parent) button->setToolTip("Show color legend in the 3D window"); button->setEnabled(false); button->setCheckable(true); - connect(button, SIGNAL(toggled(bool)), this, - SIGNAL(colorLegendToggled(bool))); + connect(button, &QToolButton::toggled, this, + &HistogramWidget::colorLegendToggled); button->setChecked(false); vLayout->addWidget(button); @@ -147,14 +147,15 @@ HistogramWidget::HistogramWidget(QWidget* parent) vLayout->addStretch(1); - connect(&ActiveObjects::instance(), SIGNAL(viewChanged(vtkSMViewProxy*)), - this, SLOT(updateUI())); + connect(&ActiveObjects::instance(), + QOverload::of(&ActiveObjects::viewChanged), + this, [this](vtkSMViewProxy*) { updateUI(); }); connect(&ActiveObjects::instance(), QOverload::of(&ActiveObjects::dataSourceChanged), this, &HistogramWidget::updateColorMapDialogs); - connect(&ModuleManager::instance(), SIGNAL(dataSourceRemoved(DataSource*)), - this, SLOT(updateUI())); - connect(this, SIGNAL(colorMapUpdated()), this, SLOT(updateUI())); + connect(&ModuleManager::instance(), &ModuleManager::dataSourceRemoved, + this, [this](DataSource*) { updateUI(); }); + connect(this, &HistogramWidget::colorMapUpdated, this, &HistogramWidget::updateUI); setLayout(hLayout); } diff --git a/tomviz/IntSliderWidget.cxx b/tomviz/IntSliderWidget.cxx index 184ba2ad5..2058aa009 100644 --- a/tomviz/IntSliderWidget.cxx +++ b/tomviz/IntSliderWidget.cxx @@ -38,13 +38,13 @@ IntSliderWidget::IntSliderWidget(bool showLineEdit, QWidget* p) : QWidget(p) this->LineEdit = nullptr; } - QObject::connect(this->Slider, SIGNAL(valueChanged(int)), this, - SLOT(sliderChanged(int))); + QObject::connect(this->Slider, &QSlider::valueChanged, this, + &IntSliderWidget::sliderChanged); if (showLineEdit) { - QObject::connect(this->LineEdit, SIGNAL(textChanged(const QString&)), this, - SLOT(textChanged(const QString&))); - QObject::connect(this->LineEdit, SIGNAL(textChangedAndEditingFinished()), - this, SLOT(editingFinished())); + QObject::connect(this->LineEdit, &pqLineEdit::textChanged, this, + &IntSliderWidget::textChanged); + QObject::connect(this->LineEdit, &pqLineEdit::textChangedAndEditingFinished, + this, &IntSliderWidget::editingFinished); } } @@ -136,6 +136,9 @@ bool IntSliderWidget::strictRange() const } const QIntValidator* dv = qobject_cast(this->LineEdit->validator()); + if (!dv) { + return false; + } return dv->bottom() == this->minimum() && dv->top() == this->maximum(); } diff --git a/tomviz/InterfaceBuilder.cxx b/tomviz/InterfaceBuilder.cxx index 166b06b01..87dda0bff 100644 --- a/tomviz/InterfaceBuilder.cxx +++ b/tomviz/InterfaceBuilder.cxx @@ -10,11 +10,14 @@ #include "SpinBox.h" #include "Utilities.h" +#include #include #include #include #include +#include #include +#include #include #include #include @@ -22,9 +25,15 @@ #include #include #include +#include #include +#include +#include +#include #include +#include + using tomviz::DataSource; Q_DECLARE_METATYPE(DataSource*) @@ -583,17 +592,156 @@ void addDatasetWidget(QGridLayout* layout, int row, QJsonObject& parameterNode) layout->addWidget(comboBox, row, 1, 1, 1); } +void addSelectScalarsWidget(QGridLayout* layout, int row, + QJsonObject& parameterNode, + DataSource* dataSource) +{ + QJsonValueRef nameValue = parameterNode["name"]; + QJsonValueRef labelValue = parameterNode["label"]; + + if (nameValue.isUndefined()) { + QJsonDocument document(parameterNode); + qWarning() << QString("Parameter %1 has no name. Skipping.") + .arg(document.toJson().data()); + return; + } + + QString name = nameValue.toString(); + + QLabel* label = new QLabel(name); + if (!labelValue.isUndefined()) { + label->setText(labelValue.toString()); + } + layout->addWidget(label, row, 0, 1, 1); + + // Container widget + QWidget* container = new QWidget(); + container->setObjectName(name); + container->setProperty("type", "select_scalars"); + label->setBuddy(container); + + QVBoxLayout* vLayout = new QVBoxLayout(); + vLayout->setContentsMargins(0, 0, 0, 0); + container->setLayout(vLayout); + + // "Apply to all scalars" checkbox + bool showApplyAll = parameterNode.value("show_apply_all").toBool(true); + QCheckBox* applyAllCheckBox = new QCheckBox("Apply to all scalars"); + applyAllCheckBox->setObjectName(name + "_apply_all"); + applyAllCheckBox->setChecked(showApplyAll); + applyAllCheckBox->setVisible(showApplyAll); + vLayout->addWidget(applyAllCheckBox); + + // Checkable combo box for individual scalar selection + QComboBox* comboBox = new QComboBox(); + comboBox->setObjectName(name + "_combo"); + QStandardItemModel* model = new QStandardItemModel(comboBox); + comboBox->setModel(model); + comboBox->setEnabled(!showApplyAll); + + if (dataSource) { + QStringList scalars = dataSource->listScalars(); + for (const QString& scalar : scalars) { + QStandardItem* item = new QStandardItem(scalar); + item->setFlags(Qt::ItemIsUserCheckable | Qt::ItemIsEnabled); + item->setData(Qt::Checked, Qt::CheckStateRole); + model->appendRow(item); + } + + // Restore previous selection from "default" if present + QJsonValueRef defaultNode = parameterNode["default"]; + if (!defaultNode.isUndefined() && defaultNode.isArray()) { + QJsonArray defaultArray = defaultNode.toArray(); + QSet selected; + for (const auto& v : defaultArray) { + selected.insert(v.toString()); + } + + bool allSelected = true; + for (int i = 0; i < model->rowCount(); ++i) { + bool isSelected = selected.contains(model->item(i)->text()); + model->item(i)->setData(isSelected ? Qt::Checked : Qt::Unchecked, + Qt::CheckStateRole); + if (!isSelected) { + allSelected = false; + } + } + applyAllCheckBox->setChecked(showApplyAll && allSelected); + comboBox->setEnabled(!applyAllCheckBox->isChecked()); + } + + // Auto-hide when only one scalar + if (scalars.size() <= 1) { + label->setVisible(false); + container->setVisible(false); + } + } + + vLayout->addWidget(comboBox); + + // Toggle combo box enabled state based on checkbox + QObject::connect(applyAllCheckBox, &QCheckBox::toggled, + [comboBox](bool checked) { + comboBox->setEnabled(!checked); + }); + + // Install event filter on combo box viewport to prevent popup from closing + // on item click, while still toggling the checkbox. Only toggle on release + // if a matching press was seen on the viewport — this ignores the orphaned + // release from the click that originally opened the popup. + class ComboEventFilter : public QObject + { + public: + ComboEventFilter(QComboBox* combo, QObject* parent) + : QObject(parent), m_combo(combo) {} + bool eventFilter(QObject* obj, QEvent* event) override + { + if (event->type() == QEvent::MouseButtonPress) { + m_pressedOnViewport = true; + return true; // Consume press to keep popup open + } + if (event->type() == QEvent::MouseButtonRelease) { + if (!m_pressedOnViewport) { + return true; // No matching press — consume without toggling + } + m_pressedOnViewport = false; + // Manually toggle the check state of the item under the cursor + auto* view = m_combo->view(); + auto index = view->indexAt( + static_cast(event)->pos()); + if (index.isValid()) { + auto* model = + qobject_cast(m_combo->model()); + if (model) { + auto* item = model->itemFromIndex(index); + if (item && (item->flags() & Qt::ItemIsUserCheckable)) { + auto state = item->checkState() == Qt::Checked + ? Qt::Unchecked : Qt::Checked; + item->setCheckState(state); + } + } + } + return true; // Consume the event to keep popup open + } + return QObject::eventFilter(obj, event); + } + private: + QComboBox* m_combo; + bool m_pressedOnViewport = false; + }; + + auto* filter = new ComboEventFilter(comboBox, comboBox); + comboBox->view()->viewport()->installEventFilter(filter); + + layout->addWidget(container, row, 1, 1, 1); +} + static const QStringList PATH_TYPES = { "file", "save_file", "directory" }; } // end anonymous namespace namespace tomviz { -bool setupEnableTriggerAbstract(QWidget* refWidget, QWidget* widget, - const QString& comparator, - const QVariant& compareValue, - bool visibility); - InterfaceBuilder::InterfaceBuilder(QObject* parentObject, DataSource* ds) : QObject(parentObject), m_dataSource(ds) {} @@ -671,6 +819,8 @@ QLayout* InterfaceBuilder::buildParameterInterface(QGridLayout* layout, addStringWidget(layout, i + 1, parameterObject); } else if (typeString == "dataset") { addDatasetWidget(layout, i + 1, parameterObject); + } else if (typeString == "select_scalars") { + addSelectScalarsWidget(layout, i + 1, parameterObject, m_dataSource); } } @@ -687,64 +837,6 @@ void InterfaceBuilder::setupEnableAndVisibleStates( setupEnableStates(parent, parameters, false); } -void InterfaceBuilder::setupEnableStates(const QObject* parent, - QJsonArray& parameters, - bool visible) const -{ - static const QStringList validComparators = { - "==", "!=", ">", ">=", "<", "<=" - }; - - QJsonObject::size_type numParameters = parameters.size(); - for (QJsonObject::size_type i = 0; i < numParameters; ++i) { - QJsonValueRef parameterNode = parameters[i]; - QJsonObject parameterObject = parameterNode.toObject(); - - QString text = visible ? "visible_if" : "enable_if"; - QString enableIfValue = parameterObject[text].toString(""); - if (enableIfValue.isEmpty()) { - continue; - } - - QString widgetName = parameterObject["name"].toString(""); - if (widgetName.isEmpty()) { - qCritical() << text << "parameters must have a name. Ignoring..."; - continue; - } - auto* widget = parent->findChild(widgetName); - if (!widget) { - qCritical() << "Failed to find widget with name:" << widgetName; - continue; - } - - auto split = enableIfValue.simplified().split(" "); - if (split.size() != 3) { - qCritical() << "Invalid" << text << "string:" << enableIfValue; - continue; - } - - auto refWidgetName = split[0]; - auto comparator = split[1]; - auto compareValue = split[2]; - auto* refWidget = parent->findChild(refWidgetName); - - if (!refWidget) { - qCritical() << "Invalid widget name in" << text << "string:" << enableIfValue; - continue; - } - - if (!validComparators.contains(comparator)) { - qCritical() << "Invalid comparator in" << text << "string:" << enableIfValue; - continue; - } - - if (!setupEnableTriggerAbstract(refWidget, widget, comparator, - compareValue, visible)) { - qCritical() << "Failed to set up" << text << "trigger for" << widgetName; - } - } -} - QLayout* InterfaceBuilder::buildInterface() const { QWidget* widget = new QWidget; @@ -793,6 +885,48 @@ static bool setWidgetValue(QObject* o, const QVariant& v) { // Returns true if the widget type was found, false otherwise. + // Handle select_scalars container widget + if (auto w = qobject_cast(o)) { + if (w->property("type").toString() == "select_scalars") { + QStringList selected; + if (v.canConvert()) { + for (const auto& item : v.toList()) { + selected << item.toString(); + } + } else if (v.canConvert()) { + selected = v.toStringList(); + } + + auto* applyAllCB = w->findChild(w->objectName() + "_apply_all"); + auto* combo = w->findChild(w->objectName() + "_combo"); + if (!applyAllCB || !combo) { + return false; + } + + auto* model = qobject_cast(combo->model()); + if (!model) { + return false; + } + + // Check if all items are selected + bool allSelected = true; + for (int i = 0; i < model->rowCount(); ++i) { + if (!selected.contains(model->item(i)->text())) { + allSelected = false; + break; + } + } + + applyAllCB->setChecked(allSelected); + for (int i = 0; i < model->rowCount(); ++i) { + Qt::CheckState state = selected.contains(model->item(i)->text()) + ? Qt::Checked : Qt::Unchecked; + model->item(i)->setData(state, Qt::CheckStateRole); + } + return true; + } + } + if (auto cb = qobject_cast(o)) { cb->setChecked(v.toBool()); } else if (auto sb = qobject_cast(o)) { @@ -861,10 +995,53 @@ QVariantMap InterfaceBuilder::parameterValues(const QObject* parent) { QVariantMap map; + // Handle select_scalars widgets first, and collect their internal widget + // names so we can skip them in the generic loops below. + QSet selectScalarsInternalNames; + QList allWidgets = parent->findChildren(); + for (auto* w : allWidgets) { + if (w->property("type").toString() != "select_scalars") { + continue; + } + QString name = w->objectName(); + auto* applyAllCB = w->findChild(name + "_apply_all"); + auto* combo = w->findChild(name + "_combo"); + if (!applyAllCB || !combo) { + continue; + } + + selectScalarsInternalNames.insert(applyAllCB->objectName()); + selectScalarsInternalNames.insert(combo->objectName()); + + auto* model = qobject_cast(combo->model()); + if (!model) { + continue; + } + + QVariantList selectedScalars; + if (applyAllCB->isChecked()) { + // All scalars selected + for (int i = 0; i < model->rowCount(); ++i) { + selectedScalars << model->item(i)->text(); + } + } else { + // Only checked scalars + for (int i = 0; i < model->rowCount(); ++i) { + if (model->item(i)->checkState() == Qt::Checked) { + selectedScalars << model->item(i)->text(); + } + } + } + map[name] = selectedScalars; + } + // Iterate over all children, taking the value of the named widgets - // and stuffing them into the map.pathField->setProperty("type", type); + // and stuffing them into the map. QList checkBoxes = parent->findChildren(); for (int i = 0; i < checkBoxes.size(); ++i) { + if (selectScalarsInternalNames.contains(checkBoxes[i]->objectName())) { + continue; + } map[checkBoxes[i]->objectName()] = (checkBoxes[i]->checkState() == Qt::Checked); } @@ -882,6 +1059,9 @@ QVariantMap InterfaceBuilder::parameterValues(const QObject* parent) QList comboBoxes = parent->findChildren(); for (int i = 0; i < comboBoxes.size(); ++i) { + if (selectScalarsInternalNames.contains(comboBoxes[i]->objectName())) { + continue; + } int currentIndex = comboBoxes[i]->currentIndex(); map[comboBoxes[i]->objectName()] = comboBoxes[i]->itemData(currentIndex); } @@ -1047,51 +1227,189 @@ bool compare(const T* widget, const QVariant& compareValue, return false; } -template -bool setupEnableTrigger(T* refWidget, QWidget* widget, - const QString& comparator, const QVariant& compareValue, - const char* property) +// Represents a single condition clause like "algorithm == 'mlem'" +struct EnableCondition { - // Set up the callback function - auto func = [=](){ - auto result = compare(refWidget, compareValue, comparator); - setWidgetProperty(widget, property, result); - }; - // Make the connection - widget->connect(refWidget, changedSignal(), widget, func); + QWidget* refWidget = nullptr; + QString comparator; + QVariant compareValue; +}; - // Trigger the update one time, since defaults are already set. - func(); +// Evaluate a single condition by delegating to the typed compare() function +static bool evaluateCondition(const EnableCondition& cond) +{ + auto* w = cond.refWidget; + if (isWidgetType(w)) { + return compare(qobject_cast(w), cond.compareValue, cond.comparator); + } else if (isWidgetType(w)) { + return compare(qobject_cast(w), cond.compareValue, cond.comparator); + } else if (isWidgetType(w)) { + return compare(qobject_cast(w), cond.compareValue, cond.comparator); + } else if (isWidgetType(w)) { + return compare(qobject_cast(w), cond.compareValue, cond.comparator); + } else if (isWidgetType(w)) { + return compare(qobject_cast(w), cond.compareValue, cond.comparator); + } + return false; +} - return true; +// Evaluate a compound expression: list of condition groups joined by "or", +// where each group is a list of conditions joined by "and". +// Result = (g0[0] && g0[1] && ...) || (g1[0] && g1[1] && ...) || ... +static bool evaluateCompound( + const QList>& orGroups) +{ + for (auto& andGroup : orGroups) { + bool groupResult = true; + for (auto& cond : andGroup) { + if (!evaluateCondition(cond)) { + groupResult = false; + break; + } + } + if (groupResult) { + return true; + } + } + return false; } -bool setupEnableTriggerAbstract(QWidget* refWidget, QWidget* widget, - const QString& comparator, - const QVariant& compareValue, - bool visibility) +static void connectWidgetChanged(QWidget* refWidget, QWidget* target, + std::function func) { - const char* property = visibility ? "visible" : "enabled"; if (isWidgetType(refWidget)) { - return setupEnableTrigger(qobject_cast(refWidget), widget, - comparator, compareValue, property); + target->connect(qobject_cast(refWidget), + changedSignal(), target, func); } else if (isWidgetType(refWidget)) { - return setupEnableTrigger(qobject_cast(refWidget), widget, - comparator, compareValue, property); + target->connect(qobject_cast(refWidget), + changedSignal(), target, func); } else if (isWidgetType(refWidget)) { - return setupEnableTrigger(qobject_cast(refWidget), widget, - comparator, compareValue, property); + target->connect(qobject_cast(refWidget), + changedSignal(), target, func); } else if (isWidgetType(refWidget)) { - return setupEnableTrigger(qobject_cast(refWidget), widget, - comparator, compareValue, property); + target->connect(qobject_cast(refWidget), + changedSignal(), target, func); } else if (isWidgetType(refWidget)) { - return setupEnableTrigger(qobject_cast(refWidget), widget, - comparator, compareValue, property); + target->connect(qobject_cast(refWidget), + changedSignal(), target, func); + } else { + qCritical() << "Unhandled widget type for enable/visible trigger:" + << refWidget->objectName(); } +} - qCritical() << "Unhandled widget type for object: " - << refWidget->objectName(); - return false; +void InterfaceBuilder::setupEnableStates(const QObject* parent, + QJsonArray& parameters, + bool visible) const +{ + static const QStringList validComparators = { + "==", "!=", ">", ">=", "<", "<=" + }; + + QJsonObject::size_type numParameters = parameters.size(); + for (QJsonObject::size_type i = 0; i < numParameters; ++i) { + QJsonValueRef parameterNode = parameters[i]; + QJsonObject parameterObject = parameterNode.toObject(); + + QString text = visible ? "visible_if" : "enable_if"; + QString enableIfValue = parameterObject[text].toString(""); + if (enableIfValue.isEmpty()) { + continue; + } + + QString widgetName = parameterObject["name"].toString(""); + if (widgetName.isEmpty()) { + qCritical() << text << "parameters must have a name. Ignoring..."; + continue; + } + auto* widget = parent->findChild(widgetName); + if (!widget) { + qCritical() << "Failed to find widget with name:" << widgetName; + continue; + } + + // Split on " or " first, then each piece on " and ". + // Precedence: "and" binds tighter than "or". + auto orParts = enableIfValue.simplified().split(" or ", + Qt::KeepEmptyParts, + Qt::CaseInsensitive); + + QList> orGroups; + bool parseError = false; + + for (auto& orPart : orParts) { + auto andParts = orPart.simplified().split(" and ", + Qt::KeepEmptyParts, + Qt::CaseInsensitive); + QList andGroup; + for (auto& clause : andParts) { + auto tokens = clause.simplified().split(" "); + if (tokens.size() != 3) { + qCritical() << "Invalid" << text << "clause:" << clause + << "in expression:" << enableIfValue; + parseError = true; + break; + } + + auto refWidgetName = tokens[0]; + auto comparator = tokens[1]; + auto compareValue = tokens[2]; + + auto* refWidget = parent->findChild(refWidgetName); + if (!refWidget) { + qCritical() << "Invalid widget name" << refWidgetName << "in" + << text << "string:" << enableIfValue; + parseError = true; + break; + } + + if (!validComparators.contains(comparator)) { + qCritical() << "Invalid comparator" << comparator << "in" + << text << "string:" << enableIfValue; + parseError = true; + break; + } + + EnableCondition cond; + cond.refWidget = refWidget; + cond.comparator = comparator; + cond.compareValue = compareValue; + andGroup.append(cond); + } + + if (parseError) { + break; + } + orGroups.append(andGroup); + } + + if (parseError) { + continue; + } + + const char* property = visible ? "visible" : "enabled"; + + // Build the evaluation callback + auto evalFunc = [orGroups, widget, property]() { + bool result = evaluateCompound(orGroups); + setWidgetProperty(widget, property, result); + }; + + // Connect every referenced widget's changed signal to re-evaluate + QSet connectedWidgets; + for (auto& andGroup : orGroups) { + for (auto& cond : andGroup) { + if (connectedWidgets.contains(cond.refWidget)) { + continue; + } + connectedWidgets.insert(cond.refWidget); + connectWidgetChanged(cond.refWidget, widget, evalFunc); + } + } + + // Evaluate once for the initial state + evalFunc(); + } } } // namespace tomviz diff --git a/tomviz/LoadPaletteReaction.cxx b/tomviz/LoadPaletteReaction.cxx index cd27aa998..a50ccc47d 100644 --- a/tomviz/LoadPaletteReaction.cxx +++ b/tomviz/LoadPaletteReaction.cxx @@ -36,10 +36,12 @@ LoadPaletteReaction::LoadPaletteReaction(QAction* parentObject) m_menu = new QMenu(); m_menu->setObjectName("LoadPaletteMenu"); parentObject->setMenu(m_menu); - connect(m_menu, SIGNAL(aboutToShow()), SLOT(populateMenu())); - connect(&pqActiveObjects::instance(), SIGNAL(serverChanged(pqServer*)), - SLOT(updateEnableState())); - connect(m_menu, SIGNAL(triggered(QAction*)), SLOT(actionTriggered(QAction*))); + connect(m_menu, &QMenu::aboutToShow, this, + &LoadPaletteReaction::populateMenu); + connect(&pqActiveObjects::instance(), &pqActiveObjects::serverChanged, this, + &LoadPaletteReaction::updateEnableState); + connect(m_menu, &QMenu::triggered, this, + &LoadPaletteReaction::actionTriggered); } LoadPaletteReaction::~LoadPaletteReaction() diff --git a/tomviz/MainWindow.cxx b/tomviz/MainWindow.cxx index 4f6da8816..f85c7b352 100644 --- a/tomviz/MainWindow.cxx +++ b/tomviz/MainWindow.cxx @@ -124,9 +124,11 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) // Update back light azimuth default on view. connect(pqApplicationCore::instance()->getServerManagerModel(), &pqServerManagerModel::viewAdded, [](pqView* view) { - vtkSMPropertyHelper helper(view->getProxy(), "BackLightAzimuth"); - // See https://github.com/OpenChemistry/tomviz/issues/1525 - helper.Set(60); + if (view && view->getProxy()->IsA("vtkSMRenderViewProxy")) { + vtkSMPropertyHelper helper(view->getProxy(), "BackLightAzimuth"); + // See https://github.com/OpenChemistry/tomviz/issues/1525 + helper.Set(60); + } }); // checkOpenGL(); @@ -134,7 +136,7 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) // Force full messages to be shown m_ui->outputWidget->showFullMessages(true); m_timer = new QTimer(this); - connect(m_timer, SIGNAL(timeout()), SLOT(autosave())); + connect(m_timer, &QTimer::timeout, this, &MainWindow::autosave); m_timer->start(5 /*minutes*/ * 60 /*seconds per minute*/ * 1000 /*msec per second*/); @@ -186,51 +188,45 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) resizeDocks(docks, dockSizes, Qt::Vertical); // raise dockWidgetMessages on error. - connect(m_ui->outputWidget, SIGNAL(messageDisplayed(const QString&, int)), - SLOT(handleMessage(const QString&, int))); + connect(m_ui->outputWidget, &pqOutputWidget::messageDisplayed, this, + &MainWindow::handleMessage); // Link the histogram in the central widget to the active data source. m_ui->centralWidget->connect( &ActiveObjects::instance(), &ActiveObjects::transformedDataSourceActivated, m_ui->centralWidget, &CentralWidget::setActiveColorMapDataSource); - m_ui->centralWidget->connect(&ActiveObjects::instance(), - SIGNAL(moduleActivated(Module*)), - SLOT(setActiveModule(Module*))); - m_ui->centralWidget->connect(&ActiveObjects::instance(), - SIGNAL(colorMapChanged(DataSource*)), - SLOT(setActiveColorMapDataSource(DataSource*))); - m_ui->centralWidget->connect(m_ui->dataPropertiesPanel, - SIGNAL(colorMapUpdated()), - SLOT(onColorMapUpdated())); - m_ui->centralWidget->connect(&ActiveObjects::instance(), - SIGNAL(operatorActivated(Operator*)), - SLOT(setActiveOperator(Operator*))); + connect(&ActiveObjects::instance(), &ActiveObjects::moduleActivated, + m_ui->centralWidget, &CentralWidget::setActiveModule); + connect(&ActiveObjects::instance(), &ActiveObjects::colorMapChanged, + m_ui->centralWidget, &CentralWidget::setActiveColorMapDataSource); + connect(m_ui->dataPropertiesPanel, &DataPropertiesPanel::colorMapUpdated, + m_ui->centralWidget, &CentralWidget::onColorMapUpdated); + connect(&ActiveObjects::instance(), &ActiveObjects::operatorActivated, + m_ui->centralWidget, &CentralWidget::setActiveOperator); m_ui->treeWidget->setModel(new PipelineModel(this)); m_ui->treeWidget->initLayout(); // Ensure that items are expanded by default, can be collapsed at will. - connect(m_ui->treeWidget->model(), - SIGNAL(rowsInserted(QModelIndex, int, int)), m_ui->treeWidget, - SLOT(expandAll())); - connect(m_ui->treeWidget->model(), SIGNAL(modelReset()), m_ui->treeWidget, - SLOT(expandAll())); + connect(m_ui->treeWidget->model(), &QAbstractItemModel::rowsInserted, + m_ui->treeWidget, &QTreeView::expandAll); + connect(m_ui->treeWidget->model(), &QAbstractItemModel::modelReset, + m_ui->treeWidget, &QTreeView::expandAll); // connect quit. - connect(m_ui->actionExit, SIGNAL(triggered()), SLOT(close())); + connect(m_ui->actionExit, &QAction::triggered, this, &MainWindow::close); // Connect up the module/data changed to the appropriate slots. - connect(&ActiveObjects::instance(), SIGNAL(dataSourceActivated(DataSource*)), - SLOT(dataSourceChanged(DataSource*))); - connect(&ActiveObjects::instance(), - SIGNAL(moleculeSourceActivated(MoleculeSource*)), - SLOT(moleculeSourceChanged(MoleculeSource*))); - connect(&ActiveObjects::instance(), SIGNAL(moduleActivated(Module*)), - SLOT(moduleChanged(Module*))); - connect(&ActiveObjects::instance(), SIGNAL(operatorActivated(Operator*)), - SLOT(operatorChanged(Operator*))); - connect(&ActiveObjects::instance(), SIGNAL(resultActivated(OperatorResult*)), - SLOT(operatorResultChanged(OperatorResult*))); + connect(&ActiveObjects::instance(), &ActiveObjects::dataSourceActivated, this, + &MainWindow::dataSourceChanged); + connect(&ActiveObjects::instance(), &ActiveObjects::moleculeSourceActivated, + this, &MainWindow::moleculeSourceChanged); + connect(&ActiveObjects::instance(), &ActiveObjects::moduleActivated, this, + &MainWindow::moduleChanged); + connect(&ActiveObjects::instance(), &ActiveObjects::operatorActivated, this, + &MainWindow::operatorChanged); + connect(&ActiveObjects::instance(), &ActiveObjects::resultActivated, this, + &MainWindow::operatorResultChanged); // Connect the about dialog up too. connect(m_ui->actionAbout, &QAction::triggered, this, @@ -332,6 +328,8 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) m_ui->menuTomography->addAction("Tilt Axis Shift Alignment (Auto)"); QAction* rotateAlignAction = m_ui->menuTomography->addAction("Tilt Axis Alignment (Manual)"); + QAction* shiftRotationCenterAction = + m_ui->menuTomography->addAction("Shift Rotation Center (Manual)"); m_ui->menuTomography->addSeparator(); QAction* reconLabel = m_ui->menuTomography->addAction("Reconstruction:"); @@ -351,8 +349,7 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) QAction* reconTVMinimizationAction = m_ui->menuTomography->addAction("TV Minimization Method"); QAction* reconTomoPyGridRecAction = - m_ui->menuTomography->addAction("TomoPy Gridrec Method"); - QAction* fxiWorkflowAction = m_ui->menuTomography->addAction("FXI Workflow"); + m_ui->menuTomography->addAction("TomoPy Reconstruction"); m_ui->menuTomography->addSeparator(); QAction* simulationLabel = @@ -411,10 +408,10 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) rotateAlignAction, "Tilt Axis Alignment (manual)", readInPythonScript("RotationAlign"), true, false, false, readInJSONDescription("RotationAlign")); - // new AddRotateAlignReaction(rotateAlignAction); new AddPythonTransformReaction( autoRotateAlignAction, "Auto Tilt Axis Align", - readInPythonScript("AutoTiltAxisRotationAlignment"), true); + readInPythonScript("AutoTiltAxisRotationAlignment"), true, false, false, + readInJSONDescription("AutoTiltAxisRotationAlignment")); new AddPythonTransformReaction( autoRotateAlignShiftAction, "Auto Tilt Axis Shift Align", readInPythonScript("AutoTiltAxisShiftAlignment"), true, false, false, @@ -432,6 +429,10 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) autoAlignPyStackRegAction, "Auto Tilt Image Align (PyStackReg)", readInPythonScript("PyStackRegImageAlignment"), false, false, false, readInJSONDescription("PyStackRegImageAlignment")); + new AddPythonTransformReaction( + shiftRotationCenterAction, "Shift Rotation Center", + readInPythonScript("ShiftRotationCenter_tomopy"), true, false, false, + readInJSONDescription("ShiftRotationCenter_tomopy")); new AddPythonTransformReaction(reconDFMAction, "Reconstruct (Direct Fourier)", readInPythonScript("Recon_DFT"), true, false, @@ -455,13 +456,9 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) readInPythonScript("Recon_TV_minimization"), true, false, false, readInJSONDescription("Recon_TV_minimization")); new AddPythonTransformReaction( - reconTomoPyGridRecAction, "Reconstruct (TomoPy Gridrec)", - readInPythonScript("Recon_tomopy_gridrec"), true, false, false, - readInJSONDescription("Recon_tomopy_gridrec")); - new AddPythonTransformReaction( - fxiWorkflowAction, "Reconstruct (FXI Workflow)", - readInPythonScript("Recon_tomopy_fxi"), true, false, false, - readInJSONDescription("Recon_tomopy_fxi")); + reconTomoPyGridRecAction, "Reconstruct (TomoPy)", + readInPythonScript("Recon_tomopy"), true, false, false, + readInJSONDescription("Recon_tomopy")); new ReconstructionReaction(reconWBP_CAction); @@ -503,16 +500,16 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) QMenu* sampleDataMenu = new QMenu("Sample Data", this); m_ui->menubar->insertMenu(m_ui->menuHelp->menuAction(), sampleDataMenu); QAction* userGuideAction = m_ui->menuHelp->addAction("User Guide"); - connect(userGuideAction, SIGNAL(triggered()), SLOT(openUserGuide())); + connect(userGuideAction, &QAction::triggered, this, &MainWindow::openUserGuide); QAction* introAction = m_ui->menuHelp->addAction("Intro to 3D Visualization"); - connect(introAction, SIGNAL(triggered()), SLOT(openVisIntro())); + connect(introAction, &QAction::triggered, this, &MainWindow::openVisIntro); #ifdef TOMVIZ_DATA QAction* reconAction = sampleDataMenu->addAction("Star Nanoparticle (Reconstruction)"); QAction* tiltAction = sampleDataMenu->addAction("Star Nanoparticle (Tilt Series)"); - connect(reconAction, SIGNAL(triggered()), SLOT(openRecon())); - connect(tiltAction, SIGNAL(triggered()), SLOT(openTilt())); + connect(reconAction, &QAction::triggered, this, &MainWindow::openRecon); + connect(tiltAction, &QAction::triggered, this, &MainWindow::openTilt); sampleDataMenu->addSeparator(); #endif QAction* constantDataAction = @@ -530,7 +527,7 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) sampleDataMenu->addSeparator(); QAction* sampleDataLinkAction = sampleDataMenu->addAction("Download More Datasets"); - connect(sampleDataLinkAction, SIGNAL(triggered()), SLOT(openDataLink())); + connect(sampleDataLinkAction, &QAction::triggered, this, &MainWindow::openDataLink); QAction* loadPaletteAction = m_ui->utilitiesToolbar->addAction( QIcon(":pqWidgets/Icons/pqPalette.svg"), "LoadPalette"); @@ -842,7 +839,7 @@ void MainWindow::showEvent(QShowEvent* e) QMainWindow::showEvent(e); if (m_isFirstShow) { m_isFirstShow = false; - QTimer::singleShot(1, this, SLOT(onFirstWindowShow())); + QTimer::singleShot(1, this, &MainWindow::onFirstWindowShow); } } @@ -1042,8 +1039,8 @@ void MainWindow::registerCustomOperators( QAction* importCustomTransformAction = m_customTransformsMenu->addAction("Import Custom Transform..."); m_customTransformsMenu->addSeparator(); - connect(importCustomTransformAction, SIGNAL(triggered()), - SLOT(importCustomTransform())); + connect(importCustomTransformAction, &QAction::triggered, this, + &MainWindow::importCustomTransform); if (!operators.empty()) { for (const OperatorDescription& op : operators) { @@ -1188,7 +1185,7 @@ void MainWindow::findPipelineTemplates() { m_pipelineTemplates->addSeparator(); QAction* actionSaveTemplate = m_pipelineTemplates->addAction("Save Template"); new SaveLoadTemplateReaction(actionSaveTemplate); - connect(actionSaveTemplate, SIGNAL(triggered()), SLOT(findPipelineTemplates())); + connect(actionSaveTemplate, &QAction::triggered, this, &MainWindow::findPipelineTemplates); } } // namespace tomviz diff --git a/tomviz/MoleculePropertiesPanel.cxx b/tomviz/MoleculePropertiesPanel.cxx index 7000b72b3..0a780241c 100644 --- a/tomviz/MoleculePropertiesPanel.cxx +++ b/tomviz/MoleculePropertiesPanel.cxx @@ -31,8 +31,8 @@ MoleculePropertiesPanel::MoleculePropertiesPanel(QWidget* parent) this->setLayout(m_layout); connect(&ActiveObjects::instance(), - SIGNAL(moleculeSourceChanged(MoleculeSource*)), - SLOT(setMoleculeSource(MoleculeSource*))); + &ActiveObjects::moleculeSourceChanged, this, + &MoleculePropertiesPanel::setMoleculeSource); update(); } diff --git a/tomviz/MoveActiveObject.cxx b/tomviz/MoveActiveObject.cxx index 499168a4d..94f26e87e 100644 --- a/tomviz/MoveActiveObject.cxx +++ b/tomviz/MoveActiveObject.cxx @@ -45,10 +45,11 @@ MoveActiveObject::MoveActiveObject(QObject* p) : Superclass(p) this->BoxWidget->SetRepresentation(this->BoxRep.GetPointer()); this->BoxWidget->SetPriority(1); - this->connect(&activeObjs, SIGNAL(dataSourceActivated(DataSource*)), - SLOT(dataSourceActivated(DataSource*))); - this->connect(&activeObjs, SIGNAL(viewChanged(vtkSMViewProxy*)), - SLOT(onViewChanged(vtkSMViewProxy*))); + this->connect(&activeObjs, &ActiveObjects::dataSourceActivated, + this, &MoveActiveObject::dataSourceActivated); + this->connect(&activeObjs, + QOverload::of(&ActiveObjects::viewChanged), + this, &MoveActiveObject::onViewChanged); this->connect(&activeObjs, &ActiveObjects::interactionDataSourceFixed, this, &MoveActiveObject::onInteractionDataSourceFixed); diff --git a/tomviz/Pipeline.cxx b/tomviz/Pipeline.cxx index 141aa3e05..77ea03adb 100644 --- a/tomviz/Pipeline.cxx +++ b/tomviz/Pipeline.cxx @@ -188,16 +188,23 @@ Pipeline::Future* Pipeline::execute(DataSource* dataSource) return emptyFuture(); } - return execute(dataSource, firstModifiedOperator); + return executeRange(dataSource, firstModifiedOperator, nullptr, true); } Pipeline::Future* Pipeline::execute(DataSource* ds, Operator* start) { - return execute(ds, start, nullptr); + return executeRange(ds, start, nullptr, true); } Pipeline::Future* Pipeline::execute(DataSource* ds, Operator* start, Operator* end) +{ + return executeRange(ds, start, end, false); +} + +Pipeline::Future* Pipeline::executeRange(DataSource* ds, Operator* start, + Operator* end, + bool checkBreakpoints) { if (paused()) { return emptyFuture(); @@ -220,7 +227,6 @@ Pipeline::Future* Pipeline::execute(DataSource* ds, Operator* start, return future; } int startIndex = 0; - // We currently only support running the last operator or the entire pipeline. if (start == nullptr) { start = operators.first(); } @@ -243,6 +249,38 @@ Pipeline::Future* Pipeline::execute(DataSource* ds, Operator* start, ds = transformedDataSource(ds); } } + // If start is not the first operator and we haven't already adjusted + // startIndex (e.g. when resuming from a breakpoint), start from the + // correct position using the already-transformed intermediate data. + // but can only use the already transformed intermediate data if the + // previous operator is the one that created it, otherwise operators + // could be applied multiple times to already transformed data. + else if (start != operators.first() && startIndex == 0) { + startIndex = operators.indexOf(start); + if (startIndex > 0) { + auto prevOp = operators[startIndex - 1]; + // We can only use intermediate data if: + // 1. The previous op completed (so its output is valid) + // 2. The current op hasn't run yet (Queued) + // 3. No operators after start have already finished (would mean + // mid-chain insertion/edit, not a breakpoint resume) + bool canUseIntermediateData = + prevOp->isCompleted() && start->isQueued(); + if (canUseIntermediateData) { + for (int i = startIndex + 1; i < operators.size(); ++i) { + if (operators[i]->isFinished()) { + canUseIntermediateData = false; + break; + } + } + } + if (canUseIntermediateData) { + ds = transformedDataSource(ds); + } else { + startIndex = 0; + } + } + } // If we have been asked to run until the new operator we can just return // the transformed data. @@ -262,6 +300,27 @@ Pipeline::Future* Pipeline::execute(DataSource* ds, Operator* start, endIndex = operators.indexOf(end); } + // Search for breakpoints within the actual execution range. This happens + // AFTER startIndex determination so that the search covers the real range + // (which may start earlier than the modified operator). + Operator* breakpointOp = nullptr; + if (checkBreakpoints) { + int searchEnd = (endIndex == -1) ? operators.size() : endIndex; + for (int i = startIndex; i < searchEnd; ++i) { + if (operators[i]->hasBreakpoint()) { + breakpointOp = operators[i]; + break; + } + } + if (breakpointOp) { + int bpIdx = operators.indexOf(breakpointOp); + for (int i = bpIdx; i < operators.size(); ++i) { + operators[i]->resetState(); + } + endIndex = bpIdx; + } + } + auto branchFuture = m_executor->execute(ds->dataObject(), operators, startIndex, endIndex); connect(branchFuture, &Pipeline::Future::finished, this, @@ -272,6 +331,11 @@ Pipeline::Future* Pipeline::execute(DataSource* ds, Operator* start, connect(pipelineFuture, &Pipeline::Future::finished, this, &Pipeline::finished); + if (breakpointOp) { + connect(pipelineFuture, &Pipeline::Future::finished, this, + [this, breakpointOp]() { emit breakpointReached(breakpointOp); }); + } + return pipelineFuture; } @@ -362,8 +426,13 @@ void Pipeline::branchFinished() // hasChildDataSource is true. auto lastOp = start->operators().last(); if (!lastOp->isCompleted()) { - // Cannot continue - return; + // The DataSource's last operator hasn't completed. This can happen when + // execution stopped early (e.g. at a breakpoint). If the last actually + // executed operator completed, update the visualization with the + // intermediate result; otherwise bail out. + if (operators.isEmpty() || !operators.last()->isCompleted()) { + return; + } } if (!lastOp->hasChildDataSource()) { @@ -488,9 +557,11 @@ void Pipeline::addDataSource(DataSource* dataSource) &Operator::newChildDataSource), [this](DataSource* ds) { addDataSource(ds); }); - // We need to ensure we move add datasource to the end of the branch + // We need to ensure we move add datasource to the end of the branch, + // but only if the new operator is the last one (appended). For mid-chain + // insertions, the child DataSource should stay where it is. auto operators = op->dataSource()->operators(); - if (operators.size() > 1) { + if (operators.size() > 1 && op == operators.last()) { auto transformedDataSourceOp = findTransformedDataSourceOperator(op->dataSource()); if (transformedDataSourceOp != nullptr) { diff --git a/tomviz/Pipeline.h b/tomviz/Pipeline.h index 7fe1c1b70..af0da0a40 100644 --- a/tomviz/Pipeline.h +++ b/tomviz/Pipeline.h @@ -122,6 +122,9 @@ public slots: /// This signal is fired the execution of the pipeline finishes. void finished(); + /// This signal is fired when execution stops at a breakpoint operator. + void breakpointReached(Operator* op); + /// This signal is fired when an operator is added. The second argument /// is the datasource that should be moved to become its output in the /// pipeline view (or null if there isn't one). @@ -138,6 +141,8 @@ private slots: void addDataSource(DataSource* dataSource); bool beingEdited(DataSource* dataSource) const; bool isModified(DataSource* dataSource, Operator** firstModified) const; + Future* executeRange(DataSource* ds, Operator* start, Operator* end, + bool checkBreakpoints); DataSource* m_data; bool m_paused = false; diff --git a/tomviz/PipelineExecutor.cxx b/tomviz/PipelineExecutor.cxx index d64d8e44c..e69c00ccd 100644 --- a/tomviz/PipelineExecutor.cxx +++ b/tomviz/PipelineExecutor.cxx @@ -211,8 +211,13 @@ ExternalPipelineExecutor::ExternalPipelineExecutor(Pipeline* pipeline) void ExternalPipelineExecutor::displayError(const QString& title, const QString& msg) { - QMessageBox::critical(tomviz::mainWidget(), title, msg); qCritical() << msg; + QMessageBox msgBox(tomviz::mainWidget()); + msgBox.setIcon(QMessageBox::Critical); + msgBox.setWindowTitle(title); + msgBox.setText("An error occurred during external pipeline execution"); + msgBox.setDetailedText(msg); + msgBox.exec(); } QString ExternalPipelineExecutor::workingDir() diff --git a/tomviz/PipelineModel.cxx b/tomviz/PipelineModel.cxx index 9ad99f51d..45a9795a2 100644 --- a/tomviz/PipelineModel.cxx +++ b/tomviz/PipelineModel.cxx @@ -317,31 +317,30 @@ PipelineModel::TreeItem* PipelineModel::TreeItem::find(MoleculeSource* source) PipelineModel::PipelineModel(QObject* p) : QAbstractItemModel(p) { - connect(&ModuleManager::instance(), SIGNAL(dataSourceAdded(DataSource*)), - SLOT(dataSourceAdded(DataSource*))); - connect(&ModuleManager::instance(), SIGNAL(childDataSourceAdded(DataSource*)), - SLOT(childDataSourceAdded(DataSource*))); - connect(&ModuleManager::instance(), SIGNAL(moduleAdded(Module*)), - SLOT(moduleAdded(Module*))); - connect(&ModuleManager::instance(), - SIGNAL(moleculeSourceAdded(MoleculeSource*)), - SLOT(moleculeSourceAdded(MoleculeSource*))); - - connect(&ActiveObjects::instance(), SIGNAL(viewChanged(vtkSMViewProxy*)), - SIGNAL(modelReset())); - connect(&ModuleManager::instance(), SIGNAL(dataSourceRemoved(DataSource*)), - SLOT(dataSourceRemoved(DataSource*))); - connect(&ModuleManager::instance(), - SIGNAL(moleculeSourceRemoved(MoleculeSource*)), - SLOT(moleculeSourceRemoved(MoleculeSource*))); - connect(&ModuleManager::instance(), SIGNAL(moduleRemoved(Module*)), - SLOT(moduleRemoved(Module*))); - connect(&ModuleManager::instance(), - SIGNAL(childDataSourceRemoved(DataSource*)), - SLOT(childDataSourceRemoved(DataSource*))); - - connect(&ModuleManager::instance(), SIGNAL(operatorRemoved(Operator*)), - SLOT(operatorRemoved(Operator*))); + connect(&ModuleManager::instance(), &ModuleManager::dataSourceAdded, this, + &PipelineModel::dataSourceAdded); + connect(&ModuleManager::instance(), &ModuleManager::childDataSourceAdded, + this, &PipelineModel::childDataSourceAdded); + connect(&ModuleManager::instance(), &ModuleManager::moduleAdded, this, + &PipelineModel::moduleAdded); + connect(&ModuleManager::instance(), &ModuleManager::moleculeSourceAdded, this, + &PipelineModel::moleculeSourceAdded); + + connect(&ActiveObjects::instance(), + static_cast( + &ActiveObjects::viewChanged), + this, [this]() { beginResetModel(); endResetModel(); }); + connect(&ModuleManager::instance(), &ModuleManager::dataSourceRemoved, this, + &PipelineModel::dataSourceRemoved); + connect(&ModuleManager::instance(), &ModuleManager::moleculeSourceRemoved, + this, &PipelineModel::moleculeSourceRemoved); + connect(&ModuleManager::instance(), &ModuleManager::moduleRemoved, this, + &PipelineModel::moduleRemoved); + connect(&ModuleManager::instance(), &ModuleManager::childDataSourceRemoved, + this, &PipelineModel::childDataSourceRemoved); + + connect(&ModuleManager::instance(), &ModuleManager::operatorRemoved, this, + &PipelineModel::operatorRemoved); // Need to register this for cross thread dataChanged signal qRegisterMetaType>("QVector"); } @@ -592,7 +591,7 @@ QModelIndex PipelineModel::parent(const QModelIndex& index) const return QModelIndex(); } auto treeItem = this->treeItem(index); - if (!treeItem->parent()) { + if (!treeItem || !treeItem->parent()) { return QModelIndex(); } return createIndex(treeItem->parent()->childIndex(), 0, treeItem->parent()); @@ -787,6 +786,22 @@ void PipelineModel::dataSourceAdded(DataSource* dataSource) emit dataSourceModified(transformed); }); + // Refresh all operator rows when a breakpoint is reached: the breakpoint + // operator's label column shows the play icon, and operators from the + // breakpoint onwards have their state column updated (Complete → Queued). + connect(pipeline, &Pipeline::breakpointReached, [this](Operator* op) { + auto ds = op->dataSource(); + if (!ds) + return; + auto ops = ds->operators(); + int bpIdx = ops.indexOf(op); + for (int i = bpIdx; i < ops.size(); ++i) { + auto idx = operatorIndex(ops[i]); + auto stateIdx = index(idx.row(), Column::state, idx.parent()); + emit dataChanged(idx, stateIdx); + } + }); + // When restoring a data source from a state file it will have its operators // before we can listen to the signal above. Display those operators. foreach (auto op, dataSource->operators()) { @@ -856,16 +871,35 @@ void PipelineModel::operatorAdded(Operator* op, // Make sure dataChange signal is emitted when operator is complete connect(op, &Operator::transformingDone, [this, op]() { auto opIndex = operatorIndex(op); - auto statusIndex = index(opIndex.row(), 1, opIndex.parent()); + auto statusIndex = index(opIndex.row(), Column::state, opIndex.parent()); emit dataChanged(statusIndex, statusIndex); }); + // Refresh label column when breakpoint state changes (delegate paints it) + connect(op, &Operator::breakpointChanged, [this, op]() { + auto opIndex = operatorIndex(op); + emit dataChanged(opIndex, opIndex); + }); connect(op, &Operator::dataSourceMoved, this, &PipelineModel::dataSourceMoved); auto index = dataSourceIndex(dataSource); auto dataSourceItem = treeItem(index); - // Operators are just append as last child. + // Find the correct insertion row based on the operator's position in the + // DataSource's operator list. int insertionRow = dataSourceItem->childCount(); + auto operators = dataSource->operators(); + int opIndex = operators.indexOf(op); + if (opIndex >= 0 && opIndex < operators.size() - 1) { + // Mid-chain insertion: find the tree item of the next operator and insert + // before it. + auto nextOp = operators[opIndex + 1]; + for (int i = 0; i < dataSourceItem->childCount(); ++i) { + if (dataSourceItem->child(i)->op() == nextOp) { + insertionRow = i; + break; + } + } + } beginInsertRows(index, insertionRow, insertionRow); dataSourceItem->insertChild(insertionRow, PipelineModel::Item(op)); endInsertRows(); @@ -1038,9 +1072,30 @@ bool PipelineModel::removeOp(Operator* o) { auto index = operatorIndex(o); if (index.isValid()) { - // This will trigger the move of the "transformed" data source - // so we need todo this outside the beginRemoveRow(...), otherwise - // the model is not correctly invalidated. + // If this operator has a child data source (the "transformed" output), + // move it to the last remaining operator in the tree model BEFORE calling + // removeOperator(). Previously, this move happened via the signal chain + // (operatorRemoved -> Pipeline handler -> dataSourceMoved -> + // moveDataSourceHelper -> beginMoveRows), but beginMoveRows can crash + // when iterating persistent model indexes during the signal chain. + // By doing the move explicitly here, the subsequent signal-triggered + // moveDataSourceHelper becomes a no-op (oldParent == newParent). + auto childDS = o->childDataSource(); + if (childDS) { + auto operators = o->dataSource()->operators(); + operators.removeAll(o); + if (!operators.isEmpty()) { + moveDataSourceHelper(childDS, operators.last()); + } + } + + // Re-compute the index since moveDataSourceHelper may have modified the + // tree (the operator's child count changed). + index = operatorIndex(o); + if (!index.isValid()) { + return true; + } + o->dataSource()->removeOperator(o); beginRemoveRows(parent(index), index.row(), index.row()); auto item = treeItem(index); @@ -1097,11 +1152,24 @@ void PipelineModel::moveDataSourceHelper(DataSource* dataSource, Operator* newParent) { auto index = dataSourceIndex(dataSource); + if (!index.isValid()) { + return; + } auto dataSourceItem = treeItem(index); + if (!dataSourceItem || !dataSourceItem->parent()) { + return; + } auto oldParent = dataSourceItem->parent()->op(); + // Already under the target parent (e.g. removeOp pre-moved it). + if (oldParent == newParent) { + return; + } auto oldParentIndex = this->operatorIndex(oldParent); auto operatorIndex = this->operatorIndex(newParent); auto operatorTreeItem = this->treeItem(operatorIndex); + if (!operatorTreeItem) { + return; + } beginMoveRows(oldParentIndex, index.row(), index.row(), operatorIndex, operatorTreeItem->childCount()); diff --git a/tomviz/PipelineView.cxx b/tomviz/PipelineView.cxx index f13ed358e..9281b8bf9 100644 --- a/tomviz/PipelineView.cxx +++ b/tomviz/PipelineView.cxx @@ -40,12 +40,35 @@ #include #include #include +#include #include #include #include namespace tomviz { +namespace { + +/// Returns true if the operator's breakpoint has been reached: the operator +/// has a breakpoint, is still Queued, and all preceding operators are Complete. +bool isBreakpointReached(Operator* op) +{ + if (!op || !op->hasBreakpoint() || !op->isQueued()) + return false; + auto ds = op->dataSource(); + if (!ds) + return false; + for (auto o : ds->operators()) { + if (o == op) + break; + if (!o->isCompleted()) + return false; + } + return true; +} + +} // namespace + class OperatorRunningDelegate : public QItemDelegate { @@ -70,18 +93,60 @@ OperatorRunningDelegate::OperatorRunningDelegate(QWidget* parent) { m_view = qobject_cast(parent); m_timer = new QTimer(this); - connect(m_timer, SIGNAL(timeout()), m_view->viewport(), SLOT(update())); + connect(m_timer, &QTimer::timeout, m_view->viewport(), QOverload<>::of(&QWidget::update)); } void OperatorRunningDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const { - auto pipelineModel = qobject_cast(m_view->model()); auto op = pipelineModel->op(index); + if (op && index.column() == Column::label) { + // Reserve space on the left for the breakpoint indicator, then let the + // base class paint the label content in the remaining area. + int bpWidth = PipelineView::breakpointAreaWidth(); + QRect bpRect(option.rect.left(), option.rect.top(), bpWidth, + option.rect.height()); + + // Draw the breakpoint / play / hover indicator + QPixmap pixmap; + qreal opacity = 1.0; + if (isBreakpointReached(op)) { + pixmap = QPixmap(":/icons/play.png"); + } else if (op->hasBreakpoint()) { + pixmap = QPixmap(":/icons/breakpoint.png"); + } else { + // Show semi-transparent breakpoint icon on hover + auto hoverIdx = m_view->hoverIndex(); + if (hoverIdx.isValid() && hoverIdx.row() == index.row() && + hoverIdx.parent() == index.parent()) { + pixmap = QPixmap(":/icons/breakpoint.png"); + opacity = 0.3; + } + } + + if (!pixmap.isNull()) { + painter->save(); + painter->setOpacity(opacity); + int iconSize = qMin(bpRect.width(), bpRect.height()) - 4; + QRect iconRect(bpRect.left() + (bpWidth - iconSize) / 2, + bpRect.top() + (bpRect.height() - iconSize) / 2, + iconSize, iconSize); + painter->drawPixmap(iconRect, pixmap); + painter->restore(); + } + + // Paint the rest of the label content shifted to the right + QStyleOptionViewItem shiftedOption = option; + shiftedOption.rect.setLeft(option.rect.left() + bpWidth); + QItemDelegate::paint(painter, shiftedOption, index); + return; + } + QItemDelegate::paint(painter, option, index); + if (op && index.column() == Column::state) { if (op->state() == OperatorState::Running) { QPixmap pixmap(":/icons/spinner.png"); @@ -119,10 +184,11 @@ void OperatorRunningDelegate::stop() PipelineView::PipelineView(QWidget* p) : QTreeView(p) { - connect(this, SIGNAL(clicked(QModelIndex)), SLOT(rowActivated(QModelIndex))); + connect(this, &QAbstractItemView::clicked, this, &PipelineView::rowActivated); setIndentation(20); setRootIsDecorated(false); setItemsExpandable(false); + setMouseTracking(true); QString customStyle = "QTreeView::branch { background-color: white; }"; setStyleSheet(customStyle); @@ -151,8 +217,8 @@ PipelineView::PipelineView(QWidget* p) : QTreeView(p) connect(&ModuleManager::instance(), &ModuleManager::pipelineViewRenderNeeded, viewport(), QOverload<>::of(&QWidget::update)); - connect(this, SIGNAL(doubleClicked(QModelIndex)), - SLOT(rowDoubleClicked(QModelIndex))); + connect(this, &QAbstractItemView::doubleClicked, this, + &PipelineView::rowDoubleClicked); } void PipelineView::setModel(QAbstractItemModel* model) @@ -169,20 +235,20 @@ void PipelineView::setModel(QAbstractItemModel* model) // since the PipelineModel listens to those signals and setCurrent // has to happen AFTER the slots on the PipelineModel are called. So // we listen to the model and respond after it does its update. - connect(pipelineModel, SIGNAL(dataSourceItemAdded(DataSource*)), - SLOT(setCurrent(DataSource*))); - connect(pipelineModel, SIGNAL(childDataSourceItemAdded(DataSource*)), - SLOT(setCurrent(DataSource*))); - connect(pipelineModel, SIGNAL(moleculeSourceItemAdded(MoleculeSource*)), - SLOT(setCurrent(MoleculeSource*))); - connect(pipelineModel, SIGNAL(moleculeSourceItemAdded(MoleculeSource*)), - SLOT(setCurrent(MoleculeSource*))); - connect(pipelineModel, SIGNAL(moduleItemAdded(Module*)), - SLOT(setCurrent(Module*))); - connect(pipelineModel, SIGNAL(operatorItemAdded(Operator*)), - SLOT(setCurrent(Operator*))); - connect(pipelineModel, SIGNAL(dataSourceModified(DataSource*)), - SLOT(setCurrent(DataSource*))); + connect(pipelineModel, &PipelineModel::dataSourceItemAdded, this, + QOverload::of(&PipelineView::setCurrent)); + connect(pipelineModel, &PipelineModel::childDataSourceItemAdded, this, + QOverload::of(&PipelineView::setCurrent)); + connect(pipelineModel, &PipelineModel::moleculeSourceItemAdded, this, + QOverload::of(&PipelineView::setCurrent)); + connect(pipelineModel, &PipelineModel::moleculeSourceItemAdded, this, + QOverload::of(&PipelineView::setCurrent)); + connect(pipelineModel, &PipelineModel::moduleItemAdded, this, + QOverload::of(&PipelineView::setCurrent)); + connect(pipelineModel, &PipelineModel::operatorItemAdded, this, + QOverload::of(&PipelineView::setCurrent)); + connect(pipelineModel, &PipelineModel::dataSourceModified, this, + QOverload::of(&PipelineView::setCurrent)); // This is needed to work around a bug in Qt 5.10, the select resize mode is // setting reset for some reason. @@ -230,6 +296,7 @@ void PipelineView::contextMenuEvent(QContextMenuEvent* e) QAction* snapshotAction = nullptr; QAction* showInterfaceAction = nullptr; QAction* exportTableResultAction = nullptr; + QAction* exportTableCsvAction = nullptr; QAction* reloadAndResampleAction = nullptr; bool allowReExecute = false; CloneDataReaction* cloneReaction; @@ -237,6 +304,7 @@ void PipelineView::contextMenuEvent(QContextMenuEvent* e) if (result && qobject_cast(result->parent())) { if (vtkTable::SafeDownCast(result->dataObject())) { exportTableResultAction = contextMenu.addAction("Save as JSON"); + exportTableCsvAction = contextMenu.addAction("Save as CSV"); } else { return; } @@ -413,6 +481,8 @@ void PipelineView::contextMenuEvent(QContextMenuEvent* e) } } else if (selectedItem == exportTableResultAction) { exportTableAsJson(vtkTable::SafeDownCast(result->dataObject())); + } else if (selectedItem == exportTableCsvAction) { + exportTableAsCsv(vtkTable::SafeDownCast(result->dataObject())); } else if (selectedItem == reloadAndResampleAction) { dataSource->reloadAndResample(); } @@ -424,6 +494,12 @@ void PipelineView::exportTableAsJson(vtkTable* table) jsonToFile(json); } +void PipelineView::exportTableAsCsv(vtkTable* table) +{ + auto csv = tableToCsv(table); + csvToFile(csv); +} + void PipelineView::deleteItems(const QModelIndexList& idxs) { auto pipelineModel = qobject_cast(model()); @@ -493,15 +569,52 @@ void PipelineView::deleteItems(const QModelIndexList& idxs) void PipelineView::rowActivated(const QModelIndex& idx) { - if (idx.isValid() && idx.column() == Column::state) { - auto pipelineModel = qobject_cast(model()); - if (pipelineModel) { - if (auto module = pipelineModel->module(idx)) { - module->setVisibility(!module->visibility()); - emit model()->dataChanged(idx, idx); - if (pqView* view = tomviz::convert(module->view())) { - view->render(); + if (!idx.isValid()) + return; + + auto pipelineModel = qobject_cast(model()); + if (!pipelineModel) + return; + + if (idx.column() == Column::label) { + if (auto op = pipelineModel->op(idx)) { + // Check if the click landed in the breakpoint area (left side of the + // label column). + auto cursorPos = viewport()->mapFromGlobal(QCursor::pos()); + auto cellRect = visualRect(idx); + int clickX = cursorPos.x() - cellRect.left(); + if (clickX >= 0 && clickX < breakpointAreaWidth()) { + auto ds = op->dataSource(); + auto pipeline = ds->pipeline(); + // Don't allow breakpoint changes while the pipeline is running. + if (pipeline && pipeline->isRunning()) { + return; + } + if (isBreakpointReached(op)) { + // Resume execution from this operator + auto operators = ds->operators(); + Operator* nextBp = nullptr; + int bpIdx = operators.indexOf(op); + for (int i = bpIdx + 1; i < operators.size(); ++i) { + if (operators[i]->hasBreakpoint()) { + nextBp = operators[i]; + break; + } + } + pipeline->execute(ds, op, nextBp)->deleteWhenFinished(); + } else { + // Toggle breakpoint + op->setBreakpoint(!op->hasBreakpoint()); } + return; + } + } + } else if (idx.column() == Column::state) { + if (auto module = pipelineModel->module(idx)) { + module->setVisibility(!module->visibility()); + emit model()->dataChanged(idx, idx); + if (pqView* view = tomviz::convert(module->view())) { + view->render(); } } } @@ -548,9 +661,9 @@ void PipelineView::currentChanged(const QModelIndex& current, auto pipelineModel = qobject_cast(model()); Q_ASSERT(pipelineModel); - // First set the selected data source to nullptr, in case the new selection - // is not a data source. + // Clear stale active state before setting new selection. ActiveObjects::instance().setSelectedDataSource(nullptr); + ActiveObjects::instance().setActiveOperator(nullptr); if (auto dataSource = pipelineModel->dataSource(current)) { ActiveObjects::instance().setSelectedDataSource(dataSource); } else if (auto module = pipelineModel->module(current)) { @@ -669,6 +782,38 @@ void PipelineView::setModuleVisibility(const QModelIndexList& idxs, } } +void PipelineView::mouseMoveEvent(QMouseEvent* event) +{ + auto idx = indexAt(event->pos()); + if (idx != m_hoverIndex) { + auto oldIndex = m_hoverIndex; + m_hoverIndex = idx; + // Repaint old and new rows so the hover breakpoint indicator updates + if (oldIndex.isValid()) { + auto labelIdx = model()->index(oldIndex.row(), Column::label, + oldIndex.parent()); + update(labelIdx); + } + if (idx.isValid()) { + auto labelIdx = model()->index(idx.row(), Column::label, idx.parent()); + update(labelIdx); + } + } + QTreeView::mouseMoveEvent(event); +} + +void PipelineView::leaveEvent(QEvent* event) +{ + if (m_hoverIndex.isValid()) { + auto oldIndex = m_hoverIndex; + m_hoverIndex = QModelIndex(); + auto labelIdx = model()->index(oldIndex.row(), Column::label, + oldIndex.parent()); + update(labelIdx); + } + QTreeView::leaveEvent(event); +} + void PipelineView::initLayout() { header()->setStretchLastSection(false); diff --git a/tomviz/PipelineView.h b/tomviz/PipelineView.h index d58bfd8b4..5844adc77 100644 --- a/tomviz/PipelineView.h +++ b/tomviz/PipelineView.h @@ -6,6 +6,7 @@ #include +#include #include #include "EditOperatorDialog.h" @@ -29,6 +30,13 @@ class PipelineView : public QTreeView void setModel(QAbstractItemModel*) override; void initLayout(); + /// Returns the model index currently hovered by the mouse, if any. + QModelIndex hoverIndex() const { return m_hoverIndex; } + + /// Width in pixels reserved for the breakpoint indicator area in the label + /// column. + static constexpr int breakpointAreaWidth() { return 20; } + protected: void keyPressEvent(QKeyEvent*) override; void contextMenuEvent(QContextMenuEvent*) override; @@ -36,6 +44,11 @@ class PipelineView : public QTreeView const QModelIndex& previous) override; void deleteItems(const QModelIndexList& idxs); bool enableDeleteItems(const QModelIndexList& idxs); + void mouseMoveEvent(QMouseEvent* event) override; + void leaveEvent(QEvent* event) override; + +private: + QPersistentModelIndex m_hoverIndex; private slots: void rowActivated(const QModelIndex& idx); @@ -48,6 +61,7 @@ private slots: void deleteItemsConfirm(const QModelIndexList& idxs); void setModuleVisibility(const QModelIndexList& idxs, bool visible); void exportTableAsJson(vtkTable*); + void exportTableAsCsv(vtkTable*); }; } // namespace tomviz diff --git a/tomviz/PipelineWorker.cxx b/tomviz/PipelineWorker.cxx index f32b3431d..2cbe97719 100644 --- a/tomviz/PipelineWorker.cxx +++ b/tomviz/PipelineWorker.cxx @@ -147,10 +147,12 @@ PipelineWorker::Run::Run(vtkDataObject* data, QList operators) PipelineWorker::Future* PipelineWorker::Run::start() { auto future = new PipelineWorker::Future(this); - connect(this, SIGNAL(finished(bool)), future, SIGNAL(finished(bool))); - connect(this, SIGNAL(canceled()), future, SIGNAL(canceled())); + connect(this, &PipelineWorker::Run::finished, future, + &PipelineWorker::Future::finished); + connect(this, &PipelineWorker::Run::canceled, future, + &PipelineWorker::Future::canceled); - QTimer::singleShot(0, this, SLOT(startNextOperator())); + QTimer::singleShot(0, this, &PipelineWorker::Run::startNextOperator); m_state = State::RUNNING; diff --git a/tomviz/ProgressDialogManager.cxx b/tomviz/ProgressDialogManager.cxx index fbb97e742..c04c0c97f 100644 --- a/tomviz/ProgressDialogManager.cxx +++ b/tomviz/ProgressDialogManager.cxx @@ -27,8 +27,8 @@ ProgressDialogManager::ProgressDialogManager(QMainWindow* mw) : Superclass(mw), mainWindow(mw) { ModuleManager& mm = ModuleManager::instance(); - QObject::connect(&mm, SIGNAL(dataSourceAdded(DataSource*)), this, - SLOT(dataSourceAdded(DataSource*))); + QObject::connect(&mm, &ModuleManager::dataSourceAdded, this, + &ProgressDialogManager::dataSourceAdded); } ProgressDialogManager::~ProgressDialogManager() {} @@ -153,8 +153,8 @@ void ProgressDialogManager::operatorAdded(Operator* op) void ProgressDialogManager::dataSourceAdded(DataSource* ds) { - QObject::connect(ds, SIGNAL(operatorAdded(Operator*)), this, - SLOT(operatorAdded(Operator*))); + QObject::connect(ds, &DataSource::operatorAdded, this, + &ProgressDialogManager::operatorAdded); } void ProgressDialogManager::operationProgress(int) {} diff --git a/tomviz/PtychoDialog.cxx b/tomviz/PtychoDialog.cxx index add48be46..ce7630e69 100644 --- a/tomviz/PtychoDialog.cxx +++ b/tomviz/PtychoDialog.cxx @@ -375,7 +375,11 @@ class PtychoDialog::Internal : public QObject setPtychoGUICommand( settings->value("ptychoGUICommand", "run-ptycho").toString()); - setPtychoDirectory(settings->value("ptychoDirectory", "").toString()); + auto savedPtychoDir = settings->value("ptychoDirectory", "").toString(); + if (!savedPtychoDir.isEmpty() && !QDir(savedPtychoDir).exists()) { + savedPtychoDir = ""; + } + setPtychoDirectory(savedPtychoDir); setCsvFile(settings->value("loadFromCSVFile", "").toString()); setFilterSIDsString(settings->value("filterSIDsString", "").toString()); @@ -552,6 +556,16 @@ class PtychoDialog::Internal : public QObject void ptychoDirEdited() { + auto dir = ptychoDirectory(); + if (!dir.isEmpty() && !QDir(dir).exists()) { + QMessageBox::critical(parent.data(), "Directory Not Found", + "Ptycho directory does not exist: " + dir); + setPtychoDirectory(""); + setCsvFile(""); + setFilterSIDsString(""); + return; + } + // Whenever this is called, make sure we clear the CSV file and SID filters setCsvFile(""); setFilterSIDsString(""); diff --git a/tomviz/PtychoRunner.cxx b/tomviz/PtychoRunner.cxx index 79d65d3c5..f1537451a 100644 --- a/tomviz/PtychoRunner.cxx +++ b/tomviz/PtychoRunner.cxx @@ -3,6 +3,7 @@ #include "PtychoRunner.h" +#include "CameraReaction.h" #include "DataSource.h" #include "LoadDataReaction.h" #include "ProgressDialog.h" @@ -278,14 +279,28 @@ class PtychoRunner::Internal : public QObject void loadOutputFiles() { + // Convert sidList to QVector for scan IDs + QVector scanIDs; + scanIDs.reserve(sidList.size()); + for (auto& sid : sidList) { + scanIDs.push_back(static_cast(sid)); + } + for (auto& filePath: outputFiles) { auto* dataSource = LoadDataReaction::loadData(filePath); if (!dataSource || !dataSource->imageData()) { qCritical() << "Failed to load file:" << filePath; return; } + if (!scanIDs.isEmpty()) { + dataSource->setScanIDs(scanIDs); + } } + // Automatically update camera to BNL convention + CameraReaction::resetPositiveZ(); + CameraReaction::rotateCamera(-90); + QString title = "Loading ptycho data complete"; auto text = QString("Ptycho data in \"%1\" was written and loaded into Tomviz") diff --git a/tomviz/PyXRFMakeHDF5Dialog.cxx b/tomviz/PyXRFMakeHDF5Dialog.cxx index 245597d2b..b07e1c904 100644 --- a/tomviz/PyXRFMakeHDF5Dialog.cxx +++ b/tomviz/PyXRFMakeHDF5Dialog.cxx @@ -11,8 +11,57 @@ #include #include +#include #include #include +#include + +namespace { + +bool executableExists(const QString& command) +{ + if (command.isEmpty()) { + return false; + } + // If the command contains a path separator, treat it as a file path + if (command.contains('/') || command.contains(QDir::separator())) { + QFileInfo info(command); + return info.isFile() && info.isExecutable(); + } + // Otherwise check whether it can be found in $PATH + return !QStandardPaths::findExecutable(command).isEmpty(); +} + +// Returns the best available pyxrf-utils command by checking, in order: +// 1. The previously saved command (if it still exists) +// 2. "run-pyxrf-utils" in $PATH +// 3. "pyxrf-utils" in $PATH +// 4. An absolute fallback path +// 5. Empty string (not found) +QString findPyxrfUtilsCommand(const QString& savedCommand) +{ + if (executableExists(savedCommand)) { + return savedCommand; + } + + const QStringList candidates = { "run-pyxrf-utils", "pyxrf-utils" }; + for (const auto& candidate : candidates) { + if (executableExists(candidate)) { + return candidate; + } + } + + const QString absoluteFallback = + "/nsls2/data2/hxn/legacy/Hiran/tomviz/conda_envs/" + "tomviz-latest-wip/bin/run-pyxrf-utils"; + if (executableExists(absoluteFallback)) { + return absoluteFallback; + } + + return ""; +} + +} // anonymous namespace namespace tomviz { @@ -225,6 +274,18 @@ class PyXRFMakeHDF5Dialog::Internal : public QObject return false; } + // Check that the executable exists when it will actually be used + if (!useAlreadyExistingData() || remakeCsvFile()) { + auto cmd = command(); + if (!executableExists(cmd)) { + reason = + QString("The pyxrf-utils executable \"%1\" was not found. " + "Please specify a valid path to the executable.") + .arg(cmd.isEmpty() ? QString("(empty)") : cmd); + return false; + } + } + return true; } @@ -243,7 +304,8 @@ class PyXRFMakeHDF5Dialog::Internal : public QObject settings->beginGroup("pyxrf"); // Do this in the general pyxrf settings - setCommand(settings->value("pyxrfUtilsCommand", "pyxrf-utils").toString()); + auto savedCommand = settings->value("pyxrfUtilsCommand", "").toString(); + setCommand(findPyxrfUtilsCommand(savedCommand)); settings->beginGroup("makeHDF5"); setMethod(settings->value("method", "New").toString()); diff --git a/tomviz/PyXRFProcessDialog.cxx b/tomviz/PyXRFProcessDialog.cxx index a02cc8c34..73c887fa1 100644 --- a/tomviz/PyXRFProcessDialog.cxx +++ b/tomviz/PyXRFProcessDialog.cxx @@ -20,8 +20,56 @@ #include #include #include +#include #include +namespace { + +bool executableExists(const QString& command) +{ + if (command.isEmpty()) { + return false; + } + // If the command contains a path separator, treat it as a file path + if (command.contains('/') || command.contains(QDir::separator())) { + QFileInfo info(command); + return info.isFile() && info.isExecutable(); + } + // Otherwise check whether it can be found in $PATH + return !QStandardPaths::findExecutable(command).isEmpty(); +} + +// Returns the best available pyxrf-utils command by checking, in order: +// 1. The previously saved command (if it still exists) +// 2. "run-pyxrf-utils" in $PATH +// 3. "pyxrf-utils" in $PATH +// 4. An absolute fallback path +// 5. Empty string (not found) +QString findPyxrfUtilsCommand(const QString& savedCommand) +{ + if (executableExists(savedCommand)) { + return savedCommand; + } + + const QStringList candidates = { "run-pyxrf-utils", "pyxrf-utils" }; + for (const auto& candidate : candidates) { + if (executableExists(candidate)) { + return candidate; + } + } + + const QString absoluteFallback = + "/nsls2/data2/hxn/legacy/Hiran/tomviz/conda_envs/" + "tomviz-latest-wip/bin/run-pyxrf-utils"; + if (executableExists(absoluteFallback)) { + return absoluteFallback; + } + + return ""; +} + +} // anonymous namespace + namespace tomviz { class PyXRFProcessDialog::Internal : public QObject @@ -208,6 +256,16 @@ class PyXRFProcessDialog::Internal : public QObject } } + // Check that the executable exists before attempting to run it + auto cmd = command(); + if (!executableExists(cmd)) { + reason = + QString("The pyxrf-utils executable \"%1\" was not found. " + "Please specify a valid path to the executable.") + .arg(cmd.isEmpty() ? QString("(empty)") : cmd); + return false; + } + return true; } @@ -575,7 +633,8 @@ class PyXRFProcessDialog::Internal : public QObject auto settings = pqApplicationCore::instance()->settings(); settings->beginGroup("pyxrf"); - setCommand(settings->value("pyxrfUtilsCommand", "pyxrf-utils").toString()); + auto savedCommand = settings->value("pyxrfUtilsCommand", "").toString(); + setCommand(findPyxrfUtilsCommand(savedCommand)); settings->beginGroup("process"); // Only load these settings if we are re-using the same previous @@ -849,4 +908,22 @@ bool PyXRFProcessDialog::rotateDatasets() const return m_internal->rotateDatasets(); } +QVector PyXRFProcessDialog::selectedScanIDs() const +{ + QVector result; + for (const auto& sid : m_internal->filteredSidList) { + auto row = m_internal->sidToRow[sid]; + auto use = m_internal->logFileValue(row, "Use"); + if (use == "x" || use == "1") { + bool ok; + int id = sid.toInt(&ok); + if (!ok) { + id = -1; + } + result.append(id); + } + } + return result; +} + } // namespace tomviz diff --git a/tomviz/PyXRFProcessDialog.h b/tomviz/PyXRFProcessDialog.h index ddb70cee3..b1d713aa4 100644 --- a/tomviz/PyXRFProcessDialog.h +++ b/tomviz/PyXRFProcessDialog.h @@ -29,6 +29,7 @@ class PyXRFProcessDialog : public QDialog double pixelSizeY() const; bool skipProcessed() const; bool rotateDatasets() const; + QVector selectedScanIDs() const; private: class Internal; diff --git a/tomviz/PyXRFRunner.cxx b/tomviz/PyXRFRunner.cxx index 37f1ccf3e..399a51fe5 100644 --- a/tomviz/PyXRFRunner.cxx +++ b/tomviz/PyXRFRunner.cxx @@ -88,6 +88,9 @@ class PyXRFRunner::Internal : public QObject // Recon options QStringList selectedElements; + // Scan IDs from the process dialog + QVector scanIDs; + bool autoLoadFinalData = true; Internal(PyXRFRunner* p) : parent(p) @@ -281,7 +284,8 @@ class PyXRFRunner::Internal : public QObject { progressDialog->accept(); - auto success = makeHDF5Process.exitStatus() == QProcess::NormalExit; + auto success = makeHDF5Process.exitStatus() == QProcess::NormalExit && + makeHDF5Process.exitCode() == 0; if (!success) { QString msg = "Make HDF5 failed"; qCritical() << msg; @@ -333,7 +337,8 @@ class PyXRFRunner::Internal : public QObject { progressDialog->accept(); - auto success = remakeCsvFileProcess.exitStatus() == QProcess::NormalExit; + auto success = remakeCsvFileProcess.exitStatus() == QProcess::NormalExit && + remakeCsvFileProcess.exitCode() == 0; if (!success) { QString msg = "Remake CSV file failed"; qCritical() << msg; @@ -411,6 +416,9 @@ class PyXRFRunner::Internal : public QObject skipProcessed = processDialog->skipProcessed(); rotateDatasets = processDialog->rotateDatasets(); + // Store the selected scan IDs + scanIDs = processDialog->selectedScanIDs(); + // Make sure the output directory exists QDir().mkpath(outputDirectory); @@ -487,9 +495,11 @@ class PyXRFRunner::Internal : public QObject { progressDialog->accept(); - auto success = processProjectionsProcess.exitStatus() == QProcess::NormalExit; - if (!success || !validateOutputDirectory()) { - QString msg = "Process projections failed"; + auto success = processProjectionsProcess.exitStatus() == QProcess::NormalExit && + processProjectionsProcess.exitCode() == 0; + if (!success) { + QString msg = QString("Process projections failed (exit code %1)") + .arg(processProjectionsProcess.exitCode()); qCritical() << msg; QMessageBox::critical(parentWidget, "Tomviz", msg); // Show the dialog again @@ -497,6 +507,12 @@ class PyXRFRunner::Internal : public QObject return; } + if (!validateOutputDirectory()) { + // Show the dialog again + showProcessProjectionsDialog(); + return; + } + selectElements(); } @@ -683,6 +699,11 @@ class PyXRFRunner::Internal : public QObject dataSource->setActiveScalars(firstName.toStdString().c_str()); dataSource->setLabel("Extracted Elements"); + + if (!scanIDs.isEmpty()) { + dataSource->setScanIDs(scanIDs); + } + dataSource->dataModified(); // Write this to an EMD format diff --git a/tomviz/PythonGeneratedDatasetReaction.cxx b/tomviz/PythonGeneratedDatasetReaction.cxx index 54a3a79a9..8f17cf096 100644 --- a/tomviz/PythonGeneratedDatasetReaction.cxx +++ b/tomviz/PythonGeneratedDatasetReaction.cxx @@ -53,7 +53,7 @@ class PythonGeneratedDataSource : public QObject { tomviz::Python python; - m_operatorModule = python.import("tomviz.utils"); + m_operatorModule = python.import("tomviz.internal_utils"); if (!m_operatorModule.isValid()) { qCritical() << "Failed to import tomviz.utils module."; } @@ -253,8 +253,10 @@ void PythonGeneratedDatasetReaction::addDataset() QVBoxLayout* layout = new QVBoxLayout; QDialogButtonBox* buttons = new QDialogButtonBox( QDialogButtonBox::Cancel | QDialogButtonBox::Ok, Qt::Horizontal, &dialog); - QObject::connect(buttons, SIGNAL(accepted()), &dialog, SLOT(accept())); - QObject::connect(buttons, SIGNAL(rejected()), &dialog, SLOT(reject())); + QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, + &QDialog::accept); + QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, + &QDialog::reject); layout->addWidget(shapeWidget); layout->addItem(parametersLayout); @@ -326,8 +328,10 @@ void PythonGeneratedDatasetReaction::addDataset() // Buttons QDialogButtonBox* buttons = new QDialogButtonBox( QDialogButtonBox::Cancel | QDialogButtonBox::Ok, Qt::Horizontal, &dialog); - QObject::connect(buttons, SIGNAL(accepted()), &dialog, SLOT(accept())); - QObject::connect(buttons, SIGNAL(rejected()), &dialog, SLOT(reject())); + QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, + &QDialog::accept); + QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, + &QDialog::reject); layout->addWidget(shapeLayout); layout->addItem(parametersLayout); @@ -485,8 +489,10 @@ void PythonGeneratedDatasetReaction::addDataset() // Buttons QDialogButtonBox* buttons = new QDialogButtonBox( QDialogButtonBox::Cancel | QDialogButtonBox::Ok, Qt::Horizontal, &dialog); - QObject::connect(buttons, SIGNAL(accepted()), &dialog, SLOT(accept())); - QObject::connect(buttons, SIGNAL(rejected()), &dialog, SLOT(reject())); + QObject::connect(buttons, &QDialogButtonBox::accepted, &dialog, + &QDialog::accept); + QObject::connect(buttons, &QDialogButtonBox::rejected, &dialog, + &QDialog::reject); layout->addItem(parametersLayout); layout->addWidget(buttons); diff --git a/tomviz/Reaction.cxx b/tomviz/Reaction.cxx index 1f09ca817..21c2bab03 100644 --- a/tomviz/Reaction.cxx +++ b/tomviz/Reaction.cxx @@ -11,8 +11,10 @@ namespace tomviz { Reaction::Reaction(QAction* parentObject) : pqReaction(parentObject) { - connect(&ActiveObjects::instance(), SIGNAL(dataSourceChanged(DataSource*)), - SLOT(updateEnableState())); + connect(&ActiveObjects::instance(), + static_cast( + &ActiveObjects::dataSourceChanged), + this, &Reaction::updateEnableState); connect(&PipelineManager::instance(), &PipelineManager::executionModeUpdated, this, &Reaction::updateEnableState); diff --git a/tomviz/RecentFilesMenu.cxx b/tomviz/RecentFilesMenu.cxx index c6437060a..983c8ae17 100644 --- a/tomviz/RecentFilesMenu.cxx +++ b/tomviz/RecentFilesMenu.cxx @@ -94,7 +94,7 @@ void saveSettings(QJsonObject json) RecentFilesMenu::RecentFilesMenu(QMenu& menu, QObject* p) : QObject(p) { - connect(&menu, SIGNAL(aboutToShow()), SLOT(aboutToShowMenu())); + connect(&menu, &QMenu::aboutToShow, this, &RecentFilesMenu::aboutToShowMenu); } RecentFilesMenu::~RecentFilesMenu() = default; @@ -240,7 +240,7 @@ void RecentFilesMenu::aboutToShowMenu() auto actn = menu->addAction(QIcon(":/icons/tomviz.png"), object["fileName"].toString("")); actn->setData(object["fileName"].toString("")); - connect(actn, SIGNAL(triggered()), SLOT(stateTriggered())); + connect(actn, &QAction::triggered, this, &RecentFilesMenu::stateTriggered); } } } diff --git a/tomviz/RotateAlignWidget.cxx b/tomviz/RotateAlignWidget.cxx index b61cfca7c..905c6f9ad 100644 --- a/tomviz/RotateAlignWidget.cxx +++ b/tomviz/RotateAlignWidget.cxx @@ -109,7 +109,8 @@ class RotateAlignWidget::RAWInternal void setupCameras() { tomviz::setupRenderer(this->mainRenderer, this->mainSliceMapper, - this->axesActor); + nullptr); + this->mainRenderer->ResetCameraClippingRange(); tomviz::setupRenderer(this->reconRenderer[0], this->reconSliceMapper[0]); tomviz::setupRenderer(this->reconRenderer[1], @@ -425,12 +426,12 @@ RotateAlignWidget::RotateAlignWidget(Operator* op, this->Internals->Ui.colorMapButton_1->setIcon(setColorMapIcon); this->Internals->Ui.colorMapButton_2->setIcon(setColorMapIcon); this->Internals->Ui.colorMapButton_3->setIcon(setColorMapIcon); - this->connect(this->Internals->Ui.colorMapButton_1, SIGNAL(clicked()), this, - SLOT(showChangeColorMapDialog0())); - this->connect(this->Internals->Ui.colorMapButton_2, SIGNAL(clicked()), this, - SLOT(showChangeColorMapDialog1())); - this->connect(this->Internals->Ui.colorMapButton_3, SIGNAL(clicked()), this, - SLOT(showChangeColorMapDialog2())); + this->connect(this->Internals->Ui.colorMapButton_1, &QToolButton::clicked, this, + &RotateAlignWidget::showChangeColorMapDialog0); + this->connect(this->Internals->Ui.colorMapButton_2, &QToolButton::clicked, this, + &RotateAlignWidget::showChangeColorMapDialog1); + this->connect(this->Internals->Ui.colorMapButton_3, &QToolButton::clicked, this, + &RotateAlignWidget::showChangeColorMapDialog2); this->Internals->mainSlice->SetMapper(this->Internals->mainSliceMapper); this->Internals->reconSlice[0]->SetMapper( @@ -473,7 +474,6 @@ RotateAlignWidget::RotateAlignWidget(Operator* op, interatorStyle2); this->Internals->Ui.sliceView_3->interactor()->SetInteractorStyle( interatorStyle3); - this->Internals->setupCameras(); this->Internals->rotationAxis->SetPoint1(0, 0, 0); this->Internals->rotationAxis->SetPoint1(1, 1, 1); diff --git a/tomviz/SaveDataReaction.cxx b/tomviz/SaveDataReaction.cxx index e7918186d..fe95ddf03 100644 --- a/tomviz/SaveDataReaction.cxx +++ b/tomviz/SaveDataReaction.cxx @@ -47,8 +47,10 @@ namespace tomviz { SaveDataReaction::SaveDataReaction(QAction* parentObject) : pqReaction(parentObject) { - connect(&ActiveObjects::instance(), SIGNAL(dataSourceChanged(DataSource*)), - SLOT(updateEnableState())); + connect(&ActiveObjects::instance(), + static_cast( + &ActiveObjects::dataSourceChanged), + this, &SaveDataReaction::updateEnableState); updateEnableState(); } diff --git a/tomviz/SaveLoadTemplateReaction.cxx b/tomviz/SaveLoadTemplateReaction.cxx index 96ac0383e..7d05172a5 100644 --- a/tomviz/SaveLoadTemplateReaction.cxx +++ b/tomviz/SaveLoadTemplateReaction.cxx @@ -85,12 +85,17 @@ bool SaveLoadTemplateReaction::loadTemplate(const QString& fileName) // Get the parent data source, as well as the active (i.e. data and output) auto activeParent = ActiveObjects::instance().activeParentDataSource(); auto activeData = ActiveObjects::instance().activeDataSource(); - + + if (!activeParent) { + qWarning("No active data source to apply template to."); + return false; + } + // Read in the template file and apply it to the current data source activeParent->deserialize(doc.object()); // Load the default modules on the output if there are none bool noModules = ModuleManager::instance().findModulesGeneric(activeData, nullptr).isEmpty(); - if (noModules && activeData != activeParent) { + if (noModules && activeData && activeData != activeParent) { activeParent->pipeline()->addDefaultModules(activeData); } diff --git a/tomviz/SaveScreenshotDialog.cxx b/tomviz/SaveScreenshotDialog.cxx index b166d2bdb..4c116004d 100644 --- a/tomviz/SaveScreenshotDialog.cxx +++ b/tomviz/SaveScreenshotDialog.cxx @@ -60,8 +60,8 @@ SaveScreenshotDialog::SaveScreenshotDialog(QWidget* p) : QDialog(p) &QDialog::reject); vLayout->addWidget(buttonBox); - QObject::connect(lockAspectButton, SIGNAL(clicked()), this, - SLOT(setLockAspectRatio())); + QObject::connect(lockAspectButton, &QPushButton::clicked, this, + &SaveScreenshotDialog::setLockAspectRatio); setLayout(vLayout); } @@ -99,14 +99,15 @@ void SaveScreenshotDialog::setLockAspectRatio() m_lockAspectRatio = !m_lockAspectRatio; if (m_lockAspectRatio) { m_aspectRatio = m_width->value() / static_cast(m_height->value()); - connect(m_width, SIGNAL(valueChanged(int)), this, SLOT(widthChanged(int))); - connect(m_height, SIGNAL(valueChanged(int)), this, - SLOT(heightChanged(int))); + connect(m_width, QOverload::of(&QSpinBox::valueChanged), this, + &SaveScreenshotDialog::widthChanged); + connect(m_height, QOverload::of(&QSpinBox::valueChanged), this, + &SaveScreenshotDialog::heightChanged); } else { - disconnect(m_width, SIGNAL(valueChanged(int)), this, - SLOT(widthChanged(int))); - disconnect(m_height, SIGNAL(valueChanged(int)), this, - SLOT(heightChanged(int))); + disconnect(m_width, QOverload::of(&QSpinBox::valueChanged), this, + &SaveScreenshotDialog::widthChanged); + disconnect(m_height, QOverload::of(&QSpinBox::valueChanged), this, + &SaveScreenshotDialog::heightChanged); } } diff --git a/tomviz/SaveWebReaction.cxx b/tomviz/SaveWebReaction.cxx index a0295e994..0ab7b39b9 100644 --- a/tomviz/SaveWebReaction.cxx +++ b/tomviz/SaveWebReaction.cxx @@ -36,8 +36,10 @@ namespace tomviz { SaveWebReaction::SaveWebReaction(QAction* parentObject, MainWindow* mainWindow) : pqReaction(parentObject), m_mainWindow(mainWindow) { - connect(&ActiveObjects::instance(), SIGNAL(dataSourceChanged(DataSource*)), - SLOT(updateEnableState())); + connect(&ActiveObjects::instance(), + static_cast( + &ActiveObjects::dataSourceChanged), + this, &SaveWebReaction::updateEnableState); updateEnableState(); } diff --git a/tomviz/ScaleActorBehavior.cxx b/tomviz/ScaleActorBehavior.cxx index 25c2de597..25e6ce050 100644 --- a/tomviz/ScaleActorBehavior.cxx +++ b/tomviz/ScaleActorBehavior.cxx @@ -79,7 +79,7 @@ ScaleActorBehavior::ScaleActorBehavior(QObject* parentObject) { pqServerManagerModel* smmodel = pqApplicationCore::instance()->getServerManagerModel(); - connect(smmodel, SIGNAL(viewAdded(pqView*)), SLOT(viewAdded(pqView*))); + connect(smmodel, &pqServerManagerModel::viewAdded, this, &ScaleActorBehavior::viewAdded); } void ScaleActorBehavior::viewAdded(pqView* view) diff --git a/tomviz/SelectVolumeWidget.cxx b/tomviz/SelectVolumeWidget.cxx index 8d94d0caf..f66e4c4c4 100644 --- a/tomviz/SelectVolumeWidget.cxx +++ b/tomviz/SelectVolumeWidget.cxx @@ -141,15 +141,15 @@ SelectVolumeWidget::SelectVolumeWidget(const double origin[3], ui.endZ->setRange(extent[4], extent[5]); ui.endZ->setValue(currentVolume[5]); - this->connect(ui.startX, SIGNAL(editingFinished()), this, - SLOT(valueChanged())); - this->connect(ui.startY, SIGNAL(editingFinished()), this, - SLOT(valueChanged())); - this->connect(ui.startZ, SIGNAL(editingFinished()), this, - SLOT(valueChanged())); - this->connect(ui.endX, SIGNAL(editingFinished()), this, SLOT(valueChanged())); - this->connect(ui.endY, SIGNAL(editingFinished()), this, SLOT(valueChanged())); - this->connect(ui.endZ, SIGNAL(editingFinished()), this, SLOT(valueChanged())); + this->connect(ui.startX, &QSpinBox::editingFinished, this, + &SelectVolumeWidget::valueChanged); + this->connect(ui.startY, &QSpinBox::editingFinished, this, + &SelectVolumeWidget::valueChanged); + this->connect(ui.startZ, &QSpinBox::editingFinished, this, + &SelectVolumeWidget::valueChanged); + this->connect(ui.endX, &QSpinBox::editingFinished, this, &SelectVolumeWidget::valueChanged); + this->connect(ui.endY, &QSpinBox::editingFinished, this, &SelectVolumeWidget::valueChanged); + this->connect(ui.endZ, &QSpinBox::editingFinished, this, &SelectVolumeWidget::valueChanged); // force through the current values pulled from the operator and set above this->valueChanged(); } diff --git a/tomviz/SetDataTypeReaction.cxx b/tomviz/SetDataTypeReaction.cxx index f636b509c..0c01c830a 100644 --- a/tomviz/SetDataTypeReaction.cxx +++ b/tomviz/SetDataTypeReaction.cxx @@ -16,8 +16,10 @@ SetDataTypeReaction::SetDataTypeReaction(QAction* action, QMainWindow* mw, DataSource::DataSourceType t) : pqReaction(action), m_mainWindow(mw), m_type(t) { - connect(&ActiveObjects::instance(), SIGNAL(dataSourceChanged(DataSource*)), - SLOT(updateEnableState())); + connect(&ActiveObjects::instance(), + static_cast( + &ActiveObjects::dataSourceChanged), + this, &SetDataTypeReaction::updateEnableState); setWidgetText(t); updateEnableState(); } diff --git a/tomviz/SetTiltAnglesReaction.cxx b/tomviz/SetTiltAnglesReaction.cxx index 118625a11..06aea7677 100644 --- a/tomviz/SetTiltAnglesReaction.cxx +++ b/tomviz/SetTiltAnglesReaction.cxx @@ -15,8 +15,10 @@ namespace tomviz { SetTiltAnglesReaction::SetTiltAnglesReaction(QAction* p, QMainWindow* mw) : pqReaction(p), m_mainWindow(mw) { - connect(&ActiveObjects::instance(), SIGNAL(dataSourceChanged(DataSource*)), - SLOT(updateEnableState())); + connect(&ActiveObjects::instance(), + static_cast( + &ActiveObjects::dataSourceChanged), + this, &SetTiltAnglesReaction::updateEnableState); updateEnableState(); } @@ -52,6 +54,6 @@ void SetTiltAnglesReaction::showSetTiltAnglesUI(QMainWindow* window, dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->setWindowTitle("Set Tilt Angles"); dialog->show(); - connect(op, SIGNAL(destroyed()), dialog, SLOT(reject())); + connect(op, &QObject::destroyed, dialog, &QDialog::reject); } } // namespace tomviz diff --git a/tomviz/ShiftRotationCenterWidget.cxx b/tomviz/ShiftRotationCenterWidget.cxx new file mode 100644 index 000000000..d05304c24 --- /dev/null +++ b/tomviz/ShiftRotationCenterWidget.cxx @@ -0,0 +1,1108 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "ShiftRotationCenterWidget.h" +#include "ui_ShiftRotationCenterWidget.h" + +#include "ActiveObjects.h" +#include "ColorMap.h" +#include "DataSource.h" +#include "InternalPythonHelper.h" +#include "PresetDialog.h" +#include "Utilities.h" + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pqLineEdit.h" + +#include + +namespace tomviz { + +class InternalProgressDialog : public QProgressDialog +{ +public: + InternalProgressDialog(QWidget* parent = nullptr) : QProgressDialog(parent) + { + setWindowTitle("Tomviz"); + setLabelText("Generating test images..."); + setMinimum(0); + setMaximum(0); + setWindowModality(Qt::WindowModal); + + // No cancel button + setCancelButton(nullptr); + + // No close button in the corner + setWindowFlags((windowFlags() | Qt::CustomizeWindowHint) & + ~Qt::WindowCloseButtonHint); + + reset(); + } + + void keyPressEvent(QKeyEvent* e) override + { + // Do not let the user close the dialog by pressing escape + if (e->key() == Qt::Key_Escape) { + return; + } + + QProgressDialog::keyPressEvent(e); + } +}; + +class InteractorStyle : public vtkInteractorStyleImage +{ + // Our customized 2D interactor style class +public: + static InteractorStyle* New(); + + InteractorStyle() { this->SetInteractionModeToImage2D(); } + + void OnLeftButtonDown() override + { + // Override this to not do window level events, and instead do panning. + int x = this->Interactor->GetEventPosition()[0]; + int y = this->Interactor->GetEventPosition()[1]; + + this->FindPokedRenderer(x, y); + if (this->CurrentRenderer == nullptr) { + return; + } + + this->GrabFocus(this->EventCallbackCommand); + if (!this->Interactor->GetShiftKey() && + !this->Interactor->GetControlKey()) { + this->StartPan(); + } else { + this->Superclass::OnLeftButtonDown(); + } + } +}; + +vtkStandardNewMacro(InteractorStyle) + +class ShiftRotationCenterWidget::Internal : public QObject +{ + Q_OBJECT + +public: + Ui::ShiftRotationCenterWidget ui; + QPointer op; + vtkSmartPointer image; + vtkSmartPointer rotationImages; + vtkSmartPointer colorMap; + vtkSmartPointer lut; + QList rotations; + vtkNew slice; + vtkNew mapper; + vtkNew renderer; + vtkNew axesActor; + + // Projection view (top-left) with center line and slice line overlay + vtkNew projSlice; + vtkNew projMapper; + vtkNew projRenderer; + vtkNew centerLine; + vtkNew centerLineActor; + vtkNew sliceLine; + vtkNew sliceLineActor; + + // Quality metric line plots (bottom-right, side by side) + vtkNew chartViewQia; + vtkNew chartQia; + vtkNew chartViewQn; + vtkNew chartQn; + vtkNew indicatorTableQia; + vtkNew indicatorTableQn; + QList qiaValues; + QList qnValues; + + QString script; + InternalPythonHelper pythonHelper; + QPointer parent; + QPointer dataSource; + int sliceNumber = 0; + QScopedPointer progressDialog; + QFutureWatcher futureWatcher; + bool testRotationsSuccess = false; + QString testRotationsErrorMessage; + + Internal(Operator* o, vtkSmartPointer img, + ShiftRotationCenterWidget* p) + : op(o), image(img) + { + // Must call setupUi() before using p in any way + ui.setupUi(p); + setParent(p); + parent = p; + + // Make the projectionView expand to fill all available vertical space + // without hiding the "Test Rotations" button. Every nested layout + // level must have stretch set on the expanding item, otherwise Qt + // treats the extra space as dead space. + ui.verticalLayout->setStretch(0, 1); // mainHLayout fills widget + ui.verticalLayout_3->setStretch(0, 1); // group box fills right column + ui.gridLayout_3->setRowStretch(6, 1); // projectionView fills group + + renderer->SetBackground(1, 1, 1); + mapper->SetOrientation(0); + slice->SetMapper(mapper); + renderer->AddViewProp(slice); + ui.sliceView->renderWindow()->AddRenderer(renderer); + + vtkNew interactorStyle; + ui.sliceView->interactor()->SetInteractorStyle(interactorStyle); + setRotationData(vtkImageData::New()); + + // Use a child data source if one is available so the color map will match + if (op->childDataSource()) { + dataSource = op->childDataSource(); + } else if (op->dataSource()) { + dataSource = op->dataSource(); + } else { + dataSource = ActiveObjects::instance().activeDataSource(); + } + + // Set up the projection view showing one projection image (Z-axis slice). + // This matches the orientation used by the main slice view in + // RotateAlignWidget: XY plane, camera looking from +Z. + projMapper->SetInputData(image); + projMapper->SetSliceNumber(image->GetDimensions()[2] / 2); + projMapper->Update(); + projSlice->SetMapper(projMapper); + + // Use the data source's color map for the projection view + auto* dsLut = vtkScalarsToColors::SafeDownCast( + dataSource->colorMap()->GetClientSideObject()); + if (dsLut) { + projSlice->GetProperty()->SetLookupTable(dsLut); + } + + projRenderer->AddViewProp(projSlice); + + // Set up the yellow center line overlay (vertical) + vtkNew lineMapper; + lineMapper->SetInputConnection(centerLine->GetOutputPort()); + centerLineActor->SetMapper(lineMapper); + centerLineActor->GetProperty()->SetColor(1, 1, 0); + centerLineActor->GetProperty()->SetLineWidth(2.0); + projRenderer->AddActor(centerLineActor); + + // Set up the red slice line overlay (horizontal) + vtkNew sliceLineMapper; + sliceLineMapper->SetInputConnection(sliceLine->GetOutputPort()); + sliceLineActor->SetMapper(sliceLineMapper); + sliceLineActor->GetProperty()->SetColor(1, 0, 0); + sliceLineActor->GetProperty()->SetLineWidth(2.0); + projRenderer->AddActor(sliceLineActor); + + ui.projectionView->renderWindow()->AddRenderer(projRenderer); + vtkNew projInteractorStyle; + ui.projectionView->interactor()->SetInteractorStyle(projInteractorStyle); + + // Set up the Qia quality metric line plot + chartViewQia->SetRenderWindow(ui.plotViewQia->renderWindow()); + chartViewQia->SetInteractor(ui.plotViewQia->interactor()); + chartViewQia->GetScene()->AddItem(chartQia); + chartQia->SetTitle("Qia"); + chartQia->GetAxis(vtkAxis::BOTTOM)->SetTitle("Center (px)"); + chartQia->GetAxis(vtkAxis::LEFT)->SetTitle(""); + + // Set up the Qn quality metric line plot + chartViewQn->SetRenderWindow(ui.plotViewQn->renderWindow()); + chartViewQn->SetInteractor(ui.plotViewQn->interactor()); + chartViewQn->GetScene()->AddItem(chartQn); + chartQn->SetTitle("Qn"); + chartQn->GetAxis(vtkAxis::BOTTOM)->SetTitle("Center (px)"); + chartQn->GetAxis(vtkAxis::LEFT)->SetTitle(""); + + tomviz::setupRenderer(projRenderer, projMapper, nullptr); + projRenderer->GetActiveCamera()->SetViewUp(1, 0, 0); + + // Mirror the image left-to-right by placing the camera on the -Z side. + auto* cam = projRenderer->GetActiveCamera(); + double* pos = cam->GetPosition(); + double* fp = cam->GetFocalPoint(); + cam->SetPosition(pos[0], pos[1], fp[2] - (pos[2] - fp[2])); + + projRenderer->ResetCameraClippingRange(); + updateCenterLine(); + updateSliceLine(); + + static unsigned int colorMapCounter = 0; + ++colorMapCounter; + + auto pxm = ActiveObjects::instance().proxyManager(); + vtkNew tfmgr; + colorMap = + tfmgr->GetColorTransferFunction( + QString("ShiftRotationCenterWidgetColorMap%1") + .arg(colorMapCounter) + .toLatin1() + .data(), + pxm); + + // Default to the same colormap as the data source (projection view / + // main render window). Fall back to grayscale if unavailable. + auto* dsLutVtk = vtkScalarsToColors::SafeDownCast( + dataSource->colorMap()->GetClientSideObject()); + auto* colorMapVtk = + vtkScalarsToColors::SafeDownCast(colorMap->GetClientSideObject()); + if (dsLutVtk && colorMapVtk) { + colorMapVtk->DeepCopy(dsLutVtk); + } else { + setColorMapToGrayscale(); + } + + for (auto* w : inputWidgets()) { + w->installEventFilter(this); + } + + // This isn't always working in Qt designer, so set it here as well + ui.colorPresetButton->setIcon(QIcon(":/pqWidgets/Icons/pqFavorites.svg")); + + auto* dims = image->GetDimensions(); + + // All center-related values are offsets from the image midpoint in pixels. + // 0 means the rotation center is exactly at the midpoint. + setRotationCenter(0); + + // Default start/stop to +/- 50 pixels + ui.start->setValue(-50); + ui.stop->setValue(50); + + // Display image dimensions + ui.imageDimensionsLabel->setText( + QString("Image: %1 x %2 x %3 (shift axis: Y = %2 px)") + .arg(dims[0]).arg(dims[1]).arg(dims[2])); + + // Default projection number to the middle projection + ui.projectionNo->setMaximum(dims[2] - 1); + ui.projectionNo->setValue(dims[2] / 2); + + // Default slice to the middle slice (bounded by image height) + ui.slice->setMaximum(dims[0] - 1); + ui.slice->setValue(dims[0] / 2); + + // Load saved settings for steps, algorithm, numIterations only + readSettings(); + + // Hide iterations by default (only shown for iterative algorithms) + updateAlgorithmUI(); + + progressDialog.reset(new InternalProgressDialog(parent)); + + updateControls(); + setupConnections(); + + // Replace the IntSliderWidget's built-in line edit with compact + // up/down arrow buttons that move the slider by one tick. + auto* sliderLineEdit = ui.imageViewSlider->findChild(); + if (sliderLineEdit) { + sliderLineEdit->hide(); + } + auto* arrowContainer = new QWidget(ui.imageViewSlider); + auto* arrowLayout = new QVBoxLayout(arrowContainer); + arrowLayout->setContentsMargins(0, 0, 0, 0); + arrowLayout->setSpacing(0); + auto* upButton = new QToolButton(arrowContainer); + upButton->setArrowType(Qt::UpArrow); + upButton->setAutoRepeat(true); + upButton->setFixedSize(20, 14); + auto* downButton = new QToolButton(arrowContainer); + downButton->setArrowType(Qt::DownArrow); + downButton->setAutoRepeat(true); + downButton->setFixedSize(20, 14); + arrowLayout->addWidget(upButton); + arrowLayout->addWidget(downButton); + ui.imageViewSlider->layout()->addWidget(arrowContainer); + connect(upButton, &QToolButton::clicked, this, [this]() { + int current = ui.imageViewSlider->value(); + if (current < ui.imageViewSlider->maximum()) { + ui.imageViewSlider->setValue(current + 1); + sliderEdited(); + } + }); + connect(downButton, &QToolButton::clicked, this, [this]() { + int current = ui.imageViewSlider->value(); + if (current > ui.imageViewSlider->minimum()) { + ui.imageViewSlider->setValue(current - 1); + sliderEdited(); + } + }); + + // Set up transform source UI visibility + updateTransformSourceUI(); + + // Update line positions now that all values are set + updateCenterLine(); + updateSliceLine(); + } + + void setupConnections() + { + connect(ui.testRotations, &QPushButton::pressed, this, + &Internal::startGeneratingTestImages); + connect(ui.imageViewSlider, &IntSliderWidget::valueEdited, this, + &Internal::sliderEdited); + connect(&futureWatcher, &QFutureWatcher::finished, this, + &Internal::testImagesGenerated); + connect(&futureWatcher, &QFutureWatcher::finished, + progressDialog.data(), &QProgressDialog::accept); + connect(ui.colorPresetButton, &QToolButton::clicked, this, + &Internal::onColorPresetClicked); + connect(ui.previewMin, &DoubleSliderWidget::valueEdited, this, + &Internal::onPreviewRangeEdited); + connect(ui.previewMax, &DoubleSliderWidget::valueEdited, this, + &Internal::onPreviewRangeEdited); + connect(ui.algorithm, QOverload::of(&QComboBox::currentIndexChanged), + this, &Internal::updateAlgorithmUI); + connect(ui.algorithm, QOverload::of(&QComboBox::currentIndexChanged), + this, &Internal::clearTestResults); + connect(ui.start, QOverload::of(&QDoubleSpinBox::valueChanged), + this, &Internal::clearTestResults); + connect(ui.stop, QOverload::of(&QDoubleSpinBox::valueChanged), + this, &Internal::clearTestResults); + connect(ui.steps, QOverload::of(&QSpinBox::valueChanged), this, + &Internal::clearTestResults); + connect(ui.numIterations, QOverload::of(&QSpinBox::valueChanged), this, + &Internal::clearTestResults); + connect(ui.circMaskRatio, + QOverload::of(&QDoubleSpinBox::valueChanged), this, + &Internal::clearTestResults); + connect(ui.projectionNo, QOverload::of(&QSpinBox::valueChanged), this, + &Internal::onProjectionChanged); + connect(ui.slice, QOverload::of(&QSpinBox::valueChanged), this, + &Internal::onSliceChanged); + connect(ui.rotationCenter, + QOverload::of(&QDoubleSpinBox::valueChanged), this, + &Internal::updateCenterLine); + connect(ui.rotationCenter, + QOverload::of(&QDoubleSpinBox::valueChanged), this, + &Internal::updateChartIndicator); + + // Transform source UI + connect(ui.transformSource, + QOverload::of(&QComboBox::currentIndexChanged), this, + &Internal::updateTransformSourceUI); + connect(ui.transformFileBrowse, &QPushButton::clicked, this, [this]() { + auto path = QFileDialog::getOpenFileName( + parent, "Open Transform File", QString(), "NPZ files (*.npz)"); + if (!path.isEmpty()) { + ui.transformFile->setText(path); + } + }); + connect(ui.saveFileBrowse, &QPushButton::clicked, this, [this]() { + auto path = QFileDialog::getSaveFileName( + parent, "Save Transform File", QString(), "NPZ files (*.npz)"); + if (!path.isEmpty()) { + ui.saveFile->setText(path); + } + }); + } + + void onProjectionChanged(int val) + { + // Update the projection view to show the selected projection + projMapper->SetSliceNumber(val); + projMapper->Update(); + updateCenterLine(); + updateSliceLine(); + + ui.projectionView->renderWindow()->Render(); + } + + void onSliceChanged(int) + { + updateSliceLine(); + clearTestResults(); + } + + void clearTestResults() + { + setRotationData(vtkImageData::New()); + rotations.clear(); + qiaValues.clear(); + qnValues.clear(); + updateImageViewSlider(); + updateChart(); + render(); + } + + void updateCenterLine() + { + if (!image) { + return; + } + + double bounds[6]; + image->GetBounds(bounds); + double centerY = (bounds[2] + bounds[3]) / 2.0; + double lineY = centerY + rotationCenter() * image->GetSpacing()[1]; + + // Vertical line in the view (constant Y, spanning X), placed just in + // front of the current Z slice (toward the camera, which looks from -Z). + double z = bounds[4] - 1; + double p1[3] = { bounds[0], lineY, z }; + double p2[3] = { bounds[1], lineY, z }; + centerLine->SetPoint1(p1); + centerLine->SetPoint2(p2); + centerLine->Update(); + centerLineActor->GetMapper()->Update(); + + projRenderer->ResetCameraClippingRange(); + ui.projectionView->renderWindow()->Render(); + } + + void updateSliceLine() + { + if (!image) { + return; + } + + double bounds[6]; + image->GetBounds(bounds); + double lineX = bounds[0] + ui.slice->value() * image->GetSpacing()[0]; + + // Horizontal red line in the view (constant X, spanning Y), placed just in + // front of the current Z slice (toward the camera, which looks from -Z). + double z = bounds[4] - 1; + double p1[3] = { lineX, bounds[2], z }; + double p2[3] = { lineX, bounds[3], z }; + sliceLine->SetPoint1(p1); + sliceLine->SetPoint2(p2); + sliceLine->Update(); + sliceLineActor->GetMapper()->Update(); + + projRenderer->ResetCameraClippingRange(); + ui.projectionView->renderWindow()->Render(); + } + + void setupRenderer() + { + // Pass nullptr for the axes actor to avoid vtkVectorText + // "Text is not set" errors caused by degenerate bounds in the + // slice axis dimension. + tomviz::setupRenderer(renderer, mapper, nullptr); + } + + void render() { ui.sliceView->renderWindow()->Render(); } + + void readSettings() + { + auto settings = pqApplicationCore::instance()->settings(); + settings->beginGroup("ShiftRotationCenterWidget"); + ui.steps->setValue(settings->value("steps", 200).toInt()); + setAlgorithm(settings->value("algorithm", "mlem").toString()); + ui.numIterations->setValue(settings->value("numIterations", 15).toInt()); + ui.circMaskRatio->setValue(settings->value("circMaskRatio", 0.8).toDouble()); + + // Restore start/stop only if the saved values fit within the current image + auto halfDim = image->GetDimensions()[1] / 2.0; + if (settings->contains("start")) { + auto savedStart = settings->value("start").toDouble(); + if (std::abs(savedStart) <= halfDim) { + ui.start->setValue(savedStart); + } + } + if (settings->contains("stop")) { + auto savedStop = settings->value("stop").toDouble(); + if (std::abs(savedStop) <= halfDim) { + ui.stop->setValue(savedStop); + } + } + + settings->endGroup(); + } + + void writeSettings() + { + auto settings = pqApplicationCore::instance()->settings(); + settings->beginGroup("ShiftRotationCenterWidget"); + settings->setValue("steps", ui.steps->value()); + settings->setValue("algorithm", algorithm()); + settings->setValue("numIterations", ui.numIterations->value()); + settings->setValue("circMaskRatio", ui.circMaskRatio->value()); + settings->setValue("start", ui.start->value()); + settings->setValue("stop", ui.stop->value()); + settings->endGroup(); + } + + QList inputWidgets() + { + return { ui.start, ui.stop, ui.steps, ui.projectionNo, ui.slice, + ui.rotationCenter }; + } + + void startGeneratingTestImages() + { + progressDialog->show(); + auto future = + QtConcurrent::run(std::bind(&Internal::generateTestImages, this)); + futureWatcher.setFuture(future); + } + + void testImagesGenerated() + { + if (!testRotationsSuccess) { + auto msg = testRotationsErrorMessage; + qCritical() << msg; + QMessageBox::critical(parent, "Tomviz", msg); + return; + } + + // Re-update the mapper on the main thread so bounds are current, + // then configure the camera. This must happen here (not in + // setRotationData) because that runs on a background thread. + mapper->Update(); + setupRenderer(); + renderer->ResetCameraClippingRange(); + updateImageViewSlider(); + + if (rotationDataValid()) { + resetColorRange(); + render(); + } + + updateChart(); + } + + void generateTestImages() + { + testRotationsSuccess = false; + rotations.clear(); + + { + Python python; + auto module = pythonHelper.loadModule(script); + if (!module.isValid()) { + testRotationsErrorMessage = "Failed to load script"; + return; + } + + auto func = module.findFunction("test_rotations"); + if (!func.isValid()) { + testRotationsErrorMessage = + "Failed to find function \"test_rotations\""; + return; + } + + Python::Object data = Python::createDataset(image, *dataSource); + + Python::Dict kwargs; + kwargs.set("dataset", data); + kwargs.set("start", ui.start->value()); + kwargs.set("stop", ui.stop->value()); + kwargs.set("steps", ui.steps->value()); + kwargs.set("sli", ui.slice->value()); + kwargs.set("algorithm", algorithm()); + kwargs.set("num_iter", ui.numIterations->value()); + kwargs.set("circ_mask_ratio", ui.circMaskRatio->value()); + + auto ret = func.call(kwargs); + auto result = ret.toDict(); + if (!result.isValid()) { + testRotationsErrorMessage = "Failed to execute test_rotations()"; + return; + } + + auto pyImages = result["images"]; + auto* object = Python::VTK::convertToDataObject(pyImages); + if (!object) { + testRotationsErrorMessage = + "No image data was returned from test_rotations()"; + return; + } + + auto* imageData = vtkImageData::SafeDownCast(object); + if (!imageData) { + testRotationsErrorMessage = + "No image data was returned from test_rotations()"; + return; + } + + auto centers = result["centers"]; + auto pyRotations = centers.toList(); + if (!pyRotations.isValid() || pyRotations.length() <= 0) { + testRotationsErrorMessage = + "No rotations returned from test_rotations()"; + return; + } + + for (int i = 0; i < pyRotations.length(); ++i) { + rotations.append(pyRotations[i].toDouble()); + } + + qiaValues.clear(); + qnValues.clear(); + + auto pyQia = result["qia"]; + auto qiaList = pyQia.toList(); + if (qiaList.isValid()) { + for (int i = 0; i < qiaList.length(); ++i) { + qiaValues.append(qiaList[i].toDouble()); + } + } + + auto pyQn = result["qn"]; + auto qnList = pyQn.toList(); + if (qnList.isValid()) { + for (int i = 0; i < qnList.length(); ++i) { + qnValues.append(qnList[i].toDouble()); + } + } + + setRotationData(imageData); + } + + // If we made it this far, it was a success + // Save these settings in case the user wants to use them again... + writeSettings(); + testRotationsSuccess = true; + } + + void setRotationData(vtkImageData* data) + { + rotationImages = data; + mapper->SetInputData(rotationImages); + mapper->SetSliceNumber(0); + mapper->Update(); + } + + void resetColorRange() + { + if (!rotationDataValid()) { + return; + } + + auto* range = rotationImages->GetScalarRange(); + + auto blocked1 = QSignalBlocker(ui.previewMin); + auto blocked2 = QSignalBlocker(ui.previewMax); + ui.previewMin->setMinimum(range[0]); + ui.previewMin->setMaximum(range[1]); + ui.previewMin->setValue(range[0]); + ui.previewMax->setMinimum(range[0]); + ui.previewMax->setMaximum(range[1]); + ui.previewMax->setValue(range[1]); + + rescaleColors(range); + } + + void rescaleColors(double* range) + { + // Always perform a deep copy of the original color map + // If we always modify the control points of the same LUT, + // the control points will often change and we will end up + // with a very different LUT than we had originally. + resetLut(); + if (!lut) { + return; + } + + auto* tf = vtkColorTransferFunction::SafeDownCast(lut); + if (!tf) { + return; + } + + rescaleLut(tf, range[0], range[1]); + } + + void onPreviewRangeEdited() + { + if (!rotationDataValid() || !lut) { + return; + } + + auto* maxRange = rotationImages->GetScalarRange(); + + double range[2]; + range[0] = ui.previewMin->value(); + range[1] = ui.previewMax->value(); + + auto minDiff = (maxRange[1] - maxRange[0]) / 1000; + if (range[1] - range[0] < minDiff) { + if (sender() == ui.previewMin) { + // Move the max + range[1] = range[0] + minDiff; + auto blocked = QSignalBlocker(ui.previewMax); + ui.previewMax->setValue(range[1]); + } else { + // Move the min + range[0] = range[1] - minDiff; + auto blocked = QSignalBlocker(ui.previewMin); + ui.previewMin->setValue(range[0]); + } + } + + rescaleColors(range); + render(); + } + + void updateControls() + { + std::vector blockers; + for (auto w : inputWidgets()) { + blockers.emplace_back(w); + } + + updateImageViewSlider(); + } + + bool rotationDataValid() + { + if (!rotationImages.GetPointer()) { + return false; + } + + if (rotations.isEmpty()) { + return false; + } + + return true; + } + + void updateImageViewSlider() + { + auto blocked = QSignalBlocker(ui.imageViewSlider); + + bool enable = rotationDataValid(); + ui.testRotationsSettingsGroup->setVisible(enable); + ui.plotViewQia->setVisible(enable); + bool iterative = (ui.algorithm->currentText() == "mlem" || + ui.algorithm->currentText() == "ospml_hybrid"); + ui.plotViewQn->setVisible(enable && !iterative); + if (!enable) { + return; + } + + auto* dims = rotationImages->GetDimensions(); + ui.imageViewSlider->setMaximum(dims[0] - 1); + + sliceNumber = dims[0] / 2; + ui.imageViewSlider->setValue(sliceNumber); + + sliderEdited(); + } + + void sliderEdited() + { + sliceNumber = ui.imageViewSlider->value(); + if (sliceNumber < rotations.size()) { + ui.currentRotation->setValue(rotations[sliceNumber]); + + // For convenience, also set the rotation center + ui.rotationCenter->setValue(rotations[sliceNumber]); + } else { + qCritical() << sliceNumber + << "is greater than the rotations size:" << rotations.size(); + } + + mapper->SetSliceNumber(sliceNumber); + mapper->Update(); + render(); + } + + bool eventFilter(QObject* o, QEvent* e) override + { + if (inputWidgets().contains(qobject_cast(o))) { + if (e->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast(e); + if (keyEvent->key() == Qt::Key_Return || + keyEvent->key() == Qt::Key_Enter) { + e->accept(); + qobject_cast(o)->clearFocus(); + return true; + } + } + } + return QObject::eventFilter(o, e); + } + + void resetLut() + { + auto dsLut = + vtkScalarsToColors::SafeDownCast(colorMap->GetClientSideObject()); + if (!dsLut) { + return; + } + + // Make a deep copy to modify + lut = dsLut->NewInstance(); + lut->DeepCopy(dsLut); + slice->GetProperty()->SetLookupTable(lut); + } + + void setColorMapToGrayscale() + { + ColorMap::instance().applyPreset("Grayscale", colorMap); + } + + void onColorPresetClicked() + { + if (!colorMap) { + qCritical() << "No color map found!"; + return; + } + + PresetDialog dialog(tomviz::mainWidget()); + connect(&dialog, &PresetDialog::applyPreset, this, [this, &dialog]() { + ColorMap::instance().applyPreset(dialog.presetName(), colorMap); + // Keep the range the same + double range[2]; + range[0] = ui.previewMin->value(); + range[1] = ui.previewMax->value(); + rescaleColors(range); + render(); + }); + dialog.exec(); + } + + void setRotationCenter(double center) { ui.rotationCenter->setValue(center); } + double rotationCenter() const { return ui.rotationCenter->value(); } + + QString algorithm() const { return ui.algorithm->currentText(); } + void setAlgorithm(const QString& alg) + { + int index = ui.algorithm->findText(alg); + if (index >= 0) { + ui.algorithm->setCurrentIndex(index); + } + } + + void updateAlgorithmUI() + { + auto alg = ui.algorithm->currentText(); + bool iterative = (alg == "mlem" || alg == "ospml_hybrid"); + ui.numIterationsLabel->setVisible(iterative); + ui.numIterations->setVisible(iterative); + + // Qn is only meaningful for non-iterative algorithms (gridrec, fbp) + // that can produce negative values in the reconstruction. + ui.plotViewQn->setVisible(!iterative && rotationDataValid()); + } + + void updateTransformSourceUI() + { + bool manual = (ui.transformSource->currentIndex() == 0); + + // Manual mode: show rotation center, save file, test rotation controls + ui.rotationCenterLabel->setVisible(manual); + ui.rotationCenter->setVisible(manual); + ui.saveFileLabel->setVisible(manual); + ui.saveFile->setVisible(manual); + ui.saveFileBrowse->setVisible(manual); + ui.testRotationCentersGroup->setVisible(manual); + ui.testRotationsSettingsGroup->setVisible(manual && rotationDataValid()); + ui.plotViewQia->setVisible(manual && rotationDataValid()); + bool iterAlg = (ui.algorithm->currentText() == "mlem" || + ui.algorithm->currentText() == "ospml_hybrid"); + ui.plotViewQn->setVisible(manual && rotationDataValid() && !iterAlg); + ui.sliceView->setVisible(manual); + + // Load-from-file mode: show transform file controls + ui.transformFileLabel->setVisible(!manual); + ui.transformFile->setVisible(!manual); + ui.transformFileBrowse->setVisible(!manual); + } + + void populateChart(vtkChartXY* targetChart, + QVTKGLWidget* view, const QList& values, + unsigned char r, unsigned char g, unsigned char b) + { + targetChart->ClearPlots(); + + if (rotations.isEmpty() || values.isEmpty()) { + view->renderWindow()->Render(); + return; + } + + int n = std::min(rotations.size(), values.size()); + + vtkNew xArr; + xArr->SetName("Center"); + xArr->SetNumberOfValues(n); + + vtkNew yArr; + yArr->SetName("Value"); + yArr->SetNumberOfValues(n); + + for (int i = 0; i < n; ++i) { + xArr->SetValue(i, rotations[i]); + yArr->SetValue(i, values[i]); + } + + vtkNew table; + table->AddColumn(xArr); + table->AddColumn(yArr); + table->SetNumberOfRows(n); + + auto* line = targetChart->AddPlot(vtkChart::LINE); + line->SetInputData(table, 0, 1); + line->SetColor(r, g, b, 255); + line->SetWidth(2.0); + } + + void addIndicator(vtkChartXY* targetChart, vtkTable* indTable, + QVTKGLWidget* view, const QList& values) + { + if (rotations.isEmpty() || values.isEmpty()) { + return; + } + + // Remove old indicator (keep only the data plot at index 0) + while (targetChart->GetNumberOfPlots() > 1) { + targetChart->RemovePlot(targetChart->GetNumberOfPlots() - 1); + } + + double center = rotationCenter(); + + double yMin = values[0]; + double yMax = values[0]; + for (auto v : values) { + if (v < yMin) + yMin = v; + if (v > yMax) + yMax = v; + } + double yPadding = (yMax - yMin) * 0.05; + + vtkNew indX; + indX->SetName("X"); + indX->SetNumberOfValues(2); + indX->SetValue(0, center); + indX->SetValue(1, center); + + vtkNew indY; + indY->SetName("Y"); + indY->SetNumberOfValues(2); + indY->SetValue(0, yMin - yPadding); + indY->SetValue(1, yMax + yPadding); + + indTable->Initialize(); + indTable->AddColumn(indX); + indTable->AddColumn(indY); + indTable->SetNumberOfRows(2); + + auto* indLine = targetChart->AddPlot(vtkChart::LINE); + indLine->SetInputData(indTable, 0, 1); + indLine->SetColor(255, 255, 0, 255); + indLine->SetWidth(2.0); + + view->renderWindow()->Render(); + } + + void updateChart() + { + populateChart(chartQia, ui.plotViewQia, qiaValues, 0, 114, 189); + populateChart(chartQn, ui.plotViewQn, qnValues, 217, 83, 25); + updateChartIndicator(); + } + + void updateChartIndicator() + { + addIndicator(chartQia, indicatorTableQia, ui.plotViewQia, qiaValues); + addIndicator(chartQn, indicatorTableQn, ui.plotViewQn, qnValues); + } +}; + +#include "ShiftRotationCenterWidget.moc" + +ShiftRotationCenterWidget::ShiftRotationCenterWidget( + Operator* op, vtkSmartPointer image, QWidget* p) + : CustomPythonOperatorWidget(p) +{ + m_internal.reset(new Internal(op, image, this)); +} + +ShiftRotationCenterWidget::~ShiftRotationCenterWidget() = default; + +void ShiftRotationCenterWidget::getValues(QVariantMap& map) +{ + map.insert("rotation_center", m_internal->rotationCenter()); + + auto sourceIndex = m_internal->ui.transformSource->currentIndex(); + map.insert("transform_source", sourceIndex == 0 ? "manual" : "from_file"); + map.insert("transform_file", m_internal->ui.transformFile->text()); + map.insert("transforms_save_file", m_internal->ui.saveFile->text()); +} + +void ShiftRotationCenterWidget::setValues(const QVariantMap& map) +{ + if (map.contains("rotation_center")) { + m_internal->setRotationCenter(map["rotation_center"].toDouble()); + } + if (map.contains("algorithm")) { + m_internal->setAlgorithm(map["algorithm"].toString()); + } + if (map.contains("num_iter")) { + m_internal->ui.numIterations->setValue(map["num_iter"].toInt()); + } + if (map.contains("transform_source")) { + auto source = map["transform_source"].toString(); + m_internal->ui.transformSource->setCurrentIndex( + source == "from_file" ? 1 : 0); + } + if (map.contains("transform_file")) { + m_internal->ui.transformFile->setText(map["transform_file"].toString()); + } + if (map.contains("transforms_save_file")) { + m_internal->ui.saveFile->setText(map["transforms_save_file"].toString()); + } +} + +void ShiftRotationCenterWidget::setScript(const QString& script) +{ + Superclass::setScript(script); + m_internal->script = script; +} + +void ShiftRotationCenterWidget::writeSettings() +{ + Superclass::writeSettings(); + m_internal->writeSettings(); +} + +} // namespace tomviz diff --git a/tomviz/FxiWorkflowWidget.h b/tomviz/ShiftRotationCenterWidget.h similarity index 64% rename from tomviz/FxiWorkflowWidget.h rename to tomviz/ShiftRotationCenterWidget.h index 165a2a6de..2e6496dc4 100644 --- a/tomviz/FxiWorkflowWidget.h +++ b/tomviz/ShiftRotationCenterWidget.h @@ -1,8 +1,8 @@ /* This source file is part of the Tomviz project, https://tomviz.org/. It is released under the 3-Clause BSD License, see "LICENSE". */ -#ifndef tomvizFxiWorkflowWidget_h -#define tomvizFxiWorkflowWidget_h +#ifndef tomvizShiftRotationCenterWidget_h +#define tomvizShiftRotationCenterWidget_h #include "CustomPythonOperatorWidget.h" @@ -15,15 +15,15 @@ class vtkImageData; namespace tomviz { class Operator; -class FxiWorkflowWidget : public CustomPythonOperatorWidget +class ShiftRotationCenterWidget : public CustomPythonOperatorWidget { Q_OBJECT typedef CustomPythonOperatorWidget Superclass; public: - FxiWorkflowWidget(Operator* op, vtkSmartPointer image, - QWidget* parent = NULL); - ~FxiWorkflowWidget(); + ShiftRotationCenterWidget(Operator* op, vtkSmartPointer image, + QWidget* parent = NULL); + ~ShiftRotationCenterWidget(); static CustomPythonOperatorWidget* New(QWidget* p, Operator* op, vtkSmartPointer data); @@ -32,21 +32,20 @@ class FxiWorkflowWidget : public CustomPythonOperatorWidget void setValues(const QMap& map) override; void setScript(const QString& script) override; - void setupUI(OperatorPython* op) override; void writeSettings() override; private: - Q_DISABLE_COPY(FxiWorkflowWidget) + Q_DISABLE_COPY(ShiftRotationCenterWidget) class Internal; QScopedPointer m_internal; }; -inline CustomPythonOperatorWidget* FxiWorkflowWidget::New( +inline CustomPythonOperatorWidget* ShiftRotationCenterWidget::New( QWidget* p, Operator* op, vtkSmartPointer data) { - return new FxiWorkflowWidget(op, data, p); + return new ShiftRotationCenterWidget(op, data, p); } } // namespace tomviz diff --git a/tomviz/FxiWorkflowWidget.ui b/tomviz/ShiftRotationCenterWidget.ui similarity index 61% rename from tomviz/FxiWorkflowWidget.ui rename to tomviz/ShiftRotationCenterWidget.ui index 4e2cd3c5a..918fa4886 100644 --- a/tomviz/FxiWorkflowWidget.ui +++ b/tomviz/ShiftRotationCenterWidget.ui @@ -1,7 +1,7 @@ - FxiWorkflowWidget - + ShiftRotationCenterWidget + 0 @@ -30,11 +30,28 @@ 0 - - - + + + + + + + + 1 + 2 + + + + + 300 + 300 + + + + + - + 2 @@ -51,7 +68,7 @@ - + Test Rotations @@ -75,6 +92,13 @@ + + + + + + + @@ -94,6 +118,9 @@ 3 + + -100000.000000000000000 + 100000.000000000000000 @@ -117,6 +144,9 @@ 3 + + -100000.000000000000000 + 100000.000000000000000 @@ -142,6 +172,97 @@ + + + + + + + + Algorithm: + + + algorithm + + + + + + + + gridrec + + + + + fbp + + + + + mlem + + + + + ospml_hybrid + + + + + + + + <html><head/><body><p>Number of iterations to use for iterative algorithms like `mlem`. Default is 15 for speed. </p><p><br/></p><p>For greater accuracy, the suggested range is between 50 and 200 iterations.</p></body></html> + + + Iterations: + + + numIterations + + + + + + + <html><head/><body><p>Number of iterations to use for iterative algorithms like `mlem`. Default is 15 for speed. </p><p><br/></p><p>For greater accuracy, the suggested range is between 50 and 200 iterations.</p></body></html> + + + 1 + + + 1000 + + + 20 + + + + + + + + + + + Projection No.: + + + projectionNo + + + + + + + false + + + 100000 + + + @@ -162,31 +283,54 @@ + + + + Circle Mask Ratio: + + + circMaskRatio + + + + + + + false + + + 2 + + + 0.000000000000000 + + + 1.000000000000000 + + + 0.050000000000000 + + + 0.800000000000000 + + + - - - - - - - - 450 - 0 - - - - true - - - <html><head/><body><p><span style=" font-size:large; font-weight:600;">Additional Parameters</span></p></body></html> - - - true - - - - + + + + + 1 + 1 + + + + + 200 + 0 + + @@ -235,6 +379,9 @@ 3 + + -1000000.000000000000000 + 1000000.000000000000000 @@ -281,7 +428,7 @@ - Rotation: + Offset: currentRotation @@ -308,23 +455,7 @@ - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 40 - - - - - - + QGroupBox{padding-top:15px; margin-top:-15px} @@ -332,256 +463,157 @@ - - - - - - Slice Start: - - - sliceStart - - - - - - - Slice Stop: - - - sliceStop - - - - - - - - 450 - 0 - - - - true - - - <html><head/><body><p><span style=" font-size:large; font-weight:600;">Reconstruction</span></p></body></html> - - - true - - - - - - - Rotation Center: - - - rotationCenter - - - - - - - false - - - 10000 - - - - - - - false - - - 10000 - - - - - - - false - - - 3 - - - 0.000000000000000 - - - 100000.000000000000000 - - - 0.500000000000000 - - - - - - - - - - - 450 - 0 - - - - true - - - <html><head/><body><p><span style=" font-size:large; font-weight:600;">Additional Parameters</span></p></body></html> - - - true - - - - - - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - 1 - 1 - - - - - 300 - 300 - - - - - - - - QGroupBox{padding-top:15px; margin-top:-15px} - - - - - - - - - - - <html><head/><body><p>Whether to apply Wiener denoise</p></body></html> + + + + + 450 + 0 + - Denoise Flag + <html><head/><body><h3 style=" margin-top:4px; margin-bottom:4px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:large; font-weight:600;">Parameters</span></h3></body></html> + + + true - - - - <html><head/><body><p>The level to apply to tomopy.prep.stripe.remove_stripe_fw</p></body></html> - + + - Denoise Level: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Source: - denoiseLevel + transformSource - - - - <html><head/><body><p>The level to apply to tomopy.prep.stripe.remove_stripe_fw</p></body></html> - - - 0 - - - 1000 - + + + + + Manual + + + + + Load From File + + - - - - <html><head/><body><p>The scaling that should be applied to the dark image</p></body></html> - + + - Dark Scale: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Center Offset: - darkScale + rotationCenter - - - - <html><head/><body><p>The scaling that should be applied to the dark image</p></body></html> + + + + false - 6 + 3 - -10000.000000000000000 + -100000.000000000000000 - 10000.000000000000000 + 100000.000000000000000 + + + 0.500000000000000 + + + + + + + Transform File: - - 1.000000000000000 + + + + + + + + + + + Browse... + + + + + + + + + Save File: + + + + + + + + + Browse... + + + + + - - - - - <html><head/><body><p><span style=" font-size:large; font-weight:600;">General Parameters</span></p><p>Parameters used in both &quot;Test Rotation Centers&quot; and &quot;Reconstruction&quot;.</p></body></html> - - - false - - - - - + + + + + + + + + 1 + 1 + + + + + 100 + 150 + + + + + + + + + 1 + 1 + + + + + 100 + 150 + + + + + + + @@ -613,19 +645,21 @@ - denoiseFlag - denoiseLevel - darkScale start stop steps + projectionNo slice + algorithm + numIterations + circMaskRatio testRotations currentRotation - colorPresetButton + transformSource rotationCenter - sliceStart - sliceStop + transformFile + saveFile + colorPresetButton diff --git a/tomviz/SpinBox.cxx b/tomviz/SpinBox.cxx index 1edab4716..d84531da8 100644 --- a/tomviz/SpinBox.cxx +++ b/tomviz/SpinBox.cxx @@ -32,8 +32,8 @@ void SpinBox::mousePressEvent(QMouseEvent* event) this->pressInUp = this->pressInDown = false; } if (this->pressInUp || this->pressInDown) { - this->connect(this, SIGNAL(valueChanged(int)), this, - SIGNAL(editingFinished())); + this->connect(this, QOverload::of(&SpinBox::valueChanged), this, + &SpinBox::editingFinished); } } @@ -42,8 +42,8 @@ void SpinBox::mouseReleaseEvent(QMouseEvent* event) QSpinBox::mouseReleaseEvent(event); if (this->pressInUp || this->pressInDown) { - this->disconnect(this, SIGNAL(valueChanged(int)), this, - SIGNAL(editingFinished())); + this->disconnect(this, QOverload::of(&SpinBox::valueChanged), this, + &SpinBox::editingFinished); } QStyleOptionSpinBox opt; diff --git a/tomviz/TransposeDataReaction.cxx b/tomviz/TransposeDataReaction.cxx index f1b1b719f..9c0fe4999 100644 --- a/tomviz/TransposeDataReaction.cxx +++ b/tomviz/TransposeDataReaction.cxx @@ -32,6 +32,6 @@ void TransposeDataReaction::transposeData(DataSource* source) new EditOperatorDialog(Op, source, true, m_mainWindow); dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->show(); - connect(Op, SIGNAL(destroyed()), dialog, SLOT(reject())); + connect(Op, &QObject::destroyed, dialog, &QDialog::reject); } } // namespace tomviz diff --git a/tomviz/Tvh5Format.cxx b/tomviz/Tvh5Format.cxx index 6e295d743..cdde754c2 100644 --- a/tomviz/Tvh5Format.cxx +++ b/tomviz/Tvh5Format.cxx @@ -185,7 +185,9 @@ bool Tvh5Format::loadDataSource(h5::H5ReadWrite& reader, if (parent) { // This is a child data source. Hook it up to the operator parent. parent->setChildDataSource(dataSource); - parent->setHasChildDataSource(true); + // Don't call setHasChildDataSource(true) here. The operator's own + // initialization (JSON "children" section or constructor) is the authority + // on whether the executor should expect child data in the return dict. parent->newChildDataSource(dataSource); // If it has a parent, it will be deserialized later. } else { @@ -218,12 +220,16 @@ bool Tvh5Format::loadDataSource(h5::H5ReadWrite& reader, for (auto* op : dataSource->operators()) op->setComplete(); - if (pipeline) { - // Make sure the pipeline is not paused in case the user wishes to - // re-run some operators. - pipeline->resume(); - // This will deserialize all children. - pipeline->finished(); + // Ensure the pipeline is not paused. DataSource::deserialize() pauses it + // but only resumes when executePipelinesOnLoad is true, which is false + // during tvh5 loading. + auto* p = dataSource->pipeline(); + if (p) { + p->resume(); + if (parent) { + // This will deserialize all children. + p->finished(); + } } return true; diff --git a/tomviz/Utilities.cxx b/tomviz/Utilities.cxx index d8866fe1d..37f4092a1 100644 --- a/tomviz/Utilities.cxx +++ b/tomviz/Utilities.cxx @@ -1124,6 +1124,55 @@ QJsonDocument tableToJson(vtkTable* table) return QJsonDocument(rows); } +QString tableToCsv(vtkTable* table) +{ + QStringList lines; + QStringList headers; + for (vtkIdType j = 0; j < table->GetNumberOfColumns(); ++j) { + headers << QString(table->GetColumnName(j)); + } + lines << headers.join(","); + for (vtkIdType i = 0; i < table->GetNumberOfRows(); ++i) { + auto row = table->GetRow(i); + QStringList fields; + for (vtkIdType j = 0; j < row->GetSize(); ++j) { + auto value = row->GetValue(j); + if (value.IsNumeric()) { + fields << QString::number(value.ToDouble()); + } else { + fields << QString(); + } + } + lines << fields.join(","); + } + return lines.join("\n"); +} + +bool csvToFile(const QString& csv) +{ + QStringList filters; + filters << "CSV Files (*.csv)"; + QFileDialog dialog; + dialog.setFileMode(QFileDialog::AnyFile); + dialog.setNameFilters(filters); + dialog.setAcceptMode(QFileDialog::AcceptSave); + QString fileName = dialogToFileName(&dialog); + if (fileName.isEmpty()) { + return false; + } + if (!fileName.endsWith(".csv")) { + fileName = QString("%1.csv").arg(fileName); + } + QFile file(fileName); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + qCritical() << QString("Error opening file for writing: %1").arg(fileName); + return false; + } + file.write(csv.toUtf8()); + file.close(); + return true; +} + QJsonDocument vectorToJson(const QVector vector) { QJsonArray rows; diff --git a/tomviz/Utilities.h b/tomviz/Utilities.h index ed33c48d3..b342369dc 100644 --- a/tomviz/Utilities.h +++ b/tomviz/Utilities.h @@ -237,6 +237,10 @@ bool jsonToFile(const QJsonDocument& json); QJsonDocument tableToJson(vtkTable* table); QJsonDocument vectorToJson(const QVector vector); +/// Write a vtkTable to csv file +QString tableToCsv(vtkTable* table); +bool csvToFile(const QString& csv); + /// Write a vtkMolecule to json file bool moleculeToFile(vtkMolecule* molecule); extern double offWhite[3]; diff --git a/tomviz/ViewFrameActions.cxx b/tomviz/ViewFrameActions.cxx index 509fca31d..f4e5c06c2 100644 --- a/tomviz/ViewFrameActions.cxx +++ b/tomviz/ViewFrameActions.cxx @@ -20,6 +20,8 @@ ViewFrameActions::availableViewTypes() views.push_back(viewType); else if (viewType.Name == "SpreadSheetView") views.push_back(viewType); + else if (viewType.Name == "XYChartView") + views.push_back(viewType); } return views; } diff --git a/tomviz/ViewMenuManager.cxx b/tomviz/ViewMenuManager.cxx index 619ff9123..af24c2e92 100644 --- a/tomviz/ViewMenuManager.cxx +++ b/tomviz/ViewMenuManager.cxx @@ -72,8 +72,10 @@ ViewMenuManager::ViewMenuManager(QMainWindow* mainWindow, QMenu* menu) pqCoreUtilities::connect(m_view, vtkCommand::PropertyModifiedEvent, this, SLOT(onViewPropertyChanged())); } - connect(&ActiveObjects::instance(), SIGNAL(viewChanged(vtkSMViewProxy*)), - SLOT(onViewChanged())); + connect(&ActiveObjects::instance(), + static_cast( + &ActiveObjects::viewChanged), + this, &ViewMenuManager::onViewChanged); connect(&ActiveObjects::instance(), &ActiveObjects::dataSourceActivated, this, &ViewMenuManager::updateDataSource); @@ -91,14 +93,14 @@ ViewMenuManager::ViewMenuManager(QMainWindow* mainWindow, QMenu* menu) m_perspectiveProjectionAction->setCheckable(true); m_perspectiveProjectionAction->setActionGroup(projectionGroup); m_perspectiveProjectionAction->setChecked(true); - connect(m_perspectiveProjectionAction, SIGNAL(triggered()), - SLOT(setProjectionModeToPerspective())); + connect(m_perspectiveProjectionAction, &QAction::triggered, this, + &ViewMenuManager::setProjectionModeToPerspective); m_orthographicProjectionAction = Menu->addAction("Orthographic Projection"); m_orthographicProjectionAction->setCheckable(true); m_orthographicProjectionAction->setActionGroup(projectionGroup); m_orthographicProjectionAction->setChecked(false); - connect(m_orthographicProjectionAction, SIGNAL(triggered()), - SLOT(setProjectionModeToOrthographic())); + connect(m_orthographicProjectionAction, &QAction::triggered, this, + &ViewMenuManager::setProjectionModeToOrthographic); Menu->addSeparator(); diff --git a/tomviz/WebExportWidget.cxx b/tomviz/WebExportWidget.cxx index 45e3412c4..cda40a64a 100644 --- a/tomviz/WebExportWidget.cxx +++ b/tomviz/WebExportWidget.cxx @@ -183,10 +183,10 @@ WebExportWidget::WebExportWidget(QWidget* p) : QDialog(p) // UI binding connect(m_buttonBox, &QDialogButtonBox::helpRequested, []() { openHelpUrl("visualization/#export-to-web"); }); - connect(m_exportButton, SIGNAL(pressed()), this, SLOT(onExport())); - connect(m_cancelButton, SIGNAL(pressed()), this, SLOT(onCancel())); - connect(m_exportType, SIGNAL(currentIndexChanged(int)), this, - SLOT(onTypeChange(int))); + connect(m_exportButton, &QPushButton::pressed, this, &WebExportWidget::onExport); + connect(m_cancelButton, &QPushButton::pressed, this, &WebExportWidget::onCancel); + connect(m_exportType, QOverload::of(&QComboBox::currentIndexChanged), this, + &WebExportWidget::onTypeChange); // Initialize visibility onTypeChange(0); diff --git a/tomviz/WelcomeDialog.cxx b/tomviz/WelcomeDialog.cxx index 7616d1421..4f1f6a4b5 100644 --- a/tomviz/WelcomeDialog.cxx +++ b/tomviz/WelcomeDialog.cxx @@ -8,6 +8,9 @@ #include "MainWindow.h" #include "ModuleManager.h" +#include +#include + #include #include @@ -17,10 +20,11 @@ WelcomeDialog::WelcomeDialog(MainWindow* mw) : QDialog(mw), m_ui(new Ui::WelcomeDialog) { m_ui->setupUi(this); - connect(m_ui->doNotShowAgain, SIGNAL(stateChanged(int)), - SLOT(onDoNotShowAgainStateChanged(int))); - connect(m_ui->noButton, SIGNAL(clicked()), SLOT(hide())); - connect(m_ui->yesButton, SIGNAL(clicked()), SLOT(onLoadSampleDataClicked())); + connect(m_ui->doNotShowAgain, &QCheckBox::checkStateChanged, this, + &WelcomeDialog::onDoNotShowAgainStateChanged); + connect(m_ui->noButton, &QPushButton::clicked, this, &WelcomeDialog::hide); + connect(m_ui->yesButton, &QPushButton::clicked, this, + &WelcomeDialog::onLoadSampleDataClicked); } WelcomeDialog::~WelcomeDialog() = default; @@ -41,7 +45,7 @@ void WelcomeDialog::onLoadSampleDataClicked() hide(); } -void WelcomeDialog::onDoNotShowAgainStateChanged(int state) +void WelcomeDialog::onDoNotShowAgainStateChanged(Qt::CheckState state) { bool showDialog = (state != Qt::Checked); diff --git a/tomviz/WelcomeDialog.h b/tomviz/WelcomeDialog.h index 23a4d4e4b..2a394d188 100644 --- a/tomviz/WelcomeDialog.h +++ b/tomviz/WelcomeDialog.h @@ -27,7 +27,7 @@ private slots: void onLoadSampleDataClicked(); // React to checkbox events - void onDoNotShowAgainStateChanged(int); + void onDoNotShowAgainStateChanged(Qt::CheckState); private: QScopedPointer m_ui; diff --git a/tomviz/acquisition/AcquisitionWidget.cxx b/tomviz/acquisition/AcquisitionWidget.cxx index c8fab6a82..e10b3e112 100644 --- a/tomviz/acquisition/AcquisitionWidget.cxx +++ b/tomviz/acquisition/AcquisitionWidget.cxx @@ -40,10 +40,12 @@ AcquisitionWidget::AcquisitionWidget(QWidget* parent) m_ui->setupUi(this); setWindowFlags(Qt::Dialog); - connect(m_ui->connectButton, SIGNAL(clicked(bool)), SLOT(connectToServer())); - connect(m_ui->disconnectButton, SIGNAL(clicked(bool)), - SLOT(disconnectFromServer())); - connect(m_ui->previewButton, SIGNAL(clicked(bool)), SLOT(setTiltAngle())); + connect(m_ui->connectButton, &QAbstractButton::clicked, this, + &AcquisitionWidget::connectToServer); + connect(m_ui->disconnectButton, &QAbstractButton::clicked, this, + &AcquisitionWidget::disconnectFromServer); + connect(m_ui->previewButton, &QAbstractButton::clicked, this, + &AcquisitionWidget::setTiltAngle); connect(m_ui->buttonBox, &QDialogButtonBox::helpRequested, []() { openHelpUrl("acquisition"); }); @@ -95,7 +97,8 @@ void AcquisitionWidget::connectToServer() .arg(connection->hostName()) .arg(connection->port())); auto request = m_client->connect(QJsonObject()); - connect(request, SIGNAL(finished(QJsonValue)), SLOT(onConnect())); + connect(request, &AcquisitionClientRequest::finished, this, + &AcquisitionWidget::onConnect); connect(request, &AcquisitionClientRequest::error, this, &AcquisitionWidget::onError); } @@ -112,7 +115,8 @@ void AcquisitionWidget::disconnectFromServer() { m_ui->statusEdit->setText("Disconnecting"); auto request = m_client->disconnect(QJsonObject()); - connect(request, SIGNAL(finished(QJsonValue)), SLOT(onDisconnect())); + connect(request, &AcquisitionClientRequest::finished, this, + &AcquisitionWidget::onDisconnect); connect(request, &AcquisitionClientRequest::error, this, &AcquisitionWidget::onError); } @@ -128,8 +132,8 @@ void AcquisitionWidget::setAcquireParameters() { QJsonObject params; auto request = m_client->acquisition_params(params); - connect(request, SIGNAL(finished(QJsonValue)), - SLOT(acquireParameterResponse(QJsonValue))); + connect(request, &AcquisitionClientRequest::finished, this, + &AcquisitionWidget::acquireParameterResponse); connect(request, &AcquisitionClientRequest::error, this, &AcquisitionWidget::onError); } @@ -160,8 +164,8 @@ void AcquisitionWidget::setTiltAngle() QJsonObject params; params["angle"] = m_ui->tiltAngleSpinBox->value(); auto request = m_client->tilt_params(params); - connect(request, SIGNAL(finished(QJsonValue)), - SLOT(acquirePreview(QJsonValue))); + connect(request, &AcquisitionClientRequest::finished, this, + &AcquisitionWidget::acquirePreview); connect(request, &AcquisitionClientRequest::error, this, &AcquisitionWidget::onError); @@ -178,8 +182,9 @@ void AcquisitionWidget::acquirePreview(const QJsonValue& result) } auto request = m_client->preview_scan(); - connect(request, SIGNAL(finished(QString, QByteArray)), - SLOT(previewReady(QString, QByteArray))); + connect(request, &AcquisitionClientImageRequest::finished, this, + [this](const QString& mimeType, const QByteArray& imageData, + const QJsonObject&) { previewReady(mimeType, imageData); }); connect(request, &AcquisitionClientRequest::error, this, &AcquisitionWidget::onError); } diff --git a/tomviz/icons/breakpoint.png b/tomviz/icons/breakpoint.png new file mode 100644 index 000000000..6c7abaf5b Binary files /dev/null and b/tomviz/icons/breakpoint.png differ diff --git a/tomviz/icons/breakpoint@2x.png b/tomviz/icons/breakpoint@2x.png new file mode 100644 index 000000000..0114dfadf Binary files /dev/null and b/tomviz/icons/breakpoint@2x.png differ diff --git a/tomviz/icons/play.png b/tomviz/icons/play.png new file mode 100644 index 000000000..5c801f27d Binary files /dev/null and b/tomviz/icons/play.png differ diff --git a/tomviz/icons/play@2x.png b/tomviz/icons/play@2x.png new file mode 100644 index 000000000..b8acb444f Binary files /dev/null and b/tomviz/icons/play@2x.png differ diff --git a/tomviz/modules/Module.cxx b/tomviz/modules/Module.cxx index 8403c6378..c6eb3f36f 100644 --- a/tomviz/modules/Module.cxx +++ b/tomviz/modules/Module.cxx @@ -114,13 +114,12 @@ bool Module::initialize(DataSource* data, vtkSMViewProxy* vtkView) if (m_view && m_view->IsA("vtkSMRenderViewProxy") && m_activeDataSource) { // FIXME: we're connecting this too many times. Fix it. - tomviz::convert(vtkView)->connect( - m_activeDataSource, SIGNAL(dataChanged()), SLOT(render())); - connect(m_activeDataSource, SIGNAL(dataChanged()), this, - SIGNAL(dataSourceChanged())); - connect(m_activeDataSource, - SIGNAL(displayPositionChanged(double, double, double)), - SLOT(dataSourceMoved(double, double, double))); + connect(m_activeDataSource, &DataSource::dataChanged, + tomviz::convert(vtkView), &pqView::render); + connect(m_activeDataSource, &DataSource::dataChanged, this, + &Module::dataSourceChanged); + connect(m_activeDataSource, &DataSource::displayPositionChanged, this, + &Module::dataSourceMoved); connect(m_activeDataSource, &DataSource::displayOrientationChanged, this, &Module::dataSourceRotated); } diff --git a/tomviz/modules/ModuleClip.cxx b/tomviz/modules/ModuleClip.cxx index 0223ecaa4..3eb38a86c 100644 --- a/tomviz/modules/ModuleClip.cxx +++ b/tomviz/modules/ModuleClip.cxx @@ -97,19 +97,19 @@ bool ModuleClip::initialize(DataSource* data, vtkSMViewProxy* vtkView) onDirectionChanged(m_direction); pqCoreUtilities::connect(m_widget, vtkCommand::InteractionEvent, this, SLOT(onPlaneChanged())); - connect(data, SIGNAL(dataChanged()), this, SLOT(dataUpdated())); + connect(data, &DataSource::dataChanged, this, &ModuleClip::dataUpdated); foreach (Module* module, ModuleManager::instance().findModulesGeneric(data, nullptr)) { if (module->dataSource() == data) { - connect(this, SIGNAL(clipFilterUpdated(vtkPlane*, bool)), module, - SLOT(updateClippingPlane(vtkPlane*, bool))); + connect(this, &ModuleClip::clipFilterUpdated, module, + &Module::updateClippingPlane); } } connect(&ModuleManager::instance(), &ModuleManager::moduleAdded, this, [this, data](Module* module) { if (module->dataSource() == data) { - connect(this, SIGNAL(clipFilterUpdated(vtkPlane*, bool)), - module, SLOT(updateClippingPlane(vtkPlane*, bool))); + connect(this, &ModuleClip::clipFilterUpdated, + module, &Module::updateClippingPlane); emit clipFilterUpdated(m_clippingPlane, false); } }); diff --git a/tomviz/modules/ModuleFactory.cxx b/tomviz/modules/ModuleFactory.cxx index 0d6241b54..69e186e2d 100644 --- a/tomviz/modules/ModuleFactory.cxx +++ b/tomviz/modules/ModuleFactory.cxx @@ -8,6 +8,7 @@ #include "ModuleContour.h" #include "ModuleMolecule.h" #include "ModuleOutline.h" +#include "ModulePlot.h" #include "ModuleRuler.h" #include "ModuleScaleCube.h" #include "ModuleSegment.h" @@ -19,6 +20,10 @@ #include #include +#include +#include +#include +#include #include #include @@ -40,7 +45,8 @@ QList ModuleFactory::moduleTypes() << "Volume" << "Threshold" << "Molecule" - << "Clip"; + << "Clip" + << "Plot"; std::sort(reply.begin(), reply.end()); return reply; } @@ -49,11 +55,13 @@ bool ModuleFactory::moduleApplicable(const QString& moduleName, DataSource* dataSource, vtkSMViewProxy* view) { - if (moduleName == "Molecule") { + Q_UNUSED(view); + + if (moduleName == "Molecule" || moduleName == "Plot") { return false; } - if (dataSource && view) { + if (dataSource) { if (dataSource->getNumberOfComponents() > 1) { if (moduleName == "Contour" || moduleName == "Threshold") { return false; @@ -68,14 +76,37 @@ bool ModuleFactory::moduleApplicable(const QString& moduleName, MoleculeSource* moleculeSource, vtkSMViewProxy* view) { - if (moleculeSource && view) { - if (moduleName == "Molecule") { + Q_UNUSED(view); + + if (moduleName == "Molecule") { + if (moleculeSource) { return true; } } return false; } +bool ModuleFactory::moduleApplicable(const QString& moduleName, + OperatorResult* operatorResult, + vtkSMViewProxy* view) +{ + Q_UNUSED(view); + + if (moduleName == "Plot") { + return ( + operatorResult && + vtkTable::SafeDownCast(operatorResult->dataObject()) + ); + } else if (moduleName == "Molecule") { + return ( + operatorResult && + vtkMolecule::SafeDownCast(operatorResult->dataObject()) + ); + } + + return false; +} + Module* ModuleFactory::allocateModule(const QString& type) { Module* module = nullptr; @@ -100,6 +131,8 @@ Module* ModuleFactory::allocateModule(const QString& type) module = new ModuleMolecule(); } else if (type== "Clip") { module = new ModuleClip(); + } else if (type == "Plot") { + module = new ModulePlot(); } return module; } @@ -180,8 +213,7 @@ Module* ModuleFactory::createModule(const QString& type, OperatorResult* result, QIcon ModuleFactory::moduleIcon(const QString& type) { QIcon icon; - DataSource* d = nullptr; - Module* mdl = ModuleFactory::createModule(type, d, nullptr); + Module* mdl = ModuleFactory::allocateModule(type); if (mdl) { icon = mdl->icon(); delete mdl; @@ -220,6 +252,9 @@ const char* ModuleFactory::moduleType(const Module* module) if (qobject_cast(module)) { return "Molecule"; } + if (qobject_cast(module)) { + return "Plot"; + } if (qobject_cast(module)) { return "Clip"; } diff --git a/tomviz/modules/ModuleFactory.h b/tomviz/modules/ModuleFactory.h index 6fd8de73a..a28e3b6ce 100644 --- a/tomviz/modules/ModuleFactory.h +++ b/tomviz/modules/ModuleFactory.h @@ -31,6 +31,9 @@ class ModuleFactory static bool moduleApplicable(const QString& moduleName, MoleculeSource* moleculeSource, vtkSMViewProxy* view); + static bool moduleApplicable(const QString& moduleName, + OperatorResult* operatorResult, + vtkSMViewProxy* view); /// Creates a module of the given type to show the dataSource in the view. static Module* createModule(const QString& type, DataSource* dataSource, diff --git a/tomviz/modules/ModuleManager.cxx b/tomviz/modules/ModuleManager.cxx index 72733fd27..a72687aeb 100644 --- a/tomviz/modules/ModuleManager.cxx +++ b/tomviz/modules/ModuleManager.cxx @@ -20,6 +20,7 @@ #include #include #include +#include #include #include @@ -258,7 +259,8 @@ ModuleManager::ModuleManager(QObject* parentObject) : Superclass(parentObject), d(new ModuleManager::MMInternals()) { connect(pqApplicationCore::instance()->getServerManagerModel(), - SIGNAL(viewRemoved(pqView*)), SLOT(onViewRemoved(pqView*))); + &pqServerManagerModel::viewRemoved, this, + &ModuleManager::onViewRemoved); } ModuleManager::~ModuleManager() = default; @@ -759,8 +761,10 @@ bool ModuleManager::serialize(QJsonObject& doc, const QDir& stateDir, jView["active"] = true; } - jView["useColorPaletteForBackground"] = - vtkSMPropertyHelper(view, "UseColorPaletteForBackground").GetAsInt(); + if (view->GetProperty("UseColorPaletteForBackground")) { + jView["useColorPaletteForBackground"] = + vtkSMPropertyHelper(view, "UseColorPaletteForBackground").GetAsInt(); + } // Now to get some more specific information about the view! pugi::xml_document document; @@ -969,7 +973,9 @@ bool ModuleManager::deserialize(const QJsonObject& doc, const QDir& stateDir, auto viewId = view["id"].toInt(); auto proxyNode = pvState.append_child("Proxy"); proxyNode.append_attribute("group").set_value("views"); - proxyNode.append_attribute("type").set_value("RenderView"); + auto xmlName = view["xmlName"].toString("RenderView"); + proxyNode.append_attribute("type").set_value( + xmlName.toStdString().c_str()); proxyNode.append_attribute("id").set_value(viewId); proxyNode.append_attribute("servers").set_value(view["servers"].toInt()); @@ -1062,8 +1068,8 @@ bool ModuleManager::deserialize(const QJsonObject& doc, const QDir& stateDir, m_stateObject = doc; m_loadDataSources = loadDataSources; connect(pqApplicationCore::instance(), - SIGNAL(stateLoaded(vtkPVXMLElement*, vtkSMProxyLocator*)), - SLOT(onPVStateLoaded(vtkPVXMLElement*, vtkSMProxyLocator*))); + &pqApplicationCore::stateLoaded, this, + &ModuleManager::onPVStateLoaded); // Set up call to ParaView to load state std::ostringstream stream; document.first_child().print(stream); @@ -1081,8 +1087,8 @@ bool ModuleManager::deserialize(const QJsonObject& doc, const QDir& stateDir, // Clean up the state -- since the Qt slot call should be synchronous // it should be done before the code returns to here. disconnect(pqApplicationCore::instance(), - SIGNAL(stateLoaded(vtkPVXMLElement*, vtkSMProxyLocator*)), this, - SLOT(onPVStateLoaded(vtkPVXMLElement*, vtkSMProxyLocator*))); + &pqApplicationCore::stateLoaded, this, + &ModuleManager::onPVStateLoaded); d->dir = QDir(); m_stateObject = QJsonObject(); diff --git a/tomviz/modules/ModuleMenu.cxx b/tomviz/modules/ModuleMenu.cxx index 0e9d8ab23..81ca8b8f3 100644 --- a/tomviz/modules/ModuleMenu.cxx +++ b/tomviz/modules/ModuleMenu.cxx @@ -7,22 +7,133 @@ #include "ModuleFactory.h" #include "ModuleManager.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include #include +#include #include namespace tomviz { +static vtkSMViewProxy* resolveView(const QString& moduleType) +{ + // Determine which view type this module needs. + bool needsChart = (moduleType == "Plot"); + QString viewTypeName = needsChart ? "XYChartView" : "RenderView"; + + // Check if the active view is already the right type. + auto* activeView = ActiveObjects::instance().activeView(); + if (activeView) { + bool activeIsChart = vtkSMContextViewProxy::SafeDownCast(activeView); + bool activeIsRender = vtkSMRenderViewProxy::SafeDownCast(activeView); + if ((needsChart && activeIsChart) || (!needsChart && activeIsRender)) { + return activeView; + } + } + + // Enumerate all views and find matching ones. + auto* smModel = + pqApplicationCore::instance()->getServerManagerModel(); + QList allViews = smModel->findItems(); + + QList matching; + for (auto* v : allViews) { + auto* proxy = v->getViewProxy(); + bool isChart = vtkSMContextViewProxy::SafeDownCast(proxy); + bool isRender = vtkSMRenderViewProxy::SafeDownCast(proxy); + if ((needsChart && isChart) || (!needsChart && isRender)) { + matching.append(v); + } + } + + if (matching.isEmpty()) { + // No matching view exists — ask to create one. + QString label = needsChart ? "Line Chart View" : "Render View"; + auto answer = QMessageBox::question( + nullptr, "Create View?", + QString("No %1 is available. Create one?").arg(label), + QMessageBox::Yes | QMessageBox::No); + if (answer != QMessageBox::Yes) { + return nullptr; + } + // Split the current layout to make room for the new view. + int emptyCell = -1; + vtkSMViewLayoutProxy* layout = nullptr; + if (activeView) { + layout = vtkSMViewLayoutProxy::FindLayout(activeView); + if (layout) { + int location = layout->GetViewLocation(activeView); + // Split returns index of left child; existing view moves there. + // The empty cell is at leftChild + 1. + int leftChild = layout->Split( + location, vtkSMViewLayoutProxy::HORIZONTAL, 0.5); + if (leftChild >= 0) { + emptyCell = leftChild + 1; + } + } + } + + auto* builder = pqApplicationCore::instance()->getObjectBuilder(); + auto* server = pqApplicationCore::instance()->getActiveServer(); + auto* newView = builder->createView(viewTypeName, server); + if (!newView) { + return nullptr; + } + auto* proxy = newView->getViewProxy(); + + // Explicitly assign the new view to the empty cell we created. + if (layout && emptyCell >= 0) { + layout->AssignView(emptyCell, proxy); + } + + ActiveObjects::instance().setActiveView(proxy); + return proxy; + } + + if (matching.size() == 1) { + auto* proxy = matching.first()->getViewProxy(); + ActiveObjects::instance().setActiveView(proxy); + return proxy; + } + + // Multiple matches — let the user pick. + QStringList names; + for (auto* v : matching) { + names << v->getSMName(); + } + bool ok = false; + QString chosen = QInputDialog::getItem( + nullptr, "Select View", + QString("Multiple views available. Select one:"), + names, 0, false, &ok); + if (!ok) { + return nullptr; + } + int idx = names.indexOf(chosen); + auto* proxy = matching[idx]->getViewProxy(); + ActiveObjects::instance().setActiveView(proxy); + return proxy; +} + ModuleMenu::ModuleMenu(QToolBar* toolBar, QMenu* menu, QObject* parentObject) : QObject(parentObject), m_menu(menu), m_toolBar(toolBar) { Q_ASSERT(menu); Q_ASSERT(toolBar); - connect(menu, SIGNAL(triggered(QAction*)), SLOT(triggered(QAction*))); - connect(&ActiveObjects::instance(), SIGNAL(dataSourceChanged(DataSource*)), - SLOT(updateActions())); - connect(&ActiveObjects::instance(), - SIGNAL(moleculeSourceChanged(MoleculeSource*)), - SLOT(updateActions())); + connect(menu, &QMenu::triggered, this, &ModuleMenu::triggered); + connect(&ActiveObjects::instance(), QOverload::of(&ActiveObjects::dataSourceChanged), this, &ModuleMenu::updateActions); + connect(&ActiveObjects::instance(), &ActiveObjects::moleculeSourceChanged, this, &ModuleMenu::updateActions); + connect(&ActiveObjects::instance(), &ActiveObjects::resultChanged, this, &ModuleMenu::updateActions); + connect(&ActiveObjects::instance(), QOverload::of(&ActiveObjects::viewChanged), this, &ModuleMenu::updateActions); updateActions(); } @@ -40,6 +151,7 @@ void ModuleMenu::updateActions() auto activeDataSource = ActiveObjects::instance().activeDataSource(); auto activeMoleculeSource = ActiveObjects::instance().activeMoleculeSource(); + auto activeOperatorResult = ActiveObjects::instance().activeOperatorResult(); auto activeView = ActiveObjects::instance().activeView(); QList modules = ModuleFactory::moduleTypes(); @@ -48,7 +160,8 @@ void ModuleMenu::updateActions() auto actn = menu->addAction(ModuleFactory::moduleIcon(txt), txt); actn->setEnabled( ModuleFactory::moduleApplicable(txt, activeDataSource, activeView) || - ModuleFactory::moduleApplicable(txt, activeMoleculeSource, activeView)); + ModuleFactory::moduleApplicable(txt, activeMoleculeSource, activeView) || + ModuleFactory::moduleApplicable(txt, activeOperatorResult, activeView)); toolBar->addAction(actn); actn->setData(txt); } @@ -64,12 +177,25 @@ void ModuleMenu::triggered(QAction* maction) auto type = maction->data().toString(); auto dataSource = ActiveObjects::instance().activeDataSource(); auto moleculeSource = ActiveObjects::instance().activeMoleculeSource(); - auto view = ActiveObjects::instance().activeView(); + auto operatorResult = ActiveObjects::instance().activeOperatorResult(); + + auto* view = resolveView(type); + if (!view) { + return; + } Module* module; if (type == "Molecule") { + if (operatorResult) { + module = + ModuleManager::instance().createAndAddModule(type, operatorResult, view); + } else { + module = + ModuleManager::instance().createAndAddModule(type, moleculeSource, view); + } + } else if (type == "Plot") { module = - ModuleManager::instance().createAndAddModule(type, moleculeSource, view); + ModuleManager::instance().createAndAddModule(type, operatorResult, view); } else { module = ModuleManager::instance().createAndAddModule(type, dataSource, view); diff --git a/tomviz/modules/ModuleMolecule.cxx b/tomviz/modules/ModuleMolecule.cxx index f686b1578..bfeb4fa0f 100644 --- a/tomviz/modules/ModuleMolecule.cxx +++ b/tomviz/modules/ModuleMolecule.cxx @@ -39,6 +39,11 @@ QIcon ModuleMolecule::icon() const return QIcon(":/pqWidgets/Icons/pqGroup.svg"); } +bool ModuleMolecule::initialize(DataSource*, vtkSMViewProxy*) +{ + return false; +} + bool ModuleMolecule::initialize(OperatorResult* result, vtkSMViewProxy* view) { if (!Module::initialize(result, view)) { diff --git a/tomviz/modules/ModuleMolecule.h b/tomviz/modules/ModuleMolecule.h index 6b595e67d..451a8205e 100644 --- a/tomviz/modules/ModuleMolecule.h +++ b/tomviz/modules/ModuleMolecule.h @@ -30,6 +30,8 @@ class ModuleMolecule : public Module QString label() const override { return "Molecule"; } QIcon icon() const override; using Module::initialize; + bool initialize(DataSource* dataSource, + vtkSMViewProxy* view) override; bool initialize(MoleculeSource* moleculeSource, vtkSMViewProxy* view) override; bool initialize(OperatorResult* result, vtkSMViewProxy* view) override; diff --git a/tomviz/modules/ModulePlot.cxx b/tomviz/modules/ModulePlot.cxx new file mode 100644 index 000000000..fef6e5f28 --- /dev/null +++ b/tomviz/modules/ModulePlot.cxx @@ -0,0 +1,341 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "ModulePlot.h" + +#include "OperatorResult.h" +#include "Utilities.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace tomviz { + +ModulePlot::ModulePlot(QObject* parentObject) + : Module(parentObject) + , m_visible(true) + , m_view(nullptr) + , m_table(nullptr) + , m_chart(nullptr) + , m_producer(nullptr) +{ + m_result_modified_cb->SetCallback(&ModulePlot::onResultModified); + m_result_modified_cb->SetClientData(this); +} + +ModulePlot::~ModulePlot() +{ + finalize(); +} + +QIcon ModulePlot::icon() const +{ + return QIcon(":/pqWidgets/Icons/pqLineChart16.png"); +} + +bool ModulePlot::initialize(DataSource* data, vtkSMViewProxy* view) +{ + Q_UNUSED(data); + Q_UNUSED(view); + + return false; +} + +bool ModulePlot::initialize(MoleculeSource* data, vtkSMViewProxy* view) +{ + Q_UNUSED(data); + Q_UNUSED(view); + + return false; +} + +bool ModulePlot::initialize(OperatorResult* result, vtkSMViewProxy* view) +{ + Module::initialize(result, view); + + m_table = vtkTable::SafeDownCast(result->dataObject()); + m_producer = vtkTrivialProducer::SafeDownCast(result->producerProxy()->GetClientSideObject()); + m_view = vtkPVContextView::SafeDownCast(view->GetClientSideView()); + m_chart = nullptr; + + if (m_table == nullptr || m_producer == nullptr || m_view == nullptr) { + return false; + } + + auto context_view = m_view->GetContextView(); + + m_chart = vtkChartXY::SafeDownCast(context_view->GetScene()->GetItem(0)); + + if (m_chart == nullptr) { + return false; + } + + // Detect when the result dataobject changes, i.e. when the pipeline re-runs + m_producer->AddObserver(vtkCommand::ModifiedEvent, m_result_modified_cb); + + addAllPlots(); + + return true; +} + +bool ModulePlot::finalize() +{ + if (m_producer) { + m_producer->RemoveObserver(m_result_modified_cb); + } + + removeAllPlots(); + + m_plots.clear(); + + return true; +} + +void ModulePlot::addAllPlots() +{ + removeAllPlots(); + + if (m_table == nullptr || m_chart == nullptr) { + return; + } + + vtkIdType num_cols = m_table->GetNumberOfColumns(); + + auto fieldData = m_table->GetFieldData(); + auto labelsArray = vtkStringArray::SafeDownCast( + fieldData->GetAbstractArray("axes_labels")); + if (labelsArray && labelsArray->GetNumberOfTuples() >= 2) { + auto x_axis = m_chart->GetAxis(vtkAxis::BOTTOM); + auto y_axis = m_chart->GetAxis(vtkAxis::LEFT); + x_axis->SetTitle(labelsArray->GetValue(0)); + y_axis->SetTitle(labelsArray->GetValue(1)); + } + + auto logScaleArray = vtkUnsignedCharArray::SafeDownCast( + fieldData->GetAbstractArray("axes_log_scale")); + if (logScaleArray && logScaleArray->GetNumberOfTuples() >= 2) { + auto x_axis = m_chart->GetAxis(vtkAxis::BOTTOM); + auto y_axis = m_chart->GetAxis(vtkAxis::LEFT); + x_axis->SetLogScale(logScaleArray->GetValue(0) != 0); + y_axis->SetLogScale(logScaleArray->GetValue(1) != 0); + } + + // Start color index from the number of plots already in the chart + // so that multiple modules sharing a view get distinct colors. + int colorOffset = m_chart->GetNumberOfPlots(); + + for (vtkIdType col = 1; col < num_cols; col++) { + auto line = vtkSmartPointer::New(); + int idx = colorOffset + col - 1; + // Golden angle spacing in hue for maximum color separation + int hue = (idx * 137) % 360; + QColor color = QColor::fromHsv(hue, 200, 200); + line->SetInputData(m_table, 0, col); + line->SetColor(color.red(), color.green(), color.blue(), 255); + line->SetWidth(3.0); + m_chart->AddPlot(line); + m_plots.append(line); + } +} + +void ModulePlot::removeAllPlots() +{ + if (m_chart == nullptr) { + return; + } + + for (auto iter = m_plots.begin(); iter != m_plots.end(); iter++) { + m_chart->RemovePlotInstance(*iter); + } + + m_plots.clear(); +} + +bool ModulePlot::setVisibility(bool val) +{ + m_visible = val; + + if (val) { + addAllPlots(); + } else { + removeAllPlots(); + } + + Module::setVisibility(val); + + return true; +} + +bool ModulePlot::visibility() const +{ + return m_visible; +} + +void ModulePlot::addToPanel(QWidget* panel) +{ + if (panel->layout()) { + delete panel->layout(); + } + + QFormLayout* layout = new QFormLayout; + + QString xLabel, yLabel; + bool xLogScale = false; + bool yLogScale = false; + + if (m_chart) { + xLabel = m_chart->GetAxis(vtkAxis::BOTTOM)->GetTitle().c_str(); + yLabel = m_chart->GetAxis(vtkAxis::LEFT)->GetTitle().c_str(); + xLogScale = m_chart->GetAxis(vtkAxis::BOTTOM)->GetLogScale(); + yLogScale = m_chart->GetAxis(vtkAxis::LEFT)->GetLogScale(); + } + + m_xLabelEdit = new QLineEdit(xLabel); + m_yLabelEdit = new QLineEdit(yLabel); + layout->addRow("X Label", m_xLabelEdit); + layout->addRow("Y Label", m_yLabelEdit); + + m_xLogCheckBox = new QCheckBox("X Log Scale"); + m_yLogCheckBox = new QCheckBox("Y Log Scale"); + m_xLogCheckBox->setChecked(xLogScale); + m_yLogCheckBox->setChecked(yLogScale); + layout->addRow(m_xLogCheckBox); + layout->addRow(m_yLogCheckBox); + + connect(m_xLabelEdit, &QLineEdit::textChanged, this, + &ModulePlot::onXLabelChanged); + connect(m_yLabelEdit, &QLineEdit::textChanged, this, + &ModulePlot::onYLabelChanged); + connect(m_xLogCheckBox, &QCheckBox::toggled, this, + &ModulePlot::onXLogScaleChanged); + connect(m_yLogCheckBox, &QCheckBox::toggled, this, + &ModulePlot::onYLogScaleChanged); + + panel->setLayout(layout); +} + +QJsonObject ModulePlot::serialize() const +{ + auto json = Module::serialize(); + auto props = json["properties"].toObject(); + + // Save the operator result name so the module can be recreated on + // deserialization by finding the matching OperatorResult. + if (operatorResult()) { + json["operatorResultName"] = operatorResult()->name(); + } + + json["properties"] = props; + return json; +} + +bool ModulePlot::deserialize(const QJsonObject& json) +{ + if (!Module::deserialize(json)) { + return false; + } + if (json["properties"].isObject()) { + auto props = json["properties"].toObject(); + return true; + } + return false; +} + +void ModulePlot::dataSourceMoved(double, double, double) +{ +} + +void ModulePlot::dataSourceRotated(double, double, double) +{ +} + +vtkDataObject* ModulePlot::dataToExport() +{ + return nullptr; +} + +void ModulePlot::onResultModified(vtkObject* caller, long unsigned int eventId, void* clientData, void*callData) +{ + Q_UNUSED(caller); + Q_UNUSED(eventId); + Q_UNUSED(callData); + + auto self = reinterpret_cast(clientData); + auto result = self->operatorResult(); + self->m_table = vtkTable::SafeDownCast(result->dataObject()); + + self->removeAllPlots(); + self->m_plots.clear(); + + if (self->visibility()) { + self->addAllPlots(); + } +} + +void ModulePlot::onXLogScaleChanged(bool logScale) +{ + if (m_chart == nullptr) { + return; + } + + auto x_axis = m_chart->GetAxis(vtkAxis::BOTTOM); + x_axis->SetLogScale(logScale); + + m_view->Update(); +} + +void ModulePlot::onYLogScaleChanged(bool logScale) +{ + if (m_chart == nullptr) { + return; + } + + auto y_axis = m_chart->GetAxis(vtkAxis::LEFT); + y_axis->SetLogScale(logScale); + + m_view->Update(); +} + +void ModulePlot::onXLabelChanged(const QString& label) +{ + if (m_chart == nullptr) { + return; + } + + auto x_axis = m_chart->GetAxis(vtkAxis::BOTTOM); + x_axis->SetTitle(label.toStdString()); + + m_view->Update(); +} + +void ModulePlot::onYLabelChanged(const QString& label) +{ + if (m_chart == nullptr) { + return; + } + + auto y_axis = m_chart->GetAxis(vtkAxis::LEFT); + y_axis->SetTitle(label.toStdString()); + + m_view->Update(); +} + +} // namespace tomviz diff --git a/tomviz/modules/ModulePlot.h b/tomviz/modules/ModulePlot.h new file mode 100644 index 000000000..7dd29f212 --- /dev/null +++ b/tomviz/modules/ModulePlot.h @@ -0,0 +1,78 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizModulePlot_h +#define tomvizModulePlot_h + +#include "Module.h" + +class vtkCallbackCommand; +class vtkChartXY; +class vtkPlot; +class vtkPVContextView; +class vtkTable; +class vtkTrivialProducer; + +class QCheckBox; +class QLineEdit; + + +namespace tomviz { + +class MoleculeSource; +class OperatorResult; + +class ModulePlot : public Module +{ + Q_OBJECT + +public: + ModulePlot(QObject* parent = nullptr); + ~ModulePlot() override; + + QString label() const override { return "Plot"; } + QIcon icon() const override; + using Module::initialize; + bool initialize(DataSource* data, vtkSMViewProxy* vtkView) override; + bool initialize(MoleculeSource* data, vtkSMViewProxy* vtkView) override; + bool initialize(OperatorResult* result, vtkSMViewProxy* view) override; + bool finalize() override; + bool setVisibility(bool val) override; + bool visibility() const override; + void addToPanel(QWidget*) override; + QJsonObject serialize() const override; + bool deserialize(const QJsonObject& json) override; + + QString exportDataTypeString() override { return ""; } + vtkDataObject* dataToExport() override; + + void dataSourceMoved(double newX, double newY, double newZ) override; + void dataSourceRotated(double newX, double newY, double newZ) override; + +private slots: + void onXLogScaleChanged(bool); + void onYLogScaleChanged(bool); + void onXLabelChanged(const QString& label); + void onYLabelChanged(const QString& label); + +private: + static void onResultModified(vtkObject* caller, long unsigned int eventId, void* clientData, void*callData); + void addAllPlots(); + void removeAllPlots(); + + Q_DISABLE_COPY(ModulePlot) + bool m_visible; + vtkWeakPointer m_view; + vtkNew m_result_modified_cb; + vtkWeakPointer m_table; + vtkWeakPointer m_chart; + vtkWeakPointer m_producer; + QList> m_plots; + QPointer m_xLogCheckBox; + QPointer m_yLogCheckBox; + QPointer m_xLabelEdit; + QPointer m_yLabelEdit; + +}; +} // namespace tomviz +#endif diff --git a/tomviz/modules/ModulePropertiesPanel.cxx b/tomviz/modules/ModulePropertiesPanel.cxx index e24ddc31c..5861ca58f 100644 --- a/tomviz/modules/ModulePropertiesPanel.cxx +++ b/tomviz/modules/ModulePropertiesPanel.cxx @@ -28,21 +28,21 @@ ModulePropertiesPanel::ModulePropertiesPanel(QWidget* parentObject) ui.setupUi(this); // Show active module in the "Module Properties" panel. - this->connect(&ActiveObjects::instance(), SIGNAL(moduleChanged(Module*)), - SLOT(setModule(Module*))); + this->connect(&ActiveObjects::instance(), &ActiveObjects::moduleChanged, + this, &ModulePropertiesPanel::setModule); this->connect(&ActiveObjects::instance(), - SIGNAL(viewChanged(vtkSMViewProxy*)), - SLOT(setView(vtkSMViewProxy*))); + QOverload::of(&ActiveObjects::viewChanged), + this, &ModulePropertiesPanel::setView); /* Disabled the search box for now, uncomment to enable again. - this->connect(ui.SearchBox, SIGNAL(advancedSearchActivated(bool)), - SLOT(updatePanel())); - this->connect(ui.SearchBox, SIGNAL(textChanged(const QString&)), - SLOT(updatePanel())); + this->connect(ui.SearchBox, &pqSearchBox::advancedSearchActivated, + this, &ModulePropertiesPanel::updatePanel); + this->connect(ui.SearchBox, &pqSearchBox::textChanged, + this, &ModulePropertiesPanel::updatePanel); */ - this->connect(ui.DetachColorMap, SIGNAL(clicked(bool)), - SLOT(detachColorMap(bool))); + this->connect(ui.DetachColorMap, &QAbstractButton::clicked, this, + &ModulePropertiesPanel::detachColorMap); } ModulePropertiesPanel::~ModulePropertiesPanel() {} @@ -53,8 +53,8 @@ void ModulePropertiesPanel::setModule(Module* module) if (this->Internals->ActiveModule) { DataSource* dataSource = this->Internals->ActiveModule->dataSource(); if (dataSource) { - QObject::disconnect(dataSource, SIGNAL(dataChanged()), this, - SLOT(updatePanel())); + QObject::disconnect(dataSource, &DataSource::dataChanged, this, + &ModulePropertiesPanel::updatePanel); } this->Internals->ActiveModule->prepareToRemoveFromPanel(this); } @@ -62,8 +62,8 @@ void ModulePropertiesPanel::setModule(Module* module) if (module) { DataSource* dataSource = module->dataSource(); if (dataSource) { - QObject::connect(dataSource, SIGNAL(dataChanged()), this, - SLOT(updatePanel())); + QObject::connect(dataSource, &DataSource::dataChanged, this, + &ModulePropertiesPanel::updatePanel); } } } diff --git a/tomviz/modules/ModuleRuler.cxx b/tomviz/modules/ModuleRuler.cxx index 8723f7a4e..28ea8b46e 100644 --- a/tomviz/modules/ModuleRuler.cxx +++ b/tomviz/modules/ModuleRuler.cxx @@ -104,8 +104,8 @@ void ModuleRuler::addToPanel(QWidget* panel) &pqPropertyWidget::apply); connect(m_widget.data(), &pqPropertyWidget::changeFinished, this, &ModuleRuler::endPointsUpdated); - connect(m_widget, SIGNAL(widgetVisibilityUpdated(bool)), this, - SLOT(updateShowLine(bool))); + connect(m_widget, &pqInteractivePropertyWidgetAbstract::widgetVisibilityUpdated, + this, &ModuleRuler::updateShowLine); m_widget->setWidgetVisible(m_showLine); @@ -126,8 +126,8 @@ void ModuleRuler::prepareToRemoveFromPanel(QWidget* vtkNotUsed(panel)) // Disconnect before the panel is removed to avoid m_showLine always being set // to false when the signal widgetVisibilityUpdated(bool) is emitted during // the tear down of the pqLinePropertyWidget. - disconnect(m_widget, SIGNAL(widgetVisibilityUpdated(bool)), this, - SLOT(updateShowLine(bool))); + disconnect(m_widget, &pqInteractivePropertyWidgetAbstract::widgetVisibilityUpdated, + this, &ModuleRuler::updateShowLine); } bool ModuleRuler::setVisibility(bool val) @@ -222,13 +222,24 @@ void ModuleRuler::endPointsUpdated() vtkSMPropertyHelper(m_rulerSource, "Point1").Get(point1, 3); vtkSMPropertyHelper(m_rulerSource, "Point2").Get(point2, 3); DataSource* source = dataSource(); - vtkImageData* img = vtkImageData::SafeDownCast( - vtkAlgorithm::SafeDownCast(source->proxy()->GetClientSideObject()) - ->GetOutputDataObject(0)); + auto* algo = + vtkAlgorithm::SafeDownCast(source->proxy()->GetClientSideObject()); + if (!algo) { + return; + } + vtkImageData* img = + vtkImageData::SafeDownCast(algo->GetOutputDataObject(0)); + if (!img) { + return; + } + vtkDataArray* scalars = img->GetPointData()->GetScalars(); + if (!scalars) { + return; + } vtkIdType p1 = img->FindPoint(point1); vtkIdType p2 = img->FindPoint(point2); - double v1 = img->GetPointData()->GetScalars()->GetTuple1(p1); - double v2 = img->GetPointData()->GetScalars()->GetTuple1(p2); + double v1 = scalars->GetTuple1(p1); + double v2 = scalars->GetTuple1(p2); emit newEndpointData(v1, v2); renderNeeded(); } diff --git a/tomviz/modules/ModuleScaleCube.cxx b/tomviz/modules/ModuleScaleCube.cxx index 4b22f5392..2e6777f3f 100644 --- a/tomviz/modules/ModuleScaleCube.cxx +++ b/tomviz/modules/ModuleScaleCube.cxx @@ -51,8 +51,9 @@ ModuleScaleCube::ModuleScaleCube(QObject* parentObject) : Module(parentObject) onPositionChanged(p[0], p[1], p[2]); }); - connect(this, SIGNAL(onPositionChanged(double, double, double)), - SLOT(updateOffset(double, double, double))); + connect(this, + QOverload::of(&ModuleScaleCube::onPositionChanged), + this, &ModuleScaleCube::updateOffset); // Connect to m_cubeRep's "modified" signal, and emit it as our own // "onSideLengthChanged" signal @@ -85,8 +86,8 @@ bool ModuleScaleCube::initialize(DataSource* data, vtkSMViewProxy* vtkView) return false; } - connect(data, SIGNAL(dataPropertiesChanged()), this, - SLOT(dataPropertiesChanged())); + connect(data, &DataSource::dataPropertiesChanged, this, + &ModuleScaleCube::dataPropertiesChanged); m_view = vtkPVRenderView::SafeDownCast(vtkView->GetClientSideView()); m_handleWidget->SetInteractor(m_view->GetInteractor()); @@ -237,27 +238,28 @@ void ModuleScaleCube::addToPanel(QWidget* panel) static_cast(color[2] * 255.0 + 0.5))); // Connect the widget's signals to this class' slots - connect(m_controllers, SIGNAL(adaptiveScalingToggled(const bool)), this, - SLOT(setAdaptiveScaling(const bool))); - connect(m_controllers, SIGNAL(sideLengthChanged(const double)), this, - SLOT(setSideLength(const double))); - connect(m_controllers, SIGNAL(annotationToggled(const bool)), this, - SLOT(setAnnotation(const bool))); + connect(m_controllers, &ModuleScaleCubeWidget::adaptiveScalingToggled, this, + &ModuleScaleCube::setAdaptiveScaling); + connect(m_controllers, &ModuleScaleCubeWidget::sideLengthChanged, this, + &ModuleScaleCube::setSideLength); + connect(m_controllers, &ModuleScaleCubeWidget::annotationToggled, this, + &ModuleScaleCube::setAnnotation); connect(m_controllers, &ModuleScaleCubeWidget::boxColorChanged, this, &ModuleScaleCube::onBoxColorChanged); connect(m_controllers, &ModuleScaleCubeWidget::textColorChanged, this, &ModuleScaleCube::onTextColorChanged); // Connect this class' signals to the widget's slots - connect(this, SIGNAL(onLengthUnitChanged(const QString)), m_controllers, - SLOT(setLengthUnit(const QString))); - connect(this, SIGNAL(onPositionUnitChanged(const QString)), m_controllers, - SLOT(setPositionUnit(const QString))); - connect(this, SIGNAL(onSideLengthChanged(const double)), m_controllers, - SLOT(setSideLength(const double))); - connect( - this, SIGNAL(onPositionChanged(const double, const double, const double)), - m_controllers, SLOT(setPosition(const double, const double, const double))); + connect(this, &ModuleScaleCube::onLengthUnitChanged, m_controllers, + &ModuleScaleCubeWidget::setLengthUnit); + connect(this, &ModuleScaleCube::onPositionUnitChanged, m_controllers, + &ModuleScaleCubeWidget::setPositionUnit); + connect(this, QOverload::of(&ModuleScaleCube::onSideLengthChanged), + m_controllers, &ModuleScaleCubeWidget::setSideLength); + connect(this, + QOverload::of( + &ModuleScaleCube::onPositionChanged), + m_controllers, &ModuleScaleCubeWidget::setPosition); } void ModuleScaleCube::setAdaptiveScaling(const bool val) @@ -280,15 +282,22 @@ void ModuleScaleCube::setAnnotation(const bool val) void ModuleScaleCube::setLengthUnit() { - QString s = qobject_cast(sender())->getUnits(); + DataSource* data = qobject_cast(sender()); + if (!data) { + return; + } + QString s = data->getUnits(); m_cubeRep->SetLengthUnit(s.toStdString().c_str()); emit onLengthUnitChanged(s); } void ModuleScaleCube::setPositionUnit() { - QString s = qobject_cast(sender())->getUnits(); - emit onLengthUnitChanged(s); + DataSource* data = qobject_cast(sender()); + if (!data) { + return; + } + emit onLengthUnitChanged(data->getUnits()); } void ModuleScaleCube::dataPropertiesChanged() diff --git a/tomviz/modules/ModuleScaleCubeWidget.cxx b/tomviz/modules/ModuleScaleCubeWidget.cxx index 19793efb0..b214bf569 100644 --- a/tomviz/modules/ModuleScaleCubeWidget.cxx +++ b/tomviz/modules/ModuleScaleCubeWidget.cxx @@ -15,12 +15,12 @@ ModuleScaleCubeWidget::ModuleScaleCubeWidget(QWidget* parent_) m_ui->setupUi(this); m_ui->leSideLength->setValidator(new QDoubleValidator(this)); - connect(m_ui->chbAdaptiveScaling, SIGNAL(toggled(bool)), this, - SIGNAL(adaptiveScalingToggled(const bool))); + connect(m_ui->chbAdaptiveScaling, &QCheckBox::toggled, this, + &ModuleScaleCubeWidget::adaptiveScalingToggled); connect(m_ui->leSideLength, &QLineEdit::editingFinished, this, [&] { sideLengthChanged(m_ui->leSideLength->text().toDouble()); }); - connect(m_ui->chbAnnotation, SIGNAL(toggled(bool)), this, - SIGNAL(annotationToggled(const bool))); + connect(m_ui->chbAnnotation, &QCheckBox::toggled, this, + &ModuleScaleCubeWidget::annotationToggled); connect(m_ui->colorChooserButton, &pqColorChooserButton::chosenColorChanged, this, &ModuleScaleCubeWidget::boxColorChanged); connect(m_ui->textColorChooserButton, diff --git a/tomviz/modules/ModuleSegment.cxx b/tomviz/modules/ModuleSegment.cxx index 9edf72b07..a84a9ec43 100644 --- a/tomviz/modules/ModuleSegment.cxx +++ b/tomviz/modules/ModuleSegment.cxx @@ -202,8 +202,8 @@ void ModuleSegment::addToPanel(QWidget* panel) proxiesWidget->addProxy(d->ContourRepresentation, "Appearance", contourRepresentationProperties, true); proxiesWidget->updateLayout(); - connect(proxiesWidget, SIGNAL(changeFinished(vtkSMProxy*)), - SIGNAL(renderNeeded())); + connect(proxiesWidget, &pqProxiesWidget::changeFinished, this, + &ModuleSegment::renderNeeded); } void ModuleSegment::onPropertyChanged() diff --git a/tomviz/modules/ModuleThreshold.cxx b/tomviz/modules/ModuleThreshold.cxx index abacde58a..60b7879e4 100644 --- a/tomviz/modules/ModuleThreshold.cxx +++ b/tomviz/modules/ModuleThreshold.cxx @@ -101,7 +101,8 @@ bool ModuleThreshold::initialize(DataSource* data, vtkSMViewProxy* vtkView) p->rename(label()); } - connect(data, SIGNAL(activeScalarsChanged()), SLOT(onScalarArrayChanged())); + connect(data, &DataSource::activeScalarsChanged, this, + &ModuleThreshold::onScalarArrayChanged); onScalarArrayChanged(); return true; diff --git a/tomviz/modules/ModuleVolume.cxx b/tomviz/modules/ModuleVolume.cxx index edca11334..3a8e09365 100644 --- a/tomviz/modules/ModuleVolume.cxx +++ b/tomviz/modules/ModuleVolume.cxx @@ -529,24 +529,24 @@ void ModuleVolume::addToPanel(QWidget* panel) layout->addWidget(m_controllers); updatePanel(); - connect(m_controllers, SIGNAL(jitteringToggled(const bool)), this, - SLOT(setJittering(const bool))); - connect(m_controllers, SIGNAL(lightingToggled(const bool)), this, - SLOT(setLighting(const bool))); - connect(m_controllers, SIGNAL(blendingChanged(const int)), this, - SLOT(setBlendingMode(const int))); - connect(m_controllers, SIGNAL(interpolationChanged(const int)), this, - SLOT(onInterpolationChanged(const int))); - connect(m_controllers, SIGNAL(ambientChanged(const double)), this, - SLOT(onAmbientChanged(const double))); - connect(m_controllers, SIGNAL(diffuseChanged(const double)), this, - SLOT(onDiffuseChanged(const double))); - connect(m_controllers, SIGNAL(specularChanged(const double)), this, - SLOT(onSpecularChanged(const double))); - connect(m_controllers, SIGNAL(specularPowerChanged(const double)), this, - SLOT(onSpecularPowerChanged(const double))); - connect(m_controllers, SIGNAL(transferModeChanged(const int)), this, - SLOT(onTransferModeChanged(const int))); + connect(m_controllers, &ModuleVolumeWidget::jitteringToggled, this, + &ModuleVolume::setJittering); + connect(m_controllers, &ModuleVolumeWidget::lightingToggled, this, + &ModuleVolume::setLighting); + connect(m_controllers, &ModuleVolumeWidget::blendingChanged, this, + &ModuleVolume::setBlendingMode); + connect(m_controllers, &ModuleVolumeWidget::interpolationChanged, this, + &ModuleVolume::onInterpolationChanged); + connect(m_controllers, &ModuleVolumeWidget::ambientChanged, this, + &ModuleVolume::onAmbientChanged); + connect(m_controllers, &ModuleVolumeWidget::diffuseChanged, this, + &ModuleVolume::onDiffuseChanged); + connect(m_controllers, &ModuleVolumeWidget::specularChanged, this, + &ModuleVolume::onSpecularChanged); + connect(m_controllers, &ModuleVolumeWidget::specularPowerChanged, this, + &ModuleVolume::onSpecularPowerChanged); + connect(m_controllers, &ModuleVolumeWidget::transferModeChanged, this, + &ModuleVolume::onTransferModeChanged); connect(m_controllers, &ModuleVolumeWidget::useRgbaMappingToggled, this, &ModuleVolume::onRgbaMappingToggled); connect(m_controllers, @@ -563,8 +563,8 @@ void ModuleVolume::addToPanel(QWidget* panel) setActiveScalars(m_scalarsCombo->itemData(idx).toInt()); onScalarArrayChanged(); }); - connect(m_controllers, SIGNAL(solidityChanged(const double)), this, - SLOT(setSolidity(const double))); + connect(m_controllers, &ModuleVolumeWidget::solidityChanged, this, + &ModuleVolume::setSolidity); connect(m_controllers, &ModuleVolumeWidget::allowMultiVolumeToggled, this, &ModuleVolume::onAllowMultiVolumeToggled); } diff --git a/tomviz/modules/ModuleVolumeWidget.cxx b/tomviz/modules/ModuleVolumeWidget.cxx index 9adde5239..52e80acd1 100644 --- a/tomviz/modules/ModuleVolumeWidget.cxx +++ b/tomviz/modules/ModuleVolumeWidget.cxx @@ -56,16 +56,18 @@ ModuleVolumeWidget::ModuleVolumeWidget(QWidget* parent_) labelsInterp << tr("Nearest Neighbor") << tr("Linear"); m_ui->cbInterpolation->addItems(labelsInterp); - connect(m_ui->cbJittering, SIGNAL(toggled(bool)), this, - SIGNAL(jitteringToggled(const bool))); - connect(m_ui->cbBlending, SIGNAL(currentIndexChanged(int)), this, - SLOT(onBlendingChanged(const int))); - connect(m_ui->cbInterpolation, SIGNAL(currentIndexChanged(int)), this, - SIGNAL(interpolationChanged(const int))); - connect(m_ui->cbTransferMode, SIGNAL(currentIndexChanged(int)), this, - SIGNAL(transferModeChanged(const int))); - connect(m_ui->cbMultiVolume, SIGNAL(toggled(bool)), this, - SIGNAL(allowMultiVolumeToggled(const bool))); + connect(m_ui->cbJittering, &QCheckBox::toggled, this, + &ModuleVolumeWidget::jitteringToggled); + connect(m_ui->cbBlending, QOverload::of(&QComboBox::currentIndexChanged), + this, &ModuleVolumeWidget::onBlendingChanged); + connect(m_ui->cbInterpolation, + QOverload::of(&QComboBox::currentIndexChanged), this, + &ModuleVolumeWidget::interpolationChanged); + connect(m_ui->cbTransferMode, + QOverload::of(&QComboBox::currentIndexChanged), this, + &ModuleVolumeWidget::transferModeChanged); + connect(m_ui->cbMultiVolume, &QCheckBox::toggled, this, + &ModuleVolumeWidget::allowMultiVolumeToggled); connect(m_ui->cbMultiVolume, &QCheckBox::toggled, this, &ModuleVolumeWidget::setAllowMultiVolume); @@ -83,18 +85,18 @@ ModuleVolumeWidget::ModuleVolumeWidget(QWidget* parent_) connect(m_ui->sliRgbaMappingMax, &DoubleSliderWidget::valueEdited, this, &ModuleVolumeWidget::onRgbaMappingMaxChanged, Qt::QueuedConnection); - connect(m_uiLighting->gbLighting, SIGNAL(toggled(bool)), this, - SIGNAL(lightingToggled(const bool))); - connect(m_uiLighting->sliAmbient, SIGNAL(valueEdited(double)), this, - SIGNAL(ambientChanged(const double))); - connect(m_uiLighting->sliDiffuse, SIGNAL(valueEdited(double)), this, - SIGNAL(diffuseChanged(const double))); - connect(m_uiLighting->sliSpecular, SIGNAL(valueEdited(double)), this, - SIGNAL(specularChanged(const double))); - connect(m_uiLighting->sliSpecularPower, SIGNAL(valueEdited(double)), this, - SIGNAL(specularPowerChanged(const double))); - connect(m_ui->soliditySlider, SIGNAL(valueEdited(double)), this, - SIGNAL(solidityChanged(const double))); + connect(m_uiLighting->gbLighting, &QGroupBox::toggled, this, + &ModuleVolumeWidget::lightingToggled); + connect(m_uiLighting->sliAmbient, &DoubleSliderWidget::valueEdited, this, + &ModuleVolumeWidget::ambientChanged); + connect(m_uiLighting->sliDiffuse, &DoubleSliderWidget::valueEdited, this, + &ModuleVolumeWidget::diffuseChanged); + connect(m_uiLighting->sliSpecular, &DoubleSliderWidget::valueEdited, this, + &ModuleVolumeWidget::specularChanged); + connect(m_uiLighting->sliSpecularPower, &DoubleSliderWidget::valueEdited, + this, &ModuleVolumeWidget::specularPowerChanged); + connect(m_ui->soliditySlider, &DoubleSliderWidget::valueEdited, this, + &ModuleVolumeWidget::solidityChanged); m_ui->groupRgbaMappingRange->setVisible(false); m_ui->rgbaMappingComponentLabel->setVisible(false); diff --git a/tomviz/operators/EditOperatorDialog.cxx b/tomviz/operators/EditOperatorDialog.cxx index 5457d8478..049c933ed 100644 --- a/tomviz/operators/EditOperatorDialog.cxx +++ b/tomviz/operators/EditOperatorDialog.cxx @@ -20,6 +20,8 @@ #include #include #include +#include +#include #include #include @@ -99,7 +101,10 @@ EditOperatorDialog::EditOperatorDialog(Operator* op, DataSource* dataSource, QVariant geometry = this->Internals->loadGeometry(); if (!geometry.isNull()) { - this->setGeometry(geometry.toRect()); + // Only restore size, not position — showEvent will center the dialog + // on the main window's screen. Restoring position can cause the + // dialog to appear on a different monitor than the main window. + this->resize(geometry.toRect().size()); } if (op->hasCustomUI()) { @@ -123,6 +128,40 @@ EditOperatorDialog::EditOperatorDialog(Operator* op, DataSource* dataSource, EditOperatorDialog::~EditOperatorDialog() {} +void EditOperatorDialog::showEvent(QShowEvent* event) +{ + Superclass::showEvent(event); + + // Always center on the main window's screen, overriding any restored + // geometry or window manager placement that may put the dialog on a + // different monitor. + auto* mainWin = tomviz::mainWidget(); + if (!mainWin) { + return; + } + + auto* screen = mainWin->screen(); + auto screenGeom = screen ? screen->availableGeometry() + : QRect(0, 0, 1920, 1080); + + auto mainCenter = mainWin->frameGeometry().center(); + auto dlgSize = frameGeometry().size(); + + // Center on the main window + int x = mainCenter.x() - dlgSize.width() / 2; + int y = mainCenter.y() - dlgSize.height() / 2; + + // Clamp to the main window's screen so we never spill onto another monitor + x = qBound(screenGeom.left(), x, + screenGeom.right() - dlgSize.width()); + y = qBound(screenGeom.top(), y, + screenGeom.bottom() - dlgSize.height()); + + move(x, y); + raise(); + activateWindow(); +} + void EditOperatorDialog::setViewMode(const QString& mode) { if (this->Internals->Widget) { @@ -246,7 +285,7 @@ void EditOperatorDialog::setupUI(EditOperatorWidget* opWidget) vLayout->setContentsMargins(5, 5, 5, 5); vLayout->setSpacing(5); if (this->Internals->Op->hasCustomUI()) { - vLayout->addWidget(opWidget); + vLayout->addWidget(opWidget, 1); this->Internals->Widget = opWidget; const double* dsPosition = this->Internals->dataSource->displayPosition(); opWidget->dataSourceMoved(dsPosition[0], dsPosition[1], dsPosition[2]); @@ -329,7 +368,7 @@ void EditOperatorDialog::showDialogForOperator(Operator* op, dialog->show(); // Close the dialog if the Operator is destroyed. - connect(op, SIGNAL(destroyed()), dialog, SLOT(reject())); + connect(op, &QObject::destroyed, dialog, &QDialog::reject); } } } diff --git a/tomviz/operators/EditOperatorDialog.h b/tomviz/operators/EditOperatorDialog.h index 119755c1d..08686c71f 100644 --- a/tomviz/operators/EditOperatorDialog.h +++ b/tomviz/operators/EditOperatorDialog.h @@ -40,6 +40,9 @@ class EditOperatorDialog : public QDialog // dialog already, that dialog is set to the requested mode and given focus. static void showDialogForOperator(Operator* op, const QString& viewMode = ""); +protected: + void showEvent(QShowEvent* event) override; + private slots: void onApply(); void onCancel(); diff --git a/tomviz/operators/Operator.cxx b/tomviz/operators/Operator.cxx index a1705ed9c..010ad9bba 100644 --- a/tomviz/operators/Operator.cxx +++ b/tomviz/operators/Operator.cxx @@ -162,13 +162,18 @@ QJsonObject Operator::serialize() const } json["type"] = OperatorFactory::instance().operatorType(this); json["id"] = QString::asprintf("%p", static_cast(this)); + if (m_breakpoint) { + json["breakpoint"] = true; + } return json; } bool Operator::deserialize(const QJsonObject& json) { - Q_UNUSED(json); + if (json.contains("breakpoint")) { + m_breakpoint = json["breakpoint"].toBool(); + } return true; } @@ -212,6 +217,14 @@ void Operator::createNewChildDataSource( } } +void Operator::setBreakpoint(bool enabled) +{ + if (m_breakpoint != enabled) { + m_breakpoint = enabled; + emit breakpointChanged(); + } +} + void Operator::cancelTransform() { m_state = OperatorState::Canceled; diff --git a/tomviz/operators/Operator.h b/tomviz/operators/Operator.h index 5deac7cd7..883b338e2 100644 --- a/tomviz/operators/Operator.h +++ b/tomviz/operators/Operator.h @@ -189,6 +189,10 @@ class Operator : public QObject /// Set the operator state, this is needed for external execution. void setState(OperatorState state) { m_state = state; } + /// Get/set whether a breakpoint is set on this operator. + bool hasBreakpoint() const { return m_breakpoint; } + void setBreakpoint(bool enabled); + /// Get the operator's help url QString helpUrl() const { return m_helpUrl; } void setHelpUrl(const QString& s) { m_helpUrl = s; } @@ -247,6 +251,9 @@ class Operator : public QObject // operator to run. void transformCompleted(); + // Emitted when the breakpoint state changes. + void breakpointChanged(); + public slots: /// Called when the 'Cancel' button is pressed on the progress dialog. /// Subclasses overriding this method should call the base implementation @@ -314,6 +321,7 @@ protected slots: int m_progressStep = 0; QString m_progressMessage; QString m_helpUrl; + bool m_breakpoint = false; std::atomic m_state{ OperatorState::Queued }; QPointer m_customDialog; }; diff --git a/tomviz/operators/OperatorDialog.cxx b/tomviz/operators/OperatorDialog.cxx index 1809658fd..2ca851d66 100644 --- a/tomviz/operators/OperatorDialog.cxx +++ b/tomviz/operators/OperatorDialog.cxx @@ -16,8 +16,8 @@ OperatorDialog::OperatorDialog(QWidget* parentObject) : Superclass(parentObject) QVBoxLayout* layout = new QVBoxLayout(this); QDialogButtonBox* buttons = new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel, Qt::Horizontal, this); - connect(buttons, SIGNAL(accepted()), this, SLOT(accept())); - connect(buttons, SIGNAL(rejected()), this, SLOT(reject())); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); this->setLayout(layout); layout->addWidget(m_ui); layout->addWidget(buttons); diff --git a/tomviz/operators/OperatorPropertiesPanel.cxx b/tomviz/operators/OperatorPropertiesPanel.cxx index af10dc559..cce65948e 100644 --- a/tomviz/operators/OperatorPropertiesPanel.cxx +++ b/tomviz/operators/OperatorPropertiesPanel.cxx @@ -24,8 +24,8 @@ namespace tomviz { OperatorPropertiesPanel::OperatorPropertiesPanel(QWidget* p) : QWidget(p) { // Show active module in the "Operator Properties" panel. - connect(&ActiveObjects::instance(), SIGNAL(operatorActivated(Operator*)), - SLOT(setOperator(Operator*))); + connect(&ActiveObjects::instance(), &ActiveObjects::operatorActivated, this, + [this](Operator* op) { setOperator(op); }); // Set up a very simple layout with a description label widget. m_layout = new QVBoxLayout; @@ -37,7 +37,7 @@ OperatorPropertiesPanel::~OperatorPropertiesPanel() = default; void OperatorPropertiesPanel::setOperator(Operator* op) { if (m_activeOperator) { - disconnect(op, SIGNAL(labelModified())); + disconnect(m_activeOperator, &Operator::labelModified, nullptr, nullptr); } deleteLayoutContents(m_layout); m_operatorWidget = nullptr; @@ -49,7 +49,7 @@ void OperatorPropertiesPanel::setOperator(Operator* op) } else { auto description = new QLabel(op->label()); layout()->addWidget(description); - connect(op, &Operator::labelModified, m_activeOperator, [this, description]() { + connect(op, &Operator::labelModified, this, [this, description]() { description->setText(m_activeOperator->label()); }); } diff --git a/tomviz/operators/OperatorPython.cxx b/tomviz/operators/OperatorPython.cxx index 970727821..239474087 100644 --- a/tomviz/operators/OperatorPython.cxx +++ b/tomviz/operators/OperatorPython.cxx @@ -52,6 +52,8 @@ class EditPythonOperatorWidget : public tomviz::EditOperatorWidget { m_ui.setupUi(this); m_ui.name->setText(o->label()); + // Ensure the tab widget expands to fill available vertical space. + m_ui.verticalLayout->setStretch(1, 1); auto* highlighter = new pqPythonSyntaxHighlighter(m_ui.script, *m_ui.script); highlighter->ConnectHighligter(); @@ -62,7 +64,7 @@ class EditPythonOperatorWidget : public tomviz::EditOperatorWidget QVBoxLayout* layout = new QVBoxLayout(); m_customWidget->setupUI(m_op); m_customWidget->setValues(m_op->arguments()); - layout->addWidget(m_customWidget); + layout->addWidget(m_customWidget, 1); m_ui.argumentsWidget->setLayout(layout); } else { QVBoxLayout* layout = new QVBoxLayout(); @@ -272,22 +274,24 @@ OperatorPython::OperatorPython(DataSource* parentObject) connectionType = Qt::DirectConnection; } // Needed so the worker thread can update data in the UI thread. - connect(this, SIGNAL(childDataSourceUpdated(vtkSmartPointer)), - this, SLOT(updateChildDataSource(vtkSmartPointer)), + connect(this, &OperatorPython::childDataSourceUpdated, this, + QOverload>::of( + &OperatorPython::updateChildDataSource), connectionType); // This connection is needed so we can create new child data sources in the UI // thread from a pipeline worker threads. - connect(this, SIGNAL(newChildDataSource(const QString&, - vtkSmartPointer)), - this, SLOT(createNewChildDataSource(const QString&, - vtkSmartPointer)), - connectionType); connect( this, - SIGNAL(newOperatorResult(const QString&, vtkSmartPointer)), + QOverload>::of( + &OperatorPython::newChildDataSource), this, - SLOT(setOperatorResult(const QString&, vtkSmartPointer))); + [this](const QString& label, vtkSmartPointer data) { + createNewChildDataSource(label, data); + }, + connectionType); + connect(this, &OperatorPython::newOperatorResult, this, + &OperatorPython::setOperatorResult); } OperatorPython::~OperatorPython() {} @@ -714,6 +718,10 @@ QVariant castJsonArg(const QJsonValue& arg, const QString& type) for (int i = 0; i < arr.size(); ++i) { arrayList << arr[i].toDouble(); } + } else if (type == "select_scalars") { + for (int i = 0; i < arr.size(); ++i) { + arrayList << arr[i].toString(); + } } return arrayList; } else if (arg.isDouble()) { diff --git a/tomviz/operators/OperatorResult.cxx b/tomviz/operators/OperatorResult.cxx index bb13acb76..2efe5aae3 100644 --- a/tomviz/operators/OperatorResult.cxx +++ b/tomviz/operators/OperatorResult.cxx @@ -98,11 +98,6 @@ void OperatorResult::setDataObject(vtkDataObject* object) vtkTrivialProducer* producer = vtkTrivialProducer::SafeDownCast(clientSideObject); producer->SetOutput(object); - // If the result is a vtkMolecule, create a ModuleMolecule to display it - if (vtkMolecule::SafeDownCast(object)) { - auto view = ActiveObjects::instance().activeView(); - ModuleManager::instance().createAndAddModule("Molecule", this, view); - } } vtkSMSourceProxy* OperatorResult::producerProxy() diff --git a/tomviz/operators/OperatorResultPropertiesPanel.cxx b/tomviz/operators/OperatorResultPropertiesPanel.cxx index f3ffabbb4..c879b1114 100644 --- a/tomviz/operators/OperatorResultPropertiesPanel.cxx +++ b/tomviz/operators/OperatorResultPropertiesPanel.cxx @@ -22,8 +22,8 @@ OperatorResultPropertiesPanel::OperatorResultPropertiesPanel(QWidget* p) : QWidget(p) { // Show active module in the "OperatorResult Properties" panel. - connect(&ActiveObjects::instance(), SIGNAL(resultChanged(OperatorResult*)), - SLOT(setOperatorResult(OperatorResult*))); + connect(&ActiveObjects::instance(), &ActiveObjects::resultChanged, this, + &OperatorResultPropertiesPanel::setOperatorResult); // Set up a very simple layout with a description label widget. m_layout = new QVBoxLayout; diff --git a/tomviz/operators/OperatorWidget.cxx b/tomviz/operators/OperatorWidget.cxx index b97be86ce..877b9222a 100644 --- a/tomviz/operators/OperatorWidget.cxx +++ b/tomviz/operators/OperatorWidget.cxx @@ -32,7 +32,7 @@ void OperatorWidget::setupUI(OperatorPython* op) QString json = op->JSONDescription(); if (!json.isNull()) { DataSource* dataSource = nullptr; - if (op->hasChildDataSource()) + if (op->childDataSource()) dataSource = op->childDataSource(); else dataSource = qobject_cast(op->parent()); diff --git a/tomviz/operators/SetTiltAnglesOperator.cxx b/tomviz/operators/SetTiltAnglesOperator.cxx index bcaf01fd2..b8304b273 100644 --- a/tomviz/operators/SetTiltAnglesOperator.cxx +++ b/tomviz/operators/SetTiltAnglesOperator.cxx @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -124,14 +125,14 @@ class SetTiltAnglesWidget : public tomviz::EditOperatorWidget QString s = QString::number(angleIncrement, 'f', 2); this->angleIncrementLabel = new QLabel(s); - connect(startTilt, SIGNAL(valueChanged(int)), this, - SLOT(updateAngleIncrement())); - connect(endTilt, SIGNAL(valueChanged(int)), this, - SLOT(updateAngleIncrement())); - connect(startAngle, SIGNAL(valueChanged(double)), this, - SLOT(updateAngleIncrement())); - connect(endAngle, SIGNAL(valueChanged(double)), this, - SLOT(updateAngleIncrement())); + connect(startTilt, QOverload::of(&QSpinBox::valueChanged), this, + &SetTiltAnglesWidget::updateAngleIncrement); + connect(endTilt, QOverload::of(&QSpinBox::valueChanged), this, + &SetTiltAnglesWidget::updateAngleIncrement); + connect(startAngle, QOverload::of(&QDoubleSpinBox::valueChanged), + this, &SetTiltAnglesWidget::updateAngleIncrement); + connect(endAngle, QOverload::of(&QDoubleSpinBox::valueChanged), + this, &SetTiltAnglesWidget::updateAngleIncrement); layout->addWidget(angleIncrementLabel, 3, 3, 1, 1, Qt::AlignCenter); auto outerLayout = new QVBoxLayout; @@ -145,6 +146,7 @@ class SetTiltAnglesWidget : public tomviz::EditOperatorWidget this->tableWidget = new QTableWidget; this->tableWidget->setRowCount(totalSlices); this->tableWidget->setColumnCount(1); + this->tableWidget->setHorizontalHeaderLabels({"Tilt Angle"}); tablePanelLayout->addWidget(this->tableWidget); // Widget to hold tilt angle import button @@ -158,7 +160,8 @@ class SetTiltAnglesWidget : public tomviz::EditOperatorWidget loadFromFileButton->setText("Load From Text File"); buttonLayout->addWidget(loadFromFileButton); buttonLayout->insertStretch(-1); - connect(loadFromFileButton, SIGNAL(clicked()), SLOT(loadFromFile())); + connect(loadFromFileButton, &QPushButton::clicked, this, + &SetTiltAnglesWidget::loadFromFile); vtkFieldData* fd = dataObject->GetFieldData(); vtkDataArray* tiltArray = nullptr; @@ -223,7 +226,7 @@ class SetTiltAnglesWidget : public tomviz::EditOperatorWidget } else { QMap tiltAngles; for (vtkIdType i = 0; i < this->tableWidget->rowCount(); ++i) { - QTableWidgetItem* item = this->tableWidget->item(i, 0); + QTableWidgetItem* item = this->tableWidget->item(i, angleColumn()); tiltAngles[i] = item->data(Qt::DisplayRole).toDouble(); } this->Op->setTiltAngles(tiltAngles); @@ -285,7 +288,7 @@ class SetTiltAnglesWidget : public tomviz::EditOperatorWidget } int startRow = ranges[0].topRow(); for (int i = 0; i < angles.size(); ++i) { - auto item = this->tableWidget->item(i + startRow, 0); + auto item = this->tableWidget->item(i + startRow, angleColumn()); if (item) { item->setData(Qt::DisplayRole, angles[i]); } @@ -334,14 +337,73 @@ public slots: } else { qCritical() << QString("Unable to read '%1'.").arg(dialog.selectedFiles()[0]); + return; } - QStringList angleStrings = content.split(QRegularExpression("\\s+")); - int maxRows = - std::min(static_cast(angleStrings.size()), this->tableWidget->rowCount()); - for (int i = 0; i < maxRows; ++i) { - QTableWidgetItem* item = this->tableWidget->item(i, 0); - item->setData(Qt::DisplayRole, angleStrings[i]); + // Parse lines, trimming and skipping empty lines + QStringList rawLines = content.split("\n"); + QList parsedLines; + for (const QString& rawLine : rawLines) { + QString line = rawLine.trimmed(); + if (line.isEmpty()) { + continue; + } + QStringList tokens = line.split(QRegularExpression("\\s+")); + parsedLines.append(tokens); + } + + if (parsedLines.isEmpty()) { + return; + } + + // Detect two-column format: every line has exactly 2 tokens, + // first parses as int, second parses as double + bool twoColumn = true; + for (const QStringList& tokens : parsedLines) { + if (tokens.size() != 2) { + twoColumn = false; + break; + } + bool ok1, ok2; + tokens[0].toInt(&ok1); + tokens[1].toDouble(&ok2); + if (!ok1 || !ok2) { + twoColumn = false; + break; + } + } + + int maxRows = std::min(static_cast(parsedLines.size()), + this->tableWidget->rowCount()); + + if (twoColumn) { + m_hasScanIDs = true; + this->tableWidget->setColumnCount(2); + this->tableWidget->setHorizontalHeaderLabels( + {"Scan ID", "Tilt Angle"}); + this->tableWidget->horizontalHeader()->setStretchLastSection(true); + + for (int i = 0; i < maxRows; ++i) { + // Scan ID column (read-only) + QTableWidgetItem* scanItem = new QTableWidgetItem; + scanItem->setData(Qt::DisplayRole, parsedLines[i][0]); + scanItem->setFlags(scanItem->flags() & ~Qt::ItemIsEditable); + this->tableWidget->setItem(i, 0, scanItem); + + // Tilt angle column (editable) + QTableWidgetItem* angleItem = new QTableWidgetItem; + angleItem->setData(Qt::DisplayRole, parsedLines[i][1]); + this->tableWidget->setItem(i, 1, angleItem); + } + } else { + m_hasScanIDs = false; + this->tableWidget->setColumnCount(1); + this->tableWidget->setHorizontalHeaderLabels({"Tilt Angle"}); + + for (int i = 0; i < maxRows; ++i) { + QTableWidgetItem* item = this->tableWidget->item(i, 0); + item->setData(Qt::DisplayRole, parsedLines[i][0]); + } } } } @@ -355,6 +417,9 @@ public slots: QTabWidget* tabWidget; QLabel* angleIncrementLabel; double angleIncrement = 1.0; + bool m_hasScanIDs = false; + + int angleColumn() const { return m_hasScanIDs ? 1 : 0; } QPointer Op; QVector previousTiltAngles; diff --git a/tomviz/python/AddConstant.py b/tomviz/python/AddConstant.py index 80be964b1..382cb0d7b 100644 --- a/tomviz/python/AddConstant.py +++ b/tomviz/python/AddConstant.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, constant=0.0): """Add a constant to the data set""" diff --git a/tomviz/python/AddPoissonNoise.py b/tomviz/python/AddPoissonNoise.py index d36073f5d..499f70826 100644 --- a/tomviz/python/AddPoissonNoise.py +++ b/tomviz/python/AddPoissonNoise.py @@ -1,11 +1,9 @@ import numpy as np import tomviz.operators -from tomviz.utils import apply_to_each_array class AddPoissonNoiseOperator(tomviz.operators.CancelableOperator): - @apply_to_each_array def transform(self, dataset, N=25): """Add Poisson noise to tilt images""" self.progress.maximum = 1 diff --git a/tomviz/python/AutoCenterOfMassTiltImageAlignment.json b/tomviz/python/AutoCenterOfMassTiltImageAlignment.json index c6a791b39..ac468f324 100644 --- a/tomviz/python/AutoCenterOfMassTiltImageAlignment.json +++ b/tomviz/python/AutoCenterOfMassTiltImageAlignment.json @@ -1,5 +1,6 @@ { "externalCompatible": false, + "apply_to_each_array": false, "results" : [ { "name" : "alignments", diff --git a/tomviz/python/AutoCrossCorrelationTiltImageAlignment.json b/tomviz/python/AutoCrossCorrelationTiltImageAlignment.json index 91c39a39c..b56095078 100644 --- a/tomviz/python/AutoCrossCorrelationTiltImageAlignment.json +++ b/tomviz/python/AutoCrossCorrelationTiltImageAlignment.json @@ -2,6 +2,7 @@ "name" : "AutoCrossCorrelationTiltImageAlignment", "label" : "Auto Tilt Image Align (XCORR)", "description" : "Automatically align tilt images by cross-correlation", + "apply_to_each_array": false, "parameters" : [ { "name" : "transform_source", diff --git a/tomviz/python/AutoTiltAxisRotationAlignment.json b/tomviz/python/AutoTiltAxisRotationAlignment.json new file mode 100644 index 000000000..422ec6681 --- /dev/null +++ b/tomviz/python/AutoTiltAxisRotationAlignment.json @@ -0,0 +1,3 @@ +{ + "apply_to_each_array": false +} diff --git a/tomviz/python/AutoTiltAxisShiftAlignment.json b/tomviz/python/AutoTiltAxisShiftAlignment.json index 3d50d450a..e5c66ce6a 100644 --- a/tomviz/python/AutoTiltAxisShiftAlignment.json +++ b/tomviz/python/AutoTiltAxisShiftAlignment.json @@ -2,6 +2,7 @@ "name" : "AutoTiltAxisShiftAlignment", "label" : "Auto Tilt Axis Shift Align", "description" : "Automatically center images along the tilt axis", + "apply_to_each_array": false, "parameters" : [ { "name" : "transform_source", diff --git a/tomviz/python/BinTiltSeriesByTwo.py b/tomviz/python/BinTiltSeriesByTwo.py index e8139da93..66dd1d508 100644 --- a/tomviz/python/BinTiltSeriesByTwo.py +++ b/tomviz/python/BinTiltSeriesByTwo.py @@ -1,7 +1,6 @@ from tomviz import utils -@utils.apply_to_each_array def transform(dataset): """Downsample tilt images by a factor of 2""" diff --git a/tomviz/python/BinVolumeByTwo.py b/tomviz/python/BinVolumeByTwo.py index f1020a5c7..3c213f07c 100644 --- a/tomviz/python/BinVolumeByTwo.py +++ b/tomviz/python/BinVolumeByTwo.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset): """Downsample volume by a factor of 2""" diff --git a/tomviz/python/CircleMask.py b/tomviz/python/CircleMask.py index ee16eeb6e..ac434285b 100644 --- a/tomviz/python/CircleMask.py +++ b/tomviz/python/CircleMask.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, axis, ratio, value): try: import tomopy diff --git a/tomviz/python/ClearVolume.py b/tomviz/python/ClearVolume.py index 2c8366a69..ead713510 100644 --- a/tomviz/python/ClearVolume.py +++ b/tomviz/python/ClearVolume.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, XRANGE=None, YRANGE=None, ZRANGE=None): """Define this method for Python operators that transform input scalars""" diff --git a/tomviz/python/ClipEdges.py b/tomviz/python/ClipEdges.py index ad661c9ca..8b5287a30 100644 --- a/tomviz/python/ClipEdges.py +++ b/tomviz/python/ClipEdges.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, clipNum=5): """Set values outside a cirular range to minimum(dataset) to remove reconstruction artifacts""" diff --git a/tomviz/python/DeconvolutionDenoise.json b/tomviz/python/DeconvolutionDenoise.json new file mode 100644 index 000000000..227d88340 --- /dev/null +++ b/tomviz/python/DeconvolutionDenoise.json @@ -0,0 +1,109 @@ +{ + "name" : "DeconvolutionDenoise", + "label" : "Deconvolution Denoise", + "description" : "Deconvolution-based denoising of volumetric data using a probe and a selected regularization method.", + "apply_to_each_array": false, + "parameters" : [ + { + "name" : "selected_scalars", + "label" : "Scalars", + "type" : "select_scalars" + }, + { + "name" : "method", + "label" : "Method", + "description" : "The deconvolution method to use", + "type" : "enumeration", + "default" : 0, + "options" : [ + {"APG_BM3D" : "APG_BM3D"}, + {"APG_TV" : "APG_TV"}, + {"ADMM_TV" : "ADMM_TV"} + ] + }, + { + "name" : "axis", + "label" : "Axis", + "description" : "The axis along which to process slices", + "type" : "enumeration", + "default" : 2, + "options" : [ + {"X" : 0}, + {"Y" : 1}, + {"Z" : 2} + ] + }, + { + "name" : "probe", + "label" : "Probe", + "description" : "The probe dataset used for deconvolution", + "type" : "dataset" + }, + { + "name" : "fast_axis_scanning", + "label" : "Fast Axis Scanning", + "description" : "", + "type" : "enumeration", + "default" : 0, + "options" : [ + {"dilation" : "dilation"}, + {"average" : "average"} + ] + }, + { + "name" : "slow_axis_scanning", + "label" : "Slow Axis Scanning", + "description" : "", + "type" : "enumeration", + "default" : 1, + "options" : [ + {"dilation" : "dilation"}, + {"average" : "average"} + ] + }, + { + "name" : "probe_kernel", + "label" : "Probe Kernel", + "description" : "The size of the probe kernel", + "type" : "int", + "default" : 11, + "minimum" : 1 + }, + { + "name" : "scale_x", + "label" : "Scale X", + "description" : "X Scale factor", + "type" : "int", + "default" : 1, + "minimum" : 1, + "enable_if" : "method != 'ADMM_TV'" + }, + { + "name" : "scale_y", + "label" : "Scale Y", + "description" : "Y Scale factor", + "type" : "int", + "default" : 1, + "minimum" : 1, + "enable_if" : "method != 'ADMM_TV'" + }, + { + "name" : "max_iter", + "label" : "Max Iterations", + "description" : "Maximum number of iterations", + "type" : "int", + "default" : 8, + "minimum" : 1, + "maximum" : 100000 + }, + { + "name" : "mu", + "label" : "Mu", + "description" : "mu parameter", + "type" : "double", + "default" : 0.01, + "precision" : 5 + } + ], + "results" : [] +} diff --git a/tomviz/python/DeconvolutionDenoise.py b/tomviz/python/DeconvolutionDenoise.py new file mode 100644 index 000000000..c5f45540d --- /dev/null +++ b/tomviz/python/DeconvolutionDenoise.py @@ -0,0 +1,886 @@ +import tomviz.operators + +import numpy as np +import scipy + + +def deconv_admm(g, psf, mu, is_canceled=None, max_iter=50): + # Fast ADMM_TV/L2 algorithm based on "An Augmented Lagrangian Method for Total Variation Video Restoration", + # Stanley H. Chan, Student Member, IEEE, Ramsin Khoshabeh, Student Member, IEEE, Kristofor B. Gibson, Student Member, IEEE, Philip E. Gill, and Truong Q. Nguyen, Fellow, IEEE + # IEEE TRANSACTIONS ON IMAGE PROCESSING, VOL. 20, NO. 11, NOVEMBER 2011 + # Note: For a cyclic H, H = F^-1*diag(Fh)*F, where h is the first column of H + # H^H = F^-1*diag(conj(Fh))*F + + # mu = 1/sigma**2 + G = np.fft.fft2(g) + sz = np.shape(g) + h_sz = np.shape(psf) + h = np.pad(psf, ((0, sz[0] - h_sz[0]), (0, sz[1] - h_sz[1]))) + h = np.roll(h, [-(h_sz[0] // 2), -(h_sz[1] // 2)], [0, 1]) + H = np.fft.fft2(h, sz) + f = g + ux = np.zeros(sz) + uy = np.zeros(sz) + yx = np.zeros(sz) + yy = np.zeros(sz) + rho_r = 2 + Dx = np.fft.fft2(np.asarray([[-1, 1], [0, 0]]), sz) + Dy = np.fft.fft2(np.asarray([[-1, 1], [0, 0]]).T, sz) + HtH = np.abs(H) ** 2 + DxtDx = np.abs(Dx) ** 2 + DytDy = np.abs(Dy) ** 2 + cov = 1 + tol = 1e-4 + itr = 0 + HtG = np.conj(H) * G + vx, vy = der_im(f) + rnorm = np.sum(np.sqrt(vx.ravel() ** 2 + vy.ravel() ** 2)) + + loss = np.zeros((max_iter, 1)) + rdiff = np.zeros((max_iter, 1)) + + while cov > tol and itr < max_iter: + if is_canceled is not None and is_canceled(): + break + + dxt_ux, dyt_uy = der_t(ux, uy) + dxt_yx, dyt_yy = der_t(yx, yy) + tmp1 = mu * HtG + np.fft.fft2(rho_r * (dxt_ux + dyt_uy) - (dxt_yx + dyt_yy)) + tmp2 = mu * HtH + rho_r * (DxtDx + DytDy) + + f_old = f + f = np.real(np.fft.ifft2(tmp1 / tmp2)) + # f = np.fft.ifft2(H*G/HtH) + vx, vy = der_im(f) + vvx = vx + (1 / rho_r) * yx + vvy = vy + (1 / rho_r) * yy + + # anisotropic form + # ux = np.fmax(np.abs(vvx)-1/rho_r,0)*np.sign(vvx) + # uy = np.fmax(np.abs(vvy)-1/rho_r,0)*np.sign(vvy) + + # isotropic form + v = np.sqrt(vvx**2 + vvy**2) + v[v == 0] = 1 + v = np.fmax(v - 1 / rho_r, 0) / v + ux = v * vvx + uy = v * vvy + + yx = yx - rho_r * (ux - vx) + yy = yy - rho_r * (uy - vy) + + cov = np.linalg.norm(f - f_old) / np.linalg.norm(f) + cost = (mu / 2) * np.sum((g.ravel() - conv_2d(f, psf).ravel()) ** 2) + np.sum( + np.sqrt(vx.ravel() ** 2 + vy.ravel() ** 2) + ) + + # print("Iter = %d\tConvegence = %6.4e\tCost = %6.4e\trho_r = %d" %(itr,cov,cost,rho_r)) + + rnorm_old = rnorm + rnorm = np.sum( + np.sqrt((ux.ravel() - vx.ravel()) ** 2 + (uy.ravel() - vy.ravel()) ** 2) + ) + if rnorm > 0.7 * rnorm_old: + rho_r = rho_r * 2 + + loss[itr, 0] = cost + rdiff[itr, 0] = cov + itr += 1 + + return f, g, loss, rdiff + + +def der_im(f): + dx_f = np.diff(f, 1, 1) + dy_f = np.diff(f, 1, 0) + dx_f = np.concatenate((dx_f, np.reshape(f[:, 0] - f[:, -1], [-1, 1])), 1) + dy_f = np.concatenate((dy_f, np.reshape(f[0, :] - f[-1, :], [1, -1])), 0) + return dx_f, dy_f + + +def der_t(ux, uy): + dxt_ux = np.concatenate( + (np.reshape(ux[:, -1] - ux[:, 0], [-1, 1]), -np.diff(ux, 1, 1)), 1 + ) + dyt_uy = np.concatenate( + (np.reshape(uy[-1, :] - uy[0, :], [1, -1]), -np.diff(uy, 1, 0)), 0 + ) + return dxt_ux, dyt_uy + + +def conv_2d(f, h): + sz = np.shape(f) + h_sz = np.shape(h) + h = np.pad(h, ((0, sz[0] - h_sz[0]), (0, sz[1] - h_sz[1]))) + h = np.roll(h, [-(h_sz[0] // 2), -(h_sz[1] // 2)], [0, 1]) + H = np.fft.fft2(h) + F = np.fft.fft2(f) + g = np.fft.ifft2(F * H) + return np.real(g) + + +def deconv_2d(g, h): + sz = np.shape(g) + h_sz = np.shape(h) + h = np.pad(h, ((0, sz[0] - h_sz[0]), (0, sz[1] - h_sz[1]))) + h = np.roll(h, [-(h_sz[0] // 2), -(h_sz[1] // 2)], [0, 1]) + H = np.fft.fft2(h) + HtH = np.abs(H) ** 2 + G = np.fft.fft2(g) + f = np.fft.ifft2(np.conj(H) * G / HtH) + return np.real(f) + + +def deconv_admm_NN(g, psf, mu, rho_r, rho_o, tol, alpha, beta, max_iter): + + # Fast ADMM_TV/L2 algorithm based on "An Augmented Lagrangian Method for Total Variation Video Restoration", + # Stanley H. Chan, Student Member, IEEE, Ramsin Khoshabeh, Student Member, IEEE, Kristofor B. Gibson, Student Member, IEEE, Philip E. Gill, and Truong Q. Nguyen, Fellow, IEEE + # IEEE TRANSACTIONS ON IMAGE PROCESSING, VOL. 20, NO. 11, NOVEMBER 2011 + # With non_negative constraint + # Note: For a cyclic H, H = F^-1*diag(Fh)*F, where h is the first column of H + # H^H = F^-1*diag(conj(Fh))*F + + # rho_r = ops.rho_r + # rho_o = ops.rho_o + # max_iter = ops.max_iter + + G = np.fft.fft2(g) + sz = np.shape(g) + h_sz = np.shape(psf) + h = np.pad(psf, ((0, sz[0] - h_sz[0]), (0, sz[1] - h_sz[1]))) + h = np.roll(h, [-(h_sz[0] // 2), -(h_sz[1] // 2)], [0, 1]) + H = np.fft.fft2(h) + f = g + ux = np.zeros(sz) + uy = np.zeros(sz) + yx = np.zeros(sz) + yy = np.zeros(sz) + + t = np.zeros(sz) + q = np.zeros(sz) + + Dx = np.fft.fft2(np.asarray([[-1, 1], [0, 0]]), sz) + Dy = np.fft.fft2(np.asarray([[-1, 1], [0, 0]]).T, sz) + HtH = np.abs(H) ** 2 + DxtDx = np.abs(Dx) ** 2 + DytDy = np.abs(Dy) ** 2 + cov = 1 + itr = 0 + + HtG = np.conj(H) * G + vx, vy = der_im(f) + rnorm = np.sum(np.sqrt(vx.ravel() ** 2 + vy.ravel() ** 2)) + onorm = np.linalg.norm(f, "fro") + while cov > tol and itr < max_iter: + + dxt_ux, dyt_uy = der_t(ux, uy) + dxt_yx, dyt_yy = der_t(yx, yy) + tmp1 = mu * HtG + np.fft.fft2( + rho_r * (dxt_ux + dyt_uy) - (dxt_yx + dyt_yy) + rho_o * t - q + ) + tmp2 = mu * HtH + rho_r * (DxtDx + DytDy) + rho_o + + f_old = f + f = np.real(np.fft.ifft2(tmp1 / tmp2)) + # f = np.fft.ifft2(H*G/HtH) + vx, vy = der_im(f) + vvx = vx + (1 / rho_r) * yx + vvy = vy + (1 / rho_r) * yy + + # anisotropic form + # ux = np.fmax(np.abs(vvx)-1/rho_r,0)*np.sign(vvx) + # uy = np.fmax(np.abs(vvy)-1/rho_r,0)*np.sign(vvy) + + # isotropic form + v = np.sqrt(vvx**2 + vvy**2) + v[v == 0] = 1 + v = np.fmax(v - 1 / rho_r, 0) / v + ux = v * vvx + uy = v * vvy + + yx = yx - rho_r * (ux - vx) + yy = yy - rho_r * (uy - vy) + + t = np.fmax(f + (1 / rho_o) * q, 0) + q = q - rho_o * (t - f) + + cov = np.linalg.norm(f - f_old) / np.linalg.norm(f) + cost = (mu / 2) * np.sum((g.ravel() - conv_2d(f, psf).ravel()) ** 2) + np.sum( + np.sqrt(vx.ravel() ** 2 + vy.ravel() ** 2) + ) + + print( + "Iter = %d\tConvegence = %6.4e\tCost = %6.4e\trho_r = %4.2f\trho_o = %4.2f" + % (itr, cov, cost, rho_r, rho_o) + ) + + rnorm_old = rnorm + rnorm = np.sum( + np.sqrt((ux.ravel() - vx.ravel()) ** 2 + (uy.ravel() - vy.ravel()) ** 2) + ) + if rnorm > alpha * rnorm_old: + rho_r = rho_r * beta + + onorm_old = onorm + onorm = np.linalg.norm(f - t, "fro") + if onorm > alpha * onorm_old: + rho_o = rho_o * beta + + itr += 1 + + # cov = 0 + + return f, t + + +def anscombe(in_data): + out_data = 2 * np.sqrt(in_data + 3.0 / 8.0) + return out_data + + +def inv_anscombe(in_data): + out_data = (in_data / 2.0) ** 2 - 1.0 / 8.0 + return out_data + + +def gauss(sz, w): + l = len(sz) + # print(l) + if l == 1: + x = np.arange(0, sz[0], 1) + out = np.exp(-((x - sz[0] // 2) ** 2) / (2 * w**2)) + elif l == 2: + y = np.arange(0, sz[0], 1) + x = np.arange(0, sz[1], 1) + X, Y = np.meshgrid(x, y) + out = np.exp(-((Y - sz[0] // 2) ** 2) / (2 * w**2)) * np.exp( + -((X - sz[1] // 2) ** 2) / (2 * w**2) + ) + elif l == 3: + y = np.arange(0, sz[0], 1) + x = np.arange(0, sz[1], 1) + z = np.arange(0, sz[2], 1) + X, Y, Z = np.meshgrid(x, y, z) + out = ( + np.exp(-((Y - sz[0] // 2) ** 2) / (2 * w**2)) + * np.exp(-((X - sz[1] // 2) ** 2) / (2 * w**2)) + * np.exp(-((Z - sz[2] // 2) ** 2) / (2 * w**2)) + ) + out = out / np.sum(out.ravel()) + return out + + +# def deconv(im_in,psf,sigma,max_iter, method='ADMM-TV'): +# if method == 'ADMM-TV': +# im_out = deconv_admm(im_in,psf,1/(2*sigma**2)) +# elif method == 'APG-TV': + + +def conv_mat_direct(im_sz, psf): + psf_sz = psf.shape + psf_tot = np.size(psf) + cent = [psf_sz[0] // 2, psf_sz[1] // 2] + im_tot = im_sz[0] * im_sz[1] + locs_tot = psf_sz[0] * psf_sz[1] * im_tot + full_j_ind = np.zeros((locs_tot, 1), dtype="int") + full_i_ind = np.zeros((locs_tot, 1), dtype="int") + full_data = np.zeros((locs_tot, 1), dtype="float") + + locs = np.arange(0, im_tot) + locs = np.reshape(locs, im_sz) + + cnt_numel = 0 + for row in range(im_sz[0]): + for col in range(im_sz[1]): + row_ind = row * im_sz[1] + col + row_locs1 = np.arange(-cent[0] + row + im_sz[0], im_sz[0], 1, dtype="int") + row_locs2 = np.arange( + 0, -cent[0] + row + psf_sz[0] - im_sz[0], 1, dtype="int" + ) + row_locs3 = np.arange( + np.fmax(-cent[0] + row, 0), + np.fmin(-cent[0] + row + psf_sz[0], im_sz[0]), + 1, + dtype="int", + ) + row_locs = np.concatenate((row_locs1, row_locs3, row_locs2)) + + col_locs1 = np.arange(-cent[1] + col + im_sz[1], im_sz[1], 1, dtype="int") + col_locs2 = np.arange( + 0, -cent[1] + col + psf_sz[1] - im_sz[1], 1, dtype="int" + ) + col_locs3 = np.arange( + np.fmax(-cent[1] + col, 0), + np.fmin(-cent[1] + col + psf_sz[1], im_sz[1]), + 1, + dtype="int", + ) + col_locs = np.concatenate((col_locs1, col_locs3, col_locs2)) + + curr_locs = locs[np.ix_(row_locs, col_locs)] + # print(np.size(col_locs)) + full_i_ind[cnt_numel : cnt_numel + psf_tot, 0] = row_ind + full_j_ind[cnt_numel : cnt_numel + psf_tot, 0] = curr_locs.ravel() + full_data[cnt_numel : cnt_numel + psf_tot, 0] = psf.ravel() + + cnt_numel = cnt_numel + psf_tot + + H = scipy.sparse.coo_matrix( + (full_data.ravel(), (full_i_ind.ravel(), full_j_ind.ravel())), (im_tot, im_tot) + ) + return H + + +def conv_mat_dilation(scale, lr_im_sz, psf): + + hr_im_sz = np.array(lr_im_sz) * np.array(scale) + psf_sz = np.shape(psf) + psf_tot = np.size(psf) + cent = [psf_sz[0] // 2, psf_sz[1] // 2] + hr_im_tot = hr_im_sz[0] * hr_im_sz[1] + lr_im_tot = lr_im_sz[0] * lr_im_sz[1] + locs_tot = psf_sz[0] * psf_sz[1] * lr_im_tot + full_j_ind = np.zeros((locs_tot, 1), dtype="int") + full_i_ind = np.zeros((locs_tot, 1), dtype="int") + full_data = np.zeros((locs_tot, 1), dtype="float") + + locs = np.arange(0, hr_im_tot) + locs = np.reshape(locs, hr_im_sz) + + cnt_numel = 0 + + for lr_row in range(lr_im_sz[0]): + for lr_col in range(lr_im_sz[1]): + row_ind = lr_row * lr_im_sz[1] + lr_col + hr_row = lr_row * scale[0] + hr_col = lr_col * scale[1] + row_locs1 = np.arange( + -cent[0] + hr_row + hr_im_sz[0], hr_im_sz[0], 1, dtype="int" + ) + row_locs2 = np.arange( + 0, -cent[0] + hr_row + psf_sz[0] - hr_im_sz[0], 1, dtype="int" + ) + row_locs3 = np.arange( + np.fmax(-cent[0] + hr_row, 0), + np.fmin(-cent[0] + hr_row + psf_sz[0], hr_im_sz[0]), + 1, + dtype="int", + ) + row_locs = np.concatenate((row_locs1, row_locs3, row_locs2)) + + col_locs1 = np.arange( + -cent[1] + hr_col + hr_im_sz[1], hr_im_sz[1], 1, dtype="int" + ) + col_locs2 = np.arange( + 0, -cent[1] + hr_col + psf_sz[1] - hr_im_sz[1], 1, dtype="int" + ) + col_locs3 = np.arange( + np.fmax(-cent[1] + hr_col, 0), + np.fmin(-cent[1] + hr_col + psf_sz[1], hr_im_sz[1]), + 1, + dtype="int", + ) + col_locs = np.concatenate((col_locs1, col_locs3, col_locs2)) + + curr_locs = locs[np.ix_(row_locs, col_locs)] + # print(np.size(col_locs)) + full_i_ind[cnt_numel : cnt_numel + psf_tot, 0] = row_ind + full_j_ind[cnt_numel : cnt_numel + psf_tot, 0] = curr_locs.ravel() + full_data[cnt_numel : cnt_numel + psf_tot, 0] = psf.ravel() + + cnt_numel = cnt_numel + psf_tot + + H = scipy.sparse.coo_matrix( + (full_data.ravel(), (full_i_ind.ravel(), full_j_ind.ravel())), + (lr_im_tot, hr_im_tot), + ) + return H + + +def sp_conv_mat(scale, lr_im_sz, psf, conv_med=["dilation", "dilation"]): + if conv_med == ["dilation", "dilation"]: + return conv_mat_dilation(scale, lr_im_sz, psf) + else: + if scale[0] * scale[1] > 1: + hr_im_sz = np.array(lr_im_sz) * np.array(scale) + H_hr = conv_mat_direct(hr_im_sz, psf) + if conv_med[0] == "dilation": + row_psf = np.ones((1, 1)) + else: # averaging + row_psf = np.ones((scale[0], 1), dtype="float") / scale[0] + H_row = conv_mat_dilation( + [scale[0], 1], [lr_im_sz[0], hr_im_sz[1]], row_psf + ) + if conv_med[1] == "dilation": + col_psf = np.ones((1, 1)) + else: # averaging + col_psf = np.ones((1, scale[1]), dtype="float") / scale[1] + H_col = conv_mat_dilation( + [1, scale[1]], [lr_im_sz[0], lr_im_sz[1]], col_psf + ) + + return H_col @ (H_row @ H_hr) + else: + return conv_mat_direct(lr_im_sz, psf) + + +def sp_conv_mat_v1(scale, lr_im_sz, psf, conv_med=["dilation", "dilation"]): + # this version is slightly faster + if conv_med == ["dilation", "dilation"]: + return conv_mat_dilation(scale, lr_im_sz, psf) + elif scale[0] * scale[1] == 1: + return conv_mat_direct(lr_im_sz, psf) + elif conv_med == ["dilation", "average"]: + hr_im_sz = np.array(lr_im_sz) * np.array(scale) + H = conv_mat_dilation([scale[0], 1], [lr_im_sz[0], hr_im_sz[1]], psf) + if scale[1] > 1: + col_psf = np.ones((1, scale[1]), dtype="float") / scale[1] + H_col = conv_mat_dilation( + [1, scale[1]], [lr_im_sz[0], lr_im_sz[1]], col_psf + ) + H = H_col @ H + return H + elif conv_med == ["average", "dilation"]: + hr_im_sz = np.array(lr_im_sz) * np.array(scale) + H = conv_mat_dilation([1, scale[1]], [hr_im_sz[0], lr_im_sz[1]], psf) + if scale[0] > 1: + row_psf = np.ones((scale[0], 1), dtype="float") / scale[1] + H_row = conv_mat_dilation( + [scale[0], 1], [lr_im_sz[0], lr_im_sz[1]], row_psf + ) + H = H_row @ H + return H + elif conv_med == ["average", "average"]: + hr_im_sz = np.array(lr_im_sz) * np.array(scale) + H = conv_mat_direct(hr_im_sz, psf) + avg_psf = np.ones(scale, dtype="float") / (scale[0] * scale[1]) + H_avg = conv_mat_dilation(scale, lr_im_sz, avg_psf) + H = H_avg @ H + return H + else: + return conv_mat_dilation(scale, lr_im_sz, psf) + + +def deconv_apg_tv(im, psf, scale, mu, conv_med, max_iter, is_canceled=None): + from skimage.transform import rescale + + # max_iter = 100 + beta = 0.5 + tol = 1e-4 + lr_im_sz = np.shape(im) + hr_im_sz = [lr_im_sz[0] * scale[0], lr_im_sz[1] * scale[1]] + + H = sp_conv_mat_v1(scale, lr_im_sz, psf, conv_med=conv_med) + y = im.ravel() + HtH = H.T @ H + Hty = H.T @ y + original = rescale(im, scale) + x = original.ravel() + a = 1 + w = x + + loss = np.zeros((max_iter, 1)) + rdiff = np.zeros((max_iter, 1)) + + for i in range(max_iter): + if is_canceled is not None and is_canceled(): + break + grad = HtH @ w - Hty + for k in range(10): + if is_canceled is not None and is_canceled(): + break + w_new = w - a * grad + x_new, _, l, r = deconv_admm( + np.reshape(w_new, hr_im_sz), [[1]], mu, is_canceled=is_canceled + ) + x_new = x_new.ravel() + L_new = 0.5 * np.sum((H @ x_new - y) ** 2) + Q = ( + 0.5 * np.sum((H @ w - y) ** 2) + + grad.T @ (x_new - w) + + 1 / (2 * a) * np.linalg.norm(x_new - w) ** 2 + ) + if L_new <= Q: + break + else: + a = a * beta + + covg = np.linalg.norm(x - x_new) / np.linalg.norm(x_new) + w = x_new + (i / (i + 3.0)) * (x_new - x) + x = x_new + + loss[i, 0] = L_new + rdiff[i, 0] = covg + + if covg < tol or k == 9: + break + return np.reshape(w, hr_im_sz), original, loss, rdiff + + +def deconv_apg_bm3d(im, psf, scale, mu, conv_med, max_iter, is_canceled=None): + import bm3d + from skimage.transform import rescale + + beta = 0.5 + tol = 1e-4 + effective_sigma = mu + + lr_im_sz = np.shape(im) + hr_im_sz = [lr_im_sz[0] * scale[0], lr_im_sz[1] * scale[1]] + + H = sp_conv_mat_v1(scale, lr_im_sz, psf, conv_med=conv_med) + y = im.ravel() + HtH = H.T @ H + Hty = H.T @ y + original = rescale(im, scale) + x = original.ravel() + a = 1 + w = x + + loss = np.zeros((max_iter, 1)) + rdiff = np.zeros((max_iter, 1)) + + for i in range(max_iter): + if is_canceled is not None and is_canceled(): + break + grad = HtH @ w - Hty + for k in range(10): + if is_canceled is not None and is_canceled(): + break + w_new = w - a * grad + x_new = bm3d.bm3d(np.reshape(w_new, hr_im_sz), effective_sigma) + x_new = x_new.ravel() + L_new = 0.5 * np.sum((H @ x_new - y) ** 2) + Q = ( + 0.5 * np.sum((H @ w - y) ** 2) + + grad.T @ (x_new - w) + + 1 / (2 * a) * np.linalg.norm(x_new - w) ** 2 + ) + if L_new <= Q: + break + else: + a = a * beta + + covg = np.linalg.norm(x - x_new) / np.linalg.norm(x_new) + w = x_new + (i / (i + 3.0)) * (x_new - x) + x = x_new + loss[i, 0] = L_new + rdiff[i, 0] = covg + if k == 9 or covg < tol: + break + + return np.reshape(w, hr_im_sz), original, loss, rdiff + + +def deconv_apg_bm3d_poisson(im, psf, scale, mu, conv_med, max_iter, is_canceled=None): + import bm3d + + ep = 0.1 + beta = 0.5 + tol = 1e-4 + effective_sigma = mu + + lr_im_sz = np.shape(im) + hr_im_sz = [lr_im_sz[0] * scale[0], lr_im_sz[1] * scale[1]] + + H = sp_conv_mat_v1(scale, lr_im_sz, psf, conv_med=conv_med) + y = im.ravel() + I_col = scipy.sparse.coo_matrix( + np.ones((lr_im_sz[0] * lr_im_sz[1], 1), dtype="float") + ) + + HtI = H.T @ I_col + # Hty = H.T@y + x = rescale(im, scale).ravel().reshape((hr_im_sz[0] * hr_im_sz[1], 1)) + a = 1 + w = x + + loss = np.zeros((max_iter, 1)) + rdiff = np.zeros((max_iter, 1)) + + for i in range(max_iter): + if is_canceled is not None and is_canceled(): + break + grad = HtI - H.T @ (y / (H @ w + ep)) + for k in range(10): + if is_canceled is not None and is_canceled(): + break + w_new = w - a * grad + w_new_trans = anscombe(w_new) + w_max = np.max(w_new_trans) + + x_new_trans = bm3d.bm3d( + np.reshape(w_new_trans / w_max, hr_im_sz), 1.0 / w_max + ) + x_new_trans = x_new_trans * w_max + + x_new = inv_anscombe(x_new_trans) + + x_new = x_new.ravel() + Hx = H @ x_new + L_new = 0.5 * np.sum((Hx - y * np.log(Hx))) + Hw = H @ w + Q = ( + 0.5 * np.sum((Hw - y * np.log(Hw))) + + grad.T @ (x_new - w) + + 1 / (2 * a) * np.linalg.norm(x_new - w) ** 2 + ) + if L_new <= Q: + break + else: + a = a * beta + + covg = np.linalg.norm(x - x_new) / np.linalg.norm(x_new) + w = x_new + (i / (i + 3.0)) * (x_new - x) + x = x_new + loss[i, 0] = L_new + rdiff[i, 0] = covg + if k == 9 or covg < tol: + break + + return np.reshape(w, hr_im_sz), loss, rdiff + + +def deconv( + im, + psf, + scale, + mu, + conv_med=["dilation", "dilation"], + deconv_med="ADMM_TV", + max_iter=50, + is_canceled=None, +): + + if deconv_med == "ADMM_TV": + if scale[0] * scale[1] != 1: + print("ADMM_TV does not support upscaling; forcing scale = [1,1].") + out, original, loss, rdiff = deconv_admm( + im, psf, mu, is_canceled=is_canceled, max_iter=max_iter + ) + elif deconv_med == "APG_TV": + out, original, loss, rdiff = deconv_apg_tv( + im, psf, scale, mu, conv_med=conv_med, max_iter=max_iter, + is_canceled=is_canceled, + ) + elif deconv_med == "APG_BM3D": + out, original, loss, rdiff = deconv_apg_bm3d( + im, psf, scale, mu, conv_med=conv_med, max_iter=max_iter, + is_canceled=is_canceled, + ) + else: + raise Exception("Unknown deconvolution method: {deconv_med}") + + return out, original, loss, rdiff + + +def PSNR(ori_im, rec_im): + y1 = np.asarray(ori_im) + y2 = np.asarray(rec_im) + if y1.max() < 1.5: + y1 = y1 * 255 + if y2.max() < 1.5: + y2 = y2 * 255 + err = np.sqrt(np.mean((y1.ravel() - y2.ravel()) ** 2)) + + psnr = 20 * np.log10(255 / err) + return psnr + + +def RMSE(ori_im, rec_im): + y1 = np.asarray(ori_im) + y2 = np.asarray(rec_im) + if y1.max() < 1.5: + y1 = y1 * 255 + if y2.max() < 1.5: + y2 = y2 * 255 + rmse = np.linalg.norm(y1.ravel() - y2.ravel()) / np.sqrt(y1.size) + return rmse + + +class DeconvolutionDenoise(tomviz.operators.CancelableOperator): + + def transform( + self, + dataset, + probe=None, + selected_scalars=None, + method="APG_BM3D", + fast_axis_scanning="dilation", + slow_axis_scanning="average", + max_iter=8, + scale_x=1, + scale_y=1, + mu=1.0, + axis=2, + probe_kernel=11, + ): + """Deconvolution-based denoising using a probe and a selected + regularization method. + """ + import numpy as np # noqa: F811 + + if probe is None: + raise Exception("A probe dataset is required.") + + if selected_scalars is None: + selected_scalars = (dataset.active_name,) + + if method == "ADMM_TV" and (scale_x != 1 or scale_y != 1): + print("ADMM_TV does not support upscaling; forcing scale = [1., 1.]") + scale_x = 1 + scale_y = 1 + + all_scalars_set = set(dataset.scalars_names) + selected_scalars_set = set(selected_scalars) + + axis_index = axis + + probe_scalars = None + + for name in probe.scalars_names: + if "amplitude" in name.lower(): + probe_scalars = probe.scalars(name) + break + + if probe_scalars is None: + raise Exception( + "There are no scalars named 'amplitude' in the selected probe dataset." + ) + + dataset_scan_ids = dataset.scan_ids + probe_scan_ids = probe.scan_ids + dataset_tilt_angles = dataset.tilt_angles + + dataset_scan_id_to_slice = {} + probe_scan_id_to_slice = {} + + dataset_shape = dataset.active_scalars.shape + probe_shape = probe.active_scalars.shape + + dataset_n_slices = dataset_shape[axis_index] + probe_n_slices = probe_shape[axis_index] + + output_scan_ids = [] + output_tilt_angles = [] + + if dataset_scan_ids is None or probe_scan_ids is None: + for i in range(dataset_n_slices): + dataset_scan_id_to_slice[i] = i + + for i in range(probe_n_slices): + probe_scan_id_to_slice[i] = i + + else: + for i, scan_id in enumerate(dataset_scan_ids): + dataset_scan_id_to_slice[scan_id] = i + + for i, scan_id in enumerate(probe_scan_ids): + probe_scan_id_to_slice[scan_id] = i + + slice_indices = [] + for i, (scan_id, dataset_slice_index) in enumerate( + dataset_scan_id_to_slice.items() + ): + probe_slice_index = probe_scan_id_to_slice.get(scan_id) + + if probe_slice_index is None: + continue + + slice_indices.append((dataset_slice_index, probe_slice_index)) + output_scan_ids.append(scan_id) + + if dataset_tilt_angles is not None: + output_tilt_angles.append(dataset_tilt_angles[i]) + + for name in selected_scalars: + scalars = dataset.scalars(name) + if scalars is None: + continue + + n_slices = len(slice_indices) + + self.progress.value = 0 + self.progress.maximum = n_slices + self.progress.message = f"Array: {name}" + + output_shape = list(scalars.shape) + scale = [scale_x, scale_y] + j = 0 + + for i in range(len(output_shape)): + if i == axis_index: + output_shape[i] = n_slices + else: + output_shape[i] = output_shape[i] * scale[j] + j += 1 + + output_scalars = np.empty(output_shape) + + for output_slice_index, ( + dataset_slice_index, + probe_slice_index, + ) in enumerate(slice_indices): + if self.canceled: + return + + # for slice_index in range(n_slices): + self.progress.value = output_slice_index + + dataset_slice_indexing_list = [slice(None)] * scalars.ndim + dataset_slice_indexing_list[axis_index] = dataset_slice_index + dataset_slice_indexing = tuple(dataset_slice_indexing_list) + + probe_slice_indexing_list = [slice(None)] * scalars.ndim + probe_slice_indexing_list[axis_index] = probe_slice_index + probe_slice_indexing = tuple(probe_slice_indexing_list) + + output_slice_indexing_list = [slice(None)] * scalars.ndim + output_slice_indexing_list[axis_index] = output_slice_index + output_slice_indexing = tuple(output_slice_indexing_list) + + scalars_slice = scalars[dataset_slice_indexing] + probe_slice = probe_scalars[probe_slice_indexing] + + prb_sz = np.shape(probe_slice) + pw = probe_kernel + psf = ( + probe_slice[ + prb_sz[0] // 2 - pw // 2 : prb_sz[0] // 2 - pw // 2 + pw, + prb_sz[1] // 2 - pw // 2 : prb_sz[1] // 2 - pw // 2 + pw, + ] + ** 2 + ) + psf = psf / np.sum(psf.ravel()) + + w, scaled_slice, loss, rdiff = deconv( + scalars_slice, + psf, + scale, + mu, + [fast_axis_scanning, slow_axis_scanning], + method, + max_iter, + is_canceled=lambda: self.canceled, + ) + + if self.canceled: + return + + output_scalars[output_slice_indexing] = w + + self.progress.value = n_slices + + dataset.set_scalars(name, output_scalars) + + # Remove scalars that were not selected from the output dataset + for scalar_name in all_scalars_set - selected_scalars_set: + dataset.remove_scalars(scalar_name) + + # Set the scan ids on the output + if dataset_scan_ids is not None: + dataset.scan_ids = np.array(output_scan_ids) + + # Set the tilt angles on the output + if dataset_tilt_angles is not None: + dataset.tilt_angles = np.array(output_tilt_angles) diff --git a/tomviz/python/DeleteSlices.py b/tomviz/python/DeleteSlices.py index 51be004a6..491dcf0ed 100644 --- a/tomviz/python/DeleteSlices.py +++ b/tomviz/python/DeleteSlices.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, firstSlice=None, lastSlice=None, axis=2): """Delete Slices in Dataset""" diff --git a/tomviz/python/FFT_AbsLog.py b/tomviz/python/FFT_AbsLog.py index 06d60cef2..bab146a2f 100644 --- a/tomviz/python/FFT_AbsLog.py +++ b/tomviz/python/FFT_AbsLog.py @@ -5,10 +5,7 @@ # # WARNING: Be patient! Large datasets may take a while. -from tomviz.utils import apply_to_each_array - -@apply_to_each_array def transform(dataset): import numpy as np diff --git a/tomviz/python/FourierShellCorrelation.json b/tomviz/python/FourierShellCorrelation.json new file mode 100644 index 000000000..e23bf2242 --- /dev/null +++ b/tomviz/python/FourierShellCorrelation.json @@ -0,0 +1,21 @@ +{ + "name": "FourierShellCorrelation", + "label": "Fourier Shell Correlation", + "description": "", + "externalCompatible": false, + "apply_to_each_array": false, + "parameters": [ + { + "name": "selected_scalars", + "label": "Scalars", + "type": "select_scalars" + } + ], + "results": [ + { + "name": "plot", + "label": "FSC", + "type": "table" + } + ] +} diff --git a/tomviz/python/FourierShellCorrelation.py b/tomviz/python/FourierShellCorrelation.py new file mode 100644 index 000000000..b25382962 --- /dev/null +++ b/tomviz/python/FourierShellCorrelation.py @@ -0,0 +1,149 @@ +import tomviz.operators +import tomviz.utils + +import numpy as np +import scipy.stats as stats + +# Fourier Shell correlation, Xiaozing's Code. + +def cal_dist(shape): + if np.size(shape) == 2: + nx,ny = shape + dist_map = np.zeros((nx,ny)) + for i in range(nx): + for j in range(ny): + dist_map[i,j] = np.sqrt((i-nx/2)**2+(j-ny/2)**2) + + elif np.size(shape) == 3: + nx,ny,nz = shape + dist_map = np.zeros((nx,ny,nz)) + for i in range(nx): + for j in range(ny): + for k in range(nz): + dist_map[i,j,k] = np.sqrt((i-nx/2)**2+(j-ny/2)**2+(k-nz/2)**2) + + else: + raise ValueError("Wrong image dimensions.") + + return dist_map + + +def cal_fsc(image1,image2,pixel_size_nm,phase_flag=False, save=False, title=None): + + if np.ndim(image1) == 2: + if phase_flag: + image1 = np.angle(image1) + image2 = np.angle(image2) + nx,ny = np.shape(image1) + image1_fft = np.fft.fftshift(np.fft.fftn(np.fft.fftshift(image1))) / np.sqrt(nx*ny*1.) + image2_fft = np.fft.fftshift(np.fft.fftn(np.fft.fftshift(image2))) / np.sqrt(nx*ny*1.) + #r_max = int(np.sqrt(nx**2/4.+ny**2/4.)) + r_max = int(np.max((nx,ny))/2) + fsc = np.zeros(r_max) + noise_onebit = np.zeros(r_max) + noise_halfbit = np.zeros(r_max) + max_dim = np.max((nx,ny)) + x = np.arange(r_max)/(max_dim*pixel_size_nm) + dist_map = cal_dist((nx,ny)) + #np.save('dist_map.np',dist_map) + for i in range(r_max): + index = np.where((i <= dist_map) & (dist_map < (i+1))) + fsc[i] = np.abs(np.sum(image1_fft[index] * np.conj(image2_fft[index])) / + np.sqrt(np.sum(np.abs(image1_fft[index])**2)*np.sum(np.abs(image2_fft[index])**2))) + n_point = np.size(index) / (2) + if n_point > 0: + noise_onebit[i] = (0.5+2.4142/np.sqrt(n_point)) / (1.5+1.4142/np.sqrt(n_point)) + noise_halfbit[i] = (0.2071+1.9102/np.sqrt(n_point)) / (1.2071+0.9102/np.sqrt(n_point)) + + elif np.ndim(image1) == 3: + if phase_flag: + image1 = np.angle(image1) + image2 = np.angle(image2) + nx,ny,nz = np.shape(image1) + image1_fft = np.fft.fftshift(np.fft.fftn(np.fft.fftshift(image1))) / np.sqrt(nx*ny*nz*1.) + image2_fft = np.fft.fftshift(np.fft.fftn(np.fft.fftshift(image2))) / np.sqrt(nx*ny*nz*1.) + #r_max = int(np.sqrt(nx**2/4+ny**2/4+nz**2/4)) + r_max = int(np.max((nx,ny,nz))/2) + fsc = np.zeros(r_max) + noise_onebit = np.zeros(r_max) + noise_halfbit = np.zeros(r_max) + max_dim = np.max((nx,ny,nz)) + x = np.arange(r_max)/(max_dim*pixel_size_nm) + dist_map = cal_dist((nx,ny,nz)) + for i in range(r_max): + index = np.where((i <= dist_map) & (dist_map < (i+1))) + fsc[i] = np.abs(np.sum(image1_fft[index] * np.conj(image2_fft[index])) / np.sqrt(np.sum(np.abs(image1_fft[index])**2)*np.sum(np.abs(image2_fft[index])**2))) + n_point = np.size(index) / (3) + if n_point > 0: + noise_onebit[i] = (0.5+2.4142/np.sqrt(n_point)) / (1.5+1.4142/np.sqrt(n_point)) + noise_halfbit[i] = (0.2071+1.9102/np.sqrt(n_point)) / (1.2071+0.9102/np.sqrt(n_point)) + + else: + raise ValueError("Wrong image dimensions.") + + return x, fsc, noise_onebit, noise_halfbit + + +def checkerboard_split(image): + shape = image.shape + odd_index = list(np.arange(1, shape[i], 2) for i in range(len(shape))) + even_index = list(np.arange(0, shape[i], 2) for i in range(len(shape))) + image1 = image[even_index[0], :, :][:, odd_index[1], :][:, :, odd_index[2]] + \ + image[odd_index[0], :, :][:, odd_index[1], :][:, :, odd_index[2]] + + image2 = image[even_index[0], :, :][:, even_index[1], :][:, :, even_index[2]] + \ + image[odd_index[0], :, :][:, even_index[1], :][:, :, even_index[2]] + + return image1, image2 + + +class FourierShellCorrelation(tomviz.operators.CancelableOperator): + def transform(self, dataset, selected_scalars=None): + if selected_scalars is None: + selected_scalars = (dataset.active_name,) + + pixel_spacing = dataset.spacing[0] + + column_names = ["x"] + all_x = None + fsc_columns = [] + noise_onebit = None + noise_halfbit = None + + for name in selected_scalars: + scalars = dataset.scalars(name) + if scalars is None: + continue + + image1, image2 = checkerboard_split(scalars) + x, fsc, onebit, halfbit = cal_fsc(image1, image2, pixel_spacing) + + if all_x is None: + all_x = x + noise_onebit = onebit + noise_halfbit = halfbit + + column_names.append(name) + fsc_columns.append(fsc) + + if all_x is None: + raise RuntimeError("No scalars found!") + + # Add shared noise curve columns after all FSC columns + column_names.append("One bit noise") + column_names.append("Half bit noise") + + n = len(all_x) + num_cols = 1 + len(fsc_columns) + 2 + table_data = np.empty(shape=(n, num_cols)) + table_data[:, 0] = all_x + for i, col in enumerate(fsc_columns): + table_data[:, i + 1] = col + table_data[:, -2] = noise_onebit + table_data[:, -1] = noise_halfbit + + axis_labels = ("Spatial Frequency", "Fourier Shell Correlation") + log_flags = (False, False) + table = tomviz.utils.make_spreadsheet(column_names, table_data, axis_labels, log_flags) + + return {"plot": table} diff --git a/tomviz/python/GaussianFilter.py b/tomviz/python/GaussianFilter.py index 7cf5fea49..6c3210b21 100644 --- a/tomviz/python/GaussianFilter.py +++ b/tomviz/python/GaussianFilter.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, sigma=2.0): """Apply a Gaussian filter to volume dataset.""" """Gaussian Filter blurs the image and reduces the noise and details.""" diff --git a/tomviz/python/GaussianFilterTiltSeries.py b/tomviz/python/GaussianFilterTiltSeries.py index 328ce537c..01548a757 100644 --- a/tomviz/python/GaussianFilterTiltSeries.py +++ b/tomviz/python/GaussianFilterTiltSeries.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, sigma=2.0): """Apply a Gaussian filter to tilt images.""" """Gaussian Filter blurs the image and reduces the noise and details.""" diff --git a/tomviz/python/GenerateTiltSeries.py b/tomviz/python/GenerateTiltSeries.py index 1a5dfcd25..dfad75b6c 100644 --- a/tomviz/python/GenerateTiltSeries.py +++ b/tomviz/python/GenerateTiltSeries.py @@ -1,13 +1,11 @@ import numpy as np import scipy.ndimage -from tomviz.utils import apply_to_each_array import tomviz.operators class GenerateTiltSeriesOperator(tomviz.operators.CancelableOperator): - @apply_to_each_array def transform(self, dataset, start_angle=-90.0, angle_increment=3.0, num_tilts=60): """Generate Tilt Series from Volume""" diff --git a/tomviz/python/GradientMagnitude2D_Sobel.py b/tomviz/python/GradientMagnitude2D_Sobel.py index 9370d2ec5..07c9110c7 100644 --- a/tomviz/python/GradientMagnitude2D_Sobel.py +++ b/tomviz/python/GradientMagnitude2D_Sobel.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset): """Calculate gradient magnitude of each tilt image using Sobel operator""" diff --git a/tomviz/python/GradientMagnitude_Sobel.py b/tomviz/python/GradientMagnitude_Sobel.py index 886ea742d..e3e2f6b7a 100644 --- a/tomviz/python/GradientMagnitude_Sobel.py +++ b/tomviz/python/GradientMagnitude_Sobel.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset): """Calculate 3D gradient magnitude using Sobel operator""" diff --git a/tomviz/python/HannWindow3D.py b/tomviz/python/HannWindow3D.py index 19a6a85c0..9d5ac6e22 100644 --- a/tomviz/python/HannWindow3D.py +++ b/tomviz/python/HannWindow3D.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset): import numpy as np diff --git a/tomviz/python/InvertData.py b/tomviz/python/InvertData.py index 1ae006aaf..b056d046b 100644 --- a/tomviz/python/InvertData.py +++ b/tomviz/python/InvertData.py @@ -1,4 +1,3 @@ -from tomviz.utils import apply_to_each_array import tomviz.operators @@ -7,7 +6,6 @@ class InvertOperator(tomviz.operators.CancelableOperator): - @apply_to_each_array def transform(self, dataset): import numpy as np self.progress.maximum = NUMBER_OF_CHUNKS diff --git a/tomviz/python/LaplaceFilter.py b/tomviz/python/LaplaceFilter.py index 3d6936767..c81e7b109 100644 --- a/tomviz/python/LaplaceFilter.py +++ b/tomviz/python/LaplaceFilter.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset): """Apply a Laplace filter to dataset.""" diff --git a/tomviz/python/ManualManipulation.py b/tomviz/python/ManualManipulation.py index 10867ccf2..d62ea3d38 100644 --- a/tomviz/python/ManualManipulation.py +++ b/tomviz/python/ManualManipulation.py @@ -2,8 +2,6 @@ from scipy.ndimage.interpolation import zoom from tomviz import utils -from tomviz.utils import apply_to_each_array - def apply_shift(array, shift): @@ -132,7 +130,6 @@ def apply_alignment(array, spacing, reference_spacing, reference_shape): return apply_resize(array, reference_shape) -@apply_to_each_array def transform(dataset, scaling=None, rotation=None, shift=None, align_with_reference=False, reference_spacing=None, reference_shape=None): diff --git a/tomviz/python/MedianFilter.py b/tomviz/python/MedianFilter.py index 02c754e03..9490fa1ee 100644 --- a/tomviz/python/MedianFilter.py +++ b/tomviz/python/MedianFilter.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, size=2): """Apply a Median filter to dataset.""" """ Median filter is a nonlinear filter used to reduce noise.""" diff --git a/tomviz/python/NormalizeTiltSeries.py b/tomviz/python/NormalizeTiltSeries.py index e9babaf70..28aa3012b 100644 --- a/tomviz/python/NormalizeTiltSeries.py +++ b/tomviz/python/NormalizeTiltSeries.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset): """ Normalize tilt series so that each tilt image has the same total intensity. @@ -21,6 +17,11 @@ def transform(dataset): for i in range(0, data.shape[2]): # Normalize each tilt image. - data[:, :, i] = data[:, :, i] / np.sum(data[:, :, i]) * intensity + img_sum = np.sum(data[:, :, i]) + if abs(img_sum) < 1e-8: + # Skip or we get a divide-by-zero error + continue + + data[:, :, i] = data[:, :, i] / img_sum * intensity dataset.active_scalars = data diff --git a/tomviz/python/Pad_Data.py b/tomviz/python/Pad_Data.py index 2649d16d3..4a1914caa 100644 --- a/tomviz/python/Pad_Data.py +++ b/tomviz/python/Pad_Data.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, pad_size_before=[0, 0, 0], pad_size_after=[0, 0, 0], pad_mode_index=0): """Pad dataset""" diff --git a/tomviz/python/PeronaMalikAnisotropicDiffusion.py b/tomviz/python/PeronaMalikAnisotropicDiffusion.py index 91fd9e376..4206b9741 100644 --- a/tomviz/python/PeronaMalikAnisotropicDiffusion.py +++ b/tomviz/python/PeronaMalikAnisotropicDiffusion.py @@ -1,10 +1,8 @@ -from tomviz.utils import apply_to_each_array import tomviz.operators class PeronaMalikAnisotropicDiffusion(tomviz.operators.CancelableOperator): - @apply_to_each_array def transform(self, dataset, conductance=1.0, iterations=100, timestep=0.0625): """This filter performs anisotropic diffusion on an image using diff --git a/tomviz/python/PowerSpectrumDensity.json b/tomviz/python/PowerSpectrumDensity.json new file mode 100644 index 000000000..3491afa92 --- /dev/null +++ b/tomviz/python/PowerSpectrumDensity.json @@ -0,0 +1,21 @@ +{ + "name": "PowerSpectrumDensity", + "label": "Power Spectrum Density", + "description": "", + "externalCompatible": false, + "apply_to_each_array": false, + "parameters": [ + { + "name": "selected_scalars", + "label": "Scalars", + "type": "select_scalars" + } + ], + "results": [ + { + "name": "plot", + "label": "PSD", + "type": "table" + } + ] +} diff --git a/tomviz/python/PowerSpectrumDensity.py b/tomviz/python/PowerSpectrumDensity.py new file mode 100644 index 000000000..4a24e6988 --- /dev/null +++ b/tomviz/python/PowerSpectrumDensity.py @@ -0,0 +1,84 @@ +import tomviz.operators +import tomviz.utils + +import numpy as np +import scipy.stats as stats + +def pad_to_cubic(arr): + """ + Pads a 3D numpy array to make it cubic (all dimensions equal to the largest dimension). + The padding is added to the end of each axis. + + Parameters: + arr (np.ndarray): Input 3D array. + + Returns: + np.ndarray: Cubic padded array. + """ + max_dim = max(arr.shape) + pad_widths = [(0, max_dim - s) for s in arr.shape] + return np.pad(arr, pad_widths, mode='constant', constant_values=0) + +def psd3D(image, pixel_size): + #again likes square images, you might need to pad the image to make it work. + fourier_image = np.fft.fftn(image) + fourier_amplitudes = np.abs(fourier_image)**2 + npix = image.shape[0] + kfreq = np.fft.fftfreq(npix) * npix + kfreq3D = np.meshgrid(kfreq, kfreq, kfreq) + knrm = np.sqrt(kfreq3D[0]**2 + kfreq3D[1]**2 + kfreq3D[2]**2) + + knrm = knrm.flatten() + fourier_amplitudes = fourier_amplitudes.flatten() + + kbins = np.arange(0.5, npix//2+1, 1.) + kvals = 0.5 * (kbins[1:] + kbins[:-1]) + Abins, _, _ = stats.binned_statistic(knrm, fourier_amplitudes, + statistic = "mean", + bins = kbins) + Abins *= np.pi * (kbins[1:]**2 - kbins[:-1]**2) + x = kvals/(npix*pixel_size) + return x, Abins + + +class PoreSizeDistribution(tomviz.operators.CancelableOperator): + def transform(self, dataset, selected_scalars=None): + if selected_scalars is None: + # Only the active scalars + selected_scalars = (dataset.active_name,) + + return_values = {} + + column_names = ["x"] + all_x = None + psd_columns = [] + + for name in selected_scalars: + scalars = dataset.scalars(name) + if scalars is None: + continue + + scalars = pad_to_cubic(scalars) + x, bins = psd3D(scalars, dataset.spacing[0]) + + if all_x is None: + all_x = x + column_names.append(name) + psd_columns.append(bins) + + if all_x is None: + raise RuntimeError("No scalars found!") + + n = len(all_x) + num_cols = 1 + len(psd_columns) + table_data = np.empty(shape=(n, num_cols)) + table_data[:, 0] = all_x + for i, col in enumerate(psd_columns): + table_data[:, i + 1] = col + + axis_labels = ("Spatial Frequency", "Power Spectrum Density") + log_flags = (False, True) + table = tomviz.utils.make_spreadsheet(column_names, table_data, axis_labels, log_flags) + return_values["plot"] = table + + return return_values diff --git a/tomviz/python/PyStackRegImageAlignment.json b/tomviz/python/PyStackRegImageAlignment.json index 6eb65897e..2b872eb47 100644 --- a/tomviz/python/PyStackRegImageAlignment.json +++ b/tomviz/python/PyStackRegImageAlignment.json @@ -2,6 +2,7 @@ "name" : "PyStackReg", "label" : "Auto Tilt Image Align (PyStackReg)", "description" : "Perform image alignment using PyStackReg.", + "apply_to_each_array": false, "parameters" : [ { "name" : "transform_source", diff --git a/tomviz/python/RandomParticles.py b/tomviz/python/RandomParticles.py index f499f840b..2fe406354 100644 --- a/tomviz/python/RandomParticles.py +++ b/tomviz/python/RandomParticles.py @@ -29,7 +29,7 @@ def generate_dataset(array, p_in=30.0, p_s=60.0, sparsity=0.20): f_shape = np.argsort(f_shape, axis=None) # Sort the shape image f_shape = f_shape.flatten() # Number of zero voxels - N_zero = np.int(np.round((array.size * (1 - sparsity)))) + N_zero = np.int64(np.round((array.size * (1 - sparsity)))) f_shape[N_zero:] = f_shape[N_zero] f_in[f_shape] = 0 diff --git a/tomviz/python/Recon_DFT.json b/tomviz/python/Recon_DFT.json index 04f0547c2..d8a6e5883 100644 --- a/tomviz/python/Recon_DFT.json +++ b/tomviz/python/Recon_DFT.json @@ -3,6 +3,7 @@ "label" : "Direct Fourier Reconstruction", "description" : "Reconstruct a tilt series using Direct Fourier Method (DFM). The tilt axis must be parallel to the x-direction and centered in the y-direction. The size of reconstruction will be (Nx,Ny,Ny). Reconstrucing a 512x512x512 tomogram typically takes 30-40 seconds.", "externalCompatible": false, + "apply_to_each_array": false, "children": [ { "name": "reconstruction", diff --git a/tomviz/python/Recon_DFT_constraint.json b/tomviz/python/Recon_DFT_constraint.json index 12d4d740b..388949fe4 100644 --- a/tomviz/python/Recon_DFT_constraint.json +++ b/tomviz/python/Recon_DFT_constraint.json @@ -3,6 +3,7 @@ "label" : "Reconstruct (Constraint based Direct Fourier)", "description" : "Reconstruct a tilt series using constraint-based Direct Fourier method. The tilt axis must be parallel to the x-direction and centered in the y-direction. The size of reconstruction will be (Nx,Ny,Ny). Reconstructing a 512x512x512 tomogram typically takes xxxx mins.", "externalCompatible": false, + "apply_to_each_array": false, "children": [ { "name": "reconstruction", diff --git a/tomviz/python/Recon_SIRT.json b/tomviz/python/Recon_SIRT.json index 7866145f1..740fb4b70 100644 --- a/tomviz/python/Recon_SIRT.json +++ b/tomviz/python/Recon_SIRT.json @@ -1,13 +1,7 @@ { "name" : "ReconstructSIRT", "label" : "SIRT Reconstruction", - "description" : "Reconstruct a tilt series using Simultaneous Iterative Reconstruction Techniques Technique (SIRT) with a Positivity Constraint. - -The tilt series data should be aligned prior to reconstruction and the tilt axis must be parallel to the x-direction. - -The size of reconstruction will be (Nx,Ny,Ny). The number of iterations can be specified below. - -Reconstrucing a 256x256x256 tomogram typically with Landweber's Method takes about 2 mins for 10 iterations.", + "description" : "Reconstruct a tilt series using Simultaneous Iterative Reconstruction Techniques Technique (SIRT) with a Positivity Constraint.\nThe tilt series data should be aligned prior to reconstruction and the tilt axis must be parallel to the x-direction.\nThe size of reconstruction will be (Nx,Ny,Ny). The number of iterations can be specified below.\nReconstrucing a 256x256x256 tomogram typically with Landweber's Method takes about 2 mins for 10 iterations.", "children": [ { "name": "reconstruction", diff --git a/tomviz/python/Recon_SIRT.py b/tomviz/python/Recon_SIRT.py index 91f7a47d7..ac2fe746e 100644 --- a/tomviz/python/Recon_SIRT.py +++ b/tomviz/python/Recon_SIRT.py @@ -1,13 +1,11 @@ import numpy as np import scipy.sparse as ss import tomviz.operators -from tomviz.utils import apply_to_each_array import time class ReconSirtOperator(tomviz.operators.CompletableOperator): - @apply_to_each_array def transform(self, dataset, Niter=10, stepSize=0.0001, updateMethodIndex=0, Nupdates=0): """ diff --git a/tomviz/python/Recon_WBP.py b/tomviz/python/Recon_WBP.py index 6bc728763..f9b259a8f 100644 --- a/tomviz/python/Recon_WBP.py +++ b/tomviz/python/Recon_WBP.py @@ -1,13 +1,11 @@ import numpy as np from scipy.interpolate import interp1d import tomviz.operators -from tomviz.utils import apply_to_each_array import time class ReconWBPOperator(tomviz.operators.CancelableOperator): - @apply_to_each_array def transform(self, dataset, Nrecon=None, filter=None, interp=None, Nupdates=None): """ diff --git a/tomviz/python/Recon_tomopy.json b/tomviz/python/Recon_tomopy.json new file mode 100644 index 000000000..cb5d501aa --- /dev/null +++ b/tomviz/python/Recon_tomopy.json @@ -0,0 +1,40 @@ +{ + "name" : "TomoPy", + "label" : "TomoPy Reconstruction", + "description" : "Run TomoPy reconstruction on a dataset", + "parameters" : [ + { + "name" : "algorithm", + "label" : "Algorithm", + "description" : "Reconstruction algorithm to use", + "type" : "enumeration", + "default" : 0, + "options" : [ + {"Gridrec" : "gridrec"}, + {"FBP" : "fbp"}, + {"MLEM" : "mlem"}, + {"OSPML Hybrid" : "ospml_hybrid"} + ] + }, + { + "name" : "num_iter", + "label" : "Number of Iterations", + "description" : "Number of iterations (iterative methods only)", + "type" : "int", + "default" : 5, + "minimum" : 1, + "maximum" : 1000, + "visible_if" : "algorithm == 'mlem' or algorithm == 'ospml_hybrid'" + } + ], + "children": [ + { + "name": "reconstruction", + "label": "Reconstruction", + "type": "reconstruction" + } + ], + "help" : { + "url": "reconstruction/#tomopy" + } +} diff --git a/tomviz/python/Recon_tomopy.py b/tomviz/python/Recon_tomopy.py new file mode 100644 index 000000000..304ac050d --- /dev/null +++ b/tomviz/python/Recon_tomopy.py @@ -0,0 +1,37 @@ +import numpy as np +import tomopy + + +def transform(dataset, algorithm='gridrec', num_iter=5): + data = dataset.active_scalars + tilt_axis = dataset.tilt_axis + + # TomoPy wants the tilt axis to be zero, so ensure that is true + if tilt_axis == 2: + data = np.transpose(data, (2, 0, 1)) + + # Normalize to [0, 1] + data = data.astype(np.float32) + data = (data - data.min()) / (data.max() - data.min()) + + angles_rad = np.deg2rad(dataset.tilt_angles) + center = data.shape[2] // 2 + + # Reconstruct + recon_kwargs = {} + if algorithm in ('mlem', 'ospml_hybrid'): + recon_kwargs['num_iter'] = num_iter + + rec = tomopy.recon(data, angles_rad, center=center, algorithm=algorithm, + **recon_kwargs) + + # Apply circular mask + rec = tomopy.circ_mask(rec, axis=0, ratio=0.95, val=0.0) + + # Transpose back to expected Tomviz format + rec = np.transpose(rec, (2, 1, 0)) + + child = dataset.create_child_dataset() + child.active_scalars = rec + + return {'reconstruction': child} diff --git a/tomviz/python/Recon_tomopy_fxi.json b/tomviz/python/Recon_tomopy_fxi.json deleted file mode 100644 index 2dbf506ff..000000000 --- a/tomviz/python/Recon_tomopy_fxi.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "name" : "FXI TomoPy Reconstruction", - "label" : "FXI TomoPy Reconstruction", - "description" : "Run FXI TomoPy Reconstruction on a dataset", - "widget": "FxiWorkflowWidget", - "externalCompatible": false, - "parameters" : [ - { - "name" : "rotation_center", - "label" : "Rotation Center", - "description" : "The center of rotation of the dataset", - "type" : "double", - "default" : 0.0, - "precision" : 3 - }, - { - "name" : "slice_start", - "label" : "Slice Start", - "description" : "The first slice to use for reconstruction", - "type" : "int", - "default" : 0 - }, - { - "name" : "slice_stop", - "label" : "Slice Stop", - "description" : "The last slice to use for reconstruction", - "type" : "int", - "default" : 0 - }, - { - "name" : "denoise_flag", - "label" : "Denoise Flag", - "description" : "Whether to apply Wiener denoise", - "type" : "bool", - "default" : false - }, - { - "name" : "denoise_level", - "label" : "Denoise Level", - "description" : "The level to apply to tomopy.prep.stripe.remove_stripe_fw", - "type" : "int", - "default" : 9 - }, - { - "name" : "dark_scale", - "label" : "Dark Scale", - "description" : "The scaling that should be applied to the dark image", - "type" : "double", - "default" : 1 - } - ], - "children": [ - { - "name": "reconstruction", - "label": "Reconstruction", - "type": "reconstruction" - } - ] -} diff --git a/tomviz/python/Recon_tomopy_fxi.py b/tomviz/python/Recon_tomopy_fxi.py deleted file mode 100644 index 83f5818f7..000000000 --- a/tomviz/python/Recon_tomopy_fxi.py +++ /dev/null @@ -1,426 +0,0 @@ -import numpy as np - - -def transform(dataset, rotation_center=0, slice_start=0, slice_stop=1, - denoise_flag=0, denoise_level=9, dark_scale=1): - - # Get the current volume as a numpy array. - array = dataset.active_scalars - - dark = dataset.dark - white = dataset.white - angles = dataset.tilt_angles - tilt_axis = dataset.tilt_axis - - # TomoPy wants the tilt axis to be zero, so ensure that is true - if tilt_axis == 2: - order = [2, 1, 0] - array = np.transpose(array, order) - - if dark is not None: - dark = np.transpose(dark, order) - - if white is not None: - white = np.transpose(white, order) - - if angles is None: - raise Exception('No angles found') - - # FIXME: Are these right? - recon_input = { - 'img_tomo': array, - 'angle': angles, - } - - if dark is not None: - recon_input['img_dark_avg'] = dark - - if white is not None: - recon_input['img_bkg_avg'] = white - - kwargs = { - 'f': recon_input, - 'rot_cen': rotation_center, - 'sli': [slice_start, slice_stop], - 'denoise_flag': denoise_flag, - 'denoise_level': denoise_level, - 'dark_scale': dark_scale, - } - - # Perform the reconstruction - output = recon(**kwargs) - - # Set the transformed array - child = dataset.create_child_dataset() - child.active_scalars = output - - return_values = {} - return_values['reconstruction'] = child - return return_values - - -def test_rotations(dataset, start=None, stop=None, steps=None, sli=0, - denoise_flag=0, denoise_level=9, dark_scale=1): - # Get the current volume as a numpy array. - array = dataset.active_scalars - - dark = dataset.dark - white = dataset.white - angles = dataset.tilt_angles - tilt_axis = dataset.tilt_axis - - # TomoPy wants the tilt axis to be zero, so ensure that is true - if tilt_axis == 2: - order = [2, 1, 0] - array = np.transpose(array, order) - - if dark is not None: - dark = np.transpose(dark, order) - - if white is not None: - white = np.transpose(white, order) - - if angles is None: - raise Exception('No angles found') - - recon_input = { - 'img_tomo': array, - 'angle': angles, - } - - if dark is not None: - recon_input['img_dark_avg'] = dark - - if white is not None: - recon_input['img_bkg_avg'] = white - - kwargs = { - 'f': recon_input, - 'start': start, - 'stop': stop, - 'steps': steps, - 'sli': sli, - 'denoise_flag': denoise_flag, - 'denoise_level': denoise_level, - 'dark_scale': dark_scale, - } - - if dark is None or white is None: - kwargs['txm_normed_flag'] = True - - # Perform the reconstruction - images, centers = rotcen_test(**kwargs) - - child = dataset.create_child_dataset() - child.active_scalars = images - - return_values = {} - return_values['images'] = child - return_values['centers'] = centers.astype(float).tolist() - return return_values - - -def find_nearest(data, value): - data = np.array(data) - return np.abs(data - value).argmin() - - -def recon(f, rot_cen, sli=[], binning=None, zero_flag=0, block_list=[], - bkg_level=0, txm_normed_flag=0, read_full_memory=0, denoise_flag=0, - denoise_level=9, dark_scale=1): - ''' - reconstruct 3D tomography - Inputs: - -------- - f: dict - input dictionary of scan - rot_cen: float - rotation center - sli: list - a range of slice to recontruct, e.g. [100:300] - bingning: int - binning the reconstruted 3D tomographic image - zero_flag: bool - if 1: set negative pixel value to 0 - if 0: keep negative pixel value - block_list: list - a list of index for the projections that will not be considered in - reconstruction - - ''' - import tomopy - - tmp = np.array(f['img_tomo'][0]) - s = [1, tmp.shape[0], tmp.shape[1]] - - if len(sli) == 0: - sli = [0, s[1]] - elif len(sli) == 1 and sli[0] >= 0 and sli[0] <= s[1]: - sli = [sli[0], sli[0]+1] - elif len(sli) == 2 and sli[0] >= 0 and sli[1] <= s[1]: - pass - else: - print('non valid slice id, will take reconstruction for the whole', - 'object') - ''' - if len(col) == 0: - col = [0, s[2]] - elif len(col) == 1 and col[0] >=0 and col[0] <= s[2]: - col = [col[0], col[0]+1] - elif len(col) == 2 and col[0] >=0 and col[1] <= s[2]: - col_info = '_col_{}_{}'.format(col[0], col[1]) - else: - col = [0, s[2]] - print('invalid col id, will take reconstruction for the whole object') - ''' - # rot_cen = rot_cen - col[0] - theta = np.array(f['angle']) / 180.0 * np.pi - pos = find_nearest(theta, theta[0]+np.pi) - block_list = list(block_list) + list(np.arange(pos+1, len(theta))) - allow_list = list(set(np.arange(len(theta))) - set(block_list)) - theta = theta[allow_list] - tmp = np.squeeze(np.array(f['img_tomo'][0])) - s = tmp.shape - - sli_step = 40 - sli_total = np.arange(sli[0], sli[1]) - binning = binning if binning else 1 - - n_steps = int(len(sli_total) / sli_step) - rot_cen = rot_cen * 1.0 / binning - - if read_full_memory: - sli_step = sli[1] - sli[0] - n_steps = 1 - - if denoise_flag: - add_slice = min(sli_step // 2, 20) - wiener_param = {} - psf = 2 - wiener_param['psf'] = np.ones([psf, psf])/(psf**2) - wiener_param['reg'] = None - wiener_param['balance'] = 0.3 - wiener_param['is_real'] = True - wiener_param['clip'] = True - else: - add_slice = 0 - wiener_param = [] - - try: - rec = np.zeros([s[0] // binning, s[1] // binning, s[1] // binning], - dtype=np.float32) - except Exception: - print('Cannot allocate memory') - - ''' - # first sli_step slices: will not do any denoising - prj_norm = proj_normalize(f, [0, sli_step], txm_normed_flag, binning, - allow_list, bkg_level) - prj_norm = wiener_denoise(prj_norm, wiener_param, denoise_flag) - rec_sub = tomopy.recon(prj_norm, theta, center=rot_cen, - algorithm='gridrec') - rec[0 : rec_sub.shape[0]] = rec_sub - ''' - # following slices - for i in range(n_steps): - if i == n_steps-1: - sli_sub = [i*sli_step+sli_total[0], len(sli_total)+sli[0]] - current_sli = sli_sub - else: - sli_sub = [i*sli_step+sli_total[0], (i+1)*sli_step+sli_total[0]] - current_sli = [sli_sub[0]-add_slice, sli_sub[1]+add_slice] - print(f'recon {i+1}/{n_steps}: sli = [{sli_sub[0]},', - f'{sli_sub[1]}] ... ') - prj_norm = proj_normalize(f, current_sli, txm_normed_flag, binning, - allow_list, bkg_level, denoise_level, - dark_scale) - prj_norm = wiener_denoise(prj_norm, wiener_param, denoise_flag) - if i != 0 and i != n_steps - 1: - start = add_slice // binning - stop = sli_step // binning + start - prj_norm = prj_norm[:, start:stop] - rec_sub = tomopy.recon(prj_norm, theta, center=rot_cen, - algorithm='gridrec') - start = i * sli_step // binning - rec[start: start + rec_sub.shape[0]] = rec_sub - - if zero_flag: - rec[rec < 0] = 0 - - return rec - - -def wiener_denoise(prj_norm, wiener_param, denoise_flag): - import skimage.restoration as skr - if not denoise_flag or not len(wiener_param): - return prj_norm - - ss = prj_norm.shape - psf = wiener_param['psf'] - reg = wiener_param['reg'] - balance = wiener_param['balance'] - is_real = wiener_param['is_real'] - clip = wiener_param['clip'] - for j in range(ss[0]): - prj_norm[j] = skr.wiener(prj_norm[j], psf=psf, reg=reg, - balance=balance, is_real=is_real, clip=clip) - return prj_norm - - -def proj_normalize(f, sli, txm_normed_flag, binning, allow_list=[], - bkg_level=0, denoise_level=9, dark_scale=1): - import tomopy - - img_tomo = np.array(f['img_tomo'][:, sli[0]:sli[1], :]) - try: - img_bkg = np.array(f['img_bkg_avg'][:, sli[0]:sli[1]]) - except Exception: - img_bkg = [] - try: - img_dark = np.array(f['img_dark_avg'][:, sli[0]:sli[1]]) - except Exception: - img_dark = [] - if len(img_dark) == 0 or len(img_bkg) == 0 or txm_normed_flag == 1: - prj = img_tomo - else: - prj = ((img_tomo - img_dark / dark_scale) / - (img_bkg - img_dark / dark_scale)) - - s = prj.shape - prj = bin_ndarray(prj, (s[0], int(s[1] / binning), int(s[2] / binning)), - 'mean') - prj_norm = -np.log(prj) - prj_norm[np.isnan(prj_norm)] = 0 - prj_norm[np.isinf(prj_norm)] = 0 - prj_norm[prj_norm < 0] = 0 - prj_norm = prj_norm[allow_list] - prj_norm = tomopy.prep.stripe.remove_stripe_fw(prj_norm, - level=denoise_level, - wname='db5', sigma=1, - pad=True) - prj_norm -= bkg_level - return prj_norm - - -def bin_ndarray(ndarray, new_shape=None, operation='mean'): - """ - Bins an ndarray in all axes based on the target shape, by summing or - averaging. - - Number of output dimensions must match number of input dimensions and - new axes must divide old ones. - - Example - ------- - >>> m = np.arange(0,100,1).reshape((10,10)) - >>> n = bin_ndarray(m, new_shape=(5,5), operation='sum') - >>> print(n) - - [[ 22 30 38 46 54] - [102 110 118 126 134] - [182 190 198 206 214] - [262 270 278 286 294] - [342 350 358 366 374]] - - """ - if new_shape is None: - s = np.array(ndarray.shape) - s1 = np.int32(s / 2) - new_shape = tuple(s1) - operation = operation.lower() - if operation not in ['sum', 'mean']: - raise ValueError("Operation not supported.") - if ndarray.ndim != len(new_shape): - raise ValueError("Shape mismatch: {} -> {}".format(ndarray.shape, - new_shape)) - compression_pairs = [(d, c // d) for d, c in zip(new_shape, - ndarray.shape)] - flattened = [x for p in compression_pairs for x in p] - ndarray = ndarray.reshape(flattened) - for i in range(len(new_shape)): - op = getattr(ndarray, operation) - ndarray = op(-1*(i+1)) - return ndarray - - -def rotcen_test(f, start=None, stop=None, steps=None, sli=0, block_list=[], - print_flag=1, bkg_level=0, txm_normed_flag=0, denoise_flag=0, - denoise_level=9, dark_scale=1): - - import tomopy - - tmp = np.array(f['img_tomo'][0]) - s = [1, tmp.shape[0], tmp.shape[1]] - - if denoise_flag: - import skimage.restoration as skr - addition_slice = 100 - psf = 2 - psf = np.ones([psf, psf])/(psf**2) - reg = None - balance = 0.3 - is_real = True - clip = True - else: - addition_slice = 0 - - if sli == 0: - sli = int(s[1] / 2) - - sli_exp = [np.max([0, sli - addition_slice // 2]), - np.min([sli + addition_slice // 2 + 1, s[1]])] - - theta = np.array(f['angle']) / 180.0 * np.pi - - img_tomo = np.array(f['img_tomo'][:, sli_exp[0]:sli_exp[1], :]) - - if txm_normed_flag: - prj = img_tomo - else: - img_bkg = np.array(f['img_bkg_avg'][:, sli_exp[0]:sli_exp[1], :]) - img_dark = np.array(f['img_dark_avg'][:, sli_exp[0]:sli_exp[1], :]) - prj = ((img_tomo - img_dark / dark_scale) / - (img_bkg - img_dark / dark_scale)) - - prj_norm = -np.log(prj) - prj_norm[np.isnan(prj_norm)] = 0 - prj_norm[np.isinf(prj_norm)] = 0 - prj_norm[prj_norm < 0] = 0 - - prj_norm -= bkg_level - - prj_norm = tomopy.prep.stripe.remove_stripe_fw(prj_norm, - level=denoise_level, - wname='db5', sigma=1, - pad=True) - if denoise_flag: # denoise using wiener filter - ss = prj_norm.shape - for i in range(ss[0]): - prj_norm[i] = skr.wiener(prj_norm[i], psf=psf, reg=reg, - balance=balance, is_real=is_real, - clip=clip) - - s = prj_norm.shape - if len(s) == 2: - prj_norm = prj_norm.reshape(s[0], 1, s[1]) - s = prj_norm.shape - - pos = find_nearest(theta, theta[0]+np.pi) - block_list = list(block_list) + list(np.arange(pos+1, len(theta))) - if len(block_list): - allow_list = list(set(np.arange(len(prj_norm))) - set(block_list)) - prj_norm = prj_norm[allow_list] - theta = theta[allow_list] - if start is None or stop is None or steps is None: - start = int(s[2]/2-50) - stop = int(s[2]/2+50) - steps = 26 - cen = np.linspace(start, stop, steps) - img = np.zeros([len(cen), s[2], s[2]]) - for i in range(len(cen)): - if print_flag: - print('{}: rotcen {}'.format(i+1, cen[i])) - img[i] = tomopy.recon(prj_norm[:, addition_slice:addition_slice + 1], - theta, center=cen[i], algorithm='gridrec') - img = tomopy.circ_mask(img, axis=0, ratio=0.8) - return img, cen diff --git a/tomviz/python/Recon_tomopy_gridrec.json b/tomviz/python/Recon_tomopy_gridrec.json deleted file mode 100644 index 6a143233e..000000000 --- a/tomviz/python/Recon_tomopy_gridrec.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name" : "TomoPy Gridrec Reconstruction", - "label" : "TomoPy Gridrec Reconstruction", - "description" : "Run TomoPy Gridrec Reconstruction on a dataset", - "parameters" : [ - { - "name" : "rot_center", - "label" : "Rotation Center", - "description" : "The center of rotation of the dataset", - "type" : "double", - "default" : 0.0, - "precision" : 3 - }, - { - "name" : "tune_rot_center", - "label" : "Tune Rotation Center", - "description" : "Allow tomopy to tune the center of rotation", - "type" : "bool", - "default" : true - } - ], - "children": [ - { - "name": "reconstruction", - "label": "Reconstruction", - "type": "reconstruction" - } - ], - "help" : { - "url": "reconstruction/#tomopy" - } -} diff --git a/tomviz/python/Recon_tomopy_gridrec.py b/tomviz/python/Recon_tomopy_gridrec.py deleted file mode 100644 index f09c36837..000000000 --- a/tomviz/python/Recon_tomopy_gridrec.py +++ /dev/null @@ -1,77 +0,0 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array -def transform(dataset, rot_center=0, tune_rot_center=True): - """Reconstruct sinograms using the tomopy gridrec algorithm - - Typically, a data exchange file would be loaded for this - reconstruction. This operation will attempt to perform - flat-field correction of the raw data using the dark and - white background data found in the data exchange file. - - This operator also requires either the tomviz/tomopy-pipeline - docker image, or a python environment with tomopy installed. - """ - - import numpy as np - import tomopy - - # Get the current volume as a numpy array. - array = dataset.active_scalars - - dark = dataset.dark - white = dataset.white - angles = dataset.tilt_angles - tilt_axis = dataset.tilt_axis - - # TomoPy wants the tilt axis to be zero, so ensure that is true - if tilt_axis == 2: - order = [2, 1, 0] - array = np.transpose(array, order) - if dark is not None and white is not None: - dark = np.transpose(dark, order) - white = np.transpose(white, order) - - if angles is not None: - # tomopy wants radians - theta = np.radians(angles) - else: - # Assume it is equally spaced between 0 and 180 degrees - theta = tomopy.angles(array.shape[0]) - - # Perform flat-field correction of raw data - if white is not None and dark is not None: - array = tomopy.normalize(array, white, dark, cutoff=1.4) - - if rot_center == 0: - # Try to find it automatically - init = array.shape[2] / 2.0 - rot_center = tomopy.find_center(array, theta, init=init, ind=0, - tol=0.5) - elif tune_rot_center: - # Tune the center - rot_center = tomopy.find_center(array, theta, init=rot_center, ind=0, - tol=0.5) - - # Calculate -log(array) - array = tomopy.minus_log(array) - - # Remove nan, neg, and inf values - array = tomopy.remove_nan(array, val=0.0) - array = tomopy.remove_neg(array, val=0.00) - array[np.where(array == np.inf)] = 0.00 - - # Perform the reconstruction - array = tomopy.recon(array, theta, center=rot_center, algorithm='gridrec') - - # Mask each reconstructed slice with a circle. - array = tomopy.circ_mask(array, axis=0, ratio=0.95) - - # Set the transformed array - child = dataset.create_child_dataset() - child.active_scalars = array - - return_values = {} - return_values['reconstruction'] = child - return return_values diff --git a/tomviz/python/RemoveArrays.json b/tomviz/python/RemoveArrays.json new file mode 100644 index 000000000..e75a48aa0 --- /dev/null +++ b/tomviz/python/RemoveArrays.json @@ -0,0 +1,14 @@ +{ + "name": "RemoveArrays", + "label": "Remove Arrays", + "description": "Select which scalar arrays to keep. All unselected arrays will be removed from the dataset.", + "apply_to_each_array": false, + "parameters": [ + { + "name": "selected_scalars", + "label": "Arrays to Keep", + "type": "select_scalars", + "show_apply_all": false + } + ] +} diff --git a/tomviz/python/RemoveArrays.py b/tomviz/python/RemoveArrays.py new file mode 100644 index 000000000..99a1f78f2 --- /dev/null +++ b/tomviz/python/RemoveArrays.py @@ -0,0 +1,10 @@ +def transform(dataset, selected_scalars=None): + if selected_scalars is None: + selected_scalars = (dataset.active_name,) + + if len(selected_scalars) == 0: + raise RuntimeError("At least one scalar array must be selected to keep.") + + to_remove = [n for n in dataset.scalars_names if n not in selected_scalars] + for name in to_remove: + dataset.remove_scalars(name) diff --git a/tomviz/python/RemoveBadPixelsTiltSeries.py b/tomviz/python/RemoveBadPixelsTiltSeries.py index bca96db04..2e15cb47d 100644 --- a/tomviz/python/RemoveBadPixelsTiltSeries.py +++ b/tomviz/python/RemoveBadPixelsTiltSeries.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, threshold=None): """Remove bad pixels in tilt series.""" diff --git a/tomviz/python/Resample.py b/tomviz/python/Resample.py index 0dff1de59..615ddf604 100644 --- a/tomviz/python/Resample.py +++ b/tomviz/python/Resample.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, resampling_factor=[1, 1, 1]): """Resample dataset""" diff --git a/tomviz/python/Rotate3D.py b/tomviz/python/Rotate3D.py index 9317c93da..7969890c5 100644 --- a/tomviz/python/Rotate3D.py +++ b/tomviz/python/Rotate3D.py @@ -1,7 +1,6 @@ from tomviz import utils -@utils.apply_to_each_array def transform(dataset, rotation_angle=90.0, rotation_axis=0): import numpy as np diff --git a/tomviz/python/RotationAlign.py b/tomviz/python/RotationAlign.py index ade67c686..fd51c03d4 100644 --- a/tomviz/python/RotationAlign.py +++ b/tomviz/python/RotationAlign.py @@ -1,10 +1,6 @@ # Perform alignment to the estimated rotation axis # # Developed as part of the tomviz project (www.tomviz.com). -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, SHIFT=None, rotation_angle=90.0, tilt_axis=0): from tomviz import utils from scipy import ndimage diff --git a/tomviz/python/SetNegativeVoxelsToZero.py b/tomviz/python/SetNegativeVoxelsToZero.py index be2787c23..2db321cdd 100644 --- a/tomviz/python/SetNegativeVoxelsToZero.py +++ b/tomviz/python/SetNegativeVoxelsToZero.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset): """Set negative voxels to zero""" diff --git a/tomviz/python/Shift3D.py b/tomviz/python/Shift3D.py index 39b2443ff..eac00fd7d 100644 --- a/tomviz/python/Shift3D.py +++ b/tomviz/python/Shift3D.py @@ -1,10 +1,6 @@ # Shift a 3D dataset using SciPy Interpolation libraries. # # Developed as part of the tomviz project (www.tomviz.com). -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, SHIFT=None): from scipy import ndimage diff --git a/tomviz/python/ShiftRotationCenter_tomopy.json b/tomviz/python/ShiftRotationCenter_tomopy.json new file mode 100644 index 000000000..294d6e51d --- /dev/null +++ b/tomviz/python/ShiftRotationCenter_tomopy.json @@ -0,0 +1,45 @@ +{ + "name" : "Shift Rotation Center", + "label" : "Shift Rotation Center", + "description" : "Shift projections so the rotation center is at the image midpoint", + "widget": "ShiftRotationCenterWidget", + "apply_to_each_array": false, + "externalCompatible": false, + "parameters" : [ + { + "name" : "rotation_center", + "label" : "Center Offset", + "description" : "Offset of the rotation center from the image midpoint (0 = centered)", + "type" : "double", + "default" : 0.0, + "precision" : 3 + }, + { + "name" : "transform_source", + "label" : "Transformation Source", + "description" : "The source of the rotation center value (i.e., set it manually or load from a file)", + "type" : "enumeration", + "default" : 0, + "options" : [ + {"Manual" : "manual"}, + {"Load From File": "from_file"} + ] + }, + { + "name" : "transform_file", + "label" : "Transform File", + "description": "Transformation file (npz format) to apply", + "type" : "file", + "filter": "NPZ files (*npz)", + "visible_if" : "transform_source == 'from_file'" + }, + { + "name" : "transforms_save_file", + "label" : "Save Transformations File", + "description": "Save transformations file to apply to other datasets later. Leave this blank to not save it.", + "type" : "save_file", + "filter": "NPZ files (*npz)", + "visible_if" : "transform_source == 'manual'" + } + ] +} diff --git a/tomviz/python/ShiftRotationCenter_tomopy.py b/tomviz/python/ShiftRotationCenter_tomopy.py new file mode 100644 index 000000000..39eaad6bf --- /dev/null +++ b/tomviz/python/ShiftRotationCenter_tomopy.py @@ -0,0 +1,170 @@ +import numpy as np + + +def transform(dataset, rotation_center=0, transform_source='manual', + transform_file='', transforms_save_file=''): + from scipy.ndimage import shift as ndshift + + if transform_source == 'from_file': + with np.load(transform_file) as f: + saved_center = f['rotation_center'] + saved_spacing = f['spacing'] + + # Scale pixel offset by spacing ratio so it applies correctly + # to datasets with different voxel sizes. + rotation_center = float( + saved_center * saved_spacing[1] / dataset.spacing[1] + ) + + # rotation_center is an offset from the image midpoint in pixels. + # A positive offset means the rotation center is right of center, + # so we shift left (negative) to bring it to center. + pixel_shift = -rotation_center + + # Shift the entire volume along the detector horizontal axis + shift_vec = [0.0, 0.0, 0.0] + shift_vec[1] = pixel_shift + + for name in dataset.scalars_names: + array = dataset.scalars(name) + result = ndshift(array, shift_vec, mode='constant') + dataset.set_scalars(name, result) + + if transform_source == 'manual' and transforms_save_file: + np.savez( + transforms_save_file, + rotation_center=rotation_center, + spacing=dataset.spacing, + ) + print('Saved transforms file to:', transforms_save_file) + + +def test_rotations(dataset, start=None, stop=None, steps=None, sli=0, + algorithm='gridrec', num_iter=15, circ_mask_ratio=0.8): + # Get the current volume as a numpy array. + array = dataset.active_scalars + + angles = dataset.tilt_angles + tilt_axis = dataset.tilt_axis + + # TomoPy wants the tilt axis to be zero, so ensure that is true + if tilt_axis == 2: + array = np.transpose(array, [2, 0, 1]) + + if angles is None: + raise Exception('No angles found') + + # start/stop are already in pixel offsets + recon_input = { + 'img_tomo': array, + 'angle': angles, + } + + kwargs = { + 'f': recon_input, + 'start': start, + 'stop': stop, + 'steps': steps, + 'sli': sli, + 'algorithm': algorithm, + 'num_iter': num_iter, + 'circ_mask_ratio': circ_mask_ratio, + } + + # Perform the test rotations + images, centers = rotcen_test(**kwargs) + + # Compute quality metrics + qia_values, qia_best = Qia(images) + qn_values, qn_best = Qn(images) + + child = dataset.create_child_dataset() + child.active_scalars = images + + return_values = {} + return_values['images'] = child + return_values['centers'] = centers.astype(float).tolist() + return_values['qia'] = qia_values + return_values['qn'] = qn_values + return return_values + + +def rotcen_test(f, start=None, stop=None, steps=None, sli=0, + algorithm='gridrec', num_iter=15, circ_mask_ratio=0.8): + + import tomopy + + tmp = np.array(f['img_tomo'][0]) + s = [1, tmp.shape[0], tmp.shape[1]] + + if sli == 0: + sli = int(s[1] / 2) + + theta = np.array(f['angle']) / 180.0 * np.pi + + img_tomo = np.array(f['img_tomo'][:, sli:sli + 1, :]) + + img_tomo[np.isnan(img_tomo)] = 0 + img_tomo[np.isinf(img_tomo)] = 0 + + s = img_tomo.shape + if len(s) == 2: + img_tomo = img_tomo.reshape(s[0], 1, s[1]) + s = img_tomo.shape + + # Convert to absolute + start_abs = int(round(s[2] / 2 + start)) + stop_abs = int(round(s[2] / 2 + stop)) + cen = np.linspace(start_abs, stop_abs, steps) + img = np.zeros([len(cen), s[2], s[2]]) + + recon_kwargs = {} + if algorithm not in ('gridrec', 'fbp'): + recon_kwargs['num_iter'] = num_iter + + for i in range(len(cen)): + print(f'{i + 1}: rotcen {cen[i]}') + img[i] = tomopy.recon(img_tomo, theta, center=cen[i], + algorithm=algorithm, **recon_kwargs) + img = tomopy.circ_mask(img, axis=0, ratio=circ_mask_ratio) + + # Convert back to relative to the center + cen -= s[2] / 2 + return img, cen + + +def Qia(rec, opt='max'): + """Integral of absolute value quality metric.""" + qlist = [] + mavg = [] + for i in range(rec.shape[0]): + m = rec[i].sum() + mavg.append(m) + mavg = np.mean(mavg) + + for i in range(rec.shape[0]): + t = np.abs(rec[i]).sum() + qlist.append(t / mavg) + if opt == 'max': + num = qlist.index(max(qlist)) + else: + num = qlist.index(min(qlist)) + return qlist, num + + +def Qn(rec): + """Integral of negativity quality metric.""" + qlist = [] + mavg = [] + for i in range(rec.shape[0]): + m = rec[i].sum() + mavg.append(m) + mavg = np.mean(mavg) + + for i in range(rec.shape[0]): + table = -1 * rec[i] > 0 + testtable = rec[i] * table + t = testtable.sum() + qlist.append(-1 * t / mavg) + num = qlist.index(max(qlist)) + return qlist, num diff --git a/tomviz/python/ShiftTiltSeriesRandomly.json b/tomviz/python/ShiftTiltSeriesRandomly.json index 603d136cb..0f1649788 100644 --- a/tomviz/python/ShiftTiltSeriesRandomly.json +++ b/tomviz/python/ShiftTiltSeriesRandomly.json @@ -2,6 +2,7 @@ "name" : "ShiftTiltSeries", "label" : "Shift Tilt Series Randomly", "description" : "Apply random integer shifts to tilt series. The maximum shift can be specified betow.", + "apply_to_each_array": false, "parameters" : [ { "name" : "maxShift", diff --git a/tomviz/python/Shift_Stack_Uniformly.py b/tomviz/python/Shift_Stack_Uniformly.py index 5087fbfe0..aa4c1b023 100644 --- a/tomviz/python/Shift_Stack_Uniformly.py +++ b/tomviz/python/Shift_Stack_Uniformly.py @@ -1,11 +1,6 @@ # Shift all data uniformly (it is a rolling shift). # # Developed as part of the tomviz project (www.tomviz.com). - -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, shift=[0, 0, 0]): import numpy as np diff --git a/tomviz/python/SimilarityMetrics.json b/tomviz/python/SimilarityMetrics.json new file mode 100644 index 000000000..217d29d04 --- /dev/null +++ b/tomviz/python/SimilarityMetrics.json @@ -0,0 +1,38 @@ +{ + "name" : "SimilarityMetrics", + "label" : "Similarity Metrics", + "description" : "Compute similarity metrics between the current dataset and a reference dataset.", + "apply_to_each_array": false, + "parameters" : [ + { + "name" : "selected_scalars", + "label" : "Scalars", + "type" : "select_scalars" + }, + { + "name" : "axis", + "label" : "Axis", + "description" : "The axis along which to process slices", + "type" : "enumeration", + "default" : 2, + "options" : [ + {"X" : 0}, + {"Y" : 1}, + {"Z" : 2} + ] + }, + { + "name" : "reference_dataset", + "label" : "Reference Dataset", + "description" : "The reference dataset to compare against", + "type" : "dataset" + } + ], + "results" : [ + { + "name" : "similarity", + "label" : "Similarity", + "type" : "table" + } + ] +} diff --git a/tomviz/python/SimilarityMetrics.py b/tomviz/python/SimilarityMetrics.py new file mode 100644 index 000000000..a61b0e962 --- /dev/null +++ b/tomviz/python/SimilarityMetrics.py @@ -0,0 +1,181 @@ +import tomviz.operators + + +class SimilarityMetrics(tomviz.operators.CancelableOperator): + + def transform(self, dataset, reference_dataset=None, selected_scalars=None, axis=2): + """Compute similarity metrics between the current dataset and a + reference dataset. + """ + import numpy as np # noqa: F811 + from skimage.transform import resize + from skimage.metrics import structural_similarity, mean_squared_error + + if reference_dataset is None: + raise Exception("A probe dataset is required.") + + if selected_scalars is None: + selected_scalars = dataset.scalars_names + + axis_index = axis + phase_scalars = None + + for name in reference_dataset.scalars_names: + if "phase" in name.lower(): + phase_scalars = reference_dataset.scalars(name) + break + + dataset_scalars_names = set(dataset.scalars_names) + reference_scalars_names = set(reference_dataset.scalars_names) + + dataset_scan_ids = dataset.scan_ids + reference_scan_ids = reference_dataset.scan_ids + + dataset_scan_id_to_slice = {} + reference_scan_id_to_slice = {} + + dataset_shape = dataset.active_scalars.shape + reference_shape = reference_dataset.active_scalars.shape + + dataset_n_slices = dataset_shape[axis_index] + reference_n_slices = reference_shape[axis_index] + + if dataset_scan_ids is None or reference_scan_ids is None: + for i in range(dataset_n_slices): + dataset_scan_id_to_slice[i] = i + + for i in range(reference_n_slices): + reference_scan_id_to_slice[i] = i + + else: + for i, scan_id in enumerate(dataset_scan_ids): + dataset_scan_id_to_slice[scan_id] = i + + for i, scan_id in enumerate(reference_scan_ids): + reference_scan_id_to_slice[scan_id] = i + + slice_indices = [] + + for i, (scan_id, dataset_slice_index) in enumerate( + dataset_scan_id_to_slice.items() + ): + reference_slice_index = reference_scan_id_to_slice.get(scan_id) + + if reference_slice_index is None: + continue + + slice_indices.append((dataset_slice_index, reference_slice_index)) + + # When comparing slices between the two datasets we will resize them to a common size + common_slice_shape = [] + for i in range(3): + if i != axis_index: + common_slice_shape.append(max(dataset_shape[i], reference_shape[i])) + + common_slice_shape = tuple(common_slice_shape) + + column_names = ["x"] + all_table_data = None + mse_columns = [] + ssim_columns = [] + + for name in selected_scalars: + scalars = dataset.scalars(name) if name in dataset_scalars_names else None + if scalars is None: + continue + + reference_scalars = ( + reference_dataset.scalars(name) + if name in reference_scalars_names + else phase_scalars + ) + if reference_scalars is None: + continue + + n_slices = len(slice_indices) + + self.progress.value = 0 + self.progress.maximum = n_slices + self.progress.message = f"Array: {name}" + + mse_data = np.empty(n_slices) + ssim_data = np.empty(n_slices) + + for output_slice_index, ( + dataset_slice_index, + reference_slice_index, + ) in enumerate(slice_indices): + if self.canceled: + return + + self.progress.value = output_slice_index + + dataset_slice_indexing_list = [slice(None)] * scalars.ndim + dataset_slice_indexing_list[axis_index] = dataset_slice_index + dataset_slice_indexing = tuple(dataset_slice_indexing_list) + + reference_slice_indexing_list = [slice(None)] * scalars.ndim + reference_slice_indexing_list[axis_index] = reference_slice_index + reference_slice_indexing = tuple(reference_slice_indexing_list) + + scalars_slice = scalars[dataset_slice_indexing] + reference_slice = reference_scalars[reference_slice_indexing] + + if scalars_slice.shape == common_slice_shape: + resized_scalars_slice = scalars_slice + else: + resized_scalars_slice = resize(scalars_slice, common_slice_shape) + + if reference_slice.shape == common_slice_shape: + resized_reference_slice = reference_slice + else: + resized_reference_slice = resize( + reference_slice, common_slice_shape + ) + + # normalize the arrays + resized_scalars_slice = ( + resized_scalars_slice - np.min(resized_scalars_slice) + ) / np.ptp(resized_scalars_slice) + resized_reference_slice = ( + resized_reference_slice - np.min(resized_reference_slice) + ) / np.ptp(resized_reference_slice) + + mse_data[output_slice_index] = mean_squared_error( + resized_reference_slice, resized_scalars_slice + ) + ssim_data[output_slice_index] = structural_similarity( + resized_reference_slice, resized_scalars_slice, data_range=1.0 + ) + + self.progress.value = n_slices + + if all_table_data is None: + all_table_data = np.arange(n_slices, dtype=float) + + column_names.append(f"{name} MSE") + column_names.append(f"{name} SSIM") + mse_columns.append(mse_data) + ssim_columns.append(ssim_data) + + if all_table_data is None: + raise RuntimeError("No scalars found!") + + n = len(all_table_data) + num_cols = 1 + len(mse_columns) + len(ssim_columns) + table_data = np.empty(shape=(n, num_cols)) + table_data[:, 0] = all_table_data + for i, (mse_col, ssim_col) in enumerate(zip(mse_columns, ssim_columns)): + table_data[:, 1 + 2 * i] = mse_col + table_data[:, 2 + 2 * i] = ssim_col + + # Return similarity table as operator result + return_values = {} + axis_labels = ("Slice Index", "") + log_flags = (False, False) + table = tomviz.utils.make_spreadsheet( + column_names, table_data, axis_labels, log_flags + ) + return_values["similarity"] = table + + return return_values diff --git a/tomviz/python/Square_Root_Data.py b/tomviz/python/Square_Root_Data.py index 408b4f1b5..731d1ff49 100644 --- a/tomviz/python/Square_Root_Data.py +++ b/tomviz/python/Square_Root_Data.py @@ -1,4 +1,3 @@ -from tomviz.utils import apply_to_each_array import tomviz.operators NUMBER_OF_CHUNKS = 10 @@ -6,7 +5,6 @@ class SquareRootOperator(tomviz.operators.CancelableOperator): - @apply_to_each_array def transform(self, dataset): """Define this method for Python operators that transform input scalars""" diff --git a/tomviz/python/Subtract_TiltSer_Background.py b/tomviz/python/Subtract_TiltSer_Background.py index 1e298d7fc..610ee6864 100644 --- a/tomviz/python/Subtract_TiltSer_Background.py +++ b/tomviz/python/Subtract_TiltSer_Background.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, XRANGE=None, YRANGE=None, ZRANGE=None): '''For each tilt image, the method uses average pixel value of selected region as the background level and subtracts it from the image.''' diff --git a/tomviz/python/Subtract_TiltSer_Background_Auto.py b/tomviz/python/Subtract_TiltSer_Background_Auto.py index a96ce3aa6..b3366616a 100644 --- a/tomviz/python/Subtract_TiltSer_Background_Auto.py +++ b/tomviz/python/Subtract_TiltSer_Background_Auto.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset): """ For each tilt image, the method calculates its histogram diff --git a/tomviz/python/SwapAxes.py b/tomviz/python/SwapAxes.py index 6ee15acb8..370fb5ac6 100644 --- a/tomviz/python/SwapAxes.py +++ b/tomviz/python/SwapAxes.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, axis1, axis2): """Swap two axes in a dataset""" diff --git a/tomviz/python/TV_Filter.py b/tomviz/python/TV_Filter.py index 50d7778b6..88f9c85bf 100644 --- a/tomviz/python/TV_Filter.py +++ b/tomviz/python/TV_Filter.py @@ -1,4 +1,3 @@ -from tomviz.utils import apply_to_each_array import tomviz.operators import numpy as np @@ -8,7 +7,6 @@ class ArtifactsTVOperator(tomviz.operators.CancelableOperator): - @apply_to_each_array def transform(self, dataset, Niter=100, a=0.1, wedgeSize=5, kmin=5, theta=0): """ diff --git a/tomviz/python/UnsharpMask.py b/tomviz/python/UnsharpMask.py index 2f8a06560..497fc9842 100644 --- a/tomviz/python/UnsharpMask.py +++ b/tomviz/python/UnsharpMask.py @@ -1,10 +1,8 @@ -from tomviz.utils import apply_to_each_array import tomviz.operators class UnsharpMask(tomviz.operators.CancelableOperator): - @apply_to_each_array def transform(self, dataset, amount=0.5, threshold=0.0, sigma=1.0): """This filter performs anisotropic diffusion on an image using the classic Perona-Malik, gradient magnitude-based equation. diff --git a/tomviz/python/WienerFilter.py b/tomviz/python/WienerFilter.py index 3ec80686c..aa8a6d7b8 100644 --- a/tomviz/python/WienerFilter.py +++ b/tomviz/python/WienerFilter.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, SX=0.5, SY=0.5, SZ=0.5, noise=15.0): """Deblur Images with a Weiner Filter.""" diff --git a/tomviz/python/ctf_correct.py b/tomviz/python/ctf_correct.py index 371010f6d..462f1092d 100755 --- a/tomviz/python/ctf_correct.py +++ b/tomviz/python/ctf_correct.py @@ -1,11 +1,8 @@ import numpy as np -from tomviz.utils import apply_to_each_array - # Given an dataset containing one or more 2D images, # apply CTF operations on them. -@apply_to_each_array def transform(dataset, apix=None, df1=None, df2=None, ast=None, ampcon=None, cs=None, kev=None, ctf_method=None, snr=None): diff --git a/tomviz/python/tomviz/_internal.py b/tomviz/python/tomviz/_internal.py index 2de288d9a..cf0120b8e 100644 --- a/tomviz/python/tomviz/_internal.py +++ b/tomviz/python/tomviz/_internal.py @@ -4,6 +4,9 @@ # This source file is part of the Tomviz project, https://tomviz.org/. # It is released under the 3-Clause BSD License, see "LICENSE". ############################################################################### +from pathlib import Path +from types import MethodType +from typing import Any, Callable import fnmatch import importlib.machinery import importlib.util @@ -15,9 +18,6 @@ import tempfile import traceback -from pathlib import Path -from typing import Callable - import tomviz import tomviz.operators @@ -130,6 +130,65 @@ def find_transform_function(transform_module, op=None): return transform_function +def has_decorator(func: Callable, decorator_marker: str = '_is_my_decorator') -> bool: + """Check if a function was already decorated with a decorator name""" + # Check the function itself + if getattr(func, decorator_marker, False): + return True + + # Traverse the __wrapped__ chain + current = func + while hasattr(current, '__wrapped__'): + current = current.__wrapped__ + if getattr(current, decorator_marker, False): + return True + + return False + + +def apply_decorator(func: Callable, decorator: Callable) -> Callable: + # Apply the decorator, taking into account different behavior for MethodType + # callables + if isinstance(func, MethodType): + # It's a bound method + tmp_func = decorator(func.__func__) + return MethodType(tmp_func, func.__self__) + + # Unbound function + return decorator(func) + + +def add_transform_decorators(transform_method: Callable, + operator_dict: dict[str, Any]) -> Callable: + """Optionally add any transform wrappers that we need to add + + Currently, this adds `@apply_to_each_array` automatically if + `"apply_to_each_array": false` is not set within the json + description, and if the decorator was not already applied. + """ + add_apply_to_each_array = True + operator_description = operator_dict.get('description') + if operator_description: + description_json = json.loads(operator_description) + if not description_json.get('apply_to_each_array', True): + # It was intentionally disabled in the json + add_apply_to_each_array = False + + if transform_method.__name__ == 'transform_scalars': + # This is an old transform function. We don't want to do any + # kind of automatic modifications to the old ones. + add_apply_to_each_array = False + + if add_apply_to_each_array: + # First, make sure it wasn't already decorated + if not has_decorator(transform_method, 'apply_to_each_array'): + # Decorate it! + from tomviz.utils import apply_to_each_array + transform_method = apply_decorator(transform_method, apply_to_each_array) + + return transform_method + + def transform_method_wrapper(transform_method: Callable, operator_serialized: str, *args, **kwargs): # We take the serialized operator as input because we may need it @@ -139,6 +198,9 @@ def transform_method_wrapper(transform_method: Callable, operator_dict = json.loads(operator_serialized) tomviz_pipeline_env = None + # Add any transform decorators that we need + transform_method = add_transform_decorators(transform_method, operator_dict) + operator_description = operator_dict.get('description') if operator_description: description_json = json.loads(operator_description) diff --git a/tomviz/python/tomviz/dataset.py b/tomviz/python/tomviz/dataset.py index f844f85ca..21e19843e 100644 --- a/tomviz/python/tomviz/dataset.py +++ b/tomviz/python/tomviz/dataset.py @@ -86,6 +86,15 @@ def set_scalars(self, name: str, array: np.ndarray): """ pass + @abstractmethod + def remove_scalars(self, name: str): + """Remove a scalar array from the dataset. + + :param name: The name of the scalar array to remove. + :raises KeyError: If the specified name does not exist in the dataset. + """ + pass + @property @abstractmethod def spacing(self) -> tuple[int, int, int]: @@ -134,6 +143,24 @@ def tilt_axis(self) -> int | None: """ pass + @property + @abstractmethod + def scan_ids(self) -> np.ndarray | None: + """Array of scan IDs associated with each projection in a tilt series. + + Returns None if scan IDs have not been set. + """ + pass + + @scan_ids.setter + @abstractmethod + def scan_ids(self, v: np.ndarray | None): + """Set the scan IDs for projections in a tilt series. + + Provide None to clear scan IDs. + """ + pass + @property @abstractmethod def dark(self) -> np.ndarray | None: diff --git a/tomviz/python/tomviz/executor.py b/tomviz/python/tomviz/executor.py index 6ed22dbb4..90816d7cc 100644 --- a/tomviz/python/tomviz/executor.py +++ b/tomviz/python/tomviz/executor.py @@ -15,7 +15,7 @@ import numpy as np from tqdm import tqdm -from tomviz._internal import find_transform_function +from tomviz._internal import add_transform_decorators, find_transform_function from tomviz.external_dataset import Dataset @@ -464,6 +464,10 @@ def is_hard_link(name): if dims is not None and dims[-1].name in ('angles', b'angles'): output['tilt_angles'] = dims[-1].values[:].astype(np.float64) + # Read scan IDs if present + if 'scan_ids' in tomography: + output['scan_ids'] = tomography['scan_ids'][:].astype(np.int32) + return output @@ -575,6 +579,12 @@ def _write_emd(path, dataset, dims=None): active_name = dataset.active_name tomviz_scalars[active_name] = h5py.SoftLink('/data/tomography/data') + # Write scan IDs if present + if dataset.scan_ids is not None: + tomography_group.create_dataset( + 'scan_ids', data=np.asarray(dataset.scan_ids, dtype=np.int32) + ) + def _read_data_exchange(path: Path, options: dict | None = None): with h5py.File(path, 'r') as f: @@ -699,6 +709,9 @@ def _load_transform_functions(operators): operator_module = _load_operator_module(operator_label, operator_script) transform = find_transform_function(operator_module) + # Add any transform decorators (like `@apply_to_each_array`) + transform = add_transform_decorators(transform, operator) + # partial apply the arguments arguments = {} if 'arguments' in operator: @@ -764,6 +777,8 @@ def load_dataset(data_file_path, read_options=None): data.tilt_angles = output['tilt_angles'] if 'tilt_axis' in output: data.tilt_axis = output['tilt_axis'] + if 'scan_ids' in output: + data.scan_ids = output['scan_ids'] if dims is not None: # Convert to native type, as is required by itk data.spacing = [float(d.values[1] - d.values[0]) for d in dims] diff --git a/tomviz/python/tomviz/external_dataset.py b/tomviz/python/tomviz/external_dataset.py index 9e285e710..66c9be8a5 100644 --- a/tomviz/python/tomviz/external_dataset.py +++ b/tomviz/python/tomviz/external_dataset.py @@ -14,6 +14,7 @@ def __init__(self, arrays, active=None): self.arrays = arrays self.tilt_angles = None self.tilt_axis = None + self.scan_ids = None # The currently active scalar self.active_name = active # If we weren't given the active array, set the first as the active @@ -124,6 +125,14 @@ def white(self) -> np.ndarray | None: def white(self, v: np.ndarray | None): self._white = v + @property + def scan_ids(self) -> np.ndarray | None: + return self._scan_ids + + @scan_ids.setter + def scan_ids(self, v: np.ndarray | None): + self._scan_ids = v + def create_child_dataset(self): child = copy.deepcopy(self) # Set tilt angles to None to be consistent with internal dataset @@ -135,5 +144,12 @@ def create_child_dataset(self): child.spacing = [s[0], s[1], s[0]] return child + def remove_scalars(self, name): + if name not in self.arrays: + raise KeyError(f"No scalar array named '{name}'") + del self.arrays[name] + if self.active_name == name and self.arrays: + self.active_name = next(iter(self.arrays.keys())) + def rename_active(self, new_name: str): self.arrays[new_name] = self.arrays.pop(self.active_name) diff --git a/tomviz/python/tomviz/internal_dataset.py b/tomviz/python/tomviz/internal_dataset.py index 24aef298f..b4078536c 100644 --- a/tomviz/python/tomviz/internal_dataset.py +++ b/tomviz/python/tomviz/internal_dataset.py @@ -35,6 +35,9 @@ def scalars(self, name=None): def set_scalars(self, name, array): internal_utils.set_array(self._data_object, array, name=name) + def remove_scalars(self, name): + internal_utils.remove_array(self._data_object, name) + @property def spacing(self): return internal_utils.get_spacing(self._data_object) @@ -59,6 +62,14 @@ def tilt_axis(self): def tilt_axis(self, v): self._tilt_axis = v + @property + def scan_ids(self): + return internal_utils.get_scan_ids(self._data_object) + + @scan_ids.setter + def scan_ids(self, v): + internal_utils.set_scan_ids(self._data_object, v) + @property def dark(self): if not self._data_source.dark_data: diff --git a/tomviz/python/tomviz/internal_utils.py b/tomviz/python/tomviz/internal_utils.py index 7875cb969..0508bf106 100644 --- a/tomviz/python/tomviz/internal_utils.py +++ b/tomviz/python/tomviz/internal_utils.py @@ -93,6 +93,17 @@ def arrays(dataobject): yield (name, get_array(dataobject, name)) +@with_vtk_dataobject +def remove_array(dataobject, name): + pd = dataobject.GetPointData() + if pd.GetAbstractArray(name) is None: + raise KeyError(f"No scalar array named '{name}'") + pd.RemoveArray(name) + # If the active scalars were removed, set the first remaining array active + if pd.GetScalars() is None and pd.GetNumberOfArrays() > 0: + pd.SetActiveScalars(pd.GetArrayName(0)) + + @with_vtk_dataobject def set_array(dataobject, newarray, minextent=None, isFortran=True, name=None): # Set the extent if needed, i.e. if the minextent is not the same as @@ -177,6 +188,34 @@ def set_tilt_angles(dataobject, newarray): do.FieldData.AddArray(vtkarray) +@with_vtk_dataobject +def get_scan_ids(dataobject): + # Get the scan IDs array + do = dsa.WrapDataObject(dataobject) + rawarray = do.FieldData.GetArray('scan_ids') + if isinstance(rawarray, dsa.VTKNoneArray): + return None + vtkarray = dsa.vtkDataArrayToVTKArray(rawarray, do) + vtkarray.Association = dsa.ArrayAssociation.FIELD + return vtkarray + + +@with_vtk_dataobject +def set_scan_ids(dataobject, newarray): + # replace the scan IDs with the new array + from vtkmodules.util.vtkConstants import VTK_INT + if newarray is None: + do = dsa.WrapDataObject(dataobject) + do.FieldData.RemoveArray('scan_ids') + return + vtkarray = np_s.numpy_to_vtk(newarray, deep=1, array_type=VTK_INT) + vtkarray.Association = dsa.ArrayAssociation.FIELD + vtkarray.SetName('scan_ids') + do = dsa.WrapDataObject(dataobject) + do.FieldData.RemoveArray('scan_ids') + do.FieldData.AddArray(vtkarray) + + @with_vtk_dataobject def get_coordinate_arrays(dataobject): """Returns a triple of Numpy arrays containing x, y, and z coordinates for diff --git a/tomviz/python/tomviz/ptycho/ptycho.py b/tomviz/python/tomviz/ptycho/ptycho.py index 9bf5a4fb9..ff1b7169b 100644 --- a/tomviz/python/tomviz/ptycho/ptycho.py +++ b/tomviz/python/tomviz/ptycho/ptycho.py @@ -15,6 +15,14 @@ def gather_ptycho_info(ptycho_dir: PathLike) -> dict: ptycho_dir = Path(ptycho_dir) + if not ptycho_dir.is_dir(): + # It either doesn't exist or it's not a directory + return { + 'sid_list': [], + 'version_list': [], + 'angle_list': [], + 'error_list': [], + } sid_list = sorted([int(x.name[1:]) for x in ptycho_dir.iterdir() if x.is_dir() and x.name.startswith('S')]) @@ -444,10 +452,15 @@ def fetch_angle_from_ptycho_hyan_file(filepath: PathLike) -> float | None: def fetch_pixel_sizes_from_ptycho_hyan_file( filepath: PathLike, ) -> tuple[float, float] | None: - print(f'Obtaining pixel sizes from config file: {filepath})') + print(f'Obtaining pixel sizes from config file: {filepath}') vars_required = [ - 'lambda_nm', 'z_m', 'x_arr_size', 'y_arr_size', 'ccd_pixel_um' + 'lambda_nm', 'z_m', 'nx', 'ny', 'ccd_pixel_um' ] + alternatives = { + 'nx': 'x_arr_size', + 'ny': 'y_arr_size', + } + vars_requested = vars_required + list(alternatives.values()) results = {} try: with open(filepath, 'r') as rf: @@ -456,13 +469,22 @@ def fetch_pixel_sizes_from_ptycho_hyan_file( continue lhs = line.split('=')[0].strip() - if lhs in vars_required: - value = float(line.split('=')[1].strip()) + if lhs in vars_requested: + value = float(line.split('=', 1)[1].strip()) results[lhs] = value except Exception as e: print('Failed to fetch pixel sizes with error:', e, file=sys.stderr) return None + # Add alternatives if they are present + for name in vars_required: + if name not in results and name in alternatives: + # Check the alt_name + alt_name = alternatives[name] + if alt_name in results: + # Convert it + results[name] = results.pop(alt_name) + missing = [x for x in vars_required if x not in results] if missing: print( @@ -476,7 +498,7 @@ def fetch_pixel_sizes_from_ptycho_hyan_file( results['lambda_nm'] * results['z_m'] * 1e6 / results['ccd_pixel_um'] ) - x_pixel_size = numerator / results['x_arr_size'] - y_pixel_size = numerator / results['y_arr_size'] + x_pixel_size = numerator / results['nx'] + y_pixel_size = numerator / results['ny'] return x_pixel_size, y_pixel_size diff --git a/tomviz/python/tomviz/utils.py b/tomviz/python/tomviz/utils.py index 84b53c1d8..9a3a92d45 100644 --- a/tomviz/python/tomviz/utils.py +++ b/tomviz/python/tomviz/utils.py @@ -257,7 +257,9 @@ def depad_array(array: np.ndarray, padding: int, tilt_axis: int) -> np.ndarray: return array[tuple(slice_list)] -def make_spreadsheet(column_names: list[str], table: np.ndarray) -> 'vtkTable': +def make_spreadsheet(column_names: list[str], table: np.ndarray, + axes_labels: tuple[str, str] = None, + axes_log_scale: tuple[bool, bool] = None) -> 'vtkTable': """Make a spreadsheet object to use within Tomviz If returned from an operator, this will ultimately appear within the @@ -287,7 +289,7 @@ def make_spreadsheet(column_names: list[str], table: np.ndarray) -> 'vtkTable': 'column names') return - from vtk import vtkTable, vtkFloatArray + from vtk import vtkTable, vtkFloatArray, vtkStringArray, vtkUnsignedCharArray vtk_table = vtkTable() for (column, name) in enumerate(column_names): array = vtkFloatArray() @@ -299,4 +301,22 @@ def make_spreadsheet(column_names: list[str], table: np.ndarray) -> 'vtkTable': for row in range(0, rows): array.InsertValue(row, table[row, column]) + if axes_labels is not None: + label_array = vtkStringArray() + label_array.SetName('axes_labels') + label_array.SetNumberOfComponents(1) + label_array.SetNumberOfTuples(2) + label_array.SetValue(0, axes_labels[0]) + label_array.SetValue(1, axes_labels[1]) + vtk_table.GetFieldData().AddArray(label_array) + + if axes_log_scale is not None: + log_array = vtkUnsignedCharArray() + log_array.SetName('axes_log_scale') + log_array.SetNumberOfComponents(1) + log_array.SetNumberOfTuples(2) + log_array.SetValue(0, int(axes_log_scale[0])) + log_array.SetValue(1, int(axes_log_scale[1])) + vtk_table.GetFieldData().AddArray(log_array) + return vtk_table diff --git a/tomviz/resources.qrc b/tomviz/resources.qrc index 4d2716918..f6bc7bab6 100644 --- a/tomviz/resources.qrc +++ b/tomviz/resources.qrc @@ -33,5 +33,9 @@ icons/pqLock@2x.png icons/greybar.png icons/greybar@2x.png + icons/breakpoint.png + icons/breakpoint@2x.png + icons/play.png + icons/play@2x.png