Files
Jungfraujoch/viewer/JFJochImageReadingWorker.cpp
leonarski_f 4462f0998d Viewer: single "Process as stills" mode + compact, folded panel
Replace the scaling partiality combo + "3D rotation scaling" checkbox with
one "Process as stills" checkbox in the indexing section (enabled only for
rotation datasets, unchecked by default). Unchecked on a rotation dataset
drives the full rotation path (rotation indexing at 60 first-pass images,
Rotation partiality, rot3d combine, scale-fulls); checked treats it as
stills (fixed partiality, per-frame indexing). The Analyze-dataset dialog
drops its rotation options (the panel is the single source) and buildConfig
reads rotation indexing from the experiment.

Fix: the worker's UpdateSpotFindingSettings copied indexing fields one by
one and was dropping RotationIndexing, so the mode never reached jobs.

Also: fold the panel accordions on start except Geometry and Unit cell, and
make the scaling resolution-limit a compact checkbox + field aligned with
the other checkboxes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:43:04 +02:00

1243 lines
47 KiB
C++

// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <cerrno>
#include <cstring>
#include <fstream>
#include <iterator>
#include "../common/JFJochMath.h"
#ifndef _WIN32
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#endif
#include "JFJochImageReadingWorker.h"
#include "../reader/JFJochReaderImage.h" // JFJochReaderImage + GAP/ERROR/SATURATED sentinels
#include "../image_analysis/geom_refinement/AssignSpotsToRings.h"
#include "../image_analysis/spot_finding/StrongPixelSet.h"
#include "../image_analysis/spot_finding/SpotUtils.h"
#include "../image_analysis/spot_finding/ImageSpotFinder.h"
#include <QVector>
#include <QMutexLocker>
#include <QFileInfo>
#include <QPainter>
#include <QFontMetrics>
#include <algorithm>
#include "../preview/JFJochTIFF.h"
namespace {
enum class PreflightResult {
Ok,
NotYetVisible, // ENOENT, ESTALE, etc.
PermissionDenied, // EACCES, EPERM
IsDirectory,
OtherError
};
PreflightResult preflight_open_ro(const QString& filename, std::string& reason_out) {
reason_out.clear();
#ifndef _WIN32
const QByteArray path = filename.toLocal8Bit();
errno = 0;
const int fd = ::open(path.constData(), O_RDONLY | O_CLOEXEC);
if (fd >= 0) {
struct stat st;
if (fstat(fd, &st) == 0 && S_ISDIR(st.st_mode)) {
::close(fd);
reason_out = "Path is a directory";
return PreflightResult::IsDirectory;
}
::close(fd);
return PreflightResult::Ok;
}
const int e = errno;
reason_out = fmt::format("{} (errno={} {})", std::strerror(e), e, std::strerror(e));
// NFS can transiently report missing/stale entries.
if (e == ENOENT || e == ESTALE || e == EIO || e == ETIMEDOUT || e == ENOTCONN)
return PreflightResult::NotYetVisible;
if (e == EACCES || e == EPERM)
return PreflightResult::PermissionDenied;
if (e == EISDIR)
return PreflightResult::IsDirectory;
return PreflightResult::OtherError;
#else
// No NFS-style transient-errno semantics off POSIX; use a portable check.
const QFileInfo info(filename);
if (!info.exists()) {
reason_out = "File not visible yet";
return PreflightResult::NotYetVisible;
}
if (info.isDir()) {
reason_out = "Path is a directory";
return PreflightResult::IsDirectory;
}
if (!info.isReadable()) {
reason_out = "Permission denied";
return PreflightResult::PermissionDenied;
}
return PreflightResult::Ok;
#endif
}
}
JFJochImageReadingWorker::JFJochImageReadingWorker(const SpotFindingSettings &settings,
const DiffractionExperiment &experiment, QObject *parent)
: QObject(parent),
indexing_settings(experiment.GetIndexingSettings()),
azint_settings(experiment.GetAzimuthalIntegrationSettings()),
bragg_settings(experiment.GetBraggIntegrationSettings()),
scaling_settings(experiment.GetScalingSettings()) {
qRegisterMetaType<QVector<QRect>>("QVector<QRect>");
qRegisterMetaType<BrokerStatus>("BrokerStatus");
qRegisterMetaType<ROIDefinition>("ROIDefinition");
qRegisterMetaType<BraggIntegrationSettings>("BraggIntegrationSettings");
qRegisterMetaType<ScalingSettings>("ScalingSettings");
qRegisterMetaType<RunData>("RunData");
qRegisterMetaType<QVector<RunData>>("QVector<RunData>");
qRegisterMetaType<QVector<qint64>>("QVector<qint64>");
qRegisterMetaType<ReferenceMtzInfo>("ReferenceMtzInfo");
spot_finding_settings = settings;
indexing = std::make_unique<IndexerThreadPool>(indexing_settings);
http_reader.Experiment(experiment);
file_reader.Experiment(experiment);
thumb_color_scale_.Select(ColorScaleEnum::Indigo);
autoload_timer = new QTimer(this);
autoload_timer->setInterval(autoload_interval);
connect(autoload_timer, &QTimer::timeout, this, &JFJochImageReadingWorker::AutoLoadTimerExpired);
status_timer = new QTimer(this);
status_timer->setInterval(status_interval);
connect(status_timer, &QTimer::timeout, this, &JFJochImageReadingWorker::StatusTimerExpired);
file_open_retry_timer = new QTimer(this);
file_open_retry_timer->setSingleShot(true);
connect(file_open_retry_timer, &QTimer::timeout, this, &JFJochImageReadingWorker::FileOpenRetryTimerExpired);
}
void JFJochImageReadingWorker::ResetFileOpenRetry_i() {
// Assumes m locked!
if (file_open_retry_timer)
file_open_retry_timer->stop();
// Signal UI to close the dialog if we were active
if (file_open_retry_active)
emit fileLoadRetryStatus(false, "");
file_open_retry_active = false;
file_open_retry_warned = false;
file_open_retry_attempts = 0;
file_open_retry_delay_ms = 50;
file_open_retry_elapsed.invalidate();
pending_load = {};
}
void JFJochImageReadingWorker::ScheduleFileOpenRetry_i(const QString& reason) {
// Assumes m locked!
if (!file_open_retry_active) {
file_open_retry_active = true;
file_open_retry_warned = false;
file_open_retry_attempts = 0;
file_open_retry_delay_ms = 50;
file_open_retry_elapsed.restart();
}
if (!file_open_retry_warned) {
file_open_retry_warned = true;
logger.Warning(fmt::format(
"File '{}' not available yet (GPFS/NFS). Retrying with back-off up to 10 s. Reason: {}",
pending_load.filename.toStdString(), reason.toStdString()));
// Signal UI to show the dialog
emit fileLoadRetryStatus(true, fmt::format("Waiting for file {} to appear on disk...", pending_load.filename.toStdString()).c_str());
} else {
logger.Debug(fmt::format(
"Retry pending for file '{}'. Reason: {}",
pending_load.filename.toStdString(), reason.toStdString()));
}
if (file_open_retry_elapsed.isValid() && file_open_retry_elapsed.elapsed() >= 10'000) {
std::string msg = fmt::format(
"Timed out waiting for file '{}' after 10 s ({} attempt(s))",
pending_load.filename.toStdString(), file_open_retry_attempts);
logger.Error(msg);
// Reset first (closes the progress dialog)
ResetFileOpenRetry_i();
// Then show the error dialog
emit fileLoadError("File Open Timeout", QString::fromStdString(msg));
return;
}
const int delay = file_open_retry_delay_ms;
file_open_retry_delay_ms = std::min(file_open_retry_delay_ms * 2, file_open_retry_delay_max_ms);
if (file_open_retry_timer)
file_open_retry_timer->start(delay);
}
void JFJochImageReadingWorker::FileOpenRetryTimerExpired() {
PendingLoadRequest req;
QMutexLocker ul(&m);
if (!file_open_retry_active)
return;
req = pending_load;
// Re-trigger LoadFile, but keep retry=true so we stay in the retry loop.
LoadFile_i(req.filename, req.image_number, req.summation, true);
}
void JFJochImageReadingWorker::LoadFile_i(const QString &filename, qint64 image_number, qint64 summation, bool retry) {
try {
std::shared_ptr<const JFJochReaderDataset> dataset;
auto start = std::chrono::high_resolution_clock::now();
if (filename.startsWith("http://")) {
http_mode = true;
http_reader.ReadURL(filename.toStdString());
total_images = http_reader.GetNumberOfImages();
dataset = http_reader.GetDataset();
SetHttpConnected_i(true, filename);
status_timer->start();
if (image_number < 0)
setAutoLoadMode_i(AutoloadMode::HTTPSync);
else
setAutoLoadMode_i(AutoloadMode::None);
} else {
http_mode = false;
status_timer->stop();
SetHttpConnected_i(false, "");
if (retry) {
pending_load.filename = filename;
pending_load.image_number = image_number;
pending_load.summation = summation;
std::string reason;
const PreflightResult pr = preflight_open_ro(filename, reason);
switch (pr) {
case PreflightResult::Ok:
break;
case PreflightResult::NotYetVisible:
ScheduleFileOpenRetry_i(QString::fromStdString(reason));
return; // IMPORTANT: do not try to open the file yet
case PreflightResult::PermissionDenied:
logger.Error(fmt::format(
"Permission denied opening '{}' (read-only preflight failed: {}). Not retrying.",
filename.toStdString(), reason));
emit fileLoadError("Permission Denied", QString::fromStdString(reason));
ResetFileOpenRetry_i();
return;
case PreflightResult::IsDirectory:
logger.Error(fmt::format(
"Error opening '{}': {}. Not retrying.",
filename.toStdString(), reason));
emit fileLoadError("Cannot open directory", QString::fromStdString(reason));
ResetFileOpenRetry_i();
return;
case PreflightResult::OtherError:
logger.Error(fmt::format(
"Other error '{}' (read-only preflight failed: {}). Not retrying.",
filename.toStdString(), reason));
emit fileLoadError("File Open Error", QString::fromStdString(reason));
ResetFileOpenRetry_i();
return;
}
// At this point we will attempt the real open.
file_open_retry_attempts++;
}
file_reader.ReadFile(filename.toStdString());
total_images = file_reader.GetNumberOfImages();
dataset = file_reader.GetDataset();
setAutoLoadMode_i(AutoloadMode::None);
if (retry && file_open_retry_active) {
logger.Info(fmt::format(
"File '{}' opened after {} attempt(s), waited {} ms",
filename.toStdString(),
file_open_retry_attempts,
file_open_retry_elapsed.isValid() ? file_open_retry_elapsed.elapsed() : 0));
}
ResetFileOpenRetry_i();
}
current_image.reset();
current_summation = 1;
current_file = filename;
if (dataset) {
curr_experiment = dataset->experiment;
roi_override_.reset(); // new file: use its ROIs, forget any earlier edits
curr_experiment.ImportIndexingSettings(indexing_settings);
curr_experiment.ImportAzimuthalIntegrationSettings(azint_settings);
curr_experiment.ImportBraggIntegrationSettings(bragg_settings);
curr_experiment.ImportScalingSettings(scaling_settings);
UpdateAzint_i(dataset.get());
}
emit datasetLoaded(dataset);
EmitReferenceInfo_i(); // refresh the reference's consistency check against the new dataset
// Reset the run collection for the freshly opened file (file mode only has snapshots).
run_labels_.clear();
if (!http_mode)
run_labels_["Original"] = "Original";
EmitRuns_i();
emit fileOpened(); // listeners (e.g. the jobs table) reset their per-file state
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
logger.Info(fmt::format("Loaded file {} in {} ms", filename.toStdString(), duration));
LoadImage_i(image_number, summation);
} catch (std::exception &e) {
logger.Error(fmt::format("Error loading file {} {}", filename.toStdString(), e.what()));
emit fileLoadError("File Load Error", QString::fromStdString(e.what()));
ResetFileOpenRetry_i();
emit datasetLoaded({});
EmitImageLoaded_i({});
}
}
void JFJochImageReadingWorker::LoadFile(const QString &filename, qint64 image_number, qint64 summation, bool retry) {
QMutexLocker ul(&m);
ResetFileOpenRetry_i();
LoadFile_i(filename, image_number, summation, retry);
}
void JFJochImageReadingWorker::CloseFile() {
QMutexLocker ul(&m);
ResetFileOpenRetry_i();
if (http_mode)
http_reader.Close();
else
file_reader.Close();
status_timer->stop();
setAutoLoadMode_i(AutoloadMode::None);
SetHttpConnected_i(false, "");
current_image_ptr.reset();
current_image.reset();
current_summation = 1;
total_images = 0;
current_file = "";
EmitImageLoaded_i({});
emit datasetLoaded({});
}
void JFJochImageReadingWorker::LoadImage(int64_t image_number, int64_t summation) {
QMutexLocker ul(&m);
// Manually selecting an image while following live drops to dataset-only follow:
// the chosen image stays put while plots and image count keep updating.
if (autoload_mode == AutoloadMode::HTTPSync || autoload_mode == AutoloadMode::HTTPSyncDataset)
setAutoLoadMode_i(AutoloadMode::HTTPSyncDataset);
else
setAutoLoadMode_i(AutoloadMode::None);
if ((image_number == current_image) && (current_summation == summation))
return;
LoadImage_i(image_number, summation);
}
void JFJochImageReadingWorker::UpdateAzint_i(const JFJochReaderDataset *dataset) {
if (dataset) {
azint_mapping = std::make_unique<AzimuthalIntegrationMapping>(curr_experiment, dataset->pixel_mask);
index_and_refine = std::make_unique<IndexAndRefine>(curr_experiment, indexing.get());
image_analysis = std::make_unique<MXAnalysisWithoutFPGA>(curr_experiment, *azint_mapping, dataset->pixel_mask,
*index_and_refine.get());
last_profile_.reset();
}
}
void JFJochImageReadingWorker::LoadImage_i(int64_t image_number, int64_t summation) {
// Assumes m locked!
try {
if (summation <= 0)
return;
std::vector<int32_t> image;
auto start = std::chrono::high_resolution_clock::now();
if (http_mode) {
if (image_number < 0 && summation != 1)
return;
const auto tmp_image_ptr = http_reader.LoadImage(image_number, summation);
if (image_number < 0 && tmp_image_ptr == nullptr) {
logger.Debug("No change in online buffer, not updating viewer");
return; // Do nothing, since there is no update in the file
}
current_image_ptr = tmp_image_ptr;
total_images = http_reader.GetNumberOfImages();
emit datasetLoaded(http_reader.GetDataset());
} else {
if (image_number < 0 || image_number + summation > total_images)
return;
current_image_ptr = file_reader.LoadImage(image_number, summation);
}
if (!current_image_ptr) {
EmitImageLoaded_i({});
return;
}
ApplyROIOverrideToImage_i(); // edited ROIs win over the file's for every image
current_image = current_image_ptr->ImageData().number;
current_summation = summation;
auto end = std::chrono::high_resolution_clock::now();
if (auto_reanalyze) {
ReanalyzeImage_i();
} else if (image_analysis && !curr_experiment.ROI().empty()) {
// ROIs are wanted even when a full re-analysis is not; compute only those.
try {
image_analysis->AnalyzeROIOnly(current_image_ptr->ImageData());
} catch (const std::exception &e) {
logger.Error("ROI-only analysis failed: {}", e.what());
}
}
auto end_analysis = std::chrono::high_resolution_clock::now();
auto duration_1 = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
auto duration_2 = std::chrono::duration_cast<std::chrono::milliseconds>(end_analysis - end).count();
// Adapt autoload interval from moving average of (load + analysis) time
if (autoload_mode != AutoloadMode::None) {
const int total_ms = static_cast<int>(duration_1 + duration_2);
autoload_ms_ma.Add(static_cast<float>(total_ms));
const auto avg_ms = autoload_ms_ma.Read();
if (avg_ms.has_value()) {
int proposed_ms = static_cast<int>(avg_ms.value() * autoload_safety_factor);
proposed_ms = std::clamp(proposed_ms, autoload_interval_min_ms, autoload_interval_max_ms);
if (proposed_ms != autoload_interval) {
autoload_interval = proposed_ms;
autoload_timer->setInterval(autoload_interval);
}
}
if (autoload_mode == AutoloadMode::HTTPSync)
emit liveRateChanged(autoload_interval > 0 ? 1000.0 / autoload_interval : 0.0);
}
logger.Info("Loaded image {} in {}/{} ms Autoload timer set to {} ms", image_number, duration_1, duration_2, autoload_interval);
emit imageNumberChanged(total_images, current_image.value());
EmitImageLoaded_i(current_image_ptr);
} catch (std::exception &e) {
logger.Error("Error loading image {}: {}", image_number, e.what());
}
}
void JFJochImageReadingWorker::UpdateDataset_i(const std::optional<DiffractionExperiment> &experiment) {
if (!current_image_ptr)
return;
std::shared_ptr<const JFJochReaderDataset> dataset;
if (http_mode) {
if (experiment)
http_reader.UpdateGeomMetadata(experiment.value());
dataset = http_reader.GetDataset();
} else {
if (experiment)
file_reader.UpdateGeomMetadata(experiment.value());
dataset = file_reader.GetDataset();
}
if (!dataset) {
logger.Error("UpdateDataset_i: dataset is null (http_mode={}) - skipping update to avoid crash", http_mode);
return;
}
curr_experiment = dataset->experiment;
if (roi_override_)
curr_experiment.ROI().SetROI(*roi_override_); // keep edited ROIs across settings changes
curr_experiment.ImportIndexingSettings(indexing_settings);
curr_experiment.ImportAzimuthalIntegrationSettings(azint_settings);
curr_experiment.ImportBraggIntegrationSettings(bragg_settings);
curr_experiment.ImportScalingSettings(scaling_settings);
UpdateAzint_i(dataset.get());
emit datasetLoaded(dataset);
current_image_ptr = std::make_shared<JFJochReaderImage>(current_image_ptr->ImageData(), dataset);
ApplyROIOverrideToImage_i();
if (auto_reanalyze)
ReanalyzeImage_i();
EmitImageLoaded_i(current_image_ptr);
}
void JFJochImageReadingWorker::UpdateDataset(const DiffractionExperiment &experiment) {
QMutexLocker ul(&m);
UpdateDataset_i(experiment);
}
void JFJochImageReadingWorker::ReanalyzeImage_i() {
if (!current_image_ptr || !azint_mapping || !image_analysis)
return;
auto start_time = std::chrono::high_resolution_clock::now();
auto new_image = std::make_shared<JFJochReaderImage>(*current_image_ptr);
auto new_image_dataset = new_image->CreateMutableDataset();
new_image_dataset->experiment.ImportIndexingSettings(indexing_settings);
new_image_dataset->experiment.ImportAzimuthalIntegrationSettings(azint_settings);
new_image_dataset->az_int_bin_to_phi = azint_mapping->GetBinToPhi();
new_image_dataset->az_int_bin_to_q = azint_mapping->GetBinToQ();
new_image_dataset->azimuthal_bins = azint_mapping->GetAzimuthalBinCount();
new_image_dataset->q_bins = azint_mapping->GetQBinCount();
// Azimuthal profile for the analysis/display pipeline. AzimuthalIntegrationProfile
// holds a mutex (non-copyable), so keep it via unique_ptr re-created each analysis.
last_profile_ = std::make_unique<AzimuthalIntegrationProfile>(*azint_mapping);
image_analysis->Analyze(new_image->ImageData(), *last_profile_, spot_finding_settings);
current_image_ptr = new_image;
auto end_time = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end_time - start_time);
logger.Info("Analysis of image in {} ms", duration.count());
}
void JFJochImageReadingWorker::Analyze() {
QMutexLocker locker(&m);
ReanalyzeImage_i();
EmitImageLoaded_i(current_image_ptr);
}
void JFJochImageReadingWorker::FindCenter(const UnitCell& calibrant, bool guess) {
QMutexLocker locker(&m);
if (!current_image_ptr)
return;
logger.Info("Finding center");
DiffractionGeometry geom = current_image_ptr->Dataset().experiment.GetDiffractionGeometry();
try {
if (guess)
GuessGeometry(geom, current_image_ptr->ImageData().spots, calibrant);
else
OptimizeGeometry(geom, current_image_ptr->ImageData().spots, calibrant);
} catch (const JFJochException &e) {
logger.ErrorException(e);
return;
}
logger.Info("Geometry found X: {} pxl Y: {} pxl Dist: {} mm", geom.GetBeamX_pxl(), geom.GetBeamY_pxl(),
geom.GetDetectorDistance_mm());
DiffractionExperiment new_experiment = current_image_ptr->Dataset().experiment;
new_experiment.BeamX_pxl(geom.GetBeamX_pxl()).BeamY_pxl(geom.GetBeamY_pxl())
.DetectorDistance_mm(geom.GetDetectorDistance_mm())
.PoniRot1_rad(geom.GetPoniRot1_rad())
.PoniRot2_rad(geom.GetPoniRot2_rad())
.PoniRot3_rad(geom.GetPoniRot3_rad());
UpdateDataset_i(new_experiment);
std::vector<float> ring_Q = CalculateXtalRings(calibrant);
QVector<float> rings;
for (int i = 0; i < 15 && i < ring_Q.size(); i++) {
rings.push_back(2 * PI / ring_Q[i]);
}
emit setRings(rings);
}
void JFJochImageReadingWorker::UpdateSpotFindingSettings(const SpotFindingSettings &settings,
const IndexingSettings &indexing,
int64_t max_spots) {
QMutexLocker locker(&m);
spot_finding_settings = settings;
indexing_settings.Algorithm(indexing.GetAlgorithm());
indexing_settings.GeomRefinementAlgorithm(indexing.GetGeomRefinementAlgorithm());
indexing_settings.RotationIndexing(indexing.GetRotationIndexing()); // "Process as stills" drives this
indexing_settings.Tolerance(indexing.GetTolerance());
indexing_settings.ViableCellMinSpots(indexing.GetViableCellMinSpots());
indexing_settings.IndexIceRings(indexing.GetIndexIceRings());
indexing_settings.UnitCellDistTolerance(indexing.GetUnitCellDistTolerance());
curr_experiment.ImportIndexingSettings(indexing_settings);
curr_experiment.MaxSpotCount(max_spots);
if (auto_reanalyze) {
ReanalyzeImage_i();
EmitImageLoaded_i(current_image_ptr);
}
}
void JFJochImageReadingWorker::ReanalyzeImages(bool input) {
QMutexLocker locker(&m);
auto_reanalyze = input;
if (auto_reanalyze) {
ReanalyzeImage_i();
EmitImageLoaded_i(current_image_ptr);
}
}
void JFJochImageReadingWorker::UpdateAzintSettings(const AzimuthalIntegrationSettings &settings) {
QMutexLocker locker(&m);
azint_settings = settings;
UpdateDataset_i(std::nullopt);
}
void JFJochImageReadingWorker::UpdateBraggIntegrationSettings(BraggIntegrationSettings settings) {
QMutexLocker locker(&m);
bragg_settings = settings;
UpdateDataset_i(std::nullopt);
}
void JFJochImageReadingWorker::UpdateScalingSettings(ScalingSettings settings) {
QMutexLocker locker(&m);
scaling_settings = settings;
// Scaling only affects the processing-job post-pass (jobs read curr_experiment via
// GetReprocessingInputs), not interactive single-image analysis - just store + import it.
curr_experiment.ImportScalingSettings(scaling_settings);
}
void JFJochImageReadingWorker::SetReferenceMtz(QString path, QString column) {
QMutexLocker locker(&m);
try {
reference_ = LoadReferenceMtz(
path.toStdString(),
column.isEmpty() ? std::nullopt : std::optional<std::string>(column.toStdString()));
logger.Info(fmt::format("Loaded {} reference reflections from {} (column {})",
reference_->reflections.size(), path.toStdString(),
reference_->used_column));
} catch (const std::exception &e) {
reference_.reset();
logger.Error(fmt::format("Error reading reference MTZ {}: {}", path.toStdString(), e.what()));
ReferenceMtzInfo info;
info.warning = QString("Load failed: ") + e.what(); // loaded stays false
emit referenceMtzChanged(info);
return;
}
EmitReferenceInfo_i();
}
void JFJochImageReadingWorker::ClearReferenceMtz() {
QMutexLocker locker(&m);
reference_.reset();
EmitReferenceInfo_i();
}
void JFJochImageReadingWorker::EmitReferenceInfo_i() {
// Assumes m locked. Builds the display summary from the stored reference and the current data,
// so the consistency warning reflects whichever dataset is loaded now.
ReferenceMtzInfo info;
if (!reference_) {
emit referenceMtzChanged(info); // loaded = false: "No reference loaded"
return;
}
info.loaded = true;
for (const auto &c : reference_->candidate_columns)
info.columns << QString::fromStdString(c.label);
info.used_column = QString::fromStdString(reference_->used_column);
QString summary = QString::number(static_cast<qulonglong>(reference_->reflections.size())) + " refl";
if (reference_->d_max > 0.0)
summary += QString::asprintf(", %.2f-%.2f A", reference_->d_max, reference_->d_min);
if (!reference_->space_group_name.empty())
summary += ", " + QString::fromStdString(reference_->space_group_name);
if (reference_->cell)
summary += QString::asprintf(", %.1f %.1f %.1f %.1f %.1f %.1f",
reference_->cell->a, reference_->cell->b, reference_->cell->c,
reference_->cell->alpha, reference_->cell->beta, reference_->cell->gamma);
info.summary = summary;
const auto data_sg = curr_experiment.GetSpaceGroupNumber().has_value()
? std::optional<int>(static_cast<int>(*curr_experiment.GetSpaceGroupNumber()))
: std::nullopt;
info.warning = QString::fromStdString(
ReferenceConsistencyWarning(*reference_, curr_experiment.GetUnitCell(), data_sg));
emit referenceMtzChanged(info);
}
void JFJochImageReadingWorker::SetROIDefinition(const ROIDefinition &rois) {
QMutexLocker locker(&m);
SetROIDefinition_i(rois);
}
void JFJochImageReadingWorker::MaskFromSelectedROI(QString name, bool add) {
QMutexLocker locker(&m);
if (!current_image_ptr)
return;
const auto &exp = current_image_ptr->Dataset().experiment;
const auto rois = exp.ROI().GetROIDefinition();
const std::string n = name.toStdString();
const ROIElement *elem = nullptr;
for (const auto &b : rois.boxes) if (b.GetName() == n) { elem = &b; break; }
if (!elem) for (const auto &c : rois.circles) if (c.GetName() == n) { elem = &c; break; }
if (!elem) for (const auto &a : rois.azimuthal) if (a.GetName() == n) { elem = &a; break; }
if (!elem)
return;
auto user_mask = current_image_ptr->Dataset().pixel_mask.GetUserMask();
auto geom = exp.GetDiffractionGeometry();
const int64_t width = exp.GetXPixelsNum();
const int64_t height = exp.GetYPixelsNum();
for (int64_t y = 0; y < height; y++) {
for (int64_t x = 0; x < width; x++) {
const float res = geom.PxlToRes(x, y);
const float phi = geom.Phi_rad(x, y) * 180.0f / static_cast<float>(PI);
if (elem->CheckROI(x, y, res, phi))
user_mask[x + y * width] = add ? 1 : 0;
}
}
UpdateUserMask_i(user_mask);
}
void JFJochImageReadingWorker::DownloadROIsFromServer() {
QMutexLocker locker(&m);
if (!http_mode)
return;
try {
SetROIDefinition_i(http_reader.GetROIDefinitions());
} catch (const std::exception &e) {
logger.Error("Download ROIs from server failed: {}", e.what());
}
}
void JFJochImageReadingWorker::UploadROIsToServer() {
QMutexLocker locker(&m);
if (!http_mode)
return;
try {
http_reader.UploadROIDefinitions(curr_experiment.ROI().GetROIDefinition());
} catch (const std::exception &e) {
logger.Error("Upload ROIs to server failed: {}", e.what());
}
}
void JFJochImageReadingWorker::SetROIDefinition_i(const ROIDefinition &rois) {
// The worker experiment is the source of truth; the analysis ROI engine is rebuilt
// for it so newly loaded images pick up the change automatically. From now on the
// edited ROIs override whatever the file carried.
roi_override_ = rois;
curr_experiment.ROI().SetROI(rois);
if (image_analysis)
image_analysis->RebuildROI();
if (!current_image_ptr)
return;
// Mutate the dataset too, so the canvas (which draws image->Dataset().experiment.ROI())
// reflects the edit, then recompute only the ROIs for the current image.
auto mutable_dataset = current_image_ptr->CreateMutableDataset();
mutable_dataset->experiment.ROI().SetROI(rois);
std::shared_ptr<const JFJochReaderDataset> dataset = mutable_dataset;
current_image_ptr = std::make_shared<JFJochReaderImage>(current_image_ptr->ImageData(), dataset);
if (image_analysis) {
try {
image_analysis->RunROIOnly(current_image_ptr->ImageData());
} catch (const std::exception &e) {
logger.Error("ROI-only analysis failed: {}", e.what());
}
}
emit datasetLoaded(dataset);
EmitImageLoaded_i(current_image_ptr);
}
void JFJochImageReadingWorker::ApplyROIOverrideToImage_i() {
if (!roi_override_ || !current_image_ptr)
return;
auto md = current_image_ptr->CreateMutableDataset();
md->experiment.ROI().SetROI(*roi_override_);
current_image_ptr = std::make_shared<JFJochReaderImage>(
current_image_ptr->ImageData(), std::shared_ptr<const JFJochReaderDataset>(md));
}
void JFJochImageReadingWorker::UpdateUserMask_i(const std::vector<uint32_t> &mask) {
std::shared_ptr<const JFJochReaderDataset> dataset;
if (http_mode) {
http_reader.UpdateUserMask(mask);
dataset = http_reader.GetDataset();
} else {
file_reader.UpdateUserMask(mask);
dataset = file_reader.GetDataset();
}
if (!dataset) {
logger.Error("UpdateUserMask_i: dataset is null (http_mode={}) - skipping update to avoid crash", http_mode);
return;
}
UpdateAzint_i(dataset.get());
emit datasetLoaded(dataset);
current_image_ptr = std::make_shared<JFJochReaderImage>(current_image_ptr->ImageData(), dataset);
if (current_image.has_value())
LoadImage_i(current_image.value(), current_summation);
}
void JFJochImageReadingWorker::ClearUserMask() {
QMutexLocker locker(&m);
if (!current_image_ptr)
return;
auto user_mask = std::vector<uint32_t>(current_image_ptr->Dataset().experiment.GetXPixelsNum() * current_image_ptr->Dataset().experiment.GetYPixelsNum(), 0);
UpdateUserMask_i(user_mask);
}
void JFJochImageReadingWorker::SaveUserMaskTIFF(QString filename) {
QMutexLocker locker(&m);
if (!current_image_ptr)
return;
auto user_mask = current_image_ptr->Dataset().pixel_mask.GetUserMask();
CompressedImage mask_image(user_mask, current_image_ptr->Dataset().experiment.GetXPixelsNum(), current_image_ptr->Dataset().experiment.GetYPixelsNum());
WriteTIFFToFile(filename.toStdString(), mask_image);
}
void JFJochImageReadingWorker::LoadUserMaskTIFF(QString filename, bool replace) {
QMutexLocker locker(&m);
if (!current_image_ptr)
return;
std::ifstream f(filename.toStdString(), std::ios::binary);
const std::string content((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
uint32_t cols = 0, lines = 0;
std::vector<uint16_t> tiff;
try {
tiff = ReadTIFFFromString16(content, cols, lines);
} catch (const std::exception &e) {
logger.Error("Could not read mask TIFF {}: {}", filename.toStdString(), e.what());
return;
}
const int64_t width = current_image_ptr->Dataset().experiment.GetXPixelsNum();
const int64_t height = current_image_ptr->Dataset().experiment.GetYPixelsNum();
if (static_cast<int64_t>(cols) != width || static_cast<int64_t>(lines) != height) {
logger.Error("Mask TIFF is {}x{} but the detector is {}x{}", cols, lines, width, height);
return;
}
// replace: start from an empty mask; add: keep the current one. A non-zero TIFF pixel masks.
auto user_mask = replace ? std::vector<uint32_t>(width * height, 0)
: current_image_ptr->Dataset().pixel_mask.GetUserMask();
for (size_t i = 0; i < tiff.size(); i++)
if (tiff[i] != 0)
user_mask[i] = 1;
UpdateUserMask_i(user_mask);
}
void JFJochImageReadingWorker::UploadUserMask() {
QMutexLocker locker(&m);
if (!current_image_ptr)
return;
auto user_mask = current_image_ptr->Dataset().pixel_mask.GetUserMask();
if (http_mode)
http_reader.UploadUserMask(user_mask);
}
void JFJochImageReadingWorker::LoadCalibration(QString dataset) {
if (!http_mode) {
auto tmp = std::make_shared<SimpleImage>();
try {
tmp->image = file_reader.ReadCalibration(tmp->buffer, dataset.toStdString());
std::shared_ptr<const SimpleImage> ctmp = tmp;
emit simpleImageLoaded(ctmp);
} catch (const std::exception &e) {
logger.Info("Error loading calibration: {}", e.what());
}
} else
logger.Info("HTTP mode doesn't allow to read calibration (at the moment");
}
void JFJochImageReadingWorker::AutoLoadTimerExpired() {
QMutexLocker locker(&m);
// Backpressure (all auto modes): don't hand the GUI more frames than it has rendered - it acks
// each one via ImageConsumed(). Without this a fast producer (live follow or movie playback)
// outruns rendering and the queued imageLoaded events pile up unbounded. Intermediate frames are
// skipped, which for a live monitor is exactly right (the next fetch grabs the newest image).
if (images_in_flight >= max_images_in_flight)
return;
switch (autoload_mode) {
case AutoloadMode::HTTPSync:
if (http_mode)
LoadImage_i(-1 , 1);
break;
case AutoloadMode::HTTPSyncDataset:
if (http_mode)
RefreshDatasetOnly_i();
break;
case AutoloadMode::Movie: {
if (total_images == 0 || !current_image)
return;
int64_t new_image = (current_image.value() + jump_value) % total_images;
LoadImage_i(new_image, current_summation);
break;
}
case AutoloadMode::None:
break;
}
}
void JFJochImageReadingWorker::EmitImageLoaded_i(const std::shared_ptr<const JFJochReaderImage>& image) {
// Assumes m locked! Count every frame handed to the GUI; ImageConsumed() decrements it once the
// frame has been rendered. AutoLoadTimerExpired caps how many frames may be in flight at once.
++images_in_flight;
emit imageLoaded(image);
}
void JFJochImageReadingWorker::ImageConsumed() {
QMutexLocker locker(&m);
--images_in_flight;
}
void JFJochImageReadingWorker::SetHttpConnected_i(bool connected, const QString &addr) {
// Assumes m locked! Only signals on a real change so the status bar does not flicker.
if (connected == http_connected)
return;
http_connected = connected;
emit httpConnectionChanged(connected, addr);
}
void JFJochImageReadingWorker::RefreshDatasetOnly_i() {
// Assumes m locked! Updates dataset/plots and image count without touching the displayed image.
if (!http_mode)
return;
try {
int64_t num_images = 0;
auto dataset = http_reader.RefreshDatasetIfChanged(num_images);
SetHttpConnected_i(true, current_file);
if (num_images != total_images) {
total_images = num_images;
if (current_image.has_value())
emit imageNumberChanged(total_images, current_image.value());
}
if (dataset)
emit datasetLoaded(dataset);
} catch (std::exception &e) {
logger.Debug("Dataset refresh failed: {}", e.what());
SetHttpConnected_i(false, current_file);
}
}
void JFJochImageReadingWorker::StatusTimerExpired() {
QMutexLocker locker(&m);
if (!http_mode)
return;
try {
BrokerStatus status = http_reader.GetBrokerStatus();
SetHttpConnected_i(true, current_file);
emit brokerStatusUpdated(status);
} catch (std::exception &e) {
SetHttpConnected_i(false, current_file);
}
}
void JFJochImageReadingWorker::setAutoLoadMode_i(AutoloadMode in_mode) {
autoload_mode = in_mode;
if (autoload_mode == AutoloadMode::None)
autoload_timer->stop();
else
autoload_timer->start();
if (autoload_mode != AutoloadMode::HTTPSync)
emit liveRateChanged(0.0);
emit autoloadChanged(autoload_mode);
}
void JFJochImageReadingWorker::setAutoLoadMode(AutoloadMode mode) {
QMutexLocker ul(&m);
switch (mode) {
case AutoloadMode::HTTPSync:
case AutoloadMode::HTTPSyncDataset:
if (http_mode)
setAutoLoadMode_i(mode);
else
setAutoLoadMode_i(AutoloadMode::None);
break;
case AutoloadMode::Movie:
setAutoLoadMode_i(mode);
break;
case AutoloadMode::None:
setAutoLoadMode_i(mode);
break;
}
}
void JFJochImageReadingWorker::setAutoLoadJump(int64_t val) {
QMutexLocker ul(&m);
if (val > 0)
jump_value = val;
}
void JFJochImageReadingWorker::LoadSpots(int64_t start_image, int64_t end_image, int64_t stride) {
QMutexLocker ul(&m);
std::shared_ptr<JFJochReaderSpots> result;
if (http_mode)
result = http_reader.ReadAllSpots(start_image, end_image, stride);
else
result = file_reader.ReadAllSpots(start_image, end_image, stride);
emit spotsLoaded(result);
}
ReprocessingInputs JFJochImageReadingWorker::GetReprocessingInputs() const {
QMutexLocker ul(&m);
ReprocessingInputs in;
if (http_mode || current_file.isEmpty() || !current_image_ptr)
return in;
in.file = current_file;
in.experiment = curr_experiment;
in.pixel_mask = current_image_ptr->Dataset().pixel_mask;
in.spot_finding = spot_finding_settings;
if (reference_)
in.reference_data = reference_->reflections;
in.valid = true;
return in;
}
void JFJochImageReadingWorker::ActivateSnapshot_i(const QString &name) {
// Assumes m locked! Switch the reader's active metadata snapshot and refresh the view.
file_reader.SetActiveSnapshot(name.toStdString());
auto dataset = file_reader.GetDataset();
if (!dataset)
return;
curr_experiment = dataset->experiment;
curr_experiment.ImportIndexingSettings(indexing_settings);
curr_experiment.ImportAzimuthalIntegrationSettings(azint_settings);
UpdateAzint_i(dataset.get());
emit datasetLoaded(dataset);
QStringList names;
for (const auto &n: file_reader.SnapshotNames())
names << QString::fromStdString(n);
emit snapshotsChanged(names, QString::fromStdString(file_reader.ActiveSnapshot()));
EmitRuns_i();
if (current_image.has_value()) {
// A snapshot carries its own per-image results; just re-read the image against the new
// metadata source, never re-run analysis (that would re-index on every snapshot switch).
const bool prev_reanalyze = auto_reanalyze;
auto_reanalyze = false;
LoadImage_i(current_image.value(), current_summation);
auto_reanalyze = prev_reanalyze;
}
}
void JFJochImageReadingWorker::EmitRuns_i() {
// Assumes m locked! Build the run list (every snapshot dataset + its display label).
QVector<RunData> runs;
if (!http_mode) {
for (const auto &[id, dataset]: file_reader.AllSnapshotDatasets()) {
const auto it = run_labels_.find(id);
const QString label = (it != run_labels_.end()) ? it->second : QString::fromStdString(id);
runs.push_back(RunData{QString::fromStdString(id), label, dataset});
}
}
emit runsChanged(runs, http_mode ? QString() : QString::fromStdString(file_reader.ActiveSnapshot()));
}
void JFJochImageReadingWorker::RenameRun(QString id, QString label) {
QMutexLocker ul(&m);
run_labels_[id.toStdString()] = label;
EmitRuns_i();
}
void JFJochImageReadingWorker::RemoveRun(QString id) {
QMutexLocker ul(&m);
if (http_mode || id == "Original")
return;
file_reader.RemoveSnapshot(id.toStdString());
run_labels_.erase(id.toStdString());
// Refresh the (possibly now-Original) active view; ActivateSnapshot_i also re-emits runsChanged.
ActivateSnapshot_i(QString::fromStdString(file_reader.ActiveSnapshot()));
}
void JFJochImageReadingWorker::RegisterProcessingSnapshot(QString id, QString label, QString master_path) {
QMutexLocker ul(&m);
if (http_mode) {
logger.Error("Processing snapshots are only available for files");
return;
}
try {
file_reader.RegisterSnapshot(id.toStdString(), master_path.toStdString());
run_labels_[id.toStdString()] = label;
ActivateSnapshot_i(id); // activates + emits datasetLoaded / snapshotsChanged / runsChanged
} catch (const std::exception &e) {
logger.Error("Failed to register snapshot {}: {}", id.toStdString(), e.what());
emit fileLoadError("Snapshot error", QString::fromStdString(e.what()));
}
}
void JFJochImageReadingWorker::SetActiveSnapshot(QString name) {
QMutexLocker ul(&m);
if (http_mode)
return;
try {
ActivateSnapshot_i(name);
} catch (const std::exception &e) {
logger.Error("Failed to activate snapshot {}: {}", name.toStdString(), e.what());
}
}
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) 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 {};
}
}