Build Packages / Create release (push) Successful in 24s
Build Packages / build:viewer:macos-arm64:nocuda (push) Successful in 3m29s
Build Packages / build:rugnux:macos-arm64:nocuda (push) Successful in 2m43s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 8m27s
Build Packages / build:rugnux:linux-x86_64:cuda (push) Successful in 9m53s
Build Packages / build:viewer:linux-x86_64:nocuda (push) Successful in 9m58s
Build Packages / build:viewer:linux-x86_64:cuda (push) Successful in 11m22s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 13m39s
Build Packages / build:viewer:windows-x86_64:nocuda (push) Successful in 18m37s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 16m32s
Build Packages / build:viewer:windows-x86_64:cuda (push) Successful in 24m11s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m30s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m3s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 20m23s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 19m41s
Build Packages / Generate python client (push) Successful in 50s
Build Packages / Build documentation (push) Successful in 1m16s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 21m0s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 18m38s
Build Packages / build:rugnux:windows-x86_64:cuda (push) Successful in 14m33s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 17m55s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m50s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 18m38s
Build Packages / Unit tests (push) Successful in 1h46m14s
* jfjoch_broker: Optional per-dataset authentication - statistics, images and plots can require a bearer token, which jfjoch_viewer supports. * jfjoch_viewer: Dark mode and a theme-matched colour scheme, a magnifier panel, and simpler contrast and background controls. * Rugnux: Multiple performance improvements on GPU and CPU (CPU-only processing up to 40% faster, faster image decoding on ARM), with unchanged results. * Rugnux: `--model` rigid-body refinement runs on the GPU, and the model-validation check is faster and more reliable. * Rugnux: Improved scaling and merging - error model, outlier rejection, absorption correction and French-Wilson amplitudes now agree more closely with XDS and ctruncate. * Rugnux: Improved integration - radial background on powder and ice rings, crowded rotation data keep their reflections, and CPU-only builds integrate large unit cells as GPU builds do. * Rugnux: More robust detector geometry - measured beam centre, X-ray bandwidth and goniometer rate, and geometry refinement accepted only on significant evidence. * Rugnux: Merged files are written in the standard setting, or in the setting of a reference MTZ, structure-factor mmCIF or model, with its free-R flags. * Rugnux: Richer report - ice and powder rings, further lattices, superstructure candidates and mosaicity, with warnings worded as prompts to check. * Rugnux: Clear error messages when a data set needs more GPU or host memory than is available. Reviewed-on: #83 Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
290 lines
11 KiB
C++
290 lines
11 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "JFJochViewerROIList.h"
|
|
|
|
#include <set>
|
|
#include <cmath>
|
|
|
|
#include "../../common/JFJochMath.h"
|
|
#include "ROIColorPalette.h"
|
|
|
|
#include <QVBoxLayout>
|
|
#include <QHBoxLayout>
|
|
#include <QGridLayout>
|
|
#include <QPushButton>
|
|
#include <QInputDialog>
|
|
#include <QPixmap>
|
|
#include <QIcon>
|
|
|
|
JFJochViewerROIList::JFJochViewerROIList(QWidget *parent) : QWidget(parent) {
|
|
auto *layout = new QVBoxLayout(this);
|
|
|
|
combo_ = new QComboBox(this);
|
|
layout->addWidget(combo_);
|
|
connect(combo_, &QComboBox::currentIndexChanged, this, &JFJochViewerROIList::OnSelectionChanged);
|
|
|
|
auto *buttons = new QHBoxLayout();
|
|
type_combo_ = new QComboBox(this);
|
|
type_combo_->addItems({"Box", "Circle", "Azimuthal"});
|
|
buttons->addWidget(type_combo_);
|
|
auto *add = new QPushButton("+", this);
|
|
auto *ren = new QPushButton("Rename", this);
|
|
auto *del = new QPushButton("Delete", this);
|
|
buttons->addWidget(add);
|
|
buttons->addWidget(ren);
|
|
buttons->addWidget(del);
|
|
layout->addLayout(buttons);
|
|
connect(add, &QPushButton::clicked, this, &JFJochViewerROIList::OnAdd);
|
|
connect(ren, &QPushButton::clicked, this, &JFJochViewerROIList::OnRename);
|
|
connect(del, &QPushButton::clicked, this, &JFJochViewerROIList::OnDelete);
|
|
|
|
auto *server = new QHBoxLayout();
|
|
auto *download = new QPushButton("Download", this);
|
|
auto *upload = new QPushButton("Upload to server", this);
|
|
server->addWidget(download);
|
|
server->addWidget(upload);
|
|
layout->addLayout(server);
|
|
connect(download, &QPushButton::clicked, this, &JFJochViewerROIList::downloadROIs);
|
|
connect(upload, &QPushButton::clicked, this, &JFJochViewerROIList::uploadROIs);
|
|
|
|
auto *grid = new QGridLayout();
|
|
for (int i = 0; i < 4; i++) {
|
|
flabel_[i] = new QLabel("", this);
|
|
fedit_[i] = new QLineEdit(this);
|
|
fedit_[i]->setAlignment(Qt::AlignRight);
|
|
fedit_[i]->setMaximumWidth(90);
|
|
grid->addWidget(flabel_[i], i, 0);
|
|
grid->addWidget(fedit_[i], i, 1, Qt::AlignRight);
|
|
connect(fedit_[i], &QLineEdit::editingFinished, this, &JFJochViewerROIList::ApplyEditor);
|
|
}
|
|
layout->addLayout(grid);
|
|
|
|
auto *mask = new QHBoxLayout();
|
|
auto *mask_add = new QPushButton("Add to mask", this);
|
|
auto *mask_sub = new QPushButton("Subtract from mask", this);
|
|
mask->addWidget(mask_add);
|
|
mask->addWidget(mask_sub);
|
|
layout->addLayout(mask);
|
|
connect(mask_add, &QPushButton::clicked, [this] {
|
|
if (!SelectedName().isEmpty()) emit maskFromROI(SelectedName(), true);
|
|
});
|
|
connect(mask_sub, &QPushButton::clicked, [this] {
|
|
if (!SelectedName().isEmpty()) emit maskFromROI(SelectedName(), false);
|
|
});
|
|
|
|
result_ = new JFJochViewerROIResult(this);
|
|
layout->addWidget(result_);
|
|
}
|
|
|
|
void JFJochViewerROIList::UpdateEditor() {
|
|
const std::string sel = SelectedName().toStdString();
|
|
auto set = [&](int i, const QString &label, double val, bool integer) {
|
|
flabel_[i]->setText(label);
|
|
flabel_[i]->setVisible(!label.isEmpty());
|
|
fedit_[i]->setVisible(!label.isEmpty());
|
|
if (!label.isEmpty())
|
|
fedit_[i]->setText(integer ? QString::number(std::lround(val)) : QString::number(val, 'g', 6));
|
|
};
|
|
for (int i = 0; i < 4; i++)
|
|
set(i, "", 0, false);
|
|
|
|
for (const auto &b : rois_.boxes)
|
|
if (b.GetName() == sel) {
|
|
set(0, "Min X", b.GetXMin(), true); set(1, "Max X", b.GetXMax(), true);
|
|
set(2, "Min Y", b.GetYMin(), true); set(3, "Max Y", b.GetYMax(), true);
|
|
return;
|
|
}
|
|
for (const auto &c : rois_.circles)
|
|
if (c.GetName() == sel) {
|
|
set(0, "Center X", c.GetX(), false); set(1, "Center Y", c.GetY(), false);
|
|
set(2, "Radius", c.GetRadius_pxl(), false); set(3, "", 0, false);
|
|
return;
|
|
}
|
|
for (const auto &a : rois_.azimuthal)
|
|
if (a.GetName() == sel) {
|
|
set(0, "Q min [1/A]", a.GetQMin_recipA(), false); set(1, "Q max [1/A]", a.GetQMax_recipA(), false);
|
|
set(2, "phi min [deg]", a.GetPhiMin_deg(), false); set(3, "phi max [deg]", a.GetPhiMax_deg(), false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
void JFJochViewerROIList::ApplyEditor() {
|
|
if (rebuilding_)
|
|
return;
|
|
const std::string sel = SelectedName().toStdString();
|
|
bool changed = false;
|
|
|
|
for (auto &b : rois_.boxes)
|
|
if (b.GetName() == sel) {
|
|
b = ROIBox(sel, fedit_[0]->text().toLongLong(), fedit_[1]->text().toLongLong(),
|
|
fedit_[2]->text().toLongLong(), fedit_[3]->text().toLongLong());
|
|
changed = true; break;
|
|
}
|
|
for (auto &c : rois_.circles)
|
|
if (c.GetName() == sel) {
|
|
c = ROICircle(sel, fedit_[0]->text().toDouble(), fedit_[1]->text().toDouble(),
|
|
std::max(0.1, fedit_[2]->text().toDouble()));
|
|
changed = true; break;
|
|
}
|
|
for (auto &a : rois_.azimuthal)
|
|
if (a.GetName() == sel) {
|
|
const float qmin = fedit_[0]->text().toFloat();
|
|
const float qmax = fedit_[1]->text().toFloat();
|
|
const float phimin = fedit_[2]->text().toFloat();
|
|
const float phimax = fedit_[3]->text().toFloat();
|
|
const float d_min = (qmax > 0) ? 2.0f * static_cast<float>(PI) / qmax : a.GetDMin_A();
|
|
const float d_max = (qmin > 0) ? 2.0f * static_cast<float>(PI) / qmin : a.GetDMax_A();
|
|
a = ROIAzimuthal(sel, d_min, d_max, phimin, phimax);
|
|
changed = true; break;
|
|
}
|
|
|
|
if (changed)
|
|
emit roisChanged(rois_);
|
|
}
|
|
|
|
void JFJochViewerROIList::loadImage(std::shared_ptr<const JFJochReaderImage> image) {
|
|
image_ = image;
|
|
const QString keep = SelectedName();
|
|
rois_ = image_ ? image_->Dataset().experiment.ROI().GetROIDefinition() : ROIDefinition{};
|
|
RebuildCombo(keep);
|
|
ShowSelectedResult();
|
|
UpdateEditor();
|
|
}
|
|
|
|
void JFJochViewerROIList::RebuildCombo(const QString &keep_selected) {
|
|
rebuilding_ = true;
|
|
combo_->clear();
|
|
|
|
// Running index across boxes/circles/azimuthal - matches the enumeration order used by the
|
|
// image overlay, so each item's swatch colour equals its annotation colour on the image.
|
|
int color_index = 0;
|
|
auto add_item = [&](const std::string &name, const char *type) {
|
|
// display "name · type", with the bare name kept as item data for lookups
|
|
QPixmap swatch(14, 14);
|
|
swatch.fill(ROIAnnotationColor(color_index++));
|
|
combo_->addItem(QIcon(swatch),
|
|
QString("%1 · %2").arg(QString::fromStdString(name), type),
|
|
QString::fromStdString(name));
|
|
};
|
|
for (const auto &b : rois_.boxes) add_item(b.GetName(), "box");
|
|
for (const auto &c : rois_.circles) add_item(c.GetName(), "circle");
|
|
for (const auto &a : rois_.azimuthal) add_item(a.GetName(), "azim");
|
|
|
|
if (!keep_selected.isEmpty()) {
|
|
const int idx = combo_->findData(keep_selected);
|
|
if (idx >= 0)
|
|
combo_->setCurrentIndex(idx);
|
|
}
|
|
rebuilding_ = false;
|
|
}
|
|
|
|
QString JFJochViewerROIList::SelectedName() const {
|
|
return combo_->currentData().toString();
|
|
}
|
|
|
|
void JFJochViewerROIList::setSelected(QString name) {
|
|
const int idx = combo_->findData(name);
|
|
if (idx >= 0 && idx != combo_->currentIndex())
|
|
combo_->setCurrentIndex(idx); // updates the stats and selection highlight
|
|
}
|
|
|
|
void JFJochViewerROIList::ShowSelectedResult() {
|
|
const QString name = SelectedName();
|
|
if (image_ && !name.isEmpty()) {
|
|
const auto &roi = image_->ImageData().roi;
|
|
auto it = roi.find(name.toStdString());
|
|
if (it != roi.end()) {
|
|
result_->SetROIResult(it->second);
|
|
return;
|
|
}
|
|
}
|
|
result_->SetROIResult(ROIMessage{}); // clears the display
|
|
}
|
|
|
|
std::string JFJochViewerROIList::UniqueName() const {
|
|
std::set<std::string> used;
|
|
for (const auto &b : rois_.boxes) used.insert(b.GetName());
|
|
for (const auto &c : rois_.circles) used.insert(c.GetName());
|
|
for (const auto &a : rois_.azimuthal) used.insert(a.GetName());
|
|
for (int i = 1; ; i++) {
|
|
std::string n = "roi" + std::to_string(i);
|
|
if (!used.count(n)) return n;
|
|
}
|
|
}
|
|
|
|
void JFJochViewerROIList::OnSelectionChanged() {
|
|
if (rebuilding_)
|
|
return;
|
|
ShowSelectedResult();
|
|
UpdateEditor();
|
|
emit selectedROIChanged(SelectedName());
|
|
}
|
|
|
|
void JFJochViewerROIList::OnAdd() {
|
|
if (rois_.boxes.size() + rois_.circles.size() + rois_.azimuthal.size() >= 16)
|
|
return; // bitmap allows at most 16 ROIs
|
|
|
|
const std::string name = UniqueName();
|
|
int64_t cx = 100, cy = 100;
|
|
if (image_) {
|
|
cx = image_->Dataset().experiment.GetXPixelsNum() / 2;
|
|
cy = image_->Dataset().experiment.GetYPixelsNum() / 2;
|
|
}
|
|
switch (type_combo_->currentIndex()) {
|
|
case 0: rois_.boxes.emplace_back(name, cx - 100, cx + 100, cy - 100, cy + 100); break;
|
|
case 1: rois_.circles.emplace_back(name, cx, cy, 100); break;
|
|
default: rois_.azimuthal.emplace_back(name, 2.0f, 4.0f); break;
|
|
}
|
|
emit roisChanged(rois_);
|
|
RebuildCombo(QString::fromStdString(name));
|
|
ShowSelectedResult();
|
|
}
|
|
|
|
void JFJochViewerROIList::OnDelete() {
|
|
const std::string name = SelectedName().toStdString();
|
|
if (name.empty())
|
|
return;
|
|
|
|
auto erase_by_name = [&name](auto &vec) {
|
|
for (auto it = vec.begin(); it != vec.end(); ++it)
|
|
if (it->GetName() == name) { vec.erase(it); return true; }
|
|
return false;
|
|
};
|
|
if (erase_by_name(rois_.boxes) || erase_by_name(rois_.circles) || erase_by_name(rois_.azimuthal)) {
|
|
emit roisChanged(rois_);
|
|
RebuildCombo(QString());
|
|
ShowSelectedResult();
|
|
}
|
|
}
|
|
|
|
void JFJochViewerROIList::OnRename() {
|
|
const QString old_name = SelectedName();
|
|
if (old_name.isEmpty())
|
|
return;
|
|
|
|
bool ok = false;
|
|
const QString new_name = QInputDialog::getText(this, "Rename ROI", "New name:",
|
|
QLineEdit::Normal, old_name, &ok);
|
|
if (!ok || new_name.isEmpty() || new_name == old_name)
|
|
return;
|
|
|
|
const std::string oldn = old_name.toStdString();
|
|
const std::string newn = new_name.toStdString();
|
|
|
|
for (auto &b : rois_.boxes)
|
|
if (b.GetName() == oldn) { b = ROIBox(newn, b.GetXMin(), b.GetXMax(), b.GetYMin(), b.GetYMax()); break; }
|
|
for (auto &c : rois_.circles)
|
|
if (c.GetName() == oldn) { c = ROICircle(newn, c.GetX(), c.GetY(), c.GetRadius_pxl()); break; }
|
|
for (auto &a : rois_.azimuthal)
|
|
if (a.GetName() == oldn) {
|
|
a = a.HasPhi() ? ROIAzimuthal(newn, a.GetDMin_A(), a.GetDMax_A(), a.GetPhiMin_deg(), a.GetPhiMax_deg())
|
|
: ROIAzimuthal(newn, a.GetDMin_A(), a.GetDMax_A());
|
|
break;
|
|
}
|
|
|
|
emit roisChanged(rois_);
|
|
RebuildCombo(new_name);
|
|
ShowSelectedResult();
|
|
}
|