// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include "JFJochViewerImageStatistics.h" #include "../../common/time_utc.h" #include #include // Two aligned rows - the lengths over their angles, so that alpha sits below a, beta below b, // gamma below c. Qt's rich text does not inherit a surrounding colour into a table, so the // colour is applied per cell. static QString mkUnitCell(const UnitCell &uc, const char *color = nullptr) { const QString style = color ? QString(" style=\"color: %1;\"").arg(color) : QString(); auto num = [&style](double v) { return QString("%2  ") .arg(style).arg(QString::number(v, 'f', 1)); }; auto unit = [&style](const char *u) { return QString("%2").arg(style).arg(u); }; return "" + num(uc.a) + num(uc.b) + num(uc.c) + unit("Å") + "" + num(uc.alpha) + num(uc.beta) + num(uc.gamma) + unit("°") + "
"; } 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: %1 mA").arg(exp.GetRingCurrent_mA().value(), 0, 'f', 2); } if (exp.GetTotalFlux().has_value()) { tooltips << QString("Total flux: %1 ph/s").arg(exp.GetTotalFlux().value(), 0, 'e', 2); } if (exp.GetAttenuatorTransmission().has_value()) { tooltips << QString("Attenuation: %1").arg(exp.GetAttenuatorTransmission().value(), 0, 'f', 3); } return tooltips.join("
"); } 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: %1 K").arg(exp.GetSampleTemperature_K().value(), 0, 'f', 2); } return QString(); } static QString mkSymmetry(const LatticeMessage& sym) { QString text("Bravais lattice: "); 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) + "
"; text += QString("Niggli class %1
").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 QString FormatTime(Duration time, int precision = 6) { return TrimZeros(std::chrono::duration(time).count(), precision); } void JFJochViewerImageStatistics::loadImage(std::shared_ptr 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("%1").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("%1").arg(collected)); detector_distance->setText(QString("%1 mm").arg(QString::number(exp.GetDetectorDistance_mm(), 'f', 3))); beam_center->setText(QString("%1 %2 pxl") .arg(QString::number(exp.GetBeamX_pxl(), 'f', 2)) .arg(QString::number(exp.GetBeamY_pxl(), 'f', 2))); wavelength->setText(QString("%1 Å").arg(QString::number(exp.GetWavelength_A(), 'f', 4))); wavelength->setToolTip(QString("Energy: %1 eV") .arg(QString::number(exp.GetIncidentEnergy_keV() * 1000.0, 'f', 0))); text = QString("%1").arg(QString::fromStdString(exp.GetDetectorDescription())); detector_name->setToolTip(QString("Width: %1 pxl (%2 mm)
Height: %3 pxl (%4 mm)
Pixel size: %5 μ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("%1").arg(mkSourceInstrumentText(exp))); source_name->setToolTip(mkSourceInstrumentTooltip(exp)); sample_name->setText(QString("%1").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("%1°").arg(angle) : QString("%1° + %2°").arg(angle).arg(TrimZeros(wedge, 3))); rotation_angle->setToolTip(QString("Start angle: %1°
Increment: %2°") .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("%1 x %2 μ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: %1 x %2 μm
" "Grid elements: %3 x %4") .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 %1° / phi %2°") .arg(TrimZeros(head->chi_deg, 3)) .arg(TrimZeros(head->phi_deg, 3))); } else { smargon_label->setText(""); smargon_angles->setText(""); } exposure_time->setText(QString("%1 s").arg(FormatTime(exp.GetImageTime()))); exposure_time->setToolTip(QString("Count time: %1 s
").arg(FormatTime(exp.GetImageCountTime()))); text = QString("%1").arg(image->ImageData().spots.size()); spots->setText(text); if (image->ImageData().spot_count.has_value()) spots->setToolTip(QString("Unfiltered (total): %1
Low resolution: %2
Ice ring: %3
Indexed %4
") .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("%1 - %2") .arg(mm->first) .arg(mm->second); valid_values->setText(text); } else { valid_values->setText("N/A"); } valid_values->setToolTip(QString("Error pixels: %1
Saturated pixels: %2") .arg(image->ErrorPixels().size()).arg(image->SaturatedPixels().size())); if (!image->Dataset().bkg_estimate.empty()) { text = QString("%1").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("%1°").arg(QString::number(mos.value(), 'f', 2)); profile_radius_label->setText("Mosaicity:"); profile_radius->setText(text); if (pr && std::isfinite(pr.value())) { text = QString("Profile radius %1 Å-1").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("%1 Å-1").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("%1 Å").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("%1").arg(mask_stats.total_masked); masked_pixels->setText(text); masked_pixels->setToolTip( QString( "Error pixels: %1 (%2 %)
Noisy pixels: %3 (%4 %)
User mask: %5 (%6 %)
" "Chip gaps: %7 (%8 %)
" "Non active area (module gap and fill): %9 (%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 = mkUnitCell(latt->GetUnitCell(), "purple"); auto vec0 = latt->Vec0(); auto vec1 = latt->Vec1(); auto vec2 = latt->Vec2(); tooltip = QString("Lattice vectors (Å):
" "a = (%1, %2, %3)
" "b = (%4, %5, %6)
" "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("

"); tooltip += mkSymmetry(*image->ImageData().lattice_type); } else if (image->Dataset().experiment.GetSpaceGroupNumber()) { tooltip += QString("

Space group (user-provided): %1") .arg(QString::fromStdString(image->Dataset().experiment.GetSpaceGroupName())); } } else // Not indexed is a normal state, not an error - grey, so red keeps meaning trouble. text = QString("No lattice"); indexed->setToolTip(tooltip); indexed->setText(text); } else { indexed->setText("N/A"); } if (image->ImageData().indexing_lattice_count.value_or(0) > 1) multiple_lattices->setText("Multiple lattices detected"); else multiple_lattices->setText(""); }