viewer: soften heights, stochastic strip selection, stable on live sync

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>
This commit is contained in:
2026-06-23 16:26:15 +02:00
co-authored by Claude Opus 4.8
parent 7d24cdba4f
commit 164c03f1ce
4 changed files with 63 additions and 32 deletions
+9 -2
View File
@@ -496,10 +496,15 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString
// into the running analysis so edits re-run on the current image / dataset.
auto *settingsPanel = new JFJochViewerSettingsDock(spot_finding_settings, indexing_settings,
experiment.GetAzimuthalIntegrationSettings(), this);
auto *settingsScroll = new QScrollArea(this);
settingsScroll->setWidget(settingsPanel);
settingsScroll->setWidgetResizable(true);
settingsScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
settingsScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
settingsDock = new QDockWidget("Settings", this);
settingsDock->setObjectName("settingsDock");
settingsDock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea);
settingsDock->setWidget(settingsPanel);
settingsDock->setWidget(settingsScroll); // scrollable: don't force the window taller than the screen
addDockWidget(Qt::LeftDockWidgetArea, settingsDock);
menuBar->AddDockEntry(settingsDock, "Settings");
@@ -547,11 +552,13 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString
addDockWidget(Qt::BottomDockWidgetArea, imageStripDock);
if (lastDatasetInfoDock) {
splitDockWidget(lastDatasetInfoDock, imageStripDock, Qt::Vertical);
resizeDocks({lastDatasetInfoDock, imageStripDock}, {340, 180}, Qt::Vertical);
resizeDocks({lastDatasetInfoDock, imageStripDock}, {260, 140}, Qt::Vertical);
}
menuBar->AddDockEntry(imageStripDock, "Image strip");
connect(reading_worker, &JFJochImageReadingWorker::datasetLoaded,
imageStrip, &JFJochViewerImageStrip::datasetLoaded);
connect(reading_worker, &JFJochImageReadingWorker::fileOpened,
imageStrip, &JFJochViewerImageStrip::resetForNewFile);
connect(imageStrip, &JFJochViewerImageStrip::requestThumbnails,
reading_worker, &JFJochImageReadingWorker::RenderThumbnails);
connect(reading_worker, &JFJochImageReadingWorker::thumbnailReady,
+1 -1
View File
@@ -12,7 +12,7 @@
JFJochSimpleChartView::JFJochSimpleChartView(QWidget *parent)
: QChartView(new QChart(), parent) {
chart()->legend()->hide();
setFixedHeight(300);
setMinimumHeight(120); // soft floor: let the host dock/window decide the height
setRenderHint(QPainter::Antialiasing);
setMouseTracking(true);
//setRubberBand(QChartView::RubberBand::HorizontalRubberBand);
+52 -29
View File
@@ -11,6 +11,8 @@
#include <QToolButton>
#include <QLabel>
#include <QPixmap>
#include <QStyle>
#include <QRandomGenerator>
#include <algorithm>
#include <numeric>
@@ -33,8 +35,15 @@ JFJochViewerImageStrip::JFJochViewerImageStrip(QWidget *parent) : QWidget(parent
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);
@@ -52,8 +61,14 @@ JFJochViewerImageStrip::JFJochViewerImageStrip(QWidget *parent) : QWidget(parent
}
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);
Rebuild();
}
void JFJochViewerImageStrip::resetForNewFile() {
Rebuild(); // a new file/stream opened; dataset_ was just set by datasetLoaded
}
void JFJochViewerImageStrip::thumbnailReady(qint64 image_number, QImage thumb) {
@@ -71,29 +86,43 @@ QVector<qint64> JFJochViewerImageStrip::ComputeRepresentatives() const {
return result;
const int N = static_cast<int>(std::min<int64_t>(8, total));
const int mode = mode_->currentIndex();
const auto &sc = dataset_->spot_count;
const auto &sci = dataset_->spot_count_indexed;
auto *rng = QRandomGenerator::global();
auto evenly = [&] {
for (int i = 0; i < N; ++i)
result.push_back(N == 1 ? 0 : static_cast<int64_t>(i) * (total - 1) / (N - 1));
// 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))]);
}
};
// Pick N images that span a metric's distribution (sort by value, sample evenly across it) —
// a quick "histogram representative" selection.
auto spread = [&](const std::vector<float> &metric) {
// 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)});
if (vals.empty()) { evenly(); return; }
std::sort(vals.begin(), vals.end());
const int n = static_cast<int>(std::min<size_t>(N, vals.size()));
for (int i = 0; i < n; ++i)
result.push_back(vals[n == 1 ? 0 : static_cast<size_t>(i) * (vals.size() - 1) / (n - 1)].second);
std::vector<int64_t> idx;
idx.reserve(vals.size());
for (const auto &v : vals) idx.push_back(v.second);
return idx;
};
if (mode == 1 && !sc.empty()) { // most spots
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());
@@ -102,23 +131,17 @@ QVector<qint64> JFJochViewerImageStrip::ComputeRepresentatives() const {
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, evenly sampled
} 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));
if (indexed.empty()) {
evenly();
} else {
const int n = static_cast<int>(std::min<size_t>(N, indexed.size()));
for (int i = 0; i < n; ++i)
result.push_back(indexed[n == 1 ? 0 : static_cast<size_t>(i) * (indexed.size() - 1) / (n - 1)]);
}
} else if (mode == 3 && !dataset_->resolution_estimate.empty()) { // resolution spread
spread(dataset_->resolution_estimate);
} else if (mode == 4 && !dataset_->bkg_estimate.empty()) { // background spread
spread(dataset_->bkg_estimate);
} else {
evenly();
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;
}
@@ -140,7 +163,7 @@ void JFJochViewerImageStrip::Rebuild() {
for (qint64 n : reps) {
auto *btn = new QToolButton(this);
btn->setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
btn->setIconSize(QSize(120, 120));
btn->setIconSize(QSize(96, 96));
btn->setText(QString::number(n + 1));
btn->setAutoRaise(true);
btn->setCursor(Qt::PointingHandCursor);
+1
View File
@@ -25,6 +25,7 @@ public:
public slots:
void datasetLoaded(std::shared_ptr<const JFJochReaderDataset> dataset);
void thumbnailReady(qint64 image_number, QImage thumb);
void resetForNewFile(); // a new file/stream opened: rebuild once, then stay put on live updates
signals:
void requestThumbnails(QVector<qint64> image_numbers, bool show_spots);