jfjoch_viewer: navigation keys, one-shot auto-contrast, file manager panel, cleanup

* Dataset-info plot hover shows a horizontal crosshair alongside the existing
  vertical one, so a run of hovers reads as a level or a drift at a glance.
* `A` applies auto-contrast once instead of switching on persistent Auto mode
  (a new `oneShotAutoForeground()`, distinct from the toolbar's Auto toggle);
  pressing it a second time on the same image switches Auto on for good, and
  pressing it while Auto is already on leaves it on. The value itself comes
  from one `AutoForegroundValue()` shared with the continuous Auto path.
* `Home`/`End`/`Page Up`/`Page Down` navigate the dataset (first/last image,
  one image forward/back); claimed in the diffraction view's keyPressEvent
  before QGraphicsView's default handling, which otherwise eats them to
  scroll the viewport.
* Alt+wheel dataset navigation is removed - some window managers already
  took it before the app ever saw it, per the caveat that used to sit next
  to its entry in the shortcuts list.
* The image strip dock and its dedicated thumbnail-rendering machinery in
  the reading worker (SetThumbnail*, RenderThumbnail_i) are removed; it cost
  a lot and nothing else used any of it.
* Toolbar text: "Colour" -> "Color".
* Color map, Auto and HDR mode now persist across sessions, the same way the
  window layout already does.
* The Inspector's "Dataset:" path wraps at '/' (a zero-width space after
  each one) instead of being cut off when it doesn't fit one line.
* LoadFile no longer reopens an already-open file - it forwards straight to
  LoadImage instead - which was the likely cause of a D-Bus client's per-image
  navigation lagging behind the same navigation done via the grid-scan hover.
* New file-manager dock (left, tabbed with Settings via tabifyDockWidget):
  a directory tree filtered to *_master.h5/*_process.h5, rooted at
  JUNGFRAUJOCH_DATA_ROOT if set, else the last-browsed root, else the home
  directory. Most beamline users care about opening images, not
  reprocessing settings, so it's the tab raised by default. Its filter box
  narrows files only - a directory always passes - because filtering the
  directories too hid the matching files under any directory whose own name
  did not match, and hid the root's ancestors, which left the tree with no
  valid root index until the root was set again by hand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PogHY7bWXV4bpctDPdyuDN
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-11 18:45:20 +02:00
co-authored by Claude Sonnet 5
parent 40dd6f25fd
commit d24cdfcff4
20 changed files with 375 additions and 462 deletions
+9 -8
View File
@@ -57,8 +57,8 @@ install it.
CLI takes.
- Auxiliary windows: image list, dataset metadata, spot list, reflection list, reciprocal-space
viewer, 2D azimuthal-integration image, calibration-image viewer and a magnifier; plus the
*Inspector* (per-image statistics, image features, resolution rings, ROI statistics), the
*Image strip* thumbnail feed and dataset-info charts.
*Inspector* (per-image statistics, image features, resolution rings, ROI statistics) and
dataset-info charts.
- The *Inspector*'s **Image features** section decides what the overlay draws — spots, predictions,
saturated and highest pixels, the beam stop — including whether the non-indexed spots and the
spots that fall on an ice ring are drawn at all.
@@ -98,9 +98,10 @@ The same list is available in the application under **Help ▸ Mouse Shortcuts**
| Wheel | Zoom in / out, centred on the cursor |
| `Shift` + wheel | Move the foreground (upper contrast limit) in linear steps |
| `Ctrl` + wheel | Move the foreground in multiplicative steps (×1.15 per notch) |
| `Alt` + wheel | Step through the dataset, one image per notch (up = next image) |
| `F` held + wheel | Same as `Shift` + wheel, for as long as `F` is held |
| `A` | Switch on the automatic foreground |
| `A` | Apply auto-contrast once; press it again to switch on continuous Auto |
| `Home` / `End` | Jump to the first / last image in the dataset |
| `Page Up` / `Page Down` | Step one image forward / back |
| Hover | Status bar shows the pixel position, its value and the resolution |
| Drag | Pan the image |
| `Shift` + drag | Draw a rectangular ROI |
@@ -108,9 +109,6 @@ The same list is available in the application under **Help ▸ Mouse Shortcuts**
| Drag an ROI or its handle | Move or resize the selected ROI |
| Right click | Copy / save the image, fit to view, clear the ROI |
Some window managers take `Alt`-modified mouse events for themselves before the application sees
them; that is a window-manager setting, not something the viewer can take back.
### Grid scan
| Action | Effect |
@@ -153,7 +151,10 @@ can drive it:
and display the given image.
- `LoadImage(image_number, summation=1)` — navigate to an image in the already-open dataset.
`summation` sums that many consecutive images before display.
`summation` sums that many consecutive images before display. A repeated `LoadFile` call naming the
file that is already open is cheap (it just navigates, like `LoadImage`) rather than reopening it,
but a client stepping through images of a dataset it opened itself should still prefer `LoadImage`
it needs no filename and avoids the file-identity comparison.
## Building from source on Windows
+2 -2
View File
@@ -70,8 +70,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
widgets/JFJochViewerFileBrowser.cpp
widgets/JFJochViewerFileBrowser.h
windows/JFJochViewerSpotListWindow.cpp
windows/JFJochViewerSpotListWindow.h
image_viewer/JFJochAzIntImage.cpp
+9 -128
View File
@@ -21,8 +21,6 @@
#include <QVector>
#include <QMutexLocker>
#include <QFileInfo>
#include <QPainter>
#include <QFontMetrics>
#include <algorithm>
#include "../preview/JFJochTIFF.h"
@@ -110,7 +108,6 @@ JFJochImageReadingWorker::JFJochImageReadingWorker(const SpotFindingSettings &se
http_reader.Experiment(experiment);
hdf5_reader.Experiment(experiment);
cbf_reader.Experiment(experiment);
thumb_color_scale_.Select(ColorScaleEnum::Indigo);
autoload_timer = new QTimer(this);
autoload_timer->setInterval(autoload_interval);
@@ -205,6 +202,15 @@ void JFJochImageReadingWorker::FileOpenRetryTimerExpired() {
void JFJochImageReadingWorker::LoadFile_i(const QString &filename, qint64 image_number, qint64 summation, bool retry) {
try {
// The file (in file mode) is already open: nothing to (re)read, just move to the requested
// image. Without this, a caller that re-issues LoadFile per image instead of LoadImage (e.g.
// a D-Bus client stepping through a dataset) pays a full close+reopen+reparse of the HDF5
// dataset for every single image, instead of the cheap single-image read LoadImage does.
if (!http_mode && !filename.isEmpty() && filename == current_file) {
LoadImage_i(image_number, summation);
return;
}
std::shared_ptr<const JFJochReaderDataset> dataset;
auto start = std::chrono::high_resolution_clock::now();
@@ -1162,128 +1168,3 @@ 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::SetThumbnailFeatureColor(QColor c) {
QMutexLocker locker(&m);
thumb_feature_color_ = c;
}
void JFJochImageReadingWorker::SetThumbnailSpotColor(QColor c) {
QMutexLocker locker(&m);
thumb_spot_color_ = c;
}
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) {
// Everything runs inside one try: an uncaught exception here would be on the worker thread and
// would std::terminate the whole app. On any failure we just skip this thumbnail.
try {
std::shared_ptr<JFJochReaderImage> img;
{
QMutexLocker locker(&m);
if (http_mode || image_number < 0 || image_number >= total_images)
return {};
img = file_reader->LoadImage(image_number, 1);
}
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 {};
const auto &lut = thumb_color_scale_.LUTData();
const int lutSize = static_cast<int>(lut.size());
if (lutSize <= 0)
return {};
// Downsample by block-maximum (keeps sharp Bragg spots), then map through the 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 float invRange = (lutSize - 1) / fg;
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 || v == BEAM_STOP_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 ? thumb_feature_color_ : thumb_spot_color_, 1.0));
p.drawEllipse(QPointF(sp.x * s, sp.y * s), 1.6, 1.6);
}
}
// Image-number badge in the top-left, so the strip needs no text label (and loses no height).
{
QPainter p(&out);
p.setRenderHint(QPainter::Antialiasing);
QFont f = p.font();
f.setBold(true);
f.setPixelSize(11);
p.setFont(f);
const QString label = QString::number(image_number + 1);
const QRect tb = QFontMetrics(f).boundingRect(label);
const QRect badge(2, 2, tb.width() + 8, tb.height() + 2);
p.setPen(Qt::NoPen);
p.setBrush(QColor(0xFA, 0x72, 0x68)); // coral
p.drawRoundedRect(badge, 3, 3);
p.setPen(Qt::white);
p.drawText(badge, Qt::AlignCenter, label);
}
return out;
} catch (const std::exception &e) {
logger.Debug("Thumbnail render failed for image {}: {}", image_number, e.what());
return {};
} catch (...) {
return {};
}
}
-17
View File
@@ -10,10 +10,6 @@
#include <QMutex>
#include <QTimer>
#include <QElapsedTimer>
#include <QImage>
#include <QColor>
#include "../common/ColorScale.h"
#include "../reader/JFJochHDF5Reader.h"
#include "../reader/JFJochCBFReader.h"
@@ -112,11 +108,6 @@ private:
SpotFindingSettings spot_finding_settings;
ColorScale thumb_color_scale_; // colour map used for the image-strip thumbnails
QColor thumb_feature_color_ = Qt::magenta; // indexed-spot overlay (matches the main viewer)
QColor thumb_spot_color_ = Qt::green; // non-indexed spot overlay
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;
@@ -206,7 +197,6 @@ 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
void referenceMtzChanged(ReferenceMtzInfo info); // a reference MTZ was (un)loaded - update the dock
public:
@@ -274,11 +264,4 @@ 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);
void SetThumbnailFeatureColor(QColor c);
void SetThumbnailSpotColor(QColor c);
};
+43 -35
View File
@@ -17,10 +17,11 @@
#include <QPushButton>
#include "JFJochImageReadingWorker.h"
#include "../common/ColorScale.h"
#include "image_viewer/JFJochDiffractionImage.h"
#include "JFJochViewerSidePanel.h"
#include "widgets/JFJochViewerSettingsDock.h"
#include "widgets/JFJochViewerImageStrip.h"
#include "widgets/JFJochViewerFileBrowser.h"
#include "JFJochViewerStatusBar.h"
#include "../common/CUDAWrapper.h"
#include "../rugnux/RugnuxDefaults.h"
@@ -46,7 +47,7 @@
// Dock-layout version for saveState/restoreState: bump whenever the dock set / structure changes so a
// stale saved layout (which can restore a dock as a broken, non-responsive floating window) is rejected
// and the default layout applies instead.
static constexpr int kLayoutVersion = 2;
static constexpr int kLayoutVersion = 4;
JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString &file) : QMainWindow(parent) {
menuBar = new JFJochViewerMenu(this);
@@ -449,6 +450,9 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString
connect(this, &JFJochViewerWindow::setAutoForeground,
viewer, &JFJochDiffractionImage::setAutoForeground);
connect(this, &JFJochViewerWindow::oneShotAutoForeground,
viewer, &JFJochDiffractionImage::oneShotAutoForeground);
// Processing jobs: run rugnux locally / copy a cluster command line, and surface
// finished runs as switchable dataset snapshots.
connect(processingJobsWindow, &JFJochProcessingJobsWindow::registerSnapshot,
@@ -500,6 +504,21 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString
addDockWidget(Qt::LeftDockWidgetArea, settingsDock);
menuBar->AddDockEntry(settingsDock, "Settings");
// File manager: an alternative to Settings in the same left-hand slot, tabbed together (the
// canonical Qt way to offer two panels one on top of the other without doubling the width) - most
// beamline users care about opening images, not reprocessing settings, so this one starts on top.
auto *fileBrowser = new JFJochViewerFileBrowser(this);
fileBrowserDock = new QDockWidget("File manager", this);
fileBrowserDock->setObjectName("fileBrowserDock");
fileBrowserDock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea);
fileBrowserDock->setWidget(fileBrowser);
addDockWidget(Qt::LeftDockWidgetArea, fileBrowserDock);
tabifyDockWidget(settingsDock, fileBrowserDock);
fileBrowserDock->raise();
menuBar->AddDockEntry(fileBrowserDock, "File manager");
connect(fileBrowser, &JFJochViewerFileBrowser::openDataset,
reading_worker, &JFJochImageReadingWorker::LoadFile);
connect(settingsPanel, &JFJochViewerSettingsDock::spotFindingChanged,
reading_worker, &JFJochImageReadingWorker::UpdateSpotFindingSettings);
connect(settingsPanel, &JFJochViewerSettingsDock::azintChanged,
@@ -554,35 +573,6 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString
processingDock->raise();
});
// Image strip / hit feed: thumbnails of representative images, rendered off-thread; click opens.
// Stacked below the plots — both want vertical room, and the strip needs less of it.
auto *imageStrip = new JFJochViewerImageStrip(this);
imageStripDock = new QDockWidget("Image strip", this);
imageStripDock->setObjectName("imageStripDock");
imageStripDock->setWidget(imageStrip);
addDockWidget(Qt::BottomDockWidgetArea, imageStripDock);
if (lastDatasetInfoDock) {
splitDockWidget(lastDatasetInfoDock, imageStripDock, Qt::Vertical);
resizeDocks({lastDatasetInfoDock, imageStripDock}, {260, 140}, Qt::Vertical);
}
menuBar->AddDockEntry(imageStripDock, "Image strip");
connect(this, &JFJochViewerWindow::datasetReady,
imageStrip, &JFJochViewerImageStrip::datasetLoaded);
connect(reading_worker, &JFJochImageReadingWorker::fileOpened,
imageStrip, &JFJochViewerImageStrip::resetForNewFile);
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(side_panel, &JFJochViewerSidePanel::setFeatureColor,
reading_worker, &JFJochImageReadingWorker::SetThumbnailFeatureColor);
connect(side_panel, &JFJochViewerSidePanel::setSpotColor,
reading_worker, &JFJochImageReadingWorker::SetThumbnailSpotColor);
connect(menuBar, &JFJochViewerMenu::imageLayoutSelected, this,
[this] { ApplyPerspective(Perspective::Image); });
connect(menuBar, &JFJochViewerMenu::processingLayoutSelected, this,
@@ -603,6 +593,18 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString
restoreGeometry(settings.value("geometry").toByteArray());
restoreState(settings.value("windowState").toByteArray(), kLayoutVersion);
// Display settings (color map, Auto, HDR) persist across sessions the same way the layout does.
const int colorMap = settings.value("colorMap", static_cast<int>(ColorScaleEnum::Indigo)).toInt();
viewer->setColorMap(colorMap);
toolBarDisplay->updateColorMap(colorMap);
const bool autoForeground = settings.value("autoForeground", false).toBool();
viewer->setAutoForeground(autoForeground); // emits autoForegroundChanged -> toolbar button state
const bool hdrMode = settings.value("hdrMode", false).toBool();
viewer->setHDRMode(hdrMode);
toolBarDisplay->updateHDRMode(hdrMode);
if (!file.isEmpty())
LoadFile(file, 0, 1, false);
}
@@ -615,11 +617,14 @@ JFJochViewerWindow::~JFJochViewerWindow() {
}
void JFJochViewerWindow::ApplyPerspective(Perspective p) {
// Image: just the image + inspector. Processing: also settings, dataset-info plots, jobs panel.
// Image: just the image + inspector (+ file manager, on top of its tab with Settings).
// Processing: also dataset-info plots and the jobs panel, with Settings raised instead.
const bool processing = (p == Perspective::Processing);
if (inspectorDock) inspectorDock->setVisible(true);
if (settingsDock) settingsDock->setVisible(processing);
if (imageStripDock) imageStripDock->setVisible(processing);
// Settings and the file manager stay tabbed together either way; only which one is on top changes.
if (settingsDock) settingsDock->setVisible(true);
if (fileBrowserDock) fileBrowserDock->setVisible(true);
(processing ? settingsDock : fileBrowserDock)->raise();
if (processingDock && !processing) processingDock->hide(); // shows only when a job starts
for (auto *d : findChildren<QDockWidget *>())
if (d->objectName().startsWith("datasetInfoDock"))
@@ -631,6 +636,9 @@ void JFJochViewerWindow::closeEvent(QCloseEvent *event) {
QSettings settings("PSI", "jfjoch_viewer");
settings.setValue("geometry", saveGeometry());
settings.setValue("windowState", saveState(kLayoutVersion));
settings.setValue("colorMap", toolBarDisplay->colorMap());
settings.setValue("autoForeground", toolBarDisplay->autoForeground());
settings.setValue("hdrMode", toolBarDisplay->hdrMode());
QMainWindow::closeEvent(event);
}
@@ -701,7 +709,7 @@ void JFJochViewerWindow::keyPressEvent(QKeyEvent *event) {
return;
}
if (event->key() == Qt::Key_A && ! event->isAutoRepeat()) {
emit setAutoForeground(true);
emit oneShotAutoForeground();
event->accept();
return;
}
+2 -1
View File
@@ -41,8 +41,8 @@ private:
QDockWidget *lastDatasetInfoDock = nullptr; // most recent dataset-info dock, for docking layout
QDockWidget *inspectorDock = nullptr; // image inspector (former right-hand side panel)
QDockWidget *settingsDock = nullptr; // inline MX/AzInt settings panel
QDockWidget *fileBrowserDock = nullptr; // data-root directory tree, tabbed with settingsDock
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
@@ -88,5 +88,6 @@ signals:
void LoadImageRequest(int64_t image_number, int64_t summation);
void adjustForegroundButton(bool input);
void setAutoForeground(bool val);
void oneShotAutoForeground();
};
+37 -2
View File
@@ -220,6 +220,11 @@ void JFJochDatasetInfoChartView::updateChart() {
delete m_hoverLine;
m_hoverLine = nullptr;
}
if (m_hoverLineHorizontal) {
chart()->scene()->removeItem(m_hoverLineHorizontal);
delete m_hoverLineHorizontal;
m_hoverLineHorizontal = nullptr;
}
#ifdef JFJOCH_USE_FFTW
if (m_showFFT) {
@@ -564,18 +569,25 @@ void JFJochDatasetInfoChartView::mouseMoveEvent(QMouseEvent *event) {
const double fBin = m_fftFrequenciesHz[static_cast<size_t>(bestIdx)];
const double amp = m_fftMagnitudes[static_cast<size_t>(bestIdx)];
// Map x position of that bin to scene coords for vertical line
// Map the bin's (frequency, amplitude) to scene coords for the crosshair.
const QRectF plotArea = chart()->plotArea();
const QPointF ptOnChart = chart()->mapToPosition(QPointF(fBin, 0.0), series);
const QPointF ptOnChart = chart()->mapToPosition(QPointF(fBin, amp), series);
if (!m_hoverLine) {
m_hoverLine = new QGraphicsLineItem;
m_hoverLine->setPen(QPen(QColor(200, 0, 0, 150), 1.0));
chart()->scene()->addItem(m_hoverLine);
}
if (!m_hoverLineHorizontal) {
m_hoverLineHorizontal = new QGraphicsLineItem;
m_hoverLineHorizontal->setPen(QPen(QColor(200, 0, 0, 150), 1.0));
chart()->scene()->addItem(m_hoverLineHorizontal);
}
m_hoverLine->setLine(QLineF(ptOnChart.x(), plotArea.top(),
ptOnChart.x(), plotArea.bottom()));
m_hoverLineHorizontal->setLine(QLineF(plotArea.left(), ptOnChart.y(),
plotArea.right(), ptOnChart.y()));
QString text = QString("f = %1 Hz, amplitude = %2")
.arg(fBin, 0, 'g', 6)
@@ -665,6 +677,24 @@ void JFJochDatasetInfoChartView::mouseMoveEvent(QMouseEvent *event) {
m_hoverLine->setLine(QLineF(ptOnChart.x(), plotArea.top(),
ptOnChart.x(), plotArea.bottom()));
// Horizontal crosshair at the hovered value, so a run of hovers reads off as a level (flat) or a
// drift (rising/falling) at a glance, not just point-by-point in the status bar.
if (std::isfinite(yv)) {
const QPointF ptOnChartY =
chart()->mapToPosition(QPointF(static_cast<double>(idx), yv), series);
if (!m_hoverLineHorizontal) {
m_hoverLineHorizontal = new QGraphicsLineItem;
m_hoverLineHorizontal->setPen(QPen(QColor(200, 0, 0, 150), 1.0));
chart()->scene()->addItem(m_hoverLineHorizontal);
}
m_hoverLineHorizontal->setLine(QLineF(plotArea.left(), ptOnChartY.y(),
plotArea.right(), ptOnChartY.y()));
} else if (m_hoverLineHorizontal) {
chart()->scene()->removeItem(m_hoverLineHorizontal);
delete m_hoverLineHorizontal;
m_hoverLineHorizontal = nullptr;
}
// Status bar text based on yv (bin mean in binned mode)
QString text;
if (m_yOneOverD) {
@@ -711,6 +741,11 @@ void JFJochDatasetInfoChartView::leaveEvent(QEvent *event) {
delete m_hoverLine;
m_hoverLine = nullptr;
}
if (m_hoverLineHorizontal) {
chart()->scene()->removeItem(m_hoverLineHorizontal);
delete m_hoverLineHorizontal;
m_hoverLineHorizontal = nullptr;
}
m_hoverLoadTimer->stop();
m_hoverPendingIdx = -1;
emit writeStatusBar(QString(), 0);
+2 -1
View File
@@ -25,7 +25,8 @@ class JFJochDatasetInfoChartView : public QChartView {
QPointer<QLineSeries> series = nullptr;
QPointer<QScatterSeries> currentSeries = nullptr;
QGraphicsLineItem *m_hoverLine = nullptr;
QGraphicsLineItem *m_hoverLine = nullptr; // vertical crosshair, at the hovered x
QGraphicsLineItem *m_hoverLineHorizontal = nullptr; // horizontal crosshair, at the hovered y
QTimer *m_hoverLoadTimer = nullptr;
int64_t m_hoverPendingIdx = -1;
+60 -7
View File
@@ -20,6 +20,7 @@
#include <QScrollBar>
#include <QMenu>
#include <cmath>
#include <limits>
#include <QMouseEvent>
#include "JFJochSimpleImage.h"
@@ -840,6 +841,31 @@ void JFJochDiffractionImage::roiScratchDrawn() {
}
void JFJochDiffractionImage::keyPressEvent(QKeyEvent *event) {
// QGraphicsView would otherwise consume these to scroll the viewport; claim them first so they
// navigate the dataset instead, via the same stepImage() the navigation toolbar already listens
// to. Home/End step by an effectively infinite amount, which stepImage's clamp turns into
// "jump to the first/last image".
switch (event->key()) {
case Qt::Key_Home:
emit stepImage(std::numeric_limits<int>::min());
event->accept();
return;
case Qt::Key_End:
emit stepImage(std::numeric_limits<int>::max());
event->accept();
return;
case Qt::Key_PageUp:
emit stepImage(1);
event->accept();
return;
case Qt::Key_PageDown:
emit stepImage(-1);
event->accept();
return;
default:
break;
}
if (event->key() == Qt::Key_Delete && image && !selected_roi_.isEmpty()) {
ROIDefinition rois = image->Dataset().experiment.ROI().GetROIDefinition();
const std::string sel = selected_roi_.toStdString();
@@ -859,16 +885,22 @@ void JFJochDiffractionImage::keyPressEvent(QKeyEvent *event) {
}
std::optional<float> JFJochDiffractionImage::AutoForegroundValue() const {
if (!image)
return {};
if (!hdr_mode)
return static_cast<float>(image->GetAutoContrastValue());
const auto val_range = image->ValidMinMax();
if (!val_range.has_value())
return {};
return static_cast<float>(val_range->second);
}
void JFJochDiffractionImage::UpdateForeground() {
if (!image || !auto_fg)
return;
if (hdr_mode) {
const auto val_range = image->ValidMinMax();
if (val_range.has_value())
foreground = val_range->second;
} else {
foreground = image->GetAutoContrastValue();
}
if (const auto val = AutoForegroundValue())
foreground = *val;
emit foregroundChanged(foreground);
}
@@ -882,6 +914,7 @@ void JFJochDiffractionImage::setHDRMode(bool input) {
void JFJochDiffractionImage::loadImage(std::shared_ptr<const JFJochReaderImage> in_image) {
live_pending_ = false; // a live ROI edit (if any) has now been recomputed
ring_cache_key_.clear(); // geometry may differ, re-trace the resolution rings
one_shot_auto_ = false; // a new image has its own auto value: `A` is a one-shot again
if (in_image) {
image = in_image;
UpdateForeground();
@@ -902,6 +935,7 @@ void JFJochDiffractionImage::loadImage(std::shared_ptr<const JFJochReaderImage>
void JFJochDiffractionImage::setAutoForeground(bool input) {
auto_fg = input;
one_shot_auto_ = false; // whatever `A` did before, the next press starts as a one-shot again
// If auto_foreground is not set, then view stays with the current settings till these are explicitly changed
UpdateForeground();
RenderImage();
@@ -909,6 +943,25 @@ void JFJochDiffractionImage::setAutoForeground(bool input) {
emit autoForegroundChanged(auto_fg);
}
void JFJochDiffractionImage::oneShotAutoForeground() {
if (auto_fg)
return; // Auto already follows every image: there is nothing to apply, and nothing to undo
if (one_shot_auto_) {
setAutoForeground(true); // second press on the same image: keep it on from now on
return;
}
const auto val = AutoForegroundValue();
if (!val)
return;
// Unlike a manual foreground change this leaves auto_fg alone (it is off here either way).
foreground = *val;
one_shot_auto_ = true;
ScheduleRenderImage();
emit foregroundChanged(foreground);
}
void JFJochDiffractionImage::setResolutionRing(QVector<float> v) {
res_ring = v;
ring_mode = RingMode::Manual;
@@ -91,6 +91,10 @@ private:
void DrawCross(float x, float y, float size, float width, float z = 1);
void UpdateForeground();
// The foreground the current image suggests: the top of the valid range in HDR mode, the
// auto-contrast value otherwise. Empty when there is no image to take it from.
[[nodiscard]] std::optional<float> AutoForegroundValue() const;
bool one_shot_auto_ = false; // `A` applied the auto value; pressing it again makes Auto permanent
void leaveEvent(QEvent *event) override;
std::shared_ptr<const JFJochReaderImage> image;
@@ -125,6 +129,9 @@ public slots:
void setSelectedROI(QString name);
void loadImage(std::shared_ptr<const JFJochReaderImage> image);
void setAutoForeground(bool input);
// Apply the auto-contrast value once, leaving Auto as it was; pressing it a second time on the
// same image switches Auto on for good (which keeps recomputing it on every subsequent image).
void oneShotAutoForeground();
void setResolutionRing(QVector<float> v);
void setResolutionRingMode(RingMode mode);
-10
View File
@@ -143,16 +143,6 @@ void JFJochImage::setFeatureColor(QColor input) {
void JFJochImage::wheelEvent(QWheelEvent *event) {
if (!scene()) return;
// Alt+wheel steps through the dataset instead of zooming. The view does not know which image
// it shows, so the step is emitted and the navigation toolbar applies it.
if (event->modifiers() & Qt::AltModifier) {
const int delta = event->angleDelta().y();
if (delta != 0)
emit stepImage(delta > 0 ? 1 : -1);
event->accept();
return;
}
const double zoomFactor = 1.15; // Zoom factor
// Get the position of the mouse in scene coordinates
+2 -1
View File
@@ -237,7 +237,8 @@ signals:
void hoverScenePos(QPointF scenePos);
// A new frame has been rendered into Frame(). Follower views repaint on this.
void frameRendered();
// Alt+wheel asks for a move through the dataset: +1 one image forward, -1 one back.
// Asks for a move through the dataset (e.g. Home/End/PageUp/PageDown in a subclass'
// keyPressEvent): the navigation toolbar applies the step, clamped to the dataset's range.
void stepImage(int steps);
private slots:
void onScroll(int value);
+12 -2
View File
@@ -16,7 +16,7 @@ JFJochViewerToolbarDisplay::JFJochViewerToolbarDisplay(QWidget *parent)
foreground_slider = new SliderPlusBox(1, MAX_SLIDER_NO_IMAGE, 1.0, 1, this,
SliderPlusBox::ScaleType::Logarithmic);
foreground_slider->setValue(10);
foreground_slider->setToolTip("White point of the colour map (image contrast)");
foreground_slider->setToolTip("White point of the color map (image contrast)");
foreground_slider->setStyleSheet(
"QSlider::groove:horizontal { height:6px; background:#F3D9D4; border-radius:3px; }"
"QSlider::sub-page:horizontal { background:#FA7268; border-radius:3px; }"
@@ -39,7 +39,7 @@ JFJochViewerToolbarDisplay::JFJochViewerToolbarDisplay(QWidget *parent)
hdr_mode_button = makeToggle("HDR", "High dynamic range: show the full intensity range unclipped");
addWidget(hdr_mode_button);
addWidget(new QLabel(" Colour map ", this));
addWidget(new QLabel(" Color map ", this));
// Initialize QComboBox with the options
color_map_select = new QComboBox(this);
@@ -87,6 +87,16 @@ void JFJochViewerToolbarDisplay::updateAutoForeground(bool val) {
auto_foreground_button->setChecked(val);
}
void JFJochViewerToolbarDisplay::updateColorMap(int val) {
QSignalBlocker blocker(color_map_select);
color_map_select->setCurrentIndex(val);
}
void JFJochViewerToolbarDisplay::updateHDRMode(bool val) {
QSignalBlocker blocker(hdr_mode_button);
hdr_mode_button->setChecked(val);
}
void JFJochViewerToolbarDisplay::HDRModeButtonPressed() {
emit setHDRMode(hdr_mode_button->isChecked());
}
@@ -24,6 +24,11 @@ class JFJochViewerToolbarDisplay : public QToolBar {
public:
JFJochViewerToolbarDisplay(QWidget *parent = nullptr);
// Current display settings, for persisting them across sessions (see JFJochViewerWindow).
[[nodiscard]] int colorMap() const { return color_map_select->currentIndex(); }
[[nodiscard]] bool autoForeground() const { return auto_foreground_button->isChecked(); }
[[nodiscard]] bool hdrMode() const { return hdr_mode_button->isChecked(); }
signals:
void setForeground(float val);
void colorMapChanged(int val);
@@ -33,6 +38,8 @@ signals:
public slots:
void updateForeground(float val);
void updateAutoForeground(bool val);
void updateColorMap(int val);
void updateHDRMode(bool val);
void imageLoaded(std::shared_ptr<const JFJochReaderImage> image);
private slots:
void foregroundSet(double val);
+130
View File
@@ -0,0 +1,130 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "JFJochViewerFileBrowser.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLineEdit>
#include <QToolButton>
#include <QTreeView>
#include <QHeaderView>
#include <QFileDialog>
#include <QSettings>
#include <QDir>
namespace {
constexpr auto kDataRootEnvVar = "JUNGFRAUJOCH_DATA_ROOT";
constexpr auto kSettingsKey = "fileBrowserRoot";
// The filter box narrows files only; a directory always passes. Filtering directories too would
// hide everything below one whose own name does not match - including the files that do match -
// and would hide the root's ancestors, which leaves the tree with no valid root index at all.
class FileOnlyFilterProxy : public QSortFilterProxyModel {
public:
using QSortFilterProxyModel::QSortFilterProxyModel;
protected:
bool filterAcceptsRow(int row, const QModelIndex &parent) const override {
const auto *fs = qobject_cast<const QFileSystemModel *>(sourceModel());
if (fs && fs->isDir(fs->index(row, 0, parent)))
return true;
return QSortFilterProxyModel::filterAcceptsRow(row, parent);
}
};
}
QString JFJochViewerFileBrowser::DefaultRoot() {
// Site default (e.g. a beamline launcher setting it to /sls/mx/data/p<group>), then whatever
// root was last browsed to (so re-launching without the variable set doesn't start over from
// scratch), then the user's home - never the current directory, which for an installed binary
// is typically the install location rather than anywhere the user would keep data.
const QString env_root = qEnvironmentVariable(kDataRootEnvVar);
if (!env_root.isEmpty() && QDir(env_root).exists())
return env_root;
const QString last_root = QSettings("PSI", "jfjoch_viewer").value(kSettingsKey).toString();
if (!last_root.isEmpty() && QDir(last_root).exists())
return last_root;
return QDir::homePath();
}
JFJochViewerFileBrowser::JFJochViewerFileBrowser(QWidget *parent) : QWidget(parent) {
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(4, 4, 4, 4);
auto *root_row = new QHBoxLayout();
root_edit_ = new QLineEdit(this);
root_edit_->setToolTip("Data root - type a path or use Browse…");
auto *browse_button = new QToolButton(this);
browse_button->setText("");
browse_button->setToolTip("Browse for a data root…");
root_row->addWidget(root_edit_);
root_row->addWidget(browse_button);
layout->addLayout(root_row);
filter_edit_ = new QLineEdit(this);
filter_edit_->setPlaceholderText("Filter…");
filter_edit_->setClearButtonEnabled(true);
layout->addWidget(filter_edit_);
model_ = new QFileSystemModel(this);
model_->setFilter(QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot);
// Directories are always listed regardless of these; only *_master.h5 / *_process.h5 among
// files. The many _data_NNNNNN.h5 shards of a dataset are not meant to be opened directly.
model_->setNameFilters({"*_master.h5", "*_process.h5"});
model_->setNameFilterDisables(false); // hide non-matching files rather than just greying them out
proxy_ = new FileOnlyFilterProxy(this);
proxy_->setSourceModel(model_);
proxy_->setFilterKeyColumn(0);
proxy_->setFilterCaseSensitivity(Qt::CaseInsensitive);
// Deliberately not recursive: it only ever narrows names already listed at an expanded level,
// never forces fetching a collapsed subtree just to search inside it.
tree_ = new QTreeView(this);
tree_->setModel(proxy_);
tree_->setHeaderHidden(true);
for (int col = 1; col < model_->columnCount(); ++col)
tree_->hideColumn(col);
tree_->setSortingEnabled(false);
layout->addWidget(tree_);
connect(browse_button, &QToolButton::clicked, this, &JFJochViewerFileBrowser::onBrowseClicked);
connect(root_edit_, &QLineEdit::editingFinished, this, &JFJochViewerFileBrowser::onRootEditingFinished);
connect(filter_edit_, &QLineEdit::textChanged, proxy_, &QSortFilterProxyModel::setFilterFixedString);
connect(tree_, &QTreeView::doubleClicked, this, &JFJochViewerFileBrowser::onDoubleClicked);
SetRoot(DefaultRoot());
}
void JFJochViewerFileBrowser::SetRoot(const QString &path) {
QDir dir(path);
if (!dir.exists())
return;
const QString canonical = QDir::toNativeSeparators(dir.absolutePath());
root_edit_->setText(canonical);
model_->setRootPath(canonical);
tree_->setRootIndex(proxy_->mapFromSource(model_->index(canonical)));
QSettings("PSI", "jfjoch_viewer").setValue(kSettingsKey, canonical);
}
void JFJochViewerFileBrowser::onBrowseClicked() {
const QString dir = QFileDialog::getExistingDirectory(this, "Select data root", root_edit_->text());
if (!dir.isEmpty())
SetRoot(dir);
}
void JFJochViewerFileBrowser::onRootEditingFinished() {
SetRoot(root_edit_->text());
}
void JFJochViewerFileBrowser::onDoubleClicked(const QModelIndex &index) {
const QModelIndex source = proxy_->mapToSource(index);
if (!source.isValid() || model_->isDir(source))
return; // directories: let the tree's own expand/collapse handle the double click
emit openDataset(QDir::toNativeSeparators(model_->filePath(source)), 0, 1, false);
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <QWidget>
#include <QFileSystemModel>
#include <QSortFilterProxyModel>
class QLineEdit;
class QTreeView;
class QModelIndex;
// Left-side dock alternative to the Settings/reprocessing panel: a directory tree rooted at a
// configurable data root, filtered down to subdirectories and *_master.h5 / *_process.h5 files, for
// browsing to and opening a dataset without caring about reprocessing. Backed by QFileSystemModel,
// which only lists a directory once it is expanded - so a root with hundreds of sibling directories
// (e.g. an SLS beamline's /sls/mx/data/) costs one readdir at each level actually opened, never a
// recursive scan of the whole tree.
class JFJochViewerFileBrowser : public QWidget {
Q_OBJECT
QLineEdit *root_edit_;
QLineEdit *filter_edit_;
QTreeView *tree_;
QFileSystemModel *model_;
QSortFilterProxyModel *proxy_;
void SetRoot(const QString &path);
[[nodiscard]] static QString DefaultRoot();
private slots:
void onBrowseClicked();
void onRootEditingFinished();
void onDoubleClicked(const QModelIndex &index);
signals:
// Mirrors JFJochViewerMenu::fileOpenSelected, so the window wires it to LoadFile the same way.
void openDataset(const QString &filename, qint64 image_number, qint64 summation, bool retry);
public:
explicit JFJochViewerFileBrowser(QWidget *parent = nullptr);
};
@@ -90,6 +90,7 @@ JFJochViewerImageStatistics::JFJochViewerImageStatistics(QWidget *parent) : QWid
QFormLayout* layout = new QFormLayout(this);
dataset_name = new QLabel(this);
dataset_name->setWordWrap(true);
layout->addRow(new QLabel("Dataset:"), dataset_name);
detector_name = new QLabel(this);
@@ -183,7 +184,12 @@ void JFJochViewerImageStatistics::loadImage(std::shared_ptr<const JFJochReaderIm
auto &exp = image->Dataset().experiment;
text = QString("<b>%1</b>").arg(QString::fromStdString(exp.GetFilePrefix()));
// Word-wrap alone would break wherever it likes (mid-directory-name); a zero-width space after
// every "/" gives it a preferred break point there, so a long path wraps like a path and only
// falls back to breaking a single component if that component alone doesn't fit.
QString path = QString::fromStdString(exp.GetFilePrefix());
path.replace('/', QString("/") + QChar(0x200B)); // U+200B ZERO WIDTH SPACE, a break opportunity
text = QString("<b>%1</b>").arg(path);
dataset_name->setText(text);
dataset_name->setToolTip(QString("Collection data: <b>%1</b><br/>Beam center: <b>%2</b> <b>%3</b> pxl<br/>Detector distance: <b>%4</b> mm<br/>Energy: <b>%5</b> eV<br/>Wavelength: <b>%6</b> Å")
.arg(QString::fromStdString(utc_to_local_human_readable(image->Dataset().arm_date)))
-192
View File
@@ -1,192 +0,0 @@
// 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 <QResizeEvent>
#include <algorithm>
#include <numeric>
#include <cmath>
#include <utility>
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(); });
scroll_ = new QScrollArea(this);
scroll_->setWidgetResizable(true);
scroll_->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scroll_->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
scroll_->setMinimumHeight(48); // let the dock shrink; thumbnails scale to fit
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::ToolButtonIconOnly); // the image number is drawn in the bitmap
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;
}
UpdateThumbnailSize();
emit requestThumbnails(reps, spots_->isChecked());
}
void JFJochViewerImageStrip::UpdateThumbnailSize() {
if (!scroll_)
return;
// Scale the thumbnails to the available height so the strip never needs more room than it has.
const int h = std::clamp(scroll_->viewport()->height() - 4, 40, 240);
for (auto *btn : std::as_const(slots_))
btn->setIconSize(QSize(h, h));
}
void JFJochViewerImageStrip::resizeEvent(QResizeEvent *event) {
QWidget::resizeEvent(event);
UpdateThumbnailSize();
}
-50
View File
@@ -1,50 +0,0 @@
// 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;
class QScrollArea;
class QResizeEvent;
// 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);
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);
void imageSelected(int64_t image_number, int64_t summation);
protected:
void resizeEvent(QResizeEvent *event) override;
private:
std::shared_ptr<const JFJochReaderDataset> dataset_;
QComboBox *mode_ = nullptr;
QCheckBox *spots_ = nullptr;
QScrollArea *scroll_ = nullptr;
QHBoxLayout *strip_ = nullptr;
QMap<qint64, QToolButton *> slots_; // image number -> its thumbnail button
QVector<qint64> ComputeRepresentatives() const;
void Rebuild();
void UpdateThumbnailSize(); // scale thumbnails to the available dock height
};
@@ -17,9 +17,10 @@ namespace {
{"Wheel", "Zoom in / out, centred on the cursor"},
{"Shift + wheel", "Move the foreground (upper contrast limit) in linear steps"},
{"Ctrl + wheel", "Move the foreground in multiplicative steps (&times;1.15 per notch)"},
{"Alt + wheel", "Step through the dataset, one image per notch (up = next image)"},
{"F held + wheel", "Same as Shift + wheel, for as long as F is held"},
{"A", "Switch on the automatic foreground"},
{"A", "Apply auto-contrast once; press again to switch on continuous Auto"},
{"Home / End", "Jump to the first / last image in the dataset"},
{"Page Up / Page Down", "Step one image forward / back"},
{"Hover", "Status bar shows the pixel position, its value and the resolution"},
{"Drag", "Pan the image"},
{"Shift + drag", "Draw a rectangular ROI"},
@@ -55,9 +56,6 @@ namespace {
html += "<tr><td width='38%'><b>" + row.action + "</b></td><td>" + row.effect + "</td></tr>";
html += "</table>";
}
html += "<p><i>Note:</i> some window managers take Alt-modified mouse events for themselves "
"before the application sees them. That is a window-manager setting, not something "
"the viewer can take back.</p>";
html += "</body></html>";
return html;
}