Files
Jungfraujoch/viewer/widgets/JFJochViewerImageStatistics.cpp
T
leonarski_fandClaude Sonnet 5 d24cdfcff4 jfjoch_viewer: navigation keys, one-shot auto-contrast, file manager panel, cleanup
* Dataset-info plot hover shows a horizontal crosshair alongside the existing
  vertical one, so a run of hovers reads as a level or a drift at a glance.
* `A` applies auto-contrast once instead of switching on persistent Auto mode
  (a new `oneShotAutoForeground()`, distinct from the toolbar's Auto toggle);
  pressing it a second time on the same image switches Auto on for good, and
  pressing it while Auto is already on leaves it on. The value itself comes
  from one `AutoForegroundValue()` shared with the continuous Auto path.
* `Home`/`End`/`Page Up`/`Page Down` navigate the dataset (first/last image,
  one image forward/back); claimed in the diffraction view's keyPressEvent
  before QGraphicsView's default handling, which otherwise eats them to
  scroll the viewport.
* Alt+wheel dataset navigation is removed - some window managers already
  took it before the app ever saw it, per the caveat that used to sit next
  to its entry in the shortcuts list.
* The image strip dock and its dedicated thumbnail-rendering machinery in
  the reading worker (SetThumbnail*, RenderThumbnail_i) are removed; it cost
  a lot and nothing else used any of it.
* Toolbar text: "Colour" -> "Color".
* Color map, Auto and HDR mode now persist across sessions, the same way the
  window layout already does.
* The Inspector's "Dataset:" path wraps at '/' (a zero-width space after
  each one) instead of being cut off when it doesn't fit one line.
* LoadFile no longer reopens an already-open file - it forwards straight to
  LoadImage instead - which was the likely cause of a D-Bus client's per-image
  navigation lagging behind the same navigation done via the grid-scan hover.
* New file-manager dock (left, tabbed with Settings via tabifyDockWidget):
  a directory tree filtered to *_master.h5/*_process.h5, rooted at
  JUNGFRAUJOCH_DATA_ROOT if set, else the last-browsed root, else the home
  directory. Most beamline users care about opening images, not
  reprocessing settings, so it's the tab raised by default. Its filter box
  narrows files only - a directory always passes - because filtering the
  directories too hid the matching files under any directory whose own name
  did not match, and hid the root's ancestors, which left the tree with no
  valid root index until the root was set again by hand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PogHY7bWXV4bpctDPdyuDN
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 18:45:20 +02:00

378 lines
15 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);
dataset_name = new QLabel(this);
dataset_name->setWordWrap(true);
layout->addRow(new QLabel("Dataset:"), dataset_name);
detector_name = new QLabel(this);
layout->addRow(new QLabel("Detector:"), detector_name);
source_name = new QLabel(this);
layout->addRow("Source / Instrument:", source_name);
sample_name = new QLabel(this);
layout->addRow("Sample:", sample_name);
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);
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);
b_factor = new QLabel(this);
layout->addRow(new QLabel("B-factor:"), b_factor);
}
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("");
dataset_name->setToolTip("");
detector_name->setText("");
detector_name->setToolTip("");
exposure_time->setText("");
exposure_time->setToolTip("");
rotation_angle->setText("");
rotation_angle->setToolTip("");
rotation_angle_label->setText("");
valid_values->setText("");
valid_values->setToolTip("");
spots->setText("");
bkg_estimate->setText("");
indexed->setText("");
indexed->setToolTip("");
multiple_lattices->setText("");
b_factor->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);
dataset_name->setToolTip(QString("Collection data: <b>%1</b><br/>Beam center: <b>%2</b> <b>%3</b> pxl<br/>Detector distance: <b>%4</b> mm<br/>Energy: <b>%5</b> eV<br/>Wavelength: <b>%6</b> Å")
.arg(QString::fromStdString(utc_to_local_human_readable(image->Dataset().arm_date)))
.arg(QString::number(exp.GetBeamX_pxl(), 'f', 2))
.arg(QString::number(exp.GetBeamY_pxl(), 'f', 2))
.arg(QString::number(exp.GetDetectorDistance_mm(), 'f', 3))
.arg(QString::number(exp.GetIncidentEnergy_keV() * 1000.0, 'f', 0))
.arg(QString::number(exp.GetWavelength_A(), 'f', 3)));
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(mkSourceInstrumentText(exp));
source_name->setToolTip(mkSourceInstrumentTooltip(exp));
sample_name->setText(mkSampleText(exp));
sample_name->setToolTip(mkSampleTooltip(exp));
if (exp.GetGoniometer()) {
rotation_angle_label->setText("Image angle:");
rotation_angle->setText(QString("<b>%1° + %2°</b>")
.arg(TrimZeros(exp.GetGoniometer()->GetAngle_deg(image->ImageData().number), 3))
.arg(TrimZeros(exp.GetGoniometer()->GetWedge_deg(), 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("");
}
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 b = image->ImageData().b_factor;
if (b && std::isfinite(b.value())) {
text = QString("<b>%1</b> Å<sup>2</sup>").arg(QString::number(b.value(), 'f', 2));
b_factor->setText(text);
} else {
b_factor->setText("N/A");
}
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("");
}