jfjoch_viewer: Better display (to be tested) of pixel refine
Build Packages / Unit tests (push) Failing after 1s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 25m52s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 29m5s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 29m54s
Build Packages / build:rpm (rocky8) (push) Successful in 31m55s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 32m12s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 32m48s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 35m27s
Build Packages / Generate python client (push) Successful in 25s
Build Packages / build:rpm (rocky9) (push) Successful in 31m59s
Build Packages / Create release (push) Skipped
Build Packages / Build documentation (push) Successful in 1m36s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 24m8s
Build Packages / XDS test (neggia plugin) (push) Successful in 17m46s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 21m36s
Build Packages / XDS test (durin plugin) (push) Successful in 19m40s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 19m38s
Build Packages / DIALS test (push) Successful in 26m30s

This commit is contained in:
2026-06-09 16:28:17 +02:00
parent 6c85aaba2b
commit efe882f4b6
10 changed files with 282 additions and 4 deletions
@@ -993,6 +993,122 @@ std::vector<float> PixelRefine::PredictImage(const AzimuthalIntegrationProfile &
return img;
}
template<class T>
std::vector<float> PixelRefine::ChiSquaredImage(const T *image,
const AzimuthalIntegrationProfile &profile,
BraggPrediction &prediction,
const PixelRefineData &data) const {
std::vector<float> img(xpixel * ypixel, 0.0f);
const double lambda = data.geom.GetWavelength_A();
const double pixel_size = data.geom.GetPixelSize_mm();
const auto azim_result = profile.GetResult();
const auto azim_std = profile.GetStd();
const auto &pixel_to_bin = mapping.GetPixelToBin();
const auto &corrections = mapping.Corrections();
const int total_bin_count = static_cast<int>(azim_result.size());
const double angle_rad = data.angle_deg * M_PI / 180.0;
const int radius = data.shoebox_radius;
const double bw = data.bandwidth;
auto recip_area = [&](double x, double y) -> double {
const Coord qx = data.geom.DetectorToRecip(x + 0.5, y) - data.geom.DetectorToRecip(x - 0.5, y);
const Coord qy = data.geom.DetectorToRecip(x, y + 0.5) - data.geom.DetectorToRecip(x, y - 0.5);
return (qx % qy).Length();
};
auto bandwidth_radial_sq = [&](double d) -> double {
if (bw <= 0.0 || d <= 0.0)
return 0.0;
const double bl = bw * lambda;
return bl * bl / (2.0 * d * d * d * d);
};
double beam[2], dist_mm, detector_rot[2], rot_vec[3];
double latt_vec0[3], latt_vec1[3], latt_vec2[3];
BuildParameterBlocks(data, beam, dist_mm, detector_rot, rot_vec, latt_vec0, latt_vec1, latt_vec2);
DiffractionExperiment exp_iter = experiment;
exp_iter.BeamX_pxl(data.geom.GetBeamX_pxl())
.BeamY_pxl(data.geom.GetBeamY_pxl())
.DetectorDistance_mm(data.geom.GetDetectorDistance_mm())
.PoniRot1_rad(data.geom.GetPoniRot1_rad())
.PoniRot2_rad(data.geom.GetPoniRot2_rad());
const BraggPredictionSettings settings_prediction{
.high_res_A = experiment.GetBraggIntegrationSettings().GetDMinLimit_A(),
.max_hkl = 100,
.centering = data.centering,
.bandwidth_sigma = static_cast<float>(data.bandwidth)
};
const int nrefl = prediction.Calc(exp_iter, data.latt, settings_prediction);
const auto &predicted = prediction.GetReflections();
for (int ri = 0; ri < nrefl; ++ri) {
const auto &refl = predicted[ri];
const auto it = reference_data.find(hkl_key_generator(refl));
if (it == reference_data.end())
continue;
const double Itrue = it->second;
const double R_bw_sq = bandwidth_radial_sq(refl.d);
const int min_y = std::max<int>(refl.predicted_y - radius, 0);
const int max_y = std::min<int>(refl.predicted_y + radius, ypixel - 1);
const int min_x = std::max<int>(refl.predicted_x - radius, 0);
const int max_x = std::min<int>(refl.predicted_x + radius, xpixel - 1);
for (int y = min_y; y <= max_y; ++y) {
for (int x = min_x; x <= max_x; ++x) {
const size_t npixel = xpixel * y + x;
const int azim_bin = pixel_to_bin[npixel];
// Same gating as Run(): only pixels that actually enter the fit.
if (azim_bin >= total_bin_count)
continue;
if (image[npixel] == std::numeric_limits<T>::max())
continue;
if (std::is_signed_v<T> && (image[npixel] == std::numeric_limits<T>::min()))
continue;
const double correction = corrections[npixel];
const double Ibkg = azim_result[azim_bin];
const double Ibkg_sigma = azim_std[azim_bin];
const double raw = static_cast<double>(image[npixel]);
const double Iobs = raw * correction;
double var = correction * std::max(Iobs, 0.0) + Ibkg_sigma * Ibkg_sigma;
if (!(var > 1.0))
var = 1.0;
const double weight = 1.0 / std::sqrt(var);
PixelObs obs{
.x = static_cast<double>(x),
.y = static_cast<double>(y),
.Iobs = Iobs,
.Ibkg = Ibkg,
.weight = weight,
.A_recip = recip_area(x, y),
.angle_rad = angle_rad
};
PixelResidual pr(obs, Itrue, lambda, pixel_size,
refl.h, refl.k, refl.l, R_bw_sq, data.crystal_system);
double Ipred = 0.0;
if (pr.Model(beam, &dist_mm, detector_rot, rot_vec,
latt_vec0, latt_vec1, latt_vec2,
&data.scale_factor, &data.B_factor, data.R, Ipred)) {
// residual_i = (I_pred - I_obs) * weight (== Ceres residual);
// its square is this pixel's contribution to the cost.
const double rw = (Ipred - Iobs) * weight;
img[npixel] += static_cast<float>(rw * rw);
}
}
}
}
return img;
}
// Explicit instantiations for the supported (uncompressed) image pixel types.
template void PixelRefine::Run<int8_t>(const int8_t *, const AzimuthalIntegrationProfile &, BraggPrediction &, PixelRefineData &);
template void PixelRefine::Run<int16_t>(const int16_t *, const AzimuthalIntegrationProfile &, BraggPrediction &, PixelRefineData &);
@@ -1000,3 +1116,10 @@ template void PixelRefine::Run<int32_t>(const int32_t *, const AzimuthalIntegrat
template void PixelRefine::Run<uint8_t>(const uint8_t *, const AzimuthalIntegrationProfile &, BraggPrediction &, PixelRefineData &);
template void PixelRefine::Run<uint16_t>(const uint16_t *, const AzimuthalIntegrationProfile &, BraggPrediction &, PixelRefineData &);
template void PixelRefine::Run<uint32_t>(const uint32_t *, const AzimuthalIntegrationProfile &, BraggPrediction &, PixelRefineData &);
template std::vector<float> PixelRefine::ChiSquaredImage<int8_t>(const int8_t *, const AzimuthalIntegrationProfile &, BraggPrediction &, const PixelRefineData &) const;
template std::vector<float> PixelRefine::ChiSquaredImage<int16_t>(const int16_t *, const AzimuthalIntegrationProfile &, BraggPrediction &, const PixelRefineData &) const;
template std::vector<float> PixelRefine::ChiSquaredImage<int32_t>(const int32_t *, const AzimuthalIntegrationProfile &, BraggPrediction &, const PixelRefineData &) const;
template std::vector<float> PixelRefine::ChiSquaredImage<uint8_t>(const uint8_t *, const AzimuthalIntegrationProfile &, BraggPrediction &, const PixelRefineData &) const;
template std::vector<float> PixelRefine::ChiSquaredImage<uint16_t>(const uint16_t *, const AzimuthalIntegrationProfile &, BraggPrediction &, const PixelRefineData &) const;
template std::vector<float> PixelRefine::ChiSquaredImage<uint32_t>(const uint32_t *, const AzimuthalIntegrationProfile &, BraggPrediction &, const PixelRefineData &) const;
@@ -182,4 +182,16 @@ public:
BraggPrediction &prediction,
const PixelRefineData &data,
bool include_background = true) const;
// Render the per-pixel chi-square (cost density) that the optimizer actually
// minimizes: for every shoebox pixel that enters the fit it stores the squared
// weighted residual ((I_pred - I_obs)/sigma)^2 in *corrected* units - identical
// to the Ceres residual_i^2 - accumulating where shoeboxes overlap. Pixels that
// are not part of any shoebox stay 0; masked/saturated pixels (skipped by the
// fit) also stay 0. Summing the image gives ~2*final_cost. Diagnostic tool.
template<class T>
std::vector<float> ChiSquaredImage(const T *image,
const AzimuthalIntegrationProfile &profile,
BraggPrediction &prediction,
const PixelRefineData &data) const;
};
+76 -4
View File
@@ -8,6 +8,7 @@
#include <cstring>
#include "JFJochImageReadingWorker.h"
#include "../reader/JFJochReaderImage.h" // JFJochReaderImage + GAP/ERROR/SATURATED sentinels
#include "../image_analysis/LoadFCalcFromMtz.h"
#include "../image_analysis/bragg_prediction/BraggPredictionFactory.h"
#include "../image_analysis/geom_refinement/AssignSpotsToRings.h"
@@ -69,6 +70,7 @@ JFJochImageReadingWorker::JFJochImageReadingWorker(const SpotFindingSettings &se
indexing_settings(experiment.GetIndexingSettings()),
azint_settings(experiment.GetAzimuthalIntegrationSettings()) {
qRegisterMetaType<PixelRefineParams>("PixelRefineParams");
qRegisterMetaType<QVector<QRect>>("QVector<QRect>");
spot_finding_settings = settings;;
indexing = std::make_unique<IndexerThreadPool>(indexing_settings);
@@ -798,6 +800,74 @@ std::shared_ptr<SimpleImage> JFJochImageReadingWorker::WrapFloatImage_i(const st
return si;
}
void JFJochImageReadingWorker::SquaredResidualWithImage_i(std::vector<float> &pred) const {
// PredictImage() returns raw detector units (same as the measured counts), so
// pred - measured is the per-pixel residual the model fails to explain. We plot
// |pred - measured|^2: sign-free, so it needs no diverging colour scale and just
// highlights where the model disagrees most. Masked / saturated pixels carry
// sentinels rather than counts, so no comparison is possible -> NaN (gap).
if (!current_image_ptr)
return;
const auto &img = current_image_ptr->Image();
const size_t n = std::min(pred.size(), img.size());
for (size_t i = 0; i < n; ++i) {
const int32_t v = img[i];
if (v == GAP_PXL_VALUE || v == ERROR_PXL_VALUE || v == SATURATED_PXL_VALUE) {
pred[i] = NAN;
} else {
const float diff = pred[i] - static_cast<float>(v);
pred[i] = diff * diff;
}
}
}
void JFJochImageReadingWorker::MaskMeasuredSentinels_i(std::vector<float> &img) const {
// The chi^2 image is 0 outside shoeboxes; show masked/saturated pixels as a gap
// (NaN) instead, so they read as "not comparable" rather than "zero cost".
if (!current_image_ptr)
return;
const auto &measured = current_image_ptr->Image();
const size_t n = std::min(img.size(), measured.size());
for (size_t i = 0; i < n; ++i) {
const int32_t v = measured[i];
if (v == GAP_PXL_VALUE || v == ERROR_PXL_VALUE || v == SATURATED_PXL_VALUE)
img[i] = NAN;
}
}
QVector<QRect> JFJochImageReadingWorker::BuildShoeboxes_i(const PixelRefineData &data) const {
// One rectangle per fitted reflection: the shoebox the optimizer summed over,
// centred on the predicted position with half-size data.shoebox_radius.
QVector<QRect> boxes;
boxes.reserve(static_cast<int>(data.reflections.size()));
const int r = data.shoebox_radius;
const int side = 2 * r + 1;
for (const auto &refl : data.reflections) {
if (!std::isfinite(refl.predicted_x) || !std::isfinite(refl.predicted_y))
continue;
const int cx = static_cast<int>(std::lround(refl.predicted_x));
const int cy = static_cast<int>(std::lround(refl.predicted_y));
boxes.push_back(QRect(cx - r, cy - r, side, side));
}
return boxes;
}
std::vector<float> JFJochImageReadingWorker::BuildDisplayImage_i(const PixelRefineData &data,
int display_mode) const {
if (display_mode == PixelRefineParams::ChiSquared) {
// The cost density the optimizer actually minimizes (weighted residual^2).
const auto &img32 = current_image_ptr->Image();
auto chi2 = pixel_refine_->ChiSquaredImage<int32_t>(img32.data(), *last_profile_, *pixel_pred_, data);
MaskMeasuredSentinels_i(chi2);
return chi2;
}
auto pred = pixel_refine_->PredictImage(*last_profile_, *pixel_pred_, data, true);
if (display_mode == PixelRefineParams::SquaredDifference)
SquaredResidualWithImage_i(pred);
return pred;
}
void JFJochImageReadingWorker::LoadReference(QString path) {
QMutexLocker ul(&m);
try {
@@ -840,8 +910,9 @@ void JFJochImageReadingWorker::PixelRefinePreview(PixelRefineParams params) {
pixel_refine_->Run<int32_t>(img32.data(), *last_profile_, *pixel_pred_, d);
emit pixelRefineResidual(d.final_cost, d.cc, static_cast<int64_t>(d.reflections.size()));
auto pred = pixel_refine_->PredictImage(*last_profile_, *pixel_pred_, d, true);
emit predictedImageReady(WrapFloatImage_i(pred));
auto display = BuildDisplayImage_i(d, params.display_mode);
emit predictedImageReady(WrapFloatImage_i(display));
emit predictedShoeboxes(BuildShoeboxes_i(d));
} catch (const std::exception &e) {
emit pixelRefineStatus(QString("PixelRefine preview failed: %1").arg(e.what()));
}
@@ -884,8 +955,9 @@ void JFJochImageReadingWorker::PixelRefineRun(PixelRefineParams params) {
emit pixelRefineParamsRefined(out);
emit pixelRefineResidual(d.final_cost, d.cc, static_cast<int64_t>(d.reflections.size()));
auto pred = pixel_refine_->PredictImage(*last_profile_, *pixel_pred_, d, true);
emit predictedImageReady(WrapFloatImage_i(pred));
auto display = BuildDisplayImage_i(d, params.display_mode);
emit predictedImageReady(WrapFloatImage_i(display));
emit predictedShoeboxes(BuildShoeboxes_i(d));
// Show the refined predictions on the main image too.
auto new_image = std::make_shared<JFJochReaderImage>(*current_image_ptr);
+12
View File
@@ -71,6 +71,17 @@ private:
void EnsurePixelRefine_i();
bool BuildPixelSeed_i(PixelRefineData &d, const PixelRefineParams &p, QString &reason) const;
std::shared_ptr<SimpleImage> WrapFloatImage_i(const std::vector<float> &img) const;
// Turn a predicted image into the squared residual |predicted - measured|^2 in
// place. Masked/saturated pixels become NaN (rendered as a gap: no comparison
// possible), not 0.
void SquaredResidualWithImage_i(std::vector<float> &pred) const;
// Mark masked/saturated pixels of the current image as NaN (gap) in a float
// image, leaving the rest untouched (used for the chi^2 view).
void MaskMeasuredSentinels_i(std::vector<float> &img) const;
// Build the per-reflection shoebox rectangles for the last refine/preview.
QVector<QRect> BuildShoeboxes_i(const PixelRefineData &data) const;
// Build the float image to display for the given PixelRefineParams::DisplayMode.
std::vector<float> BuildDisplayImage_i(const PixelRefineData &data, int display_mode) const;
std::unique_ptr<ROIElement> roi;
@@ -134,6 +145,7 @@ signals:
// PixelRefine (experimental)
void predictedImageReady(std::shared_ptr<const SimpleImage> image);
void predictedShoeboxes(QVector<QRect> boxes); // per-reflection optimization windows
void pixelRefineResidual(double cost, double cc, int64_t n_reflections);
void pixelRefineParamsRefined(PixelRefineParams params);
void pixelRefineStatus(QString message);
+3
View File
@@ -28,6 +28,7 @@
#include "windows/JFJochPixelRefineWindow.h"
#include "windows/JFJochMagnifierWindow.h"
#include "image_viewer/JFJochImage.h"
#include "image_viewer/JFJochSimpleImage.h"
#include <QMessageBox>
JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString &file) : QMainWindow(parent) {
@@ -351,6 +352,8 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString
reading_worker, &JFJochImageReadingWorker::LoadReference);
connect(reading_worker, &JFJochImageReadingWorker::predictedImageReady,
pixelRefineWindow, &JFJochPixelRefineWindow::setPredictedImage);
connect(reading_worker, &JFJochImageReadingWorker::predictedShoeboxes,
pixelRefineWindow->imageView(), &JFJochSimpleImage::setShoeboxes);
connect(reading_worker, &JFJochImageReadingWorker::pixelRefineResidual,
pixelRefineWindow, &JFJochPixelRefineWindow::setResidual);
connect(reading_worker, &JFJochImageReadingWorker::pixelRefineParamsRefined,
+25
View File
@@ -40,6 +40,31 @@ void JFJochSimpleImage::setImage(std::shared_ptr<const SimpleImage> img) {
}
}
void JFJochSimpleImage::setShoeboxes(QVector<QRect> boxes) {
shoeboxes_ = std::move(boxes);
// Redraw overlays on the current image (no-op if no image yet).
updateOverlay();
}
void JFJochSimpleImage::addCustomOverlay() {
if (shoeboxes_.isEmpty() || !scene())
return;
// Cosmetic 1-px outline so the box edges stay thin at any zoom; only draw the
// ones currently in view (there can be hundreds of reflections).
const QRectF visibleRect = mapToScene(viewport()->geometry()).boundingRect();
QPen pen(QColor(0, 220, 255), 0); // cyan, distinct from the prediction colours
pen.setCosmetic(true);
for (const QRect &b : shoeboxes_) {
const QRectF r(b.x(), b.y(), b.width(), b.height());
if (!visibleRect.intersects(r))
continue;
auto *item = scene()->addRect(r, pen);
addOverlayItem(item);
}
}
void JFJochSimpleImage::mouseHover(QMouseEvent *event) {
if (image_) {
const QPointF scenePos = mapToScene(event->pos());
+8
View File
@@ -7,6 +7,7 @@
#include <QVector>
#include <QColor>
#include <QPointF>
#include <QRect>
#include <QGraphicsTextItem>
#include <vector>
#include <QTimer>
@@ -18,14 +19,21 @@ class JFJochSimpleImage : public JFJochImage {
Q_OBJECT
std::shared_ptr<const SimpleImage> image_;
// Per-reflection shoebox rectangles (pixel coordinates) to overlay: the pixels
// PixelRefine actually summed over. Empty = nothing drawn.
QVector<QRect> shoeboxes_;
// Prepare image
template<class T>
void loadImageInternal(const uint8_t *input);
void loadImageInternal();
void mouseHover(QMouseEvent *event) override;
void addCustomOverlay() override;
public:
explicit JFJochSimpleImage(QWidget *parent = nullptr);
public slots:
void setImage(std::shared_ptr<const SimpleImage> img);
void setShoeboxes(QVector<QRect> boxes);
};
@@ -31,6 +31,15 @@ JFJochPixelRefineWindow::JFJochPixelRefineWindow(QWidget *parent)
auto controlsLayout = new QVBoxLayout(controls);
layout->addWidget(controls, 0);
// --- what the left image shows ------------------------------------------
m_displayMode = new QComboBox(this);
m_displayMode->addItem(tr("Prediction"));
m_displayMode->addItem(tr("Squared difference |pred - image|²"));
m_displayMode->addItem(tr("χ² (weighted residual = LSQ cost)"));
auto displayForm = new QFormLayout();
displayForm->addRow(tr("Display:"), m_displayMode);
controlsLayout->addLayout(displayForm);
auto paramBox = new QGroupBox(tr("Model parameters"), this);
auto form = new QFormLayout(paramBox);
@@ -97,6 +106,8 @@ JFJochPixelRefineWindow::JFJochPixelRefineWindow(QWidget *parent)
for (auto *s : {m_R0, m_R1, m_bw, m_scale, m_B, m_beamx, m_beamy})
connect(s, &SliderPlusBox::valueChanged, this, [this](double) { onControlChanged(); });
connect(m_displayMode, &QComboBox::currentIndexChanged, this, [this](int) { onControlChanged(); });
connect(m_overrideBeam, &QCheckBox::toggled, this, [this](bool on) {
m_beamx->setEnabled(on);
m_beamy->setEnabled(on);
@@ -141,6 +152,7 @@ PixelRefineParams JFJochPixelRefineWindow::currentParams() const {
p.refine_scale = m_refScale->isChecked();
p.refine_B = m_refB->isChecked();
p.refine_R = m_refR->isChecked();
p.display_mode = m_displayMode->currentIndex();
return p;
}
+3
View File
@@ -10,6 +10,7 @@
#include <memory>
#include <QCheckBox>
#include <QComboBox>
#include <QLabel>
#include <QPushButton>
#include <QTimer>
@@ -33,6 +34,8 @@ class JFJochPixelRefineWindow : public JFJochHelperWindow {
SliderPlusBox *m_beamx;
SliderPlusBox *m_beamy;
QComboBox *m_displayMode; // Prediction vs. Difference (prediction - image)
QCheckBox *m_overrideBeam;
QCheckBox *m_refOrientation;
QCheckBox *m_refCell;
+8
View File
@@ -28,6 +28,14 @@ struct PixelRefineParams {
bool refine_R = true;
int max_iterations = 3; // <=0 means evaluate-only (preview / residual)
// Display only (no effect on the fit): what the preview/refine image shows.
enum DisplayMode : int {
Prediction = 0, // forward-model image
SquaredDifference = 1, // |prediction - measured|^2 (raw, unweighted)
ChiSquared = 2 // ((prediction - measured)/sigma)^2 = the LSQ cost density
};
int display_mode = Prediction;
};
Q_DECLARE_METATYPE(PixelRefineParams)