viewer: thumbnail hit feed (image strip)
A dockable strip of thumbnails for a handful of representative images, with spots overlaid so a real pattern reads like a constellation; click opens that image. - JFJochViewerImageStrip: mode selector (evenly spaced / most spots / indexed, computed from the dataset's per-image metrics) + a Spots toggle; a scrollable row of thumbnail buttons; click emits imageSelected. - The reading worker renders thumbnails off-thread and non-disruptively: load each image via its reader, downsample by block-maximum (keeps Bragg spots), map through the colour-scale LUT, optionally paint spots (coral indexed / teal not), and emit thumbnailReady(image_number, QImage). File mode only. - Docked in the bottom area, shown in the Processing perspective; the colour map follows the display toolbar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,8 @@ ADD_EXECUTABLE(jfjoch_viewer jfjoch_viewer.cpp JFJochViewerWindow.cpp JFJochView
|
||||
widgets/CollapsibleSection.h
|
||||
widgets/ToolbarIcons.cpp
|
||||
widgets/ToolbarIcons.h
|
||||
widgets/JFJochViewerImageStrip.cpp
|
||||
widgets/JFJochViewerImageStrip.h
|
||||
windows/JFJochViewerSpotListWindow.cpp
|
||||
windows/JFJochViewerSpotListWindow.h
|
||||
image_viewer/JFJochAzIntImage.cpp
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
#include <QVector>
|
||||
#include <QMutexLocker>
|
||||
#include <QFileInfo>
|
||||
#include <QPainter>
|
||||
#include <algorithm>
|
||||
|
||||
#include "../preview/JFJochTIFF.h"
|
||||
|
||||
@@ -99,11 +101,13 @@ JFJochImageReadingWorker::JFJochImageReadingWorker(const SpotFindingSettings &se
|
||||
qRegisterMetaType<ScalingSettings>("ScalingSettings");
|
||||
qRegisterMetaType<RunData>("RunData");
|
||||
qRegisterMetaType<QVector<RunData>>("QVector<RunData>");
|
||||
qRegisterMetaType<QVector<qint64>>("QVector<qint64>");
|
||||
spot_finding_settings = settings;
|
||||
|
||||
indexing = std::make_unique<IndexerThreadPool>(indexing_settings);
|
||||
http_reader.Experiment(experiment);
|
||||
file_reader.Experiment(experiment);
|
||||
thumb_color_scale_.Select(ColorScaleEnum::Indigo);
|
||||
|
||||
autoload_timer = new QTimer(this);
|
||||
autoload_timer->setInterval(autoload_interval);
|
||||
@@ -1025,3 +1029,91 @@ void JFJochImageReadingWorker::SetActiveSnapshot(QString name) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void JFJochImageReadingWorker::SetThumbnailColorMap(int color_map) {
|
||||
QMutexLocker locker(&m);
|
||||
thumb_color_scale_.Select(static_cast<ColorScaleEnum>(color_map));
|
||||
}
|
||||
|
||||
void JFJochImageReadingWorker::RenderThumbnails(QVector<qint64> image_numbers, bool show_spots) {
|
||||
// File mode only; live HTTP cannot address arbitrary images cheaply.
|
||||
if (http_mode)
|
||||
return;
|
||||
for (qint64 n : image_numbers) {
|
||||
QImage thumb = RenderThumbnail_i(n, show_spots);
|
||||
if (!thumb.isNull())
|
||||
emit thumbnailReady(n, thumb);
|
||||
}
|
||||
}
|
||||
|
||||
QImage JFJochImageReadingWorker::RenderThumbnail_i(int64_t image_number, bool show_spots) {
|
||||
std::shared_ptr<JFJochReaderImage> img;
|
||||
try {
|
||||
QMutexLocker locker(&m);
|
||||
if (http_mode || image_number < 0 || image_number >= total_images)
|
||||
return {};
|
||||
img = file_reader.LoadImage(image_number, 1);
|
||||
} catch (const std::exception &e) {
|
||||
logger.Debug("Thumbnail load failed for image {}: {}", image_number, e.what());
|
||||
return {};
|
||||
}
|
||||
if (!img)
|
||||
return {};
|
||||
|
||||
const int W = static_cast<int>(img->Dataset().experiment.GetXPixelsNum());
|
||||
const int H = static_cast<int>(img->Dataset().experiment.GetYPixelsNum());
|
||||
const auto &px = img->Image();
|
||||
if (W <= 0 || H <= 0 || static_cast<int64_t>(px.size()) < static_cast<int64_t>(W) * H)
|
||||
return {};
|
||||
|
||||
// Downsample by block-maximum (keeps sharp Bragg spots), then map through the colour-scale LUT.
|
||||
constexpr int maxDim = 120;
|
||||
const double s = static_cast<double>(maxDim) / std::max(W, H);
|
||||
const int tw = std::max(1, static_cast<int>(W * s));
|
||||
const int th = std::max(1, static_cast<int>(H * s));
|
||||
const int bx = std::max(1, W / tw);
|
||||
const int by = std::max(1, H / th);
|
||||
|
||||
const float fg = std::max(1.0f, static_cast<float>(img->GetAutoContrastValue()));
|
||||
const auto &lut = thumb_color_scale_.LUTData();
|
||||
const int lutSize = static_cast<int>(lut.size());
|
||||
const float invRange = fg > 0 ? (lutSize - 1) / fg : 0.0f;
|
||||
const rgb gap = thumb_color_scale_.Apply(ColorScaleSpecial::Gap);
|
||||
|
||||
QImage out(tw, th, QImage::Format_RGB32);
|
||||
for (int ty = 0; ty < th; ++ty) {
|
||||
QRgb *line = reinterpret_cast<QRgb *>(out.scanLine(ty));
|
||||
const int y1 = std::min(H, ty * by + by);
|
||||
for (int tx = 0; tx < tw; ++tx) {
|
||||
const int x1 = std::min(W, tx * bx + bx);
|
||||
int32_t best = 0;
|
||||
bool any = false;
|
||||
for (int yy = ty * by; yy < y1; ++yy)
|
||||
for (int xx = tx * bx; xx < x1; ++xx) {
|
||||
const int32_t v = px[yy * W + xx];
|
||||
if (v == GAP_PXL_VALUE || v == ERROR_PXL_VALUE) continue;
|
||||
if (v == SATURATED_PXL_VALUE) { best = static_cast<int32_t>(fg); any = true; continue; }
|
||||
if (!any || v > best) { best = v; any = true; }
|
||||
}
|
||||
rgb c;
|
||||
if (!any) {
|
||||
c = gap;
|
||||
} else {
|
||||
int idx = static_cast<int>(std::max(0, best) * invRange + 0.5f);
|
||||
idx = std::clamp(idx, 0, lutSize - 1);
|
||||
c = lut[idx];
|
||||
}
|
||||
line[tx] = qRgb(c.r, c.g, c.b);
|
||||
}
|
||||
}
|
||||
|
||||
if (show_spots) {
|
||||
QPainter p(&out);
|
||||
p.setRenderHint(QPainter::Antialiasing);
|
||||
for (const auto &sp : img->ImageData().spots) {
|
||||
p.setPen(QPen(sp.indexed ? QColor(0xFA, 0x72, 0x68) : QColor(0x2A, 0x9D, 0x8F), 1.0));
|
||||
p.drawEllipse(QPointF(sp.x * s, sp.y * s), 1.6, 1.6);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
#include <QMutex>
|
||||
#include <QTimer>
|
||||
#include <QElapsedTimer>
|
||||
#include <QImage>
|
||||
|
||||
#include "../common/ColorScale.h"
|
||||
|
||||
#include "../reader/JFJochHDF5Reader.h"
|
||||
#include "../common/Logger.h"
|
||||
@@ -88,6 +91,9 @@ private:
|
||||
|
||||
SpotFindingSettings spot_finding_settings;
|
||||
|
||||
ColorScale thumb_color_scale_; // colour map used for the image-strip thumbnails
|
||||
QImage RenderThumbnail_i(int64_t image_number, bool show_spots);
|
||||
|
||||
std::optional<int64_t> current_image;
|
||||
int64_t current_summation = 1;
|
||||
int64_t total_images = 0;
|
||||
@@ -160,6 +166,7 @@ signals:
|
||||
void snapshotsChanged(QStringList names, QString active);
|
||||
void runsChanged(QVector<RunData> runs, QString active_id);
|
||||
void fileOpened(); // a new file/stream was loaded - listeners should reset per-file state
|
||||
void thumbnailReady(qint64 image_number, QImage thumb); // for the image-strip / hit feed
|
||||
|
||||
public:
|
||||
JFJochImageReadingWorker(const SpotFindingSettings &settings, const DiffractionExperiment& experiment, QObject *parent = nullptr);
|
||||
@@ -212,4 +219,9 @@ public slots:
|
||||
void SetActiveSnapshot(QString name);
|
||||
void RenameRun(QString id, QString label); // change a run's display label (legend)
|
||||
void RemoveRun(QString id); // drop a reprocessing snapshot (not "Original")
|
||||
|
||||
// Render downsampled thumbnails for the given images (file mode only), emitting thumbnailReady
|
||||
// for each; non-disruptive (does not change the displayed image).
|
||||
void RenderThumbnails(QVector<qint64> image_numbers, bool show_spots);
|
||||
void SetThumbnailColorMap(int color_map);
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "image_viewer/JFJochDiffractionImage.h"
|
||||
#include "JFJochViewerSidePanel.h"
|
||||
#include "widgets/JFJochViewerSettingsDock.h"
|
||||
#include "widgets/JFJochViewerImageStrip.h"
|
||||
#include "JFJochViewerStatusBar.h"
|
||||
#include "../common/CUDAWrapper.h"
|
||||
#include "windows/JFJochViewerImageListWindow.h"
|
||||
@@ -534,6 +535,24 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString
|
||||
}
|
||||
menuBar->AddDockEntry(processingDock, "Processing");
|
||||
|
||||
// Image strip / hit feed: thumbnails of representative images, rendered off-thread; click opens.
|
||||
auto *imageStrip = new JFJochViewerImageStrip(this);
|
||||
imageStripDock = new QDockWidget("Image strip", this);
|
||||
imageStripDock->setObjectName("imageStripDock");
|
||||
imageStripDock->setWidget(imageStrip);
|
||||
addDockWidget(Qt::BottomDockWidgetArea, imageStripDock);
|
||||
menuBar->AddDockEntry(imageStripDock, "Image strip");
|
||||
connect(reading_worker, &JFJochImageReadingWorker::datasetLoaded,
|
||||
imageStrip, &JFJochViewerImageStrip::datasetLoaded);
|
||||
connect(imageStrip, &JFJochViewerImageStrip::requestThumbnails,
|
||||
reading_worker, &JFJochImageReadingWorker::RenderThumbnails);
|
||||
connect(reading_worker, &JFJochImageReadingWorker::thumbnailReady,
|
||||
imageStrip, &JFJochViewerImageStrip::thumbnailReady);
|
||||
connect(imageStrip, &JFJochViewerImageStrip::imageSelected,
|
||||
reading_worker, &JFJochImageReadingWorker::LoadImage);
|
||||
connect(toolBarDisplay, &JFJochViewerToolbarDisplay::colorMapChanged,
|
||||
reading_worker, &JFJochImageReadingWorker::SetThumbnailColorMap);
|
||||
|
||||
connect(menuBar, &JFJochViewerMenu::imageLayoutSelected, this,
|
||||
[this] { ApplyPerspective(Perspective::Image); });
|
||||
connect(menuBar, &JFJochViewerMenu::processingLayoutSelected, this,
|
||||
@@ -565,6 +584,7 @@ void JFJochViewerWindow::ApplyPerspective(Perspective p) {
|
||||
if (inspectorDock) inspectorDock->setVisible(true);
|
||||
if (settingsDock) settingsDock->setVisible(processing);
|
||||
if (processingDock) processingDock->setVisible(processing);
|
||||
if (imageStripDock) imageStripDock->setVisible(processing);
|
||||
for (auto *d : findChildren<QDockWidget *>())
|
||||
if (d->objectName().startsWith("datasetInfoDock"))
|
||||
d->setVisible(processing);
|
||||
|
||||
@@ -42,6 +42,7 @@ private:
|
||||
QDockWidget *inspectorDock = nullptr; // image inspector (former right-hand side panel)
|
||||
QDockWidget *settingsDock = nullptr; // inline MX/AzInt settings panel
|
||||
QDockWidget *processingDock = nullptr; // processing jobs panel
|
||||
QDockWidget *imageStripDock = nullptr; // thumbnail strip / hit feed
|
||||
QByteArray defaultLayoutState; // captured after construction, for "Reset layout"
|
||||
int datasetInfoCounter = 0; // gives each dataset-info dock a unique objectName
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// 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 <algorithm>
|
||||
#include <numeric>
|
||||
|
||||
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);
|
||||
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_);
|
||||
controls->addStretch();
|
||||
layout->addLayout(controls);
|
||||
|
||||
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) {
|
||||
dataset_ = std::move(dataset);
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
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();
|
||||
const auto &sc = dataset_->spot_count;
|
||||
const auto &sci = dataset_->spot_count_indexed;
|
||||
|
||||
auto evenly = [&] {
|
||||
for (int i = 0; i < N; ++i)
|
||||
result.push_back(N == 1 ? 0 : static_cast<int64_t>(i) * (total - 1) / (N - 1));
|
||||
};
|
||||
|
||||
if (mode == 1 && !sc.empty()) { // most spots
|
||||
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, evenly sampled
|
||||
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 {
|
||||
evenly();
|
||||
}
|
||||
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(120, 120));
|
||||
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());
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QWidget>
|
||||
#include <QMap>
|
||||
#include <memory>
|
||||
|
||||
#include "../../reader/JFJochReaderDataset.h"
|
||||
|
||||
class QComboBox;
|
||||
class QCheckBox;
|
||||
class QHBoxLayout;
|
||||
class QToolButton;
|
||||
|
||||
// A strip of thumbnails for a handful of representative images (evenly spaced, most spots, or
|
||||
// indexed), optionally overlaid with spots so a real pattern reads like a constellation. Clicking
|
||||
// a thumbnail opens that image. Thumbnails are rendered off-thread by the reading worker.
|
||||
class JFJochViewerImageStrip : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit JFJochViewerImageStrip(QWidget *parent = nullptr);
|
||||
|
||||
public slots:
|
||||
void datasetLoaded(std::shared_ptr<const JFJochReaderDataset> dataset);
|
||||
void thumbnailReady(qint64 image_number, QImage thumb);
|
||||
|
||||
signals:
|
||||
void requestThumbnails(QVector<qint64> image_numbers, bool show_spots);
|
||||
void imageSelected(int64_t image_number, int64_t summation);
|
||||
|
||||
private:
|
||||
std::shared_ptr<const JFJochReaderDataset> dataset_;
|
||||
QComboBox *mode_ = nullptr;
|
||||
QCheckBox *spots_ = nullptr;
|
||||
QHBoxLayout *strip_ = nullptr;
|
||||
QMap<qint64, QToolButton *> slots_; // image number -> its thumbnail button
|
||||
|
||||
QVector<qint64> ComputeRepresentatives() const;
|
||||
void Rebuild();
|
||||
};
|
||||
Reference in New Issue
Block a user