diff --git a/viewer/CMakeLists.txt b/viewer/CMakeLists.txt index 667a9f74..8e51d412 100644 --- a/viewer/CMakeLists.txt +++ b/viewer/CMakeLists.txt @@ -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 diff --git a/viewer/JFJochImageReadingWorker.cpp b/viewer/JFJochImageReadingWorker.cpp index b9d6725a..a22862d5 100644 --- a/viewer/JFJochImageReadingWorker.cpp +++ b/viewer/JFJochImageReadingWorker.cpp @@ -21,6 +21,8 @@ #include #include #include +#include +#include #include "../preview/JFJochTIFF.h" @@ -99,11 +101,13 @@ JFJochImageReadingWorker::JFJochImageReadingWorker(const SpotFindingSettings &se qRegisterMetaType("ScalingSettings"); qRegisterMetaType("RunData"); qRegisterMetaType>("QVector"); + qRegisterMetaType>("QVector"); spot_finding_settings = settings; indexing = std::make_unique(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(color_map)); +} + +void JFJochImageReadingWorker::RenderThumbnails(QVector 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 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(img->Dataset().experiment.GetXPixelsNum()); + const int H = static_cast(img->Dataset().experiment.GetYPixelsNum()); + const auto &px = img->Image(); + if (W <= 0 || H <= 0 || static_cast(px.size()) < static_cast(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(maxDim) / std::max(W, H); + const int tw = std::max(1, static_cast(W * s)); + const int th = std::max(1, static_cast(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(img->GetAutoContrastValue())); + const auto &lut = thumb_color_scale_.LUTData(); + const int lutSize = static_cast(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(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(fg); any = true; continue; } + if (!any || v > best) { best = v; any = true; } + } + rgb c; + if (!any) { + c = gap; + } else { + int idx = static_cast(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; +} diff --git a/viewer/JFJochImageReadingWorker.h b/viewer/JFJochImageReadingWorker.h index 6cc50558..352739ca 100644 --- a/viewer/JFJochImageReadingWorker.h +++ b/viewer/JFJochImageReadingWorker.h @@ -10,6 +10,9 @@ #include #include #include +#include + +#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 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 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 image_numbers, bool show_spots); + void SetThumbnailColorMap(int color_map); }; diff --git a/viewer/JFJochViewerWindow.cpp b/viewer/JFJochViewerWindow.cpp index 09013595..8983d93e 100644 --- a/viewer/JFJochViewerWindow.cpp +++ b/viewer/JFJochViewerWindow.cpp @@ -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()) if (d->objectName().startsWith("datasetInfoDock")) d->setVisible(processing); diff --git a/viewer/JFJochViewerWindow.h b/viewer/JFJochViewerWindow.h index 3c5960fb..6562ca4f 100644 --- a/viewer/JFJochViewerWindow.h +++ b/viewer/JFJochViewerWindow.h @@ -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 diff --git a/viewer/widgets/JFJochViewerImageStrip.cpp b/viewer/widgets/JFJochViewerImageStrip.cpp new file mode 100644 index 00000000..8fc38461 --- /dev/null +++ b/viewer/widgets/JFJochViewerImageStrip.cpp @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include "JFJochViewerImageStrip.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +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 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 JFJochViewerImageStrip::ComputeRepresentatives() const { + QVector result; + if (!dataset_) + return result; + const int64_t total = dataset_->experiment.GetImageNum(); + if (total <= 0) + return result; + const int N = static_cast(std::min(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(i) * (total - 1) / (N - 1)); + }; + + if (mode == 1 && !sc.empty()) { // most spots + std::vector idx(sc.size()); + std::iota(idx.begin(), idx.end(), 0); + const size_t n = std::min(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 indexed; + for (size_t i = 0; i < sci.size(); ++i) + if (sci[i] > 0) indexed.push_back(static_cast(i)); + if (indexed.empty()) { + evenly(); + } else { + const int n = static_cast(std::min(N, indexed.size())); + for (int i = 0; i < n; ++i) + result.push_back(indexed[n == 1 ? 0 : static_cast(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 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()); +} diff --git a/viewer/widgets/JFJochViewerImageStrip.h b/viewer/widgets/JFJochViewerImageStrip.h new file mode 100644 index 00000000..fae90657 --- /dev/null +++ b/viewer/widgets/JFJochViewerImageStrip.h @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include +#include +#include + +#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 dataset); + void thumbnailReady(qint64 image_number, QImage thumb); + +signals: + void requestThumbnails(QVector image_numbers, bool show_spots); + void imageSelected(int64_t image_number, int64_t summation); + +private: + std::shared_ptr dataset_; + QComboBox *mode_ = nullptr; + QCheckBox *spots_ = nullptr; + QHBoxLayout *strip_ = nullptr; + QMap slots_; // image number -> its thumbnail button + + QVector ComputeRepresentatives() const; + void Rebuild(); +};