diff --git a/Applications/shapeworks/Commands.cpp b/Applications/shapeworks/Commands.cpp index 768d654a52b..f13bae816c6 100644 --- a/Applications/shapeworks/Commands.cpp +++ b/Applications/shapeworks/Commands.cpp @@ -398,8 +398,8 @@ bool CorrespondenceQualityCommand::execute(const optparse::Values& options, Shar const auto& r = sorted_results[i]; std::cout << " " << r.subject << " (domain " << r.domain << ")" << " norm_mean=" << r.norm_mean << " (" << std::setprecision(3) << (r.norm_mean * 100.0) << "%)" - << std::setprecision(6) << " mean=" << r.mean_dist << " max=" << r.max_dist - << " bbox_diag=" << r.bbox_diag << "\n"; + << std::setprecision(6) << " mean=" << r.mean_dist << " median=" << r.median_dist + << " max=" << r.max_dist << " bbox_diag=" << r.bbox_diag << "\n"; } } @@ -414,11 +414,13 @@ bool CorrespondenceQualityCommand::execute(const optparse::Values& options, Shar boost::filesystem::current_path(oldBasePath); return false; } - csv << "subject,domain,is_template,mean_dist,max_dist,bbox_diag,norm_mean,norm_max\n"; + csv << "subject,domain,is_template,mean_dist,median_dist,p99_dist,max_dist,bbox_diag,norm_mean,norm_median," + "norm_p99,norm_max\n"; csv << std::fixed << std::setprecision(8); for (const auto& r : report.rows) { csv << r.subject << "," << r.domain << "," << (r.is_template ? 1 : 0) << "," << r.mean_dist << "," - << r.max_dist << "," << r.bbox_diag << "," << r.norm_mean << "," << r.norm_max << "\n"; + << r.median_dist << "," << r.p99_dist << "," << r.max_dist << "," << r.bbox_diag << "," << r.norm_mean + << "," << r.norm_median << "," << r.norm_p99 << "," << r.norm_max << "\n"; } SW_LOG("Wrote per-subject CSV: {}", out_path.string()); } diff --git a/Libs/Particles/CorrespondenceEvaluation.cpp b/Libs/Particles/CorrespondenceEvaluation.cpp index 39c59c729c0..e4ed1a5da72 100644 --- a/Libs/Particles/CorrespondenceEvaluation.cpp +++ b/Libs/Particles/CorrespondenceEvaluation.cpp @@ -43,7 +43,10 @@ Eigen::MatrixXd load_particles_matrix(const std::string& filename) { return m; } -CorrespondenceQualityStats summarize(std::vector values) { +} // namespace + +//--------------------------------------------------------------------------- +CorrespondenceQualityStats CorrespondenceEvaluation::summarize(std::vector values) { CorrespondenceQualityStats s; if (values.empty()) return s; std::sort(values.begin(), values.end()); @@ -55,8 +58,85 @@ CorrespondenceQualityStats summarize(std::vector values) { return s; } -} // namespace +//--------------------------------------------------------------------------- +CorrespondenceQualityRow CorrespondenceEvaluation::evaluate_reconstruction( + vtkSmartPointer reconstructed, const Mesh& groomed, DistanceMethod method, + vtkSmartPointer* out_distance) { + CorrespondenceQualityRow row; + if (!reconstructed || reconstructed->GetNumberOfPoints() == 0) { + return row; + } + + const Mesh::DistanceMethod distance_method = + (method == DistanceMethod::PointToPoint) ? Mesh::DistanceMethod::PointToPoint : Mesh::DistanceMethod::PointToCell; + + Mesh recon_mesh(reconstructed); + auto field = recon_mesh.distance(groomed, distance_method)[0]; + + const int n = field->GetNumberOfTuples(); + if (n == 0) { + return row; + } + + std::vector values(n); + double sum = 0.0; + double maxv = 0.0; + for (int k = 0; k < n; ++k) { + const double v = std::fabs(field->GetTuple1(k)); + values[k] = v; + sum += v; + if (v > maxv) maxv = v; + } + + const size_t mid = values.size() / 2; + std::nth_element(values.begin(), values.begin() + mid, values.end()); + const double median = values[mid]; + + const size_t p99_idx = std::min(values.size() - 1, static_cast(0.99 * values.size())); + std::nth_element(values.begin(), values.begin() + p99_idx, values.end()); + + row.mean_dist = sum / n; + row.median_dist = median; + row.p99_dist = values[p99_idx]; + row.max_dist = maxv; + + const auto bbox = groomed.boundingBox(); + row.bbox_diag = (bbox.max - bbox.min).GetNorm(); + if (row.bbox_diag > 0.0) { + row.norm_mean = row.mean_dist / row.bbox_diag; + row.norm_median = row.median_dist / row.bbox_diag; + row.norm_p99 = row.p99_dist / row.bbox_diag; + row.norm_max = row.max_dist / row.bbox_diag; + } + + if (out_distance) { + field->SetName("distance"); + *out_distance = field; + } + + return row; +} + +//--------------------------------------------------------------------------- +void CorrespondenceEvaluation::compute_aggregates(CorrespondenceQualityReport& report) { + std::vector means; + std::vector norm_means; + int num_template_rows = 0; + for (const auto& r : report.rows) { + if (r.is_template) { + num_template_rows++; + continue; + } + means.push_back(r.mean_dist); + norm_means.push_back(r.norm_mean); + } + report.num_template_rows = num_template_rows; + report.num_evaluated = static_cast(means.size()); + report.agg_raw = summarize(means); + report.agg_norm = summarize(norm_means); +} +//--------------------------------------------------------------------------- CorrespondenceQualityReport CorrespondenceEvaluation::evaluate(ProjectHandle project, DistanceMethod method, const std::string& output_meshes_dir) { if (!project) { @@ -72,9 +152,6 @@ CorrespondenceQualityReport CorrespondenceEvaluation::evaluate(ProjectHandle pro throw std::runtime_error("project has no domains"); } - const Mesh::DistanceMethod distance_method = - (method == DistanceMethod::PointToPoint) ? Mesh::DistanceMethod::PointToPoint : Mesh::DistanceMethod::PointToCell; - // Pass 1: load particles + groomed paths per (subject, domain). Only keep subjects // with complete data across all domains, so the L1-medoid is computed over a // consistent cohort and the same global template applies to every domain. @@ -166,9 +243,6 @@ CorrespondenceQualityReport CorrespondenceEvaluation::evaluate(ProjectHandle pro CorrespondenceQualityReport report; report.template_subject = name_per_subject[template_idx]; - std::vector all_means; // pooled raw mean distances (template excluded) - std::vector all_norm_means; // pooled bbox-normalized values (template excluded) - // Pass 2: per-domain warp + distance using the single global template. for (int domain = 0; domain < num_domains; ++domain) { SW_LOG("=== Domain {} ===", domain); @@ -189,44 +263,18 @@ CorrespondenceQualityReport CorrespondenceEvaluation::evaluate(ProjectHandle pro continue; } - Mesh recon_mesh(reconstructed); Mesh groomed_mesh = load_groomed_as_mesh(groomed_per_subject_domain[i][domain]); - auto field = recon_mesh.distance(groomed_mesh, distance_method)[0]; - - const int n = field->GetNumberOfTuples(); - if (n == 0) continue; - double sum = 0.0; - double maxv = 0.0; - for (int k = 0; k < n; ++k) { - const double v = std::fabs(field->GetTuple1(k)); - sum += v; - if (v > maxv) maxv = v; - } - const double mean_d = sum / n; + vtkSmartPointer field; + CorrespondenceQualityRow row = evaluate_reconstruction(reconstructed, groomed_mesh, method, &field); + if (!field) continue; - const auto bbox = groomed_mesh.boundingBox(); - const double bbox_diag = (bbox.max - bbox.min).GetNorm(); - const double norm_mean = (bbox_diag > 0.0) ? mean_d / bbox_diag : 0.0; - const double norm_max = (bbox_diag > 0.0) ? maxv / bbox_diag : 0.0; - - CorrespondenceQualityRow row; row.subject = name_per_subject[i]; row.domain = domain; - row.mean_dist = mean_d; - row.max_dist = maxv; - row.bbox_diag = bbox_diag; - row.norm_mean = norm_mean; - row.norm_max = norm_max; row.is_template = (i == template_idx); report.rows.push_back(row); - if (!row.is_template) { - all_means.push_back(mean_d); - all_norm_means.push_back(norm_mean); - } - if (!meshes_dir.empty()) { - field->SetName("distance"); + Mesh recon_mesh(reconstructed); recon_mesh.setField("distance", field, Mesh::FieldType::Point); std::string fname = name_per_subject[i] + "_domain" + std::to_string(domain) + "_reconstructed.vtk"; if (row.is_template) { @@ -242,10 +290,7 @@ CorrespondenceQualityReport CorrespondenceEvaluation::evaluate(ProjectHandle pro throw std::runtime_error("no subjects evaluated"); } - report.num_template_rows = static_cast(report.rows.size() - all_means.size()); - report.num_evaluated = static_cast(all_means.size()); - report.agg_raw = summarize(all_means); - report.agg_norm = summarize(all_norm_means); + compute_aggregates(report); return report; } diff --git a/Libs/Particles/CorrespondenceEvaluation.h b/Libs/Particles/CorrespondenceEvaluation.h index 3de1a61a9d5..f8626c974c7 100644 --- a/Libs/Particles/CorrespondenceEvaluation.h +++ b/Libs/Particles/CorrespondenceEvaluation.h @@ -1,12 +1,18 @@ #pragma once +#include + #include #include #include #include +class vtkDataArray; +class vtkPolyData; + namespace shapeworks { +class Mesh; class Project; using ProjectHandle = std::shared_ptr; @@ -15,9 +21,13 @@ struct CorrespondenceQualityRow { std::string subject; int domain = 0; double mean_dist = 0.0; //!< mean point-to-cell (or point-to-point) distance, reconstructed -> groomed + double median_dist = 0.0; //!< median per-vertex distance + double p99_dist = 0.0; //!< 99th percentile per-vertex distance, a max that ignores single outlier vertices double max_dist = 0.0; //!< max per-vertex distance double bbox_diag = 0.0; //!< diagonal of the subject's groomed-mesh bounding box double norm_mean = 0.0; //!< mean_dist / bbox_diag (scale-invariant) + double norm_median = 0.0; //!< median_dist / bbox_diag + double norm_p99 = 0.0; //!< p99_dist / bbox_diag double norm_max = 0.0; //!< max_dist / bbox_diag bool is_template = false; //!< true for the L1-medoid template row (excluded from aggregates) }; @@ -54,6 +64,11 @@ struct CorrespondenceQualityReport { * The template row itself is included in `rows` (with is_template=true) but * excluded from aggregate statistics — its reconstruction is near-identity * and would skew small cohorts. + * + * `evaluate()` drives the whole thing from a project file. Callers that already + * have reconstructions in memory (Studio, which reconstructs through its own + * configured mesh warper) should use `evaluate_reconstruction()` and + * `compute_aggregates()` instead so the metric definition stays in one place. */ class CorrespondenceEvaluation { public: @@ -73,6 +88,24 @@ class CorrespondenceEvaluation { static CorrespondenceQualityReport evaluate(ProjectHandle project, DistanceMethod method = DistanceMethod::PointToCell, const std::string& output_meshes_dir = ""); + + //! Score a single already-reconstructed mesh against its groomed target. + //! + //! Fills everything on the row except `subject`, `domain` and `is_template`, + //! which the caller owns. If \p out_distance is non-null it receives the + //! per-vertex distance field (named "distance"), for surface display or + //! writing alongside the mesh. + //! + //! Returns a default-constructed row if \p reconstructed is null or empty. + static CorrespondenceQualityRow evaluate_reconstruction(vtkSmartPointer reconstructed, + const Mesh& groomed, DistanceMethod method, + vtkSmartPointer* out_distance = nullptr); + + //! Summary statistics (mean/median/p95/max) over a set of values. + static CorrespondenceQualityStats summarize(std::vector values); + + //! Fill num_evaluated, num_template_rows, agg_raw and agg_norm from report.rows. + static void compute_aggregates(CorrespondenceQualityReport& report); }; } // namespace shapeworks diff --git a/Libs/Python/ShapeworksPython.cpp b/Libs/Python/ShapeworksPython.cpp index e656ad8bdf8..ef37807450d 100644 --- a/Libs/Python/ShapeworksPython.cpp +++ b/Libs/Python/ShapeworksPython.cpp @@ -1401,9 +1401,13 @@ PYBIND11_MODULE(shapeworks_py, m) { .def_readonly("subject", &CorrespondenceQualityRow::subject) .def_readonly("domain", &CorrespondenceQualityRow::domain) .def_readonly("mean_dist", &CorrespondenceQualityRow::mean_dist) + .def_readonly("median_dist", &CorrespondenceQualityRow::median_dist) + .def_readonly("p99_dist", &CorrespondenceQualityRow::p99_dist) .def_readonly("max_dist", &CorrespondenceQualityRow::max_dist) .def_readonly("bbox_diag", &CorrespondenceQualityRow::bbox_diag) .def_readonly("norm_mean", &CorrespondenceQualityRow::norm_mean) + .def_readonly("norm_median", &CorrespondenceQualityRow::norm_median) + .def_readonly("norm_p99", &CorrespondenceQualityRow::norm_p99) .def_readonly("norm_max", &CorrespondenceQualityRow::norm_max) .def_readonly("is_template", &CorrespondenceQualityRow::is_template); diff --git a/Studio/Analysis/AnalysisTool.cpp b/Studio/Analysis/AnalysisTool.cpp index 59d496bb30e..efccfc6dfa9 100644 --- a/Studio/Analysis/AnalysisTool.cpp +++ b/Studio/Analysis/AnalysisTool.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,7 @@ #include +#include "CorrespondenceQualityPanel.h" #include "ParticleAreaPanel.h" #include "ShapeScalarPanel.h" @@ -41,7 +43,6 @@ const std::string AnalysisTool::MODE_PCA_C("pca"); const std::string AnalysisTool::MODE_SINGLE_SAMPLE_C("single sample"); const std::string AnalysisTool::MODE_REGRESSION_C("regression"); -constexpr auto MESH_WARP_TEMPLATE_INDEX = "mesh_warp_template_index"; constexpr auto MESH_WARP_METHOD = "mesh_warp_method"; //--------------------------------------------------------------------------- @@ -82,6 +83,28 @@ AnalysisTool::AnalysisTool(Preferences& prefs) : preferences_(prefs) { shape_scalar_panel_ = new ShapeScalarPanel(this); layout()->addWidget(shape_scalar_panel_); + correspondence_quality_panel_ = new CorrespondenceQualityPanel(this); + layout()->addWidget(correspondence_quality_panel_); + + // the correspondence distance and the sample ordering are only visible in the sample views, so + // take the user there rather than leaving them wondering why nothing changed + connect(correspondence_quality_panel_, &CorrespondenceQualityPanel::request_show_sample, this, + &AnalysisTool::show_sample); + connect(correspondence_quality_panel_, &CorrespondenceQualityPanel::request_template, this, + &AnalysisTool::set_mesh_warp_template); + connect(correspondence_quality_panel_, &CorrespondenceQualityPanel::request_template_median, this, + &AnalysisTool::set_mesh_warp_template_to_median); + connect(correspondence_quality_panel_, &CorrespondenceQualityPanel::request_apply_template, this, + &AnalysisTool::apply_mesh_warp_template); + connect(correspondence_quality_panel_, &CorrespondenceQualityPanel::request_samples_view, this, + [this](bool all_samples) { + auto mode = get_analysis_mode(); + if (mode == MODE_ALL_SAMPLES_C || (!all_samples && mode == MODE_SINGLE_SAMPLE_C)) { + return; // already somewhere the results show + } + set_analysis_mode(MODE_ALL_SAMPLES_C); + }); + auto spacer = new QSpacerItem(20, 40, QSizePolicy::Minimum, QSizePolicy::Expanding); layout()->addItem(spacer); @@ -219,9 +242,7 @@ AnalysisTool::AnalysisTool(Preferences& prefs) : preferences_(prefs) { &AnalysisTool::handle_samples_predicted_scalar_options); // add a right click menu to the samples table allowing the user to copy the table to the clipboard - ui_->samples_table->setContextMenuPolicy(Qt::CustomContextMenu); - connect(ui_->samples_table, &QTableWidget::customContextMenuRequested, this, - &AnalysisTool::samples_table_context_menu); + StudioUtils::add_table_copy_menu(ui_->samples_table); // disable editing of the table ui_->samples_table->setEditTriggers(QAbstractItemView::NoEditTriggers); @@ -359,6 +380,28 @@ void AnalysisTool::set_labels(QString which, QString value) { //--------------------------------------------------------------------------- int AnalysisTool::get_sample_number() { return ui_->sampleSpinBox->value(); } +//--------------------------------------------------------------------------- +void AnalysisTool::show_sample(int index) { + if (!session_ || index < 0 || index >= static_cast(session_->get_shapes().size())) { + return; + } + + // Asking for the sample that is already showing returns to the grid, so a results table can step + // in and out of samples without sending the user back up to the Samples tab to find the radio. + if (get_analysis_mode() == MODE_SINGLE_SAMPLE_C && ui_->sampleSpinBox->value() == index) { + ui_->allSamplesRadio->setChecked(true); + ui_->singleSamplesRadio->setChecked(false); + set_analysis_mode(MODE_ALL_SAMPLES_C); + handle_analysis_options(); + return; + } + + ui_->singleSamplesRadio->setChecked(true); + ui_->sampleSpinBox->setValue(index); + set_analysis_mode(MODE_SINGLE_SAMPLE_C); + handle_analysis_options(); +} + //--------------------------------------------------------------------------- AnalysisTool::~AnalysisTool() {} @@ -367,6 +410,7 @@ void AnalysisTool::set_session(QSharedPointer session) { session_ = session; particle_area_panel_->set_session(session); shape_scalar_panel_->set_session(session); + correspondence_quality_panel_->set_session(session); // reset to original ui_->mesh_warping_radio_button->setChecked(true); @@ -1460,6 +1504,7 @@ void AnalysisTool::reset_stats() { particle_area_panel_->reset(); shape_scalar_panel_->reset(); + correspondence_quality_panel_->reset(); stats_ = ParticleShapeStatistics(); evals_ready_ = false; stats_ready_ = false; @@ -1548,6 +1593,7 @@ void AnalysisTool::enable_actions(bool newly_enabled) { ui_->sampleSpinBox->setMaximum(session_->get_num_shapes() - 1); // the mesh warp template is chosen from the non-excluded shapes ui_->mesh_warp_sample_spinbox->setMaximum(static_cast(session_->get_non_excluded_shapes().size()) - 1); + push_template_to_panels(); } //--------------------------------------------------------------------------- @@ -1723,6 +1769,14 @@ std::string AnalysisTool::get_display_feature_map() { } } + // the correspondence distance is a per-vertex field on each sample's reconstruction, so it + // only applies to the sample views + if (correspondence_quality_panel_->get_display_distance() && + (get_analysis_mode() == AnalysisTool::MODE_ALL_SAMPLES_C || + get_analysis_mode() == AnalysisTool::MODE_SINGLE_SAMPLE_C)) { + return correspondence_quality_panel_->get_display_feature_name(); + } + if (get_analysis_mode() == AnalysisTool::MODE_ALL_SAMPLES_C && ui_->show_difference_to_predicted_scalar->isChecked()) { return "predicted_scalar_diff"; @@ -2423,39 +2477,23 @@ void AnalysisTool::handle_samples_predicted_scalar_options() { } //--------------------------------------------------------------------------- -void AnalysisTool::samples_table_context_menu() { - QMenu menu; - QAction* action = menu.addAction("Copy to Clipboard"); - connect(action, &QAction::triggered, this, &AnalysisTool::samples_table_copy_to_clipboard); - menu.exec(QCursor::pos()); +void AnalysisTool::push_template_to_panels() { + if (!correspondence_quality_panel_ || !session_) { + return; + } + correspondence_quality_panel_->set_template_info(ui_->mesh_warp_sample_spinbox->value(), + ui_->mesh_warp_sample_spinbox->maximum(), + ui_->template_mesh_name_label->text()); } //--------------------------------------------------------------------------- -void AnalysisTool::samples_table_copy_to_clipboard() { - QTableWidget* table = ui_->samples_table; - QString text; - // start with headers - for (int i = 0; i < table->columnCount(); i++) { - text += table->horizontalHeaderItem(i)->text(); - if (i < table->columnCount() - 1) { - text += ","; - } - } - text += "\n"; - for (int i = 0; i < table->rowCount(); i++) { - for (int j = 0; j < table->columnCount(); j++) { - auto item = table->item(i, j); - if (item) { - text += item->text(); - } - if (j < table->columnCount() - 1) { - text += ","; - } - } - text += "\n"; - } - QApplication::clipboard()->setText(text); -} +void AnalysisTool::set_mesh_warp_template(int index) { ui_->mesh_warp_sample_spinbox->setValue(index); } + +//--------------------------------------------------------------------------- +void AnalysisTool::set_mesh_warp_template_to_median() { mesh_warp_median_clicked(); } + +//--------------------------------------------------------------------------- +void AnalysisTool::apply_mesh_warp_template() { mesh_warp_run_clicked(); } //--------------------------------------------------------------------------- void AnalysisTool::mesh_warp_median_clicked() { @@ -2471,9 +2509,11 @@ void AnalysisTool::mesh_warp_sample_changed() { auto shapes = session_->get_non_excluded_shapes(); if (index < 0 || index >= shapes.size()) { ui_->template_mesh_name_label->setText(""); + push_template_to_panels(); return; } ui_->template_mesh_name_label->setText(QString::fromStdString(shapes[index]->get_subject()->get_display_name())); + push_template_to_panels(); } //--------------------------------------------------------------------------- diff --git a/Studio/Analysis/AnalysisTool.h b/Studio/Analysis/AnalysisTool.h index 829ccb73381..99a742e7817 100644 --- a/Studio/Analysis/AnalysisTool.h +++ b/Studio/Analysis/AnalysisTool.h @@ -31,6 +31,7 @@ class StatsGroupLDAJob; class StatsGroupDWDJob; class ParticleAreaPanel; class ShapeScalarPanel; +class CorrespondenceQualityPanel; class AnalysisTool : public QWidget { Q_OBJECT; @@ -83,6 +84,18 @@ class AnalysisTool : public QWidget { int get_sample_number(); + //! switch to the single sample view showing the given index into Session::get_shapes() + void show_sample(int index); + + //! set the mesh warp template, as an index into the non-excluded shapes + void set_mesh_warp_template(int index); + + //! set the mesh warp template to the cohort median shape + void set_mesh_warp_template_to_median(); + + //! rebuild the mesh warper for the current template, discarding cached reconstructions + void apply_mesh_warp_template(); + bool compute_stats(); void update_slider(); @@ -119,6 +132,9 @@ class AnalysisTool : public QWidget { void compute_shape_evaluations(); + //! project parameter key holding the mesh-warp template index (an index into the non-excluded shapes) + static constexpr const char* MESH_WARP_TEMPLATE_INDEX = "mesh_warp_template_index"; + static const std::string MODE_ALL_SAMPLES_C; static const std::string MODE_MEAN_C; static const std::string MODE_PCA_C; @@ -186,6 +202,9 @@ class AnalysisTool : public QWidget { void initialize_mesh_warper(); + //! mirror the current warp template into the panels that also present it + void push_template_to_panels(); + void group_p_values_clicked(); void network_analysis_clicked(); @@ -221,8 +240,7 @@ class AnalysisTool : public QWidget { void handle_samples_predicted_scalar_options(); - void samples_table_context_menu(); - void samples_table_copy_to_clipboard(); + // mesh warping options void mesh_warp_median_clicked(); @@ -339,6 +357,7 @@ class AnalysisTool : public QWidget { ParticleAreaPanel* particle_area_panel_{nullptr}; ShapeScalarPanel* shape_scalar_panel_{nullptr}; + CorrespondenceQualityPanel* correspondence_quality_panel_{nullptr}; std::vector> workers_; }; diff --git a/Studio/Analysis/CorrespondenceQualityPanel.cpp b/Studio/Analysis/CorrespondenceQualityPanel.cpp new file mode 100644 index 00000000000..a49bbb2df47 --- /dev/null +++ b/Studio/Analysis/CorrespondenceQualityPanel.cpp @@ -0,0 +1,635 @@ +// qt +#include +#include +#include +#include + +// shapeworks +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace shapeworks { + +namespace { +enum SortMetric { SORT_MEAN = 0, SORT_MEDIAN = 1, SORT_MAX = 2, SORT_LOCALIZED = 3, SORT_NAME = 4 }; +} + +//--------------------------------------------------------------------------- +CorrespondenceQualityPanel::CorrespondenceQualityPanel(QWidget* parent) + : QWidget(parent), ui_(new Ui_CorrespondenceQualityPanel) { + ui_->setupUi(this); + + connect(ui_->open_button, &QPushButton::toggled, ui_->content, &QWidget::setVisible); + connect(ui_->header, &QPushButton::clicked, ui_->open_button, &QPushButton::toggle); + + ui_->header_label->setAttribute(Qt::WA_TransparentForMouseEvents); + ui_->open_button->setChecked(false); + ui_->progress->hide(); + + ui_->show_distance->setEnabled(false); + ui_->normalize_checkbox->setEnabled(false); + ui_->sort_group->setEnabled(false); + + // rich text lays out to the full label width, so without a margin the summary table's right + // border falls outside the visible area + ui_->summary_label->setMargin(3); + + // the template names vary in length, and a label that asks for its text width would resize the + // column and shuffle every control beside it on each step of the spinbox + ui_->template_name_label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + + ui_->results_table->verticalHeader()->hide(); + // the sample name is what benefits from spare width, not the last numeric column + ui_->results_table->horizontalHeader()->setStretchLastSection(false); + StudioUtils::add_table_copy_menu(ui_->results_table); + ui_->results_table->setToolTip("Click a row to show that sample on its own, click it again to go back to all samples"); + connect(ui_->results_table, &QTableWidget::cellClicked, this, &CorrespondenceQualityPanel::handle_table_clicked); + + update_run_button(); + + connect(ui_->run_button, &QPushButton::clicked, this, &CorrespondenceQualityPanel::run_clicked); + connect(ui_->template_median_button, &QPushButton::clicked, this, + [this]() { Q_EMIT request_template_median(); }); + connect(ui_->template_spinbox, qOverload(&QSpinBox::valueChanged), this, + &CorrespondenceQualityPanel::template_changed); + connect(ui_->show_distance, &QCheckBox::clicked, this, &CorrespondenceQualityPanel::show_distance_clicked); + + ui_->sort_metric_combo->setToolTip(StudioUtils::wrap_tooltip( + "How to rank the samples. Localized is the ratio of a sample's p99 distance to its mean: high " + "when most of the surface is fine and a small patch is badly wrong, which is what a few swapped " + "correspondence points look like.")); + connect(ui_->sort_metric_combo, qOverload(&QComboBox::currentIndexChanged), this, + &CorrespondenceQualityPanel::options_changed); + connect(ui_->sort_order_combo, qOverload(&QComboBox::currentIndexChanged), this, + &CorrespondenceQualityPanel::options_changed); + connect(ui_->normalize_checkbox, &QCheckBox::clicked, this, &CorrespondenceQualityPanel::options_changed); + connect(ui_->sort_samples_checkbox, &QCheckBox::clicked, this, &CorrespondenceQualityPanel::options_changed); + + update_summary(); + update_table(); + update_graphs(); +} + +//--------------------------------------------------------------------------- +CorrespondenceQualityPanel::~CorrespondenceQualityPanel() {} + +//--------------------------------------------------------------------------- +void CorrespondenceQualityPanel::set_session(QSharedPointer session) { + session_ = session; + reset(); +} + +//--------------------------------------------------------------------------- +void CorrespondenceQualityPanel::reset() { + const bool had_results = !job_.isNull(); + job_.reset(); + ui_->show_distance->setEnabled(false); + ui_->show_distance->setChecked(false); + ui_->normalize_checkbox->setEnabled(false); + ui_->sort_group->setEnabled(false); + ui_->sort_samples_checkbox->setChecked(false); + if (session_ && !session_->get_shape_display_order().empty()) { + session_->set_shape_display_order({}); + } + update_summary(); + update_table(); + update_graphs(); + + // the panel no longer claims the feature map, but the samples keep the colors until the viewer + // is told to look again + if (had_results && session_) { + session_->trigger_reinsert_shapes(); + } +} + +//--------------------------------------------------------------------------- +bool CorrespondenceQualityPanel::get_display_distance() const { + return ui_->show_distance->isChecked() && ui_->show_distance->isEnabled(); +} + +//--------------------------------------------------------------------------- +std::string CorrespondenceQualityPanel::get_display_feature_name() const { + return CorrespondenceQualityJob::FEATURE_NAME; +} + +//--------------------------------------------------------------------------- +bool CorrespondenceQualityPanel::sorting_by_name() const { return ui_->sort_metric_combo->currentIndex() == SORT_NAME; } + +//--------------------------------------------------------------------------- +bool CorrespondenceQualityPanel::sorting_by_ratio() const { + return ui_->sort_metric_combo->currentIndex() == SORT_LOCALIZED; +} + +//--------------------------------------------------------------------------- +bool CorrespondenceQualityPanel::sort_descending() const { return ui_->sort_order_combo->currentIndex() == 0; } + +//--------------------------------------------------------------------------- +bool CorrespondenceQualityPanel::normalized() const { return ui_->normalize_checkbox->isChecked(); } + +//--------------------------------------------------------------------------- +double CorrespondenceQualityPanel::get_sort_value(const CorrespondenceQualityRow& row) const { + const bool norm = normalized(); + switch (ui_->sort_metric_combo->currentIndex()) { + case SORT_MEDIAN: + return norm ? row.norm_median : row.median_dist; + case SORT_MAX: + return norm ? row.norm_max : row.max_dist; + case SORT_LOCALIZED: + // how concentrated the error is: a few swapped particles leave most of the surface intact, + // so the mean stays low while the tail spikes. p99 rather than max, which is a single + // vertex and moves with one bad triangle. Scale free, so normalization does not apply. + return row.mean_dist > 0 ? row.p99_dist / row.mean_dist : 0.0; + case SORT_MEAN: + default: + return norm ? row.norm_mean : row.mean_dist; + } +} + +//--------------------------------------------------------------------------- +std::vector CorrespondenceQualityPanel::get_sorted_rows() const { + std::vector order; + if (!job_) { + return order; + } + const auto& rows = job_->get_report().rows; + order.resize(rows.size()); + std::iota(order.begin(), order.end(), 0); + + const bool by_name = sorting_by_name(); + const bool descending = sort_descending(); + + std::stable_sort(order.begin(), order.end(), [&](int a, int b) { + if (by_name) { + return descending ? rows[a].subject > rows[b].subject : rows[a].subject < rows[b].subject; + } + const double va = get_sort_value(rows[a]); + const double vb = get_sort_value(rows[b]); + return descending ? va > vb : va < vb; + }); + return order; +} + +//--------------------------------------------------------------------------- +void CorrespondenceQualityPanel::set_template_info(int index, int maximum, QString name) { + const bool changed = ui_->template_spinbox->value() != index; + { + // driven by the analysis tool, so do not echo it straight back + QSignalBlocker blocker(ui_->template_spinbox); + ui_->template_spinbox->setMaximum(std::max(0, maximum)); + ui_->template_spinbox->setValue(index); + } + ui_->template_name_label->setText(name); + ui_->template_name_label->setToolTip(name); // in case the column is too narrow to show it all + + // the template can also be changed from the Surface Reconstruction panel, and any results were + // measured against the old one + if (changed && job_) { + reset(); + } +} + +//--------------------------------------------------------------------------- +void CorrespondenceQualityPanel::template_changed() { + Q_EMIT request_template(ui_->template_spinbox->value()); + // every distance was measured against the old template, so they no longer describe this model + if (job_) { + reset(); + } +} + +//--------------------------------------------------------------------------- +void CorrespondenceQualityPanel::run_clicked() { + if (!session_) { + return; + } + + // ensure someone doesn't accidentally abort right after clicking RUN + ui_->run_button->setEnabled(false); + + if (job_ && !job_->is_complete()) { + ui_->run_button->setText("Aborting..."); + job_->abort(); + SW_LOG("Aborting {}", job_->name()); + return; + } + + // the spinbox only records the choice; the warper still holds whatever was last applied, and the + // measurement has to be made against the template the panel is showing + Q_EMIT request_apply_template(); + + ui_->progress->show(); + handle_job_progress(0); + + auto method = ui_->method_combo->currentIndex() == 1 ? CorrespondenceEvaluation::DistanceMethod::PointToPoint + : CorrespondenceEvaluation::DistanceMethod::PointToCell; + + job_ = QSharedPointer::create(session_, method); + connect(job_.data(), &CorrespondenceQualityJob::progress, this, &CorrespondenceQualityPanel::handle_job_progress); + connect(job_.data(), &CorrespondenceQualityJob::finished, this, &CorrespondenceQualityPanel::handle_job_complete); + auto worker = Worker::create_worker(); + worker->run_job(job_); + + // re-enable after 1 second to prevent accidental double-clicks + QTimer::singleShot(1000, this, [&]() { update_run_button(); }); +} + +//--------------------------------------------------------------------------- +void CorrespondenceQualityPanel::show_distance_clicked() { + if (!session_) { + return; + } + if (get_display_distance()) { + // the distance field lives on the reconstructed surfaces of each sample + session_->set_display_mode(DisplayMode::Reconstructed); + Q_EMIT request_samples_view(false); + } + session_->trigger_reinsert_shapes(); +} + +//--------------------------------------------------------------------------- +void CorrespondenceQualityPanel::options_changed() { + // normalization applies to every number the panel shows, the summary included + update_summary(); + update_table(); + update_graphs(); + apply_sample_order(); + if (ui_->sort_samples_checkbox->isChecked()) { + Q_EMIT request_samples_view(true); // the sample order is only visible in the All Samples grid + } +} + +//--------------------------------------------------------------------------- +void CorrespondenceQualityPanel::handle_job_progress(double progress) { + ui_->progress->setValue(static_cast(progress * 100)); +} + +//--------------------------------------------------------------------------- +void CorrespondenceQualityPanel::handle_job_complete() { + update_run_button(); + ui_->progress->hide(); + + if (job_->is_aborted() || job_->is_failed()) { + job_.reset(); + reset(); + return; + } + + // The glyphs are colored from the shape's point features, so hand them the sampled values. This + // has to come first: set_point_features() interpolates those particle values back over the mesh + // under the same name, which would otherwise replace the real per-vertex field. The + // reconstruction passes through the particles, so that interpolation is near zero everywhere and + // hides the very error this panel measures. + auto shapes = session_->get_shapes(); + for (const auto& [shape_index, values] : job_->get_particle_values()) { + if (shape_index >= 0 && shape_index < static_cast(shapes.size())) { + shapes[shape_index]->set_point_features(CorrespondenceQualityJob::FEATURE_NAME, values); + } + } + + // now put the measured per-vertex distances back on the surfaces + for (const auto& [shape_index, fields] : job_->get_distance_fields()) { + if (shape_index < 0 || shape_index >= static_cast(shapes.size())) { + continue; + } + auto meshes = shapes[shape_index]->get_reconstructed_meshes(true); + for (int d = 0; d < static_cast(fields.size()) && d < static_cast(meshes.meshes().size()); d++) { + auto poly_data = meshes.meshes()[d]->get_poly_data(); + if (poly_data && fields[d]) { + poly_data->GetPointData()->AddArray(fields[d]); + } + } + } + + ui_->show_distance->setEnabled(true); + ui_->show_distance->setChecked(true); + ui_->normalize_checkbox->setEnabled(true); + ui_->sort_group->setEnabled(true); + session_->set_display_mode(DisplayMode::Reconstructed); + Q_EMIT request_samples_view(false); + + update_summary(); + update_table(); + update_graphs(); + apply_sample_order(); + + session_->trigger_reinsert_shapes(); +} + +//--------------------------------------------------------------------------- +void CorrespondenceQualityPanel::handle_table_clicked(int row, int column) { + Q_UNUSED(column); + if (!job_) { + return; + } + // the table is sorted, so map the visible row back to the report row and then to the shape + auto order = get_sorted_rows(); + if (row < 0 || row >= static_cast(order.size())) { + return; + } + const auto& shape_indices = job_->get_row_shape_indices(); + const int report_row = order[row]; + if (report_row < 0 || report_row >= static_cast(shape_indices.size())) { + return; + } + Q_EMIT request_show_sample(shape_indices[report_row]); +} + +//--------------------------------------------------------------------------- +void CorrespondenceQualityPanel::update_summary() { + if (!job_) { + ui_->summary_label->hide(); + return; + } + ui_->summary_label->show(); + + const auto& report = job_->get_report(); + // the aggregates are always over the per-sample *mean* distance, whatever the sort metric is + const auto& stats = normalized() ? report.agg_norm : report.agg_raw; + const double scale = normalized() ? 100.0 : 1.0; + const QString units = normalized() ? "% of bbox diagonal" : "world units"; + + auto value = [&](double v) { return QString::number(v * scale, 'f', 4); }; + auto cell = [](const QString& text) { return "" + text + ""; }; + auto heading = [](const QString& text) { return "" + text + ""; }; + + const QString template_name = + report.template_subject.empty() ? QString("none") : QString::fromStdString(report.template_subject); + + QString text = ""; + text += "" + cell("" + QString::number(report.num_evaluated) + "") + ""; + text += "" + cell(template_name.toHtmlEscaped()) + ""; + text += "
Samples evaluated
Template (excluded)
"; + + text += "

Mean distance across samples (" + units + ")

"; + + text += ""; + text += "" + heading("Mean") + heading("Median") + heading("p95") + heading("Max") + ""; + text += "" + cell(value(stats.mean)) + cell(value(stats.median)) + cell(value(stats.p95)) + + cell(value(stats.max)) + ""; + text += "
"; + + // these are percentiles across samples; the table's p99 column is across one sample's vertices + ui_->summary_label->setToolTip(StudioUtils::wrap_tooltip( + "Distribution across samples of each sample's mean distance. The p95 here is over samples, unlike the p99 " + "column in the table, which is over the vertices of a single sample.")); + ui_->summary_label->setText(text); +} + +//--------------------------------------------------------------------------- +void CorrespondenceQualityPanel::update_table() { + auto table = ui_->results_table; + table->clear(); + table->setRowCount(0); + + if (!job_) { + table->hide(); + return; + } + table->show(); + + const auto& rows = job_->get_report().rows; + const bool norm = normalized(); + const bool multi_domain = session_ && session_->get_domains_per_shape() > 1; + + QStringList headers; + headers << "Sample"; + if (multi_domain) { + headers << "Domain"; + } + const QString suffix = norm ? " %" : ""; + headers << ("Mean" + suffix) << ("Median" + suffix) << ("p99" + suffix) << ("Max" + suffix); + + table->setColumnCount(headers.size()); + table->setHorizontalHeaderLabels(headers); + table->setRowCount(rows.size()); + + // the summary above reports percentiles across samples, these are across the vertices of one + // sample, so say which is which rather than leaving two similar looking percentiles side by side + const QStringList tips = {"Distance from this sample's reconstruction to its groomed mesh", + multi_domain ? "Anatomy this row measures" : QString(), + "Mean over this sample's reconstruction vertices", + "Median over this sample's reconstruction vertices", + "99th percentile of this sample's per-vertex distances: the worst part of the surface, " + "without following a single stray vertex the way the max does", + "Largest single per-vertex distance on this sample"}; + int tip_index = 0; + for (int c = 0; c < headers.size(); c++) { + if (!multi_domain && tip_index == 1) { + tip_index++; // no domain column to describe + } + if (auto header_item = table->horizontalHeaderItem(c)) { + header_item->setToolTip(tips.value(tip_index)); + } + tip_index++; + } + + auto order = get_sorted_rows(); + const double scale = norm ? 100.0 : 1.0; + + for (int i = 0; i < static_cast(order.size()); i++) { + const auto& row = rows[order[i]]; + + QString name = QString::fromStdString(row.subject); + if (row.is_template) { + name += " (template)"; + } + + int col = 0; + auto name_item = new QTableWidgetItem(name); + // the column is narrow enough that most names elide, so the tooltip has to carry the full one + name_item->setToolTip(name + QString("\nbounding box diagonal: %1").arg(row.bbox_diag)); + table->setItem(i, col++, name_item); + + if (multi_domain) { + table->setItem(i, col++, new QTableWidgetItem(QString::number(row.domain))); + } + + const double values[4] = {norm ? row.norm_mean : row.mean_dist, norm ? row.norm_median : row.median_dist, + norm ? row.norm_p99 : row.p99_dist, norm ? row.norm_max : row.max_dist}; + const double raw[4] = {row.mean_dist, row.median_dist, row.p99_dist, row.max_dist}; + for (int v = 0; v < 4; v++) { + auto item = new QTableWidgetItem(QString::number(values[v] * scale, 'f', 4)); + item->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); // so the decimal points line up + if (norm) { + item->setToolTip(QString("%1 in world units").arg(raw[v])); + } + table->setItem(i, col++, item); + } + } + + // numbers take exactly what they need; the sample name takes whatever is left over, so long + // names are not elided while a numeric column sits half empty + auto header = table->horizontalHeader(); + for (int c = 1; c < table->columnCount(); c++) { + header->setSectionResizeMode(c, QHeaderView::ResizeToContents); + } + header->setSectionResizeMode(0, QHeaderView::Stretch); +} + +//--------------------------------------------------------------------------- +void CorrespondenceQualityPanel::update_graphs() { + if (!job_) { + ui_->boxplot->hide(); + return; + } + ui_->boxplot->show(); + + const auto& rows = job_->get_report().rows; + const bool norm = normalized(); + const double scale = norm ? 100.0 : 1.0; + + // Rank the samples, then plot the chosen metric alongside the max. Plotting the max is the + // point: a handful of swapped particles leaves the mean almost untouched because the rest of the + // surface is still fine, so a mean-only chart would rank that sample as healthy. The max spikes + // instead, and the gap between the two lines is how localized the damage is. + std::vector primary; + std::vector companion; + for (int row_index : get_sorted_rows()) { + const auto& row = rows[row_index]; + if (row.is_template) { // near-identity reconstruction, would flatten the rest of the chart + continue; + } + if (sorting_by_ratio()) { + // the ratio is built from p99, so plot that rather than max + primary.push_back((norm ? row.norm_mean : row.mean_dist) * scale); + companion.push_back((norm ? row.norm_p99 : row.p99_dist) * scale); + } else if (ui_->sort_metric_combo->currentIndex() == SORT_MAX) { + primary.push_back((norm ? row.norm_max : row.max_dist) * scale); + companion.push_back((norm ? row.norm_mean : row.mean_dist) * scale); + } else { + primary.push_back(get_sort_value(row) * scale); + companion.push_back((norm ? row.norm_max : row.max_dist) * scale); + } + } + + if (primary.empty()) { // e.g. a cohort of only the template + ui_->boxplot->hide(); + return; + } + + auto to_vector = [](const std::vector& values) { + Eigen::VectorXd out(values.size()); + for (int i = 0; i < static_cast(values.size()); i++) { + out(i) = values[i]; + } + return out; + }; + + QString primary_label = ui_->sort_metric_combo->currentText(); + QString companion_label = "Max distance"; + if (sorting_by_ratio()) { + primary_label = "Mean distance"; + companion_label = "p99 distance"; + } else if (sorting_by_name()) { + primary_label = "Mean distance"; + } else if (ui_->sort_metric_combo->currentIndex() == SORT_MAX) { + companion_label = "Mean distance"; + } + + std::vector series; + series.push_back({to_vector(primary), primary_label, QColor(40, 80, 200)}); + series.push_back({to_vector(companion), companion_label, QColor(200, 60, 40)}); + + // median and p95 of the per-sample mean, matching the summary above + const auto& stats = norm ? job_->get_report().agg_norm : job_->get_report().agg_raw; + std::vector reference_lines{stats.median * scale, stats.p95 * scale}; + + const QString y_label = norm ? "Distance (% of bbox diag)" : "Distance (world units)"; + const QString x_label = sorting_by_name() ? "Sample (name order)" : "Sample (table order)"; + + // the two series differ by more than an order of magnitude, so a linear axis would flatten the + // lower one against zero + // the primary series is ranked, so the corner it falls away from is the empty one + const auto key_corner = sort_descending() ? AnalysisUtils::KeyCorner::BottomLeft + : AnalysisUtils::KeyCorner::BottomRight; + + AnalysisUtils::create_ranked_plot(ui_->boxplot, series, reference_lines, "Correspondence quality", x_label, y_label, + true, key_corner); +} + +//--------------------------------------------------------------------------- +void CorrespondenceQualityPanel::apply_sample_order() { + if (!session_) { + return; + } + + const bool had_order = !session_->get_shape_display_order().empty(); + + if (!job_ || !ui_->sort_samples_checkbox->isChecked() || job_->get_report().rows.empty()) { + if (had_order) { + session_->set_shape_display_order({}); + session_->trigger_reinsert_shapes(); + } + return; + } + + const auto& rows = job_->get_report().rows; + const auto& shape_indices = job_->get_row_shape_indices(); + + // one value per shape: the worst of its domains, so a shape is as challenging as its + // hardest anatomy + std::map value_by_shape; + std::map name_by_shape; + for (int i = 0; i < static_cast(rows.size()) && i < static_cast(shape_indices.size()); i++) { + const int shape_index = shape_indices[i]; + const double value = get_sort_value(rows[i]); + auto it = value_by_shape.find(shape_index); + if (it == value_by_shape.end()) { + value_by_shape[shape_index] = value; + name_by_shape[shape_index] = rows[i].subject; + } else { + it->second = std::max(it->second, value); + } + } + + std::vector scored; + std::vector unscored; + for (int i = 0; i < static_cast(session_->get_shapes().size()); i++) { + if (value_by_shape.count(i)) { + scored.push_back(i); + } else { + unscored.push_back(i); + } + } + + const bool by_name = sorting_by_name(); + const bool descending = sort_descending(); + std::stable_sort(scored.begin(), scored.end(), [&](int a, int b) { + if (by_name) { + return descending ? name_by_shape[a] > name_by_shape[b] : name_by_shape[a] < name_by_shape[b]; + } + return descending ? value_by_shape[a] > value_by_shape[b] : value_by_shape[a] < value_by_shape[b]; + }); + + // shapes with no score (excluded, or missing meshes) keep their natural order at the end + scored.insert(scored.end(), unscored.begin(), unscored.end()); + + session_->set_shape_display_order(scored); + session_->trigger_reinsert_shapes(); +} + +//--------------------------------------------------------------------------- +void CorrespondenceQualityPanel::update_run_button() { + if (job_ && !job_->is_complete()) { + Style::apply_abort_button_style(ui_->run_button); + ui_->run_button->setText("Abort"); + } else { + Style::apply_normal_button_style(ui_->run_button); + ui_->run_button->setText("Run"); + } + ui_->run_button->setEnabled(true); +} +} // namespace shapeworks diff --git a/Studio/Analysis/CorrespondenceQualityPanel.h b/Studio/Analysis/CorrespondenceQualityPanel.h new file mode 100644 index 00000000000..f464daa05a9 --- /dev/null +++ b/Studio/Analysis/CorrespondenceQualityPanel.h @@ -0,0 +1,102 @@ +#pragma once + +// Qt +#include +#include + +// Studio +#include + +class Ui_CorrespondenceQualityPanel; +class JKQTPlotter; + +namespace shapeworks { + +class Session; + +//! Panel for the correspondence quality analysis +/*! + * Runs CorrespondenceQualityJob and presents the result: a per-sample table that + * can be sorted by mean, median or max distance, a box plot of the distribution, + * and options to color the surface by the per-vertex distance and to reorder the + * All Samples view worst-first so the challenging shapes come up front. + */ +class CorrespondenceQualityPanel : public QWidget { + Q_OBJECT; + + public: + CorrespondenceQualityPanel(QWidget* parent = 0); + ~CorrespondenceQualityPanel(); + + //! set the pointer to the session + void set_session(QSharedPointer session); + void reset(); + + //! should the per-vertex distance be shown on the surface? + bool get_display_distance() const; + + //! name of the surface scalar to display + std::string get_display_feature_name() const; + + //! mirror the analysis tool's warp template into this panel's controls + void set_template_info(int index, int maximum, QString name); + + public Q_SLOTS: + + void run_clicked(); + void show_distance_clicked(); + + //! any option that changes how the results are presented: normalization, sort metric or order + void options_changed(); + + void handle_job_progress(double progress); + void handle_job_complete(); + + //! clicking a row jumps the viewer to that sample + void handle_table_clicked(int row, int column); + + void template_changed(); + + Q_SIGNALS: + + //! ask the analysis tool to switch to a samples view, which is where this panel's results show. + //! \p all_samples forces the All Samples grid (sorting only applies there); otherwise the single + //! sample view is left alone if that is where the user already is. + void request_samples_view(bool all_samples); + + //! ask the analysis tool to show one sample, by index into Session::get_shapes() + void request_show_sample(int shape_index); + + //! ask the analysis tool to change the warp template, by index into the non-excluded shapes + void request_template(int index); + void request_template_median(); + + //! ask the analysis tool to rebuild the warper for the current template + void request_apply_template(); + + private: + //! value a row is ranked by, honoring the metric and normalization options + double get_sort_value(const CorrespondenceQualityRow& row) const; + + bool sorting_by_name() const; + bool sorting_by_ratio() const; + bool sort_descending() const; + bool normalized() const; + + //! report row indices in the current sort order + std::vector get_sorted_rows() const; + + void update_run_button(); + void update_summary(); + void update_table(); + void update_graphs(); + + //! push (or clear) the sample ordering used by the All Samples view + void apply_sample_order(); + + QSharedPointer session_; + QSharedPointer job_; + + Ui_CorrespondenceQualityPanel* ui_; +}; +} // namespace shapeworks diff --git a/Studio/Analysis/CorrespondenceQualityPanel.ui b/Studio/Analysis/CorrespondenceQualityPanel.ui new file mode 100644 index 00000000000..3af20a4ef8c --- /dev/null +++ b/Studio/Analysis/CorrespondenceQualityPanel.ui @@ -0,0 +1,476 @@ + + + CorrespondenceQualityPanel + + + + 0 + 0 + 339 + 700 + + + + + 0 + 0 + + + + Form + + + + + +QWidget#widget { + background-color: rgba(0,0,255,255); +} + +QToolButton{ + border: 1px solid rgba( 0, 0, 0, 0 ); + margin: 0, 0, 0, 0; + padding: 0, 0, 0, 0; +} + +QToolButton:hover{ + background-color: rgba( 140, 140, 140, 50 ); +} + +QToolButton:pressed{ + border: 1px solid rgba( 100, 100, 100, 120 ); + background-color: rgba( 100, 100, 100, 200 ); +} + + +/************************************************************************/ + + + + +QLabel#header_label { + color: white; + font: bold; +} + +QPushButton#header { + background-color: qlineargradient(spread:pad, x1:0.5, y1:0, x2:0.5, y2:0.960227, stop:0 rgba(0, 0, 0, 0), stop:0.45 rgba(0, 0, 0, 30), stop:1 rgba(0, 0, 0, 25)); + border-radius: 3px; +} + +QWidget#header_background { + background-color: rgb( 192, 92, 0); + border-radius: 3px; + border: 1px solid rgb(90 90, 90); +} + +QWidget#panel { + border-radius: 4px; + border: 1px solid rgb(60, 60, 60); + color: rgb(90, 90, 90); +} + +/*********************************************/ + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 2 + + + 2 + + + 2 + + + 2 + + + 2 + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 215 + 21 + + + + + 2 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 21 + 21 + + + + + 21 + 21 + + + + ... + + + + :/Studio/Images/RightArrowWhite.png + :/Studio/Images/DownArrowWhite.png:/Studio/Images/RightArrowWhite.png + + + true + + + true + + + + + + + Correspondence Quality + + + + + + + + + + + + + + + + Distance: + + + + + + + How the distance from each reconstructed vertex to the groomed surface is measured + + + + Point to cell + + + + + Point to point + + + + + + + + Template: + + + + + + + + + Use the cohort median shape as the template + + + Median + + + + + + + Sample used as the warp template. Every sample is reconstructed from it, so its own reconstruction is near-identity and is excluded from the statistics. + + + + + + + + + Template Name: + + + + + + + + + + + + + + true + + + Run + + + + + + + + 0 + 0 + + + + 0 + + + + + + + Normalize by bounding box diagonal + + + Show every distance as a percentage of that sample's groomed bounding box diagonal, so samples of different size are comparable. Applies to the summary, the table, the chart and the sort. + + + true + + + + + + + + + + true + + + Qt::TextSelectableByMouse + + + + + + + Show distance on surface + + + Color each sample by its per-vertex distance to the groomed surface (reconstructed view) + + + + + + + Qt::Horizontal + + + + + + + Sort + + + + + + By: + + + + + + + + Mean distance + + + + + Median distance + + + + + Max distance + + + + + Localized (p99 / mean) + + + + + Name + + + + + + + + Order: + + + + + + + + Descending (worst first) + + + + + Ascending (best first) + + + + + + + + Sort samples in view + + + Reorder the samples shown in the All Samples view to match this sort + + + + + + + + + + + 0 + 0 + + + + + 100 + 200 + + + + true + + + QAbstractItemView::SelectRows + + + QAbstractItemView::NoEditTriggers + + + + + + + + 0 + 0 + + + + + 100 + 300 + + + + + + + + + + + + + + + JKQTPlotter + QWidget +
jkqtplotter/jkqtplotter.h
+ 1 +
+
+ + + + +
diff --git a/Studio/Analysis/ParticleAreaPanel.cpp b/Studio/Analysis/ParticleAreaPanel.cpp index 38999780331..e8d4b3e984e 100644 --- a/Studio/Analysis/ParticleAreaPanel.cpp +++ b/Studio/Analysis/ParticleAreaPanel.cpp @@ -130,7 +130,9 @@ void ParticleAreaPanel::display_option_changed() { } //--------------------------------------------------------------------------- -void ParticleAreaPanel::handle_job_progress(int progress) { ui_->progress->setValue(progress * 100); } +void ParticleAreaPanel::handle_job_progress(double progress) { + ui_->progress->setValue(static_cast(progress * 100)); +} //--------------------------------------------------------------------------- void ParticleAreaPanel::handle_job_complete() { diff --git a/Studio/Analysis/ParticleAreaPanel.h b/Studio/Analysis/ParticleAreaPanel.h index 1d25e0e17b0..5301ac1baf5 100644 --- a/Studio/Analysis/ParticleAreaPanel.h +++ b/Studio/Analysis/ParticleAreaPanel.h @@ -48,7 +48,7 @@ class ParticleAreaPanel : public QWidget { void display_option_changed(); - void handle_job_progress(int progress); + void handle_job_progress(double progress); void handle_job_complete(); Q_SIGNALS: diff --git a/Studio/Analysis/ShapeScalarPanel.cpp b/Studio/Analysis/ShapeScalarPanel.cpp index 0e420df1004..62ad6133c7a 100644 --- a/Studio/Analysis/ShapeScalarPanel.cpp +++ b/Studio/Analysis/ShapeScalarPanel.cpp @@ -104,7 +104,9 @@ void ShapeScalarPanel::run_clicked() { } //--------------------------------------------------------------------------- -void ShapeScalarPanel::handle_job_progress(int progress) { ui_->progress->setValue(progress * 100); } +void ShapeScalarPanel::handle_job_progress(double progress) { + ui_->progress->setValue(static_cast(progress * 100)); +} //--------------------------------------------------------------------------- void ShapeScalarPanel::handle_job_complete() { diff --git a/Studio/Analysis/ShapeScalarPanel.h b/Studio/Analysis/ShapeScalarPanel.h index d87fd72ce51..6cefe0ce8c7 100644 --- a/Studio/Analysis/ShapeScalarPanel.h +++ b/Studio/Analysis/ShapeScalarPanel.h @@ -40,7 +40,7 @@ class ShapeScalarPanel : public QWidget { void run_clicked(); - void handle_job_progress(int progress); + void handle_job_progress(double progress); void handle_job_complete(); Q_SIGNALS: diff --git a/Studio/CMakeLists.txt b/Studio/CMakeLists.txt index 6a5e028210c..5e320f9e455 100644 --- a/Studio/CMakeLists.txt +++ b/Studio/CMakeLists.txt @@ -102,6 +102,7 @@ SET(STUDIO_DATA_MOC_HDRS ) SET(STUDIO_JOB_SRCS + Job/CorrespondenceQualityJob.cpp Job/GroupPvalueJob.cpp Job/ParticleAreaJob.cpp Job/NetworkAnalysisJob.cpp @@ -112,6 +113,7 @@ SET(STUDIO_JOB_SRCS ) SET(STUDIO_JOB_MOC_HDRS + Job/CorrespondenceQualityJob.h Job/GroupPvalueJob.h Job/NetworkAnalysisJob.h Job/ParticleAreaJob.h @@ -139,12 +141,14 @@ SET(STUDIO_OPTIMIZE_MOC_HDRS SET(STUDIO_ANALYSIS_SRCS Analysis/AnalysisTool.cpp + Analysis/CorrespondenceQualityPanel.cpp Analysis/ParticleAreaPanel.cpp Analysis/ShapeEvaluationJob.cpp Analysis/ShapeScalarPanel.cpp ) SET(STUDIO_ANALYSIS_MOC_HDRS Analysis/AnalysisTool.h + Analysis/CorrespondenceQualityPanel.h Analysis/ParticleAreaPanel.h Analysis/ShapeEvaluationJob.h Analysis/ShapeScalarPanel.h diff --git a/Studio/Data/Session.cpp b/Studio/Data/Session.cpp index a4bd48e1a25..f07608dd0aa 100644 --- a/Studio/Data/Session.cpp +++ b/Studio/Data/Session.cpp @@ -744,6 +744,28 @@ ShapeList Session::get_non_excluded_shapes() { return non_excluded_shapes; } +//--------------------------------------------------------------------------- +ShapeList Session::get_ordered_shapes() { + if (shape_display_order_.size() != shapes_.size()) { + return shapes_; + } + ShapeList ordered; + ordered.reserve(shapes_.size()); + for (int index : shape_display_order_) { + if (index < 0 || index >= static_cast(shapes_.size())) { + return shapes_; // stale order, fall back to natural + } + ordered.push_back(shapes_[index]); + } + return ordered; +} + +//--------------------------------------------------------------------------- +void Session::set_shape_display_order(const std::vector& order) { shape_display_order_ = order; } + +//--------------------------------------------------------------------------- +std::vector Session::get_shape_display_order() { return shape_display_order_; } + //--------------------------------------------------------------------------- void Session::remove_shapes(QList list) { std::sort(list.begin(), list.end(), std::greater<>()); diff --git a/Studio/Data/Session.h b/Studio/Data/Session.h index 400add27479..368a1eacd2a 100644 --- a/Studio/Data/Session.h +++ b/Studio/Data/Session.h @@ -117,6 +117,17 @@ class Session : public QObject, public QEnableSharedFromThis { //! return all non-excluded shapes ShapeList get_non_excluded_shapes(); + //! return all shapes in the current sample display order + ShapeList get_ordered_shapes(); + + //! set an alternate ordering for the sample display, as indices into get_shapes(). + //! An empty or invalid order restores the natural order. This affects only the + //! all-samples display; indices used elsewhere (stats, PCA, warp template) are unchanged. + void set_shape_display_order(const std::vector& order); + + //! get the current sample display order (empty when natural) + std::vector get_shape_display_order(); + void calculate_reconstructed_samples(); /// get the filename @@ -356,6 +367,9 @@ class Session : public QObject, public QEnableSharedFromThis { /// collection of shapes ShapeList shapes_; + /// alternate ordering for the sample display, as indices into shapes_ (empty = natural order) + std::vector shape_display_order_; + Particles difference_particles_; std::shared_ptr mesh_manager_; diff --git a/Studio/DeepSSM/DeepSSMTool.cpp b/Studio/DeepSSM/DeepSSMTool.cpp index c5a5c3f3cc1..68b424260bb 100644 --- a/Studio/DeepSSM/DeepSSMTool.cpp +++ b/Studio/DeepSSM/DeepSSMTool.cpp @@ -23,6 +23,7 @@ #include #include #include +#include // vtk #include @@ -49,20 +50,20 @@ DeepSSMTool::DeepSSMTool(Preferences& prefs) : preferences_(prefs) { ui_->tl_ae_epochs->setToolTip("Number of epochs to train the autoencoder"); ui_->tl_tf_epochs->setToolTip("Number of epochs to train the T-flank"); ui_->tl_joint_epochs->setToolTip("Number of epochs to train the whole model"); - ui_->tl_alpha->setToolTip( - "The weight applied to the T-flank with respect to the autoencoder loss when training the whole model."); - ui_->tl_ae_a->setToolTip( + ui_->tl_alpha->setToolTip(StudioUtils::wrap_tooltip( + "The weight applied to the T-flank with respect to the autoencoder loss when training the whole model.")); + ui_->tl_ae_a->setToolTip(StudioUtils::wrap_tooltip( "The autoencoder focal loss scaling factor adjusts the intensity of the focal loss.\nHigher values accentuate " - "the loss, while lower values dampen it."); - ui_->tl_ae_c->setToolTip( + "the loss, while lower values dampen it.")); + ui_->tl_ae_c->setToolTip(StudioUtils::wrap_tooltip( "The autoencoder focal loss threshold parameter modulates the loss contribution of each particle.\nWhen the " - "particle difference is below the threshold, the particle's impact on the overall loss is reduced."); - ui_->tl_lat_a->setToolTip( + "particle difference is below the threshold, the particle's impact on the overall loss is reduced.")); + ui_->tl_lat_a->setToolTip(StudioUtils::wrap_tooltip( "The T-flank focal loss scaling factor adjusts the intensity of the focal loss.\nHigher values accentuate the " - "loss, while lower values dampen it."); - ui_->tl_lat_c->setToolTip( + "loss, while lower values dampen it.")); + ui_->tl_lat_c->setToolTip(StudioUtils::wrap_tooltip( "The T-flank focal loss threshold parameter modulates the loss contribution of each particle.\nWhen the particle " - "difference is below the threshold, the particle's impact on the overall loss is reduced."); + "difference is below the threshold, the particle's impact on the overall loss is reduced.")); #ifdef Q_OS_MACOS ui_->tab_widget->tabBar()->setMinimumWidth(300); diff --git a/Studio/Groom/GroomTool.cpp b/Studio/Groom/GroomTool.cpp index 68ea36c6eab..20f2e8b82ad 100644 --- a/Studio/Groom/GroomTool.cpp +++ b/Studio/Groom/GroomTool.cpp @@ -67,15 +67,15 @@ GroomTool::GroomTool(Preferences& prefs, Telemetry& telemetry) : preferences_(pr ui_->remesh_checkbox->setToolTip( "Enable remeshing to create a more uniform adaptive mesh. Also fixes many mesh problems."); - ui_->remesh_percent_checkbox->setToolTip( - "Check this box to set the number of vertices based on a percentage of the mesh's current number of vertices"); + ui_->remesh_percent_checkbox->setToolTip(StudioUtils::wrap_tooltip( + "Check this box to set the number of vertices based on a percentage of the mesh's current number of vertices")); ui_->remesh_percent_slider->setToolTip( "Set the amount of vertices as a percentage of the current number of vertices."); ui_->remesh_num_vertices->setToolTip("Set the desired number of vertices."); - ui_->remesh_gradation_slider->setToolTip( - "Set the adaptivity of remeshing, higher will allocate more triangles around areas of high curvature."); - ui_->remesh_gradation_spinbox->setToolTip( - "Set the adaptivity of remeshing, higher will allocate more triangles around areas of high curvature."); + ui_->remesh_gradation_slider->setToolTip(StudioUtils::wrap_tooltip( + "Set the adaptivity of remeshing, higher will allocate more triangles around areas of high curvature.")); + ui_->remesh_gradation_spinbox->setToolTip(StudioUtils::wrap_tooltip( + "Set the adaptivity of remeshing, higher will allocate more triangles around areas of high curvature.")); ui_->shared_boundary->setToolTip("Check this box to model the shared boundary between two domains."); ui_->shared_boundary_tolerance->setToolTip("Set the tolerance for the shared boundary."); diff --git a/Studio/Job/CorrespondenceQualityJob.cpp b/Studio/Job/CorrespondenceQualityJob.cpp new file mode 100644 index 00000000000..5eadefa2e6e --- /dev/null +++ b/Studio/Job/CorrespondenceQualityJob.cpp @@ -0,0 +1,162 @@ +#include "CorrespondenceQualityJob.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace shapeworks { + +//--------------------------------------------------------------------------- +CorrespondenceQualityJob::CorrespondenceQualityJob(QSharedPointer session, + CorrespondenceEvaluation::DistanceMethod method) + : session_(session), method_(method) {} + +//--------------------------------------------------------------------------- +void CorrespondenceQualityJob::run() { + SW_DEBUG("Running correspondence quality job"); + Q_EMIT progress(0); + + report_ = CorrespondenceQualityReport(); + row_shape_indices_.clear(); + particle_values_.clear(); + distance_fields_.clear(); + + auto shapes = session_->get_shapes(); + auto non_excluded = session_->get_non_excluded_shapes(); + + // The template is whichever shape Studio is warping from, so its reconstruction is + // near-identity. Mark it here so it can be excluded from the aggregates. + int template_shape_index = -1; + auto params = session_->get_project()->get_parameters(Parameters::ANALYSIS_PARAMS); + int template_index = params.get(AnalysisTool::MESH_WARP_TEMPLATE_INDEX, -1); + if (template_index >= 0 && template_index < static_cast(non_excluded.size())) { + auto template_shape = non_excluded[template_index]; + for (int i = 0; i < static_cast(shapes.size()); i++) { + if (shapes[i] == template_shape) { + template_shape_index = i; + report_.template_subject = shapes[i]->get_display_name(); + break; + } + } + } + + const float total = std::max(1, non_excluded.size()); + float count = 0; + + for (int s = 0; s < static_cast(shapes.size()); s++) { + auto& shape = shapes[s]; + if (shape->is_excluded()) { + continue; + } + + auto reconstructed = shape->get_reconstructed_meshes(true); + auto groomed = shape->get_groomed_meshes(true); + + if (!reconstructed.valid() || !groomed.valid()) { + SW_LOG("Correspondence quality: skipping '{}', reconstructed or groomed mesh unavailable", + shape->get_display_name()); + count++; + Q_EMIT progress(count / total); + continue; + } + + const int num_domains = + std::min(reconstructed.meshes().size(), groomed.meshes().size()); + + // the distance sampled at each particle, all domains concatenated, which is the order + // Shape stores point features in + std::vector per_particle; + + for (int d = 0; d < num_domains; d++) { + auto reconstructed_poly_data = reconstructed.meshes()[d]->get_poly_data(); + auto groomed_poly_data = groomed.meshes()[d]->get_poly_data(); + if (!reconstructed_poly_data || !groomed_poly_data) { + continue; + } + + Mesh groomed_mesh(groomed_poly_data); + vtkSmartPointer distance; + auto row = CorrespondenceEvaluation::evaluate_reconstruction(reconstructed_poly_data, groomed_mesh, method_, + &distance); + if (!distance) { + continue; + } + + row.subject = shape->get_display_name(); + row.domain = d; + row.is_template = (s == template_shape_index); + report_.rows.push_back(row); + row_shape_indices_.push_back(s); + + // leave the per-vertex field on the reconstructed mesh so it can be shown as a surface scalar + distance->SetName(FEATURE_NAME); + reconstructed_poly_data->GetPointData()->AddArray(distance); + distance_fields_[s].push_back(distance); + + // Color each particle by the error around it rather than at it. The warp inserts the + // particles into the mesh as vertices and maps them onto this shape's particles, which lie on + // its surface, so the distance at a particle is zero by construction and sampling there would + // give every glyph the same value. All the signal is in the gaps between particles, so + // assign every vertex to its nearest particle and average over that neighbourhood. + auto particles = shape->get_particles().get_local_points(d); + + auto particle_points = vtkSmartPointer::New(); + for (auto& particle : particles) { + particle_points->InsertNextPoint(particle[0], particle[1], particle[2]); + } + auto particle_poly_data = vtkSmartPointer::New(); + particle_poly_data->SetPoints(particle_points); + + auto locator = vtkSmartPointer::New(); + locator->SetDataSet(particle_poly_data); + locator->BuildLocator(); + + std::vector sums(particles.size(), 0.0); + std::vector counts(particles.size(), 0); + for (vtkIdType v = 0; v < reconstructed_poly_data->GetNumberOfPoints(); v++) { + double vertex[3]; + reconstructed_poly_data->GetPoint(v, vertex); + vtkIdType id = locator->FindClosestPoint(vertex); + if (id >= 0 && id < static_cast(sums.size())) { + sums[id] += std::fabs(distance->GetTuple1(v)); + counts[id]++; + } + } + for (size_t i = 0; i < particles.size(); i++) { + per_particle.push_back(counts[i] > 0 ? sums[i] / counts[i] : 0.0); + } + } + + if (!per_particle.empty()) { + Eigen::VectorXd values(per_particle.size()); + for (int i = 0; i < static_cast(per_particle.size()); i++) { + values(i) = per_particle[i]; + } + particle_values_[s] = values; + } + + count++; + Q_EMIT progress(count / total); + if (is_aborted()) { + return; + } + } + + CorrespondenceEvaluation::compute_aggregates(report_); + + if (report_.rows.empty()) { + SW_ERROR("Correspondence quality: no samples could be evaluated"); + set_failed(); + } +} + +} // namespace shapeworks diff --git a/Studio/Job/CorrespondenceQualityJob.h b/Studio/Job/CorrespondenceQualityJob.h new file mode 100644 index 00000000000..a6047a19a7a --- /dev/null +++ b/Studio/Job/CorrespondenceQualityJob.h @@ -0,0 +1,60 @@ +#pragma once +#include +#include + +#include +#include +#include +#include + +namespace shapeworks { + +class Session; + +//! Scores each sample's correspondence quality. +/*! + * Reconstructs each sample through Studio's own configured mesh warper (the same + * reconstruction shown in the viewer, using the user's chosen template and warp + * method) and measures the distance from that reconstruction back to the sample's + * groomed mesh. The per-vertex distance field is left on each reconstructed mesh + * under FEATURE_NAME so it can be displayed as a surface scalar. + */ +class CorrespondenceQualityJob : public Job { + Q_OBJECT + public: + CorrespondenceQualityJob(QSharedPointer session, CorrespondenceEvaluation::DistanceMethod method); + + void run() override; + QString name() override { return "Correspondence Quality"; } + + //! name of the per-vertex distance array attached to each reconstructed mesh + static constexpr const char* FEATURE_NAME = "correspondence_distance"; + + const CorrespondenceQualityReport& get_report() const { return report_; } + + //! index into Session::get_shapes() for each row of the report + const std::vector& get_row_shape_indices() const { return row_shape_indices_; } + + //! the distance field sampled at each particle, keyed by index into Session::get_shapes(). + //! The glyphs are colored from these, so they need to be applied to the shapes (on the GUI + //! thread) with Shape::set_point_features() before the field can be displayed. + const std::map& get_particle_values() const { return particle_values_; } + + //! the per-vertex distance field for each domain of each shape, keyed by index into + //! Session::get_shapes(). Shape::set_point_features() interpolates the particle values back over + //! the mesh under the same name, so these have to be re-applied after it to survive. + const std::map>>& get_distance_fields() const { + return distance_fields_; + } + + private: + QSharedPointer session_; + CorrespondenceEvaluation::DistanceMethod method_; + + CorrespondenceQualityReport report_; + std::vector row_shape_indices_; + std::map particle_values_; + std::map>> distance_fields_; +}; + +} // namespace shapeworks diff --git a/Studio/Optimize/OptimizeTool.cpp b/Studio/Optimize/OptimizeTool.cpp index 8b64db2d00b..0666b9a3ee8 100644 --- a/Studio/Optimize/OptimizeTool.cpp +++ b/Studio/Optimize/OptimizeTool.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include using namespace shapeworks; @@ -89,9 +90,9 @@ OptimizeTool::OptimizeTool(Preferences& prefs, Telemetry& telemetry) : preferenc ui_->ending_regularization->setToolTip("Ending regularization of correspondence covariance matrix"); ui_->iterations_per_split->setToolTip("Number of iterations for each particle split"); ui_->optimization_iterations->setToolTip("Number of optimizations to run"); - ui_->use_geodesic_distance->setToolTip( + ui_->use_geodesic_distance->setToolTip(StudioUtils::wrap_tooltip( "Use geodesic distances for sampling term: may be more effective for capturing thin features. " - "Requires ~10x more time, and larger memory footprint. Only supported for mesh inputs"); + "Requires ~10x more time, and larger memory footprint. Only supported for mesh inputs")); ui_->geodesic_remesh_percent->setToolTip("Percent remesh reduction to use for geodesic distance"); ui_->use_normals->setToolTip("Use surface normals as part of optimization"); ui_->normals_strength->setToolTip("Strength of surface normals relative to position"); @@ -102,27 +103,27 @@ OptimizeTool::OptimizeTool(Preferences& prefs, Telemetry& telemetry) : preferenc ui_->multiscale->setToolTip("Use multiscale optimization mode"); ui_->multiscale_particles->setToolTip("Start multiscale optimization after this many particles"); ui_->use_landmarks->setToolTip("Use landmarks as initial particles"); - ui_->registration_initialization->setToolTip( + ui_->registration_initialization->setToolTip(StudioUtils::wrap_tooltip( "Spread particles over a single automatically chosen reference shape, then carry them onto every " "other shape by deformably registering the reference to it, instead of splitting particles on all " - "shapes at once. Cannot be combined with landmarks or fixed subjects"); - ui_->registration_transform_type->setToolTip( + "shapes at once. Cannot be combined with landmarks or fixed subjects")); + ui_->registration_transform_type->setToolTip(StudioUtils::wrap_tooltip( "Registration stages to run when transferring particles. SyN runs rigid, then affine, then " - "symmetric normalization"); - ui_->registration_band->setToolTip( + "symmetric normalization")); + ui_->registration_band->setToolTip(StudioUtils::wrap_tooltip( "Half-width, in physical units, of the band around the surface that registration considers. " "Leave empty to scale it automatically from the resolution of the groomed inputs (a few voxels), " - "which is the recommended default"); + "which is the recommended default")); ui_->registration_band->setPlaceholderText("auto"); - ui_->registration_grid_size->setToolTip( + ui_->registration_grid_size->setToolTip(StudioUtils::wrap_tooltip( "Rasterization resolution for mesh registration: voxels across the largest dimension. Lower is " "faster and coarser, higher is finer; 128 is the default. Above ~192 gives little benefit at " - "much higher time and cache cost. Ignored for image (distance-transform) inputs"); - ui_->narrow_band->setToolTip( + "much higher time and cache cost. Ignored for image (distance-transform) inputs")); + ui_->narrow_band->setToolTip(StudioUtils::wrap_tooltip( "Narrow band around distance transforms. " "This value should only be changed if an error occurs " "during optimization suggesting that it should be increased. " - "It has no effect on the optimization"); + "It has no effect on the optimization")); ui_->shared_boundary->setToolTip("Use shared boundary optimization"); ui_->shared_boundary_weight->setToolTip("Weight of shared boundary optimization"); ui_->sampling_scale->setToolTip("Enable sampling gradient scaling"); diff --git a/Studio/Utils/AnalysisUtils.cpp b/Studio/Utils/AnalysisUtils.cpp index 4612c77ecc6..377fff1cbf4 100644 --- a/Studio/Utils/AnalysisUtils.cpp +++ b/Studio/Utils/AnalysisUtils.cpp @@ -3,10 +3,13 @@ #include #include #include +#include #include #include #include +#include + namespace shapeworks { //--------------------------------------------------------------------------- @@ -104,7 +107,109 @@ void AnalysisUtils::create_box_plot(JKQTPlotter* plot, Eigen::VectorXd data, QSt plot->setMousePositionShown(false); plot->setMinimumSize(250, 250); plot->zoomToFit(); + // setColor() above only mutates style state; without this the widget can keep showing the + // render triggered by addGraph(), which still has the auto-assigned palette color + plot->redrawPlot(); } + +//--------------------------------------------------------------------------- +void AnalysisUtils::create_ranked_plot(JKQTPlotter* plot, const std::vector& series, + const std::vector& reference_lines, QString title, QString x_label, + QString y_label, bool log_y, KeyCorner key_corner) { + JKQTPDatastore* ds = plot->getDatastore(); + ds->clear(); + plot->clearGraphs(); + + int num_points = 0; + double min_value = std::numeric_limits::max(); + for (const auto& s : series) { + num_points = std::max(num_points, s.values.size()); + for (int i = 0; i < s.values.size(); i++) { + min_value = std::min(min_value, s.values[i]); + } + } + if (num_points == 0) { + plot->redrawPlot(); + return; + } + + QVector x; + for (int i = 0; i < num_points; i++) { + x << i + 1; + } + size_t column_x = ds->addCopiedColumn(x, x_label); + + // a log axis cannot show a zero or negative value, so only use it when the data allows + const bool use_log = log_y && min_value > 0; + plot->getYAxis()->setLogAxis(use_log); + + // reference lines go in first so the series are drawn over them + for (double value : reference_lines) { + if (use_log && value <= 0) { + continue; + } + auto* line = new JKQTPGeoInfiniteLine(plot->getPlotter(), 0, value, 1, 0, QColor(120, 120, 120), 1, Qt::DashLine); + line->setTwoSided(true); + plot->addGraph(line); + } + + for (const auto& s : series) { + QVector y; + for (int i = 0; i < s.values.size(); i++) { + y << s.values[i]; + } + size_t column_y = ds->addCopiedColumn(y, s.label); + + auto* graph = new JKQTPXYLineGraph(plot); + graph->setColor(s.color); + graph->setSymbolType(JKQTPNoSymbol); + graph->setLineWidth(2); + graph->setXColumn(column_x); + graph->setYColumn(column_y); + graph->setTitle(s.label); + plot->addGraph(graph); + } + + plot->getPlotter()->setUseAntiAliasingForGraphs(true); + plot->getPlotter()->setUseAntiAliasingForSystem(true); + plot->getPlotter()->setUseAntiAliasingForText(true); + plot->getPlotter()->setPlotLabelFontSize(18); + plot->getPlotter()->setPlotLabel("\\textbf{" + title + "}"); + plot->getPlotter()->setDefaultTextSize(14); + plot->getPlotter()->setShowKey(series.size() > 1); + plot->getPlotter()->setKeyFontSize(10); + // an outside key gets clipped in a panel this narrow, so tuck it into whichever bottom corner the + // caller says the data leaves free + plot->getPlotter()->setKeyPosition(key_corner == KeyCorner::BottomRight ? JKQTPKeyInsideBottomRight + : JKQTPKeyInsideBottomLeft); + + // setLabelFontSize() is the axis *title*; the tick numbers have their own, much smaller, default + plot->getXAxis()->setAxisLabel(x_label); + plot->getXAxis()->setLabelFontSize(14); + plot->getXAxis()->setTickLabelFontSize(12); + plot->getYAxis()->setAxisLabel(y_label); + plot->getYAxis()->setLabelFontSize(14); + plot->getYAxis()->setTickLabelFontSize(12); + + if (use_log) { + // a log axis only labels whole decades, which over this range means just two numbers, so label + // the minor ticks as well + plot->getYAxis()->setMinorTickLabelsEnabled(true); + plot->getYAxis()->setMinorTickLabelFullNumber(true); + plot->getYAxis()->setMinorTickLabelFontSize(9); + // the axis drops any minor label that would collide with its neighbour, so ask for the denser + // set and let it keep whatever fits + plot->getYAxis()->setMinorTicks(4); + } + + plot->getPlotter()->setPlotBorderBottom(10); + + plot->clearAllMouseWheelActions(); + plot->setMousePositionShown(false); + plot->zoomToFit(); + plot->redrawPlot(); +} + //--------------------------------------------------------------------------- } // namespace shapeworks diff --git a/Studio/Utils/AnalysisUtils.h b/Studio/Utils/AnalysisUtils.h index 91abf504717..f4e2bc8ee58 100644 --- a/Studio/Utils/AnalysisUtils.h +++ b/Studio/Utils/AnalysisUtils.h @@ -19,6 +19,23 @@ class AnalysisUtils { static void create_box_plot(JKQTPlotter* plot, Eigen::VectorXd data, QString title, QString x_label, QColor color = Qt::blue); + + //! which corner the key sits in, so it can be kept clear of the data + enum class KeyCorner { BottomLeft, BottomRight }; + + //! one line of a ranked plot + struct RankedSeries { + Eigen::VectorXd values; + QString label; + QColor color{Qt::blue}; + }; + + //! Per-sample ranked plot: each series drawn as a line over the sample index, with optional + //! horizontal reference lines. A log y axis keeps series of very different magnitude readable, + //! and is ignored when any value is non-positive. + static void create_ranked_plot(JKQTPlotter* plot, const std::vector& series, + const std::vector& reference_lines, QString title, QString x_label, + QString y_label, bool log_y, KeyCorner key_corner = KeyCorner::BottomLeft); }; } // namespace shapeworks diff --git a/Studio/Utils/StudioUtils.cpp b/Studio/Utils/StudioUtils.cpp index e31c42fde9f..2f5cda6813e 100644 --- a/Studio/Utils/StudioUtils.cpp +++ b/Studio/Utils/StudioUtils.cpp @@ -11,8 +11,13 @@ #include #include +#include +#include #include +#include +#include #include +#include namespace shapeworks { @@ -212,4 +217,86 @@ void StudioUtils::update_domain_combobox(QComboBox* combobox, QSharedPointersetCurrentIndex(currentIndex); } } +//--------------------------------------------------------------------------- +static QString csv_escape(const QString& value) { + // a field containing a comma, quote or newline has to be quoted, with quotes doubled + if (value.contains(',') || value.contains('"') || value.contains('\n')) { + QString escaped = value; + escaped.replace("\"", "\"\""); + return "\"" + escaped + "\""; + } + return value; +} + +//--------------------------------------------------------------------------- +void StudioUtils::copy_table_to_clipboard(QTableWidget* table) { + if (!table) { + return; + } + + QStringList lines; + + QStringList header; + for (int c = 0; c < table->columnCount(); c++) { + auto item = table->horizontalHeaderItem(c); + header << csv_escape(item ? item->text() : QString()); + } + lines << header.join(","); + + for (int r = 0; r < table->rowCount(); r++) { + QStringList row; + for (int c = 0; c < table->columnCount(); c++) { + auto item = table->item(r, c); + row << csv_escape(item ? item->text() : QString()); + } + lines << row.join(","); + } + + QApplication::clipboard()->setText(lines.join("\n") + "\n"); +} + +//--------------------------------------------------------------------------- +void StudioUtils::add_table_copy_menu(QTableWidget* table) { + if (!table) { + return; + } + table->setContextMenuPolicy(Qt::CustomContextMenu); + QObject::connect(table, &QTableWidget::customContextMenuRequested, table, [table]() { + QMenu menu; + QAction* action = menu.addAction("Copy to Clipboard"); + QObject::connect(action, &QAction::triggered, table, [table]() { copy_table_to_clipboard(table); }); + menu.exec(QCursor::pos()); + }); +} + +//--------------------------------------------------------------------------- +QString StudioUtils::wrap_tooltip(const QString& text, int wrap_chars) { + QStringList lines; + for (const QString& paragraph : text.split('\n')) { // keep any breaks the text already asked for + QString line; + for (const QString& word : paragraph.simplified().split(' ')) { + if (!line.isEmpty() && line.length() + 1 + word.length() > wrap_chars) { + lines << line; + line.clear(); + } + if (!line.isEmpty()) { + line += " "; + } + line += word; + } + lines << line; + } + + if (lines.size() < 2) { + return text; + } + + // Qt only breaks a tooltip when it is rich text, and it re-wraps rich text into a narrow block of its own + // choosing unless the lines are marked unbreakable, so escape the text and give it explicit breaks + for (QString& line : lines) { + line = line.toHtmlEscaped(); + } + return "

" + lines.join("
") + "

"; +} + } // namespace shapeworks diff --git a/Studio/Utils/StudioUtils.h b/Studio/Utils/StudioUtils.h index 9f1e12477dc..40db4ea5e06 100644 --- a/Studio/Utils/StudioUtils.h +++ b/Studio/Utils/StudioUtils.h @@ -12,6 +12,7 @@ class QWidget; class vtkImageData; class vtkRenderer; class QComboBox; +class QTableWidget; namespace shapeworks { @@ -52,6 +53,15 @@ class StudioUtils { //! update a combobox with domain names static void update_domain_combobox(QComboBox* combobox, QSharedPointer session, const std::vector& filters = {}); + + //! copy the contents of a table, with its header row, to the clipboard as CSV + static void copy_table_to_clipboard(QTableWidget* table); + + //! give a table a right click "Copy to Clipboard" menu + static void add_table_copy_menu(QTableWidget* table); + + //! break a long tooltip into multiple lines + static QString wrap_tooltip(const QString& text, int wrap_chars = 80); }; } // namespace shapeworks diff --git a/Studio/Visualization/Lightbox.cpp b/Studio/Visualization/Lightbox.cpp index aeba65a25e1..06f771dc7e8 100644 --- a/Studio/Visualization/Lightbox.cpp +++ b/Studio/Visualization/Lightbox.cpp @@ -16,6 +16,7 @@ #include #include +#include namespace shapeworks { @@ -89,6 +90,7 @@ void Lightbox::handle_new_mesh() { } redraw(); check_for_first_draw(); + check_for_pending_camera_reset(); } //----------------------------------------------------------------------------- @@ -248,8 +250,21 @@ void Lightbox::set_tile_layout(int width, int height) { tile_layout_width_ = width; tile_layout_height_ = height; + // The viewers share one camera and setup_renderers() gives every viewer a new viewport, so the + // camera is still framed for the old layout. Don't refit here: the layout is changed before the + // new shapes are handed over (AnalysisTool emits analysis_mode_changed before update_view), and + // the meshes are generated asynchronously, so there may be nothing yet worth framing. + camera_reset_pending_ = true; + camera_reset_attempts_ = 0; + setup_renderers(); display_shapes(); + + // Don't try to frame anything yet: the layout is changed before the new shapes are installed + // (AnalysisTool emits analysis_mode_changed before update_view), so shapes_ is still whatever the + // previous mode was showing. Defer to the end of this update cycle, by which point set_shapes() + // has run. + QTimer::singleShot(0, this, [this]() { check_for_pending_camera_reset(); }); } //----------------------------------------------------------------------------- @@ -269,6 +284,7 @@ void Lightbox::set_start_row(int row) { void Lightbox::set_shapes(ShapeList shapes) { shapes_ = shapes; display_shapes(); + check_for_pending_camera_reset(); } //----------------------------------------------------------------------------- @@ -421,6 +437,37 @@ void Lightbox::check_for_first_draw() { } } +//----------------------------------------------------------------------------- +void Lightbox::check_for_pending_camera_reset() { + if (!camera_reset_pending_ || viewers_.empty()) { + return; + } + + // tiles are filled from position 0, so only the ones backed by a shape can be framed + const int start = get_start_shape(); + const int filled = std::min(viewers_.size(), std::max(0, static_cast(shapes_.size()) - start)); + + // is_viewer_ready() is not the right gate: display_shape() clears it when the mesh is missing but + // update_points() sets it straight back from the particles alone, so it goes true while only the + // glyphs are up. Framing that gives the wrong result, so wait for the surface itself. + if (filled == 0 || !viewers_[0]->get_meshes().valid()) { + return; // nothing to frame yet, try again when the next mesh arrives + } + + reset_camera(); + + bool all_ready = true; + for (int i = 0; i < filled; i++) { + if (!viewers_[i]->get_meshes().valid()) { + all_ready = false; + } + } + // keep refitting as the remaining meshes arrive, then stop so the user's camera is left alone. + // The attempt cap makes sure a tile whose mesh never generates cannot leave this armed forever. + camera_reset_attempts_++; + camera_reset_pending_ = !all_ready && camera_reset_attempts_ < 2 * static_cast(viewers_.size()); +} + //----------------------------------------------------------------------------- void Lightbox::set_orientation_marker(Preferences::OrientationMarkerType type, Preferences::OrientationMarkerCorner corner) { diff --git a/Studio/Visualization/Lightbox.h b/Studio/Visualization/Lightbox.h index e5a5102ed92..e4530f3334f 100644 --- a/Studio/Visualization/Lightbox.h +++ b/Studio/Visualization/Lightbox.h @@ -110,6 +110,9 @@ class Lightbox : public QObject { void check_for_first_draw(); + //! Refit the shared camera after a tile layout change, once the tiles actually have content + void check_for_pending_camera_reset(); + void display_shapes(); void insert_shape_into_viewer(std::shared_ptr shape, int position); @@ -134,6 +137,10 @@ class Lightbox : public QObject { bool first_draw_ = true; + //! set when the tile layout changes, cleared once every visible tile has been drawn + bool camera_reset_pending_ = false; + int camera_reset_attempts_ = 0; + vtkSmartPointer style_; vtkSmartPointer slice_style_; diff --git a/Studio/Visualization/Visualizer.cpp b/Studio/Visualization/Visualizer.cpp index ee646daa0f5..d81a3506839 100644 --- a/Studio/Visualization/Visualizer.cpp +++ b/Studio/Visualization/Visualizer.cpp @@ -74,7 +74,7 @@ void Visualizer::set_center(bool center) { center_ = center; } //----------------------------------------------------------------------------- void Visualizer::display_samples() { update_viewer_properties(); - auto shapes = session_->get_shapes(); + auto shapes = session_->get_ordered_shapes(); display_shapes(shapes); } diff --git a/Testing/ParticlesTests/ParticlesTests.cpp b/Testing/ParticlesTests/ParticlesTests.cpp index de823bb8a27..de22a222eac 100644 --- a/Testing/ParticlesTests/ParticlesTests.cpp +++ b/Testing/ParticlesTests/ParticlesTests.cpp @@ -1,7 +1,9 @@ #include #include +#include "CorrespondenceEvaluation.h" #include "Libs/Optimize/Domain/Surface.h" +#include "Mesh/Mesh.h" #include "ParticleNormalEvaluation.h" #include "ParticleShapeStatistics.h" #include "ParticleSystemEvaluation.h" @@ -295,3 +297,117 @@ TEST(ParticlesTests, particle_normal_evaluation_test) } //--------------------------------------------------------------------------- +//--------------------------------------------------------------------------- +namespace { + +//! flat NxN grid of triangles in the z=0 plane, spanning [0,1] in x and y +Mesh make_grid_mesh(int n) { + Eigen::MatrixXd points(n * n, 3); + for (int y = 0; y < n; y++) { + for (int x = 0; x < n; x++) { + points.row(y * n + x) << static_cast(x) / (n - 1), static_cast(y) / (n - 1), 0.0; + } + } + + Eigen::MatrixXi faces(2 * (n - 1) * (n - 1), 3); + int f = 0; + for (int y = 0; y < n - 1; y++) { + for (int x = 0; x < n - 1; x++) { + const int i = y * n + x; + faces.row(f++) << i, i + 1, i + n; + faces.row(f++) << i + 1, i + n + 1, i + n; + } + } + return Mesh(points, faces); +} + +} // namespace + +//--------------------------------------------------------------------------- +TEST(CorrespondenceEvaluationTests, identicalMeshesHaveNoDistance) { + Mesh mesh = make_grid_mesh(10); + + auto row = CorrespondenceEvaluation::evaluate_reconstruction(mesh.getVTKMesh(), mesh, + CorrespondenceEvaluation::DistanceMethod::PointToCell); + + ASSERT_NEAR(row.mean_dist, 0.0, 1e-9); + ASSERT_NEAR(row.median_dist, 0.0, 1e-9); + ASSERT_NEAR(row.p99_dist, 0.0, 1e-9); + ASSERT_NEAR(row.max_dist, 0.0, 1e-9); + ASSERT_GT(row.bbox_diag, 0.0); +} + +//--------------------------------------------------------------------------- +TEST(CorrespondenceEvaluationTests, uniformOffsetMeasuresThatOffset) { + const double offset = 0.25; + Mesh groomed = make_grid_mesh(10); + + Mesh shifted = make_grid_mesh(10); + shifted.translate(makeVector({0, 0, offset})); + + auto row = CorrespondenceEvaluation::evaluate_reconstruction(shifted.getVTKMesh(), groomed, + CorrespondenceEvaluation::DistanceMethod::PointToCell); + + // every vertex is the same distance from the target plane, so all the statistics agree + ASSERT_NEAR(row.mean_dist, offset, 1e-6); + ASSERT_NEAR(row.median_dist, offset, 1e-6); + ASSERT_NEAR(row.max_dist, offset, 1e-6); + + // the target is the flat grid, so its bounding box diagonal is that of the unit square + ASSERT_NEAR(row.bbox_diag, std::sqrt(2.0), 1e-6); + ASSERT_NEAR(row.norm_mean, offset / std::sqrt(2.0), 1e-6); +} + +//--------------------------------------------------------------------------- +// The failure this metric exists to catch: a few swapped correspondence points leave most of the +// surface intact, so the mean barely moves while the tail spikes. +TEST(CorrespondenceEvaluationTests, localizedDefectSpikesTheTailNotTheMean) { + const int n = 20; // 400 vertices, so p99 and max land on different ones + Mesh groomed = make_grid_mesh(n); + + Mesh damaged = make_grid_mesh(n); + auto poly_data = damaged.getVTKMesh(); + double point[3]; + poly_data->GetPoint(0, point); + point[2] += 1.0; // drag a single vertex well off the surface + poly_data->GetPoints()->SetPoint(0, point); + poly_data->Modified(); + + auto row = CorrespondenceEvaluation::evaluate_reconstruction(poly_data, groomed, + CorrespondenceEvaluation::DistanceMethod::PointToCell); + + ASSERT_NEAR(row.median_dist, 0.0, 1e-9); // the surface is otherwise untouched + ASSERT_NEAR(row.max_dist, 1.0, 1e-6); // the moved vertex + ASSERT_LT(row.mean_dist, 0.01); // one vertex in 400 barely moves the mean + ASSERT_GT(row.max_dist / row.mean_dist, 50); // which is exactly why ranking on the mean hides it + + // p99 ignores the single outlier, so it is a steadier basis for the localization ratio than max + ASSERT_LT(row.p99_dist, row.max_dist); +} + +//--------------------------------------------------------------------------- +TEST(CorrespondenceEvaluationTests, summarizeReportsOrderStatistics) { + std::vector values{5.0, 1.0, 4.0, 2.0, 3.0}; + auto stats = CorrespondenceEvaluation::summarize(values); + + ASSERT_NEAR(stats.mean, 3.0, 1e-9); + ASSERT_NEAR(stats.median, 3.0, 1e-9); + ASSERT_NEAR(stats.max, 5.0, 1e-9); + + ASSERT_NEAR(CorrespondenceEvaluation::summarize({}).mean, 0.0, 1e-9); +} + +//--------------------------------------------------------------------------- +TEST(CorrespondenceEvaluationTests, aggregatesExcludeTheTemplate) { + CorrespondenceQualityReport report; + report.rows.push_back({"a", 0, 1.0, 1.0, 1.0, 1.0, 10.0, 0.1, 0.1, 0.1, 0.1, false}); + report.rows.push_back({"b", 0, 3.0, 3.0, 3.0, 3.0, 10.0, 0.3, 0.3, 0.3, 0.3, false}); + report.rows.push_back({"t", 0, 99.0, 99.0, 99.0, 99.0, 10.0, 9.9, 9.9, 9.9, 9.9, true}); + + CorrespondenceEvaluation::compute_aggregates(report); + + ASSERT_EQ(report.num_evaluated, 2); + ASSERT_EQ(report.num_template_rows, 1); + ASSERT_NEAR(report.agg_raw.mean, 2.0, 1e-9); // the template row would have dominated this + ASSERT_NEAR(report.agg_raw.max, 3.0, 1e-9); +} diff --git a/build_dependencies.sh b/build_dependencies.sh index 7138b031504..946d6d093aa 100755 --- a/build_dependencies.sh +++ b/build_dependencies.sh @@ -26,7 +26,7 @@ ITK_VER="v5.4.4" ITK_VER_STR="5.4" QT_MIN_VER="5.15.4" XLNT_VER="538f80794c7d736afc0a452d21313606cc5538fc" -JKQTPLOTTER_VER="v2022.11.30-refix-rpath" +JKQTPLOTTER_VER="v2026.08.24-tick-labels" OpenVDB_VER="v9.1.0" libigl_VER="v2.3.0" geometry_central_VER="8b20898f6c7be1eab827a9f720c8fd45e58ae63c" # This library isn't using tagged versions diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md index bab1bb8da88..26c3fd9bfda 100644 --- a/docs/about/release-notes.md +++ b/docs/about/release-notes.md @@ -14,7 +14,7 @@ * **ShapeWorks Back-end** * Registration-based particle initialization as an alternative to particle splitting: particles are spread over a single reference shape and carried onto every other shape by deformable registration (rigid → affine → SyN over distance transforms), so each shape starts optimization already holding a full set of corresponding particles (#2374) - * New `correspondence-quality` command and Python API that scores each subject by reconstructing its surface from its local particles and measuring distance back to the groomed mesh, normalized by bounding-box diagonal for comparison across anatomies (#2612) + * New `correspondence-quality` command and Python API that scores each subject by reconstructing its surface from its local particles and measuring distance back to the groomed mesh, normalized by bounding-box diagonal for comparison across anatomies. Reports mean, median, 99th percentile and max distance per subject; p99 measures the worst part of a surface without following a single stray vertex the way max does (#2612) * Large-cohort optimization speedups: the per-iteration correspondence update drops an O(P·N²) identity multiply during initialization and uses a symmetric eigensolver instead of a single-threaded general SVD, restoring multi-core use on large cohorts with no change to the result (#2574) * Geodesic remeshing fixes: `geodesic_remesh_percent` is now interpreted correctly as a percentage, geodesics are actually enabled on the remeshed surface (previously it silently fell back to Euclidean distances), and per-particle face lookups are cached against the query point (#2556) * Contours are detected by cell type rather than by inspecting the first cell, and polyline cells with more than two points are split into segments, so single-polyline contours load and optimize instead of crashing (#2377, #2457) @@ -34,6 +34,11 @@ * File → Export → Export All Meshes writes the reconstructed mesh for every subject in the project (#2281) * The glyph-size slider and auto-sizing now scale to the shape's largest dimension instead of a fixed world-unit range, so very small or very large shapes get usable glyph sizes (#2459) * Cutting-plane table edits to center and normal now take effect (#2567) + * New *Correspondence Quality* panel in the Analyze pane: scores every sample by reconstructing it from its local particles through Studio's own mesh warper — the same reconstruction shown in the viewer, using your chosen template and warp method — and measuring the distance back to that sample's groomed mesh + * *Show distance on surface* colors each sample's surface and particles by the per-vertex distance, so a bad region can be located and not just detected + * Samples can be sorted by mean, median, p99 or max distance, or by how localized the error is, which surfaces swapped-correspondence cases whose mean distance still looks healthy; *Sort samples in view* applies the same ranking to the All Samples grid so the challenging shapes come up first, and clicking a row shows that sample on its own, or returns to all samples if it is already showing + * The quality chart plots the ranked metric together with the tail of the distribution on a log axis, with median and p95 marked, so a small badly reconstructed patch stays visible even when it barely moves the mean + * Result tables can be right-clicked to copy their contents to the clipboard as CSV, with the header row and proper quoting of values containing a comma or a quote ### Fixes * Fix DeepSSM jobs reporting success after failing: a job that died during training logged "Training complete", chained into testing, and exited zero. Failures now stop the run and exit non-zero, and the CLI no longer hangs waiting on a failed job (#2621) @@ -46,10 +51,14 @@ * Fix initial landmarks and cutting-plane points rendering at near-zero size before optimization (#2276, #2595) * Clear particle filenames when optimization fails, so saving the project no longer persists paths to files that were never written and the project reloads cleanly (#2455) * Fix an intermittent race condition in debug instrumentation (#2530) + * Fix the Particle Area Analysis and Shape/Scalar Correlation progress bars never moving: the job's fractional progress was truncated to an integer before being scaled to a percentage + * Fix box plots showing an auto-assigned palette color rather than the color they were given, which made the plot color appear to change between runs + * Fix the lightbox not refitting its shared camera when the tile layout changes, which left samples small and off-centre after switching to the All Samples view until *Autoview* was pressed ### Platform Updates * Bundled Python 3.12 (see above); VTK 9.5.0, ITK 5.4.4 and Qt 5.15.4 are unchanged from 6.7.0 * GitHub Actions updated to Node 20 runtimes (#1909) + * JKQTPlotter is pinned to a tag rather than a moving branch, and updated to skip minor tick labels that would collide with a neighbouring label on a log axis diff --git a/docs/img/studio/studio_correspondence_quality.png b/docs/img/studio/studio_correspondence_quality.png new file mode 100644 index 00000000000..9a37ea09ab2 Binary files /dev/null and b/docs/img/studio/studio_correspondence_quality.png differ diff --git a/docs/img/studio/studio_correspondence_quality_surface.png b/docs/img/studio/studio_correspondence_quality_surface.png new file mode 100644 index 00000000000..3178202da87 Binary files /dev/null and b/docs/img/studio/studio_correspondence_quality_surface.png differ diff --git a/docs/studio/studio-analyze.md b/docs/studio/studio-analyze.md index 6781744aa64..213c9e836cf 100644 --- a/docs/studio/studio-analyze.md +++ b/docs/studio/studio-analyze.md @@ -138,6 +138,45 @@ The *Particle Area Analysis* panel allows for the visualization of the area of e ![ShapeWorks Studio Particle Area Analysis Panel Standard Deviation Display](../img/studio/studio_particle_area_analysis_std.png) +## Correspondence Quality ## + +The *Correspondence Quality* panel scores every sample by how well its particles describe its own surface. Each sample is reconstructed from its local particles through the mesh warper configured in the [Surface Reconstruction](surface-reconstruction.md) panel — the same reconstruction shown in the viewer, using your chosen template and warp method — and the distance from that reconstruction back to the sample's groomed mesh is measured. A sample whose particles no longer follow its own surface (a failed split, a bad initialization, an outlier shape the model does not cover) shows a large distance. + +Note that this measures the correspondence model against the *groomed* meshes, so it reflects both optimization quality and any grooming problems upstream of it. + +![ShapeWorks Studio Correspondence Quality Panel](../img/studio/studio_correspondence_quality.png) + +The results are shown on the samples, so running the analysis switches you to the *Samples* tab (if you are not already on a sample view) and to the reconstructed surfaces. + +*Template* selects the sample everything is warped from. It is the same template the [Surface Reconstruction](surface-reconstruction.md) panel uses, so changing it here changes it there as well, and pressing *Run* rebuilds the reconstructions before measuring. *Median* picks the cohort median. Changing the template discards any results already on screen, since they were measured against the previous one. + +### Reading the results + +Press *Run* to compute. Distances are reported per sample as mean, median, p99 and max over the reconstruction's vertices. The p99 column is the worst part of the surface without the sensitivity to a single stray vertex that max has. With *Normalize by bounding box diagonal* checked (the default), each distance is divided by that sample's groomed bounding box diagonal and shown as a percentage, which makes samples of different size comparable. Normalization applies to the summary, the table, the chart and the sort together. + +The chart plots the samples in the same order as the table, on a log axis with the median and p95 marked. It draws two lines: the metric you are sorting by, and the tail of the distribution beside it — the max distance normally, p99 when sorting by *Localized* so the chart matches the ranking, and the mean when sorting by max. Two lines rather than one, because a single-line chart hides the most common failure: when a few correspondence points get swapped, only a small patch of the surface is wrong, so the mean barely moves while the tail spikes. The gap between the lines is how localized the damage is — close together means a diffusely poor reconstruction, a wide gap means a small bad region on an otherwise good one. + +For the same reason *Sort by* offers **Localized (p99 / mean)**, which ranks by how concentrated each sample's error is rather than how large it is, bringing swapped-particle cases to the top even when their mean distance looks healthy. A high ratio means most of the surface is fine and a small patch is badly wrong; a low one means the error is spread evenly and the reconstruction is uniformly mediocre. p99 rather than max, so one stray vertex cannot push a sample up the ranking, and the ratio is already scale-free, so the normalize option does not change it. + +The template sample is marked in the table and excluded from the summary statistics and the chart, since it is warped from itself and its reconstruction is near-identity. + +Click a row to show that sample on its own in the viewer, which is the quickest way to work down the ranking; click the same row again to return to all samples. Right-click the table to copy it to the clipboard as CSV, in the order it is currently sorted. + +### Finding the challenging shapes + +Use *Sort by* to rank the table by mean, median, p99 or max distance, by how localized the error is, or by name, in descending order (worst first) or ascending (best first). Checking *Sort samples in view* applies the same ranking to the *All Samples* view, so the most challenging shapes appear first in the grid. For a multi-domain project a sample is ranked by its worst domain. Unchecking it restores the original order. + +### Seeing where it breaks down + +*Show distance on surface* colors each sample by its per-vertex distance to the groomed surface, switching the view to the reconstructed surfaces where that field lives. This shows *where* correspondence breaks down, not just which samples are worst. + +The particles are colored by the average distance over the part of the surface nearest to each one, rather than by the distance at the particle itself. The reconstruction is warped through the particles and the particles lie on the surface, so the distance at a particle is zero whatever the state of the model; the error to look for is always in the gaps between them. + +![ShapeWorks Studio Correspondence Quality Surface Distance](../img/studio/studio_correspondence_quality_surface.png) + + +The same metric is available outside Studio via the `shapeworks correspondence-quality` command and the Python API — see [Correspondence Quality](../workflow/analyze.md#correspondence-quality). Studio reconstructs through its own warper, so its numbers can differ slightly from the command line's if you have changed the template sample or the warp method. + ## Shape/Scalar Correlation ## The *Shape/Scalar Correlation* panel uses 2 block PLS regression to identify the relationship between shape and scalar data. diff --git a/docs/workflow/analyze.md b/docs/workflow/analyze.md index 391890178d0..25e4fcb8b40 100644 --- a/docs/workflow/analyze.md +++ b/docs/workflow/analyze.md @@ -17,7 +17,7 @@ You can scroll through the dataset and zoom in and out to inspect fewer or more ## Correspondence Quality -Scrolling through the model tells you whether correspondence *looks* right. To quantify it per subject, use the `correspondence-quality` command. +Scrolling through the model tells you whether correspondence *looks* right. To quantify it per subject, use the `correspondence-quality` command, or the [Correspondence Quality panel](../studio/studio-analyze.md#correspondence-quality) in Studio, which additionally lets you sort the samples worst-first so the challenging shapes come up front. For each subject, ShapeWorks reconstructs the surface from that subject's **local** particles by biharmonic mesh warp from the cohort template, and then measures the distance from the reconstruction back to that subject's groomed mesh. A subject whose particles no longer describe its own surface — a failed split, a bad initialization, an outlier shape the model does not cover — shows up as a large distance. @@ -43,7 +43,7 @@ shapeworks correspondence-quality --name | `--method` | `point-to-cell` (default) or `point-to-point`. | | `--worst` | How many worst-ranked subjects to print. Default 5. | -The command prints a summary — mean, median, p95 and max of the per-subject mean distance, raw and normalized — followed by the worst-ranked subjects. The CSV has one row per subject per domain with the columns `subject`, `domain`, `is_template`, `mean_dist`, `max_dist`, `bbox_diag`, `norm_mean`, `norm_max`. +The command prints a summary — mean, median, p95 and max of the per-subject mean distance, raw and normalized — followed by the worst-ranked subjects. The CSV has one row per subject per domain with the columns `subject`, `domain`, `is_template`, `mean_dist`, `median_dist`, `p99_dist`, `max_dist`, `bbox_diag`, `norm_mean`, `norm_median`, `norm_p99`, `norm_max`. `p99_dist` is the 99th percentile of the per-vertex distances: a measure of the worst part of the surface that, unlike `max_dist`, does not move with a single bad vertex. *A reconstructed mesh written by `--output_meshes`, coloured by the `distance` field. Load it in Studio and select `distance` from the scalar dropdown to see where the reconstruction departs from the groomed surface.* ![Correspondence quality distance field](../img/workflow/correspondence_quality.png) @@ -64,7 +64,7 @@ print(report.template_subject, report.num_evaluated) print(report.agg_norm.mean, report.agg_norm.p95) for row in report.rows: - print(row.subject, row.domain, row.mean_dist, row.norm_mean, row.is_template) + print(row.subject, row.domain, row.mean_dist, row.median_dist, row.p99_dist, row.max_dist, row.is_template) ``` `evaluate()` also takes `method` (`sw.CorrespondenceEvaluation.DistanceMethod.PointToCell` or `PointToPoint`) and `output_meshes_dir`. The project's relative paths are resolved against the current working directory, so run from the project's directory.