Files
Jungfraujoch/viewer/JFJochViewerWindow.cpp
T
leonarski_fandClaude Opus 5 16bf3408f0 Address code-review findings; make detection limits detector-driven
One changeset, developed together in response to a review of this branch, so the
files carry several of the changes at once. Full test suite passes (733 cases).

Spot finding
- Split ImageSpotFinder into Detect() (flag strong pixels - the expensive
  per-pixel pass) and ExtractSpots() (CCL + min/max-pix + resolution mask), with
  Run() = both. The per-image min-pix escalation now detects ONCE and repeats
  only the cheap extraction, instead of re-running the whole finder four times
  per frame as it did on the default path. It also keeps the winning attempt's
  spot list rather than re-extracting it, so the frame that is integrated is
  exactly the frame that was scored - which a GPU re-extract could not guarantee
  (float atomic ordering).
- spot_finding_time_s no longer swallows indexing time, and indexing_time_s now
  sums every escalation call instead of reporting only the last.

Detection limits follow the detector
- The azimuthal-integration upper q and the spot-finding high-resolution limit
  are now std::optional, in the C++ structs AND in the OpenAPI schema, and
  resolve to the detector's own maximum (DiffractionExperiment::GetDetectorMaxQ_
  recipA). Adaptive detection reads a pixel's ring from the azimuthal bins, so a
  pixel outside that q range could never be strong - the integration range
  silently bounded what detection could see, regardless of the requested
  resolution limit. Regenerated the C++ and TypeScript clients; the viewer and
  the web frontend each gained a "to detector edge" switch.

Detection defaults are now per workflow (measured, not assumed)
- Stills: adaptive detection, min-pix chosen per image, no resolution clipping.
- Rotation: fixed-threshold finder, min-pix 2, 1.5 A limit.
  On a 33-crystal rotation battery, adaptive detection helped four hard crystals
  but deterministically broke three (a lost space group, a halved indexing rate,
  a collapsed merge), and the detector-edge limit cost indexing on a strong
  rotation set (100.0 -> 96.8%). Each is still overridable by its flag, and
  --no-adaptive-spots is new.

Indexer seed escalation
- Stop escalating once a seed's lattice explains >= 90% of the seed spots.
  Previously any frame with >= 80 spots always paid three indexer calls, online
  broker included.

Merge-consistency filter
- --min-image-cc gated on a per-image CC computed BEFORE the stills partiality
  post-refinement and never refreshed; the refiner now recomputes it, so the
  reported CC describes the data that are actually merged.
- Replaced the per-call cc_mask argument with one MergeOnTheFly flag, so the
  merge, the error model and MergeStats can no longer disagree about which
  images are in (the --scale path merged unfiltered while its statistics were
  filtered).

Per-image B-factor refinement (-B) removed
- Measured on four serial-stills datasets: it is a no-op where the per-image fit
  is well conditioned and actively harmful where it is not (CC1/2 -8.1, R_meas
  +23.2 on the weakest large-cell set, whose fits hit their [-50, 200] bounds on
  14-25% of images). It had also been silently DISCARDED since the partiality
  post-refinement landed - reported but not applied. Rather than fix and keep a
  knob with no demonstrated benefit, the flag and the whole image_scale_b_factor
  chain are gone: setting, scaling fit, message field, CBOR, HDF5 write and
  read-back, per-image plot, OpenAPI enum, viewer column and checkbox, docs.
  ScaleOnTheFly no longer needs Ceres at all - the fit is a linear IRLS.
  (The Wilson per-image b_factor is a different quantity and stays.)

Stills partiality width now fits both of its components
- sigma^2 = gamma0^2 + (gamma_e*d*)^2 instead of a purely angular gamma_e*d*
  with gamma0 pinned to 0. Fitted per crystal by least squares of dist_ewald^2
  on d*^2. The angular-only width is fitted over a d*^2-dense population, so it
  was pinned by the high-resolution edge and collapsed at low d*: median
  partiality 0.008 beyond 13 A for reflections that were plainly recorded, 55%
  of them under the merge's partiality floor, and the survivors divided by those
  values - which inflated the merged low-resolution intensity scale 3.6x
  (~ +9 A^2 of apparent B). Measured on 5000 stills: the ramp flattens to 0.89x,
  no observation is dropped any more (701750 -> 716811), shell-mean CC1/2 and
  R-free improve slightly. Note CC1/2, R_meas, completeness and a B-refining
  R-free are all blind to that ramp, which is why it survived earlier validation;
  the cost is high-resolution R_meas (98.5 -> 101.9 shell-averaged).

Removed dead code from add-then-remove churn
- Prediction-time "still partiality" (unreachable: no setter), the phantom
  IndexingSettings::min_indexed_spot_fraction knob (getter, no setter - now the
  constant it always was), StillsPartialityRefine's caller-less Settings
  constructor and its reference to a long-gone env var, ProcessImage's unread
  bool return, an unused include, and a dead viewer overlay hook.

Also
- Viewer: the magnifier compared a QImage with itself, so its scene rect was set
  once ever and it could not pan into a larger dataset; the hover tail timer
  could fire after leaveEvent and resurrect the resolution readout outside the
  image.
- update_version.sh regenerated the frontend lock file BEFORE bumping the
  version (every release shipped an off-by-one lock), and did git rm/git add on
  a path that has not existed since the client moved to src/client - with no
  set -e, both failed silently.
- fpga/pcie_driver/postinstall.sh tested "[ ! occurrences > 0 ]", which is a
  redirect, not a test, so dkms add never ran.
- Unit tests for the adaptive-threshold host functions, which had none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 09:07:00 +02:00

730 lines
37 KiB
C++

// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "JFJochViewerWindow.h"
#include <QThread>
#include <QDockWidget>
#include <QKeyEvent>
#include <QGuiApplication>
#include <QScreen>
#include <QScrollArea>
#include <QSettings>
#include <QCloseEvent>
#include <QRandomGenerator>
#include <QLabel>
#include <QPainter>
#include <QPushButton>
#include "JFJochImageReadingWorker.h"
#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"
#include "windows/JFJochViewerMetadataWindow.h"
#ifdef JFJOCH_VIEWER_DBUS
#include "dbus/JFJochViewerAdaptor.h"
#endif
#include "windows/JFJochProcessingJobsWindow.h"
#include "windows/JFJochViewerSpotListWindow.h"
#include "windows/JFJochViewerReflectionListWindow.h"
#include "windows/JFJochCalibrationWindow.h"
#include "windows/JFJochViewerReciprocalSpaceWindow.h"
#include "toolbar/JFJochViewerToolbarDisplay.h"
#include "toolbar/JFJochViewerToolbarImage.h"
#include "windows/JFJoch2DAzintImageWindow.h"
#include "windows/JFJochMagnifierWindow.h"
#include "image_viewer/JFJochImage.h"
#include "image_viewer/JFJochSimpleImage.h"
#include <QMessageBox>
// 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;
JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString &file) : QMainWindow(parent) {
menuBar = new JFJochViewerMenu(this);
setMenuBar(menuBar);
// PSI logo in the menu-bar corner. There are four interchangeable dot designs; pick one at
// random each launch, for fun.
{
const int n = QRandomGenerator::global()->bounded(1, 5); // 1..4
QPixmap logoPixmap(QStringLiteral(":/psi_%1.png").arg(n, 2, 10, QLatin1Char('0')));
auto *logo = new QLabel(this);
logo->setPixmap(logoPixmap.scaledToHeight(22, Qt::SmoothTransformation));
logo->setContentsMargins(6, 0, 8, 0);
menuBar->setCornerWidget(logo, Qt::TopRightCorner);
}
setFocusPolicy(Qt::StrongFocus);
auto toolBarImage = new JFJochViewerToolbarImage(this);
toolBarImage->setObjectName("toolBarImage"); // objectName required for saveState/restoreState
addToolBar(Qt::TopToolBarArea, toolBarImage);
addToolBarBreak(Qt::TopToolBarArea);
toolBarDisplay = new JFJochViewerToolbarDisplay(this);
toolBarDisplay->setObjectName("toolBarDisplay");
addToolBar(Qt::TopToolBarArea, toolBarDisplay);
statusbar = new JFJochViewerStatusBar(this);
setStatusBar(statusbar);
setDockOptions(dockOptions() & ~QMainWindow::DockOption::AllowTabbedDocks);
setWindowTitle("Jungfraujoch image viewer");
// Start large on a big display but fit within a laptop screen.
const QRect avail = QGuiApplication::primaryScreen()->availableGeometry();
resize(qMin(1200, avail.width() - 100), qMin(1100, avail.height() - 100));
SpotFindingSettings spot_finding_settings = DiffractionExperiment::DefaultDataProcessingSettings();
// The interactive viewer is an offline tool looking at one dataset at a time, so it starts from the
// self-calibrating settings rather than numbers the user would have to tune: the per-ring adaptive
// threshold, min-pix chosen per image (std::nullopt), and detection out to the detector edge
// (high_resolution_limit unset). All three are switchable in the settings dock.
spot_finding_settings.adaptive_threshold = true;
spot_finding_settings.min_pix_per_spot = std::nullopt;
spot_finding_settings.indexing = true;
IndexingSettings indexing_settings;
indexing_settings.IndexingThreads(1);
indexing_settings.Algorithm(IndexingAlgorithmEnum::Auto);
if (get_gpu_count() == 0) {
indexing_settings.Algorithm(IndexingAlgorithmEnum::FFTW);
indexing_settings.FFT_NumVectors(8 * 1024);
}
indexing_settings.GeomRefinementAlgorithm(GeomRefinementAlgorithmEnum::BeamCenter);
DiffractionExperiment experiment;
experiment.ImportIndexingSettings(indexing_settings);
experiment.DetectIceRings(true);
// Central area: the diffraction image. Everything else (inspector, plots, processing) is a
// dock, so the layout can be rearranged, saved, and switched between perspectives.
auto viewer = new JFJochDiffractionImage(this);
viewer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
setCentralWidget(viewer);
auto side_panel = new JFJochViewerSidePanel(this);
auto side_panel_scroll = new QScrollArea(this);
side_panel_scroll->setWidget(side_panel);
side_panel_scroll->setWidgetResizable(true);
side_panel_scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
side_panel_scroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
side_panel_scroll->setMinimumWidth(450);
side_panel_scroll->setMaximumWidth(600);
inspectorDock = new QDockWidget("Inspector", this);
inspectorDock->setObjectName("inspectorDock");
inspectorDock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea);
inspectorDock->setWidget(side_panel_scroll);
addDockWidget(Qt::RightDockWidgetArea, inspectorDock);
menuBar->AddDockEntry(inspectorDock, "Inspector");
reading_worker = new JFJochImageReadingWorker(spot_finding_settings, experiment);
reading_thread = new QThread(this);
reading_worker->moveToThread(reading_thread);
reading_thread->start();
auto tableWindow = new JFJochViewerImageListWindow(this);
auto metadataWindow = new JFJochViewerMetadataWindow(this);
auto spotWindow = new JFJochViewerSpotListWindow(this);
auto reflectionWindow = new JFJochViewerReflectionListWindow(this);
auto calibrationWindow = new JFJochCalibrationWindow(this);
auto reciprocalWindow = new JFJochViewerReciprocalSpaceWindow(this);
auto azintImageWindow = new JFJoch2DAzintImageWindow(this);
auto magnifierWindow = new JFJochMagnifierWindow(this);
processingJobsWindow = new JFJochProcessingJobsWindow(reading_worker, this);
// The two hero actions (re-analyse the current image / the whole dataset) live at the top of the
// settings panel now — see the JFJochViewerSettingsDock wiring below.
menuBar->AddWindowEntry(tableWindow, "Image list");
menuBar->AddWindowEntry(spotWindow, "Spot list");
menuBar->AddWindowEntry(reflectionWindow, "Reflection list");
menuBar->AddWindowEntry(metadataWindow, "Image metadata");
menuBar->AddWindowEntry(calibrationWindow, "Calibration image viewer");
menuBar->AddWindowEntry(reciprocalWindow, "Reciprocal space viewer");
menuBar->AddWindowEntry(azintImageWindow, "Azimuthal integration 2D image");
menuBar->AddWindowEntry(magnifierWindow, "Magnifier");
// processingJobsWindow is docked (bottom, next to the plots), not a standalone window - see below.
#ifdef JFJOCH_VIEWER_DBUS
if (dbus) {
// Create adaptor attached to this window
new JFJochViewerAdaptor(this);
QDBusConnection connection = QDBusConnection::sessionBus();
if (!connection.registerService("ch.psi.jfjoch_viewer")) {
qWarning("Failed to register D-Bus service: %s", qPrintable(connection.lastError().message()));
} else {
if (!connection.registerObject("/", this, QDBusConnection::ExportAdaptors)) {
qFatal("Failed to register D-Bus object: %s", qPrintable(connection.lastError().message()));
}
}
}
#else
(void) dbus;
#endif
connect(this, &JFJochViewerWindow::LoadFileRequest,
reading_worker, &JFJochImageReadingWorker::LoadFile);
connect(this, &JFJochViewerWindow::LoadImageRequest,
reading_worker, &JFJochImageReadingWorker::LoadImage);
connect(menuBar, &JFJochViewerMenu::fileOpenSelected,
reading_worker, &JFJochImageReadingWorker::LoadFile);
connect(menuBar, &JFJochViewerMenu::fileCloseSelected,
reading_worker, &JFJochImageReadingWorker::CloseFile);
// The worker runs in its own thread; its imageLoaded crosses the thread boundary exactly once,
// into OnImageReady, which re-emits imageReady to every GUI consumer synchronously (same thread)
// and then acks the worker (imageConsumed). Routing all consumers through one window signal makes
// the ack land after they have all run, and lets the worker cap how many frames are in flight.
connect(reading_worker, &JFJochImageReadingWorker::imageLoaded,
this, &JFJochViewerWindow::OnImageReady);
connect(this, &JFJochViewerWindow::imageConsumed,
reading_worker, &JFJochImageReadingWorker::ImageConsumed);
// Dataset stream routed the same way, so it gets the same backpressure: the worker's datasetLoaded
// crosses the thread boundary once into OnDatasetReady, which re-emits datasetReady to every GUI
// consumer synchronously and then acks the worker (datasetConsumed).
connect(reading_worker, &JFJochImageReadingWorker::datasetLoaded,
this, &JFJochViewerWindow::OnDatasetReady);
connect(this, &JFJochViewerWindow::datasetConsumed,
reading_worker, &JFJochImageReadingWorker::DatasetConsumed);
connect(this, &JFJochViewerWindow::imageReady,
viewer, &JFJochDiffractionImage::loadImage);
connect(this, &JFJochViewerWindow::imageReady,
side_panel, &JFJochViewerSidePanel::loadImage);
connect(reading_worker, &JFJochImageReadingWorker::imageStatsUpdated,
side_panel, &JFJochViewerSidePanel::loadImage);
connect(reading_worker, &JFJochImageReadingWorker::imageNumberChanged, toolBarImage,
&JFJochViewerToolbarImage::setImageNumber);
connect(toolBarImage, &JFJochViewerToolbarImage::loadImage, reading_worker, &JFJochImageReadingWorker::LoadImage);
connect(toolBarDisplay, &JFJochViewerToolbarDisplay::setForeground, viewer,
&JFJochDiffractionImage::changeForeground);
connect(viewer, &JFJochDiffractionImage::autoForegroundChanged,
toolBarDisplay, &JFJochViewerToolbarDisplay::updateAutoForeground);
connect(toolBarDisplay, &JFJochViewerToolbarDisplay::setAutoForeground, viewer,
&JFJochDiffractionImage::setAutoForeground);
connect(toolBarDisplay, &JFJochViewerToolbarDisplay::setHDRMode, viewer,
&JFJochDiffractionImage::setHDRMode);
connect(toolBarDisplay, &JFJochViewerToolbarDisplay::colorMapChanged, viewer,
&JFJochDiffractionImage::setColorMap);
connect(toolBarDisplay, &JFJochViewerToolbarDisplay::colorMapChanged, azintImageWindow,
&JFJoch2DAzintImageWindow::setColorMap);
connect(this, &JFJochViewerWindow::imageReady,
azintImageWindow, &JFJoch2DAzintImageWindow::imageLoaded);
connect(viewer, &JFJochDiffractionImage::foregroundChanged,
toolBarDisplay, &JFJochViewerToolbarDisplay::updateForeground);
connect(side_panel, &JFJochViewerSidePanel::roisChanged,
reading_worker, &JFJochImageReadingWorker::SetROIDefinition);
connect(side_panel, &JFJochViewerSidePanel::selectedROIChanged,
viewer, &JFJochDiffractionImage::setSelectedROI);
connect(viewer, &JFJochDiffractionImage::roiGeometryEdited,
reading_worker, &JFJochImageReadingWorker::SetROIDefinition);
connect(viewer, &JFJochDiffractionImage::roiSelected,
side_panel, &JFJochViewerSidePanel::selectROIInList);
connect(side_panel, &JFJochViewerSidePanel::downloadROIs,
reading_worker, &JFJochImageReadingWorker::DownloadROIsFromServer);
connect(side_panel, &JFJochViewerSidePanel::uploadROIs,
reading_worker, &JFJochImageReadingWorker::UploadROIsToServer);
connect(side_panel, &JFJochViewerSidePanel::maskFromROI,
reading_worker, &JFJochImageReadingWorker::MaskFromSelectedROI);
connect(menuBar, &JFJochViewerMenu::clearUserMaskSelected,
reading_worker, &JFJochImageReadingWorker::ClearUserMask);
connect(menuBar, &JFJochViewerMenu::saveUserMaskTiffSelected,
reading_worker, &JFJochImageReadingWorker::SaveUserMaskTIFF);
connect(menuBar, &JFJochViewerMenu::loadUserMaskTiffSelected,
reading_worker, &JFJochImageReadingWorker::LoadUserMaskTIFF);
connect(menuBar, &JFJochViewerMenu::uploadUserMaskSelected,
reading_worker, &JFJochImageReadingWorker::UploadUserMask);
connect(this, &JFJochViewerWindow::datasetReady,
tableWindow, &JFJochViewerImageListWindow::datasetLoaded);
connect(this, &JFJochViewerWindow::datasetReady,
spotWindow, &JFJochViewerSpotListWindow::datasetLoaded);
connect(this, &JFJochViewerWindow::datasetReady,
metadataWindow, &JFJochViewerMetadataWindow::datasetLoaded);
connect(this, &JFJochViewerWindow::imageReady,
tableWindow, &JFJochViewerImageListWindow::imageLoaded);
connect(this, &JFJochViewerWindow::imageReady,
spotWindow, &JFJochViewerSpotListWindow::imageLoaded);
connect(tableWindow, &JFJochViewerImageListWindow::imageSelected,
reading_worker, &JFJochImageReadingWorker::LoadImage);
connect(reading_worker, &JFJochImageReadingWorker::autoloadChanged,
toolBarImage, &JFJochViewerToolbarImage::setAutoloadMode);
connect(toolBarImage, &JFJochViewerToolbarImage::autoLoadButtonPressed,
reading_worker, &JFJochImageReadingWorker::setAutoLoadMode);
connect(toolBarImage, &JFJochViewerToolbarImage::imageJumpChanged,
reading_worker, &JFJochImageReadingWorker::setAutoLoadJump);
// HTTP-sync button: reflect the live connection, and open the connect dialog when clicked
// while no live source is attached (i.e. a plain file is open).
connect(reading_worker, &JFJochImageReadingWorker::httpConnectionChanged,
toolBarImage, &JFJochViewerToolbarImage::setHttpConnection);
connect(toolBarImage, &JFJochViewerToolbarImage::requestHttpConnect,
menuBar, &JFJochViewerMenu::openHttpSelected);
connect(toolBarImage, &JFJochViewerToolbarImage::requestOpenFile,
menuBar, &JFJochViewerMenu::openSelected);
connect(side_panel, &JFJochViewerSidePanel::analyze,
reading_worker, &JFJochImageReadingWorker::Analyze);
connect(side_panel, &JFJochViewerSidePanel::findBeamCenter,
reading_worker, &JFJochImageReadingWorker::FindCenter);
connect(this, &JFJochViewerWindow::datasetReady,
reflectionWindow, &JFJochViewerReflectionListWindow::datasetLoaded);
connect(this, &JFJochViewerWindow::imageReady,
reflectionWindow, &JFJochViewerReflectionListWindow::imageLoaded);
connect(reflectionWindow, &JFJochHelperWindow::zoom, viewer, &JFJochDiffractionImage::centerOnSpot);
connect(spotWindow, &JFJochHelperWindow::zoom, viewer, &JFJochDiffractionImage::centerOnSpot);
connect(side_panel, &JFJochViewerSidePanel::showSpots,
viewer, &JFJochDiffractionImage::showSpots);
connect(side_panel, &JFJochViewerSidePanel::showPredictions,
viewer, &JFJochDiffractionImage::showPredictions);
connect(side_panel, &JFJochViewerSidePanel::showROILabels,
viewer, &JFJochDiffractionImage::showROILabels);
connect(side_panel, &JFJochViewerSidePanel::showROIFill,
viewer, &JFJochDiffractionImage::showROIFill);
connect(side_panel, &JFJochViewerSidePanel::setFeatureColor,
viewer, &JFJochDiffractionImage::setFeatureColor);
connect(side_panel, &JFJochViewerSidePanel::setFeatureColor,
calibrationWindow, &JFJochCalibrationWindow::setFeatureColor);
connect(side_panel, &JFJochViewerSidePanel::setSpotColor,
viewer, &JFJochDiffractionImage::setSpotColor);
connect(side_panel, &JFJochViewerSidePanel::showHighestPixels,
viewer, &JFJochDiffractionImage::showHighestPixels);
connect(side_panel, &JFJochViewerSidePanel::showSaturatedPixels,
viewer, &JFJochDiffractionImage::showSaturation);
connect(viewer, &JFJochDiffractionImage::writeStatusBar,
statusbar, &JFJochViewerStatusBar::display);
connect(side_panel, &JFJochViewerSidePanel::writeStatusBar,
statusbar, &JFJochViewerStatusBar::display);
// Detector connection / broker state / live readouts in the status bar
connect(reading_worker, &JFJochImageReadingWorker::brokerStatusUpdated,
statusbar, &JFJochViewerStatusBar::setBrokerStatus);
connect(reading_worker, &JFJochImageReadingWorker::httpConnectionChanged,
statusbar, &JFJochViewerStatusBar::setHttpConnection);
connect(reading_worker, &JFJochImageReadingWorker::autoloadChanged,
statusbar, &JFJochViewerStatusBar::setAutoloadMode);
connect(reading_worker, &JFJochImageReadingWorker::imageNumberChanged,
statusbar, &JFJochViewerStatusBar::setImageNumber);
connect(reading_worker, &JFJochImageReadingWorker::liveRateChanged,
statusbar, &JFJochViewerStatusBar::setLiveRate);
connect(metadataWindow, &JFJochViewerMetadataWindow::datasetUpdated,
reading_worker, &JFJochImageReadingWorker::UpdateDataset);
connect(reading_worker, &JFJochImageReadingWorker::setRings,
side_panel, &JFJochViewerSidePanel::SetRings);
connect(side_panel, &JFJochViewerSidePanel::resRingsSet,
viewer, &JFJochDiffractionImage::setResolutionRing);
connect(side_panel, &JFJochViewerSidePanel::ringModeSet,
viewer, &JFJochDiffractionImage::setResolutionRingMode);
connect(side_panel, &JFJochViewerSidePanel::highlightIceRings,
viewer, &JFJochDiffractionImage::highlightIceRings);
connect(calibrationWindow, &JFJochCalibrationWindow::loadCalibration,
reading_worker, &JFJochImageReadingWorker::LoadCalibration);
connect(this, &JFJochViewerWindow::datasetReady,
calibrationWindow, &JFJochCalibrationWindow::datasetLoaded);
connect(reading_worker, &JFJochImageReadingWorker::simpleImageLoaded,
calibrationWindow, &JFJochCalibrationWindow::calibrationLoaded);
connect(this, &JFJochViewerWindow::datasetReady,
reciprocalWindow, &JFJochViewerReciprocalSpaceWindow::datasetLoaded);
connect(this, &JFJochViewerWindow::imageReady,
reciprocalWindow, &JFJochViewerReciprocalSpaceWindow::imageLoaded);
connect(reciprocalWindow, &JFJochViewerReciprocalSpaceWindow::loadSpotsRequest,
reading_worker, &JFJochImageReadingWorker::LoadSpots);
connect(reading_worker, &JFJochImageReadingWorker::spotsLoaded,
reciprocalWindow, &JFJochViewerReciprocalSpaceWindow::spotsLoaded);
connect(reciprocalWindow, &JFJochHelperWindow::zoom,
viewer, &JFJochDiffractionImage::centerOnSpot);
connect(side_panel, &JFJochViewerSidePanel::setSpotColor,
reciprocalWindow, &JFJochViewerReciprocalSpaceWindow::setSpotColor);
connect(side_panel, &JFJochViewerSidePanel::setFeatureColor,
reciprocalWindow, &JFJochViewerReciprocalSpaceWindow::setFeatureColor);
connect(azintImageWindow, &JFJoch2DAzintImageWindow::zoomOnBin,
viewer, &JFJochDiffractionImage::centerOnSpot);
// --- Magnifier ---
// The magnifier shows the frame the main view has already rendered - the same pixels with
// its own zoom and centre - so it neither converts nor colours anything itself.
connect(viewer, &JFJochImage::frameRendered, magnifierWindow, [viewer, magnifierWindow] {
magnifierWindow->setFrame(viewer->Frame());
});
// ... and the raw counts behind it, for the per-pixel labels. Stores a pointer, nothing more.
connect(this, &JFJochViewerWindow::imageReady,
magnifierWindow, &JFJochHelperWindow::imageLoaded);
connect(viewer, &JFJochImage::hoverScenePos,
magnifierWindow, &JFJochMagnifierWindow::centerAt);
// Ensure worker is deleted in its own thread when the thread stops
connect(reading_thread, &QThread::finished, reading_worker, &QObject::deleteLater);
// lastDataset and lastImage are captured in OnDatasetReady / OnImageReady, which see every
// dataset / frame before fanning them out.
connect(this, &JFJochViewerWindow::imageReady,
toolBarDisplay, &JFJochViewerToolbarDisplay::imageLoaded);
connect(reading_worker, &JFJochImageReadingWorker::fileLoadError,
this, &JFJochViewerWindow::OnFileLoadError);
connect(reading_worker, &JFJochImageReadingWorker::fileLoadRetryStatus,
this, &JFJochViewerWindow::OnFileLoadRetryStatus);
connect(menuBar, &JFJochViewerMenu::openDatasetInfo, this, &JFJochViewerWindow::NewDatasetInfo);
NewDatasetInfo();
connect(this, &JFJochViewerWindow::adjustForegroundButton,
viewer, &JFJochDiffractionImage::adjustForeground);
connect(this, &JFJochViewerWindow::setAutoForeground,
viewer, &JFJochDiffractionImage::setAutoForeground);
// Processing jobs: run rugnux locally / copy a cluster command line, and surface
// finished runs as switchable dataset snapshots.
connect(processingJobsWindow, &JFJochProcessingJobsWindow::registerSnapshot,
reading_worker, &JFJochImageReadingWorker::RegisterProcessingSnapshot);
connect(processingJobsWindow, &JFJochProcessingJobsWindow::activateSnapshot,
reading_worker, &JFJochImageReadingWorker::SetActiveSnapshot);
connect(processingJobsWindow, &JFJochProcessingJobsWindow::writeStatusBar,
statusbar, &JFJochViewerStatusBar::display);
connect(processingJobsWindow, &JFJochProcessingJobsWindow::renameRun,
reading_worker, &JFJochImageReadingWorker::RenameRun);
connect(processingJobsWindow, &JFJochProcessingJobsWindow::removeRun,
reading_worker, &JFJochImageReadingWorker::RemoveRun);
connect(reading_worker, &JFJochImageReadingWorker::httpConnectionChanged,
processingJobsWindow, &JFJochProcessingJobsWindow::onHttpConnectionChanged);
connect(reading_worker, &JFJochImageReadingWorker::fileOpened,
processingJobsWindow, &JFJochProcessingJobsWindow::clearJobs);
connect(reading_worker, &JFJochImageReadingWorker::snapshotsChanged, processingJobsWindow,
[pw = processingJobsWindow](QStringList, QString active) { pw->setActiveRun(active); });
connect(reading_worker, &JFJochImageReadingWorker::runsChanged, this,
[this](QVector<RunData> runs, QString active) {
lastRuns = std::move(runs);
lastActiveRunId = std::move(active);
});
// Inline settings dock: the surgical MX/AzInt subset, always visible (left), wired straight
// into the running analysis so edits re-run on the current image / dataset.
auto *settingsPanel = new JFJochViewerSettingsDock(spot_finding_settings, indexing_settings,
experiment.GetAzimuthalIntegrationSettings(),
experiment.GetBraggIntegrationSettings(),
experiment.GetScalingSettings(), this);
// The panel's natural minimum (a long reference-consistency warning, the geometry rows, …) would
// otherwise pin the dock wide and stop it folding back; setMinimumWidth(0) lets the scroll area's
// own minimum (330) govern the fold. The horizontal scrollbar is AlwaysOff (like the inspector
// dock): with widgetResizable, both-axes ScrollBarAsNeeded oscillates (a vertical bar narrows the
// viewport -> a horizontal bar appears -> shortens it -> vertical fits -> ...), which pegs the CPU
// and freezes the panel when floated.
settingsPanel->setMinimumWidth(0);
auto *settingsScroll = new QScrollArea(this);
settingsScroll->setWidget(settingsPanel);
settingsScroll->setWidgetResizable(true);
settingsScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
settingsScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
settingsScroll->setMinimumWidth(330);
settingsScroll->setMaximumWidth(480);
settingsDock = new QDockWidget("Settings", this);
settingsDock->setObjectName("settingsDock");
settingsDock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea);
settingsDock->setWidget(settingsScroll); // scrollable: don't force the window taller than the screen
addDockWidget(Qt::LeftDockWidgetArea, settingsDock);
menuBar->AddDockEntry(settingsDock, "Settings");
connect(settingsPanel, &JFJochViewerSettingsDock::spotFindingChanged,
reading_worker, &JFJochImageReadingWorker::UpdateSpotFindingSettings);
connect(settingsPanel, &JFJochViewerSettingsDock::azintChanged,
reading_worker, &JFJochImageReadingWorker::UpdateAzintSettings);
connect(settingsPanel, &JFJochViewerSettingsDock::braggChanged,
reading_worker, &JFJochImageReadingWorker::UpdateBraggIntegrationSettings);
connect(settingsPanel, &JFJochViewerSettingsDock::scalingChanged,
reading_worker, &JFJochImageReadingWorker::UpdateScalingSettings);
// The two analysis actions now live on top of the panel.
connect(settingsPanel, &JFJochViewerSettingsDock::reanalyzeImage,
reading_worker, &JFJochImageReadingWorker::ReanalyzeImages);
connect(settingsPanel, &JFJochViewerSettingsDock::analyzeDataset,
processingJobsWindow, &JFJochProcessingJobsWindow::newJob);
// Grey out "Analyze dataset" on a live HTTP source (re-processing needs a stored file).
connect(reading_worker, &JFJochImageReadingWorker::httpConnectionChanged,
settingsPanel, &JFJochViewerSettingsDock::setHttpConnection);
connect(settingsPanel, &JFJochViewerSettingsDock::experimentChanged,
reading_worker, &JFJochImageReadingWorker::UpdateDataset);
connect(settingsPanel, &JFJochViewerSettingsDock::findBeamCenter,
reading_worker, &JFJochImageReadingWorker::FindCenter);
connect(this, &JFJochViewerWindow::datasetReady,
settingsPanel, &JFJochViewerSettingsDock::datasetLoaded);
connect(this, &JFJochViewerWindow::imageReady,
settingsPanel, &JFJochViewerSettingsDock::loadImage);
connect(settingsPanel, &JFJochViewerSettingsDock::ringsFromCalibration,
side_panel, &JFJochViewerSidePanel::SetRings);
connect(settingsPanel, &JFJochViewerSettingsDock::referenceSelected,
reading_worker, &JFJochImageReadingWorker::SetReferenceMtz);
connect(reading_worker, &JFJochImageReadingWorker::referenceMtzChanged,
settingsPanel, &JFJochViewerSettingsDock::referenceLoaded);
// Processing panel: hidden by default and narrower; it reveals itself (to the right of the
// plots) only when a reprocessing job starts.
processingDock = new QDockWidget("Processing", this);
processingDock->setObjectName("processingDock");
// Allow any area so dragging it out (floating) and re-docking behave; the table's natural width
// (its columns) must not pin the dock wide, so let it fold like the settings dock.
processingDock->setAllowedAreas(Qt::AllDockWidgetAreas);
processingDock->setFeatures(
QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable);
processingJobsWindow->setMinimumWidth(0);
processingDock->setWidget(processingJobsWindow);
addDockWidget(Qt::BottomDockWidgetArea, processingDock);
if (lastDatasetInfoDock) {
splitDockWidget(lastDatasetInfoDock, processingDock, Qt::Horizontal);
resizeDocks({lastDatasetInfoDock, processingDock}, {900, 280}, Qt::Horizontal);
}
processingDock->hide();
menuBar->AddDockEntry(processingDock, "Processing");
connect(processingJobsWindow, &JFJochProcessingJobsWindow::jobStarted, this, [this] {
processingDock->show();
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,
[this] { ApplyPerspective(Perspective::Processing); });
connect(menuBar, &JFJochViewerMenu::resetLayoutSelected, this,
[this] { if (!defaultLayoutState.isEmpty()) restoreState(defaultLayoutState, kLayoutVersion); });
// Tear-off floating is disabled for all docks: a floated QDockWidget is unreliable across platforms
// (on WSLg it becomes an off-screen, override-redirect window that can't be focused or recovered, so
// the undocked panel goes dead). Docks stay movable between dock areas and closable, just not floated.
for (QDockWidget *dock : findChildren<QDockWidget *>())
dock->setFeatures(dock->features() & ~QDockWidget::DockWidgetFloatable);
// Remember the freshly-built layout so "Reset layout" can return to it, then restore the
// user's last-used arrangement if they have one (and if it matches the current layout version).
defaultLayoutState = saveState(kLayoutVersion);
QSettings settings("PSI", "jfjoch_viewer");
restoreGeometry(settings.value("geometry").toByteArray());
restoreState(settings.value("windowState").toByteArray(), kLayoutVersion);
if (!file.isEmpty())
LoadFile(file, 0, 1, false);
}
JFJochViewerWindow::~JFJochViewerWindow() {
if (reading_thread && reading_thread->isRunning()) {
reading_thread->quit();
reading_thread->wait();
}
}
void JFJochViewerWindow::ApplyPerspective(Perspective p) {
// Image: just the image + inspector. Processing: also settings, dataset-info plots, jobs panel.
const bool processing = (p == Perspective::Processing);
if (inspectorDock) inspectorDock->setVisible(true);
if (settingsDock) settingsDock->setVisible(processing);
if (imageStripDock) imageStripDock->setVisible(processing);
if (processingDock && !processing) processingDock->hide(); // shows only when a job starts
for (auto *d : findChildren<QDockWidget *>())
if (d->objectName().startsWith("datasetInfoDock"))
d->setVisible(processing);
}
void JFJochViewerWindow::closeEvent(QCloseEvent *event) {
// Persist the dock/toolbar arrangement and window geometry so the next launch resumes it.
QSettings settings("PSI", "jfjoch_viewer");
settings.setValue("geometry", saveGeometry());
settings.setValue("windowState", saveState(kLayoutVersion));
QMainWindow::closeEvent(event);
}
void JFJochViewerWindow::LoadFile(const QString &filename, qint64 image_number, qint64 summation, bool retry) {
emit LoadFileRequest(filename, image_number, summation, true);
}
void JFJochViewerWindow::LoadImage(qint64 image_number, qint64 summation) {
emit LoadImageRequest(image_number, summation);
}
void JFJochViewerWindow::OnImageReady(std::shared_ptr<const JFJochReaderImage> image) {
lastImage = image; // newest frame, for dataset-info docks opened later
emit imageReady(image); // fan out to every GUI consumer synchronously (same thread)
emit imageConsumed(); // ack the worker so it may fetch/produce the next frame
}
void JFJochViewerWindow::OnDatasetReady(std::shared_ptr<const JFJochReaderDataset> dataset) {
lastDataset = dataset; // newest dataset, for dataset-info docks opened later
emit datasetReady(dataset); // fan out to every GUI consumer synchronously (same thread)
emit datasetConsumed(); // ack the worker so it may produce the next dataset refresh
}
void JFJochViewerWindow::NewDatasetInfo() {
auto info = new JFJochViewerDatasetInfo(this);
info->datasetLoaded(lastDataset);
info->imageLoaded(lastImage);
info->runsChanged(lastRuns, lastActiveRunId);
auto dock = new QDockWidget(QString("Dataset info"), this);
dock->setObjectName(QStringLiteral("datasetInfoDock%1").arg(datasetInfoCounter++));
dock->setAllowedAreas(Qt::BottomDockWidgetArea);
dock->setFeatures(
QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable |
QDockWidget::DockWidgetVerticalTitleBar);
dock->setAttribute(Qt::WA_DeleteOnClose);
dock->setWidget(info);
addDockWidget(Qt::BottomDockWidgetArea, dock);
// Place additional plots beside the previous one so "+ Plot" gives a visible row of plots.
if (lastDatasetInfoDock)
splitDockWidget(lastDatasetInfoDock, dock, Qt::Horizontal);
lastDatasetInfoDock = dock;
// Wire signals like the initial dataset_info
connect(this, &JFJochViewerWindow::datasetReady,
info, &JFJochViewerDatasetInfo::datasetLoaded);
connect(this, &JFJochViewerWindow::imageReady,
info, &JFJochViewerDatasetInfo::imageLoaded);
// All runs (original + reprocessing snapshots) overlay as separate lines.
connect(reading_worker, &JFJochImageReadingWorker::runsChanged,
info, &JFJochViewerDatasetInfo::runsChanged);
// Live processing results: the in-progress run as its own overlay line.
connect(processingJobsWindow, &JFJochProcessingJobsWindow::liveDataset,
info, &JFJochViewerDatasetInfo::liveRunUpdated);
connect(info, &JFJochViewerDatasetInfo::imageSelected,
reading_worker, &JFJochImageReadingWorker::LoadImage);
connect(info, &JFJochViewerDatasetInfo::addPlot, this, &JFJochViewerWindow::NewDatasetInfo);
connect(toolBarDisplay, &JFJochViewerToolbarDisplay::colorMapChanged,
info, &JFJochViewerDatasetInfo::setColorMap);
connect(info, &JFJochViewerDatasetInfo::writeStatusBar,
statusbar, &JFJochViewerStatusBar::display);
}
void JFJochViewerWindow::keyPressEvent(QKeyEvent *event) {
if (event->key() == Qt::Key_F) {
emit adjustForegroundButton(true);
event->accept();
return;
}
if (event->key() == Qt::Key_A && ! event->isAutoRepeat()) {
emit setAutoForeground(true);
event->accept();
return;
}
QMainWindow::keyPressEvent(event);
}
void JFJochViewerWindow::keyReleaseEvent(QKeyEvent *event) {
if (event->key() == Qt::Key_F) {
emit adjustForegroundButton(false);
event->accept();
return;
}
QMainWindow::keyReleaseEvent(event);
}
void JFJochViewerWindow::OnFileLoadError(QString title, QString message) {
QMessageBox::critical(this, title, message);
}
void JFJochViewerWindow::OnFileLoadRetryStatus(bool active, QString message) {
if (active) {
if (!retryDialog) {
retryDialog = new QProgressDialog(this);
retryDialog->setWindowModality(Qt::WindowModal);
retryDialog->setRange(0, 0); // Infinite/Busy indicator
retryDialog->setCancelButton(nullptr); // Disable cancel for now
retryDialog->setMinimumDuration(0); // Show immediately
retryDialog->setWindowTitle("Loading File");
}
retryDialog->setLabelText(message);
retryDialog->show();
} else {
if (retryDialog) {
retryDialog->close();
retryDialog->deleteLater();
retryDialog = nullptr;
}
}
}