Build Packages / Create release (push) Successful in 7s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 4m56s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m10s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 13m51s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 16m56s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 20m59s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m25s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 21m17s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 14m14s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m59s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m48s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 15m52s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 19m33s
Build Packages / Generate python client (push) Successful in 29s
Build Packages / Build documentation (push) Successful in 1m19s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m49s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 25m31s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 25m24s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 26m5s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 22m19s
Build Packages / Unit tests (push) Failing after 2h28m24s
Build Packages / Validation Battery (push) Successful in 40m33s
* Rugnux: Processes a collection live while jfjoch_broker writes it (`rugnux <master.h5> <broker URL>`). * Rugnux: Processing starts on the first images and confirms its choices once all are in, so analysis can begin during collection. * Rugnux: Faster processing, especially on servers with several GPUs and CPU sockets and on computers without a GPU (`--no-gpu`, macOS). * Rugnux: Uses much less GPU memory, so large data sets stay on the GPU of smaller cards, and a data set too large for the card is processed on the CPU instead. * Rugnux: On macOS, Bragg integration runs on the Apple GPU. * Rugnux: Better handling of twinning and pseudo-symmetry, with the twin hypothesis written as `<prefix>_twin_<SG>.mtz`, and a wrong `--model` no longer confirms the data or decides the hand. * Rugnux: `--model` accepts predicted (AlphaFold) models and falls back to molecular replacement when the model is not placed as a rigid body (experimental `--mr` forces it), settling space group, hand and twinning and writing a different answer as `<prefix>_mr.mtz`. * Rugnux: Molecular replacement is faster and settles the space group in more cases with several copies in the asymmetric unit. * Rugnux: Deposition-ready mmCIF, a richer report, and numbered steps on the terminal with the full log in `<prefix>_log.txt`; `_P1.mtz`, `--estimate-beam-center` and `--beam-center-search` are removed. * Rugnux: The beam-stop shadow is found correctly on tilted detectors. * Rugnux: Stills files that record a stationary goniometer axis get the default stills geometry refinement. * jfjoch_viewer: "Analyze dataset" uses the same defaults as a bare `rugnux <file>`, and shows the results of processed CBF, marCCD and SMV sweeps. * jfjoch_viewer: The Files panel refreshes on F5 and every few seconds, and the plot type and other selections stay put during live collection. * jfjoch_writer: Memory stays bounded and old data files are closed efficiently on long runs, any failure ends the run in an error state the broker is told about, and the master file appears only after its data files; the ZeroMQ pusher logs when a stalled writer makes it drop images. * Fixes: The TCP image pusher no longer blocks the broker's receiver, GPU out-of-memory failures stop with a clear message, and the XDS plugin masks module and chip edges. --------- Co-authored-by: Filip Leonarski <jungfrau@filips-mac-mini.home> Reviewed-on: #85 Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
1057 lines
42 KiB
C++
1057 lines
42 KiB
C++
// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||
// SPDX-License-Identifier: GPL-3.0-only
|
||
|
||
#include <QMenu>
|
||
#include "../ViewerTheme.h"
|
||
#include <QApplication>
|
||
#include <QClipboard>
|
||
#include <QCategoryAxis>
|
||
#include <QtCharts/QLegendMarker>
|
||
#include <QGraphicsLayout>
|
||
#include <QGraphicsSimpleTextItem>
|
||
#include <cmath>
|
||
#include <complex>
|
||
|
||
#include "JFJochDatasetInfoChartView.h"
|
||
#include "../../common/FFTWPlannerLock.h"
|
||
|
||
namespace {
|
||
// Qt Charts draws its curves 2 px wide, which is hard to follow across a desk. Take the weight
|
||
// from the font, so a zoomed-up UI gets a proportionally heavier line (~3 px at the default font).
|
||
void setSeriesPenWidth(QLineSeries *line, int font_height) {
|
||
QPen pen = line->pen();
|
||
pen.setWidthF(font_height / 5.0);
|
||
line->setPen(pen);
|
||
}
|
||
}
|
||
|
||
JFJochDatasetInfoChartView::JFJochDatasetInfoChartView(QWidget *parent)
|
||
: QChartView(new QChart(), parent) {
|
||
StyleChart(chart());
|
||
// The theme recolours every series, the run colours included, so the chart is rebuilt after it.
|
||
connect(ThemeNotifier::Instance(), &ThemeNotifier::changed, this, [this] {
|
||
StyleChart(chart());
|
||
updateChart();
|
||
});
|
||
chart()->legend()->hide();
|
||
// Reclaim Qt Charts' outer layout padding so the axis labels keep their room even when the dock
|
||
// is short (otherwise they are the first thing Qt drops); the inner margins are set per rebuild.
|
||
chart()->layout()->setContentsMargins(0, 0, 0, 0);
|
||
chart()->setBackgroundRoundness(0);
|
||
setRenderHint(QPainter::Antialiasing);
|
||
// setRubberBand(QChartView::RubberBand::RectangleRubberBand);
|
||
setMouseTracking(true);
|
||
|
||
m_hoverLoadTimer = new QTimer(this);
|
||
m_hoverLoadTimer->setSingleShot(true);
|
||
connect(m_hoverLoadTimer, &QTimer::timeout, this, &JFJochDatasetInfoChartView::onHoverLoadTimeout);
|
||
}
|
||
|
||
|
||
void JFJochDatasetInfoChartView::setImage(int64_t val) {
|
||
if (!currentSeries || currentSeries->chart() != chart())
|
||
return;
|
||
|
||
curr_image = val;
|
||
currentSeries->clear();
|
||
|
||
if (values.empty() || val < 0 ||
|
||
val >= static_cast<int64_t>(values.size()))
|
||
return;
|
||
|
||
// For binning > 1, show the binned mean at bin center
|
||
if (binning > 1) {
|
||
const int64_t nBins =
|
||
static_cast<int64_t>(values.size()) / binning;
|
||
if (nBins <= 0)
|
||
return;
|
||
|
||
int64_t binIdx = val / binning;
|
||
binIdx = std::clamp<int64_t>(binIdx, 0, nBins - 1);
|
||
|
||
double sum = 0.0;
|
||
int64_t count = 0;
|
||
for (int64_t b = 0; b < binning; ++b) {
|
||
const int64_t idx = binIdx * binning + b;
|
||
if (idx >= static_cast<int64_t>(values.size()))
|
||
break;
|
||
const double v = values[static_cast<size_t>(idx)];
|
||
if (std::isfinite(v)) {
|
||
sum += v;
|
||
++count;
|
||
}
|
||
}
|
||
|
||
if (count > 0) {
|
||
const double mean = sum / static_cast<double>(count);
|
||
if (std::isfinite(mean)) {
|
||
const double centerX =
|
||
(static_cast<double>(binIdx) + 0.5) * static_cast<double>(binning);
|
||
currentSeries->append(centerX, mean);
|
||
}
|
||
}
|
||
} else {
|
||
// binning == 1: original behavior, per-image value
|
||
if (std::isfinite(values[curr_image])) {
|
||
const double disp = values[curr_image];
|
||
if (std::isfinite(disp))
|
||
currentSeries->append(curr_image, disp);
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
void JFJochDatasetInfoChartView::mousePressEvent(QMouseEvent *event) {
|
||
if (event->button() == Qt::LeftButton) {
|
||
if (values.empty()) {
|
||
QChartView::mousePressEvent(event);
|
||
return;
|
||
}
|
||
|
||
const QPointF clickedPoint = event->pos();
|
||
const QPointF chartCoord = chart()->mapToValue(clickedPoint, series);
|
||
const double xVal = chartCoord.x();
|
||
|
||
if (!std::isfinite(xVal) || xVal < 0.0 ||
|
||
xVal > static_cast<double>(values.size() - 1)) {
|
||
QChartView::mousePressEvent(event);
|
||
return;
|
||
}
|
||
|
||
int64_t selectedIdx = 0;
|
||
|
||
if (binning <= 1) {
|
||
// Original behavior: pick nearest frame index
|
||
selectedIdx = std::lround(xVal);
|
||
} else {
|
||
// Binned mode: pick bin index from x, then representative frame
|
||
const int64_t nBins =
|
||
static_cast<int64_t>(values.size()) / binning;
|
||
if (nBins <= 0) {
|
||
QChartView::mousePressEvent(event);
|
||
return;
|
||
}
|
||
|
||
int64_t binIdx =
|
||
static_cast<int64_t>(std::floor(xVal / static_cast<double>(binning)));
|
||
binIdx = std::clamp<int64_t>(binIdx, 0, nBins - 1);
|
||
|
||
int64_t centerIdx = binIdx * binning + binning / 2;
|
||
if (centerIdx >= static_cast<int64_t>(values.size()))
|
||
centerIdx = static_cast<int64_t>(values.size()) - 1;
|
||
|
||
selectedIdx = centerIdx;
|
||
}
|
||
|
||
if (selectedIdx >= 0 &&
|
||
selectedIdx < static_cast<int64_t>(values.size())) {
|
||
emit imageSelected(selectedIdx);
|
||
}
|
||
}
|
||
QChartView::mousePressEvent(event); // Call the base implementation
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::resetZoom() {
|
||
chart()->zoomReset();
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::loadValues(const std::vector<float> &input, int64_t image, bool one_over_d2,
|
||
const JFJochReaderDataset *dataset, const QString &primaryName,
|
||
std::vector<NamedSeries> overlays, const QColor &primaryColor,
|
||
std::vector<float> primaryX, int64_t fullRange) {
|
||
m_yOneOverD = one_over_d2;
|
||
primaryName_ = primaryName;
|
||
primary_color_ = primaryColor;
|
||
primary_x_ = std::move(primaryX);
|
||
full_range_ = fullRange;
|
||
|
||
// d -> 1/d^2 for resolution plots; identity otherwise. Applied to every series alike.
|
||
auto transform = [one_over_d2](const std::vector<float> &in, std::vector<float> &out) {
|
||
out.resize(in.size());
|
||
for (size_t i = 0; i < in.size(); i++) {
|
||
if (one_over_d2) {
|
||
const float d = in[i];
|
||
out[i] = std::isfinite(d) ? 1.0f / (d * d) : 0.0f;
|
||
} else
|
||
out[i] = in[i];
|
||
}
|
||
};
|
||
|
||
transform(input, values);
|
||
overlays_ = std::move(overlays);
|
||
for (auto &ov: overlays_)
|
||
transform(ov.values, ov.values); // in-place
|
||
|
||
if (dataset != nullptr) {
|
||
goniometer_axis = dataset->experiment.GetGoniometer();
|
||
image_time_s = std::chrono::duration<double>(dataset->experiment.GetImageTime()).count();
|
||
} else {
|
||
goniometer_axis = {};
|
||
image_time_s = {};
|
||
}
|
||
|
||
curr_image = image;
|
||
updateChart();
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::appendSeries(QLineSeries *s, const std::vector<float> &vals,
|
||
const std::vector<float> &xs, double &mn, double &mx) const {
|
||
// x position for value index i: the mapped image number if available, else the index itself.
|
||
auto xpos = [&xs](int64_t i) -> double {
|
||
return i < static_cast<int64_t>(xs.size()) ? static_cast<double>(xs[i]) : static_cast<double>(i);
|
||
};
|
||
if (binning == 1) {
|
||
for (int i = 0; i < static_cast<int>(vals.size()); i++) {
|
||
const double v = vals[static_cast<size_t>(i)];
|
||
if (!std::isfinite(v)) continue;
|
||
s->append(xpos(i), v);
|
||
mn = std::min(mn, v);
|
||
mx = std::max(mx, v);
|
||
}
|
||
} else {
|
||
for (int i = 0; i < static_cast<int>(vals.size() / static_cast<size_t>(binning)); i++) {
|
||
double tmp = 0.0;
|
||
int64_t count = 0;
|
||
for (int b = 0; b < binning; b++) {
|
||
const int64_t idx = static_cast<int64_t>(i) * binning + b;
|
||
if (idx >= static_cast<int64_t>(vals.size())) break;
|
||
const double v = vals[static_cast<size_t>(idx)];
|
||
if (std::isfinite(v)) { tmp += v; count++; }
|
||
}
|
||
if (count > 0) {
|
||
const double mean = tmp / static_cast<double>(count);
|
||
s->append(xpos(static_cast<int64_t>(i) * binning + binning / 2), mean);
|
||
mn = std::min(mn, mean);
|
||
mx = std::max(mx, mean);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::updateChart() {
|
||
// A minimum height in lines of text, not in pixels, so the plot is not squeezed as the font
|
||
// grows (the compact minimum is set below, from the y-axis title).
|
||
if (compact_label_.isEmpty())
|
||
setMinimumHeight(8 * fontMetrics().height());
|
||
|
||
// Room for the outermost labels: a line above the highest Y label, and half a label past the last
|
||
// X tick on the right. The right margin is in character widths so it holds as the font grows.
|
||
chart()->setMargins(QMargins(2, 10, 4 * fontMetrics().averageCharWidth(), 2));
|
||
|
||
// Important: drop any stale QObject pointers BEFORE rebuilding the chart.
|
||
series = nullptr;
|
||
currentSeries = nullptr;
|
||
|
||
chart()->removeAllSeries();
|
||
|
||
if (m_hoverLine) {
|
||
chart()->scene()->removeItem(m_hoverLine);
|
||
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)
|
||
buildFFTChart();
|
||
else
|
||
buildTimeDomainChart();
|
||
#else
|
||
buildTimeDomainChart();
|
||
#endif
|
||
|
||
// Qt Charts elides any axis label that does not fit its allotted box down to "..." (the default
|
||
// since 6.2). In a dock this narrow that is every label, so show them in full instead.
|
||
for (auto *axis: chart()->axes())
|
||
axis->setTruncateLabels(false);
|
||
|
||
if (!compact_label_.isEmpty()) {
|
||
// Half-height plot: minimal margins, the y axis reduced to its two range ends under a
|
||
// short bold name in the series' colour, and no x labels on the upper plot (the lower
|
||
// one carries them for both).
|
||
QFont title_font = font();
|
||
title_font.setBold(true);
|
||
compact_y_label_px_ = 0;
|
||
for (auto *axis: chart()->axes(Qt::Vertical)) {
|
||
if (auto *v = qobject_cast<QValueAxis *>(axis)) {
|
||
v->setTickCount(2);
|
||
v->setLabelFormat("%.3g");
|
||
// Two ticks means the labels are the two range ends: measure them, so a caller can
|
||
// pad this plot out to a taller neighbour's wider labels (see SetCompactYLabelWidth).
|
||
for (double end: {v->min(), v->max()})
|
||
compact_y_label_px_ = std::max(compact_y_label_px_,
|
||
fontMetrics().horizontalAdvance(QString::asprintf("%.3g", end)));
|
||
}
|
||
axis->setTitleVisible(false); // drawn upright by updateCompactTitle() instead
|
||
if (primary_color_.isValid())
|
||
axis->setLabelsColor(primary_color_);
|
||
}
|
||
updateCompactTitle(title_font);
|
||
// The upper plot keeps its x labels but paints them transparent rather than hiding them:
|
||
// Qt then reserves the same label band on both charts, so the two plot areas - the part the
|
||
// curve is drawn in - come out the same height, at any font size.
|
||
if (compact_hide_x_)
|
||
for (auto *axis: chart()->axes(Qt::Horizontal))
|
||
axis->setLabelsColor(Qt::transparent);
|
||
pairCompactYLabels();
|
||
} else if (compact_title_) {
|
||
compact_title_->hide();
|
||
compact_title_w_ = 0;
|
||
}
|
||
}
|
||
|
||
// The name of a half-height plot's y axis. Qt Charts always draws a vertical axis title rotated,
|
||
// which reads awkwardly next to the two numbers it names and is elided to "Sp..." as soon as the
|
||
// font outgrows the plot; paint it upright instead, one letter under the next, in the series'
|
||
// colour. The letters are the tallest thing on the left, so they set the plot's minimum height.
|
||
void JFJochDatasetInfoChartView::updateCompactTitle(const QFont &title_font) {
|
||
if (!compact_title_) {
|
||
compact_title_ = new QGraphicsSimpleTextItem;
|
||
chart()->scene()->addItem(compact_title_);
|
||
connect(chart(), &QChart::plotAreaChanged, this,
|
||
[this](const QRectF &) { placeCompactTitle(); });
|
||
}
|
||
|
||
QString stacked;
|
||
for (const QChar c: compact_label_) {
|
||
if (!stacked.isEmpty())
|
||
stacked += QLatin1Char('\n');
|
||
stacked += c;
|
||
}
|
||
compact_title_->setText(stacked);
|
||
compact_title_->setFont(title_font);
|
||
compact_title_->setBrush(primary_color_.isValid() ? primary_color_
|
||
: palette().color(QPalette::Text));
|
||
compact_title_->show();
|
||
|
||
const QRectF r = compact_title_->boundingRect();
|
||
compact_title_w_ = static_cast<int>(std::ceil(r.width()));
|
||
// Below the plot area sit the x labels and the margins, about two lines of text. The pair is
|
||
// given a common minimum in pairCompactYLabels().
|
||
compact_min_h_ = static_cast<int>(std::ceil(r.height())) + 2 * fontMetrics().height();
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::placeCompactTitle() {
|
||
if (!compact_title_ || !compact_title_->isVisible())
|
||
return;
|
||
// Scene coordinates, like the hover lines: centred on the plot area, in the strip the compact
|
||
// margins keep free for it to the left of the y labels.
|
||
const QRectF plot = chart()->plotArea();
|
||
compact_title_->setPos(2, plot.center().y() - compact_title_->boundingRect().height() / 2);
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::applyCompactMargins() {
|
||
// Qt Charts puts the y labels inside the plot area's left margin, so a plot reading "12345"
|
||
// starts further right than one reading "3". Widen the narrower plot's margin by the
|
||
// difference, which puts both plot areas - and so both curves - at the same left edge.
|
||
const int pad = std::max(0, compact_y_pad_px_ - compact_y_label_px_);
|
||
// 2 px of air on either side of the upright axis name placeCompactTitle() paints in the margin.
|
||
const int title = compact_title_w_ > 0 ? compact_title_w_ + 2 : 0;
|
||
chart()->setMargins(QMargins(2 + title + pad, 4, 4 * fontMetrics().averageCharWidth(), 2));
|
||
placeCompactTitle();
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::setHoverLineY(double y, double snap_to_y) {
|
||
const QRectF plotArea = chart()->plotArea();
|
||
|
||
// Snap to the curve only when the cursor is practically on it, so the line stays a free ruler.
|
||
if (std::isfinite(snap_to_y) && std::abs(snap_to_y - y) < fontMetrics().height() / 2.0)
|
||
y = snap_to_y;
|
||
y = std::clamp(y, plotArea.top(), plotArea.bottom());
|
||
|
||
if (!m_hoverLineHorizontal) {
|
||
m_hoverLineHorizontal = new QGraphicsLineItem;
|
||
m_hoverLineHorizontal->setPen(QPen(GuideLineColor(), 1.0));
|
||
chart()->scene()->addItem(m_hoverLineHorizontal);
|
||
}
|
||
m_hoverLineHorizontal->setLine(QLineF(plotArea.left(), y, plotArea.right(), y));
|
||
}
|
||
|
||
// Give this plot and its stacked neighbour the same y-label width and the same minimum height.
|
||
// Done on every rebuild, which is what keeps the pair matched after a font change: both are
|
||
// measured again in the new font, and whichever plot handles the font change second re-pairs them.
|
||
void JFJochDatasetInfoChartView::pairCompactYLabels() {
|
||
JFJochDatasetInfoChartView *peer =
|
||
(compact_peer_ && !compact_peer_->compact_label_.isEmpty()) ? compact_peer_.data() : nullptr;
|
||
const int px = peer ? std::max(compact_y_label_px_, peer->compact_y_label_px_) : 0;
|
||
|
||
compact_y_pad_px_ = px;
|
||
applyCompactMargins();
|
||
setMinimumHeight(peer ? std::max(compact_min_h_, peer->compact_min_h_) : compact_min_h_);
|
||
if (peer) {
|
||
peer->compact_y_pad_px_ = px;
|
||
peer->applyCompactMargins();
|
||
peer->setMinimumHeight(minimumHeight()); // equal minimums, or the layout splits unevenly
|
||
}
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::SetCompact(const QString &axis_label, bool hide_x_labels) {
|
||
compact_label_ = axis_label;
|
||
compact_hide_x_ = hide_x_labels;
|
||
if (compact_label_.isEmpty())
|
||
compact_y_pad_px_ = 0;
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::buildTimeDomainChart() {
|
||
if (values.size() >= static_cast<size_t>(binning)) {
|
||
// At least one full point
|
||
|
||
series = new QLineSeries(this);
|
||
if (!primaryName_.isEmpty())
|
||
series->setName(primaryName_);
|
||
// Without a run colour (a live HTTP dataset has no run list) the line would keep the
|
||
// default black pen; it takes the first series colour instead.
|
||
series->setColor(primary_color_.isValid() ? primary_color_ : PlotSeriesColor(0));
|
||
setSeriesPenWidth(series, fontMetrics().height());
|
||
currentSeries = new QScatterSeries(this);
|
||
currentSeries->setColor(PlotMarkerColor()); // "current image" marker: not a run colour
|
||
currentSeries->setBorderColor(chart()->backgroundBrush().color()); // a ring of plot background around the dot
|
||
currentSeries->setMarkerSize(9.0);
|
||
|
||
double dispMin = std::numeric_limits<double>::infinity();
|
||
double dispMax = -std::numeric_limits<double>::infinity();
|
||
|
||
appendSeries(series, values, primary_x_, dispMin, dispMax);
|
||
|
||
// Overlay runs share the primary's axes and range; build them now so the Y range fits all.
|
||
std::vector<QLineSeries *> overlayLines;
|
||
overlayLines.reserve(overlays_.size());
|
||
for (const auto &ov: overlays_) {
|
||
auto *line = new QLineSeries(this);
|
||
line->setName(ov.name);
|
||
if (ov.color.isValid())
|
||
line->setColor(ov.color);
|
||
setSeriesPenWidth(line, fontMetrics().height());
|
||
appendSeries(line, ov.values, ov.x, dispMin, dispMax);
|
||
overlayLines.push_back(line);
|
||
}
|
||
|
||
// ---- current point marker as binned value when binning > 1 ----
|
||
if (curr_image >= 0 &&
|
||
curr_image < static_cast<int64_t>(values.size())) {
|
||
|
||
if (binning > 1) {
|
||
const int64_t nBins =
|
||
static_cast<int64_t>(values.size()) / binning;
|
||
if (nBins > 0) {
|
||
int64_t binIdx = curr_image / binning;
|
||
binIdx = std::clamp<int64_t>(binIdx, 0, nBins - 1);
|
||
|
||
double sum = 0.0;
|
||
int64_t count = 0;
|
||
for (int64_t b = 0; b < binning; ++b) {
|
||
const int64_t idx = binIdx * binning + b;
|
||
if (idx >= static_cast<int64_t>(values.size()))
|
||
break;
|
||
const double v = values[static_cast<size_t>(idx)];
|
||
if (std::isfinite(v)) {
|
||
sum += v;
|
||
++count;
|
||
}
|
||
}
|
||
if (count > 0) {
|
||
const double mean =
|
||
sum / static_cast<double>(count);
|
||
if (std::isfinite(mean)) {
|
||
const double centerX =
|
||
(static_cast<double>(binIdx) + 0.5) *
|
||
static_cast<double>(binning);
|
||
currentSeries->append(centerX, mean);
|
||
}
|
||
}
|
||
}
|
||
} else if (std::isfinite(values[static_cast<size_t>(curr_image)])) {
|
||
currentSeries->append(curr_image,
|
||
values[static_cast<size_t>(curr_image)]);
|
||
}
|
||
}
|
||
|
||
chart()->addSeries(series);
|
||
chart()->addSeries(currentSeries);
|
||
chart()->createDefaultAxes();
|
||
|
||
// ----- X axis handling -----
|
||
QValueAxis *axisX = qobject_cast<QValueAxis *>(chart()->axes(Qt::Horizontal, series).value(0));
|
||
if (axisX) {
|
||
// Always span the whole dataset so a subset run shows at its real position, not stretched.
|
||
if (full_range_ > 1)
|
||
axisX->setRange(0, static_cast<double>(full_range_ - 1));
|
||
if (goniometer_axis.has_value() && m_xUseGoniometerAxis) {
|
||
// Hide labels on numeric axis and move it to the top
|
||
|
||
axisX->setTitleText(QString(""));
|
||
axisX->setLabelsVisible(false);
|
||
|
||
// Re-attach numeric axis on top side (default axis on other side)
|
||
chart()->removeAxis(axisX);
|
||
chart()->addAxis(axisX, Qt::AlignTop);
|
||
series->attachAxis(axisX);
|
||
currentSeries->attachAxis(axisX);
|
||
|
||
// Build a visible category axis on the bottom with goniometer angles. No axis
|
||
// title: Qt Charts caps the horizontal axis area at a fraction of the chart
|
||
// height, and in a dock this short the title is elided to nothing at any window
|
||
// size - the degree sign on every tick carries the unit instead.
|
||
auto *axXcat = new QCategoryAxis();
|
||
axXcat->setLabelsPosition(QCategoryAxis::AxisLabelsPositionOnValue);
|
||
axXcat->setGridLineVisible(false);
|
||
axXcat->setMinorGridLineVisible(false);
|
||
|
||
const int tickCountX = std::max(2, axisX->tickCount());
|
||
const double xmin = axisX->min();
|
||
const double xmax = axisX->max();
|
||
const double xstep = (tickCountX > 1) ? (xmax - xmin) / (tickCountX - 1) : 0.0;
|
||
// Label the whole axis, not just the images that have arrived: the goniometer knows
|
||
// the angle of an image before it is collected, so a live run 10% in gets the ticks
|
||
// of the full sweep instead of a tenth of it labelled ten times as finely.
|
||
const int64_t lastIdx = full_range_ > 1
|
||
? full_range_ - 1
|
||
: static_cast<int64_t>(values.empty() ? 0 : values.size() - 1);
|
||
|
||
// Ticks snap to round angles - multiples of 90° when the sweep is wide enough,
|
||
// finer otherwise - instead of whatever angle the evenly-spaced image ticks land
|
||
// on (a 90-450° sweep used to label 359.80). The angle is affine in the image
|
||
// index, so each round angle maps back to a fractional index position.
|
||
QList<QPair<double, QString>> snapped;
|
||
if (lastIdx > 0) {
|
||
const double a0 = goniometer_axis->GetAngle_deg(0);
|
||
const double a1 = goniometer_axis->GetAngle_deg(lastIdx);
|
||
const double lo = std::min(a0, a1), hi = std::max(a0, a1);
|
||
double interval = 0.0;
|
||
for (double cand : {90.0, 45.0, 30.0, 15.0, 10.0, 5.0, 2.0, 1.0, 0.5, 0.2, 0.1})
|
||
if (hi - lo >= 2.0 * cand) { interval = cand; break; }
|
||
if (interval > 0.0) {
|
||
const double per_image = (a1 - a0) / static_cast<double>(lastIdx);
|
||
for (double ang = std::ceil(lo / interval) * interval;
|
||
ang <= hi + interval * 1e-6; ang += interval) {
|
||
const double xv = (ang - a0) / per_image;
|
||
if (xv < xmin || xv > xmax)
|
||
continue;
|
||
snapped.append({xv, QString("%1°").arg(QString::number(ang, 'f', interval < 1.0 ? 1 : 0))});
|
||
}
|
||
std::sort(snapped.begin(), snapped.end(),
|
||
[](const auto &l, const auto &r) { return l.first < r.first; });
|
||
}
|
||
}
|
||
|
||
if (snapped.size() >= 2) {
|
||
for (const auto &t : snapped)
|
||
axXcat->append(t.second, t.first);
|
||
} else {
|
||
for (int i = 0; i < tickCountX; ++i) {
|
||
const double xv = (i == tickCountX - 1) ? xmax : (xmin + i * xstep);
|
||
// Map tick position to closest image index
|
||
int64_t imgIdx = static_cast<int64_t>(std::llround(xv));
|
||
if (imgIdx < 0) imgIdx = 0;
|
||
if (imgIdx > lastIdx) imgIdx = lastIdx;
|
||
|
||
double angleDeg = 0.0;
|
||
if (lastIdx >= 0) {
|
||
angleDeg = goniometer_axis->GetAngle_deg(imgIdx);
|
||
}
|
||
|
||
QString lab = QString("%1°").arg(QString::number(angleDeg, 'f', 2));
|
||
axXcat->append(lab, xv);
|
||
}
|
||
}
|
||
|
||
chart()->addAxis(axXcat, Qt::AlignBottom);
|
||
series->attachAxis(axXcat);
|
||
currentSeries->attachAxis(axXcat);
|
||
} else {
|
||
axisX->setLabelsVisible(true);
|
||
axisX->setTitleText(QStringLiteral("Image number"));
|
||
}
|
||
}
|
||
|
||
// ----- Y-axis handling -----
|
||
QValueAxis *axisY = qobject_cast<QValueAxis *>(chart()->axes(Qt::Vertical, series).value(0));
|
||
if (axisY) {
|
||
if (std::isfinite(dispMin) && std::isfinite(dispMax)) {
|
||
if (m_minYZeroEnabled) {
|
||
const double minY = 0.0;
|
||
const double maxY = (dispMax > minY) ? dispMax : (minY + 1.0);
|
||
axisY->setRange(minY, maxY);
|
||
} else {
|
||
// Default: tight range to data
|
||
if (!(dispMax > dispMin)) {
|
||
// Avoid zero-height range
|
||
dispMax = dispMin + 1.0;
|
||
}
|
||
axisY->setRange(dispMin, dispMax);
|
||
}
|
||
}
|
||
|
||
if (m_yOneOverD) {
|
||
// Keep value axis for numeric range + grid, but move it to the RIGHT
|
||
axisY->setLabelsVisible(false);
|
||
chart()->removeAxis(axisY);
|
||
chart()->addAxis(axisY, Qt::AlignRight);
|
||
series->attachAxis(axisY);
|
||
currentSeries->attachAxis(axisY);
|
||
|
||
// Build a mirrored visible axis with labels in d (Å) on the LEFT
|
||
// No "d (Å)" axis title: every label already carries the unit, and no other plot
|
||
// titles its y axis.
|
||
auto *axYcat = new QCategoryAxis();
|
||
axYcat->setLabelsPosition(QCategoryAxis::AxisLabelsPositionOnValue);
|
||
axYcat->setGridLineVisible(false);
|
||
axYcat->setMinorGridLineVisible(false);
|
||
|
||
const int tickCountY = std::max(2, axisY->tickCount());
|
||
const double ymin = axisY->min();
|
||
const double ymax = axisY->max();
|
||
const double ystep = (tickCountY > 1) ? (ymax - ymin) / (tickCountY - 1) : 0.0;
|
||
|
||
for (int i = 0; i < tickCountY; ++i) {
|
||
const double yv = (i == tickCountY - 1) ? ymax : (ymin + i * ystep);
|
||
QString lab;
|
||
if (!(yv > 0.0)) {
|
||
lab = QStringLiteral("—"); // invalid for d
|
||
} else if (std::abs(yv) < 1e-300) {
|
||
lab = QStringLiteral("∞");
|
||
} else {
|
||
const double d = 1.0 / std::sqrt(yv);
|
||
lab = QString("%1 Å").arg(d, 0, 'f', 2);
|
||
}
|
||
axYcat->append(lab, yv);
|
||
}
|
||
|
||
chart()->addAxis(axYcat, Qt::AlignLeft);
|
||
series->attachAxis(axYcat);
|
||
currentSeries->attachAxis(axYcat);
|
||
|
||
// Give a bit more room on the left so labels are not clipped
|
||
QMargins m = chart()->margins();
|
||
if (m.left() < 12) {
|
||
m.setLeft(12);
|
||
chart()->setMargins(m);
|
||
}
|
||
} else {
|
||
// Normal numeric labels, axis on the LEFT
|
||
chart()->removeAxis(axisY);
|
||
chart()->addAxis(axisY, Qt::AlignLeft);
|
||
series->attachAxis(axisY);
|
||
currentSeries->attachAxis(axisY);
|
||
|
||
axisY->setTitleText(QString());
|
||
axisY->setLabelsVisible(true);
|
||
}
|
||
}
|
||
|
||
// Attach overlay lines to the primary series' final axes; show the legend when overlaying.
|
||
const auto finalAxes = series->attachedAxes();
|
||
for (auto *line: overlayLines) {
|
||
chart()->addSeries(line);
|
||
for (auto *ax: finalAxes)
|
||
line->attachAxis(ax);
|
||
}
|
||
|
||
// The current-image marker is not a run - keep it out of the legend.
|
||
for (auto *marker: chart()->legend()->markers(currentSeries))
|
||
marker->setVisible(false);
|
||
// Only overlay runs get a legend.
|
||
chart()->legend()->setVisible(!overlays_.empty());
|
||
chart()->legend()->setAlignment(Qt::AlignBottom);
|
||
StyleChartAxes(chart());
|
||
}
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::setBinning(int64_t val) {
|
||
if (val >= 1) {
|
||
binning = val;
|
||
updateChart();
|
||
}
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::changeEvent(QEvent *event) {
|
||
QChartView::changeEvent(event);
|
||
if (event->type() == QEvent::FontChange)
|
||
updateChart(); // axis room and the curve's pen are both taken from the font
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::contextMenuEvent(QContextMenuEvent *event) {
|
||
QMenu menu(this);
|
||
QAction *copyXY = menu.addAction("Copy (x y) points");
|
||
copyXY->setEnabled(!values.empty());
|
||
|
||
QAction *sep1 = menu.addSeparator();
|
||
Q_UNUSED(sep1);
|
||
|
||
QAction *actMinYZero = menu.addAction("Y min at 0");
|
||
actMinYZero->setCheckable(true);
|
||
actMinYZero->setChecked(m_minYZeroEnabled);
|
||
|
||
QAction *actXGoniometer = menu.addAction("Use goniometer X-axis");
|
||
actXGoniometer->setCheckable(true);
|
||
actXGoniometer->setChecked(m_xUseGoniometerAxis);
|
||
actXGoniometer->setEnabled(goniometer_axis.has_value());
|
||
|
||
// Binning sub‑menu (values are defined only once here)
|
||
QMenu *binMenu = menu.addMenu("Binning");
|
||
|
||
const std::array<int, 8> binValues{1, 5, 10, 25, 50, 100, 250, 1000};
|
||
QList<QAction *> binActions;
|
||
binActions.reserve(static_cast<int>(binValues.size()));
|
||
|
||
for (int v : binValues) {
|
||
QAction *act = binMenu->addAction(QString::number(v));
|
||
act->setCheckable(true);
|
||
act->setChecked(binning == v);
|
||
act->setData(v); // remember which bin this action represents
|
||
binActions.push_back(act);
|
||
}
|
||
|
||
#ifdef JFJOCH_USE_FFTW
|
||
QAction *actShowFFT = menu.addAction("Show FFT (amplitude vs Hz)");
|
||
actShowFFT->setCheckable(true);
|
||
actShowFFT->setChecked(m_showFFT);
|
||
// Require valid sampling interval
|
||
actShowFFT->setEnabled(!values.empty() && image_time_s > 0.0);
|
||
#endif
|
||
|
||
QAction *chosen = menu.exec(event->globalPos());
|
||
if (chosen == copyXY) {
|
||
QString out;
|
||
out.reserve(static_cast<int>(values.size() * 16)); // rough prealloc
|
||
for (size_t i = 0; i < values.size(); ++i) {
|
||
out.append(QString::number(i));
|
||
out.append(' ');
|
||
out.append(QString::number(values[i], 'g', 10));
|
||
if (i + 1 < values.size()) out.append('\n');
|
||
}
|
||
QClipboard *cb = QApplication::clipboard();
|
||
cb->setText(out);
|
||
} else if (chosen == actMinYZero) {
|
||
m_minYZeroEnabled = !m_minYZeroEnabled;
|
||
updateChart();
|
||
} else if (chosen == actXGoniometer) {
|
||
m_xUseGoniometerAxis = !m_xUseGoniometerAxis;
|
||
updateChart();
|
||
} else if (binActions.contains(chosen)) {
|
||
// Any binning action selected: read the bin value from QAction::data
|
||
bool ok = false;
|
||
int v = chosen->data().toInt(&ok);
|
||
if (ok && v >= 1) {
|
||
setBinning(v);
|
||
}
|
||
#ifdef JFJOCH_USE_FFTW
|
||
} else if (chosen == actShowFFT) {
|
||
m_showFFT = !m_showFFT;
|
||
updateChart();
|
||
#endif
|
||
}
|
||
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::mouseMoveEvent(QMouseEvent *event) {
|
||
QChartView::mouseMoveEvent(event);
|
||
if (!series || values.empty())
|
||
return;
|
||
|
||
#ifdef JFJOCH_USE_FFTW
|
||
if (m_showFFT && !m_fftFrequenciesHz.empty()) {
|
||
// FFT mode: x is frequency in Hz
|
||
const QPointF chartPos = chart()->mapToValue(event->pos(), series);
|
||
double f = chartPos.x();
|
||
if (!std::isfinite(f))
|
||
return;
|
||
|
||
// If we only have DC, nothing meaningful to show
|
||
if (m_fftFrequenciesHz.size() <= 1)
|
||
return;
|
||
|
||
// Find nearest FFT bin, excluding k = 0 (DC component)
|
||
int64_t bestIdx = -1;
|
||
double bestDiff = std::numeric_limits<double>::infinity();
|
||
for (size_t i = 1; i < m_fftFrequenciesHz.size(); ++i) {
|
||
const double diff = std::abs(m_fftFrequenciesHz[i] - f);
|
||
if (diff < bestDiff) {
|
||
bestDiff = diff;
|
||
bestIdx = static_cast<int64_t>(i);
|
||
}
|
||
}
|
||
if (bestIdx < 1)
|
||
return;
|
||
|
||
const double fBin = m_fftFrequenciesHz[static_cast<size_t>(bestIdx)];
|
||
const double amp = m_fftMagnitudes[static_cast<size_t>(bestIdx)];
|
||
|
||
// Map the bin's (frequency, amplitude) to scene coords for the crosshair.
|
||
const QRectF plotArea = chart()->plotArea();
|
||
const QPointF ptOnChart = chart()->mapToPosition(QPointF(fBin, amp), series);
|
||
|
||
if (!m_hoverLine) {
|
||
m_hoverLine = new QGraphicsLineItem;
|
||
m_hoverLine->setPen(QPen(GuideLineColor(), 1.0));
|
||
chart()->scene()->addItem(m_hoverLine);
|
||
}
|
||
m_hoverLine->setLine(QLineF(ptOnChart.x(), plotArea.top(),
|
||
ptOnChart.x(), plotArea.bottom()));
|
||
setHoverLineY(event->pos().y(), ptOnChart.y());
|
||
|
||
QString text = QString("f = %1 Hz, amplitude = %2")
|
||
.arg(fBin, 0, 'g', 6)
|
||
.arg(amp, 0, 'g', 6);
|
||
emit writeStatusBar(text, 6000);
|
||
|
||
// No image loading in FFT mode
|
||
m_hoverLoadTimer->stop();
|
||
m_hoverPendingIdx = -1;
|
||
return;
|
||
}
|
||
#endif
|
||
|
||
if (values.empty())
|
||
return;
|
||
|
||
// Map mouse position to chart coordinates
|
||
const QPointF chartPos = chart()->mapToValue(event->pos(), series);
|
||
const double xVal = chartPos.x();
|
||
|
||
if (!std::isfinite(xVal) || xVal < 0.0 ||
|
||
xVal > static_cast<double>(values.size() - 1)) {
|
||
return;
|
||
}
|
||
|
||
int64_t idx = 0;
|
||
double yv = std::numeric_limits<double>::quiet_NaN();
|
||
|
||
if (binning <= 1) {
|
||
// Original behavior: nearest frame index and per-image value
|
||
idx = std::lround(xVal);
|
||
if (idx < 0 || idx >= static_cast<int64_t>(values.size()))
|
||
return;
|
||
yv = values[static_cast<size_t>(idx)];
|
||
} else {
|
||
// Binned mode: map x to bin, then use bin mean as the "current point"
|
||
const int64_t nBins =
|
||
static_cast<int64_t>(values.size()) / binning;
|
||
if (nBins <= 0)
|
||
return;
|
||
|
||
int64_t binIdx =
|
||
static_cast<int64_t>(std::floor(xVal / static_cast<double>(binning)));
|
||
binIdx = std::clamp<int64_t>(binIdx, 0, nBins - 1);
|
||
|
||
// Representative frame index for status text & image loading
|
||
int64_t centerIdx = binIdx * binning + binning / 2;
|
||
if (centerIdx >= static_cast<int64_t>(values.size()))
|
||
centerIdx = static_cast<int64_t>(values.size()) - 1;
|
||
|
||
idx = centerIdx;
|
||
|
||
// Compute bin mean for hover display / "current" value
|
||
double sum = 0.0;
|
||
int64_t count = 0;
|
||
for (int64_t b = 0; b < binning; ++b) {
|
||
const int64_t vIdx = binIdx * binning + b;
|
||
if (vIdx >= static_cast<int64_t>(values.size()))
|
||
break;
|
||
const double v = values[static_cast<size_t>(vIdx)];
|
||
if (std::isfinite(v)) {
|
||
sum += v;
|
||
++count;
|
||
}
|
||
}
|
||
if (count > 0)
|
||
yv = sum / static_cast<double>(count);
|
||
else
|
||
yv = std::numeric_limits<double>::quiet_NaN();
|
||
}
|
||
|
||
if (idx < 0 || idx >= static_cast<int64_t>(values.size()))
|
||
return;
|
||
|
||
// Map that x position to scene coords for the vertical line.
|
||
// In binned mode this is the bin center index, in unbinned mode the exact frame.
|
||
const QRectF plotArea = chart()->plotArea();
|
||
const QPointF ptOnChart =
|
||
chart()->mapToPosition(QPointF(static_cast<double>(idx), 0.0), series);
|
||
|
||
if (!m_hoverLine) {
|
||
m_hoverLine = new QGraphicsLineItem;
|
||
m_hoverLine->setPen(QPen(GuideLineColor(), 1.0));
|
||
chart()->scene()->addItem(m_hoverLine);
|
||
}
|
||
|
||
m_hoverLine->setLine(QLineF(ptOnChart.x(), plotArea.top(),
|
||
ptOnChart.x(), plotArea.bottom()));
|
||
|
||
// Horizontal crosshair at the cursor, so any level on the plot - not only the curve - can be
|
||
// read off and compared against the rest of the run.
|
||
setHoverLineY(event->pos().y(),
|
||
std::isfinite(yv)
|
||
? chart()->mapToPosition(QPointF(static_cast<double>(idx), yv), series).y()
|
||
: std::numeric_limits<double>::quiet_NaN());
|
||
|
||
// Status bar text based on yv (bin mean in binned mode)
|
||
QString text;
|
||
if (m_yOneOverD) {
|
||
if (std::isfinite(yv) && yv > 0.0) {
|
||
const double d = 1.0 / std::sqrt(yv);
|
||
text = QString("image = %1 d = %2 Å")
|
||
.arg(idx)
|
||
.arg(d, 0, 'f', 2);
|
||
} else {
|
||
text = QString("image = %1, no resolution estimate").arg(idx);
|
||
}
|
||
} else {
|
||
if (std::isfinite(yv)) {
|
||
text = QString("image = %1 value = %2")
|
||
.arg(idx)
|
||
.arg(yv, 0, 'g', 6);
|
||
} else {
|
||
text = QString("image = %1, no value").arg(idx);
|
||
}
|
||
}
|
||
emit writeStatusBar(text, 6000);
|
||
|
||
// Debounced image load on hover when Shift is pressed
|
||
if (event->modifiers() & Qt::ShiftModifier) {
|
||
if (!m_hoverLoadTimer->isActive()) {
|
||
m_hoverPendingIdx = -1;
|
||
if (idx != curr_image)
|
||
emit imageSelected(idx);
|
||
m_hoverLoadTimer->start(500); // debounce
|
||
} else {
|
||
m_hoverPendingIdx = idx;
|
||
}
|
||
} else {
|
||
m_hoverLoadTimer->stop();
|
||
m_hoverPendingIdx = -1;
|
||
}
|
||
}
|
||
|
||
|
||
void JFJochDatasetInfoChartView::leaveEvent(QEvent *event) {
|
||
QChartView::leaveEvent(event);
|
||
if (m_hoverLine) {
|
||
chart()->scene()->removeItem(m_hoverLine);
|
||
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);
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::onHoverLoadTimeout() {
|
||
if (!(QApplication::keyboardModifiers() & Qt::ShiftModifier))
|
||
return;
|
||
|
||
if (m_hoverPendingIdx >= 0 &&
|
||
m_hoverPendingIdx < static_cast<int64_t>(values.size())) {
|
||
if (m_hoverPendingIdx != curr_image) {
|
||
emit imageSelected(m_hoverPendingIdx);
|
||
}
|
||
}
|
||
}
|
||
|
||
#ifdef JFJOCH_USE_FFTW
|
||
void JFJochDatasetInfoChartView::buildFFTChart() {
|
||
const size_t N = values.size();
|
||
|
||
if (N == 0 || !image_time_s.has_value() || image_time_s <= 0.0) {
|
||
return;
|
||
}
|
||
|
||
// Prepare input buffer (single precision, NaN/inf treated as 0)
|
||
std::vector<float> in(N, 0.0f);
|
||
for (size_t i = 0; i < N; ++i) {
|
||
const double v = values[i];
|
||
in[i] = std::isfinite(v) ? static_cast<float>(v) : 0.0f;
|
||
}
|
||
|
||
const int n = static_cast<int>(N);
|
||
const int nComplex = n / 2 + 1;
|
||
|
||
std::vector<std::complex<float>> out(static_cast<size_t>(nComplex));
|
||
|
||
fftwf_plan plan;
|
||
{
|
||
std::lock_guard lock(FFTWPlannerMutex());
|
||
plan = fftwf_plan_dft_r2c_1d(
|
||
n,
|
||
in.data(),
|
||
reinterpret_cast<fftwf_complex *>(out.data()),
|
||
FFTW_ESTIMATE);
|
||
}
|
||
|
||
if (!plan) {
|
||
return;
|
||
}
|
||
|
||
fftwf_execute(plan);
|
||
{
|
||
std::lock_guard lock(FFTWPlannerMutex());
|
||
fftwf_destroy_plan(plan);
|
||
}
|
||
|
||
// Compute amplitude spectrum and frequencies (0 .. Nyquist)
|
||
m_fftMagnitudes.resize(static_cast<size_t>(nComplex));
|
||
m_fftFrequenciesHz.resize(static_cast<size_t>(nComplex));
|
||
|
||
const double dt = image_time_s.value(); // seconds per sample
|
||
const double fs = 1.0 / dt; // sampling frequency
|
||
const double df = fs / static_cast<double>(n); // frequency resolution
|
||
|
||
for (int k = 0; k < nComplex; ++k) {
|
||
const double re = out[static_cast<size_t>(k)].real();
|
||
const double im = out[static_cast<size_t>(k)].imag();
|
||
const double mag = std::hypot(re, im); // amplitude
|
||
|
||
m_fftMagnitudes[static_cast<size_t>(k)] = mag;
|
||
m_fftFrequenciesHz[static_cast<size_t>(k)] = static_cast<double>(k) * df;
|
||
}
|
||
|
||
// Build chart series: X = frequency (Hz), Y = amplitude
|
||
series = new QLineSeries(this);
|
||
currentSeries = nullptr; // no "current image" marker in FFT mode
|
||
|
||
double magMin = std::numeric_limits<double>::infinity();
|
||
double magMax = -std::numeric_limits<double>::infinity();
|
||
|
||
for (int k = 1; k < nComplex; ++k) {
|
||
const double f = m_fftFrequenciesHz[static_cast<size_t>(k)];
|
||
const double mag = m_fftMagnitudes[static_cast<size_t>(k)];
|
||
series->append(f, mag);
|
||
if (mag < magMin) magMin = mag;
|
||
if (mag > magMax) magMax = mag;
|
||
}
|
||
|
||
chart()->addSeries(series);
|
||
chart()->createDefaultAxes();
|
||
|
||
QValueAxis *axisX = qobject_cast<QValueAxis *>(chart()->axes(Qt::Horizontal, series).value(0));
|
||
QValueAxis *axisY = qobject_cast<QValueAxis *>(chart()->axes(Qt::Vertical, series).value(0));
|
||
|
||
if (axisX) {
|
||
axisX->setTitleText(QStringLiteral("Frequency (Hz)"));
|
||
axisX->setLabelsVisible(true);
|
||
}
|
||
|
||
if (axisY) {
|
||
if (std::isfinite(magMin) && std::isfinite(magMax)) {
|
||
if (!(magMax > magMin)) {
|
||
magMax = magMin + 1.0;
|
||
}
|
||
axisY->setRange(magMin, magMax);
|
||
}
|
||
axisY->setTitleText(QStringLiteral("Amplitude"));
|
||
axisY->setLabelsVisible(true);
|
||
}
|
||
StyleChartAxes(chart());
|
||
}
|
||
#endif
|
||
|