Files
Jungfraujoch/viewer/widgets/JFJochViewerImageStatistics.cpp
T
leonarski_fandClaude Opus 5 0ca6b251e5
Build Packages / build:windows:nocuda (push) Successful in 17m28s
Build Packages / build:windows:cuda (push) Successful in 19m59s
Build Packages / build:rugnux:windows (push) Successful in 10m55s
Build Packages / Unit tests (push) Successful in 47m41s
Build Packages / build:viewer-tgz:cpu (push) Successful in 7m17s
Build Packages / build:viewer-tgz:cuda (push) Successful in 8m8s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 7m2s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 4m57s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 10m21s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 9m10s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 10m14s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 8m17s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 10m57s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 10m9s
Build Packages / build:rpm (rocky8) (push) Successful in 11m10s
Build Packages / build:rpm (rocky9) (push) Successful in 10m17s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 10m55s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 8m58s
Build Packages / Create release (push) Skipped
Build Packages / Generate python client (push) Successful in 14s
Build Packages / Build documentation (push) Successful in 43s
Build Packages / DIALS test (push) Successful in 10m29s
Build Packages / XDS test (neggia plugin) (push) Failing after 3h12m41s
Build Packages / XDS test (JFJoch plugin) (push) Failing after 3h12m43s
Build Packages / XDS test (durin plugin) (push) Failing after 3h12m45s
jfjoch_viewer: no settings section is wider than the panel it sits in
Measured every section's content minimum width against the 330 px the
settings dock gives it (the powder one, fixed yesterday, sat at 278):

  geometry 346, unit cell 355, goniometer 322, spot finding 370,
  indexing 321, bragg 287, scaling 297, azint 243, reference 149

Five over. Three causes, none of them about the section:

- addRow("", w) reserves the label column and puts the widget in the
  field column, so a checkbox's whole width was added to the widest
  label. addRow(w) spans both columns instead, which is what a
  label-less row means anyway. Spot finding 370 -> 251.
- A spin box is never narrower than its longest possible value, suffix
  included, and these rows hold two or three abreast. The unit moves to
  the row label, which is how the panel names units elsewhere
  ("High resolution [Å]"): "a, b, c [Å]", "Beam origin [px]",
  "Detector tilt [°]", "Step x, y [μm]", "Radii r1/r2/r3 [px]".
  A cell length can still be four digits and three decimals, so those
  six fields also divide the row rather than demand their own width.
- A combo is never narrower than its longest entry - the same thing
  that pinned the powder panel. "Flex (best per-image refinement)" set
  the indexing section's width; it elides now, as the calibrant does.

Every section is now 291 or less, and none of them moves the dock.

Two inspector changes in the same panel:

- An axis that is present but does not turn covers no angular range, so
  the image angle is stated as one number rather than "-145° + 0°".
- Sample-head angles that stay put during a run - an SLS Smargon writes
  chi and phi - get a row of their own, "chi 0° / phi 0°". Both its
  labels stay empty for a file that carries none, so it costs nothing
  where it means nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011oVDk7aukYqfPZBLHn5nGg
2026-09-12 07:31:54 +02:00

411 lines
17 KiB
C++

// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "JFJochViewerImageStatistics.h"
#include "../../common/time_utc.h"
#include <QFormLayout>
#include <QRegularExpression>
static QString mkUnitCell(const UnitCell &uc) {
return QString("<b>%1</b> Å <b>%2</b> Å <b>%3</b> Å <b>%4</b>° <b>%5</b>° <b>%6</b>°")
.arg(QString::number(uc.a, 'f', 1))
.arg(QString::number(uc.b, 'f', 1))
.arg(QString::number(uc.c, 'f', 1))
.arg(QString::number(uc.alpha, 'f', 1))
.arg(QString::number(uc.beta, 'f', 1))
.arg(QString::number(uc.gamma, 'f', 1));
}
static QString mkSourceInstrumentText(const DiffractionExperiment& exp) {
QString src, inst;
const auto &m = exp.GetInstrumentMetadata();
if (!m.GetSourceName().empty()) src = QString::fromStdString(m.GetSourceName());
if (!m.GetInstrumentName().empty()) inst = QString::fromStdString(m.GetInstrumentName());
if (!src.isEmpty() && !inst.isEmpty()) return src + " / " + inst;
if (!src.isEmpty()) return src;
if (!inst.isEmpty()) return inst;
return QString("-");
}
static QString mkSourceInstrumentTooltip(const DiffractionExperiment &exp) {
QStringList tooltips;
if (exp.GetRingCurrent_mA().has_value()) {
tooltips << QString("Ring current: <b>%1</b> mA").arg(exp.GetRingCurrent_mA().value(), 0, 'f', 2);
}
if (exp.GetTotalFlux().has_value()) {
tooltips << QString("Total flux: <b>%1</b> ph/s").arg(exp.GetTotalFlux().value(), 0, 'e', 2);
}
if (exp.GetAttenuatorTransmission().has_value()) {
tooltips << QString("Attenuation: <b>%1</b>").arg(exp.GetAttenuatorTransmission().value(), 0, 'f', 3);
}
return tooltips.join("<br/>");
}
static QString mkSampleText(const DiffractionExperiment& exp) {
const auto& name = exp.GetSampleName();
return name.empty() ? QString("-") : QString::fromStdString(name);
}
static QString mkSampleTooltip(const DiffractionExperiment& exp) {
if (exp.GetSampleTemperature_K().has_value()) {
return QString("Sample temperature: <b>%1</b> K").arg(exp.GetSampleTemperature_K().value(), 0, 'f', 2);
}
return QString();
}
static QString mkSymmetry(const LatticeMessage& sym) {
QString text("Bravais lattice: <b>");
switch (sym.crystal_system) {
case gemmi::CrystalSystem::Triclinic:
text += "a";
break;
case gemmi::CrystalSystem::Monoclinic:
text += "m";
break;
case gemmi::CrystalSystem::Orthorhombic:
text += "o";
break;
case gemmi::CrystalSystem::Tetragonal:
text += "t";
break;
case gemmi::CrystalSystem::Hexagonal:
text += "h";
break;
case gemmi::CrystalSystem::Cubic:
text += "c";
break;
default: ;
}
text += QString(sym.centering) + "</b><br/>";
text += QString("Niggli class <b>%1</b><br/>").arg(sym.niggli_class);
return text;
}
JFJochViewerImageStatistics::JFJochViewerImageStatistics(QWidget *parent) : QWidget(parent) {
QFormLayout* layout = new QFormLayout(this);
// Order: what the dataset is, then how it was collected, then what this image contains.
dataset_name = new QLabel(this);
dataset_name->setWordWrap(true);
layout->addRow(new QLabel("Dataset:"), dataset_name);
collection_time = new QLabel(this);
collection_time->setWordWrap(true);
layout->addRow(new QLabel("Collection time:"), collection_time);
source_name = new QLabel(this);
layout->addRow("Source / Instrument:", source_name);
sample_name = new QLabel(this);
layout->addRow("Sample:", sample_name);
detector_name = new QLabel(this);
layout->addRow(new QLabel("Detector:"), detector_name);
detector_distance = new QLabel(this);
layout->addRow(new QLabel("Detector distance:"), detector_distance);
beam_center = new QLabel(this);
layout->addRow(new QLabel("Beam center:"), beam_center);
wavelength = new QLabel(this);
layout->addRow(new QLabel("Wavelength:"), wavelength);
exposure_time = new QLabel(this);
layout->addRow(new QLabel("Exposure Time:"), exposure_time);
rotation_angle_label = new QLabel(this);
rotation_angle = new QLabel(this);
layout->addRow(rotation_angle_label, rotation_angle);
// Sample-head angles that do not move during a run (an SLS Smargon writes chi and phi). Both
// labels stay empty for a file that carries none, so the row costs nothing where it means nothing.
smargon_label = new QLabel(this);
smargon_angles = new QLabel(this);
layout->addRow(smargon_label, smargon_angles);
valid_values = new QLabel(this);
layout->addRow(new QLabel("Valid values:"), valid_values);
masked_pixels = new QLabel(this);
layout->addRow(new QLabel("Masked pixels:"), masked_pixels);
spots = new QLabel(this);
layout->addRow(new QLabel("Spots:"), spots);
bkg_estimate = new QLabel(this);
layout->addRow(new QLabel("Background:"), bkg_estimate);
indexed = new QLabel(this);
layout->addRow(new QLabel("Indexing:"), indexed);
multiple_lattices = new QLabel(this);
layout->addRow(multiple_lattices);
res_estimate = new QLabel(this);
layout->addRow(new QLabel("Resolution estimate:"), res_estimate);
profile_radius_label = new QLabel("");
profile_radius = new QLabel(this);
layout->addRow(profile_radius_label, profile_radius);
}
QString TrimZeros(double number, int precision) {
auto s = QString::number(number, 'f', precision);
s.remove(QRegularExpression("\\.?0+$"));
if (s.isEmpty()) s = "0";
return s;
}
template <typename Duration>
QString FormatTime(Duration time, int precision = 6) {
return TrimZeros(std::chrono::duration<float>(time).count(), precision);
}
void JFJochViewerImageStatistics::loadImage(std::shared_ptr<const JFJochReaderImage> image) {
if (!image) {
source_name->setText("");
sample_name->setText("");
dataset_name->setText("");
collection_time->setText("");
detector_name->setText("");
detector_name->setToolTip("");
detector_distance->setText("");
beam_center->setText("");
wavelength->setText("");
wavelength->setToolTip("");
exposure_time->setText("");
exposure_time->setToolTip("");
rotation_angle->setText("");
rotation_angle->setToolTip("");
rotation_angle_label->setText("");
smargon_label->setText("");
smargon_angles->setText("");
valid_values->setText("");
valid_values->setToolTip("");
spots->setText("");
bkg_estimate->setText("");
indexed->setText("");
indexed->setToolTip("");
multiple_lattices->setText("");
profile_radius_label->setText("");
profile_radius->setText("");
profile_radius->setToolTip("");
res_estimate->setText("");
masked_pixels->setText("");
masked_pixels->setToolTip("");
return;
}
QString text;
auto &exp = image->Dataset().experiment;
// Word-wrap alone would break wherever it likes (mid-directory-name); a zero-width space after
// every "/" gives it a preferred break point there, so a long path wraps like a path and only
// falls back to breaking a single component if that component alone doesn't fit.
QString path = QString::fromStdString(exp.GetFilePrefix());
path.replace('/', QString("/") + QChar(0x200B)); // U+200B ZERO WIDTH SPACE, a break opportunity
text = QString("<b>%1</b>").arg(path);
dataset_name->setText(text);
const QString collected = QString::fromStdString(utc_to_local_human_readable(image->Dataset().arm_date));
collection_time->setText(collected.isEmpty() ? QString("-") : QString("<b>%1</b>").arg(collected));
detector_distance->setText(QString("<b>%1</b> mm").arg(QString::number(exp.GetDetectorDistance_mm(), 'f', 3)));
beam_center->setText(QString("<b>%1</b> <b>%2</b> pxl")
.arg(QString::number(exp.GetBeamX_pxl(), 'f', 2))
.arg(QString::number(exp.GetBeamY_pxl(), 'f', 2)));
wavelength->setText(QString("<b>%1</b> Å").arg(QString::number(exp.GetWavelength_A(), 'f', 4)));
wavelength->setToolTip(QString("Energy: <b>%1</b> eV")
.arg(QString::number(exp.GetIncidentEnergy_keV() * 1000.0, 'f', 0)));
text = QString("<b>%1</b>").arg(QString::fromStdString(exp.GetDetectorDescription()));
detector_name->setToolTip(QString("Width: <b>%1</b> pxl (<b>%2</b> mm)<br/>Height: <b>%3</b> pxl (<b>%4</b> mm)<br/>Pixel size: <b>%5</b> μm")
.arg(QString::number(exp.GetXPixelsNum()))
.arg(QString::number(exp.GetXPixelsNum() * exp.GetPixelSize_mm(), 'f', 3))
.arg(QString::number(exp.GetYPixelsNum()))
.arg(QString::number(exp.GetYPixelsNum() * exp.GetPixelSize_mm(), 'f', 3))
.arg(QString::number(exp.GetPixelSize_mm() * 1000.0, 'f', 0)));
detector_name->setText(text);
source_name->setText(QString("<b>%1</b>").arg(mkSourceInstrumentText(exp)));
source_name->setToolTip(mkSourceInstrumentTooltip(exp));
sample_name->setText(QString("<b>%1</b>").arg(mkSampleText(exp)));
sample_name->setToolTip(mkSampleTooltip(exp));
if (exp.GetGoniometer()) {
// An axis that is present but does not turn has no wedge to add: state the angle it stands at.
const float wedge = exp.GetGoniometer()->GetWedge_deg();
const QString angle = TrimZeros(exp.GetGoniometer()->GetAngle_deg(image->ImageData().number), 3);
rotation_angle_label->setText("Image angle:");
rotation_angle->setText(wedge == 0.0f
? QString("<b>%1°</b>").arg(angle)
: QString("<b>%1° + %2°</b>").arg(angle).arg(TrimZeros(wedge, 3)));
rotation_angle->setToolTip(QString("Start angle: <b>%1°</b><br/>Increment: <b>%2°</b>")
.arg(TrimZeros(exp.GetGoniometer()->GetStart_deg(), 3))
.arg(TrimZeros(exp.GetGoniometer()->GetIncrement_deg(), 3))
);
} else if (exp.GetGridScan()) {
rotation_angle_label->setText("Grid scan:");
rotation_angle->setText(QString("<b>%1</b> x <b>%2</b> μm")
.arg(QString::number(exp.GetGridScan()->GetGridStepX_um(), 'f', 1))
.arg(QString::number(exp.GetGridScan()->GetGridStepY_um(), 'f', 1)));
rotation_angle->setToolTip(QString("Grid size: <b>%1</b> x <b>%2</b> μm<br/>"
"Grid elements: <b>%3</b> x <b>%4</b>")
.arg(QString::number(exp.GetGridScan()->GetGridSizeX_um()))
.arg(QString::number(exp.GetGridScan()->GetGridSizeY_um()))
.arg(QString::number(exp.GetGridScan()->GetGridSizeX_step()))
.arg(QString::number(exp.GetGridScan()->GetGridSizeY_step())));
} else {
rotation_angle_label->setText("");
rotation_angle->setText(QString(""));
rotation_angle->setToolTip("");
}
if (const auto head = exp.GetDatasetSettings().GetSmargonPosition()) {
smargon_label->setText("Smargon:");
smargon_angles->setText(QString("chi <b>%1</b>° / phi <b>%2</b>°")
.arg(TrimZeros(head->chi_deg, 3))
.arg(TrimZeros(head->phi_deg, 3)));
} else {
smargon_label->setText("");
smargon_angles->setText("");
}
exposure_time->setText(QString("<b>%1</b> s").arg(FormatTime(exp.GetImageTime())));
exposure_time->setToolTip(QString("Count time: <b>%1</b> s<br/>").arg(FormatTime(exp.GetImageCountTime())));
text = QString("<b>%1</b>").arg(image->ImageData().spots.size());
spots->setText(text);
if (image->ImageData().spot_count.has_value())
spots->setToolTip(QString("Unfiltered (total): <b>%1</b> <br/>Low resolution: <b>%2</b><br/>Ice ring: <b>%3</b><br/>Indexed <b>%4</b><br/>")
.arg(image->ImageData().spot_count.value())
.arg(image->ImageData().spot_count_low_res.value_or(0))
.arg(image->ImageData().spot_count_ice_rings.value_or(0))
.arg(image->ImageData().spot_count_indexed.value_or(0)));
auto mm = image->ValidMinMax();
if (mm.has_value()) {
text = QString("<b>%1</b> - <b>%2</b>")
.arg(mm->first)
.arg(mm->second);
valid_values->setText(text);
} else {
valid_values->setText("N/A");
}
valid_values->setToolTip(QString("Error pixels: <b>%1</b><br/>Saturated pixels: <b>%2</b>")
.arg(image->ErrorPixels().size()).arg(image->SaturatedPixels().size()));
if (!image->Dataset().bkg_estimate.empty()) {
text = QString("<b>%1</b>").arg(image->ImageData().bkg_estimate.value_or(0));
bkg_estimate->setText(text);
} else {
bkg_estimate->setText("N/A");
}
auto pr = image->ImageData().profile_radius;
auto mos = image->ImageData().mosaicity_deg;
if (mos && std::isfinite(mos.value())) {
text = QString("<b>%1</b>°").arg(QString::number(mos.value(), 'f', 6));
profile_radius_label->setText("Mosaicity:");
profile_radius->setText(text);
if (pr && std::isfinite(pr.value())) {
text = QString("Profile radius <b>%1</b> Å<sup>-1</sup>").arg(QString::number(pr.value(), 'f', 6));
profile_radius->setToolTip(text);
} else
profile_radius->setToolTip("");
} else if (pr && std::isfinite(pr.value())) {
profile_radius_label->setText("Profile radius:");
text = QString("<b>%1</b> Å<sup>-1</sup>").arg(QString::number(pr.value(), 'f', 6));
profile_radius->setText(text);
profile_radius->setToolTip("");
} else {
profile_radius_label->setText("");
profile_radius->setText("");
profile_radius->setToolTip("");
}
auto res = image->ImageData().resolution_estimate;
if (res && std::isfinite(res.value())) {
text = QString("<b>%1</b> Å").arg(QString::number(res.value(), 'f', 2));
res_estimate->setText(text);
} else {
res_estimate->setText("N/A");
}
float total_pixels = image->Dataset().experiment.GetPixelsNum();
if (total_pixels == 0)
total_pixels = 1;
auto mask_stats = image->Dataset().pixel_mask->GetStatistics();
text = QString("<b>%1</b>").arg(mask_stats.total_masked);
masked_pixels->setText(text);
masked_pixels->setToolTip(
QString(
"Error pixels: <b>%1</b> (%2 %)<br/> Noisy pixels: <b>%3</b> (%4 %) <br/> User mask: <b>%5</b> (%6 %)<br/>"
"Chip gaps: <b>%7</b> (%8 %)<br/>"
"Non active area (module gap and fill): <b>%9</b> (%10 %)")
.arg(mask_stats.error_pixel).arg(QString::number(mask_stats.error_pixel / total_pixels * 100.0, 'f', 1))
.arg(mask_stats.noisy_pixel).arg(QString::number(mask_stats.noisy_pixel / total_pixels * 100.0, 'f', 1))
.arg(mask_stats.user_mask).arg(QString::number(mask_stats.user_mask / total_pixels * 100.0, 'f', 1))
.arg(mask_stats.chip_gap_pixel).arg(QString::number(mask_stats.chip_gap_pixel / total_pixels * 100.0, 'f', 1))
.arg(mask_stats.module_gap_pixel).arg(QString::number(mask_stats.module_gap_pixel / total_pixels * 100.0, 'f', 1))
);
if (!image->Dataset().indexing_result.empty()) {
QString tooltip;
auto latt = image->ImageData().indexing_lattice;
if (latt) {
text = QString("<span style=\"color: purple;\">%1</span>")
.arg(mkUnitCell(latt->GetUnitCell()));
auto vec0 = latt->Vec0();
auto vec1 = latt->Vec1();
auto vec2 = latt->Vec2();
tooltip = QString("<b>Lattice vectors (Å):</b><br/>"
"a = (%1, %2, %3)<br/>"
"b = (%4, %5, %6)<br/>"
"c = (%7, %8, %9)")
.arg(vec0.x, 7, 'f', 1)
.arg(vec0.y, 7, 'f', 1)
.arg(vec0.z, 7, 'f', 1)
.arg(vec1.x, 7, 'f', 1)
.arg(vec1.y, 7, 'f', 1)
.arg(vec1.z, 7, 'f', 1)
.arg(vec2.x, 7, 'f', 1)
.arg(vec2.y, 7, 'f', 1)
.arg(vec2.z, 7, 'f', 1);
if (image->ImageData().lattice_type) {
tooltip += QString("<br/><br/>");
tooltip += mkSymmetry(*image->ImageData().lattice_type);
} else if (image->Dataset().experiment.GetSpaceGroupNumber()) {
tooltip += QString("<br/><br/>Space group (user-provided): <b>%1</b>")
.arg(QString::fromStdString(image->Dataset().experiment.GetSpaceGroupName()));
}
} else
text = QString("<span style=\"color: red;\"><b>No lattice</b></span>");
indexed->setToolTip(tooltip);
indexed->setText(text);
} else {
indexed->setText("N/A");
}
if (image->ImageData().indexing_lattice_count.value_or(0) > 1)
multiple_lattices->setText("<span style=\"color: red;\"><b>Multiple lattices detected</b></span>");
else
multiple_lattices->setText("");
}