Files
Jungfraujoch/viewer/JFJochViewerDatasetInfo.cpp
leonarski_fandClaude Opus 5 e381d2fd50
Build Packages / build:viewer-tgz:cpu (push) Successful in 12m14s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 8m24s
Build Packages / build:rpm (rocky8_nocuda) (push) Failing after 5m36s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 12m55s
Build Packages / build:viewer-tgz:cuda (push) Successful in 14m11s
Build Packages / build:rpm (rocky9_nocuda) (push) Failing after 5m6s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Failing after 4m11s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Failing after 3m58s
Build Packages / build:rpm (rocky8) (push) Failing after 3m22s
Build Packages / build:rpm (rocky9_sls9) (push) Failing after 4m12s
Build Packages / build:rpm (rocky9) (push) Failing after 3m20s
Build Packages / build:rpm (rocky8_sls9) (push) Failing after 4m23s
Build Packages / build:rpm (ubuntu2204) (push) Failing after 3m36s
Build Packages / build:windows:nocuda (push) Successful in 17m9s
Build Packages / build:rpm (ubuntu2404) (push) Failing after 4m8s
Build Packages / Generate python client (push) Successful in 35s
Build Packages / Build documentation (push) Successful in 52s
Build Packages / Create release (push) Skipped
Build Packages / build:windows:cuda (push) Successful in 19m32s
Build Packages / XDS test (durin plugin) (push) Successful in 7m22s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 7m28s
Build Packages / XDS test (neggia plugin) (push) Successful in 7m15s
Build Packages / build:rugnux:windows (push) Successful in 12m10s
Build Packages / DIALS test (push) Successful in 17m32s
Build Packages / Unit tests (push) Successful in 1h16m29s
grid scan: review fixes - the ice channel sees its own spots, and a needle is not mirrored
Two independent reviews of the merged grid-scan work. The findings that changed
behaviour:

The ice score's spot channel was fed a list the spot budget had already stripped.
FilterSpotsByCount orders ice-band spots LAST when indexing is not to use them, so on
a frame with more spots than the budget the ice spots are the first discarded - and
the channel that exists for "ice arrives as discrete spots and leaves the radial
profile flat" then read zero on exactly the frames it was written for. Probed at 3000
spots with 1200 on the hexagonal radii and a budget of 1000: 1.000 before the cap,
0.000 after. IceScore now takes d-spacings and is handed the list from before the cap.

The viewer scaled the crystal box by the SIGNED grid step, where every other consumer
takes the magnitude. On a negative step that mirrors the box - +30 deg drawn as -30 -
and hands QRectF a negative width.

rugnux --mode raster never put its settings on the experiment, so the indexing switch
was read at its default while a deprecated per-run flag did the actual work; and
RugnuxCommandLine emitted no --mode for Grid, so a raster job copied to a cluster ran
the default mx - indexing, integrating and merging every cell of the raster.

res_A is NaN where nothing in a blob measured a resolution, and nlohmann writes NaN as
null, which the schema and the generated clients both reject. It is now left unset.

The broker's configuration example named a key that does not exist (calibration, not
calibration_settings); nlohmann ignores unknown keys, so a user copying it got a
silently ignored block. The changelog had lost the rc.166 heading and 21 rc.167
entries to a bad edit of mine, and three entries had been filed under rc.166.

Also: a warning where mode Grid meets a dataset with no grid scan, which was silent
and indistinguishable from finding nothing; the viewer combo still named the retired
ice_ring_score; and the claim that growth "cannot invent a crystal" was too strong -
it cannot start a patch, but the cell count is read over the grown patch, so it does
decide which patches pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
2026-09-08 09:04:52 +02:00

384 lines
16 KiB
C++

// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <QGridLayout>
#include <QPushButton>
#include <QColor>
#include "JFJochViewerDatasetInfo.h"
namespace {
// Stable colour per run by its position in the run list (Original is index 0 -> blue), so a
// run keeps its colour whether it is the active (primary) line or an overlay.
QColor RunColor(int index) {
static const QColor palette[] = {
QColor(0x1f, 0x77, 0xb4), QColor(0xff, 0x7f, 0x0e), QColor(0x2c, 0xa0, 0x2c),
QColor(0xd6, 0x27, 0x28), QColor(0x94, 0x67, 0xbd), QColor(0x8c, 0x56, 0x4b),
QColor(0xe3, 0x77, 0xc2), QColor(0x7f, 0x7f, 0x7f),
};
constexpr int n = static_cast<int>(sizeof(palette) / sizeof(palette[0]));
return palette[((index % n) + n) % n];
}
// Combo value of the composite grid-scan map. Well clear of the running metric numbers and
// below the ROI block (100 and up), so a metric added later cannot collide with it.
constexpr int kCompositeMetric = 50;
// A run's image->original-number map as floats for the chart x-axis (empty => identity).
std::vector<float> XForRun(const JFJochReaderDataset &ds) {
return {ds.source_image_number.begin(), ds.source_image_number.end()};
}
}
JFJochViewerDatasetInfo::JFJochViewerDatasetInfo(QWidget *parent) : QWidget(parent) {
auto layout = new QGridLayout(this);
combo_box = new QComboBox(this);
last_selection = 0;
layout->addWidget(combo_box, 0, 0);
auto reset_button = new QPushButton("Reset zoom", this);
reset_button->setFixedWidth(100);
grid_button = new QPushButton("Grid", this);
grid_button->setFixedWidth(100);
grid_button->setCheckable(true);
grid_button->setEnabled(false);
image_button = new QPushButton("Per-image", this);
image_button->setFixedWidth(100);
image_button->setCheckable(true);
auto add_plot_button = new QPushButton("+ Plot", this);
add_plot_button->setFixedWidth(80);
add_plot_button->setToolTip("Open another dataset-info plot, side by side");
add_plot_button->setStyleSheet(
"QPushButton { background-color:#1F3A5F; color:white; font-weight:bold; border:none;"
" border-radius:3px; padding:3px 8px; } QPushButton:hover { background-color:#16314F; }");
connect(add_plot_button, &QPushButton::clicked, this, &JFJochViewerDatasetInfo::addPlot);
layout->addWidget(reset_button, 0, 2);
layout->addWidget(grid_button, 0, 3);
layout->addWidget(image_button, 0, 4);
layout->addWidget(add_plot_button, 0, 5);
stack = new QStackedWidget(this);
chart_view = new JFJochDatasetInfoChartView(this);
chart_view->setMinimumHeight(140); // keep enough room for the axis labels even when compressed
grid_scan_image = new JFJochGridScanImage(this);
image_chart = new JFJochViewerSidePanelChart(this); // per-image profiles, folded in from the side panel
stack->addWidget(chart_view);
stack->addWidget(grid_scan_image);
stack->addWidget(image_chart);
layout->addWidget(stack, 1, 0, 1, 6);
connect(chart_view, &JFJochDatasetInfoChartView::imageSelected,
this, &JFJochViewerDatasetInfo::imageSelectedInChart);
connect(reset_button, &QPushButton::clicked,
this, &JFJochViewerDatasetInfo::resetZoomButtonPressed);
connect(combo_box, &QComboBox::currentIndexChanged,
this, &JFJochViewerDatasetInfo::comboBoxSelected);
connect(grid_scan_image, &JFJochGridScanImage::imageSelected,
this, &JFJochViewerDatasetInfo::imageSelectedInChart);
connect(chart_view, &JFJochDatasetInfoChartView::writeStatusBar,
[this](QString string, int timeout_ms) {
emit writeStatusBar(string, timeout_ms);
});
connect(grid_scan_image, &JFJochGridScanImage::writeStatusBar,
[this](QString string, int timeout_ms) {
emit writeStatusBar(string, timeout_ms);
});
connect(grid_button, &QPushButton::clicked,
[this]() {
if (grid_button->isChecked()) {
image_button->setChecked(false);
combo_box->setEnabled(true);
}
UpdateView();
});
connect(image_button, &QPushButton::clicked,
[this]() {
if (image_button->isChecked())
grid_button->setChecked(false);
UpdateLabels(); // swap the one combo's contents for the mode
combo_box->setCurrentIndex(0);
comboBoxSelected(0); // apply the first option of the new mode
UpdateView();
});
connect(image_chart, &JFJochViewerSidePanelChart::writeStatusBar,
[this](QString s, int t) { emit writeStatusBar(s, t); });
setLayout(layout);
}
void JFJochViewerDatasetInfo::RestoreSelection() {
QSignalBlocker bl(combo_box);
combo_box->setCurrentIndex(last_selection < combo_box->count() ? last_selection : 0);
}
void JFJochViewerDatasetInfo::UpdateLabels() {
QSignalBlocker bl(combo_box);
if (combo_box->count() > 0)
last_selection = combo_box->currentIndex();
combo_box->clear();
// In per-image mode the one combo selects which per-image profile to show (no second combo).
if (image_button && image_button->isChecked()) {
for (const auto &pt : JFJochViewerSidePanelChart::PlotTypes())
combo_box->addItem(pt.first, pt.second);
return;
}
if (this->dataset) {
// First for a grid scan: the composite is what a raster is looked at for, and the whole
// point is that it comes up without anyone having to know to ask for it.
if (dataset->experiment.GetGridScan())
combo_box->addItem("Protein / ice map", kCompositeMetric);
combo_box->addItem("Background estimate", 0);
combo_box->addItem("Ice ring ratio", 14);
combo_box->addItem("Spindle blind fraction", 15);
combo_box->addItem("Resolution estimate", 7);
combo_box->addItem("Spot count", 1);
combo_box->addItem("Spot count (indexed)", 2);
combo_box->addItem("Spot count (ice rings)", 3);
combo_box->addItem("Spot count (low res.)", 4);
// Offer indexing/scaling metrics if the active run, any overlay run, or the live run has them.
bool has_indexing = !dataset->indexing_result.empty();
bool has_scale = !dataset->image_scale_factor.empty();
for (const auto &r: runs_)
if (r.dataset) {
has_indexing = has_indexing || !r.dataset->indexing_result.empty();
has_scale = has_scale || !r.dataset->image_scale_factor.empty();
}
if (live_run_) {
has_indexing = has_indexing || !live_run_->indexing_result.empty();
has_scale = has_scale || !live_run_->image_scale_factor.empty();
}
if (has_indexing) {
combo_box->insertSeparator(1000);
combo_box->addItem("Indexing result", 5);
combo_box->addItem("Profile radius", 6);
combo_box->addItem("B-factor", 8);
combo_box->addItem("Mosaicity", 9);
combo_box->addItem("Integrated reflections", 12);
combo_box->addItem("Indexing lattice count", 13);
}
if (has_scale) {
combo_box->insertSeparator(1000);
combo_box->addItem("Scale factor", 10);
combo_box->addItem("CC", 11);
}
for (int i = 0; i < this->dataset->roi.size(); i++) {
std::string name = std::string("ROI ") + this->dataset->roi[i];
combo_box->insertSeparator(1000);
combo_box->addItem(QString::fromStdString(name + " mean"), 100 + i * 4);
combo_box->addItem(QString::fromStdString(name + " sum"), 100 + i * 4 + 1);
combo_box->addItem(QString::fromStdString(name + " weighted x"), 100 + i * 4 + 2);
combo_box->addItem(QString::fromStdString(name + " weighted y"), 100 + i * 4 + 3);
}
} else {
combo_box->clear();
}
}
void JFJochViewerDatasetInfo::datasetLoaded(std::shared_ptr<const JFJochReaderDataset> dataset) {
this->dataset = dataset;
// Per-image mode: the combo holds plot types and the chart follows the displayed image, so a
// new dataset must not repopulate the combo or run the per-dataset plot.
if (image_button && image_button->isChecked()) {
UpdateView();
return;
}
if (dataset) {
UpdateLabels();
RestoreSelection();
UpdatePlot();
} else {
chart_view->loadValues({}, 0, false);
grid_scan_image->clear();
UpdateLabels();
}
}
void JFJochViewerDatasetInfo::imageLoaded(std::shared_ptr<const JFJochReaderImage> image) {
this->image = image;
if (image) {
chart_view->setImage(image->ImageData().number);
grid_scan_image->setImage(image->ImageData().number);
}
image_chart->loadImage(image); // per-image profile follows the displayed image
}
void JFJochViewerDatasetInfo::imageSelectedInChart(int64_t number) {
emit imageSelected(number, 1);
}
std::vector<float> JFJochViewerDatasetInfo::ExtractMetric(const JFJochReaderDataset &ds, int val,
bool &one_over_d2) const {
one_over_d2 = false;
std::vector<float> data;
if (val == 0) data = ds.bkg_estimate;
else if (val == 1) data = ds.spot_count;
else if (val == 2) data = ds.spot_count_indexed;
else if (val == 3) data = ds.spot_count_ice_rings;
else if (val == 4) data = ds.spot_count_low_res;
else if (val == 5) data = ds.indexing_result;
else if (val == 6) data = ds.profile_radius;
else if (val == 7) { data = ds.resolution_estimate; one_over_d2 = true; }
else if (val == 8) data = ds.b_factor;
else if (val == 9) data = ds.mosaicity_deg;
else if (val == 10) data = ds.image_scale_factor;
else if (val == 11) data = ds.image_scale_cc;
else if (val == 12) data = ds.integrated_reflections;
else if (val == 13) data = ds.indexing_lattice_count;
else if (val == 14) data = ds.ice_ring_ratio;
else if (val == 15) data = ds.spindle_blind_fraction;
else if (val == kCompositeMetric) data = ds.protein_score;
else if (val >= 100) {
const int roi_index = (val - 100) / 4;
if (val % 4 == 0) {
if (roi_index < ds.roi_sum.size() && ds.roi_sum.size() == ds.roi_npixel.size())
for (int i = 0; i < ds.roi_sum[roi_index].size(); i++)
data.push_back(static_cast<float>(ds.roi_sum[roi_index][i])
/ static_cast<float>(ds.roi_npixel[roi_index][i]));
} else if (val % 4 == 1) {
if (roi_index < ds.roi_sum.size()) {
data.reserve(ds.roi_sum[roi_index].size());
for (auto &v: ds.roi_sum[roi_index])
data.push_back(v);
}
} else if (val % 4 == 2) {
if (roi_index < ds.roi_x.size()) data = ds.roi_x[roi_index];
} else if (val % 4 == 3) {
if (roi_index < ds.roi_y.size()) data = ds.roi_y[roi_index];
}
}
return data;
}
void JFJochViewerDatasetInfo::UpdatePlot() {
int index = combo_box->currentIndex();
if (combo_box->count() == 0 || index < 0)
return;
int val = combo_box->itemData(index).toInt();
if (!dataset) {
grid_button->setEnabled(false);
return;
}
const int64_t image_number = image ? image->ImageData().number : 0;
bool one_over_d2 = false;
std::vector<float> data = ExtractMetric(*dataset, val, one_over_d2);
// One overlay line per other run, plus the in-progress live run. Each run keeps a stable colour
// tied to its position in the list, so colours don't swap when the active run changes. The
// primary is matched by dataset pointer (not active_id_) so colour/name stay consistent even
// while a datasetLoaded/runsChanged pair is still arriving.
std::vector<JFJochDatasetInfoChartView::NamedSeries> overlays;
QString primary_name;
QColor primary_color;
for (int i = 0; i < runs_.size(); i++) {
const auto &run = runs_[i];
if (!run.dataset) continue;
const QColor color = RunColor(i);
if (run.dataset == dataset) { primary_name = run.label; primary_color = color; continue; }
bool ignore = false;
overlays.push_back({run.label, ExtractMetric(*run.dataset, val, ignore), color, XForRun(*run.dataset)});
}
if (live_run_ && live_run_ != dataset) {
bool ignore = false;
overlays.push_back({QStringLiteral("Live"), ExtractMetric(*live_run_, val, ignore),
RunColor(static_cast<int>(runs_.size())), XForRun(*live_run_)});
}
// The whole dataset spans this many images (the largest run, i.e. the original file), so the
// x-axis is fixed to it and subset runs appear at their real position.
int64_t full_range = dataset->experiment.GetImageNum();
for (const auto &run: runs_)
if (run.dataset)
full_range = std::max<int64_t>(full_range, run.dataset->experiment.GetImageNum());
chart_view->loadValues(data, image_number, one_over_d2, dataset.get(), primary_name,
std::move(overlays), primary_color, XForRun(*dataset), full_range);
if (dataset->experiment.GetGridScan()) {
if (val == kCompositeMetric)
grid_scan_image->loadComposite(dataset->bkg_estimate, dataset->protein_score,
dataset->ice_score, dataset->grid_crystals,
dataset->experiment.GetGridScan().value());
else
grid_scan_image->loadData(data, dataset->experiment.GetGridScan().value(), one_over_d2);
grid_button->setEnabled(true);
if (!image_button->isChecked())
grid_button->setChecked(true);
} else {
grid_scan_image->clear();
grid_button->setEnabled(false);
}
UpdateView();
}
void JFJochViewerDatasetInfo::UpdateView() {
if (image_button->isChecked())
stack->setCurrentWidget(image_chart);
else if (grid_button->isChecked() && dataset && dataset->experiment.GetGridScan())
stack->setCurrentWidget(grid_scan_image);
else
stack->setCurrentWidget(chart_view);
}
void JFJochViewerDatasetInfo::runsChanged(QVector<RunData> runs, QString active_id) {
runs_ = std::move(runs);
active_id_ = std::move(active_id);
if (image_button && image_button->isChecked())
return; // per-image mode: runs/overlays are a per-dataset concern
UpdateLabels();
RestoreSelection();
UpdatePlot();
}
void JFJochViewerDatasetInfo::liveRunUpdated(std::shared_ptr<const JFJochReaderDataset> in_dataset) {
live_run_ = std::move(in_dataset);
if (image_button && image_button->isChecked())
return;
UpdatePlot(); // lightweight refresh; no combo rebuild on every live tick
}
void JFJochViewerDatasetInfo::comboBoxSelected(int index) {
// The one combo means per-dataset metrics in normal mode, per-image plot types in image mode.
if (image_button && image_button->isChecked()) {
if (image_chart && index >= 0 && index < combo_box->count())
image_chart->setPlotType(combo_box->itemData(index).toInt());
} else {
UpdatePlot();
}
}
void JFJochViewerDatasetInfo::setColorMap(int color_map) {
grid_scan_image->setColorMap(color_map);
}
void JFJochViewerDatasetInfo::resetZoomButtonPressed() {
if (stack->currentWidget() == grid_scan_image)
grid_scan_image->fitToView();
else
chart_view->resetZoom();
}