Address the review on the image strip / hit feed: - Height constraints: JFJochSimpleChartView used a hard setFixedHeight(300) and, being a page in the dataset-info stack, forced the whole plot dock (and thus the window) taller than the screen once the strip was stacked below. Make it a soft minimum (120). Wrap the settings dock in a QScrollArea so its content can scroll instead of forcing window height. Smaller strip thumbnails (96) and lower default bottom-dock heights. The window no longer grows past its requested size. - Stochastic selection: representatives are now picked at random within N equal bins (over image index, or a metric's sorted order), and a Refresh button re-rolls a fresh set — avoiding deterministic-spacing artefacts. "Most spots" stays deterministic; "Indexed" becomes spaced-random. - Live stability: the strip stores dataset updates but only rebuilds on file open (worker fileOpened) / mode / Spots / Refresh, so HTTP sync image updates no longer trigger constant thumbnail refetching. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
177 lines
7.0 KiB
C++
177 lines
7.0 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "JFJochViewerImageStrip.h"
|
|
|
|
#include <QComboBox>
|
|
#include <QCheckBox>
|
|
#include <QHBoxLayout>
|
|
#include <QVBoxLayout>
|
|
#include <QScrollArea>
|
|
#include <QToolButton>
|
|
#include <QLabel>
|
|
#include <QPixmap>
|
|
#include <QStyle>
|
|
#include <QRandomGenerator>
|
|
|
|
#include <algorithm>
|
|
#include <numeric>
|
|
#include <cmath>
|
|
|
|
JFJochViewerImageStrip::JFJochViewerImageStrip(QWidget *parent) : QWidget(parent) {
|
|
auto *layout = new QVBoxLayout(this);
|
|
layout->setContentsMargins(4, 2, 4, 2);
|
|
|
|
auto *controls = new QHBoxLayout();
|
|
controls->addWidget(new QLabel("Show", this));
|
|
mode_ = new QComboBox(this);
|
|
mode_->addItem("Evenly spaced", 0);
|
|
mode_->addItem("Most spots", 1);
|
|
mode_->addItem("Indexed", 2);
|
|
mode_->addItem("Resolution", 3); // representatives across the resolution range
|
|
mode_->addItem("Background", 4); // representatives across the background range
|
|
controls->addWidget(mode_);
|
|
spots_ = new QCheckBox("Spots", this);
|
|
spots_->setChecked(true);
|
|
spots_->setToolTip("Overlay found spots — a real pattern reads like a constellation");
|
|
controls->addWidget(spots_);
|
|
auto *refresh = new QToolButton(this);
|
|
refresh->setIcon(style()->standardIcon(QStyle::SP_BrowserReload));
|
|
refresh->setAutoRaise(true);
|
|
refresh->setCursor(Qt::PointingHandCursor);
|
|
refresh->setToolTip("Re-roll the representative selection");
|
|
controls->addWidget(refresh);
|
|
controls->addStretch();
|
|
layout->addLayout(controls);
|
|
connect(refresh, &QToolButton::clicked, this, [this] { Rebuild(); });
|
|
|
|
auto *scroll = new QScrollArea(this);
|
|
scroll->setWidgetResizable(true);
|
|
scroll->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
|
scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
|
|
auto *stripWidget = new QWidget(scroll);
|
|
strip_ = new QHBoxLayout(stripWidget);
|
|
strip_->setContentsMargins(0, 0, 0, 0);
|
|
strip_->addStretch();
|
|
scroll->setWidget(stripWidget);
|
|
layout->addWidget(scroll);
|
|
|
|
connect(mode_, &QComboBox::currentIndexChanged, this, [this] { Rebuild(); });
|
|
connect(spots_, &QCheckBox::toggled, this, [this] { Rebuild(); });
|
|
}
|
|
|
|
void JFJochViewerImageStrip::datasetLoaded(std::shared_ptr<const JFJochReaderDataset> dataset) {
|
|
// Store the latest dataset (metrics may update during live sync / re-analysis) but do NOT
|
|
// rebuild here — that would refetch thumbnails on every HTTP image update. Rebuilding happens
|
|
// on file open (resetForNewFile, which fires just after this) and on mode / Spots / Refresh.
|
|
dataset_ = std::move(dataset);
|
|
}
|
|
|
|
void JFJochViewerImageStrip::resetForNewFile() {
|
|
Rebuild(); // a new file/stream opened; dataset_ was just set by datasetLoaded
|
|
}
|
|
|
|
void JFJochViewerImageStrip::thumbnailReady(qint64 image_number, QImage thumb) {
|
|
auto it = slots_.find(image_number);
|
|
if (it != slots_.end())
|
|
it.value()->setIcon(QPixmap::fromImage(thumb));
|
|
}
|
|
|
|
QVector<qint64> JFJochViewerImageStrip::ComputeRepresentatives() const {
|
|
QVector<qint64> result;
|
|
if (!dataset_)
|
|
return result;
|
|
const int64_t total = dataset_->experiment.GetImageNum();
|
|
if (total <= 0)
|
|
return result;
|
|
const int N = static_cast<int>(std::min<int64_t>(8, total));
|
|
const int mode = mode_->currentIndex();
|
|
auto *rng = QRandomGenerator::global();
|
|
|
|
// Pick one random image from each of N equal bins over an ordered candidate list. Stochastic,
|
|
// so Refresh re-rolls a fresh set and deterministic-spacing artefacts are avoided.
|
|
auto binnedRandom = [&](const std::vector<int64_t> &candidates) {
|
|
const size_t M = candidates.size();
|
|
if (M == 0)
|
|
return;
|
|
const int n = static_cast<int>(std::min<size_t>(N, M));
|
|
for (int i = 0; i < n; ++i) {
|
|
const size_t lo = static_cast<size_t>(i) * M / n;
|
|
size_t hi = static_cast<size_t>(i + 1) * M / n;
|
|
if (hi <= lo) hi = lo + 1;
|
|
result.push_back(candidates[lo + rng->bounded(static_cast<quint32>(hi - lo))]);
|
|
}
|
|
};
|
|
|
|
// Image indices ordered by a metric (finite values only), low to high.
|
|
auto orderedByMetric = [](const std::vector<float> &metric) {
|
|
std::vector<std::pair<float, int64_t>> vals;
|
|
for (size_t i = 0; i < metric.size(); ++i)
|
|
if (std::isfinite(metric[i]))
|
|
vals.push_back({metric[i], static_cast<int64_t>(i)});
|
|
std::sort(vals.begin(), vals.end());
|
|
std::vector<int64_t> idx;
|
|
idx.reserve(vals.size());
|
|
for (const auto &v : vals) idx.push_back(v.second);
|
|
return idx;
|
|
};
|
|
|
|
std::vector<int64_t> allIndices(total);
|
|
std::iota(allIndices.begin(), allIndices.end(), 0);
|
|
|
|
const auto &sc = dataset_->spot_count;
|
|
const auto &sci = dataset_->spot_count_indexed;
|
|
|
|
if (mode == 1 && !sc.empty()) { // most spots (deterministic top-N)
|
|
std::vector<int64_t> idx(sc.size());
|
|
std::iota(idx.begin(), idx.end(), 0);
|
|
const size_t n = std::min<size_t>(N, idx.size());
|
|
std::partial_sort(idx.begin(), idx.begin() + n, idx.end(),
|
|
[&](int64_t a, int64_t b) { return sc[a] > sc[b]; });
|
|
idx.resize(n);
|
|
std::sort(idx.begin(), idx.end());
|
|
for (int64_t i : idx) result.push_back(i);
|
|
} else if (mode == 2 && !sci.empty()) { // indexed images, spaced random
|
|
std::vector<int64_t> indexed;
|
|
for (size_t i = 0; i < sci.size(); ++i)
|
|
if (sci[i] > 0) indexed.push_back(static_cast<int64_t>(i));
|
|
binnedRandom(indexed.empty() ? allIndices : indexed);
|
|
} else if (mode == 3 && !dataset_->resolution_estimate.empty()) {
|
|
binnedRandom(orderedByMetric(dataset_->resolution_estimate));
|
|
} else if (mode == 4 && !dataset_->bkg_estimate.empty()) {
|
|
binnedRandom(orderedByMetric(dataset_->bkg_estimate));
|
|
} else { // evenly spaced (random within each bin)
|
|
binnedRandom(allIndices);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
void JFJochViewerImageStrip::Rebuild() {
|
|
// Clear the strip (keep the trailing stretch).
|
|
slots_.clear();
|
|
while (strip_->count() > 1) {
|
|
QLayoutItem *item = strip_->takeAt(0);
|
|
if (item->widget())
|
|
item->widget()->deleteLater();
|
|
delete item;
|
|
}
|
|
if (!dataset_)
|
|
return;
|
|
|
|
const QVector<qint64> reps = ComputeRepresentatives();
|
|
int pos = 0;
|
|
for (qint64 n : reps) {
|
|
auto *btn = new QToolButton(this);
|
|
btn->setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
|
|
btn->setIconSize(QSize(96, 96));
|
|
btn->setText(QString::number(n + 1));
|
|
btn->setAutoRaise(true);
|
|
btn->setCursor(Qt::PointingHandCursor);
|
|
btn->setToolTip(QStringLiteral("Open image %1").arg(n + 1));
|
|
connect(btn, &QToolButton::clicked, this, [this, n] { emit imageSelected(n, 1); });
|
|
strip_->insertWidget(pos++, btn);
|
|
slots_[n] = btn;
|
|
}
|
|
emit requestThumbnails(reps, spots_->isChecked());
|
|
}
|